← back to Japan Enrich
japan viewer: '⤓ Import' button — enqueue SKUs for dw_unified onboarding (Shopify gated)
ab44253fe0dc4461d6f7601840294be756175f92 · 2026-07-06 13:45:27 -0700 · Steve
Per-card + 'Import filtered' bulk button POST to /api/import → append INTENT to
staging/import-queue.jsonl (local, reversible, toggle to un-queue; gitignored).
db/promote-import-queue.js promotes the queue into per-vendor *_catalog tables
with us_distributor set, on_shopify=false — dry-run by default, --commit gated,
never publishes to Shopify. Queue state reloads on refresh; handler now async.
Files touched
M .gitignoreA db/promote-import-queue.jsM viewer-local/public/index.htmlM viewer-local/server.js
Diff
commit ab44253fe0dc4461d6f7601840294be756175f92
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Jul 6 13:45:27 2026 -0700
japan viewer: '⤓ Import' button — enqueue SKUs for dw_unified onboarding (Shopify gated)
Per-card + 'Import filtered' bulk button POST to /api/import → append INTENT to
staging/import-queue.jsonl (local, reversible, toggle to un-queue; gitignored).
db/promote-import-queue.js promotes the queue into per-vendor *_catalog tables
with us_distributor set, on_shopify=false — dry-run by default, --commit gated,
never publishes to Shopify. Queue state reloads on refresh; handler now async.
---
.gitignore | 1 +
db/promote-import-queue.js | 79 ++++++++++++++++++++++++++++++++++++++++++
viewer-local/public/index.html | 36 ++++++++++++++++++-
viewer-local/server.js | 50 +++++++++++++++++++++++++-
4 files changed, 164 insertions(+), 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 1074a54..9ee4297 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,4 @@ images/
.DS_Store
__pycache__/
*.pyc
+staging/import-queue.jsonl
diff --git a/db/promote-import-queue.js b/db/promote-import-queue.js
new file mode 100644
index 0000000..5fff4eb
--- /dev/null
+++ b/db/promote-import-queue.js
@@ -0,0 +1,79 @@
+#!/usr/bin/env node
+/**
+ * Promote the viewer's import queue (staging/import-queue.jsonl) into dw_unified.
+ *
+ * GATED — this is the customer-data write half of the "⤓ Import" button. It is
+ * DRY-RUN by default (prints the planned upserts, touches nothing, zero deps).
+ * `--commit` performs the writes and requires `pg` + DATABASE_URL. Even on
+ * --commit it ONLY stages rows PostgreSQL-side (on_shopify=false / status draft),
+ * per DW's "PostgreSQL BEFORE Shopify" rule — it NEVER publishes to Shopify.
+ * Do not run --commit against canonical dw_unified without Steve's go.
+ *
+ * node db/promote-import-queue.js # dry-run (safe)
+ * node db/promote-import-queue.js --commit # gated: writes to dw_unified
+ */
+const fs = require('fs');
+const path = require('path');
+
+const QUEUE = path.join(__dirname, '..', 'staging', 'import-queue.jsonl');
+const COMMIT = process.argv.includes('--commit');
+
+// source → target per-vendor catalog table + vendor_code (matches DW *_catalog convention)
+const TARGET = {
+ sangetsu: { table: 'sangetsu_catalog', vendor_code: 'sangetsu' },
+ lilycolor: { table: 'lilycolor_catalog', vendor_code: 'lilycolor' },
+ greenland: { table: 'greenland_catalog', vendor_code: 'greenland' },
+};
+
+function rows() {
+ if (!fs.existsSync(QUEUE)) return [];
+ return fs.readFileSync(QUEUE, 'utf8').trim().split('\n').filter(Boolean)
+ .map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
+}
+
+async function main() {
+ const q = rows();
+ if (!q.length) { console.log('import queue empty — nothing to promote.'); return; }
+ const byVendor = {};
+ for (const r of q) (byVendor[r.source] = byVendor[r.source] || []).push(r);
+ console.log(`Queue: ${q.length} SKUs across ${Object.keys(byVendor).length} vendor(s).`);
+ for (const [src, list] of Object.entries(byVendor)) {
+ const t = TARGET[src];
+ console.log(`\n${src} → ${t ? t.table : '(no mapping!)'} (${list.length} SKUs)`);
+ for (const r of list.slice(0, 5)) console.log(` ${r.sku} us_distributor=${r.distributor||'(none)'} ${r.title||''}`);
+ if (list.length > 5) console.log(` … +${list.length - 5} more`);
+ }
+ if (!COMMIT) {
+ console.log('\nDRY-RUN — no writes. Re-run with --commit (gated) to stage into dw_unified.');
+ console.log('Each row upserts (vendor_code, mfr_sku) with: pattern_name, collection, product_url,');
+ console.log('image_url, us_distributor=<distributor>, on_shopify=false. Shopify publish stays separate/gated.');
+ return;
+ }
+ // ---- gated write path ----
+ const DSN = process.env.DATABASE_URL;
+ if (!DSN) { console.error('COMMIT aborted: DATABASE_URL not set.'); process.exit(1); }
+ let Client;
+ try { ({ Client } = require('pg')); } catch { console.error('COMMIT needs `pg` — run: npm i pg'); process.exit(1); }
+ const db = new Client({ connectionString: DSN });
+ await db.connect();
+ let n = 0;
+ for (const [src, list] of Object.entries(byVendor)) {
+ const t = TARGET[src]; if (!t) { console.warn(`skip ${src}: no target table`); continue; }
+ const exists = await db.query('select to_regclass($1) as t', [`public.${t.table}`]);
+ if (!exists.rows[0].t) { console.warn(`skip ${src}: ${t.table} does not exist (create it first — gated schema step)`); continue; }
+ for (const r of list) {
+ await db.query(
+ `insert into ${t.table} (vendor_code, mfr_sku, pattern_name, collection, product_url, image_url, us_distributor, on_shopify)
+ values ($1,$2,$3,$4,$5,$6,$7,false)
+ on conflict (vendor_code, mfr_sku) do update set
+ pattern_name=excluded.pattern_name, collection=excluded.collection,
+ product_url=excluded.product_url, image_url=excluded.image_url,
+ us_distributor=excluded.us_distributor`,
+ [t.vendor_code, r.sku, r.title, r.collection, r.url, r.image, r.distributor]);
+ n++;
+ }
+ }
+ await db.end();
+ console.log(`\nCommitted ${n} rows to dw_unified (on_shopify=false). Shopify publish remains a separate gated step.`);
+}
+main().catch((e) => { console.error(e); process.exit(1); });
diff --git a/viewer-local/public/index.html b/viewer-local/public/index.html
index 655ba13..395a00d 100644
--- a/viewer-local/public/index.html
+++ b/viewer-local/public/index.html
@@ -90,6 +90,11 @@ body.imgonly .grid:not(.list) .card .b{display:none}
.chip.dist:hover{background:#5eead41a}
.chip.dist.direct{border-color:var(--line);color:var(--mut)}
.chip.dist.direct:hover{border-color:var(--acc);color:var(--ink)}
+/* import button — queue a SKU for onboarding (green when queued) */
+.chip.imp{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
+.chip.imp:hover{border-color:var(--acc);color:var(--ink)}
+.chip.imp.on{border-color:var(--pub);color:var(--pub)}
+#qbadge{color:var(--pub);font-weight:600}
.chip.toggle{background:transparent;color:var(--mut);border-color:var(--line);cursor:pointer;user-select:none}
.chip.toggle:hover{border-color:var(--acc);color:var(--ink)}
/* colorway sibling dots — the "other colors of this pattern" */
@@ -158,6 +163,8 @@ body.listmode #listhead{display:grid}
<div class="ctl"><label class="muted">Density</label>
<input type="range" id="density" min="3" max="20" step="1"></div>
<button class="btn" id="view">▤ List view</button>
+ <button class="btn" id="impfiltered" title="Queue every SKU currently in view for import into dw_unified (promotion to Shopify stays gated)">⤓ Import filtered</button>
+ <span class="muted" id="qbadge"></span>
</header>
<div id="layout">
<aside aria-label="filters">
@@ -186,7 +193,7 @@ body.listmode #listhead{display:grid}
<script>
const $=s=>document.querySelector(s);
const grid=$('#grid');
-let ALL=[], VIEW=[], shown=0, FAMILY_ORDER=[], PRICE_ORDER=[];
+let ALL=[], VIEW=[], shown=0, FAMILY_ORDER=[], PRICE_ORDER=[], QUEUED=new Set();
const PAGE=120;
const FAMILY_DOT={'Red':'#c0392b','Orange/Brown':'#a8642a','Yellow':'#d4ac0d','Green':'#4e8b3b','Teal':'#1aa5a5',
'Blue':'#2e6fb0','Purple':'#7d5ba6','Pink':'#d87aa5','Neutral/Beige':'#cdbd96','Black':'#1a1a1a','Grey':'#9a9aa2','White':'#f2f2f2'};
@@ -273,6 +280,7 @@ function card(p){
<div class="chips">
${p.color_family?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_family)}</span>`:''}
${dist}${cwDots}${price}
+ <span class="chip imp${QUEUED.has(p.sku)?' on':''}" data-sku="${esc(p.sku)}" onclick="event.stopPropagation();toggleImport('${esc(p.sku)}',this)" title="Queue this SKU for import into dw_unified (promotion to Shopify is gated)">${QUEUED.has(p.sku)?'✓ queued':'⤓ import'}</span>
<span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
</div>
<span class="lc" title="Color family">${esc(p.color_family||'')}</span>
@@ -303,6 +311,30 @@ grid.addEventListener('click',e=>{
const c=e.target.closest('.card[data-href]');
if(c&&c.dataset.href)window.open(c.dataset.href,'_blank','noopener,noreferrer');
});
+// ── import queue: enqueue INTENT for onboarding (promotion to dw_unified/Shopify is gated) ──
+function updateQueueBadge(){const el=$('#qbadge');if(el)el.textContent=QUEUED.size?`⤓ ${QUEUED.size.toLocaleString()} queued`:'';}
+async function toggleImport(sku,el){
+ const on=QUEUED.has(sku);
+ try{
+ const r=await fetch(location.origin+(on?'/api/import/remove':'/api/import'),
+ {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({sku})}).then(r=>r.json());
+ if(on)QUEUED.delete(sku); else QUEUED.add(sku);
+ if(el){el.classList.toggle('on',!on);el.textContent=on?'⤓ import':'✓ queued';}
+ updateQueueBadge();
+ }catch(e){}
+}
+window.toggleImport=toggleImport;
+async function importFiltered(){
+ const skus=VIEW.map(p=>p.sku);
+ if(!skus.length)return;
+ if(!confirm(`Queue ${skus.length.toLocaleString()} SKUs (current view) for import into dw_unified?\n\nThis only records intent — promotion to Shopify stays gated.`))return;
+ try{
+ await fetch(location.origin+'/api/import',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({skus})});
+ const q=await fetch(location.origin+'/api/import-queue').then(r=>r.json());
+ QUEUED=new Set(q.skus||[]); updateQueueBadge(); applyFilters();
+ }catch(e){}
+}
+$('#impfiltered').onclick=importFiltered;
function copyTxt(btn){
const t=btn.dataset.copy||'';
navigator.clipboard.writeText(t).then(()=>{
@@ -438,6 +470,8 @@ async function loadAll(){
src_label:SRC_LABEL[p.source]||p.source,
enr:p.enriched?'Enriched':'Unenriched',
}));
+ try{const q=await fetch(location.origin+'/api/import-queue').then(r=>r.json());QUEUED=new Set(q.skus||[]);}catch(e){}
+ updateQueueBadge();
buildFacetCache(); applyFilters();
}
loadFX().finally(loadAll);
diff --git a/viewer-local/server.js b/viewer-local/server.js
index 7b3916e..09b404b 100644
--- a/viewer-local/server.js
+++ b/viewer-local/server.js
@@ -26,6 +26,21 @@ const readJsonl = (f) => (fs.existsSync(f)
? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
: []);
+// ── import queue (local, append-only, REVERSIBLE) ───────────────────────────────
+// The "⤓ Import" button enqueues a scoped SKU here. This records INTENT only —
+// promotion into canonical dw_unified / Shopify is a SEPARATE, Steve-gated step
+// (db/promote-import-queue.js). Nothing customer-facing happens from the viewer.
+const IMPORT_QUEUE = path.join(__dirname, '..', 'staging', 'import-queue.jsonl');
+const readQueue = () => readJsonl(IMPORT_QUEUE);
+const writeQueue = (arr) => fs.writeFileSync(IMPORT_QUEUE, arr.map((r) => JSON.stringify(r)).join('\n') + (arr.length ? '\n' : ''));
+function readBody(req) {
+ return new Promise((resolve) => {
+ let b = ''; req.on('data', (c) => { b += c; if (b.length > 8e6) req.destroy(); });
+ req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
+ req.on('error', () => resolve({}));
+ });
+}
+
// --- enrichment overlay (hex / color_family / style / material) ----------------
// The local-hybrid enricher ($0, Pillow + qwen2.5vl) emits full staging rows + an
// `enrich` block keyed by mfr_sku. Read whichever enriched file(s) exist (later wins).
@@ -376,7 +391,7 @@ function sendJson(req, res, obj) {
res.end(body);
}
-const server = http.createServer((req, res) => {
+const server = http.createServer(async (req, res) => {
if (REQUIRE_AUTH && !authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
return res.end('Authentication required');
@@ -410,6 +425,39 @@ const server = http.createServer((req, res) => {
res.end(JSON.stringify({ id: scope.id, label: scope.label, sub: scope.sub, tabs: scope.tabs }));
return;
}
+ // ── import queue: enqueue INTENT for onboarding (reversible; no prod write) ──
+ if (u.pathname === '/api/import-queue') {
+ const q = readQueue();
+ return sendJson(req, res, { count: q.length, skus: q.map((r) => r.sku) });
+ }
+ if (u.pathname === '/api/import' && req.method === 'POST') {
+ const body = await readBody(req);
+ const want = Array.isArray(body.skus) ? body.skus : (body.sku ? [body.sku] : []);
+ const bySku = new Map(POOL.map((r) => [String(r.sku), r])); // scope pool: china can't queue a japan sku
+ const q = readQueue();
+ const have = new Set(q.map((r) => String(r.sku)));
+ let queued = 0, already = 0, missing = 0;
+ for (const raw of want) {
+ const sku = String(raw);
+ if (have.has(sku)) { already++; continue; }
+ const r = bySku.get(sku);
+ if (!r) { missing++; continue; }
+ q.push({ queued_at: new Date().toISOString(), scope: scope.id, source: r.source, sku: r.sku,
+ distributor: r.distributor || null, dist_direct: !!r.dist_direct, title: r.title || null,
+ collection: r.collection || null, url: r.url || null, image: r.image || null,
+ price_yen: r.price_yen || null, status: 'queued' });
+ have.add(sku); queued++;
+ }
+ writeQueue(q);
+ return sendJson(req, res, { queued, already, missing, count: q.length });
+ }
+ if (u.pathname === '/api/import/remove' && req.method === 'POST') {
+ const body = await readBody(req);
+ const rm = new Set((Array.isArray(body.skus) ? body.skus : (body.sku ? [body.sku] : [])).map(String));
+ const q = readQueue().filter((r) => !rm.has(String(r.sku)));
+ writeQueue(q);
+ return sendJson(req, res, { count: q.length, removed: [...rm] });
+ }
if (u.pathname === '/api/skus') {
const f = parseFilters(u);
// host scope overrides the user-supplied source so a stale localStorage tab can't
← 2cfccdd japan viewer: make distributor chip a link to the SKU's dist
·
back to Japan Enrich
·
japan viewer: import-queue drawer — list queued SKUs, per-ro 9f2be43 →