← back to Designer Wallcoverings
TK-135: unified color-tag normalizer — strip all color:/Color: to bare catalog-wide + our bare color + metafield (gated)
51ad1beb95ecbf9bf408a20468ce2b3bd06826e4 · 2026-07-27 19:45:15 -0700 · Steve
Files touched
A shopify/scripts/tk135-apply-normalize.jsA shopify/scripts/tk135-build-normalize-worklist.js
Diff
commit 51ad1beb95ecbf9bf408a20468ce2b3bd06826e4
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 27 19:45:15 2026 -0700
TK-135: unified color-tag normalizer — strip all color:/Color: to bare catalog-wide + our bare color + metafield (gated)
---
shopify/scripts/tk135-apply-normalize.js | 94 +++++++++++++++++++++++
shopify/scripts/tk135-build-normalize-worklist.js | 53 +++++++++++++
2 files changed, 147 insertions(+)
diff --git a/shopify/scripts/tk135-apply-normalize.js b/shopify/scripts/tk135-apply-normalize.js
new file mode 100644
index 00000000..093145fb
--- /dev/null
+++ b/shopify/scripts/tk135-apply-normalize.js
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+/**
+ * TK-00135 — Apply color-tag normalize (GATED live write, Shopify-authoritative).
+ * Reads data/tk135/normalize-worklist.tsv: <gid>\t<removeCSV>\t<addCSV>\t<paletteJsonOrEmpty>
+ * Per product: tagsRemove(prefixed color: tags) + tagsAdd(bare values) + (if palette) metafieldsSet custom.color_palette.
+ * Converts every color:/Color: tag to a bare value catalog-wide. Metafield holds the pure hex+% data.
+ *
+ * DRY-RUN by default; --apply required. --limit N, --report FILE, --progress FILE (resume).
+ * Idempotent, resumable (progress log), never flips status, only touches color: tags + our metafield.
+ * Mirror re-syncs from Shopify (Shopify is authoritative).
+ *
+ * node tk135-apply-normalize.js # DRY-RUN
+ * node tk135-apply-normalize.js --apply --limit 20 # canary (gated)
+ * node tk135-apply-normalize.js --apply --report data/tk135/normalize-run.json
+ */
+const https = require('https'), fs = require('fs'), os = require('os'), path = require('path');
+const args = process.argv.slice(2);
+const APPLY = args.includes('--apply');
+const arg = (k, d) => { const i = args.indexOf(k); return i >= 0 ? args[i + 1] : d; };
+const LIMIT = parseInt(arg('--limit', '0'), 10) || Infinity;
+const IN = arg('--in', path.join(__dirname, 'data/tk135/normalize-worklist.tsv'));
+const REPORT = arg('--report', null);
+const PROGRESS = arg('--progress', path.join(__dirname, 'data/tk135/normalize-progress.log'));
+
+const env = fs.readFileSync(os.homedir() + '/Projects/secrets-manager/.env', 'utf8');
+const TOKEN = (env.match(/^SHOPIFY_ADMIN_TOKEN=(.*)$/m) || [])[1].replace(/['"]/g, '').trim();
+const STORE = 'designer-laboratory-sandbox.myshopify.com', API = '2024-10';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function gql(query, variables = {}) {
+ return new Promise((resolve, reject) => {
+ const body = JSON.stringify({ query, variables });
+ const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST',
+ headers: { 'Content-Type': 'application/json', 'X-Shopify-Access-Token': TOKEN, 'Content-Length': Buffer.byteLength(body) } },
+ r => { let d = ''; r.on('data', c => d += c); r.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(d); } }); });
+ req.on('error', reject); req.write(body); req.end();
+ });
+}
+async function gqlR(q, v, tries = 4) {
+ for (let i = 0; i < tries; i++) {
+ const r = await gql(q, v);
+ if (r && !r.errors) return r;
+ if (!(r.errors || []).some(e => /throttl/i.test(JSON.stringify(e)))) return r;
+ await sleep(1500 * (i + 1));
+ }
+ return gql(q, v);
+}
+const M_MF = `mutation($mf:[MetafieldsSetInput!]!){metafieldsSet(metafields:$mf){userErrors{field message}}}`;
+const M_ADD = `mutation($id:ID!,$tags:[String!]!){tagsAdd(id:$id,tags:$tags){userErrors{field message}}}`;
+const M_DEL = `mutation($id:ID!,$tags:[String!]!){tagsRemove(id:$id,tags:$tags){userErrors{field message}}}`;
+
+function load() {
+ const out = [];
+ for (const l of fs.readFileSync(IN, 'utf8').trim().split('\n')) {
+ const [gid, rm, ad, pj] = l.split('\t');
+ if (!gid) continue;
+ out.push({ gid, remove: rm ? rm.split('|').filter(Boolean) : [], add: ad ? ad.split('|').filter(Boolean) : [], palette: pj || '' });
+ }
+ return out;
+}
+
+(async () => {
+ let rows = load();
+ if (fs.existsSync(PROGRESS)) {
+ const done = new Set(fs.readFileSync(PROGRESS, 'utf8').split('\n').filter(Boolean));
+ const b = rows.length; rows = rows.filter(r => !done.has(r.gid));
+ if (b !== rows.length) console.log(`resume: skipping ${b - rows.length} already-done\n`);
+ }
+ if (LIMIT !== Infinity) rows = rows.slice(0, LIMIT);
+ console.log(`tk135-apply-normalize — ${rows.length} products — ${APPLY ? '🔴 LIVE' : 'DRY-RUN'}\n in: ${IN}\n (Shopify-authoritative; dw_unified mirror re-syncs)\n`);
+ const report = { ticket: 'TK-135', op: 'normalize-color-tags', started: new Date().toISOString(), apply: APPLY, updated: [], failed: [] };
+
+ if (!APPLY) {
+ rows.slice(0, 10).forEach(r => console.log(` · ${r.gid.split('/').pop()} -[${r.remove.join(', ')}] +[${r.add.join(', ')}]${r.palette ? ' +metafield' : ''}`));
+ console.log(`\n (dry-run) no writes. ${rows.length} products would be normalized.`);
+ if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify({ ...report, totals: { would_update: rows.length } }, null, 2)); }
+ return;
+ }
+
+ for (const r of rows) {
+ let errs = [];
+ if (r.palette) { const mf = await gqlR(M_MF, { mf: [{ ownerId: r.gid, namespace: 'custom', key: 'color_palette', type: 'json', value: r.palette }] }); errs = errs.concat(mf.data?.metafieldsSet?.userErrors || []); }
+ if (r.remove.length) { const d = await gqlR(M_DEL, { id: r.gid, tags: r.remove }); errs = errs.concat(d.data?.tagsRemove?.userErrors || []); }
+ if (r.add.length) { const a = await gqlR(M_ADD, { id: r.gid, tags: r.add }); errs = errs.concat(a.data?.tagsAdd?.userErrors || []); }
+ if (errs.length) { report.failed.push({ gid: r.gid, errs }); console.log(` ❌ ${r.gid.split('/').pop()} ${JSON.stringify(errs)}`); }
+ else { report.updated.push(r.gid); fs.appendFileSync(PROGRESS, r.gid + '\n'); }
+ if (report.updated.length % 500 === 0 && report.updated.length) console.log(` …${report.updated.length} normalized`);
+ await sleep(150);
+ }
+ report.finished = new Date().toISOString();
+ report.totals = { updated: report.updated.length, failed: report.failed.length };
+ console.log(`\n=== SUMMARY ===\nnormalized=${report.totals.updated} failed=${report.totals.failed}`);
+ if (REPORT) { fs.mkdirSync(path.dirname(REPORT), { recursive: true }); fs.writeFileSync(REPORT, JSON.stringify(report, null, 2)); console.log(`report -> ${REPORT}`); }
+})();
diff --git a/shopify/scripts/tk135-build-normalize-worklist.js b/shopify/scripts/tk135-build-normalize-worklist.js
new file mode 100644
index 00000000..ecd28cff
--- /dev/null
+++ b/shopify/scripts/tk135-build-normalize-worklist.js
@@ -0,0 +1,53 @@
+#!/usr/bin/env node
+/**
+ * TK-00135 — Build the color-tag normalize worklist (read-only, no writes).
+ * Per ACTIVE product: strip every `color:` / `Color: ` prefixed tag to its bare value,
+ * plus fold in our image-derived bare color + palette metafield.
+ * Emits: <gid>\t<removeCSV>\t<addCSV>\t<paletteJsonOrEmpty>
+ * removeCSV = prefixed color tags to remove (|-separated)
+ * addCSV = bare color values to add (|-separated)
+ * Sources: data/tk135/active-tags.tsv (gid\ttags) + data/tk135/palette-proposed.tsv (gid\tcolor:Bucket\tjson)
+ */
+const fs = require('fs'), path = require('path');
+const DIR = path.join(__dirname, 'data/tk135');
+const OUT = path.join(DIR, 'normalize-worklist.tsv');
+
+// palette map: gid -> {bucket, palette}
+const pal = {};
+for (const l of fs.readFileSync(path.join(DIR, 'palette-proposed.tsv'), 'utf8').trim().split('\n')) {
+ const [gid, tag, pj] = l.split('\t');
+ if (gid && tag && pj && !pj.startsWith('ERR:')) pal[gid] = { bucket: tag.replace(/^\s*[Cc]olor:\s*/, '').trim(), palette: pj };
+}
+
+const isColor = t => /^\s*[Cc]olor:\s*/.test(t);
+const bareOf = t => t.replace(/^\s*[Cc]olor:\s*/, '').trim();
+
+let out = [], nProducts = 0, nRemoved = 0, nAdded = 0, distinctBare = {};
+for (const l of fs.readFileSync(path.join(DIR, 'active-tags.tsv'), 'utf8').trim().split('\n')) {
+ const i = l.indexOf('\t'); if (i < 0) continue;
+ const gid = l.slice(0, i), tagsRaw = l.slice(i + 1);
+ const tags = tagsRaw.split(',').map(s => s.trim()).filter(Boolean);
+ const prefixed = tags.filter(isColor);
+ const p = pal[gid];
+ if (!prefixed.length && !p) continue; // nothing to do
+ const removeSet = [...new Set(prefixed)];
+ const bareSet = new Set(prefixed.map(bareOf).filter(Boolean));
+ if (p && p.bucket) bareSet.add(p.bucket); // our image-derived bare color
+ // don't "add" a bare value that is already present un-prefixed
+ const existingBare = new Set(tags.filter(t => !isColor(t)));
+ const addSet = [...bareSet].filter(b => !existingBare.has(b));
+ if (!removeSet.length && !addSet.length && !p) continue;
+ nProducts++; nRemoved += removeSet.length; nAdded += addSet.length;
+ addSet.forEach(b => distinctBare[b] = (distinctBare[b] || 0) + 1);
+ out.push([gid, removeSet.join('|'), addSet.join('|'), p ? p.palette : ''].join('\t'));
+}
+fs.writeFileSync(OUT, out.join('\n') + '\n');
+const top = Object.entries(distinctBare).sort((a, b) => b[1] - a[1]).slice(0, 20);
+console.log(`normalize worklist -> ${OUT}`);
+console.log(` products to touch: ${nProducts}`);
+console.log(` prefixed color: tags to REMOVE: ${nRemoved}`);
+console.log(` bare color tags to ADD: ${nAdded}`);
+console.log(` top bare values added:`);
+top.forEach(([k, v]) => console.log(` ${String(v).padStart(6)} ${k}`));
+console.log(`\n 8 sample rows (gid | remove | add):`);
+out.slice(0, 8).forEach(r => { const [g, rm, ad] = r.split('\t'); console.log(` ${g.split('/').pop()} remove[${rm}] add[${ad}]`); });
← 1ad1dff4 TK-135: BARE-VALUE color tags (no color: prefix) + self-heal
·
back to Designer Wallcoverings
·
TK-135: comma-separate tags in normalize worklist (not pipe) 35de4b03 →