[object Object]

← back to Designerwallcoverings

Sanderson TK-11070 batch-remediation + rollback scripts

d3e15771f37c2a72a5445cb61c162f999a30adea · 2026-09-01 10:53:19 -0700 · Steve Abrams

tk11070-remediate.mjs: joins live Shopify Sanderson products to the fixed-importer
payloads by mfr SKU; surgically fixes color: tags (preserves ALL non-color tags per
spec), strips cross-pattern plate media (keeps hero + own-SAW shots), repairs the X&Y
colorway split, skips malformed/unresolvable + SAF fabrics (out of scope), records a
full rollback map to /tmp/tk11070-rollback.json, PG-first (text[]) then Shopify, 90s
inter-batch gaps, ledgers each write. tk11070-rollback.mjs restores tags+media+PG.

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

Files touched

Diff

commit d3e15771f37c2a72a5445cb61c162f999a30adea
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Sep 1 10:53:19 2026 -0700

    Sanderson TK-11070 batch-remediation + rollback scripts
    
    tk11070-remediate.mjs: joins live Shopify Sanderson products to the fixed-importer
    payloads by mfr SKU; surgically fixes color: tags (preserves ALL non-color tags per
    spec), strips cross-pattern plate media (keeps hero + own-SAW shots), repairs the X&Y
    colorway split, skips malformed/unresolvable + SAF fabrics (out of scope), records a
    full rollback map to /tmp/tk11070-rollback.json, PG-first (text[]) then Shopify, 90s
    inter-batch gaps, ledgers each write. tk11070-rollback.mjs restores tags+media+PG.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/sanderson-onboard/tk11070-remediate.mjs | 295 ++++++++++++++++++++++++
 scripts/sanderson-onboard/tk11070-rollback.mjs  |  65 ++++++
 2 files changed, 360 insertions(+)

diff --git a/scripts/sanderson-onboard/tk11070-remediate.mjs b/scripts/sanderson-onboard/tk11070-remediate.mjs
new file mode 100644
index 0000000..2be408b
--- /dev/null
+++ b/scripts/sanderson-onboard/tk11070-remediate.mjs
@@ -0,0 +1,295 @@
+#!/usr/bin/env node
+/**
+ * TK-11070 — batch-correct LIVE Sanderson products: strip wrong color: tags + AI-palette strays,
+ * set the correct color:<own colorway>, and strip cross-pattern plate images (keep hero + own-SAW shots).
+ *
+ * Source of truth for the CORRECT tag/image set = out/payloads.jsonl (built by the fixed
+ * build-payloads.mjs, TK-11070 commit 349d204). We join LIVE Shopify Sanderson products to that
+ * payload by manufacturer SKU, compute the diff, record a full rollback map, and apply PG-first→Shopify.
+ *
+ * PG-first: trim sanderson_catalog.gallery_images to the kept set (saving old array in the rollback map).
+ * Shopify (authoritative): productUpdate tags = corrected set; productDeleteMedia for foreign-plate media.
+ *
+ *   node tk11070-remediate.mjs                       # DRY-RUN report only (no writes)
+ *   node tk11070-remediate.mjs --apply --limit=6     # canary (first N by handle order)
+ *   node tk11070-remediate.mjs --apply --only=h1,h2  # apply to specific handles
+ *   node tk11070-remediate.mjs --apply               # full batch (90s gaps between sub-batches)
+ */
+import fs from 'node:fs';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execSync } from 'node:child_process';
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const LIMIT = parseInt((process.argv.find(a => a.startsWith('--limit=')) || '').split('=')[1] || '0', 10);
+const ONLY = ((process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || '').split(',').filter(Boolean);
+const ROLLBACK = '/tmp/tk11070-rollback.json';
+const LEDGER_LOG = path.join(process.env.HOME, '.claude/yolo-queue/executed-reversible');
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+// Base filename, with Shopify's dedup UUID suffix stripped so live media (which Shopify renames
+// "NAME_<uuid>.jpg" when a base name collides on re-upload) compares equal to the source filename.
+// e.g. "DCAVAD102_51a8_086ec2b4-bb60-4239-87bc-43b700de7635.jpg" → "DCAVAD102_51a8.jpg"
+const UUID = /_[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\.[a-z]+$)/i;
+const fileOf = u => String(u || '').split('/').pop().split('?')[0].replace(UUID, '');
+const decode = s => String(s || '').replace(/&amp;/g, '&').replace(/&#39;/g, "'").replace(/&quot;/g, '"');
+const titleCase = s => decode(s).replace(/\b\w/g, c => c.toUpperCase()).trim();
+
+// Repair the scraper's "X & Y" pattern/color split (TK-11070). For patterns like "Bamboo & Birds"
+// the scraper split at the first space → pattern_name="Bamboo & Birds", color_name="& Birds <colorway>".
+// Deterministic strict repair (Codex-guarded): iff color_name starts with "& <token>" AND pattern_name
+// ends with "& <token>" (same token), strip that prefix; normalize "A /B" → "A/B". Returns null when
+// the result is empty/odd so the caller SKIPS rather than writes a bad facet.
+function cleanColorway(colorName, patternName) {
+  let c = decode(colorName || '').trim();
+  const pat = decode(patternName || '').trim();
+  if (!c) return null;
+  const mC = c.match(/^&\s+(\S+)\s+(.+)$/);           // "& Birds China Blue /Lotus Pink"
+  if (mC) {
+    const token = mC[1];
+    const mP = pat.match(/&\s+(\S+)\s*$/);             // pattern ends "… & Birds"
+    if (mP && mP[1].toLowerCase() === token.toLowerCase()) {
+      c = mC[2].trim();                                // → "China Blue /Lotus Pink"
+    } else {
+      return null;                                     // "& X" but pattern doesn't confirm → skip
+    }
+  }
+  c = c.replace(/\s*\/\s*/g, '/').replace(/\s+/g, ' ').trim();   // "China Blue /Lotus Pink"→"China Blue/Lotus Pink"
+  if (!c || /^&/.test(c) || c.length > 40) return null;          // still malformed → skip
+  return c;
+}
+
+async function gql(query, variables) {
+  for (let a = 0; a < 5; a++) {
+    const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+      method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(45000) });
+    const j = await res.json().catch(() => ({}));
+    if (j.errors) { if (a === 4) return { errors: j.errors }; await sleep(1200 * (a + 1)); continue; }
+    // throttle on THROTTLED userErrors too
+    await sleep(350);
+    return j.data;
+  }
+}
+
+// pull ALL live Sanderson products (id, handle, status, tags, mfr sku metafield, media)
+async function fetchLive() {
+  const out = [];
+  let cursor = null;
+  const q = `query($cursor:String){
+    products(first:50, query:"vendor:Sanderson", after:$cursor){
+      pageInfo{ hasNextPage endCursor }
+      nodes{
+        id handle status title tags
+        mfr: metafield(namespace:"custom", key:"manufacturer_sku"){ value }
+        media(first:50){ nodes{ id ... on MediaImage { image { url } } } }
+        variants(first:5){ nodes{ sku } }
+      }
+    }
+  }`;
+  for (let guard = 0; guard < 30; guard++) {
+    const d = await gql(q, { cursor });
+    if (!d || d.errors) { console.error('fetchLive gql error', JSON.stringify(d && d.errors)); break; }
+    for (const n of d.products.nodes) out.push(n);
+    if (!d.products.pageInfo.hasNextPage) break;
+    cursor = d.products.pageInfo.endCursor;
+  }
+  return out;
+}
+
+function loadPayloads() {
+  const m = new Map(); // key: upper mfr_sku → payload
+  const f = path.join(HERE, 'out', 'payloads.jsonl');
+  for (const line of fs.readFileSync(f, 'utf8').trim().split('\n')) {
+    const o = JSON.parse(line);
+    if (o.mfr_sku) m.set(String(o.mfr_sku).toUpperCase(), o);
+  }
+  return m;
+}
+
+function pgGallery(mfrSku) {
+  const out = execSync(`psql "${DB}" -tAc "select coalesce(to_json(gallery_images)::text,'null') from sanderson_catalog where upper(mfr_sku)=upper('${mfrSku.replace(/'/g, "''")}')"`, { encoding: 'utf8' }).trim();
+  try { return out === 'null' ? null : JSON.parse(out); } catch { return null; }
+}
+
+// map upper(mfr_sku) → {color_name, pattern_name, ai_palette:Set} for surgical tag repair
+function pgColorMap() {
+  const raw = execSync(`psql "${DB}" -tAc "select json_agg(json_build_object('mfr',mfr_sku,'color',color_name,'pattern',pattern_name,'bg',ai_background_color,'colors',ai_colors)) from sanderson_catalog"`, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
+  const m = new Map();
+  for (const r of JSON.parse(raw || '[]')) {
+    const palette = new Set(
+      [...(Array.isArray(r.colors) ? r.colors.map(c => c && (c.name || c.color)) : []), r.bg]
+        .filter(Boolean).map(x => titleCase(x)));
+    m.set(String(r.mfr).toUpperCase(), { color: r.color, pattern: r.pattern, palette });
+  }
+  return m;
+}
+
+// Is a tag a "color-ish" tag we're allowed to touch? (color: facet, or a bare AI-palette color word).
+// Everything else — New Arrival, Trending…, display_variant, collection, style, pattern, Sanderson… —
+// is a NON-color tag and MUST be preserved (spec: "Don't touch non-color tags").
+function surgicalTags(liveTags, correctColorway, palette) {
+  const keep = [];
+  for (const t of liveTags) {
+    if (/^color:/i.test(t)) continue;               // drop ALL existing color: facets (rebuild below)
+    if (palette.has(titleCase(t))) continue;         // drop bare AI-palette color-word strays
+    keep.push(t);                                    // preserve every other (non-color) tag as-is
+  }
+  const out = [...keep];
+  if (correctColorway) {
+    const facet = 'color:' + titleCase(correctColorway);
+    if (!out.includes(facet)) out.push(facet);       // add the ONE correct color facet
+    // also add the bare colorway word if not present (mirrors importer's Fawn/Multi bare tag)
+    const bare = titleCase(correctColorway);
+    if (!out.includes(bare)) out.push(bare);
+  }
+  return out;
+}
+
+async function main() {
+  const payloads = loadPayloads();
+  let live = await fetchLive();
+  console.log(`live Sanderson products: ${live.length}`);
+  if (ONLY.length) live = live.filter(p => ONLY.includes(p.handle));
+
+  const colorMap = pgColorMap();
+  const plans = [];
+  const unmatched = [];      // no payload / not a wallcovering (SAF fabrics)
+  const skipped = [];        // matched but colorway can't be resolved confidently
+  for (const p of live) {
+    const mfr = (p.mfr && p.mfr.value) || null;
+    // resolve mfr sku: prefer the metafield; else derive from a variant sku by looking up payload by dw_sku
+    let payload = mfr ? payloads.get(String(mfr).toUpperCase()) : null;
+    let resolvedMfr = mfr;
+    if (!payload) {
+      const dw = (p.variants.nodes.map(v => v.sku).filter(Boolean)[0] || '').replace(/-sample$/i, '').toUpperCase();
+      for (const pl of payloads.values()) if (String(pl.sku).toUpperCase() === dw) { payload = pl; resolvedMfr = pl.mfr_sku; break; }
+    }
+    if (!payload) { unmatched.push({ handle: p.handle, mfr, reason: mfr && /^SAF/i.test(mfr) ? 'Sanderson FABRIC (SAF*) — not in wallcovering catalog' : 'no payload match' }); continue; }
+
+    const cm = colorMap.get(String(resolvedMfr).toUpperCase()) || {};
+    const cleanColor = cleanColorway(cm.color, cm.pattern);
+    if (!cleanColor) { skipped.push({ handle: p.handle, mfr: resolvedMfr, raw_color: cm.color, reason: 'malformed/unresolvable source colorway' }); continue; }
+
+    const correctFiles = new Set(payload.product.images.map(i => fileOf(i.src)));
+    const currentTags = p.tags;
+    const newTags = surgicalTags(currentTags, cleanColor, cm.palette || new Set());
+    const media = p.media.nodes.map(m => ({ id: m.id, url: (m.image && m.image.url) || '', file: fileOf(m.image && m.image.url) }));
+    const delMedia = media.filter(m => !correctFiles.has(m.file));
+    const keepMedia = media.filter(m => correctFiles.has(m.file));
+
+    const tagsChanged = JSON.stringify([...currentTags].sort()) !== JSON.stringify([...newTags].sort());
+    if (!tagsChanged && delMedia.length === 0) continue; // already clean
+
+    plans.push({
+      id: p.id, handle: p.handle, status: p.status, mfr: String(resolvedMfr || ''),
+      colorway: cleanColor,
+      oldTags: currentTags, newTags,
+      oldMedia: media, delMedia, keepMedia,
+      correctFiles: [...correctFiles],
+    });
+  }
+
+  console.log(`plans: ${plans.length} products need correction · unmatched: ${unmatched.length} · skipped(malformed): ${skipped.length}`);
+  if (unmatched.length) {
+    const fab = unmatched.filter(u => /FABRIC/.test(u.reason)).length;
+    console.log(`  unmatched breakdown: ${fab} Sanderson fabrics (SAF*, out of scope), ${unmatched.length - fab} other`);
+  }
+  if (skipped.length) console.log('  SKIPPED (malformed colorway):', JSON.stringify(skipped));
+  // persist skip/unmatched lists for the report
+  fs.writeFileSync('/tmp/tk11070-skips.json', JSON.stringify({ unmatched, skipped }, null, 2));
+
+  // report
+  const sample = plans.slice(0, LIMIT || plans.length);
+  let toApply = plans;
+  if (LIMIT) toApply = plans.slice(0, LIMIT);
+  for (const pl of sample) {
+    const addC = pl.newTags.filter(t => !pl.oldTags.includes(t));
+    const remC = pl.oldTags.filter(t => !pl.newTags.includes(t));
+    console.log(`\n[${pl.status}] ${pl.handle} (${pl.mfr})`);
+    if (addC.length || remC.length) console.log(`   tags +[${addC.join(', ')}]  -[${remC.join(', ')}]`);
+    if (pl.delMedia.length) console.log(`   media: keep ${pl.keepMedia.map(m => m.file).join(', ') || '(none!)'} · DROP ${pl.delMedia.length}: ${pl.delMedia.map(m => m.file).join(', ')}`);
+    // SAFETY: never strip to zero images
+    if (pl.keepMedia.length === 0 && pl.oldMedia.length > 0) console.log(`   ⚠ WARN: keep-set empty — would leave 0 images; will SKIP media delete for this product`);
+  }
+
+  if (!APPLY) { console.log(`\nDRY-RUN. ${plans.length} products would change (showing ${sample.length}). Re-run with --apply.`); return; }
+
+  // ---- APPLY ----
+  const rollback = fs.existsSync(ROLLBACK) ? JSON.parse(fs.readFileSync(ROLLBACK, 'utf8')) : {};
+  let done = 0, tagsN = 0, mediaN = 0, mediaSkips = 0, batchCount = 0;
+  for (const pl of toApply) {
+    // SAFETY: if deleting all delMedia would leave 0 images, do NOT delete media (still fix tags).
+    const wouldLeave = pl.oldMedia.length - pl.delMedia.length;
+    const doMediaDelete = pl.delMedia.length > 0 && wouldLeave >= 1;
+
+    // record rollback BEFORE any write
+    rollback[pl.handle] = {
+      id: pl.id, mfr: pl.mfr, ts: new Date().toISOString(),
+      old_tags: pl.oldTags, new_tags: pl.newTags,
+      old_media: pl.oldMedia, deleted_media: doMediaDelete ? pl.delMedia : [],
+      old_gallery: pgGallery(pl.mfr),
+    };
+    fs.writeFileSync(ROLLBACK, JSON.stringify(rollback, null, 2));
+
+    // PG-first: trim gallery_images (text[]) to the correct keep-set (source order preserved).
+    const keepUrls = [...(rollback[pl.handle].old_gallery || [])].filter(u => pl.correctFiles.includes(fileOf(u)));
+    try {
+      // build a Postgres text[] literal: '{"url1","url2"}' with embedded quotes/backslashes escaped
+      const pgArr = '{' + keepUrls.map(u => '"' + String(u).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"').join(',') + '}';
+      const sqlFile = `/tmp/tk11070-pg-${pl.mfr}.sql`;
+      fs.writeFileSync(sqlFile, `update sanderson_catalog set gallery_images = $$${pgArr}$$::text[] where upper(mfr_sku)=upper($$${pl.mfr}$$);\n`);
+      execSync(`psql "${DB}" -f "${sqlFile}"`, { stdio: 'ignore' });
+      fs.unlinkSync(sqlFile);
+    } catch (e) { console.error(`   PG trim failed for ${pl.handle}: ${e.message}`); }
+
+    // Shopify: tags
+    const tagsChanged = JSON.stringify([...pl.oldTags].sort()) !== JSON.stringify([...pl.newTags].sort());
+    if (tagsChanged) {
+      const d = await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ product{ id } userErrors{ field message } } }`,
+        { input: { id: pl.id, tags: pl.newTags } });
+      const ue = d && d.productUpdate && d.productUpdate.userErrors;
+      if (ue && ue.length) console.error(`   tag update userErrors ${pl.handle}: ${JSON.stringify(ue)}`);
+      else tagsN++;
+    }
+
+    // Shopify: delete foreign-plate media
+    if (doMediaDelete) {
+      const ids = pl.delMedia.map(m => m.id);
+      const d = await gql(`mutation($mediaIds:[ID!]!, $productId:ID!){ productDeleteMedia(mediaIds:$mediaIds, productId:$productId){ deletedMediaIds mediaUserErrors{ field message } } }`,
+        { mediaIds: ids, productId: pl.id });
+      const ue = d && d.productDeleteMedia && d.productDeleteMedia.mediaUserErrors;
+      if (ue && ue.length) console.error(`   media delete userErrors ${pl.handle}: ${JSON.stringify(ue)}`);
+      else mediaN += (d.productDeleteMedia.deletedMediaIds || []).length;
+    } else if (pl.delMedia.length > 0) {
+      console.log(`   ⚠ ${pl.handle}: SKIPPED media delete (would leave 0 images)`);
+      mediaSkips++;
+    }
+
+    done++;
+    console.log(`[${done}/${toApply.length}] ${pl.handle} ✓ tags${tagsChanged ? '✓' : '·'} media-del ${doMediaDelete ? pl.delMedia.length : 0}`);
+    // ledger (log-exec expects space-separated --flag value)
+    try {
+      const logExec = path.join(LEDGER_LOG, 'log-exec.mjs');
+      execSync(`node "${logExec}" ` +
+        `--agent vp-dw-commerce --ticket TK-11070 ` +
+        `--action "Sanderson remediate ${pl.handle}: tags+media strip" ` +
+        `--blast 1 ` +
+        `--undo "cd ${HERE} && node tk11070-rollback.mjs --only=${pl.handle} --apply" ` +
+        `--verify "GET product ${pl.id} — color: tag correct, foreign plates gone"`, { stdio: 'ignore' });
+    } catch { /* ledger best-effort */ }
+
+    // cadence: 90s gap every 25 products (sub-batch), else short throttle
+    batchCount++;
+    if (batchCount % 25 === 0 && done < toApply.length) { console.log('   …90s inter-batch gap…'); await sleep(90000); }
+    else await sleep(600);
+  }
+  console.log(`\nDONE: ${done} products · tag updates ${tagsN} · media deleted ${mediaN} · media-skips ${mediaSkips}`);
+  console.log(`rollback map: ${ROLLBACK}`);
+}
+main();
diff --git a/scripts/sanderson-onboard/tk11070-rollback.mjs b/scripts/sanderson-onboard/tk11070-rollback.mjs
new file mode 100644
index 0000000..e0df17d
--- /dev/null
+++ b/scripts/sanderson-onboard/tk11070-rollback.mjs
@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+/**
+ * TK-11070 rollback — restore a product (or all products) to its pre-remediation state
+ * from /tmp/tk11070-rollback.json. Restores: (1) Shopify tags → old_tags, (2) re-adds any
+ * deleted media by URL, (3) PG sanderson_catalog.gallery_images → old_gallery.
+ *
+ *   node tk11070-rollback.mjs                       # DRY-RUN, all recorded products
+ *   node tk11070-rollback.mjs --only=h1,h2          # DRY-RUN specific handles
+ *   node tk11070-rollback.mjs --apply               # RESTORE all
+ *   node tk11070-rollback.mjs --only=h1 --apply     # RESTORE one
+ */
+import fs from 'node:fs';
+import { execSync } from 'node:child_process';
+
+const DB = 'postgresql:///dw_unified?host=/tmp';
+const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+const API = process.env.SHOPIFY_API_VERSION || '2024-10';
+const APPLY = process.argv.includes('--apply');
+const ONLY = ((process.argv.find(a => a.startsWith('--only=')) || '').split('=')[1] || '').split(',').filter(Boolean);
+const ROLLBACK = '/tmp/tk11070-rollback.json';
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+if (!TOKEN) { console.error('FATAL: set SHOPIFY_ADMIN_TOKEN'); process.exit(1); }
+
+async function gql(query, variables) {
+  for (let a = 0; a < 5; a++) {
+    const res = await fetch(`https://${STORE}/admin/api/${API}/graphql.json`, {
+      method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+      body: JSON.stringify({ query, variables }), signal: AbortSignal.timeout(45000) });
+    const j = await res.json().catch(() => ({}));
+    if (j.errors) { if (a === 4) return { errors: j.errors }; await sleep(1200 * (a + 1)); continue; }
+    await sleep(350); return j.data;
+  }
+}
+
+async function main() {
+  const map = JSON.parse(fs.readFileSync(ROLLBACK, 'utf8'));
+  const handles = ONLY.length ? ONLY : Object.keys(map);
+  for (const h of handles) {
+    const r = map[h];
+    if (!r) { console.log(`no rollback record for ${h}`); continue; }
+    console.log(`\n[${h}] restore tags(${r.old_tags.length}), re-add ${r.deleted_media.length} media, PG gallery(${(r.old_gallery || []).length})`);
+    if (!APPLY) continue;
+    // tags
+    await gql(`mutation($input:ProductInput!){ productUpdate(input:$input){ userErrors{ message } } }`, { input: { id: r.id, tags: r.old_tags } });
+    // re-add deleted media (by original url)
+    if (r.deleted_media.length) {
+      const media = r.deleted_media.filter(m => m.url).map(m => ({ originalSource: m.url, mediaContentType: 'IMAGE' }));
+      const d = await gql(`mutation($media:[CreateMediaInput!]!, $id:ID!){ productCreateMedia(media:$media, productId:$id){ mediaUserErrors{ message } } }`, { media, id: r.id });
+      const ue = d && d.productCreateMedia && d.productCreateMedia.mediaUserErrors;
+      if (ue && ue.length) console.error(`   media re-add errors: ${JSON.stringify(ue)}`);
+    }
+    // PG gallery (text[])
+    if (r.old_gallery !== undefined && r.old_gallery !== null) {
+      const pgArr = '{' + r.old_gallery.map(u => '"' + String(u).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"').join(',') + '}';
+      const sqlFile = `/tmp/tk11070-rb-${r.mfr}.sql`;
+      fs.writeFileSync(sqlFile, `update sanderson_catalog set gallery_images = $$${pgArr}$$::text[] where upper(mfr_sku)=upper($$${r.mfr}$$);\n`);
+      execSync(`psql "${DB}" -f "${sqlFile}"`, { stdio: 'ignore' });
+      fs.unlinkSync(sqlFile);
+    }
+    console.log(`   restored ✓`);
+  }
+  if (!APPLY) console.log('\nDRY-RUN. Re-run with --apply to restore.');
+}
+main();

← 572262d Sanderson importer: repair X&Y pattern/color split for color  ·  back to Designerwallcoverings  ·  feat(gmc): exclude quote_only class from Google feed (Fentuc 81226bf →