← 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); });