← back to Designer Wallcoverings
wallquest→PR: Lillian August onboard + per-yard/8yd model + canonical increment fix
a0933e5bdb32df48e33bde4819c8afd379776e2a · 2026-07-06 14:55:04 -0700 · Steve
- stage-lillian / relabel-cached-to-pr / convert-to-peryard: onboard + relabel to Phillipe Romano
- publish-grasscloth-pr: live PR publish (sold-per-yard, 8yd increments, Sample $4.25)
- set-increment-all: canonical global.v_prods_quantity_order_min/_units + display_variant fix
(contrarian caught custom.* metafields didn't drive the storefront control)
- discover-collections / room-composite / settlement-vision / build-viewer: onboard support
Files touched
A scripts/wallquest-refresh/build-viewer-grasscloth.cjsA scripts/wallquest-refresh/convert-to-peryard.cjsA scripts/wallquest-refresh/discover-collections.cjsA scripts/wallquest-refresh/publish-grasscloth-pr.cjsA scripts/wallquest-refresh/relabel-cached-to-pr.cjsA scripts/wallquest-refresh/room-composite-grasscloth.cjsA scripts/wallquest-refresh/set-increment-all.cjsA scripts/wallquest-refresh/settlement-vision-grasscloth.cjsA scripts/wallquest-refresh/stage-lillian.cjs
Diff
commit a0933e5bdb32df48e33bde4819c8afd379776e2a
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 14:55:04 2026 -0700
wallquest→PR: Lillian August onboard + per-yard/8yd model + canonical increment fix
- stage-lillian / relabel-cached-to-pr / convert-to-peryard: onboard + relabel to Phillipe Romano
- publish-grasscloth-pr: live PR publish (sold-per-yard, 8yd increments, Sample $4.25)
- set-increment-all: canonical global.v_prods_quantity_order_min/_units + display_variant fix
(contrarian caught custom.* metafields didn't drive the storefront control)
- discover-collections / room-composite / settlement-vision / build-viewer: onboard support
---
.../wallquest-refresh/build-viewer-grasscloth.cjs | 104 ++++++++++++++++++
scripts/wallquest-refresh/convert-to-peryard.cjs | 78 ++++++++++++++
scripts/wallquest-refresh/discover-collections.cjs | 59 +++++++++++
.../wallquest-refresh/publish-grasscloth-pr.cjs | 118 +++++++++++++++++++++
scripts/wallquest-refresh/relabel-cached-to-pr.cjs | 70 ++++++++++++
.../room-composite-grasscloth.cjs | 56 ++++++++++
scripts/wallquest-refresh/set-increment-all.cjs | 58 ++++++++++
.../settlement-vision-grasscloth.cjs | 37 +++++++
scripts/wallquest-refresh/stage-lillian.cjs | 48 +++++++++
9 files changed, 628 insertions(+)
diff --git a/scripts/wallquest-refresh/build-viewer-grasscloth.cjs b/scripts/wallquest-refresh/build-viewer-grasscloth.cjs
new file mode 100644
index 00000000..9ba8e106
--- /dev/null
+++ b/scripts/wallquest-refresh/build-viewer-grasscloth.cjs
@@ -0,0 +1,104 @@
+// Build a self-contained onboard-preview viewer for the WallQuest specialty grasscloths &
+// veneers -> Phillipe Romano batch (87 SKUs). Product grid keyed by mfr, images + room-setting
+// slot, sort + density slider (Steve's standing rules). Reads /tmp/grasscloth-recs.json.
+const fs = require('fs');
+const OUTDIR = '/tmp/grasscloth-viewer';
+fs.mkdirSync(OUTDIR + '/rooms', { recursive: true });
+const recs = JSON.parse(fs.readFileSync('/tmp/grasscloth-recs.json', 'utf8'));
+
+const best650 = imgs => { try { const a = JSON.parse(imgs); return a.find(u => /_650\./.test(u)) || a.find(u => !/_400\./.test(u)) || a[0]; } catch { return null; } };
+const prefixOf = s => (s.match(/^[A-Z]+/) || [''])[0];
+const data = recs.map(r => ({
+ dw_sku: r.dw_sku, mfr_sku: r.mfr_sku, prefix: prefixOf(r.dw_sku),
+ pattern: r.pattern_name || '', color: r.color_name || '', material: r.material || '',
+ cost: r.price_retail,
+ rollRetail: r.price_retail ? +(r.price_retail / 0.65 / 0.85).toFixed(2) : null, // per 8-yd roll
+ retail: r.price_retail ? +((r.price_retail / 0.65 / 0.85) / 8).toFixed(2) : null, // per yard (sold in 8-yd increments)
+ width: r.width_inches, desc: r.ai_description || '',
+ // prefer LOCAL swatch (served by viewer) — remote wallquest.com URLs hotlink-block in-browser
+ img: fs.existsSync(`${OUTDIR}/img/${r.dw_sku}.jpeg`) ? `img/${r.dw_sku}.jpeg` : (best650(r.all_images) || r.image_url || ''),
+ room: fs.existsSync(`${OUTDIR}/rooms/${r.dw_sku}-living.png`) ? `rooms/${r.dw_sku}-living.png` : '',
+ src: r.product_url,
+}));
+
+const roomsN = data.filter(d => d.room).length;
+const html = `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Specialty Grasscloths & Veneers → Phillipe Romano — Onboard Preview</title>
+<style>
+:root{--cols:4;--bg:#faf8f4;--ink:#1a1a1a;--mut:#8a8377;--line:#e7e2d8;--accent:#3d5a4c}
+*{box-sizing:border-box}body{margin:0;font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;background:var(--bg);color:var(--ink)}
+header{padding:22px 28px;border-bottom:1px solid var(--line);background:#fff;position:sticky;top:0;z-index:5}
+h1{margin:0 0 3px;font-size:19px;letter-spacing:.14em;text-transform:uppercase;font-weight:600}
+.sub{color:var(--mut);font-size:12.5px}
+.controls{display:flex;gap:18px;align-items:center;flex-wrap:wrap;margin-top:14px}
+.controls label{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--mut);margin-right:6px}
+select,input[type=range]{vertical-align:middle}select{padding:5px 8px;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:13px}
+.pl{margin-left:auto;font-size:11px;color:var(--mut);border:1px solid var(--line);padding:4px 9px;border-radius:20px}
+.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:16px;padding:22px 28px}
+.card{background:#fff;border:1px solid var(--line);border-radius:10px;overflow:hidden;display:flex;flex-direction:column}
+.imgs{display:grid;grid-template-columns:1fr 1fr;gap:0}
+.imgs.solo{grid-template-columns:1fr}
+.ph{aspect-ratio:1/1;background:#efeae0 center/cover no-repeat;position:relative}
+.ph.room::after{content:"ROOM";position:absolute;bottom:6px;right:6px;background:rgba(61,90,76,.9);color:#fff;font-size:9px;letter-spacing:.08em;padding:2px 6px;border-radius:10px}
+.mat{position:absolute;top:8px;left:8px;background:rgba(255,255,255,.92);font-size:10px;letter-spacing:.06em;text-transform:uppercase;padding:3px 8px;border-radius:20px;color:var(--accent);font-weight:600}
+.body{padding:11px 13px 13px}
+.t{font-size:13.5px;font-weight:600;line-height:1.3}
+.c{font-size:12px;color:var(--mut);margin-top:2px}
+.meta{display:flex;justify-content:space-between;align-items:center;margin-top:9px;font-size:11.5px;color:var(--mut)}
+.sku{font-family:ui-monospace,Menlo,monospace;font-size:11px}
+.mfr{color:var(--accent);font-weight:600}
+.px{color:var(--accent);font-weight:600}
+.desc{font-size:11.5px;color:#5c574d;margin-top:8px;display:none}
+body.showdesc .desc{display:block}
+a.card{text-decoration:none;color:inherit}
+</style></head><body>
+<header>
+<h1>Specialty Grasscloths & Veneers → Phillipe Romano</h1>
+<div class="sub">${data.length} SKUs · GRS / MIC / CORK · ${data.filter(d=>d.cost).length} priced · ${roomsN} room settings</div>
+<div class="controls">
+<span><label>Sort</label><select id="sort">
+<option value="mfr">MFR SKU</option><option value="dw">DW SKU</option>
+<option value="pattern">Pattern</option><option value="material">Material</option>
+<option value="color">Color</option><option value="price">Price ↑</option><option value="priced">Price ↓</option>
+</select></span>
+<span><label>Density</label><input type="range" id="dens" min="2" max="7" value="4"></span>
+<span><label>Descriptions</label><input type="checkbox" id="showdesc"></span>
+<span class="pl">Phillipe Romano · staged, not pushed</span>
+</div></header>
+<div class="grid" id="grid"></div>
+<script>
+const DATA = ${JSON.stringify(data)};
+const grid = document.getElementById('grid');
+const money = v => v==null?'—':'$'+(+v).toLocaleString(undefined,{minimumFractionDigits:2,maximumFractionDigits:2});
+function render(rows){
+ grid.innerHTML = rows.map(d=>{
+ const imgs = [d.img && '<div class="ph" style="background-image:url(\\''+d.img+'\\')"><span class="mat">'+d.material+'</span></div>',
+ d.room && '<div class="ph room" style="background-image:url(\\''+d.room+'\\')"></div>'].filter(Boolean);
+ return '<a class="card" href="'+d.src+'" target="_blank" rel="noopener noreferrer">'
+ +'<div class="imgs '+(imgs.length<2?'solo':'')+'">'+imgs.join('')+'</div>'
+ +'<div class="body"><div class="t">'+(d.pattern||'—')+'</div>'
+ +'<div class="c">'+(d.color||'')+'</div>'
+ +'<div class="meta"><span class="sku mfr">'+d.mfr_sku+'</span><span class="sku">'+d.dw_sku+'</span></div>'
+ +'<div class="meta"><span class="px">'+money(d.retail)+'/yd</span><span>8-yd roll '+money(d.rollRetail)+'</span></div>'
+ +'<div class="meta"><span style="font-size:10.5px">sold per yard · 8-yd increments</span><span>cost/roll '+money(d.cost)+'</span></div>'
+ +(d.desc?'<div class="desc">'+d.desc+'</div>':'')+'</div></a>';
+ }).join('');
+}
+const cmp = {
+ mfr:(a,b)=>a.mfr_sku.localeCompare(b.mfr_sku), dw:(a,b)=>a.dw_sku.localeCompare(b.dw_sku),
+ pattern:(a,b)=>(a.pattern||'').localeCompare(b.pattern||'')||a.color.localeCompare(b.color),
+ material:(a,b)=>(a.material||'').localeCompare(b.material||'')||a.mfr_sku.localeCompare(b.mfr_sku),
+ color:(a,b)=>(a.color||'').localeCompare(b.color||''),
+ price:(a,b)=>(a.cost||0)-(b.cost||0), priced:(a,b)=>(b.cost||0)-(a.cost||0),
+};
+function apply(){ const s=document.getElementById('sort').value; render([...DATA].sort(cmp[s])); }
+document.getElementById('sort').onchange=apply;
+document.getElementById('dens').oninput=e=>document.documentElement.style.setProperty('--cols',e.target.value);
+document.getElementById('showdesc').onchange=e=>document.body.classList.toggle('showdesc',e.target.checked);
+// persist
+['sort','dens'].forEach(id=>{const el=document.getElementById(id);const k='gv_'+id;if(localStorage[k]){el.value=localStorage[k];}el.addEventListener('change',()=>localStorage[k]=el.value);el.addEventListener('input',()=>localStorage[k]=el.value);});
+if(localStorage.gv_dens)document.documentElement.style.setProperty('--cols',localStorage.gv_dens);
+apply();
+</script></body></html>`;
+fs.writeFileSync(OUTDIR + '/index.html', html);
+console.log(`viewer built: ${OUTDIR}/index.html (${data.length} products, ${roomsN} rooms)`);
diff --git a/scripts/wallquest-refresh/convert-to-peryard.cjs b/scripts/wallquest-refresh/convert-to-peryard.cjs
new file mode 100644
index 00000000..0693f98a
--- /dev/null
+++ b/scripts/wallquest-refresh/convert-to-peryard.cjs
@@ -0,0 +1,78 @@
+// Convert today's PL WallQuest real-textile products from roll-"Standard" to sold-per-yard,
+// 8-yd increments. Renames the non-Sample variant option value "Standard"->"Sold per Yard",
+// sets price = rollPrice/8 (round 2), adds min_order_qty=8 / order_increment=8 / sold_by metafields.
+// Idempotent: skips products already on "Sold per Yard" with increment metafields.
+// Usage: node convert-to-peryard.cjs <table> [--pilot | --limit N | --dry]
+const https = require('https'); const { execSync } = require('child_process');
+const ENVL = require('fs').readFileSync(require('os').homedir() + '/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/.env.local', 'utf8');
+const g = k => (ENVL.match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1];
+const PGPW = g('POSTGRES_PASSWORD');
+const STORE = g('SHOPIFY_STORE_DOMAIN'); const API = g('SHOPIFY_ADMIN_API_VERSION') || '2024-10';
+const TOKEN = (g('SHOPIFY_ADMIN_ACCESS_TOKEN') || g('SHOPIFY_ADMIN_TOKEN') || '').replace(/['"]/g, '').trim();
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const q = sql => execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -tAc "${sql.replace(/"/g, '\\"')}"`, { encoding: 'utf8', maxBuffer: 1e8 });
+const args = process.argv.slice(2);
+const TABLE = args.find(a => !a.startsWith('--')) || 'daisy_bennett_catalog';
+const DRY = args.includes('--dry'); const PILOT = args.includes('--pilot');
+const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1]) : null;
+if (!/^[a-z_]+$/.test(TABLE)) { console.error('bad table'); process.exit(1); }
+
+function rest(method, path, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = https.request({ host: STORE, path: `/admin/api/${API}${path}`, method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { const j = d ? JSON.parse(d) : {}; if (res.statusCode >= 400) return reject(new Error(`${res.statusCode} ${JSON.stringify(j).slice(0, 200)}`)); resolve(j); } catch (e) { reject(e); } }); });
+ req.on('error', reject); if (data) req.write(data); req.end();
+ });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const DROP_DW = args.includes('--drop-dw');
+async function convert(pid, dw) {
+ const { product } = await rest('GET', `/products/${pid}.json`);
+ if (!product) throw new Error('not found');
+ const nonSample = product.variants.find(v => !/-Sample$/i.test(v.sku || ''));
+ const sampleV = product.variants.find(v => /-Sample$/i.test(v.sku || ''));
+ if (!nonSample) throw new Error('no standard variant');
+ const already = (nonSample.option1 || '').toLowerCase().includes('yard');
+ const curPrice = parseFloat(nonSample.price);
+ // IDEMPOTENT: only divide by 8 if not already per-yard (else keep the current per-yard price)
+ const perYard = already ? curPrice : +(curPrice / 8).toFixed(2);
+ const rollPrice = already ? +(curPrice * 8).toFixed(2) : curPrice;
+ // drop leading DW from SKUs (DWGRS-10007 -> GRS-10007); keep numbers, ensure unique
+ const newSku = DROP_DW ? (nonSample.sku || '').replace(/^DW/i, '') : nonSample.sku;
+ const newSampleSku = DROP_DW && sampleV ? (sampleV.sku || '').replace(/^DW/i, '') : (sampleV && sampleV.sku);
+ const skuChanged = DROP_DW && newSku !== nonSample.sku;
+ if (DRY) { console.log(`DRY ${dw} p${pid}: "${nonSample.option1}" $${rollPrice} -> "Sold per Yard" $${perYard}/yd${skuChanged ? ` | sku ${nonSample.sku}->${newSku}` : ''}${already ? ' (already yard)' : ''}`); return { dry: true }; }
+ if (!already || skuChanged) {
+ await rest('PUT', `/variants/${nonSample.id}.json`, { variant: { id: nonSample.id, option1: 'Sold per Yard', price: String(perYard), ...(skuChanged ? { sku: newSku } : {}) } });
+ if (skuChanged && sampleV) await rest('PUT', `/variants/${sampleV.id}.json`, { variant: { id: sampleV.id, sku: newSampleSku } });
+ }
+ if (skuChanged) { try { execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -c "UPDATE ${TABLE} SET dw_sku='${newSku}', updated_at=now() WHERE dw_sku='${dw}';"`); } catch (e) {} }
+ // increment metafields (idempotent create)
+ const mfs = [
+ { namespace: 'custom', key: 'min_order_qty', value: '8', type: 'number_integer' },
+ { namespace: 'custom', key: 'order_increment', value: '8', type: 'number_integer' },
+ { namespace: 'custom', key: 'sold_by', value: 'Yard (8-yd increments)', type: 'single_line_text_field' },
+ ];
+ for (const mf of mfs) { try { await rest('POST', `/products/${pid}/metafields.json`, { metafield: mf }); } catch (e) {} }
+ console.log(`✓ ${dw} p${pid}: $${rollPrice}/roll -> $${perYard}/yd (8-yd incr)`);
+ return { pid, dw, perYard };
+}
+
+(async () => {
+ if (!TOKEN) { console.error('no token'); process.exit(1); }
+ // resumable: in drop-dw mode only fetch rows still carrying DW (Daisy); once dropped they fall out
+ const dwFilter = DROP_DW ? "AND dw_sku ~ '^DW'" : '';
+ let rows = JSON.parse(q(`SELECT COALESCE(json_agg(row_to_json(t)),'[]') FROM (SELECT dw_sku, shopify_product_id FROM ${TABLE} WHERE shopify_product_id IS NOT NULL AND shopify_product_id<>'' ${dwFilter} ORDER BY dw_sku) t;`).trim());
+ const OFFSET = args.includes('--offset') ? parseInt(args[args.indexOf('--offset') + 1]) : 0;
+ if (OFFSET) rows = rows.slice(OFFSET);
+ if (PILOT) rows = rows.slice(0, 1);
+ if (LIMIT) rows = rows.slice(0, LIMIT);
+ console.error(`converting ${rows.length} from ${TABLE} (dry=${DRY})`);
+ let ok = 0, err = 0;
+ for (const r of rows) {
+ try { await convert(r.shopify_product_id, r.dw_sku); ok++; } catch (e) { err++; console.error(`✗ ${r.dw_sku}: ${e.message}`); }
+ await sleep(500);
+ }
+ console.error(`DONE ${TABLE} ok=${ok} err=${err}`);
+})();
diff --git a/scripts/wallquest-refresh/discover-collections.cjs b/scripts/wallquest-refresh/discover-collections.cjs
new file mode 100644
index 00000000..d7f687fb
--- /dev/null
+++ b/scripts/wallquest-refresh/discover-collections.cjs
@@ -0,0 +1,59 @@
+// Discover products across one or more WallQuest collection landing pages.
+// Usage: node discover-collections.cjs <slug1> <slug2> ... (slug = url tail, e.g. paper-ink)
+// Writes /tmp/wq-collections-discover.jsonl with {collection, url, slug, thumb}.
+const Browserbase = require('@browserbasehq/sdk').default;
+const { chromium } = require('playwright-core');
+const fs = require('fs'); const { execSync } = require('child_process');
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/browserbase/.env' });
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const ENVL = fs.readFileSync(require('os').homedir() + '/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/.env.local', 'utf8');
+const PGPW = (ENVL.match(/^POSTGRES_PASSWORD=(.+)$/m) || [])[1];
+const WPW = execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -tAF'|' -c "SELECT trade_password FROM vendor_registry WHERE vendor_code='wallquest';"`, { encoding: 'utf8' }).trim();
+const bb = new Browserbase({ apiKey: process.env.BROWSERBASE_API_KEY });
+const SLUGS = process.argv.slice(2);
+if (!SLUGS.length) { console.error('need collection slugs'); process.exit(1); }
+const OUT = '/tmp/wq-collections-discover.jsonl'; fs.writeFileSync(OUT, '');
+const outS = fs.createWriteStream(OUT, { flags: 'a' });
+const goto = async (p, u) => { for (let i = 0; i < 3; i++) { try { await p.goto(u, { waitUntil: 'domcontentloaded', timeout: 45000 }); return 1; } catch (e) { await p.waitForTimeout(3000); } } return 0; };
+async function login(page) {
+ await goto(page, 'https://www.wallquest.com/login'); await page.waitForTimeout(8000);
+ await page.fill('#Email', 'info@designerwallcoverings.com'); await page.fill('#Password', WPW);
+ for (const s of ['input.login-button', '.login-button']) { if (await page.$(s)) { try { await page.click(s, { timeout: 5000 }); break; } catch (e) {} } }
+ await page.waitForTimeout(9000);
+}
+const extractTiles = p => p.evaluate(() => {
+ const abs = h => { try { return new URL(h, location.origin).href; } catch { return h; } };
+ const out = [];
+ for (const it of document.querySelectorAll('.item-box, .product-item')) {
+ const a = it.querySelector('.product-title a, h2 a, a[href]'); if (!a) continue;
+ const href = a.getAttribute('href'); if (!href || /\/(login|register|cart|wishlist|compare|account)/i.test(href)) continue;
+ const img = it.querySelector('.picture img, img');
+ out.push({ url: abs(href), thumb: img ? abs(img.getAttribute('src') || img.getAttribute('data-src') || '') : null });
+ }
+ return out;
+});
+(async () => {
+ let br; const per = {};
+ try {
+ const s = await bb.sessions.create({ projectId: process.env.BROWSERBASE_PROJECT_ID, proxies: true, browserSettings: { solveCaptchas: true } });
+ br = await chromium.connectOverCDP(s.connectUrl);
+ const page = br.contexts()[0].pages()[0] || await br.contexts()[0].newPage();
+ await login(page);
+ for (const slug of SLUGS) {
+ const seen = new Set();
+ for (let pn = 1; pn <= 12; pn++) {
+ const u = `https://www.wallquest.com/${slug}?orderby=50&pagesize=100&viewmode=grid&pagenumber=${pn}`;
+ if (!await goto(page, u)) break;
+ await page.waitForTimeout(4000);
+ const tiles = await extractTiles(page);
+ const fresh = tiles.filter(t => t.url && !seen.has(t.url));
+ fresh.forEach(t => { seen.add(t.url); outS.write(JSON.stringify({ collection: slug, url: t.url, slug: t.url.split('/').pop().split('?')[0].toUpperCase(), thumb: t.thumb }) + '\n'); });
+ if (!tiles.length || !fresh.length) break;
+ }
+ per[slug] = seen.size; console.error(`${slug}: ${seen.size} products`);
+ }
+ } catch (e) { console.error('ERR', e.message); }
+ finally { if (br) await br.close().catch(() => {}); }
+ outS.end();
+ console.error('DISCOVER DONE', JSON.stringify(per));
+})();
diff --git a/scripts/wallquest-refresh/publish-grasscloth-pr.cjs b/scripts/wallquest-refresh/publish-grasscloth-pr.cjs
new file mode 100644
index 00000000..4605d6d1
--- /dev/null
+++ b/scripts/wallquest-refresh/publish-grasscloth-pr.cjs
@@ -0,0 +1,118 @@
+// Publish the grasscloths & veneers -> Phillipe Romano batch to LIVE Shopify.
+// Sold per yard, 8-yd increments (min_order_qty=8, order_increment=8). Sample $4.25.
+// vendor Phillipe Romano, productType Wallcovering, inv 2026 both variants, all channels.
+// Usage: node publish-grasscloth-pr.cjs [--pilot | --sku GRS-99999 | --limit N | --skip csv] [--dry]
+const https = require('https'); const { execSync } = require('child_process');
+const ENVL = require('fs').readFileSync(require('os').homedir() + '/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/.env.local', 'utf8');
+const g = k => (ENVL.match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1];
+const PGPW = g('POSTGRES_PASSWORD');
+const STORE = g('SHOPIFY_STORE_DOMAIN') || 'designer-laboratory-sandbox.myshopify.com';
+const API = g('SHOPIFY_ADMIN_API_VERSION') || '2024-10';
+const TOKEN = (g('SHOPIFY_ADMIN_ACCESS_TOKEN') || g('SHOPIFY_ADMIN_TOKEN') || '').replace(/['"]/g, '').trim();
+const INV_LOCATION = 'gid://shopify/Location/5795643504'; // 15442 Ventura Blvd
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const q = sql => execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -tAc "${sql.replace(/"/g, '\\"')}"`, { encoding: 'utf8', maxBuffer: 1e8 });
+
+const args = process.argv.slice(2);
+const DRY = args.includes('--dry');
+const PILOT = args.includes('--pilot');
+const ONE = (args[args.indexOf('--sku') + 1] && args.includes('--sku')) ? args[args.indexOf('--sku') + 1] : null;
+const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1]) : null;
+const SKIP = args.includes('--skip') ? (args[args.indexOf('--skip') + 1] || '').split(',') : [];
+
+const BANNED = /wallquest|chesapeake|brewster|nextwall|seabrook|voyage|william\s*morris|miyabi/ig;
+const scrub = s => String(s == null ? '' : s).replace(BANNED, '').replace(/\s{2,}/g, ' ').trim();
+const assertClean = (label, s) => { if (BANNED.test(s)) throw new Error(`LEAK in ${label}: ${s}`); BANNED.lastIndex = 0; return s; };
+
+function rest(method, path, body) {
+ return new Promise((resolve, reject) => {
+ const data = body ? JSON.stringify(body) : null;
+ const req = https.request({ host: STORE, path: `/admin/api/${API}${path}`, method, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } }, res => {
+ let d = ''; res.on('data', c => d += c); res.on('end', () => { try { const j = d ? JSON.parse(d) : {}; if (res.statusCode >= 400) return reject(new Error(`${res.statusCode} ${JSON.stringify(j).slice(0, 300)}`)); resolve(j); } catch (e) { reject(e); } });
+ });
+ req.on('error', reject); if (data) req.write(data); req.end();
+ });
+}
+function gql(query, variables) {
+ return new Promise((resolve, reject) => {
+ const data = JSON.stringify({ query, variables });
+ const req = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { reject(e); } }); });
+ req.on('error', reject); req.write(data); req.end();
+ });
+}
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+function build(row) {
+ const pattern = scrub(row.pattern_name), color = scrub(row.color_name), material = scrub(row.material);
+ // 'Specialty Grasscloths & Veneers' is the generic category, not a real pattern — for those the
+ // real name lives in color_name (e.g. "Moon Rock Cork - Blue Moss"). Avoid the doubled title.
+ const generic = /^specialty grasscloths?\s*&\s*veneers$/i.test(pattern);
+ const namePart = generic ? color : [pattern, color].filter(Boolean).join(' - ');
+ const title = assertClean('title', (namePart || pattern || 'Natural Wallcovering') + ' | Phillipe Romano');
+ const perYard = +((row.price_retail / 0.65 / 0.85) / 8).toFixed(2);
+ const rollRetail = +(perYard * 8).toFixed(2);
+ const famTag = /cork/i.test(material) ? 'Cork' : /metal|mica|leaf/i.test(material) ? 'Mica' : 'Grasscloth';
+ const tags = [...new Set(['Phillipe Romano', 'Natural Wallcovering', famTag, material, color, pattern, 'Sold Per Yard', `mfr:${row.dw_sku}`].map(scrub).filter(Boolean))];
+ tags.forEach(t => assertClean('tag', t));
+ const body = assertClean('body', `<p>${scrub(row.ai_description) || pattern + ' natural wallcovering by Phillipe Romano.'}</p><p>Sold by the yard in 8-yard increments (one roll = 8 yd, ${row.width_inches || 36}" wide).</p>`);
+ const img = (() => { try { const a = JSON.parse(row.all_images); return a.find(u => /_650\./.test(u)) || a[0]; } catch { return row.image_url; } })();
+ return { title, body, tags: tags.join(', '), perYard, rollRetail, img, dw: row.dw_sku, material };
+}
+
+async function publishOne(row) {
+ const b = build(row);
+ if (DRY) { console.log(`DRY ${b.dw} | ${b.title} | $${b.perYard}/yd (roll $${b.rollRetail}) | tags:${b.tags}`); return { dry: true }; }
+ const payload = { product: {
+ title: b.title, body_html: b.body, vendor: 'Phillipe Romano', product_type: 'Wallcovering', status: 'active', tags: b.tags,
+ options: [{ name: 'Size' }],
+ variants: [
+ { option1: 'Sold per Yard', sku: b.dw, price: String(b.perYard), inventory_management: 'shopify', inventory_policy: 'continue', requires_shipping: true },
+ { option1: 'Sample', sku: `${b.dw}-Sample`, price: '4.25', inventory_management: 'shopify', inventory_policy: 'deny', requires_shipping: true },
+ ],
+ images: b.img ? [{ src: b.img }] : [],
+ } };
+ const res = await rest('POST', '/products.json', payload);
+ const p = res.product; const pid = p.id;
+ // metafields: 8-yd increment/min + manufacturer sku (public) + real vendor (private)
+ const mfs = [
+ { namespace: 'custom', key: 'min_order_qty', value: '8', type: 'number_integer' },
+ { namespace: 'custom', key: 'order_increment', value: '8', type: 'number_integer' },
+ { namespace: 'custom', key: 'sold_by', value: 'Yard (8-yd increments)', type: 'single_line_text_field' },
+ { namespace: 'custom', key: 'manufacturer_sku', value: b.dw, type: 'single_line_text_field' },
+ ];
+ for (const mf of mfs) { try { await rest('POST', `/products/${pid}/metafields.json`, { metafield: mf }); } catch (e) {} }
+ // inventory 2026 on both variants
+ try {
+ const invIds = p.variants.map(v => v.inventory_item_id);
+ for (const iid of invIds) {
+ await rest('POST', '/inventory_levels/set.json', { location_id: 5795643504, inventory_item_id: iid, available: 2026 });
+ }
+ } catch (e) { console.error(' inv warn:', e.message); }
+ // publish to all channels
+ try {
+ const gid = `gid://shopify/Product/${pid}`;
+ const pubs = await gql(`{publications(first:30){edges{node{id name}}}}`);
+ const inputs = (pubs.data?.publications?.edges || []).map(e => ({ publicationId: e.node.id }));
+ if (inputs.length) await gql(`mutation($id:ID!,$input:[PublicationInput!]!){publishablePublish(id:$id,input:$input){userErrors{field message}}}`, { id: gid, input: inputs });
+ } catch (e) { console.error(' publish warn:', e.message); }
+ // link shopify_product_id back into catalog
+ try { execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -c "UPDATE phillipe_romano_catalog SET shopify_product_id='${pid}', on_shopify=true, updated_at=now() WHERE dw_sku='${b.dw}';"`); } catch (e) {}
+ console.log(`✓ ${b.dw} → product ${pid} | ${b.title} | $${b.perYard}/yd`);
+ return { pid, dw: b.dw, title: b.title, perYard: b.perYard };
+}
+
+(async () => {
+ if (!TOKEN) { console.error('NO SHOPIFY TOKEN'); process.exit(1); }
+ let rows = JSON.parse(q("SELECT COALESCE(json_agg(row_to_json(t)),'[]') FROM (SELECT dw_sku, mfr_sku, pattern_name, color_name, material, price_retail, image_url, all_images, ai_description, width_inches FROM phillipe_romano_catalog WHERE dw_sku ~ '^(GRS|MIC|CORK)-' AND upper(mfr_sku) ~ '^(BL|SY|NS|WP|LN)' AND (shopify_product_id IS NULL OR shopify_product_id='') AND price_retail>0 ORDER BY dw_sku) t;").trim());
+ rows = rows.filter(r => !SKIP.includes(r.dw_sku));
+ if (ONE) rows = rows.filter(r => r.dw_sku === ONE);
+ if (PILOT) rows = rows.slice(0, 1);
+ if (LIMIT) rows = rows.slice(0, LIMIT);
+ console.error(`publishing ${rows.length} products (dry=${DRY})`);
+ let ok = 0, err = 0;
+ for (const r of rows) {
+ try { await publishOne(r); ok++; } catch (e) { err++; console.error(`✗ ${r.dw_sku}: ${e.message}`); }
+ await sleep(600);
+ }
+ console.error(`DONE ok=${ok} err=${err}`);
+})();
diff --git a/scripts/wallquest-refresh/relabel-cached-to-pr.cjs b/scripts/wallquest-refresh/relabel-cached-to-pr.cjs
new file mode 100644
index 00000000..31a05c7b
--- /dev/null
+++ b/scripts/wallquest-refresh/relabel-cached-to-pr.cjs
@@ -0,0 +1,70 @@
+// Relabel the 94 cached real-textile Paper&Ink/Lillian products (currently Malibu, DWQW) to
+// Phillipe Romano: new GRS/MIC/CORK SKU, per-yard/8-yd, vendor PR, private-label scrub.
+// Live ones -> mutate the existing Shopify product. Not-live -> create fresh. Idempotent via
+// a moved marker (dw_sku rewritten in wallquest_catalog + row copied to phillipe_romano_catalog).
+// Usage: node relabel-cached-to-pr.cjs [--pilot|--limit N|--offset N] [--dry]
+const https = require('https'); const { execSync } = require('child_process');
+const ENVL = require('fs').readFileSync(require('os').homedir() + '/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/.env.local', 'utf8');
+const g = k => (ENVL.match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1];
+const PGPW = g('POSTGRES_PASSWORD'); const STORE = g('SHOPIFY_STORE_DOMAIN'); const API = g('SHOPIFY_ADMIN_API_VERSION') || '2024-10';
+const TOKEN = (g('SHOPIFY_ADMIN_ACCESS_TOKEN') || g('SHOPIFY_ADMIN_TOKEN') || '').replace(/['"]/g, '').trim();
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const fs2 = require('fs');
+const q = sql => { const f = `/tmp/relabel-q-${process.pid}.sql`; fs2.writeFileSync(f, sql); return execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -tAf ${f}`, { encoding: 'utf8', maxBuffer: 1e8 }); };
+const qx = sql => { const f = `/tmp/relabel-x-${process.pid}.sql`; fs2.writeFileSync(f, sql); return execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -f ${f}`, { encoding: 'utf8', maxBuffer: 1e8 }); };
+const sqlStr = s => "'" + String(s == null ? '' : s).replace(/'/g, "''") + "'";
+const args = process.argv.slice(2);
+const DRY = args.includes('--dry'), PILOT = args.includes('--pilot');
+const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1]) : null;
+const OFFSET = args.includes('--offset') ? parseInt(args[args.indexOf('--offset') + 1]) : 0;
+const BANNED = /wallquest|chesapeake|brewster|nextwall|seabrook|voyage|william\s*morris|miyabi|malibu/ig;
+const scrub = s => String(s == null ? '' : s).replace(BANNED, '').replace(/\s{2,}/g, ' ').trim();
+const clean = (l, s) => { if (BANNED.test(s)) throw new Error(`LEAK ${l}: ${s}`); BANNED.lastIndex = 0; return s; };
+function rest(m, p, b) { return new Promise((res, rej) => { const data = b ? JSON.stringify(b) : null; const r = https.request({ host: STORE, path: `/admin/api/${API}${p}`, method: m, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } }, rs => { let d = ''; rs.on('data', c => d += c); rs.on('end', () => { try { const j = d ? JSON.parse(d) : {}; if (rs.statusCode >= 400) return rej(new Error(`${rs.statusCode} ${JSON.stringify(j).slice(0, 200)}`)); res(j); } catch (e) { rej(e); } }); }); r.on('error', rej); if (data) r.write(data); r.end(); }); }
+function gql(query, variables) { return new Promise((res, rej) => { const data = JSON.stringify({ query, variables }); const r = https.request({ host: STORE, path: `/admin/api/${API}/graphql.json`, method: 'POST', headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, rs => { let d = ''; rs.on('data', c => d += c); rs.on('end', () => { try { res(JSON.parse(d)); } catch (e) { rej(e); } }); }); r.on('error', rej); r.write(data); r.end(); }); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+const prefixOf = m => /cork/i.test(m) ? 'CORK' : /mica|metal|leaf/i.test(m) ? 'MIC' : 'GRS';
+function titleOf(pattern, color) { const p = scrub(pattern), c = scrub(color); const generic = /^(specialty grasscloths?|natural)/i.test(p); const np = generic ? c : [p, c].filter(Boolean).join(' - '); return clean('title', (np || p || 'Natural Wallcovering') + ' | Phillipe Romano'); }
+function tagsOf(material, pattern, color, newSku) { const fam = prefixOf(material) === 'CORK' ? 'Cork' : prefixOf(material) === 'MIC' ? 'Mica' : 'Grasscloth'; const t = [...new Set(['Phillipe Romano', 'Natural Wallcovering', fam, scrub(material), scrub(color), scrub(pattern), 'Sold Per Yard', `mfr:${newSku}`].map(scrub).filter(Boolean))]; t.forEach(x => clean('tag', x)); return t.join(', '); }
+
+async function relabelLive(row) {
+ const pid = row.shopify_product_id; const newSku = row.newSku;
+ const perYard = +((row.price_retail / 0.65 / 0.85) / 8).toFixed(2);
+ const title = titleOf(row.pattern_name, row.color_name); const tags = tagsOf(row.material, row.pattern_name, row.color_name, newSku);
+ if (DRY) { console.log(`DRY LIVE ${row.dw_sku}->${newSku} p${pid} | ${title} | $${perYard}/yd`); return; }
+ const { product } = await rest('GET', `/products/${pid}.json`);
+ await rest('PUT', `/products/${pid}.json`, { product: { id: pid, vendor: 'Phillipe Romano', title, tags } });
+ const nonSample = product.variants.find(v => !/-Sample$/i.test(v.sku || '')) || product.variants[0];
+ const sampleV = product.variants.find(v => /-Sample$/i.test(v.sku || ''));
+ await rest('PUT', `/variants/${nonSample.id}.json`, { variant: { id: nonSample.id, option1: 'Sold per Yard', sku: newSku, price: String(perYard) } });
+ if (sampleV) await rest('PUT', `/variants/${sampleV.id}.json`, { variant: { id: sampleV.id, option1: 'Sample', sku: `${newSku}-Sample`, price: '4.25' } });
+ for (const mf of [{ key: 'min_order_qty', value: '8', type: 'number_integer' }, { key: 'order_increment', value: '8', type: 'number_integer' }, { key: 'sold_by', value: 'Yard (8-yd increments)', type: 'single_line_text_field' }, { key: 'manufacturer_sku', value: newSku, type: 'single_line_text_field' }]) { try { await rest('POST', `/products/${pid}/metafields.json`, { metafield: { namespace: 'custom', ...mf } }); } catch (e) {} }
+ // move DB: copy to PR catalog, mark wallquest row moved
+ qx(`INSERT INTO phillipe_romano_catalog (mfr_sku,dw_sku,pattern_name,color_name,material,price_retail,product_type,shopify_product_id,on_shopify,created_at,updated_at) VALUES (${sqlStr(row.mfr_sku)},${sqlStr(newSku)},${sqlStr(row.pattern_name)},${sqlStr(row.color_name)},${sqlStr(row.material)},${row.price_retail},'Wallcovering',${sqlStr(pid)},true,now(),now()) ON CONFLICT (mfr_sku) DO UPDATE SET dw_sku=EXCLUDED.dw_sku,shopify_product_id=EXCLUDED.shopify_product_id;
+UPDATE wallquest_catalog SET dw_sku=${sqlStr(newSku)}, updated_at=now() WHERE mfr_sku=${sqlStr(row.mfr_sku)};`);
+ console.log(`✓ ${row.dw_sku}->${newSku} p${pid} | $${perYard}/yd | ${title}`);
+}
+
+(async () => {
+ if (!TOKEN) { console.error('no token'); process.exit(1); }
+ // rebuild in-scope cached set, assign SKUs deterministically (sorted), skip already-moved (dw_sku not DWQW)
+ let rows = JSON.parse(q(`SELECT COALESCE(json_agg(row_to_json(t)),'[]') FROM (
+ SELECT c.mfr_sku,c.dw_sku,c.material,c.pattern_name,c.color_name,c.price_retail,c.shopify_product_id
+ FROM wallquest_catalog c
+ WHERE c.mfr_sku IN (SELECT mfr_sku FROM wq_relabel_scope)
+ AND c.dw_sku ~ '^DWQW' AND c.price_retail>0
+ AND c.shopify_product_id IS NOT NULL AND c.shopify_product_id<>''
+ ORDER BY c.dw_sku) t;`).trim());
+ // assign SKUs continuing the block — read live max so resumed chunks never collide
+ const maxOf = (pfx, floor) => { const m = parseInt(q(`SELECT COALESCE(max(NULLIF(regexp_replace(dw_sku,'\\D','','g'),'')::bigint),0) FROM phillipe_romano_catalog WHERE dw_sku ~ '^${pfx}-9';`).trim() || '0', 10); return Math.max(m, floor) + 1; };
+ let nGRS = maxOf('GRS', 98699), nCORK = maxOf('CORK', 99030), nMIC = maxOf('MIC', 98626);
+ rows.forEach(r => { const p = prefixOf(r.material); r.newSku = p === 'CORK' ? `CORK-${nCORK++}` : p === 'MIC' ? `MIC-${nMIC++}` : `GRS-${nGRS++}`; });
+ if (OFFSET) rows = rows.slice(OFFSET);
+ if (PILOT) rows = rows.slice(0, 1);
+ if (LIMIT) rows = rows.slice(0, LIMIT);
+ console.error(`relabel ${rows.length} (dry=${DRY})`);
+ let ok = 0, err = 0;
+ for (const r of rows) { try { if (r.shopify_product_id) await relabelLive(r); else console.error(` (skip not-live ${r.dw_sku} — create path TODO)`); ok++; } catch (e) { err++; console.error(`✗ ${r.dw_sku}: ${e.message}`); } await sleep(600); }
+ console.error(`DONE ok=${ok} err=${err}`);
+})();
diff --git a/scripts/wallquest-refresh/room-composite-grasscloth.cjs b/scripts/wallquest-refresh/room-composite-grasscloth.cjs
new file mode 100644
index 00000000..a56fa972
--- /dev/null
+++ b/scripts/wallquest-refresh/room-composite-grasscloth.cjs
@@ -0,0 +1,56 @@
+// Room compositor for the grasscloths & veneers -> Phillipe Romano batch.
+// Empty-room base + the product's TILED real swatch -> gemini-2.5-flash-image paints ONLY the wall.
+// Writes rooms/<dw_sku>-living.png into the viewer. Env: SKUS=... to limit; ROOM=living|bedroom.
+const fs = require('fs'); const https = require('https'); const { execSync } = require('child_process');
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/ai-analyzer/.env' });
+const KEY = process.env.GEMINI_API_KEY; if (!KEY) { console.error('no GEMINI_API_KEY'); process.exit(1); }
+const MODEL = 'gemini-2.5-flash-image';
+const URL_ = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+const VIEWDIR = '/tmp/grasscloth-viewer'; const ROOMDIR = `${VIEWDIR}/rooms`;
+fs.mkdirSync(ROOMDIR, { recursive: true });
+const ROOMS = {
+ living: 'a bright, elegant contemporary living room — the camera faces the main accent wall, a low linen sofa and a slim console with a ceramic lamp in front of it, oak floor, soft daylight from the side',
+ bedroom: 'a serene luxury bedroom — the camera faces the headboard wall, an upholstered bed with linen bedding in front of it, two bedside tables with brass lamps, soft morning light',
+};
+const ROOM = process.env.ROOM || 'living'; const scene = ROOMS[ROOM];
+const BASE_ROOM = process.env.BASE_ROOM || '/tmp/carl-robinson-viewer/bakeoff/empty-room.png';
+const roomB64 = fs.readFileSync(BASE_ROOM).toString('base64');
+const MACRO = 'The wall must look like a real hand-woven natural-fibre wallcovering with three-dimensional tactile texture: individual fibres and the over-under weave clearly visible and in crisp focus, grazing raking side-light casting micro-shadows between fibres revealing real depth and relief — NOT a flat printed surface. Realistic architectural weave scale, editorial interior photography, high micro-contrast on the wall.';
+const prompt = material => `Take the FIRST image (an empty room described as: ${scene}). Paper ONLY the blank accent wall with the EXACT ${material} texture and color from the SECOND image. Keep the furniture, floor, lighting and composition of the first image identical. ${MACRO}`;
+
+const recs = JSON.parse(fs.readFileSync('/tmp/grasscloth-recs.json', 'utf8'));
+const only = (process.env.SKUS || '').split(',').map(s => s.trim()).filter(Boolean);
+const work = recs.filter(r => (!only.length || only.includes(r.dw_sku)) && fs.existsSync(`${VIEWDIR}/img/${r.dw_sku}.jpeg`)
+ && !(fs.existsSync(`${ROOMDIR}/${r.dw_sku}-${ROOM}.png`) && fs.statSync(`${ROOMDIR}/${r.dw_sku}-${ROOM}.png`).size > 5000));
+
+function tiledSwatchB64(sku) {
+ const t = `/tmp/tile-${sku}.png`;
+ execSync(`magick "${VIEWDIR}/img/${sku}.jpeg" -resize 380x380^ -gravity center -extent 380x380 /tmp/_sw-${sku}.png && magick -size 1024x1024 tile:/tmp/_sw-${sku}.png "${t}" && magick "${t}" -unsharp 0x1.0 "${t}"`);
+ return fs.readFileSync(t).toString('base64');
+}
+function genRoom(swatchB64, material) {
+ const payload = { contents: [{ parts: [{ text: prompt(material) }, { inline_data: { mime_type: 'image/png', data: roomB64 } }, { inline_data: { mime_type: 'image/png', data: swatchB64 } }] }], generationConfig: { responseModalities: ['IMAGE'], temperature: 0.4 } };
+ return new Promise((resolve, reject) => {
+ const u = new URL(URL_); const body = JSON.stringify(payload);
+ const r = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 90000 }, res => {
+ let d = ''; res.on('data', c => d += c);
+ res.on('end', () => { try { const j = JSON.parse(d); if (j.error) return reject(new Error(j.error.message)); const parts = j.candidates?.[0]?.content?.parts || []; const ip = parts.find(p => p.inline_data || p.inlineData); const inline = ip && (ip.inline_data || ip.inlineData); if (!inline) return reject(new Error('no image')); resolve(Buffer.from(inline.data, 'base64')); } catch (e) { reject(e); } });
+ });
+ r.on('error', reject); r.on('timeout', () => { r.destroy(); reject(new Error('timeout')); });
+ r.write(body); r.end();
+ });
+}
+(async () => {
+ let ok = 0, err = 0;
+ for (const r of work) {
+ try {
+ const swatch = tiledSwatchB64(r.dw_sku);
+ const png = await genRoom(swatch, (r.material || 'natural texture').toLowerCase());
+ fs.writeFileSync(`${ROOMDIR}/${r.dw_sku}-${ROOM}.png`, png);
+ ok++; console.error(`✓ ${r.dw_sku} ${r.material} (${(png.length / 1024 | 0)}KB) [${ok}/${work.length}]`);
+ } catch (e) { err++; console.error(`✗ ${r.dw_sku}: ${String(e).slice(0, 90)}`); }
+ await new Promise(z => setTimeout(z, 500));
+ }
+ console.error(`ROOMS DONE ok=${ok} err=${err} → ${ROOMDIR}`);
+ console.error(`COST ~$${(ok * 0.039).toFixed(3)} (gemini-2.5-flash-image, ${ok} imgs @ ~$0.039)`);
+})();
diff --git a/scripts/wallquest-refresh/set-increment-all.cjs b/scripts/wallquest-refresh/set-increment-all.cjs
new file mode 100644
index 00000000..48f918a8
--- /dev/null
+++ b/scripts/wallquest-refresh/set-increment-all.cjs
@@ -0,0 +1,58 @@
+// Apply the DW CANONICAL per-yard increment metafields + display_variant tag to ALL of this
+// session's per-yard PR products (the custom.* keys I wrote don't drive the storefront control).
+// Canonical set (from set-increment-carl-robinson.cjs + DW CLAUDE.md global-namespace taxonomy):
+// global.v_prods_quantity_order_min=8, global.v_prods_quantity_order_units=8, dwc.order_unit=8,
+// global.unit_of_measure='Priced Per Yard', global.Sold-Per='Yard', global.Minimum=8, + display_variant tag.
+// Also writes global.width when width_inches is known. Idempotent. Usage: node set-increment-all.cjs [--sku GRS-x | --limit N | --offset N]
+const https = require('https'); const { execSync } = require('child_process');
+const ENVL = require('fs').readFileSync(require('os').homedir() + '/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/.env.local', 'utf8');
+const g = k => (ENVL.match(new RegExp('^' + k + '=(.+)$', 'm')) || [])[1];
+const PGPW = g('POSTGRES_PASSWORD'); const STORE = g('SHOPIFY_STORE_DOMAIN'); const API = g('SHOPIFY_ADMIN_API_VERSION') || '2024-10';
+const TOKEN = (g('SHOPIFY_ADMIN_ACCESS_TOKEN') || g('SHOPIFY_ADMIN_TOKEN') || '').replace(/['"]/g, '').trim();
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const q = sql => execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -tAc "${sql.replace(/"/g, '\\"')}"`, { encoding: 'utf8', maxBuffer: 1e8 });
+const args = process.argv.slice(2);
+const ONE = args.includes('--sku') ? args[args.indexOf('--sku') + 1] : null;
+const LIMIT = args.includes('--limit') ? parseInt(args[args.indexOf('--limit') + 1]) : null;
+const OFFSET = args.includes('--offset') ? parseInt(args[args.indexOf('--offset') + 1]) : 0;
+const INC = '8';
+function rest(m, p, b) { return new Promise((res, rej) => { const data = b ? JSON.stringify(b) : null; const r = https.request({ host: STORE, path: `/admin/api/${API}${p}`, method: m, headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json', ...(data ? { 'Content-Length': Buffer.byteLength(data) } : {}) } }, rs => { let d = ''; rs.on('data', c => d += c); rs.on('end', () => { try { const j = d ? JSON.parse(d) : {}; if (rs.statusCode >= 400) return rej(new Error(`${rs.statusCode} ${JSON.stringify(j).slice(0, 150)}`)); res(j); } catch (e) { rej(e); } }); }); r.on('error', rej); if (data) r.write(data); r.end(); }); }
+const sleep = ms => new Promise(r => setTimeout(r, ms));
+
+async function apply(row) {
+ const pid = row.shopify_product_id;
+ const mfs = [
+ { namespace: 'global', key: 'v_prods_quantity_order_min', value: INC },
+ { namespace: 'global', key: 'v_prods_quantity_order_units', value: INC },
+ { namespace: 'dwc', key: 'order_unit', value: INC },
+ { namespace: 'global', key: 'unit_of_measure', value: 'Priced Per Yard' },
+ { namespace: 'global', key: 'Sold-Per', value: 'Yard' },
+ { namespace: 'global', key: 'Minimum', value: INC },
+ ];
+ if (row.width_inches) mfs.push({ namespace: 'global', key: 'width', value: `${row.width_inches} Inches` });
+ for (const mf of mfs) { try { await rest('POST', `/products/${pid}/metafields.json`, { metafield: { ...mf, type: 'single_line_text_field' } }); } catch (e) {} }
+ // display_variant tag
+ try {
+ const { product } = await rest('GET', `/products/${pid}.json`);
+ const tags = (product.tags || '').split(',').map(t => t.trim()).filter(Boolean);
+ if (!tags.includes('display_variant')) { tags.push('display_variant'); await rest('PUT', `/products/${pid}.json`, { product: { id: Number(pid), tags: tags.join(', ') } }); }
+ } catch (e) {}
+ console.log(`✓ ${row.dw_sku} p${pid} — canonical min/step ${INC}yd + display_variant`);
+}
+
+(async () => {
+ if (!TOKEN) { console.error('no token'); process.exit(1); }
+ // all per-yard PR + Daisy + Carl products live on Shopify
+ let rows = JSON.parse(q(`SELECT COALESCE(json_agg(row_to_json(t)),'[]') FROM (
+ SELECT dw_sku, shopify_product_id, width_inches FROM phillipe_romano_catalog WHERE dw_sku ~ '^(GRS|MIC|CORK)-' AND shopify_product_id IS NOT NULL AND shopify_product_id<>''
+ UNION ALL SELECT dw_sku, shopify_product_id, NULL FROM daisy_bennett_catalog WHERE shopify_product_id IS NOT NULL AND shopify_product_id<>''
+ UNION ALL SELECT dw_sku, shopify_product_id, NULL FROM carl_robinson_catalog WHERE shopify_product_id IS NOT NULL AND shopify_product_id<>''
+ ) t;`).trim());
+ if (ONE) rows = rows.filter(r => r.dw_sku === ONE);
+ if (OFFSET) rows = rows.slice(OFFSET);
+ if (LIMIT) rows = rows.slice(0, LIMIT);
+ console.error(`set-increment on ${rows.length} products`);
+ let ok = 0, err = 0;
+ for (const r of rows) { try { await apply(r); ok++; } catch (e) { err++; console.error(`✗ ${r.dw_sku}: ${e.message}`); } await sleep(400); }
+ console.error(`DONE ok=${ok} err=${err}`);
+})();
diff --git a/scripts/wallquest-refresh/settlement-vision-grasscloth.cjs b/scripts/wallquest-refresh/settlement-vision-grasscloth.cjs
new file mode 100644
index 00000000..708325ac
--- /dev/null
+++ b/scripts/wallquest-refresh/settlement-vision-grasscloth.cjs
@@ -0,0 +1,37 @@
+// Settlement post-gen vision pass on the 87 grasscloth/veneer swatches.
+// Asks Gemini vision: does the image contain settlement-relevant botanical content?
+// (directional leaves/palm fronds/foliage patterns, bananas, grapes, birds, butterflies).
+// Textures should all return CLEAR. Any BLOCK/REVIEW is held from publish.
+const fs = require('fs'); const https = require('https');
+require('dotenv').config({ path: require('os').homedir() + '/.claude/skills/ai-analyzer/.env' });
+const KEY = process.env.GEMINI_API_KEY; if (!KEY) { console.error('no key'); process.exit(1); }
+const MODEL = 'gemini-2.5-flash';
+const URL_ = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${KEY}`;
+const recs = JSON.parse(fs.readFileSync('/tmp/grasscloth-recs.json', 'utf8'));
+const Q = `You are a legal settlement image gate for wallcovering. Look ONLY at the image. Answer JSON {"botanical":bool,"birds":bool,"butterflies":bool,"bananas":bool,"grapes":bool,"directional_foliage_pattern":bool,"verdict":"CLEAR|REVIEW|BLOCK","note":"<=8 words"}. BLOCK only if it is a repeating pattern of directional leaves/palm fronds/foliage OR shows birds/butterflies/bananas/grapes. A plain woven natural texture (grasscloth, sisal, cork, mica, paperweave, metallic) with no depicted plants/animals = CLEAR.`;
+function ask(b64) {
+ const payload = { contents: [{ parts: [{ text: Q }, { inline_data: { mime_type: 'image/jpeg', data: b64 } }] }], generationConfig: { temperature: 0, responseMimeType: 'application/json' } };
+ return new Promise((res, rej) => {
+ const u = new URL(URL_); const body = JSON.stringify(payload);
+ const r = https.request({ hostname: u.hostname, path: u.pathname + u.search, method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 60000 }, rs => { let d = ''; rs.on('data', c => d += c); rs.on('end', () => { try { const j = JSON.parse(d); if (j.error) return rej(new Error(j.error.message)); const t = j.candidates?.[0]?.content?.parts?.[0]?.text || '{}'; res(JSON.parse(t)); } catch (e) { rej(e); } }); });
+ r.on('error', rej); r.on('timeout', () => { r.destroy(); rej(new Error('timeout')); }); r.write(body); r.end();
+ });
+}
+(async () => {
+ const out = []; let clear = 0, flag = 0;
+ for (const r of recs) {
+ const p = `/tmp/grasscloth-viewer/img/${r.dw_sku}.jpeg`;
+ if (!fs.existsSync(p)) { out.push({ dw_sku: r.dw_sku, verdict: 'NOIMG' }); continue; }
+ try {
+ const v = await ask(fs.readFileSync(p).toString('base64'));
+ out.push({ dw_sku: r.dw_sku, mfr_sku: r.mfr_sku, ...v });
+ if (v.verdict === 'CLEAR') clear++; else { flag++; console.error(`⚠ ${r.dw_sku} ${r.mfr_sku}: ${v.verdict} ${v.note}`); }
+ } catch (e) { out.push({ dw_sku: r.dw_sku, verdict: 'ERR', err: String(e).slice(0, 60) }); console.error(`✗ ${r.dw_sku}: ${e.message}`); }
+ await new Promise(z => setTimeout(z, 120));
+ }
+ fs.writeFileSync('/tmp/gc-settlement.json', JSON.stringify(out, null, 1));
+ const blocked = out.filter(o => o.verdict === 'BLOCK' || o.verdict === 'REVIEW');
+ console.error(`SETTLEMENT DONE clear=${clear} flagged=${flag} errors=${out.filter(o => o.verdict === 'ERR').length}`);
+ console.error(blocked.length ? `HOLD: ${blocked.map(b => b.dw_sku + '(' + b.verdict + ')').join(', ')}` : 'ALL CLEAR — none held');
+ console.error(`cost ~$${(out.length * 0.0004).toFixed(3)}`);
+})();
diff --git a/scripts/wallquest-refresh/stage-lillian.cjs b/scripts/wallquest-refresh/stage-lillian.cjs
new file mode 100644
index 00000000..efcb2722
--- /dev/null
+++ b/scripts/wallquest-refresh/stage-lillian.cjs
@@ -0,0 +1,48 @@
+// Stage the Lillian August Natural Textured scrapes into phillipe_romano_catalog with GRS SKUs.
+// Dedup (skip mfr already in PR catalog). price_retail=cost. shopify_product_id NULL (publish next).
+const fs = require('fs'); const { execSync } = require('child_process');
+const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
+const ENVL = fs.readFileSync(require('os').homedir() + '/Projects/Designer-Wallcoverings/DW-Programming/ImportNewSkufromURL/.env.local', 'utf8');
+const PGPW = (ENVL.match(/^POSTGRES_PASSWORD=(.+)$/m) || [])[1];
+const qf = sql => { const f = `/tmp/stage-lil-${process.pid}.sql`; fs.writeFileSync(f, sql); return execSync(`PGPASSWORD='${PGPW}' ${PSQL} -h localhost -U dw_admin -d dw_unified -tAf ${f}`, { encoding: 'utf8', maxBuffer: 1e8 }); };
+const BANNED = /wallquest|chesapeake|brewster|nextwall|seabrook|voyage|william\s*morris|miyabi|malibu/ig;
+const scrub = s => String(s == null ? '' : s).replace(BANNED, '').replace(/\s{2,}/g, ' ').trim();
+const S = s => "'" + String(s == null ? '' : s).replace(/'/g, "''") + "'";
+const parseInches = w => { const m = String(w || '').match(/([\d.]+)\s*in/i); return m ? parseFloat(m[1]) : null; };
+const prefixOf = m => /cork/i.test(m) ? 'CORK' : /mica|metal|leaf/i.test(m) ? 'MIC' : 'GRS';
+
+const rows = fs.readFileSync('/tmp/lillian-ok-snapshot.jsonl', 'utf8').split('\n').filter(Boolean).map(JSON.parse)
+ .sort((a, b) => a.mfr_sku.localeCompare(b.mfr_sku));
+
+// dedup: skip mfr already staged in PR catalog (by url slug OR mfr_sku)
+const slugOf = r => (r.url || '').split('/').pop().split('?')[0].toLowerCase();
+const existSlugs = new Set(qf("SELECT string_agg(lower(regexp_replace(product_url,'^.*/','')),'|') FROM phillipe_romano_catalog WHERE product_url IS NOT NULL AND product_url<>'';").trim().split('|').filter(Boolean));
+const existMfr = new Set(qf("SELECT string_agg(mfr_sku,'|') FROM phillipe_romano_catalog;").trim().split('|').filter(Boolean));
+const fresh = rows.filter(r => { const sl = slugOf(r); return sl && !existSlugs.has(sl) && !existMfr.has(r.mfr_sku); });
+
+// next SKU numbers per prefix
+const maxNum = p => Math.max(parseInt(qf(`SELECT COALESCE(max(NULLIF(regexp_replace(dw_sku,'\\D','','g'),'')::bigint),0) FROM phillipe_romano_catalog WHERE dw_sku ~ '^${p}-9';`).trim() || '0', 10), p === 'CORK' ? 99030 : p === 'MIC' ? 98626 : 98699);
+let n = { GRS: maxNum('GRS') + 1, CORK: maxNum('CORK') + 1, MIC: maxNum('MIC') + 1 };
+
+const values = fresh.map(r => {
+ const s = r.specs || {};
+ const material = scrub(s.Material) || 'Grasscloth and Naturals';
+ const pfx = prefixOf(material);
+ const dw = `${pfx}-${n[pfx]++}`;
+ const nm = scrub(r.name);
+ // "Lillian August Grasscloth - Havana" -> pattern "Lillian August Grasscloth", color "Havana"
+ const parts = nm.split(' - ');
+ const color = parts.length > 1 ? parts.slice(1).join(' - ') : (scrub(r.pattern_name) || '');
+ const pattern = parts.length > 1 ? parts[0] : (scrub(r.pattern_name) || nm);
+ const cost = parseFloat(String(r.priceValue).replace(/,/g, ''));
+ const img = (r.imgs || []).find(u => /_650\./.test(u)) || (r.imgs || [])[0] || '';
+ const allImgs = JSON.stringify(r.imgs || []);
+ const width = s['Roll Width'] || null; const win = parseInches(width);
+ const desc = scrub(`${pattern} natural wallcovering by Phillipe Romano. Sold by the yard in 8-yard increments (one roll = 8 yd${win ? ', ' + win + '" wide' : ''}).`);
+ const tags = JSON.stringify([...new Set(['Phillipe Romano', 'Natural Wallcovering', pfx === 'CORK' ? 'Cork' : pfx === 'MIC' ? 'Mica' : 'Grasscloth', material, color, pattern, 'Sold Per Yard'].map(scrub).filter(Boolean))]);
+ return `(${S(r.mfr_sku)},${S(dw)},${S(pattern)},${S(color)},${S(pattern)},'Wallcovering',${S(width)},${S(material)},${cost},${S(img)},${S(allImgs)},${S(r.url)},${win == null ? 'NULL' : win},${S(desc)},${S(tags)}::jsonb,false)`;
+});
+if (!values.length) { console.log('nothing fresh to stage'); process.exit(0); }
+const stmt = `INSERT INTO phillipe_romano_catalog (mfr_sku,dw_sku,pattern_name,color_name,collection,product_type,width,material,price_retail,image_url,all_images,product_url,width_inches,ai_description,ai_tags,on_shopify) VALUES\n${values.join(',\n')} ON CONFLICT (mfr_sku) DO NOTHING;`;
+const res = qf(stmt);
+console.log('staged:', res.trim() || 'ok', '| fresh rows:', fresh.length, '| assigned GRS from', 'up to', JSON.stringify(n));
← 3c20ad3b Daisy Bennett: room-QA sweep (Gemini vision) — 62/62 rooms,
·
back to Designer Wallcoverings
·
WallQuest title material-append + renumber planners 91e639ed →