← back to Rebel Walls Push
scripts/retitle.js
150 lines
#!/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); });