← 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
M scripts/relabel-units.js
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 →