[object Object]

← 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

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 →