← back to Designerwallcoverings
TK-10039: 4 push-*-live.js (zoffany/tres-tintas/fallingstar/artmura) write specs to metafields at create + prose-only body; artmura now writes metafields (text-safe mapper removes the 422 fear)
4357a081b31a757edea81de13f270ca85f60d827 · 2026-07-29 07:46:35 -0700 · Steve Abrams
Files touched
M scripts/artmura-onboard/push-artmura-live.jsM scripts/fallingstar-onboard/push-fallingstar-live.jsM scripts/tres-tintas-onboard/push-tres-tintas-live.jsM scripts/zoffany-onboard/push-zoffany-live.js
Diff
commit 4357a081b31a757edea81de13f270ca85f60d827
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed Jul 29 07:46:35 2026 -0700
TK-10039: 4 push-*-live.js (zoffany/tres-tintas/fallingstar/artmura) write specs to metafields at create + prose-only body; artmura now writes metafields (text-safe mapper removes the 422 fear)
---
scripts/artmura-onboard/push-artmura-live.js | 119 +++++++++++++++++++--
.../fallingstar-onboard/push-fallingstar-live.js | 78 ++++++++++++--
.../tres-tintas-onboard/push-tres-tintas-live.js | 69 +++++++++++-
scripts/zoffany-onboard/push-zoffany-live.js | 97 +++++++++++++++--
4 files changed, 333 insertions(+), 30 deletions(-)
diff --git a/scripts/artmura-onboard/push-artmura-live.js b/scripts/artmura-onboard/push-artmura-live.js
index 71fe3b7..2894a8d 100644
--- a/scripts/artmura-onboard/push-artmura-live.js
+++ b/scripts/artmura-onboard/push-artmura-live.js
@@ -49,18 +49,111 @@ function shopify(method, p, body) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
-function specTable(r) {
- const rows = [
+function specBody(r) {
+ // Body is PROSE ONLY — specs now go to metafields at create (text-safe), not a body table.
+ return `<div class="artmura-product">\n${r.body_html || ('<p>' + esc(r.title) + ' by Artmura.</p>')}\n</div>`;
+}
+
+// CANONICAL text-safe spec→metafield mapping. Every target below is a TEXT-typed
+// metafield (verified safe — no 422). custom.material is multi_line_text_field.
+// product_reference keys are deliberately AVOIDED by routing to global.*/specs.* text twins.
+function buildMetafields(r) {
+ const mf = [];
+ const seen = new Set(); // dedupe by namespace.key — Shopify rejects duplicate keys on create
+ const single = { type: 'single_line_text_field', namespace: 'custom' };
+ const multi = { type: 'multi_line_text_field', namespace: 'custom' };
+ const add = (namespace, key, type, value) => {
+ if (value == null || String(value).trim() === '') return;
+ const id = `${namespace}.${key}`;
+ if (seen.has(id)) return; // first-write-wins; avoids 422 on duplicate metafield key
+ seen.add(id);
+ mf.push({ namespace, key, type, value: String(value) });
+ };
+
+ // Source rows: [label, value] — same fields the old spec table fed, plus canonical labels.
+ const labelVal = [
['Material', r.substrate || 'Non-Woven'],
['Dimensions', r.dimensions],
['Wall coverage', r.wall_coverage],
['Grade', r.grade],
['Lead-time', r.lead_time],
['Origin', r.origin],
- ].filter(([, v]) => v);
- const trs = rows.map(([k, v]) =>
- `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
- return `<div class="artmura-product">\n${r.body_html || ('<p>' + esc(r.title) + ' by Artmura.</p>')}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+ // additional canonical labels (emit only when present on the record)
+ ['Pattern', r.pattern_name],
+ ['Color', r.color],
+ ['Content', r.content],
+ ['Composition', r.composition],
+ ['Width', r.width],
+ ['Collection', r.collection_name || r.collection_book],
+ ['Repeat', r.repeat || r.pattern_repeat],
+ ['Match', r.match],
+ ['Finish', r.finish],
+ ['Fire Rating', r.fire_rating],
+ ['Backing', r.backing || r.substrate],
+ ['Weight', r.weight],
+ ['Length', r.length || r.roll_length],
+ ['Manufacturer SKU', r.manufacturer_sku || r.mfr_sku],
+ ['Brand', r.brand],
+ ['Designer', r.designer],
+ ['Country', r.country],
+ ['Style', r.style],
+ ['Sold By', r.sold_by || r.unit_of_measure],
+ ['Coverage', r.coverage],
+ ['Lead Time', r.lead_time],
+ ];
+
+ // Presence set (for the Composition-both-present rule)
+ const present = {};
+ for (const [label, value] of labelVal) {
+ if (value != null && String(value).trim() !== '') present[label] = String(value);
+ }
+
+ for (const [label, valueRaw] of labelVal) {
+ if (valueRaw == null || String(valueRaw).trim() === '') continue;
+ const value = String(valueRaw);
+ switch (label) {
+ case 'Pattern': add('custom', 'pattern_name', single.type, value); break;
+ case 'Color': add('custom', 'color', single.type, value); break;
+ case 'Material':
+ case 'Content': add('custom', 'material', multi.type, value); break;
+ case 'Composition':
+ add('custom', 'material', multi.type, value);
+ // specs.composition ONLY if BOTH Material/Content AND Composition present
+ if (present['Composition'] && (present['Material'] || present['Content']))
+ add('specs', 'composition', multi.type, value);
+ break;
+ case 'Width':
+ add('custom', 'width', single.type, value);
+ add('global', 'width', single.type, value);
+ break;
+ case 'Collection': add('custom', 'collection_name', single.type, value); break;
+ case 'Repeat': add('custom', 'pattern_repeat', single.type, value); break;
+ case 'Match': add('global', 'Match', single.type, value); break;
+ case 'Finish': add('global', 'Finish', single.type, value); break;
+ case 'Fire Rating': add('custom', 'fire_rating', single.type, value); break;
+ case 'Backing': add('global', 'Substrate', single.type, value); break;
+ case 'Weight': add('global', 'Weight', single.type, value); break;
+ case 'Length': add('custom', 'length', single.type, value); break;
+ case 'Manufacturer SKU': add('custom', 'manufacturer_sku',single.type, value); break;
+ case 'Brand': add('custom', 'brand', single.type, value); break;
+ case 'Designer': add('custom', 'designer', single.type, value); break;
+ case 'Country': add('global', 'Country', single.type, value); break;
+ case 'Style': add('global', 'Style', single.type, value); break;
+ case 'Sold By': add('global', 'unit_of_measure', single.type, value); break;
+ case 'Dimensions': add('custom', 'dimensions', single.type, value); break;
+ case 'Grade': add('custom', 'grade', single.type, value); break;
+ case 'Lead-time':
+ case 'Lead Time': add('custom', 'leadtime', single.type, value); break;
+ case 'Coverage': add('custom', 'coverage', single.type, value); break;
+ case 'Wall coverage': add('custom', 'wall_coverage', single.type, value); break;
+ case 'Origin':
+ // "Origin" maps to global.Country (customer-facing country of origin)
+ add('global', 'Country', single.type, value);
+ break;
+ default: /* unmapped labels are skipped */ break;
+ }
+ }
+ return mf;
}
function buildPayload(r, dwSku) {
@@ -68,13 +161,15 @@ function buildPayload(r, dwSku) {
const tags = [r.pattern_series, r.collection_book, 'Wallcovering', 'Non-Woven', 'Made in Italy', 'Artmura', dwSku]
.filter(Boolean).join(', ');
const images = (r.images || []).map(src => ({ alt: esc(r.title), src }));
- // NOTE: no metafields on create — the store has existing custom.* definitions with
- // fixed types (material=multi_line_text_field, some keys=product_reference) and REST
- // create rejects (422) on any type mismatch. Specs live in body_html; metafields are
- // backfilled separately (backfill_metafields.js) with per-key type detection.
+ // NOTE (2026-07-29, TK-10039): specs now go to METAFIELDS at create (text-safe), NOT a body table.
+ // The canonical mapping (buildMetafields) targets ONLY text-typed metafields — product_reference
+ // custom.* keys are deliberately avoided by routing to global.*/specs.* text twins — so REST
+ // create is safe (no 422 type-mismatch). Body carries the prose description only.
+ const metafields = buildMetafields(r);
return { product: {
title, vendor: 'Artmura', product_type: r.product_type || 'Wallcoverings',
- status: STATUS, tags, images, body_html: specTable(r),
+ status: STATUS, tags, images, body_html: specBody(r),
+ ...(metafields.length ? { metafields } : {}),
options: [{ name: 'Size' }],
variants: [
{ sku: dwSku, price: String(r.price_newwall_retail), option1: 'Yard', taxable: true, requires_shipping: true },
@@ -101,6 +196,8 @@ function buildPayload(r, dwSku) {
const pl = buildPayload(r, d.dw_sku);
console.log(`\n[create] ${d.dw_sku} "${pl.product.title}"`);
console.log(` variants: Yard $${r.price_newwall_retail} / Sample $${r.sample_price || 5} · imgs:${pl.product.images.length} · tags: ${pl.product.tags}`);
+ const mfs = pl.product.metafields || [];
+ console.log(` metafields (${mfs.length}): ${mfs.map(m => `${m.namespace}.${m.key}`).join(', ') || '(none)'}`);
}
console.log(`\nDRY-RUN only. Re-run with --apply (optionally --limit=N) to create drafts.`);
await pool.end(); return;
diff --git a/scripts/fallingstar-onboard/push-fallingstar-live.js b/scripts/fallingstar-onboard/push-fallingstar-live.js
index ae55cf0..1ee8c5b 100644
--- a/scripts/fallingstar-onboard/push-fallingstar-live.js
+++ b/scripts/fallingstar-onboard/push-fallingstar-live.js
@@ -46,26 +46,92 @@ function shopify(method, p, body) {
});
}
-function specTable(r) {
+// Body is PROSE ONLY — the story/description. Specs live in metafields (TK-10039).
+function specBody(r) {
+ return `<div class="fallingstar-product">\n${r.body_html || ('<p>' + esc(r.dw_title) + '</p>')}\n</div>`;
+}
+
+// Canonical text-safe label→metafield mapping. Emits Shopify REST metafield defs (all TEXT-typed).
+// Skips blanks; no duplicate namespace.key; custom.material is multi_line_text_field.
+function specMetafields(r) {
const rows = [
['Designer', r.designer], ['Collection', r.collection], ['Pattern', r.pattern],
['Material', 'Hand-Painted Wallpaper'], ['Sample size', '8.5" x 11"'], ['Brand', 'FallingStar Studio'],
- ].filter(([, v]) => v);
- const trs = rows.map(([k, v]) =>
- `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
- return `<div class="fallingstar-product">\n${r.body_html || ('<p>' + esc(r.dw_title) + '</p>')}\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+ ].filter(([, v]) => v != null && String(v).trim() !== '');
+
+ const has = label => rows.some(([k]) => k === label);
+ const out = [];
+ const seen = new Set();
+ const push = (namespace, key, type, value) => {
+ const v = value == null ? '' : String(value).trim();
+ if (!v) return;
+ const dedupe = `${namespace}.${key}`;
+ if (seen.has(dedupe)) return;
+ seen.add(dedupe);
+ out.push({ namespace, key, type, value: v });
+ };
+
+ for (const [label, value] of rows) {
+ switch (label) {
+ case 'Pattern': push('custom', 'pattern_name', 'single_line_text_field', value); break;
+ case 'Color': push('custom', 'color', 'single_line_text_field', value); break;
+ case 'Material':
+ case 'Content':
+ case 'Composition':
+ push('custom', 'material', 'multi_line_text_field', value);
+ // Composition → specs.composition ONLY when BOTH Material and Composition are present.
+ if (label === 'Composition' && has('Material')) push('specs', 'composition', 'multi_line_text_field', value);
+ break;
+ case 'Width':
+ push('custom', 'width', 'single_line_text_field', value);
+ push('global', 'width', 'single_line_text_field', value);
+ break;
+ case 'Collection': push('custom', 'collection_name', 'single_line_text_field', value); break;
+ case 'Repeat': push('custom', 'pattern_repeat', 'single_line_text_field', value); break;
+ case 'Match': push('global', 'Match', 'single_line_text_field', value); break;
+ case 'Finish': push('global', 'Finish', 'single_line_text_field', value); break;
+ case 'Fire Rating': push('custom', 'fire_rating', 'single_line_text_field', value); break;
+ case 'Backing':
+ case 'Substrate': push('global', 'Substrate', 'single_line_text_field', value); break;
+ case 'Weight': push('global', 'Weight', 'single_line_text_field', value); break;
+ case 'Length':
+ case 'Roll Length': push('custom', 'length', 'single_line_text_field', value); break;
+ case 'Manufacturer SKU':
+ case 'SKU': push('custom', 'manufacturer_sku', 'single_line_text_field', value); break;
+ case 'Brand': push('custom', 'brand', 'single_line_text_field', value); break;
+ case 'Designer': push('custom', 'designer', 'single_line_text_field', value); break;
+ case 'Country':
+ case 'Origin': push('global', 'Country', 'single_line_text_field', value); break;
+ case 'Style': push('global', 'Style', 'single_line_text_field', value); break;
+ case 'Sold By':
+ case 'Unit of Measure': push('global', 'unit_of_measure', 'single_line_text_field', value); break;
+ default: /* Unmapped labels → skip */ break;
+ }
+ }
+ return out;
}
function buildPayload(r) {
const images = (r.images || []).map(src => ({ alt: esc(r.dw_title), src }));
+ // Preserve any pre-existing metafields on the record; merge spec metafields with no duplicate namespace.key.
+ const existing = Array.isArray(r.metafields) ? r.metafields : [];
+ const seen = new Set(existing.map(m => `${m.namespace}.${m.key}`));
+ const metafields = existing.slice();
+ for (const m of specMetafields(r)) {
+ const dedupe = `${m.namespace}.${m.key}`;
+ if (seen.has(dedupe)) continue;
+ seen.add(dedupe);
+ metafields.push(m);
+ }
return { product: {
title: r.dw_title, vendor: 'FallingStar Studio', product_type: 'Wallcoverings',
- status: STATUS, tags: r.tags.join(', '), images, body_html: specTable(r),
+ status: STATUS, tags: r.tags.join(', '), images, body_html: specBody(r),
options: [{ name: 'Size' }],
variants: [
{ sku: r.dw_sku, price: String(r.price_retail), option1: 'Roll', taxable: true, requires_shipping: true },
{ sku: `${r.dw_sku}-Sample`, price: String(r.sample_price || 5), option1: 'Sample', taxable: true, requires_shipping: true },
],
+ metafields,
} };
}
diff --git a/scripts/tres-tintas-onboard/push-tres-tintas-live.js b/scripts/tres-tintas-onboard/push-tres-tintas-live.js
index 369b417..81af3e1 100644
--- a/scripts/tres-tintas-onboard/push-tres-tintas-live.js
+++ b/scripts/tres-tintas-onboard/push-tres-tintas-live.js
@@ -48,18 +48,79 @@ function shopify(method, p, body) {
}
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
+// Spec rows: [label, value] pairs. These now feed METAFIELDS (specMetafields),
+// NOT a spec <table> baked into body_html (TK-10039).
+function specRows(r) {
+ return [['Material', 'Non-Woven'], ['Type', r.collection], ['Pattern ref.', r.vendor_sku], ['Origin', 'Spain']]
+ .filter(([, v]) => v);
+}
+
function bodyHtml(r) {
- const rows = [['Material', 'Non-Woven'], ['Type', r.collection], ['Pattern ref.', r.vendor_sku], ['Origin', 'Spain']]
- .filter(([, v]) => v)
- .map(([k, v]) => `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
- return `<div class="tres-tintas-product"><p>${esc(r.name)} by Tres Tintas Barcelona.</p>\n<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${rows}\n</table></div>`;
+ return `<div class="tres-tintas-product"><p>${esc(r.name)} by Tres Tintas Barcelona.</p></div>`;
+}
+
+// CANONICAL text-safe spec→metafield mapping (INLINED — CommonJS, do NOT import the .mjs).
+// All targets are TEXT-typed. Skips blanks; dedups namespace.key; emits global.width alongside
+// custom.width. If BOTH Material and Composition exist, Composition → specs.composition.
+function specMetafields(rows) {
+ const has = label => rows.some(([k]) => k === label);
+ const hasMaterialAndComposition = (has('Material') || has('Content')) && has('Composition');
+ const out = [];
+ const seen = new Set();
+ const push = (namespace, key, value, type) => {
+ if (value == null || String(value).trim() === '') return;
+ const id = `${namespace}.${key}`;
+ if (seen.has(id)) return;
+ seen.add(id);
+ out.push({ namespace, key, value: String(value), type });
+ };
+ for (const [label, value] of rows) {
+ switch (label) {
+ case 'Pattern': push('custom', 'pattern_name', value, 'single_line_text_field'); break;
+ case 'Color': push('custom', 'color', value, 'single_line_text_field'); break;
+ case 'Material':
+ case 'Content':
+ push('custom', 'material', value, 'multi_line_text_field'); break;
+ case 'Composition':
+ if (hasMaterialAndComposition) push('specs', 'composition', value, 'multi_line_text_field');
+ else push('custom', 'material', value, 'multi_line_text_field');
+ break;
+ case 'Width':
+ push('custom', 'width', value, 'single_line_text_field');
+ push('global', 'width', value, 'single_line_text_field'); break;
+ case 'Collection': push('custom', 'collection_name', value, 'single_line_text_field'); break;
+ case 'Repeat':
+ case 'Pattern Repeat': push('custom', 'pattern_repeat', value, 'single_line_text_field'); break;
+ case 'Match': push('global', 'Match', value, 'single_line_text_field'); break;
+ case 'Finish': push('global', 'Finish', value, 'single_line_text_field'); break;
+ case 'Fire Rating': push('custom', 'fire_rating', value, 'single_line_text_field'); break;
+ case 'Backing':
+ case 'Substrate': push('global', 'Substrate', value, 'single_line_text_field'); break;
+ case 'Weight': push('global', 'Weight', value, 'single_line_text_field'); break;
+ case 'Length':
+ case 'Roll Length': push('custom', 'length', value, 'single_line_text_field'); break;
+ case 'Manufacturer SKU':
+ case 'SKU': push('custom', 'manufacturer_sku', value, 'single_line_text_field'); break;
+ case 'Brand': push('custom', 'brand', value, 'single_line_text_field'); break;
+ case 'Designer': push('custom', 'designer', value, 'single_line_text_field'); break;
+ case 'Country':
+ case 'Origin': push('global', 'Country', value, 'single_line_text_field'); break;
+ case 'Style': push('global', 'Style', value, 'single_line_text_field'); break;
+ case 'Sold By':
+ case 'Unit of Measure': push('global', 'unit_of_measure', value, 'single_line_text_field'); break;
+ default: break; // not in map → skip
+ }
+ }
+ return out;
}
function buildPayload(r) {
const title = `${r.name} Wallcovering | Tres Tintas`;
const tags = [r.collection, 'Wallcovering', 'Non-Woven', 'Made in Spain', 'Tres Tintas', r.dw_sku].filter(Boolean).join(', ');
const images = r.image ? [{ alt: esc(r.name), src: r.image }] : [];
+ const metafields = specMetafields(specRows(r));
return { product: {
title, vendor: 'Tres Tintas', product_type: 'Wallcoverings', status: STATUS, tags, images, body_html: bodyHtml(r),
+ ...(metafields.length ? { metafields } : {}),
options: [{ name: 'Size' }],
variants: [
{ sku: r.dw_sku, price: String(r.dw_retail), option1: 'Roll', taxable: true, requires_shipping: true,
diff --git a/scripts/zoffany-onboard/push-zoffany-live.js b/scripts/zoffany-onboard/push-zoffany-live.js
index 183a0c9..ff673a9 100644
--- a/scripts/zoffany-onboard/push-zoffany-live.js
+++ b/scripts/zoffany-onboard/push-zoffany-live.js
@@ -69,15 +69,86 @@ function buildTags(r) {
return [...new Set(t)].join(', ');
}
-function specTable(r) {
- const rows = [
+// Spec rows [label, value] — feed BOTH the (now prose-only) body context and the metafields.
+function specRows(r) {
+ return [
['Pattern', r.pattern_name], ['Colour', r.color_name], ['Collection', r.collection],
['Width', r.width], ['Pattern Repeat', r.pattern_repeat], ['Composition', r.composition],
- ].filter(([, v]) => v);
- const trs = rows.map(([k, v]) =>
- `<tr><th style="text-align:left;padding:6px 14px 6px 0;font-weight:600;">${esc(k)}</th><td style="padding:6px 0;">${esc(v)}</td></tr>`).join('\n');
- const intro = (r.description && !/^DISCONTINUED/.test(r.description)) ? `<p>${esc(r.description)}</p>\n` : '';
- return `<div class="zoffany-product">\n${intro}<h3>Specifications</h3>\n<table style="border-collapse:collapse;">\n${trs}\n</table>\n</div>`;
+ ].filter(([, v]) => v != null && String(v).trim() !== '');
+}
+
+// Body is PROSE ONLY now — the story/description. Specs move to metafields (TK-10039).
+function specTable(r) {
+ const intro = (r.description && !/^DISCONTINUED/.test(r.description)) ? `<p>${esc(r.description)}</p>` : '';
+ return `<div class="zoffany-product">\n${intro}\n</div>`;
+}
+
+// Canonical text-safe label → metafield mapping (CommonJS-inlined; do NOT import the .mjs).
+// All targets are verified TEXT-typed (no product_reference 422). custom.material MUST be multi_line.
+function buildMetafields(r) {
+ const rows = specRows(r);
+ const byLabel = {};
+ for (const [k, v] of rows) byLabel[k] = String(v).trim();
+ const has = k => byLabel[k] != null && byLabel[k] !== '';
+ const get = (...ks) => { for (const k of ks) if (has(k)) return byLabel[k]; return null; };
+
+ const S = 'single_line_text_field';
+ const M = 'multi_line_text_field';
+ const out = [];
+ const seen = new Set();
+ const push = (namespace, key, value, type) => {
+ if (value == null || String(value).trim() === '') return;
+ const id = `${namespace}.${key}`;
+ if (seen.has(id)) return; // no double-write of a namespace.key
+ seen.add(id);
+ out.push({ namespace, key, type, value: String(value) });
+ };
+
+ // Pattern
+ push('custom', 'pattern_name', get('Pattern'), S);
+ // Color
+ push('custom', 'color', get('Color', 'Colour'), S);
+ // Material / Content / Composition
+ const material = get('Material', 'Content', 'Composition');
+ push('custom', 'material', material, M);
+ // Composition specifically → also specs.composition (multi_line) if both Material AND Composition exist
+ if (has('Composition') && (has('Material') || has('Content'))) {
+ push('specs', 'composition', byLabel['Composition'], M);
+ }
+ // Width → custom.width AND global.width (theme reads global.width)
+ const width = get('Width');
+ push('custom', 'width', width, S);
+ push('global', 'width', width, S);
+ // Collection
+ push('custom', 'collection_name', get('Collection'), S);
+ // Repeat / Pattern Repeat
+ push('custom', 'pattern_repeat', get('Repeat', 'Pattern Repeat'), S);
+ // Match / Pattern Match
+ push('global', 'Match', get('Match', 'Pattern Match'), S);
+ // Finish
+ push('global', 'Finish', get('Finish'), S);
+ // Fire Rating / Flammability
+ push('custom', 'fire_rating', get('Fire Rating', 'Flammability'), S);
+ // Backing / Substrate
+ push('global', 'Substrate', get('Backing', 'Substrate'), S);
+ // Weight
+ push('global', 'Weight', get('Weight'), S);
+ // Length / Roll Length
+ push('custom', 'length', get('Length', 'Roll Length'), S);
+ // Manufacturer SKU / SKU
+ push('custom', 'manufacturer_sku', get('Manufacturer SKU', 'SKU'), S);
+ // Brand
+ push('custom', 'brand', get('Brand'), S);
+ // Designer
+ push('custom', 'designer', get('Designer'), S);
+ // Country / Origin
+ push('global', 'Country', get('Country', 'Origin'), S);
+ // Style
+ push('global', 'Style', get('Style'), S);
+ // Sold By / Unit of Measure
+ push('global', 'unit_of_measure', get('Sold By', 'Unit of Measure'), S);
+
+ return { list: out, seen };
}
function buildPayload(r) {
@@ -86,7 +157,7 @@ function buildPayload(r) {
const imgs = arr(r.all_images);
// primary first, dedup by URL, cap at 12 (avoid Schumacher-style dup-gallery bloat)
const srcs = [...new Set([r.image_url, ...imgs].filter(Boolean))].slice(0, 12);
- return { product: {
+ const product = {
title, vendor: 'Zoffany', product_type: r.is_fabric ? 'Fabric' : 'Wallcovering',
status: STATUS, tags: buildTags(r), body_html: specTable(r),
images: srcs.map(src => ({ alt: esc(`${r.pattern_name} - ${r.color_name}`), src })),
@@ -96,7 +167,13 @@ function buildPayload(r) {
taxable: true, requires_shipping: true,
inventory_management: null, inventory_policy: 'continue',
}],
- } };
+ };
+ // Spec metafields (TK-10039): preserve any already on the payload, no duplicate namespace.key.
+ const existing = Array.isArray(product.metafields) ? product.metafields : [];
+ const already = new Set(existing.map(m => `${m.namespace}.${m.key}`));
+ const { list } = buildMetafields(r);
+ product.metafields = [...existing, ...list.filter(m => !already.has(`${m.namespace}.${m.key}`))];
+ return { product };
}
(async () => {
@@ -119,6 +196,8 @@ function buildPayload(r) {
console.log(`\n[create] ${r.dw_sku} "${pl.title}" [${pl.status}]`);
console.log(` variant: ${pl.variants[0].option1} $${pl.variants[0].price} · imgs:${pl.images.length}`);
console.log(` tags: ${pl.tags}`);
+ console.log(` body_html: ${pl.body_html.replace(/\n/g, ' ')}`);
+ console.log(` metafields (${pl.metafields.length}): ${pl.metafields.map(m => `${m.namespace}.${m.key}[${m.type}]=${JSON.stringify(m.value)}`).join(' · ')}`);
}
console.log(`\nDRY-RUN only. Re-run with --apply (optionally --limit=N) to create.`);
await pool.end(); return;
← 695d29e auto-save: 2026-07-29T07:37:00 (8 files) — data/orphan-clean
·
back to Designerwallcoverings
·
auto-save: 2026-07-29T08:07:14 (3 files) — scripts/price-she 079c0ee →