[object Object]

← back to Rebel Walls Push

TK-10029: add relabel-units.js — dry-run verified, 454 products need unit relabel

5f49d7065e88d9378059a00039735fac2d38fae7 · 2026-07-29 04:20:48 -0700 · steve@designerwallcoverings.com

Files touched

Diff

commit 5f49d7065e88d9378059a00039735fac2d38fae7
Author: steve@designerwallcoverings.com <steve@designerwallcoverings.com>
Date:   Wed Jul 29 04:20:48 2026 -0700

    TK-10029: add relabel-units.js — dry-run verified, 454 products need unit relabel
---
 scripts/relabel-units.js | 195 +++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 195 insertions(+)

diff --git a/scripts/relabel-units.js b/scripts/relabel-units.js
new file mode 100644
index 0000000..e9917b5
--- /dev/null
+++ b/scripts/relabel-units.js
@@ -0,0 +1,195 @@
+#!/usr/bin/env node
+/**
+ * TK-10029: Rebel Walls mural unit relabel.
+ * Finds products with wrong variant labels ("Roll", "Single Roll", "Sold Per Bolt ...",
+ * "Complete Mural", or "Default Title" on the mural variant) and relabels them to
+ * "Sold Per Square Meter".
+ *
+ * Dry-run by default. Pass --apply to execute.
+ * Logs every action. Safe to re-run (idempotent — skips already-correct products).
+ *
+ * Usage:
+ *   node scripts/relabel-units.js             # dry-run, full audit
+ *   node scripts/relabel-units.js --apply     # live Shopify writes
+ *   node scripts/relabel-units.js --limit 10  # cap actions
+ */
+const https = require('https');
+const fs = require('fs');
+
+const SECRETS_ENV = '/Users/macstudio3/Projects/secrets-manager/.env';
+const DOMAIN = 'designer-laboratory-sandbox.myshopify.com';
+const API_VERSION = '2024-10';
+
+function getToken() {
+  const env = fs.readFileSync(SECRETS_ENV, 'utf8');
+  const m = env.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+  if (!m) throw new Error('SHOPIFY_ADMIN_TOKEN not found');
+  return m[1].trim();
+}
+const TOKEN = getToken();
+
+const args = process.argv.slice(2);
+const DRY = !args.includes('--apply');
+const LIMIT = (() => { const i = args.indexOf('--limit'); return i >= 0 ? parseInt(args[i+1]) : Infinity; })();
+
+// Values that indicate "this is the mural variant but has the wrong unit label"
+const WRONG_MURAL_LABELS = new Set([
+  'roll', 'single roll', 'sold per roll', 'complete mural',
+  'sold per bolt (20.5in x 33ft)', 'default title',
+]);
+
+const CORRECT_LABEL = 'Sold Per Square Meter';
+
+async function gql(query, vars) {
+  return new Promise((resolve, reject) => {
+    const body = JSON.stringify({ query, variables: vars || {} });
+    const req = https.request({
+      hostname: DOMAIN, path: `/admin/api/${API_VERSION}/graphql.json`,
+      method: 'POST',
+      headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
+    }, res => {
+      let d = '';
+      res.on('data', c => d += c);
+      res.on('end', () => { try { resolve(JSON.parse(d)); } catch(e) { reject(e); } });
+    });
+    req.on('error', reject);
+    req.write(body);
+    req.end();
+  });
+}
+
+async function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
+
+async function fetchAll() {
+  const products = [];
+  let cursor = null;
+  while (true) {
+    const q = `query($after: String) {
+      products(first: 250, query: "vendor:Rebel Walls", after: $after) {
+        pageInfo { hasNextPage endCursor }
+        edges { node {
+          id title
+          options { id name values }
+          variants(first: 10) { edges { node { id sku title selectedOptions { name value } } } }
+        } }
+      }
+    }`;
+    const r = await gql(q, { after: cursor });
+    const page = r.data.products;
+    for (const e of page.edges) products.push(e.node);
+    if (!page.pageInfo.hasNextPage) break;
+    cursor = page.pageInfo.endCursor;
+    await sleep(300);
+  }
+  return products;
+}
+
+function needsFix(product) {
+  for (const v of product.variants.edges.map(e => e.node)) {
+    for (const o of v.selectedOptions) {
+      if (WRONG_MURAL_LABELS.has(o.value.toLowerCase())) return true;
+    }
+  }
+  return false;
+}
+
+async function relabelProduct(product) {
+  // Find the mural option (not Sample) and relabel its value
+  const optionToFix = product.options.find(o =>
+    o.name === 'Size' || o.name === 'Title'
+  );
+  if (!optionToFix) {
+    console.log(`  SKIP ${product.id} — no Size/Title option found`);
+    return false;
+  }
+
+  // Build new values: replace wrong mural labels with CORRECT_LABEL
+  const newValues = optionToFix.values.map(v =>
+    WRONG_MURAL_LABELS.has(v.toLowerCase()) ? CORRECT_LABEL : v
+  );
+  // Deduplicate (e.g. if both "Roll" and "Sold Per Square Meter" existed)
+  const deduped = [...new Set(newValues)];
+
+  if (JSON.stringify(deduped) === JSON.stringify(optionToFix.values)) {
+    console.log(`  SKIP ${product.title} — already correct`);
+    return false;
+  }
+
+  console.log(`  FIX ${product.title}`);
+  console.log(`    Option "${optionToFix.name}": ${JSON.stringify(optionToFix.values)} → ${JSON.stringify(deduped)}`);
+
+  if (DRY) return true;
+
+  // Use productOptionUpdate to rename the option values
+  const mutation = `
+    mutation UpdateOption($productId: ID!, $option: OptionUpdateInput!, $optionValuesToUpdate: [OptionValueUpdateInput!]!) {
+      productOptionUpdate(productId: $productId, option: $option, optionValuesToUpdate: $optionValuesToUpdate) {
+        product { id }
+        userErrors { field message }
+      }
+    }
+  `;
+
+  // Build optionValuesToUpdate: only the ones that need changing
+  const updates = optionToFix.values
+    .map((v, i) => ({ id: null, name: v, newName: deduped[i] }))
+    .filter(u => u.name !== u.newName);
+
+  // We need option value IDs — fetch them
+  const detailQ = `query { product(id: "${product.id}") { options { id name optionValues { id name } } } }`;
+  const detail = await gql(detailQ);
+  const opt = detail.data.product.options.find(o => o.id === optionToFix.id);
+  if (!opt) { console.log(`  ERR can't fetch option values for ${product.id}`); return false; }
+
+  const valUpdates = opt.optionValues
+    .filter(ov => WRONG_MURAL_LABELS.has(ov.name.toLowerCase()))
+    .map(ov => ({ id: ov.id, name: CORRECT_LABEL }));
+
+  if (!valUpdates.length) { console.log(`  SKIP already done`); return false; }
+
+  const res = await gql(mutation, {
+    productId: product.id,
+    option: { id: optionToFix.id, name: optionToFix.name },
+    optionValuesToUpdate: valUpdates,
+  });
+
+  const errs = res.data?.productOptionUpdate?.userErrors;
+  if (errs && errs.length) {
+    console.log(`  ERR ${product.id}: ${errs.map(e => e.message).join('; ')}`);
+    return false;
+  }
+  return true;
+}
+
+(async () => {
+  console.log(`[relabel-units] mode=${DRY ? 'DRY-RUN' : 'LIVE'} limit=${LIMIT === Infinity ? 'none' : LIMIT}`);
+  console.log('[relabel-units] fetching all Rebel Walls products...');
+  const all = await fetchAll();
+  console.log(`[relabel-units] fetched ${all.length} products`);
+
+  const toFix = all.filter(needsFix);
+  console.log(`[relabel-units] ${toFix.length} products need relabel`);
+
+  const counts = {};
+  for (const p of all) {
+    for (const v of p.variants.edges.map(e => e.node)) {
+      for (const o of v.selectedOptions) {
+        counts[o.value] = (counts[o.value] || 0) + 1;
+      }
+    }
+  }
+  const mural = Object.entries(counts).filter(([k]) => k !== 'Sample' && k !== 'Default Title').sort((a,b) => b[1]-a[1]);
+  console.log('[relabel-units] current mural variant value distribution:');
+  for (const [k, v] of mural) console.log(`  ${v.toString().padStart(5)}  "${k}"`);
+
+  if (!toFix.length) { console.log('[relabel-units] nothing to fix — done'); return; }
+
+  let fixed = 0, skipped = 0;
+  for (const p of toFix) {
+    if (fixed >= LIMIT) { console.log(`[relabel-units] hit limit ${LIMIT}`); break; }
+    const did = await relabelProduct(p);
+    if (did) { fixed++; await sleep(DRY ? 0 : 500); } else { skipped++; }
+  }
+
+  console.log(`[relabel-units] done. fixed=${fixed} skipped=${skipped} dry=${DRY}`);
+})().catch(e => { console.error('[relabel-units] FATAL', e.message); process.exit(1); });

← 2f475a9 auto-save: 2026-07-21T13:37:45 (1 files) — scripts/push.js.b  ·  back to Rebel Walls Push  ·  relabel-units: skip sample products from Roll→SqMeter relabe 09027cf →