From 46f998c428b1f4bbc2057e6850b3a68c8828e31f Mon Sep 17 00:00:00 2001 From: Brad Hover Date: Tue, 4 Feb 2025 15:56:01 -0800 Subject: [PATCH] community task requests --- docs/README.md | 16 + .../README.md | 49 +++ .../script.liquid | 148 +++++++ .../README.md | 61 +++ .../script.liquid | 274 +++++++++++++ .../README.md | 60 +++ .../script.liquid | 362 ++++++++++++++++++ lib/task_schema.json | 2 + ...auto-tag-products-with-their-category.json | 22 ++ ...tock-locations-to-a-variant-metafield.json | 22 ++ ...-ny-metaobject-reference-field-values.json | 27 ++ 11 files changed, 1043 insertions(+) create mode 100644 docs/auto-tag-products-with-their-category/README.md create mode 100644 docs/auto-tag-products-with-their-category/script.liquid create mode 100644 docs/sync-in-stock-locations-to-a-variant-metafield/README.md create mode 100644 docs/sync-in-stock-locations-to-a-variant-metafield/script.liquid create mode 100644 docs/tag-products-ny-metaobject-reference-field-values/README.md create mode 100644 docs/tag-products-ny-metaobject-reference-field-values/script.liquid create mode 100644 tasks/auto-tag-products-with-their-category.json create mode 100644 tasks/sync-in-stock-locations-to-a-variant-metafield.json create mode 100644 tasks/tag-products-ny-metaobject-reference-field-values.json diff --git a/docs/README.md b/docs/README.md index 11259cf2..f1ba1658 100644 --- a/docs/README.md +++ b/docs/README.md @@ -149,6 +149,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Auto-tag products when another tag is added](./auto-tag-products-when-another-tag-is-added) * [Auto-tag products when their variants change](./auto-tag-products-when-their-skus-change) * [Auto-tag products with incoming inventory](./auto-tag-products-with-incoming-inventory) +* [Auto-tag products with their category](./auto-tag-products-with-their-category) * [Auto-tag products with their vendors](./auto-tag-products-with-their-vendors) * [Auto-tag products without descriptions](./auto-tag-products-without-descriptions) * [Auto-untag customers when a certain product is refunded](./auto-untag-customers-when-a-certain-product-is-refunded) @@ -313,6 +314,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Sync an inverse customer tag](./sync-an-inverse-customer-tag) * [Sync an inverse order tag](./sync-an-inverse-order-tag) * [Sync an inverse product tag](./sync-an-inverse-product-tag) +* [Sync in stock locations to a variant metafield](./sync-in-stock-locations-to-a-variant-metafield) * [Sync inventory across a product type](./sync-inventory-across-a-product-type) * [Sync inventory across product variants](./sync-inventory-across-product-variants) * [Sync inventory for shared SKUs](./sync-inventory-for-shared-skus) @@ -332,6 +334,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Tag orders that have at least x of a certain product](./tag-orders-that-have-at-least-x-of-a-certain-product) * [Tag products as in- or out-of-stock, by location ID](./tag-products-as-in-or-out-of-stock-by-location-id) * [Tag products as in- or out-of-stock](./tag-products-as-in-or-out-of-stock) +* [Tag products by metaobject reference field values](./tag-products-ny-metaobject-reference-field-values) * [Tag products by their price ranges](./tag-products-by-their-price-ranges) * [Tag products with no images](./tag-products-with-no-images) * [Temporarily add an order note](./temporarily-add-an-order-note) @@ -498,6 +501,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Auto-tag products when another tag is added](./auto-tag-products-when-another-tag-is-added) * [Auto-tag products when their variants change](./auto-tag-products-when-their-skus-change) * [Auto-tag products with incoming inventory](./auto-tag-products-with-incoming-inventory) +* [Auto-tag products with their category](./auto-tag-products-with-their-category) * [Auto-tag products with their vendors](./auto-tag-products-with-their-vendors) * [Auto-tag products without descriptions](./auto-tag-products-without-descriptions) * [Copy order and/or product tags to customers](./copy-order-tags-to-customers) @@ -596,6 +600,10 @@ This directory is built automatically. Each task's documentation is generated fr * [Generate a simple product catalog PDF](./generate-a-simple-product-catalog-pdf) +### Category + +* [Auto-tag products with their category](./auto-tag-products-with-their-category) + ### Collections * [Auto create collections by metafield values](./auto-create-collections-by-metafield-values) @@ -1094,6 +1102,10 @@ This directory is built automatically. Each task's documentation is generated fr * [Tag customers who reach a certain threshold of refunded orders](./tag-customers-who-reach-a-certain-threshold-of-refunded-orders) * [Track incoming donations in a store metafield](./track-incoming-donations-in-a-store-metafield) +### Metaobjects + +* [Tag products by metaobject reference field values](./tag-products-ny-metaobject-reference-field-values) + ### Multi-Location * [Auto-connect new products to all locations](./auto-connect-new-products-to-all-locations) @@ -1343,6 +1355,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Auto-tag products when another tag is added](./auto-tag-products-when-another-tag-is-added) * [Auto-tag products when their variants change](./auto-tag-products-when-their-skus-change) * [Auto-tag products with incoming inventory](./auto-tag-products-with-incoming-inventory) +* [Auto-tag products with their category](./auto-tag-products-with-their-category) * [Auto-tag products with their vendors](./auto-tag-products-with-their-vendors) * [Auto-tag products without descriptions](./auto-tag-products-without-descriptions) * [Auto-update inventory policy based on a "preorder" tag](./auto-update-inventory-policy-based-on-a-preorder-tag) @@ -1395,6 +1408,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Sync inventory levels to variant metafields](./sync-inventory-levels-to-variant-metafields) * [Sync variant inventory within a product by pack size](./sync-variant-inventory-within-a-product-by-pack-size) * [Tag products as in- or out-of-stock](./tag-products-as-in-or-out-of-stock) +* [Tag products by metaobject reference field values](./tag-products-ny-metaobject-reference-field-values) * [Tag products by their price ranges](./tag-products-by-their-price-ranges) * [Tag products with no images](./tag-products-with-no-images) * [Track incoming donations in a store metafield](./track-incoming-donations-in-a-store-metafield) @@ -1666,6 +1680,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Tag customers on the anniversary of their first order](./tag-customers-on-the-anniversary-of-their-first-order) * [Tag customers who reach a certain threshold of refunded orders](./tag-customers-who-reach-a-certain-threshold-of-refunded-orders) * [Tag new orders of customers with prior unpaid orders](./tag-new-orders-of-customers-with-prior-unpaid-orders) +* [Tag products by metaobject reference field values](./tag-products-ny-metaobject-reference-field-values) * [Tag products with no images](./tag-products-with-no-images) * [Temporarily enable tax-exempt status when a customer is tagged](./temporarily-enable-tax-exempt-status-when-a-customer-is-tagged) * [Trigger order emails with a tag](./trigger-order-emails-with-a-tag) @@ -1712,6 +1727,7 @@ This directory is built automatically. Each task's documentation is generated fr ### Uncategorized * [Mechanic tour task](./mechanic-tour-task) +* [Sync in stock locations to a variant metafield](./sync-in-stock-locations-to-a-variant-metafield) ### Unpaid diff --git a/docs/auto-tag-products-with-their-category/README.md b/docs/auto-tag-products-with-their-category/README.md new file mode 100644 index 00000000..a534fd34 --- /dev/null +++ b/docs/auto-tag-products-with-their-category/README.md @@ -0,0 +1,49 @@ +# Auto-tag products with their category + +Tags: Auto-Tag, Category, Products + +Use this task to tag products with their category from [Shopify's standard product taxonomy](https://shopify.github.io/product-taxonomy/releases/latest/). Run this task manually to scan every active product in your store. Optionally, set it to run daily to scan active products updated in the last day. + +* View in the task library: [tasks.mechanic.dev/auto-tag-products-with-their-category](https://tasks.mechanic.dev/auto-tag-products-with-their-category) +* Task JSON, for direct import: [task.json](../../tasks/auto-tag-products-with-their-category.json) +* Preview task code: [script.liquid](./script.liquid) + +## Default options + +```json +{ + "apply_this_prefix_to_tags__required": "category: ", + "run_task_daily__boolean": false +} +``` + +[Learn about task options in Mechanic](https://learn.mechanic.dev/core/tasks/options) + +## Subscriptions + +```liquid +{% if options.run_task_daily__boolean %} + mechanic/scheduler/daily +{% endif %} +mechanic/user/trigger +``` + +[Learn about event subscriptions in Mechanic](https://learn.mechanic.dev/core/tasks/subscriptions) + +## Documentation + +Use this task to tag products with their category from [Shopify's standard product taxonomy](https://shopify.github.io/product-taxonomy/releases/latest/). Run this task manually to scan every active product in your store. Optionally, set it to run daily to scan active products updated in the last day. + +Note: A tag prefix must be configured so the task will know which category tag(s) to remove from a product if applicable. + +## Installing this task + +Find this task [in the library at tasks.mechanic.dev](https://tasks.mechanic.dev/auto-tag-products-with-their-category), and use the "Try this task" button. Or, import [this task's JSON export](../../tasks/auto-tag-products-with-their-category.json) – see [Importing and exporting tasks](https://learn.mechanic.dev/core/tasks/import-and-export) to learn how imports work. + +## Contributions + +Found a bug? Got an improvement to add? Start here: [../../CONTRIBUTING.md](../../CONTRIBUTING.md). + +## Task requests + +Submit your [task requests](https://mechanic.canny.io/task-requests) for consideration by the Mechanic community, and they may be chosen for development and inclusion in the [task library](https://tasks.mechanic.dev/)! diff --git a/docs/auto-tag-products-with-their-category/script.liquid b/docs/auto-tag-products-with-their-category/script.liquid new file mode 100644 index 00000000..d8838704 --- /dev/null +++ b/docs/auto-tag-products-with-their-category/script.liquid @@ -0,0 +1,148 @@ +{% assign tag_prefix = options.apply_this_prefix_to_tags__required %} + +{% if event.topic == "mechanic/user/trigger" or event.topic == "mechanic/scheduler/daily" %} + {% assign search_query = "status:active" %} + + {% comment %} + -- if this is a daily scheduled run, then create a search query filter to only get active products updated since previous day + {% endcomment %} + + {% if event.topic contains "mechanic/scheduler/daily" %} + {% assign search_query + = event.data + | date: "%FT%TZ", tz: "UTC", advance: "-1 day" + | json + | prepend: "status:active updated_at:>=" + %} + {% endif %} + + {% unless event.preview %} + {% log search_query: search_query %} + {% endunless %} + + {% comment %} + -- get all or recently updated active products in the shop, depending upon event topic + {% endcomment %} + + {% assign cursor = nil %} + {% assign products = array %} + + {% for n in (1..200) %} + {% capture query %} + query { + products( + first: 250 + after: {{ cursor | json }} + query: {{ search_query | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + title + category { + name + isArchived + } + tags + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "products": { + "nodes": [ + { + "id": "gid://shopify/Product/1234567890", + "category": { + "name": "Shoes", + "isArchived": false + } + } + ] + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} + + {% assign products = products | concat: result.data.products.nodes %} + + {% if result.data.products.pageInfo.hasNextPage %} + {% assign cursor = result.data.products.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} + {% endfor %} + + {% comment %} + -- process products to see which category tag to add, and which to remove + {% endcomment %} + + {% for product in products %} + {% assign tag_should_have = nil %} + {% assign tag_to_add = nil %} + {% assign tags_to_remove = array %} + + {% unless product.category == blank or product.category.isArchived %} + {% assign tag_should_have = product.category.name | prepend: tag_prefix %} + {% endunless %} + + {% unless product.tags contains tag_should_have %} + {% assign tag_to_add = tag_should_have %} + {% endunless %} + + {% for tag in product.tags %} + {% if tag == tag_should_have %} + {% continue %} + {% endif %} + + {% if tag contains tag_prefix %} + {% assign tag_prefix_check = tag | slice: 0, tag_prefix.size %} + + {% if tag_prefix_check == tag_prefix and tag.size > tag_prefix.size %} + {% assign tags_to_remove = tags_to_remove | push: tag %} + {% endif %} + {% endif %} + {% endfor %} + + {% if tag_to_add != blank or tags_to_remove != blank %} + {% action "shopify" %} + mutation { + {% if tag_to_add != blank %} + tagsAdd( + id: {{ product.id | json }} + tags: {{ tag_to_add | json }} + ) { + userErrors { + field + message + } + } + {% endif %} + {% if tags_to_remove != blank %} + tagsRemove( + id: {{ product.id | json }} + tags: {{ tags_to_remove | json }} + ) { + userErrors { + field + message + } + } + {% endif %} + } + {% endaction %} + {% endif %} + {% endfor %} +{% endif %} diff --git a/docs/sync-in-stock-locations-to-a-variant-metafield/README.md b/docs/sync-in-stock-locations-to-a-variant-metafield/README.md new file mode 100644 index 00000000..b47bc81b --- /dev/null +++ b/docs/sync-in-stock-locations-to-a-variant-metafield/README.md @@ -0,0 +1,61 @@ +# Sync in stock locations to a variant metafield + +Tags: (not tagged!) + +This task will maintain a variant list metafield of in stock location names. Running on a schedule, it will check recently updated variants to see which are in stock at each location. Variants with positive "available" inventory at a location, or are configured for overselling, are considered to be in stock, as are variants that are sold from a location but not tracked. + +* View in the task library: [tasks.mechanic.dev/sync-in-stock-locations-to-a-variant-metafield](https://tasks.mechanic.dev/sync-in-stock-locations-to-a-variant-metafield) +* Task JSON, for direct import: [task.json](../../tasks/sync-in-stock-locations-to-a-variant-metafield.json) +* Preview task code: [script.liquid](./script.liquid) + +## Default options + +```json +{ + "variant_metafield__required": "custom.in_stock_locations", + "include_location_names__array": null, + "exclude_location_names__array": null, + "run_every_10_minutes__boolean": false, + "run_hourly__boolean": true, + "run_daily__boolean": false +} +``` + +[Learn about task options in Mechanic](https://learn.mechanic.dev/core/tasks/options) + +## Subscriptions + +```liquid +{% if options.run_every_10_minutes__boolean %} + mechanic/scheduler/10min +{% elsif options.run_hourly__boolean %} + mechanic/scheduler/hourly +{% elsif options.run_daily__boolean %} + mechanic/scheduler/daily +{% endif %} +mechanic/user/trigger +``` + +[Learn about event subscriptions in Mechanic](https://learn.mechanic.dev/core/tasks/subscriptions) + +## Documentation + +This task will maintain a variant list metafield of in stock location names. Running on a schedule, it will check recently updated variants to see which are in stock at each location. Variants with positive "available" inventory at a location, or are configured for overselling, are considered to be in stock, as are variants that are sold from a location but not tracked. + +Optionally, you may choose to have this task only check specific locations using the "Include location names" option, or to ignore specific locations using the "Exclude location names" option. Exclusions will only apply if the inclusions field is empty. + +Run the task manually to scan all variants (up to 25K) in the shop for initial setup. + +**Important:** if you wish the configured variant metafield to be used as a search filter on your website using [Shopify Search & Discovery](https://help.shopify.com/en/manual/online-store/search-and-discovery), then you must set up a [custom metafield definition](https://help.shopify.com/en/manual/custom-data/metafields/metafield-definitions/creating-custom-metafield-definitions) for it *before* running this task. Otherwise, you will not be able to create the metafield definition with a "list.single_line_text_field" type. + +## Installing this task + +Find this task [in the library at tasks.mechanic.dev](https://tasks.mechanic.dev/sync-in-stock-locations-to-a-variant-metafield), and use the "Try this task" button. Or, import [this task's JSON export](../../tasks/sync-in-stock-locations-to-a-variant-metafield.json) – see [Importing and exporting tasks](https://learn.mechanic.dev/core/tasks/import-and-export) to learn how imports work. + +## Contributions + +Found a bug? Got an improvement to add? Start here: [../../CONTRIBUTING.md](../../CONTRIBUTING.md). + +## Task requests + +Submit your [task requests](https://mechanic.canny.io/task-requests) for consideration by the Mechanic community, and they may be chosen for development and inclusion in the [task library](https://tasks.mechanic.dev/)! diff --git a/docs/sync-in-stock-locations-to-a-variant-metafield/script.liquid b/docs/sync-in-stock-locations-to-a-variant-metafield/script.liquid new file mode 100644 index 00000000..0d5e5a46 --- /dev/null +++ b/docs/sync-in-stock-locations-to-a-variant-metafield/script.liquid @@ -0,0 +1,274 @@ +{% assign variant_metafield = options.variant_metafield__required %} +{% assign include_location_names = options.include_location_names__array %} +{% assign exclude_location_names = options.exclude_location_names__array %} + +{% if event.topic == "mechanic/user/trigger" or event.topic contains "mechanic/scheduler/" %} + {% comment %} + -- if this is a scheduled event run, then create a search query filter in relation to the scheduler interval + {% endcomment %} + + {% if event.topic contains "mechanic/scheduler/" %} + {% if event.topic == "mechanic/scheduler/10min" %} + {% assign lookback = event.data | date: "%FT%TZ", tz: "UTC", advance: "-10 minutes" %} + + {% elsif event.topic == "mechanic/scheduler/hourly" %} + {% assign lookback = event.data | date: "%FT%TZ", tz: "UTC", advance: "-1 hour" %} + + {% elsif event.topic == "mechanic/scheduler/daily" %} + {% assign lookback = event.data | date: "%FT%TZ", tz: "UTC", advance: "-1 day" %} + {% endif %} + + {% if lookback %} + {% assign search_query = lookback | json | prepend: "updated_at:>=" %} + {% endif %} + + {% unless event.preview %} + {% log search_query: search_query %} + {% endunless %} + {% endif %} + + {% comment %} + -- get all or recently updated variants in the shop, depending upon event topic + {% endcomment %} + + {% assign cursor = nil %} + {% assign variants = array %} + + {% for n in (1..100) %} + {% capture query %} + query { + productVariants( + first: 250 + after: {{ cursor | json }} + query: {{ search_query | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + displayName + inventoryPolicy + metafield(key: {{ variant_metafield | json }}) { + jsonValue + } + inventoryItem { + tracked + inventoryLevels( + first: 200 + ) { + nodes { + location { + name + } + quantities(names: "available") { + quantity + } + } + } + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "productVariants": { + "nodes": [ + { + "id": "gid://shopify/ProductVariant/1234567890", + "inventoryPolicy": "DENY", + "metafield": { + "jsonValue": [ + "Warehouse A" + ] + }, + "inventoryItem": { + "tracked": true, + "inventoryLevels": { + "nodes": [ + { + "location": { + "name": "Warehouse A" + }, + "quantities": [ + { + "quantity": 0 + } + ] + }, + { + "location": { + "name": "Warehouse B" + }, + "quantities": [ + { + "quantity": 1 + } + ] + }, + { + "location": { + "name": "Warehouse C" + }, + "quantities": [ + { + "quantity": 1 + } + ] + } + ] + } + } + } + ] + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} + + {% assign variants = variants | concat: result.data.productVariants.nodes %} + + {% if result.data.productVariants.pageInfo.hasNextPage %} + {% assign cursor = result.data.productVariants.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} + {% endfor %} + + {% comment %} + -- check which locations are in stock for each variant (i.e. "available" > 0) + -- variants that are configured for overselling or are not tracked at a location are considered as in stock + {% endcomment %} + + {% assign metafield_set_inputs = array %} + {% assign metafield_delete_inputs = array %} + + {% for variant in variants %} + {% assign in_stock_location_names = array %} + {% assign current_metafield_value = variant.metafield.jsonValue %} + + {% for inventory_level in variant.inventoryItem.inventoryLevels.nodes %} + {% assign location_name = inventory_level.location.name %} + + {% if include_location_names != blank %} + {% unless include_location_names contains location_name %} + {% continue %} + {% endunless %} + + {% elsif exclude_location_names != blank %} + {% if exclude_location_names contains location_name %} + {% continue %} + {% endif %} + {% endif %} + + {% if variant.inventoryPolicy == "CONTINUE" + or variant.inventoryItem.tracked == false + or inventory_level.quantities.first.quantity > 0 + %} + {% assign in_stock_location_names = in_stock_location_names | push: inventory_level.location.name %} + {% endif %} + {% endfor %} + + {% comment %} + -- sort the in stock location names for comparison against current metafield value + {% endcomment %} + + {% assign in_stock_location_names = in_stock_location_names | sort_naturally %} + + {% if in_stock_location_names == blank %} + {% if variant.metafield != blank %} + {% log + out_of_stock_variant_to_clear: variant, + in_stock_location_names: in_stock_location_names + %} + + {% assign metafield_delete_input = hash %} + {% assign metafield_delete_input["ownerId"] = variant.id %} + {% assign metafield_delete_input["namespace"] = variant_metafield | split: "." | first %} + {% assign metafield_delete_input["key"] = variant_metafield | split: "." | last %} + {% assign metafield_delete_inputs = metafield_delete_inputs | push: metafield_delete_input %} + {% endif %} + + {% elsif in_stock_location_names != variant.metafield.jsonValue %} + {% log + in_stock_variant_to_update: variant, + in_stock_location_names: in_stock_location_names + %} + + {% assign metafield_set_input = hash %} + {% assign metafield_set_input["ownerId"] = variant.id %} + {% assign metafield_set_input["namespace"] = variant_metafield | split: "." | first %} + {% assign metafield_set_input["key"] = variant_metafield | split: "." | last %} + {% assign metafield_set_input["type"] = "list.single_line_text_field" %} + {% assign metafield_set_input["value"] = in_stock_location_names | json %} + {% assign metafield_set_inputs = metafield_set_inputs | push: metafield_set_input %} + {% endif %} + {% endfor %} + + {% if metafield_delete_inputs != blank %} + {% assign groups_of_metafield_delete_inputs = metafield_delete_inputs | in_groups_of: 250, fill_with: false %} + + {% for group_of_metafield_delete_inputs in groups_of_metafield_delete_inputs %} + {% action "shopify" %} + mutation { + metafieldsDelete( + metafields: {{ group_of_metafield_delete_inputs | graphql_arguments }} + ) { + deletedMetafields { + ownerId + namespace + key + } + userErrors { + field + message + } + } + } + {% endaction %} + {% endfor %} + {% endif %} + + {% if metafield_set_inputs != blank %} + {% assign groups_of_metafield_set_inputs = metafield_set_inputs | in_groups_of: 25, fill_with: false %} + + {% for group_of_metafield_set_inputs in groups_of_metafield_set_inputs %} + {% action "shopify" %} + mutation { + metafieldsSet( + metafields: {{ group_of_metafield_set_inputs | graphql_arguments }} + ) { + metafields { + id + namespace + key + type + value + owner { + ... on ProductVariant { + id + displayName + } + } + } + userErrors { + code + field + message + } + } + } + {% endaction %} + {% endfor %} + {% endif %} +{% endif %} diff --git a/docs/tag-products-ny-metaobject-reference-field-values/README.md b/docs/tag-products-ny-metaobject-reference-field-values/README.md new file mode 100644 index 00000000..0cf7efb1 --- /dev/null +++ b/docs/tag-products-ny-metaobject-reference-field-values/README.md @@ -0,0 +1,60 @@ +# Tag products by metaobject reference field values + +Tags: Metaobjects, Products, Tag + +Use this task to copy metaobject field values to the referencing products' tags. Configure a product metafield that is a *metaobject_reference* type (or *list.metaobject_reference*), and add the metaobject field keys which contain the values to be added as tags, along with paired tag prefixes for each. Then run the task manually, or schedule it to run daily, and it will scan all active products in the shop on each run and tag them as needed. + +* View in the task library: [tasks.mechanic.dev/tag-products-ny-metaobject-reference-field-values](https://tasks.mechanic.dev/tag-products-ny-metaobject-reference-field-values) +* Task JSON, for direct import: [task.json](../../tasks/tag-products-ny-metaobject-reference-field-values.json) +* Preview task code: [script.liquid](./script.liquid) + +## Default options + +```json +{ + "product_metafield__required": "custom.book_authors", + "metaobject_field_keys_and_tag_prefixes__keyval_required": { + "name": "author: " + }, + "remove_outdated_prefixed_tags__boolean": true, + "run_daily__boolean": null, + "test_mode__boolean": true +} +``` + +[Learn about task options in Mechanic](https://learn.mechanic.dev/core/tasks/options) + +## Subscriptions + +```liquid +{% if options.run_daily__boolean %} + mechanic/scheduler/daily +{% endif %} +mechanic/user/trigger +``` + +[Learn about event subscriptions in Mechanic](https://learn.mechanic.dev/core/tasks/subscriptions) + +## Documentation + +Use this task to copy metaobject field values to the referencing products' tags. Configure a product metafield that is a *metaobject_reference* type (or *list.metaobject_reference*), and add the metaobject field keys which contain the values to be added as tags, along with paired tag prefixes for each. Then run the task manually, or schedule it to run daily, and it will scan all active products in the shop on each run and tag them as needed. + +First consider using "Test mode" to have the task log out which tagging decisions it would have made, before having it make live changes to your products. + +*Tagging example*: + +A metaobject for "Book Authors" has a **name** field defined that you wish to apply as tags to the book products in your shop. The book products have a metafield definition for **custom.book_authors** that references the metaobject's entries, allowing you to attach one or more authors to a book. Using a configured tag prefix of "author: ", this task might add product tags like: "author: Jean Deaux" and "author: Anon Y. Mous". + +**Note:** Mechanic cannot access metafields controlled by other apps. + +## Installing this task + +Find this task [in the library at tasks.mechanic.dev](https://tasks.mechanic.dev/tag-products-ny-metaobject-reference-field-values), and use the "Try this task" button. Or, import [this task's JSON export](../../tasks/tag-products-ny-metaobject-reference-field-values.json) – see [Importing and exporting tasks](https://learn.mechanic.dev/core/tasks/import-and-export) to learn how imports work. + +## Contributions + +Found a bug? Got an improvement to add? Start here: [../../CONTRIBUTING.md](../../CONTRIBUTING.md). + +## Task requests + +Submit your [task requests](https://mechanic.canny.io/task-requests) for consideration by the Mechanic community, and they may be chosen for development and inclusion in the [task library](https://tasks.mechanic.dev/)! diff --git a/docs/tag-products-ny-metaobject-reference-field-values/script.liquid b/docs/tag-products-ny-metaobject-reference-field-values/script.liquid new file mode 100644 index 00000000..decad621 --- /dev/null +++ b/docs/tag-products-ny-metaobject-reference-field-values/script.liquid @@ -0,0 +1,362 @@ +{% assign product_metafield = options.product_metafield__required %} +{% assign metaobject_field_keys_and_tag_prefixes = options.metaobject_field_keys_and_tag_prefixes__keyval_required %} +{% assign remove_outdated_prefixed_tags = options.remove_outdated_prefixed_tags__boolean %} +{% assign run_daily = options.run_daily__boolean %} +{% assign test_mode = options.test_mode__boolean %} + +{% assign metaobject_field_keys = metaobject_field_keys_and_tag_prefixes | keys %} + +{% unless event.preview %} + {% log + task_config: "for this task run...", + product_metafield: product_metafield, + metaobject_field_keys_and_tag_prefixes: metaobject_field_keys_and_tag_prefixes, + remove_outdated_prefixed_tags: remove_outdated_prefixed_tags, + run_daily: run_daily, + test_mode: test_mode + %} +{% endunless %} + +{% comment %} + -- create hash of configured tag prefixes for lookups +{% endcomment %} + +{% assign tag_prefixes_hash = hash %} + +{% for keyval in metaobject_field_keys_and_tag_prefixes %} + {% assign metaobject_field_key = keyval[0] %} + {% assign tag_prefix = keyval[1] %} + + {% if tag_prefix != blank %} + {% assign tag_prefixes_hash[metaobject_field_key] = tag_prefix | lstrip %} + {% endif %} +{% endfor %} + +{% assign tag_prefixes = tag_prefixes_hash | values %} + +{% assign products = array %} + +{% if event.topic == "mechanic/user/trigger" or event.topic contains "mechanic/scheduler/" %} + {% comment %} + -- get all active products in the shop (up to 25K) + {% endcomment %} + + {% assign cursor = nil %} + + {% for n in (1..100) %} + {% capture query %} + query { + products( + first: 250 + after: {{ cursor | json }} + query: "status:active" + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + tags + metafield(key: {{ product_metafield | json }}) { + type + jsonValue + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% assign products + = result.data.products.nodes + | default: array + | concat: products + %} + + {% if result.data.products.pageInfo.hasNextPage %} + {% assign cursor = result.data.products.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} + {% endfor %} + +{% elsif event.topic contains "shopify/products/" %} + {% capture query %} + query { + product(id: {{ product.admin_graphql_api_id | json }}) { + id + tags + metafield(key: {{ product_metafield | json }}) { + type + jsonValue + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% assign products[0] = result.data.product %} +{% endif %} + +{% if event.preview %} + {% capture products_json %} + [ + { + "id": "gid://shopify/Product/1234567890", + "metafield": { + "type": "list.metaobject_reference", + "jsonValue": [ + "gid://shopify/Metaobject/1234567890" + ] + } + } + ] + {% endcapture %} + + {% assign products = products_json | parse_json %} +{% endif %} + +{% assign product_ids_and_tags = hash %} +{% assign seen_metaobjects = hash %} + +{% for product in products %} + {% assign tags_should_have = array %} + {% assign tags_to_add = array %} + {% assign tags_to_remove = array %} + + {% if product.metafield == blank %} + {% continue %} + {% endif %} + + {% unless product.metafield.type contains "metaobject_reference" %} + {% error + message: "The configured product metafield is neither a 'metaobject_reference' nor a 'list.metaobject_reference' type.", + product: product + %} + {% break %} + {% endunless %} + + {% comment %} + -- jsonValue will return a string or array depending on metafield type; iterating over the value will work for both cases + {% endcomment %} + + {% assign product_metaobject_ids = product.metafield.jsonValue %} + + {% for product_metaobject_id in product_metaobject_ids %} + {% comment %} + -- get metaobject data if it hasn't been seen yet on this task run + {% endcomment %} + + {% if seen_metaobjects[product_metaobject_id] == blank %} + {% capture query %} + query { + metaobject(id: {{ product_metaobject_id | json }}) { + id + handle + displayName + fields { + key + type + jsonValue + } + } + } + + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "metaobject": { + "id": "gid://shopify/Metaobject/1234567890", + "handle": "preview-sample", + "displayName": "Preview Sample", + "fields": [ + { + "key": {{ metaobject_field_keys.first | json }}, + "type": "list.single_line_text_field", + "jsonValue": [ + "Preview", + "Sample" + ] + } + ] + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} + + {% assign seen_metaobjects[product_metaobject_id] = result.data.metaobject %} + {% endif %} + + {% assign metaobject = seen_metaobjects[product_metaobject_id] %} + + {% comment %} + -- get values from metaobject fields + {% endcomment %} + + {% for metaobject_field_key in metaobject_field_keys %} + {% assign tag_prefix = tag_prefixes_hash[metaobject_field_key] %} + {% assign metaobject_field + = metaobject.fields + | where: "key", metaobject_field_key + | first + %} + + {% if metaobject_field == blank %} + {% continue %} + {% endif %} + + {% assign metaobject_field_type = metaobject_field.type | remove: "list." %} + {% assign metaobject_field_values = metaobject_field.jsonValue %} + + {% for metaobject_field_value in metaobject_field.jsonValue %} + {% case metaobject_field_type %} + {% when "boolean" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "color" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "date" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "date_time" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "dimension" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} {{ metaobject_field_value.unit }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "number_decimal" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "number_integer" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "rating" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} / {{ metaobject_field_value.scale_max }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "single_line_text_field" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "volume" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} {{ metaobject_field_value.unit }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% when "weight" %} + {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} {{ metaobject_field_value.unit }}{% endcapture %} + {% assign tags_should_have[tags_should_have.size] = tag | strip %} + + {% else %} + {% log + message: "Unsupported metaobject field type for this task", + metaobject_field_type: metaobject_field.type, + product_id: product.id + %} + {% endcase %} + {% endfor %} + {% endfor %} + {% endfor %} + + {% if remove_outdated_prefixed_tags %} + {% for tag_prefix in tag_prefixes %} + {% assign tag_prefix_size = tag_prefix.size %} + + {% for product_tag in product.tags %} + {% assign product_tag_slice = product_tag | slice: 0, tag_prefix_size %} + + {% if product_tag.size > tag_prefix_size and product_tag_slice == tag_prefix %} + {% unless tags_should_have contains product_tag %} + {% assign tags_to_remove = tags_to_remove | push: product_tag %} + {% endunless %} + {% endif %} + {% endfor %} + {% endfor %} + {% endif %} + + {% for tag_should_have in tags_should_have %} + {% unless product.tags contains tag_should_have %} + {% assign tags_to_add = tags_to_add | push: tag_should_have %} + {% endunless %} + {% endfor %} + + {% if tags_to_add != blank or tags_to_remove != blank %} + {% assign product_ids_and_tags[product.id] = hash %} + {% assign product_ids_and_tags[product.id]["tags_to_add"] = tags_to_add %} + {% assign product_ids_and_tags[product.id]["tags_to_remove"] = tags_to_remove %} + + {% else %} + {% log + message: "No tagging operations needed for this product with a metafield reference; skipping.", + product: product + %} + {% endif %} +{% endfor %} + +{% unless event.preview %} + {% log metaobjects_seen_on_this_task_run: seen_metaobjects %} +{% endunless %} + +{% if test_mode %} + {% log %} + { + "message": "Found {{ product_ids_and_tags.size }} tagging operations", + "product_ids_and_tags": {{ product_ids_and_tags | json }} + } + {% endlog %} + + {% break %} +{% endif %} + +{% for keyval in product_ids_and_tags %} + {% assign product_id = keyval[0] %} + {% assign tags_to_add = keyval[1].tags_to_add %} + {% assign tags_to_remove = keyval[1].tags_to_remove %} + + {% if tags_to_add != blank or tags_to_remove != blank %} + {% action "shopify" %} + mutation { + {% if tags_to_add != blank %} + tagsAdd( + id: {{ product_id | json }} + tags: {{ tags_to_add | json }} + ) { + userErrors { + field + message + } + } + {% endif %} + {% if tags_to_remove != blank %} + tagsRemove( + id: {{ product_id | json }} + tags: {{ tags_to_remove | json }} + ) { + userErrors { + field + message + } + } + {% endif %} + } + {% endaction %} + {% endif %} +{% endfor %} diff --git a/lib/task_schema.json b/lib/task_schema.json index 1672d537..b2e98065 100644 --- a/lib/task_schema.json +++ b/lib/task_schema.json @@ -32,6 +32,7 @@ "Cart", "Cart Attributes", "Catalog", + "Category", "Collections", "Comments", "Companies", @@ -78,6 +79,7 @@ "Max Orders", "Membership", "Metafields", + "Metaobjects", "Multi-Location", "Online Store 2.0", "Order Attributes", diff --git a/tasks/auto-tag-products-with-their-category.json b/tasks/auto-tag-products-with-their-category.json new file mode 100644 index 00000000..e81a6582 --- /dev/null +++ b/tasks/auto-tag-products-with-their-category.json @@ -0,0 +1,22 @@ +{ + "docs": "Use this task to tag products with their category from [Shopify's standard product taxonomy](https://shopify.github.io/product-taxonomy/releases/latest/). Run this task manually to scan every active product in your store. Optionally, set it to run daily to scan active products updated in the last day.\n\nNote: A tag prefix must be configured so the task will know which category tag(s) to remove from a product if applicable.", + "halt_action_run_sequence_on_error": false, + "name": "Auto-tag products with their category", + "online_store_javascript": null, + "options": { + "apply_this_prefix_to_tags__required": "category: ", + "run_task_daily__boolean": false + }, + "perform_action_runs_in_sequence": false, + "preview_event_definitions": [], + "script": "{% assign tag_prefix = options.apply_this_prefix_to_tags__required %}\n\n{% if event.topic == \"mechanic/user/trigger\" or event.topic == \"mechanic/scheduler/daily\" %}\n {% assign search_query = \"status:active\" %}\n\n {% comment %}\n -- if this is a daily scheduled run, then create a search query filter to only get active products updated since previous day\n {% endcomment %}\n\n {% if event.topic contains \"mechanic/scheduler/daily\" %}\n {% assign search_query\n = event.data\n | date: \"%FT%TZ\", tz: \"UTC\", advance: \"-1 day\"\n | json\n | prepend: \"status:active updated_at:>=\"\n %}\n {% endif %}\n\n {% unless event.preview %}\n {% log search_query: search_query %}\n {% endunless %}\n\n {% comment %}\n -- get all or recently updated active products in the shop, depending upon event topic\n {% endcomment %}\n\n {% assign cursor = nil %}\n {% assign products = array %}\n\n {% for n in (1..200) %}\n {% capture query %}\n query {\n products(\n first: 250\n after: {{ cursor | json }}\n query: {{ search_query | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n title\n category {\n name\n isArchived\n }\n tags\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"products\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/Product/1234567890\",\n \"category\": {\n \"name\": \"Shoes\",\n \"isArchived\": false\n }\n }\n ]\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign products = products | concat: result.data.products.nodes %}\n\n {% if result.data.products.pageInfo.hasNextPage %}\n {% assign cursor = result.data.products.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- process products to see which category tag to add, and which to remove\n {% endcomment %}\n\n {% for product in products %}\n {% assign tag_should_have = nil %}\n {% assign tag_to_add = nil %}\n {% assign tags_to_remove = array %}\n\n {% unless product.category == blank or product.category.isArchived %}\n {% assign tag_should_have = product.category.name | prepend: tag_prefix %}\n {% endunless %}\n\n {% unless product.tags contains tag_should_have %}\n {% assign tag_to_add = tag_should_have %}\n {% endunless %}\n\n {% for tag in product.tags %}\n {% if tag == tag_should_have %}\n {% continue %}\n {% endif %}\n\n {% if tag contains tag_prefix %}\n {% assign tag_prefix_check = tag | slice: 0, tag_prefix.size %}\n\n {% if tag_prefix_check == tag_prefix and tag.size > tag_prefix.size %}\n {% assign tags_to_remove = tags_to_remove | push: tag %}\n {% endif %}\n {% endif %}\n {% endfor %}\n\n {% if tag_to_add != blank or tags_to_remove != blank %}\n {% action \"shopify\" %}\n mutation {\n {% if tag_to_add != blank %}\n tagsAdd(\n id: {{ product.id | json }}\n tags: {{ tag_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n {% if tags_to_remove != blank %}\n tagsRemove(\n id: {{ product.id | json }}\n tags: {{ tags_to_remove | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n }\n {% endaction %}\n {% endif %}\n {% endfor %}\n{% endif %}\n", + "subscriptions": [ + "mechanic/user/trigger" + ], + "subscriptions_template": "{% if options.run_task_daily__boolean %}\n mechanic/scheduler/daily\n{% endif %}\nmechanic/user/trigger", + "tags": [ + "Auto-Tag", + "Category", + "Products" + ] +} diff --git a/tasks/sync-in-stock-locations-to-a-variant-metafield.json b/tasks/sync-in-stock-locations-to-a-variant-metafield.json new file mode 100644 index 00000000..1e3fe44b --- /dev/null +++ b/tasks/sync-in-stock-locations-to-a-variant-metafield.json @@ -0,0 +1,22 @@ +{ + "docs": "This task will maintain a variant list metafield of in stock location names. Running on a schedule, it will check recently updated variants to see which are in stock at each location. Variants with positive \"available\" inventory at a location, or are configured for overselling, are considered to be in stock, as are variants that are sold from a location but not tracked.\n\nOptionally, you may choose to have this task only check specific locations using the \"Include location names\" option, or to ignore specific locations using the \"Exclude location names\" option. Exclusions will only apply if the inclusions field is empty.\n\nRun the task manually to scan all variants (up to 25K) in the shop for initial setup.\n\n**Important:** if you wish the configured variant metafield to be used as a search filter on your website using [Shopify Search & Discovery](https://help.shopify.com/en/manual/online-store/search-and-discovery), then you must set up a [custom metafield definition](https://help.shopify.com/en/manual/custom-data/metafields/metafield-definitions/creating-custom-metafield-definitions) for it *before* running this task. Otherwise, you will not be able to create the metafield definition with a \"list.single_line_text_field\" type.", + "halt_action_run_sequence_on_error": false, + "name": "Sync in stock locations to a variant metafield", + "online_store_javascript": null, + "options": { + "variant_metafield__required": "custom.in_stock_locations", + "include_location_names__array": null, + "exclude_location_names__array": null, + "run_every_10_minutes__boolean": false, + "run_hourly__boolean": true, + "run_daily__boolean": false + }, + "perform_action_runs_in_sequence": false, + "preview_event_definitions": [], + "script": "{% assign variant_metafield = options.variant_metafield__required %}\n{% assign include_location_names = options.include_location_names__array %}\n{% assign exclude_location_names = options.exclude_location_names__array %}\n\n{% if event.topic == \"mechanic/user/trigger\" or event.topic contains \"mechanic/scheduler/\" %}\n {% comment %}\n -- if this is a scheduled event run, then create a search query filter in relation to the scheduler interval\n {% endcomment %}\n\n {% if event.topic contains \"mechanic/scheduler/\" %}\n {% if event.topic == \"mechanic/scheduler/10min\" %}\n {% assign lookback = event.data | date: \"%FT%TZ\", tz: \"UTC\", advance: \"-10 minutes\" %}\n\n {% elsif event.topic == \"mechanic/scheduler/hourly\" %}\n {% assign lookback = event.data | date: \"%FT%TZ\", tz: \"UTC\", advance: \"-1 hour\" %}\n\n {% elsif event.topic == \"mechanic/scheduler/daily\" %}\n {% assign lookback = event.data | date: \"%FT%TZ\", tz: \"UTC\", advance: \"-1 day\" %}\n {% endif %}\n\n {% if lookback %}\n {% assign search_query = lookback | json | prepend: \"updated_at:>=\" %}\n {% endif %}\n\n {% unless event.preview %}\n {% log search_query: search_query %}\n {% endunless %}\n {% endif %}\n\n {% comment %}\n -- get all or recently updated variants in the shop, depending upon event topic\n {% endcomment %}\n\n {% assign cursor = nil %}\n {% assign variants = array %}\n\n {% for n in (1..100) %}\n {% capture query %}\n query {\n productVariants(\n first: 250\n after: {{ cursor | json }}\n query: {{ search_query | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n displayName\n inventoryPolicy\n metafield(key: {{ variant_metafield | json }}) {\n jsonValue\n }\n inventoryItem {\n tracked\n inventoryLevels(\n first: 200\n ) {\n nodes {\n location {\n name\n }\n quantities(names: \"available\") {\n quantity\n }\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"productVariants\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/ProductVariant/1234567890\",\n \"inventoryPolicy\": \"DENY\",\n \"metafield\": {\n \"jsonValue\": [\n \"Warehouse A\"\n ]\n },\n \"inventoryItem\": {\n \"tracked\": true,\n \"inventoryLevels\": {\n \"nodes\": [\n {\n \"location\": {\n \"name\": \"Warehouse A\"\n },\n \"quantities\": [\n {\n \"quantity\": 0\n }\n ]\n },\n {\n \"location\": {\n \"name\": \"Warehouse B\"\n },\n \"quantities\": [\n {\n \"quantity\": 1\n }\n ]\n },\n {\n \"location\": {\n \"name\": \"Warehouse C\"\n },\n \"quantities\": [\n {\n \"quantity\": 1\n }\n ]\n }\n ]\n }\n }\n }\n ]\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign variants = variants | concat: result.data.productVariants.nodes %}\n\n {% if result.data.productVariants.pageInfo.hasNextPage %}\n {% assign cursor = result.data.productVariants.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- check which locations are in stock for each variant (i.e. \"available\" > 0)\n -- variants that are configured for overselling or are not tracked at a location are considered as in stock\n {% endcomment %}\n\n {% assign metafield_set_inputs = array %}\n {% assign metafield_delete_inputs = array %}\n\n {% for variant in variants %}\n {% assign in_stock_location_names = array %}\n {% assign current_metafield_value = variant.metafield.jsonValue %}\n\n {% for inventory_level in variant.inventoryItem.inventoryLevels.nodes %}\n {% assign location_name = inventory_level.location.name %}\n\n {% if include_location_names != blank %}\n {% unless include_location_names contains location_name %}\n {% continue %}\n {% endunless %}\n\n {% elsif exclude_location_names != blank %}\n {% if exclude_location_names contains location_name %}\n {% continue %}\n {% endif %}\n {% endif %}\n\n {% if variant.inventoryPolicy == \"CONTINUE\"\n or variant.inventoryItem.tracked == false\n or inventory_level.quantities.first.quantity > 0\n %}\n {% assign in_stock_location_names = in_stock_location_names | push: inventory_level.location.name %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- sort the in stock location names for comparison against current metafield value\n {% endcomment %}\n\n {% assign in_stock_location_names = in_stock_location_names | sort_naturally %}\n\n {% if in_stock_location_names == blank %}\n {% if variant.metafield != blank %}\n {% log\n out_of_stock_variant_to_clear: variant,\n in_stock_location_names: in_stock_location_names\n %}\n\n {% assign metafield_delete_input = hash %}\n {% assign metafield_delete_input[\"ownerId\"] = variant.id %}\n {% assign metafield_delete_input[\"namespace\"] = variant_metafield | split: \".\" | first %}\n {% assign metafield_delete_input[\"key\"] = variant_metafield | split: \".\" | last %}\n {% assign metafield_delete_inputs = metafield_delete_inputs | push: metafield_delete_input %}\n {% endif %}\n\n {% elsif in_stock_location_names != variant.metafield.jsonValue %}\n {% log\n in_stock_variant_to_update: variant,\n in_stock_location_names: in_stock_location_names\n %}\n\n {% assign metafield_set_input = hash %}\n {% assign metafield_set_input[\"ownerId\"] = variant.id %}\n {% assign metafield_set_input[\"namespace\"] = variant_metafield | split: \".\" | first %}\n {% assign metafield_set_input[\"key\"] = variant_metafield | split: \".\" | last %}\n {% assign metafield_set_input[\"type\"] = \"list.single_line_text_field\" %}\n {% assign metafield_set_input[\"value\"] = in_stock_location_names | json %}\n {% assign metafield_set_inputs = metafield_set_inputs | push: metafield_set_input %}\n {% endif %}\n {% endfor %}\n\n {% if metafield_delete_inputs != blank %}\n {% assign groups_of_metafield_delete_inputs = metafield_delete_inputs | in_groups_of: 250, fill_with: false %}\n\n {% for group_of_metafield_delete_inputs in groups_of_metafield_delete_inputs %}\n {% action \"shopify\" %}\n mutation {\n metafieldsDelete(\n metafields: {{ group_of_metafield_delete_inputs | graphql_arguments }}\n ) {\n deletedMetafields {\n ownerId\n namespace\n key\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n {% endif %}\n\n {% if metafield_set_inputs != blank %}\n {% assign groups_of_metafield_set_inputs = metafield_set_inputs | in_groups_of: 25, fill_with: false %}\n\n {% for group_of_metafield_set_inputs in groups_of_metafield_set_inputs %}\n {% action \"shopify\" %}\n mutation {\n metafieldsSet(\n metafields: {{ group_of_metafield_set_inputs | graphql_arguments }}\n ) {\n metafields {\n id\n namespace\n key\n type\n value\n owner {\n ... on ProductVariant {\n id\n displayName\n }\n }\n }\n userErrors {\n code\n field\n message\n }\n }\n }\n {% endaction %}\n {% endfor %}\n {% endif %}\n{% endif %}\n", + "subscriptions": [ + "mechanic/scheduler/hourly", + "mechanic/user/trigger" + ], + "subscriptions_template": "{% if options.run_every_10_minutes__boolean %}\n mechanic/scheduler/10min\n{% elsif options.run_hourly__boolean %}\n mechanic/scheduler/hourly\n{% elsif options.run_daily__boolean %}\n mechanic/scheduler/daily\n{% endif %}\nmechanic/user/trigger" +} diff --git a/tasks/tag-products-ny-metaobject-reference-field-values.json b/tasks/tag-products-ny-metaobject-reference-field-values.json new file mode 100644 index 00000000..3db65b33 --- /dev/null +++ b/tasks/tag-products-ny-metaobject-reference-field-values.json @@ -0,0 +1,27 @@ +{ + "docs": "Use this task to copy metaobject field values to the referencing products' tags. Configure a product metafield that is a *metaobject_reference* type (or *list.metaobject_reference*), and add the metaobject field keys which contain the values to be added as tags, along with paired tag prefixes for each. Then run the task manually, or schedule it to run daily, and it will scan all active products in the shop on each run and tag them as needed.\n\nFirst consider using \"Test mode\" to have the task log out which tagging decisions it would have made, before having it make live changes to your products.\n\n*Tagging example*:\n\nA metaobject for \"Book Authors\" has a **name** field defined that you wish to apply as tags to the book products in your shop. The book products have a metafield definition for **custom.book_authors** that references the metaobject's entries, allowing you to attach one or more authors to a book. Using a configured tag prefix of \"author: \", this task might add product tags like: \"author: Jean Deaux\" and \"author: Anon Y. Mous\".\n\n**Note:** Mechanic cannot access metafields controlled by other apps.", + "halt_action_run_sequence_on_error": false, + "name": "Tag products by metaobject reference field values", + "online_store_javascript": null, + "options": { + "product_metafield__required": "custom.book_authors", + "metaobject_field_keys_and_tag_prefixes__keyval_required": { + "name": "author: " + }, + "remove_outdated_prefixed_tags__boolean": true, + "run_daily__boolean": null, + "test_mode__boolean": true + }, + "perform_action_runs_in_sequence": false, + "preview_event_definitions": [], + "script": "{% assign product_metafield = options.product_metafield__required %}\n{% assign metaobject_field_keys_and_tag_prefixes = options.metaobject_field_keys_and_tag_prefixes__keyval_required %}\n{% assign remove_outdated_prefixed_tags = options.remove_outdated_prefixed_tags__boolean %}\n{% assign run_daily = options.run_daily__boolean %}\n{% assign test_mode = options.test_mode__boolean %}\n\n{% assign metaobject_field_keys = metaobject_field_keys_and_tag_prefixes | keys %}\n\n{% unless event.preview %}\n {% log\n task_config: \"for this task run...\",\n product_metafield: product_metafield,\n metaobject_field_keys_and_tag_prefixes: metaobject_field_keys_and_tag_prefixes,\n remove_outdated_prefixed_tags: remove_outdated_prefixed_tags,\n run_daily: run_daily,\n test_mode: test_mode\n %}\n{% endunless %}\n\n{% comment %}\n -- create hash of configured tag prefixes for lookups\n{% endcomment %}\n\n{% assign tag_prefixes_hash = hash %}\n\n{% for keyval in metaobject_field_keys_and_tag_prefixes %}\n {% assign metaobject_field_key = keyval[0] %}\n {% assign tag_prefix = keyval[1] %}\n\n {% if tag_prefix != blank %}\n {% assign tag_prefixes_hash[metaobject_field_key] = tag_prefix | lstrip %}\n {% endif %}\n{% endfor %}\n\n{% assign tag_prefixes = tag_prefixes_hash | values %}\n\n{% assign products = array %}\n\n{% if event.topic == \"mechanic/user/trigger\" or event.topic contains \"mechanic/scheduler/\" %}\n {% comment %}\n -- get all active products in the shop (up to 25K)\n {% endcomment %}\n\n {% assign cursor = nil %}\n\n {% for n in (1..100) %}\n {% capture query %}\n query {\n products(\n first: 250\n after: {{ cursor | json }}\n query: \"status:active\"\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n tags\n metafield(key: {{ product_metafield | json }}) {\n type\n jsonValue\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% assign products\n = result.data.products.nodes\n | default: array\n | concat: products\n %}\n\n {% if result.data.products.pageInfo.hasNextPage %}\n {% assign cursor = result.data.products.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n{% elsif event.topic contains \"shopify/products/\" %}\n {% capture query %}\n query {\n product(id: {{ product.admin_graphql_api_id | json }}) {\n id\n tags\n metafield(key: {{ product_metafield | json }}) {\n type\n jsonValue\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% assign products[0] = result.data.product %}\n{% endif %}\n\n{% if event.preview %}\n {% capture products_json %}\n [\n {\n \"id\": \"gid://shopify/Product/1234567890\",\n \"metafield\": {\n \"type\": \"list.metaobject_reference\",\n \"jsonValue\": [\n \"gid://shopify/Metaobject/1234567890\"\n ]\n }\n }\n ]\n {% endcapture %}\n\n {% assign products = products_json | parse_json %}\n{% endif %}\n\n{% assign product_ids_and_tags = hash %}\n{% assign seen_metaobjects = hash %}\n\n{% for product in products %}\n {% assign tags_should_have = array %}\n {% assign tags_to_add = array %}\n {% assign tags_to_remove = array %}\n\n {% if product.metafield == blank %}\n {% continue %}\n {% endif %}\n\n {% unless product.metafield.type contains \"metaobject_reference\" %}\n {% error\n message: \"The configured product metafield is neither a 'metaobject_reference' nor a 'list.metaobject_reference' type.\",\n product: product\n %}\n {% break %}\n {% endunless %}\n\n {% comment %}\n -- jsonValue will return a string or array depending on metafield type; iterating over the value will work for both cases\n {% endcomment %}\n\n {% assign product_metaobject_ids = product.metafield.jsonValue %}\n\n {% for product_metaobject_id in product_metaobject_ids %}\n {% comment %}\n -- get metaobject data if it hasn't been seen yet on this task run\n {% endcomment %}\n\n {% if seen_metaobjects[product_metaobject_id] == blank %}\n {% capture query %}\n query {\n metaobject(id: {{ product_metaobject_id | json }}) {\n id\n handle\n displayName\n fields {\n key\n type\n jsonValue\n }\n }\n }\n\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"metaobject\": {\n \"id\": \"gid://shopify/Metaobject/1234567890\",\n \"handle\": \"preview-sample\",\n \"displayName\": \"Preview Sample\",\n \"fields\": [\n {\n \"key\": {{ metaobject_field_keys.first | json }},\n \"type\": \"list.single_line_text_field\",\n \"jsonValue\": [\n \"Preview\",\n \"Sample\"\n ]\n }\n ]\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign seen_metaobjects[product_metaobject_id] = result.data.metaobject %}\n {% endif %}\n\n {% assign metaobject = seen_metaobjects[product_metaobject_id] %}\n\n {% comment %}\n -- get values from metaobject fields\n {% endcomment %}\n\n {% for metaobject_field_key in metaobject_field_keys %}\n {% assign tag_prefix = tag_prefixes_hash[metaobject_field_key] %}\n {% assign metaobject_field\n = metaobject.fields\n | where: \"key\", metaobject_field_key\n | first\n %}\n\n {% if metaobject_field == blank %}\n {% continue %}\n {% endif %}\n\n {% assign metaobject_field_type = metaobject_field.type | remove: \"list.\" %}\n {% assign metaobject_field_values = metaobject_field.jsonValue %}\n\n {% for metaobject_field_value in metaobject_field.jsonValue %}\n {% case metaobject_field_type %}\n {% when \"boolean\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"color\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"date\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"date_time\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"dimension\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} {{ metaobject_field_value.unit }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"number_decimal\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"number_integer\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"rating\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} / {{ metaobject_field_value.scale_max }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"single_line_text_field\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"volume\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} {{ metaobject_field_value.unit }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% when \"weight\" %}\n {% capture tag %}{{ tag_prefix }}{{ metaobject_field_value.value }} {{ metaobject_field_value.unit }}{% endcapture %}\n {% assign tags_should_have[tags_should_have.size] = tag | strip %}\n\n {% else %}\n {% log\n message: \"Unsupported metaobject field type for this task\",\n metaobject_field_type: metaobject_field.type,\n product_id: product.id\n %}\n {% endcase %}\n {% endfor %}\n {% endfor %}\n {% endfor %}\n\n {% if remove_outdated_prefixed_tags %}\n {% for tag_prefix in tag_prefixes %}\n {% assign tag_prefix_size = tag_prefix.size %}\n\n {% for product_tag in product.tags %}\n {% assign product_tag_slice = product_tag | slice: 0, tag_prefix_size %}\n\n {% if product_tag.size > tag_prefix_size and product_tag_slice == tag_prefix %}\n {% unless tags_should_have contains product_tag %}\n {% assign tags_to_remove = tags_to_remove | push: product_tag %}\n {% endunless %}\n {% endif %}\n {% endfor %}\n {% endfor %}\n {% endif %}\n\n {% for tag_should_have in tags_should_have %}\n {% unless product.tags contains tag_should_have %}\n {% assign tags_to_add = tags_to_add | push: tag_should_have %}\n {% endunless %}\n {% endfor %}\n\n {% if tags_to_add != blank or tags_to_remove != blank %}\n {% assign product_ids_and_tags[product.id] = hash %}\n {% assign product_ids_and_tags[product.id][\"tags_to_add\"] = tags_to_add %}\n {% assign product_ids_and_tags[product.id][\"tags_to_remove\"] = tags_to_remove %}\n\n {% else %}\n {% log\n message: \"No tagging operations needed for this product with a metafield reference; skipping.\",\n product: product\n %}\n {% endif %}\n{% endfor %}\n\n{% unless event.preview %}\n {% log metaobjects_seen_on_this_task_run: seen_metaobjects %}\n{% endunless %}\n\n{% if test_mode %}\n {% log %}\n {\n \"message\": \"Found {{ product_ids_and_tags.size }} tagging operations\",\n \"product_ids_and_tags\": {{ product_ids_and_tags | json }}\n }\n {% endlog %}\n\n {% break %}\n{% endif %}\n\n{% for keyval in product_ids_and_tags %}\n {% assign product_id = keyval[0] %}\n {% assign tags_to_add = keyval[1].tags_to_add %}\n {% assign tags_to_remove = keyval[1].tags_to_remove %}\n\n {% if tags_to_add != blank or tags_to_remove != blank %}\n {% action \"shopify\" %}\n mutation {\n {% if tags_to_add != blank %}\n tagsAdd(\n id: {{ product_id | json }}\n tags: {{ tags_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n {% if tags_to_remove != blank %}\n tagsRemove(\n id: {{ product_id | json }}\n tags: {{ tags_to_remove | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n }\n {% endaction %}\n {% endif %}\n{% endfor %}\n", + "subscriptions": [ + "mechanic/user/trigger" + ], + "subscriptions_template": "{% if options.run_daily__boolean %}\n mechanic/scheduler/daily\n{% endif %}\nmechanic/user/trigger", + "tags": [ + "Metaobjects", + "Products", + "Tag" + ] +}