[object Object]

← back to Rebel Walls Push

add retitle.js: title-only bulk update inserting " Wallcoverings", resumable, idempotent

6401830f67e9114c46cf8627047f1342657a44f7 · 2026-06-04 08:01:29 -0700 · SteveStudio2

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

Files touched

Diff

commit 6401830f67e9114c46cf8627047f1342657a44f7
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Thu Jun 4 08:01:29 2026 -0700

    add retitle.js: title-only bulk update inserting " Wallcoverings", resumable, idempotent
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/retitle.js | 149 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 149 insertions(+)

diff --git a/scripts/retitle.js b/scripts/retitle.js
new file mode 100644
index 0000000..73b8e25
--- /dev/null
+++ b/scripts/retitle.js
@@ -0,0 +1,149 @@
+#!/usr/bin/env node
+'use strict';
+/*
+ * Rebel Walls TITLE-ONLY bulk update — insert " Wallcoverings" before " | Rebel Walls".
+ * Steve-authorized 2026-06-04: "Add the word Wallcoverings before the ' | Rebel Walls'
+ * for all rebel walls."
+ *
+ * Runs LAST, after the sibling activation pass (activate.js) is done, to avoid racing
+ * concurrent writes to the same ~500 Shopify products. Touches ONLY the title field via
+ * productUpdate — never status / inventory / publications / variants / metafields.
+ *
+ * For every rebelwalls_catalog row that HAS a checkpointed shopify_product_id:
+ *   - compute the canonical NEW title via the same buildTitle() the pusher now uses
+ *   - if Shopify's current title already equals it -> skip (idempotent)
+ *   - else productUpdate { id, title } only
+ *
+ * Resumable: checkpoint column rebelwalls_catalog.title_v2_at (added if missing).
+ * Failures -> data/retitle-failures.json. Progress -> data/retitle-progress.jsonl.
+ *
+ * Usage:
+ *   node scripts/retitle.js --canary 5
+ *   node scripts/retitle.js --all
+ *   node scripts/retitle.js --all --limit 100
+ *   node scripts/retitle.js --dry-run --all
+ *   node scripts/retitle.js --all --redo      # ignore title_v2_at, recheck all
+ */
+const fs = require('fs');
+const path = require('path');
+const { gqlRetry, psql, sleep } = require('./_shop.js');
+
+const VENDOR = 'Rebel Walls';
+const LOG_DIR = path.join(__dirname, '..', 'data');
+const PROG_LOG = path.join(LOG_DIR, 'retitle-progress.jsonl');
+
+const args = process.argv.slice(2);
+const flag = n => args.includes(n);
+const val = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
+const CANARY = val('--canary', null);
+const DO_ALL = flag('--all');
+const LIMIT = val('--limit', null);
+const REDO = flag('--redo');
+const DRY = flag('--dry-run');
+
+// ---- title builder: VERBATIM from push.js (keep in sync) -----------------
+function titleCase(s) {
+  if (!s) return s;
+  const minor = new Set(['of','and','the','in','on','for','a','an','to','with']);
+  const words = s.split(/(\s+|-)/);
+  let wi = 0;
+  return words.map(tok => {
+    if (/^\s+$/.test(tok) || tok === '-') return tok;
+    const first = wi === 0;
+    wi++;
+    if (/\d/.test(tok) || /[A-Z]/.test(tok.slice(1)) || tok === tok.toUpperCase() && tok.length <= 4 && /[A-Z]/.test(tok)) {
+      return tok.replace(/^(\d+)([a-z])/, (m, d, l) => d + l.toUpperCase());
+    }
+    const low = tok.toLowerCase();
+    if (!first && minor.has(low)) return low;
+    return low.charAt(0).toUpperCase() + low.slice(1);
+  }).join('');
+}
+function buildTitle(row) {
+  let pattern = (row.pattern_name || '').trim();
+  let color = (row.color_name || '').trim();
+  if (pattern) pattern = titleCase(pattern);
+  if (color && !/unknown/i.test(color)) color = titleCase(color); else color = '';
+  let core = color ? `${pattern}, ${color}` : pattern;
+  if (!core) core = row.mfr_sku || row.dw_sku;
+  return `${core} Wallcoverings | ${VENDOR}`.replace(/wallpaper/gi, 'Wallcovering');
+}
+
+// ---- pg -----------------------------------------------------------------
+function ensureColumn() {
+  psql(`ALTER TABLE rebelwalls_catalog ADD COLUMN IF NOT EXISTS title_v2_at timestamptz;`);
+}
+function fetchRows() {
+  let where = "shopify_product_id IS NOT NULL AND shopify_product_id <> ''";
+  if (!REDO) where += ' AND title_v2_at IS NULL';
+  let lim = '';
+  if (CANARY) lim = `LIMIT ${parseInt(CANARY, 10)}`;
+  else if (LIMIT) lim = `LIMIT ${parseInt(LIMIT, 10)}`;
+  const cols = ['id','mfr_sku','dw_sku','pattern_name','color_name','shopify_product_id'];
+  const raw = psql(`SELECT ${cols.join(',')} FROM rebelwalls_catalog WHERE ${where} ORDER BY id ${lim};`).trim();
+  if (!raw) return [];
+  return raw.split('\n').map(line => {
+    const f = line.split('\t');
+    const o = {}; cols.forEach((c, i) => { o[c] = f[i] === '' ? null : f[i]; });
+    return o;
+  });
+}
+function markDone(pgId) {
+  psql(`UPDATE rebelwalls_catalog SET title_v2_at = NOW() WHERE id = ${parseInt(pgId, 10)};`);
+}
+
+// ---- graphql: title-only ------------------------------------------------
+const Q_TITLE = `query($id:ID!){ product(id:$id){ id title } }`;
+const M_TITLE = `mutation($input:ProductInput!){
+  productUpdate(input:$input){ product{ id title } userErrors{ field message } }
+}`;
+
+async function retitleRow(row) {
+  const gid = `gid://shopify/Product/${row.shopify_product_id}`;
+  const want = buildTitle(row);
+  const pr = await gqlRetry(Q_TITLE, { id: gid }, 'get-title');
+  const prod = pr.json.data && pr.json.data.product;
+  if (!prod) throw new Error(`product ${row.shopify_product_id} not found`);
+  if (prod.title === want) return { skipped: true, title: want };
+  if (DRY) return { dry: true, from: prod.title, to: want };
+  const r = await gqlRetry(M_TITLE, { input: { id: gid, title: want } }, 'set-title');
+  const d = r.json.data && r.json.data.productUpdate;
+  const ue = d && d.userErrors;
+  if (ue && ue.length) throw new Error('userErrors: ' + JSON.stringify(ue));
+  return { updated: true, from: prod.title, to: d.product.title };
+}
+
+(async () => {
+  if (!CANARY && !DO_ALL) { console.error('specify --canary N | --all'); process.exit(1); }
+  ensureColumn();
+  const rows = fetchRows();
+  console.log(`[retitle] ${rows.length} pushed rows to retitle (dry=${DRY}, redo=${REDO})`);
+  if (!rows.length) { console.log('nothing to do'); return; }
+
+  let updated = 0, skipped = 0, fail = 0;
+  const failures = [];
+  for (let i = 0; i < rows.length; i++) {
+    const row = rows[i];
+    try {
+      const res = await retitleRow(row);
+      if (res.skipped) { skipped++; if (!DRY) markDone(row.id); }
+      else if (res.dry) { console.log(`DRY [${row.id}] ${row.dw_sku}\n   from: ${res.from}\n     to: ${res.to}`); }
+      else { updated++; markDone(row.id);
+        fs.appendFileSync(PROG_LOG, JSON.stringify({ ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, pid: row.shopify_product_id, title: res.to }) + '\n');
+      }
+      if (!DRY && (updated + skipped) % 25 === 0)
+        console.log(`  progress ${updated + skipped}/${rows.length} (updated ${updated}, skipped ${skipped})`);
+    } catch (e) {
+      fail++;
+      failures.push({ pg_id: row.id, dw_sku: row.dw_sku, pid: row.shopify_product_id, error: e.message });
+      console.error(`  FAIL [${row.id}] ${row.dw_sku}: ${e.message}`);
+      fs.appendFileSync(PROG_LOG, JSON.stringify({ ts: new Date().toISOString(), pg_id: row.id, dw_sku: row.dw_sku, error: e.message }) + '\n');
+    }
+    if (!DRY) await sleep(300);
+  }
+  console.log(`\n[retitle] DONE updated=${updated} skipped=${skipped} fail=${fail}`);
+  if (failures.length) {
+    fs.writeFileSync(path.join(LOG_DIR, 'retitle-failures.json'), JSON.stringify(failures, null, 2));
+    console.log('  failures -> data/retitle-failures.json');
+  }
+})().catch(e => { console.error('FATAL', e); process.exit(1); });

← de60df5 rebel walls title: insert " Wallcoverings" before " | Rebel  ·  back to Rebel Walls Push  ·  Rebel Walls: bake live-on-create (ACTIVE + all channels + in c54afd5 →