[object Object]

← back to Rebel Walls Push

TK-10029: retarget relabel to canonical 'Mural (per m²)' (DTD Option A, 7/7)

be208a1fe5a562f866650ddd3863e69f7f3bbce1 · 2026-08-10 09:44:52 -0700 · Steve Abrams

- CORRECT_LABEL now 'Mural (per m²)' — the label the live importer push.js already mints
- converge legacy roll/bolt/complete-mural + interim 'Sold Per Square Meter' + mojibake onto it
- robust matcher isWrongMuralLabel() catches corrupted 'Mural (per m<?>)'
- NON-DESTRUCTIVE: renames only, never deletes a variant, never creates a duplicate canonical
- collision/orphan cases (canonical already present + Default Title) FLAGGED for manual review, not auto-modified
- dry-run: 1467 renames, 0 deletes, 12 review; still --apply-gated (live customer-facing write)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit be208a1fe5a562f866650ddd3863e69f7f3bbce1
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Aug 10 09:44:52 2026 -0700

    TK-10029: retarget relabel to canonical 'Mural (per m²)' (DTD Option A, 7/7)
    
    - CORRECT_LABEL now 'Mural (per m²)' — the label the live importer push.js already mints
    - converge legacy roll/bolt/complete-mural + interim 'Sold Per Square Meter' + mojibake onto it
    - robust matcher isWrongMuralLabel() catches corrupted 'Mural (per m<?>)'
    - NON-DESTRUCTIVE: renames only, never deletes a variant, never creates a duplicate canonical
    - collision/orphan cases (canonical already present + Default Title) FLAGGED for manual review, not auto-modified
    - dry-run: 1467 renames, 0 deletes, 12 review; still --apply-gated (live customer-facing write)
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/relabel-units.js | 111 ++++++++++++++++++++++++++++++-----------------
 1 file changed, 70 insertions(+), 41 deletions(-)

diff --git a/scripts/relabel-units.js b/scripts/relabel-units.js
index 6224a95..59d273a 100644
--- a/scripts/relabel-units.js
+++ b/scripts/relabel-units.js
@@ -32,13 +32,28 @@ 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; })();
 
+// TK-10029 / DTD 2026-08-10: canonical mural unit label = "Mural (per m²)" (Option A,
+// unanimous 7/7). This is what the live importer push.js already mints, so we converge
+// every OTHER mural-unit label onto it — including the legacy roll/bolt labels AND the
+// interim "Sold Per Square Meter" cohort AND any mojibake-corrupted "Mural (per m<?>)".
+const CORRECT_LABEL = 'Mural (per m²)';
+
 // 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',
+  'sold per square meter', 'sold per square metre',
 ]);
 
-const CORRECT_LABEL = 'Sold Per Square Meter';
+// Robust matcher: catches the fixed set above AND any corrupted "Mural (per m…)" variant
+// (e.g. the mojibake "Mural (per m�)") that is NOT already the exact canonical.
+function isWrongMuralLabel(value) {
+  const lower = value.toLowerCase();
+  if (WRONG_MURAL_LABELS.has(lower)) return true;
+  // mojibake / near-miss of the canonical: starts like "mural (per m" but isn't exact
+  if (lower.startsWith('mural (per m') && value !== CORRECT_LABEL) return true;
+  return false;
+}
 
 async function gql(query, vars) {
   return new Promise((resolve, reject) => {
@@ -94,70 +109,81 @@ function needsFix(product) {
   if (isSampleProduct(product)) return false;
   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;
+      if (isWrongMuralLabel(o.value)) 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'
-  );
+  // Find the option that actually carries a wrong mural label (robust — not name-bound).
+  const optionToFix =
+    product.options.find(o => o.values.some(v => isWrongMuralLabel(v))) ||
+    product.options.find(o => o.name === 'Size' || o.name === 'Title');
   if (!optionToFix) {
-    console.log(`  SKIP ${product.id} — no Size/Title option found`);
+    console.log(`  SKIP ${product.id} — no mural 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;
+  const wrongValues = optionToFix.values.filter(v => isWrongMuralLabel(v));
+  if (!wrongValues.length) { console.log(`  SKIP ${product.title} — already correct`); return false; }
+
+  const canonicalAlreadyPresent = optionToFix.values.includes(CORRECT_LABEL);
+
+  // NON-DESTRUCTIVE plan (renames only — never deletes a live variant, never creates a
+  // duplicate canonical). "Default Title" is ambiguous: sometimes it IS the mural variant,
+  // sometimes a leftover orphan. So:
+  //   - if the canonical is already on the option → the unit label is ALREADY correct;
+  //     leave the product untouched and just FLAG any redundant wrong value for review.
+  //   - else rename exactly ONE mural-unit value to canonical (prefer a real unit label
+  //     over generic "Default Title"); FLAG any additional wrong values for review rather
+  //     than renaming them (which would duplicate) or deleting them (destructive).
+  const nonDefault = wrongValues.filter(v => v.toLowerCase() !== 'default title');
+  const primary = nonDefault[0] || wrongValues[0];
+
+  const toRename = canonicalAlreadyPresent ? [] : [primary];
+  const toReview = canonicalAlreadyPresent ? wrongValues : wrongValues.filter(v => v !== primary);
+
+  if (!toRename.length) {
+    // Unit label already canonical — not a unit-label defect. Report the orphan, don't touch.
+    console.log(`  REVIEW ${product.title} — canonical already present; redundant value(s) ${JSON.stringify(toReview)} left for manual cleanup (no auto-delete)`);
+    return 'review';
   }
 
   console.log(`  FIX ${product.title}`);
-  console.log(`    Option "${optionToFix.name}": ${JSON.stringify(optionToFix.values)} → ${JSON.stringify(deduped)}`);
+  console.log(`    Option "${optionToFix.name}": ${JSON.stringify(optionToFix.values)}`);
+  console.log(`      RENAME ["${primary}"] → "${CORRECT_LABEL}"`);
+  if (toReview.length) console.log(`      REVIEW leftover ${JSON.stringify(toReview)} — NOT modified (avoids destructive delete / duplicate); flag for manual cleanup`);
 
   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
+  // Fetch option value IDs (values themselves have no id in the list query).
   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()))
+  const optionValuesToUpdate = opt.optionValues
+    .filter(ov => ov.name === primary)
     .map(ov => ({ id: ov.id, name: CORRECT_LABEL }));
 
-  if (!valUpdates.length) { console.log(`  SKIP already done`); return false; }
+  if (!optionValuesToUpdate.length) { console.log(`  SKIP already done`); return false; }
+
+  const mutation = `
+    mutation UpdateOption($productId: ID!, $option: OptionUpdateInput!,
+                          $optionValuesToUpdate: [OptionValueUpdateInput!]!) {
+      productOptionUpdate(productId: $productId, option: $option,
+                          optionValuesToUpdate: $optionValuesToUpdate) {
+        product { id }
+        userErrors { field message }
+      }
+    }
+  `;
 
   const res = await gql(mutation, {
     productId: product.id,
     option: { id: optionToFix.id, name: optionToFix.name },
-    optionValuesToUpdate: valUpdates,
+    optionValuesToUpdate,
   });
 
   const errs = res.data?.productOptionUpdate?.userErrors;
@@ -191,12 +217,15 @@ async function relabelProduct(product) {
 
   if (!toFix.length) { console.log('[relabel-units] nothing to fix — done'); return; }
 
-  let fixed = 0, skipped = 0;
+  let fixed = 0, skipped = 0, review = 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++; }
+    if (did === 'review') { review++; }
+    else if (did) { fixed++; await sleep(DRY ? 0 : 500); }
+    else { skipped++; }
   }
 
-  console.log(`[relabel-units] done. fixed=${fixed} skipped=${skipped} dry=${DRY}`);
+  console.log(`[relabel-units] done. fixed=${fixed} skipped=${skipped} review=${review} dry=${DRY}`);
+  if (review) console.log(`[relabel-units] ${review} products FLAGGED for manual review (canonical already present + a redundant orphan value; not auto-modified). See REVIEW lines above.`);
 })().catch(e => { console.error('[relabel-units] FATAL', e.message); process.exit(1); });

← 09027cf relabel-units: skip sample products from Roll→SqMeter relabe  ·  back to Rebel Walls Push  ·  TK-10029: durable pre-apply rollback ledger (1467 renames + 6880860 →