[object Object]

← back to New Import Viewer

Full action suite per chip + select 1β†’all: every card renders all 5 actions (disabled-with-reason; launch hard-disabled on linked rows = dup guard) + copy SKU/DW + vendor/admin/storefront links; visible πŸ•“ crawl date+time on grid cards; /api/ids endpoint + first-N / shown / ALL-matching selection; bulk staging chunks through the 5k cap (includes parallel-session netnew predicate)

183373528154b7f1826a0b44ed4c67d7439886e8 Β· 2026-06-09 19:22:26 -0700 Β· SteveStudio2

Files touched

Diff

commit 183373528154b7f1826a0b44ed4c67d7439886e8
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 9 19:22:26 2026 -0700

    Full action suite per chip + select 1β†’all: every card renders all 5 actions (disabled-with-reason; launch hard-disabled on linked rows = dup guard) + copy SKU/DW + vendor/admin/storefront links; visible πŸ•“ crawl date+time on grid cards; /api/ids endpoint + first-N / shown / ALL-matching selection; bulk staging chunks through the 5k cap (includes parallel-session netnew predicate)
---
 public/index.html | 106 +++++++++++++++++++++++++++++++++++++++++++++---------
 server.js         |  36 +++++++++++++++++--
 2 files changed, 122 insertions(+), 20 deletions(-)

diff --git a/public/index.html b/public/index.html
index 499a90b..72a2913 100644
--- a/public/index.html
+++ b/public/index.html
@@ -11,8 +11,9 @@ header{position:sticky;top:0;z-index:9;background:linear-gradient(180deg,#0c0d12
 h1{font-size:15px;margin:0;font-weight:700;letter-spacing:.2px}
 .pill{background:var(--new);color:#1a1205;font-weight:700;border-radius:999px;padding:2px 10px;font-size:12px}
 .muted{color:var(--mut)}
-select,input[type=text]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 9px;font-size:13px}
+select,input[type=text],input[type=number]{background:var(--panel);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:6px 9px;font-size:13px}
 input[type=text]{min-width:180px}
+input[type=number]{width:78px}
 .ctl{display:flex;align-items:center;gap:7px}
 input[type=range]{accent-color:var(--acc)}
 /* Live-writes arm toggle β€” defaults OFF every page load (per-session). OFF = safe
@@ -127,6 +128,9 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
 /* list-only cells: hidden in the default card grid, shown only in list view */
 .lsku,.lcrawl{display:none}
 .grid.list .lsku,.grid.list .lcrawl{display:block}
+/* grid-card πŸ•“ chip duplicates the list view's lcrawl column β€” hide it there */
+.sub.when{font-size:11px}
+.grid.list .sub.when{display:none}
 </style></head>
 <body>
 <div id="livestrip" title="Live Shopify writes are armed"></div>
@@ -160,7 +164,15 @@ body.armed .chip.act-toggle{border-color:#fca5a5;color:#fecaca}
   <div class="ctl"><label class="muted">Density</label>
     <input type="range" id="density" min="3" max="10" step="1"></div>
   <button class="btn" id="view">β–€ List view</button>
-  <button class="btn" id="selall">β˜‘ Select all shown</button>
+  <!-- select 1 β†’ all: first-N (number input), all loaded cards, or the ENTIRE
+       filtered set (ids fetched server-side β€” selection isn't limited to what's
+       rendered). Bulk actions on big selections chunk through the 5k API cap. -->
+  <div class="ctl"><label class="muted">Select</label>
+    <input type="number" id="selcount" min="1" step="1" value="1" title="how many to select (first N in current sort)">
+    <button class="btn" id="selN" title="select the first N matching the current filter">β˜‘ first N</button>
+    <button class="btn" id="selall" title="select every card currently loaded on the page">β˜‘ shown</button>
+    <button class="btn" id="selmatch" title="select EVERY row matching the current filter β€” not just loaded cards">β˜‘ ALL matching</button>
+  </div>
   <button class="armtoggle" id="armtoggle" style="display:none" onclick="toggleArm()">πŸ”’ Live writes: OFF</button>
 </header>
 <!-- catalog-health strip: threshold-colored, clickable quick-filters (D) -->
@@ -212,13 +224,23 @@ function renderArm(){
 function fmtVendor(v){return String(v||'').replace(/_/g,' ').replace(/\b\w/g,c=>c.toUpperCase());}
 // Build the action-button row; live-armed actions get the danger treatment.
 function buildActs(id,st){
-  const btns=actionsFor(st).map(([a,label,cls])=>{
-    const live=isLive(a.split(':')[0]);
-    return `<button class="act ${cls}${live?' live':''}" onclick="rowAction(${id},'${a}',this)">${live?'⚑ ':''}${label}${live?' · LIVE':''}</button>`;
+  const defs=actionsFor(st);
+  const btns=defs.map(([a,label,cls,dis,why])=>{
+    const live=!dis&&isLive(a.split(':')[0]);
+    return `<button class="act ${cls}${live?' live':''}"${dis?` disabled title="${esc(why||'')}"`:''} onclick="rowAction(${id},'${a}',this)">${live?'⚑ ':''}${label}${live?' · LIVE':''}</button>`;
   });
-  const hasLive=actionsFor(st).some(([a])=>isLive(a.split(':')[0]));
+  const hasLive=defs.some(([a,,,dis])=>!dis&&isLive(a.split(':')[0]));
   return `<div class="acts${hasLive?' haslive':''}">${btns.join('')}</div>`;
 }
+// Copy-to-clipboard for the per-card utility buttons (SKU / DW SKU). Value
+// travels in data-copy so quote-bearing SKUs can't break the onclick string.
+function copyTxt(btn){
+  const t=btn.dataset.copy||'';
+  navigator.clipboard.writeText(t).then(()=>{
+    const o=btn.textContent; btn.textContent='βœ“ copied';
+    setTimeout(()=>{btn.textContent=o;},1200);
+  });
+}
 // Rewrite a card's status chip + action buttons in place after its state changes.
 function updateCardStatus(id,st){
   const c=grid.querySelector('.card[data-id="'+id+'"]'); if(!c)return;
@@ -238,14 +260,19 @@ const dens=localStorage.getItem('nip.density')||'6'; $('#density').value=dens;
 document.documentElement.style.setProperty('--cols',dens);
 function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
 const SHOP='https://admin.shopify.com/store/designer-laboratory-sandbox/products/';
-// Which staged actions make sense for a given state.
+// FULL SUITE β€” every card renders every action; impossible ones are disabled
+// with the reason in the tooltip instead of hidden. [action, label, class,
+// disabled, why]. Launch is hard-disabled on already-linked rows (staging a
+// launch there would create a DUPLICATE product β€” the one unforgivable sin).
 function actionsFor(st){
-  if(st==='NEW') return [['launch:draft','Stage β†’ Draft',''],['launch:active','Stage β†’ Active','pub']];
-  if(st==='PUBLISHED') return [['unpublish:','Unpublish','unpub'],['archive:','Archive','arch']];
-  if(st==='UNPUBLISHED') return [['publish:','Publish','pub'],['archive:','Archive','arch']];
-  if(st==='ARCHIVED') return [['publish:','Publish (restore)','pub']];
-  if(st==='ON_SHOPIFY') return [['publish:','Publish','pub'],['unpublish:','Unpublish','unpub'],['archive:','Archive','arch']];
-  return [];
+  const linked=st!=='NEW';
+  return [
+    ['launch:draft','πŸš€ Stage β†’ Draft','',linked,'already linked to Shopify β€” launching again would create a duplicate'],
+    ['launch:active','πŸš€ Stage β†’ Active','pub',linked,'already linked to Shopify β€” launching again would create a duplicate'],
+    ['publish:','🟒 Publish','pub',!linked||st==='PUBLISHED',!linked?'not on Shopify yet β€” stage a launch first':'already published'],
+    ['unpublish:','βšͺ Unpublish','unpub',!linked||st==='UNPUBLISHED',!linked?'not on Shopify yet β€” stage a launch first':'already unpublished'],
+    ['archive:','πŸ”΄ Archive','arch',!linked||st==='ARCHIVED',!linked?'not on Shopify yet β€” stage a launch first':'already archived'],
+  ];
 }
 function card(p){
   const title=esc(p.pattern||p.sku||'(untitled)')+(p.color?(', '+esc(p.color)):'');
@@ -257,12 +284,20 @@ function card(p){
   const shopLink=p.handle?`<a class="lnk" href="${SHOP}${esc(p.handle)}" target="_blank" rel="noopener noreferrer">Shopify admin β†—</a>`
                 :(p.shopifyId?`<a class="lnk" href="${SHOP}${esc(p.shopifyId)}" target="_blank" rel="noopener noreferrer">Shopify admin β†—</a>`:'');
   const vendLink=p.url?`<a class="lnk" href="${esc(p.url)}" target="_blank" rel="noopener noreferrer">vendor β†—</a>`:'';
+  const storeLink=p.handle?`<a class="lnk" href="https://www.designerwallcoverings.com/products/${esc(p.handle)}" target="_blank" rel="noopener noreferrer">storefront β†—</a>`:'';
   const acts=buildActs(p.id,st);
+  // utility row β€” copy SKUs + every outbound link, always present in βš™ actions
+  const utils=`<div class="acts">
+    ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">πŸ“‹ SKU</button>`:''}
+    ${p.dwSku?`<button class="act" data-copy="${esc(p.dwSku)}" onclick="copyTxt(this)" title="copy DW SKU">πŸ“‹ DW</button>`:''}
+    ${vendLink}${shopLink}${storeLink}
+  </div>`;
   return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}">
     <label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
     ${img}<div class="b">
     <span class="vend">${esc(fmtVendor(p.vendor))}</span>
     <div class="ttl">${title}</div>
+    <span class="sub when" title="last crawl: ${esc(p.crawled||'unknown')}${p.firstSeen?(' Β· first seen: '+esc(p.firstSeen)):''}">πŸ•“ ${esc(p.crawled||p.firstSeen||'β€”')}</span>
     <div class="chips">
       <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
       <span class="chip toggle" onclick="togglePane(this,'info')">β“˜ details</span>
@@ -278,6 +313,7 @@ function card(p){
     </div>
     <div class="actpane">
       ${acts}
+      ${utils}
       <div class="actmsg" id="msg-${p.id}"></div>
     </div>
   </div></div>`;
@@ -339,6 +375,32 @@ function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.c
 $('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('nip.view')==='list'?'grid':'list');applyView();};
 applyView();
 $('#selall').onclick=()=>{grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;if(!id)return;sel.add(id);c.classList.add('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=true;});syncSel();};
+// ── select 1 β†’ all ── server-side id fetch so selection isn't bounded by what's
+// rendered. "first N" follows the current sort; "ALL matching" = entire filter.
+async function fetchIds(limit){
+  const p=new URLSearchParams({status:$('#status').value,vendor:$('#vendor').value,sort:$('#sort').value,q:$('#q').value});
+  if(limit)p.set('limit',limit);
+  const d=await(await fetch('/api/ids?'+p)).json();
+  return d.ids||[];
+}
+function applySelIds(ids){
+  ids.forEach(id=>sel.add(id));
+  grid.querySelectorAll('.card').forEach(c=>{const id=+c.dataset.id;const on=sel.has(id);c.classList.toggle('sel',on);const cb=c.querySelector('.pick input');if(cb)cb.checked=on;});
+  syncSel();
+}
+$('#selN').onclick=async()=>{
+  const n=Math.max(1,parseInt($('#selcount').value,10)||1);
+  const b=$('#selN'); b.disabled=true;
+  try{applySelIds(await fetchIds(n));}finally{b.disabled=false;}
+};
+$('#selmatch').onclick=async()=>{
+  const b=$('#selmatch'); b.disabled=true;
+  try{
+    const ids=await fetchIds(0);
+    if(!ids.length||!confirm(`Select ALL ${ids.length.toLocaleString()} rows matching the current filter?\n\n(Selection includes rows beyond the loaded page; bulk actions always stage, never write live.)`))return;
+    applySelIds(ids);
+  }finally{b.disabled=false;}
+};
 $('#clearsel').onclick=()=>{sel.clear();grid.querySelectorAll('.card.sel').forEach(c=>{c.classList.remove('sel');const cb=c.querySelector('.pick input');if(cb)cb.checked=false;});syncSel();};
 async function rowAction(id,spec,btn){
   const [action,mode]=spec.split(':');
@@ -369,11 +431,21 @@ $('#bulkgo').onclick=async()=>{
   const [action,mode]=$('#bulkact').value.split(':');
   if(!confirm(`${action.toUpperCase()} ${ids.length} product(s)${mode?(' as '+mode.toUpperCase()):''}?\n\nBulk actions ALWAYS stage to the launch queue β€” bulk never writes live to the store (single-card only).`))return;
   $('#bulkgo').disabled=true; $('#bulkmsg').textContent='staging…';
+  // server caps 5,000 ids per request β€” chunk so "select ALL matching" (50k+)
+  // stages completely instead of silently truncating at the first 5k.
   try{
-    const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids,action,mode,stageOnly:true,arm:false})});
-    const d=await r.json(); if(d.error)throw new Error(d.error);
-    const vlist=Object.entries(d.byVendor||{}).map(([v,n])=>`${v}:${n}`).join(', ');
-    $('#bulkmsg').textContent=`βœ… staged ${d.staged} ${action} β€” ${vlist}`;
+    let staged=0; const byVendor={};
+    for(let i=0;i<ids.length;i+=5000){
+      const ch=ids.slice(i,i+5000);
+      $('#bulkmsg').textContent=`staging… ${Math.min(i+ch.length,ids.length).toLocaleString()} / ${ids.length.toLocaleString()}`;
+      const r=await fetch('/api/action',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({ids:ch,action,mode,stageOnly:true,arm:false})});
+      const d=await r.json(); if(d.error)throw new Error(d.error);
+      staged+=d.staged||0;
+      Object.entries(d.byVendor||{}).forEach(([v,n])=>byVendor[v]=(byVendor[v]||0)+n);
+    }
+    const vents=Object.entries(byVendor);
+    const vlist=vents.slice(0,8).map(([v,n])=>`${v}:${n}`).join(', ')+(vents.length>8?` … +${vents.length-8} vendors`:'');
+    $('#bulkmsg').textContent=`βœ… staged ${staged.toLocaleString()} ${action} β€” ${vlist}`;
   }catch(e){$('#bulkmsg').textContent='βœ— '+e.message;}
   $('#bulkgo').disabled=false;
 };
diff --git a/server.js b/server.js
index ef072aa..3e36781 100644
--- a/server.js
+++ b/server.js
@@ -70,6 +70,17 @@ async function shopifyWrite(action, productId) {
 
 // NEW-to-import predicate β€” the single source of truth for "new vs Shopify".
 const NEW_PRED = "(vc.sync_status='new' OR ((vc.on_shopify IS NOT TRUE) AND vc.shopify_product_id IS NULL))";
+// NET-NEW = flagged-new MINUS anything whose mfr pattern number is ALREADY on
+// Shopify (Steve 2026-06-09: "new - on shopify = new not on shopify"). The plain
+// NEW count is inflated β€” rows can carry sync_status='new' while the SAME mfr_sku
+// is already live on Shopify under a different/severed link. This subtracts those
+// by matching the manufacturer pattern number (normalized) against shopify_products,
+// so NET-NEW is the genuinely-importable set. NEVER DUPLICATE.
+// Index-friendly equality (idx_shopify_products_mfr_sku) β†’ 240ms, not a seq scan.
+// vendor_catalog + shopify_products store mfr_sku with consistent casing, so raw
+// equality catches the overlap; a trim() guard handles stray whitespace cheaply.
+const NETNEW_PRED = `(${NEW_PRED} AND vc.mfr_sku IS NOT NULL AND vc.mfr_sku <> '' AND NOT EXISTS (
+  SELECT 1 FROM shopify_products sx WHERE sx.mfr_sku = vc.mfr_sku))`;
 // Resolve one shopify_products row per vendor_catalog row. shopify_id is a
 // gid://shopify/Product/<n> string (UNIQUE index); vc.shopify_product_id is the
 // bare bigint. Construct the gid on the vc side so the unique index on
@@ -90,6 +101,7 @@ const SHOP_STATUS = `CASE
   ELSE 'NEW' END`;
 // Status filters the UI can request. Keys map to a WHERE fragment.
 const STATUS_FILTERS = {
+  netnew:      NETNEW_PRED,
   new:         NEW_PRED,
   all:         'TRUE',
   onshopify:   '(vc.shopify_product_id IS NOT NULL OR vc.on_shopify IS TRUE)',
@@ -164,7 +176,8 @@ const server = http.createServer((req, res) => {
           count(*) FILTER (WHERE upper(sp.status)='ARCHIVED'),
           count(*),
           to_char(max(vc.last_scraped_at),'YYYY-MM-DD HH24:MI'),
-          count(*) FILTER (WHERE vc.image_url IS NULL OR vc.image_url='')
+          count(*) FILTER (WHERE vc.image_url IS NULL OR vc.image_url=''),
+          count(*) FILTER (WHERE ${NETNEW_PRED})
         FROM vendor_catalog vc ${SP_JOIN}`)[0] || [];
       const armable = LIVE && !!SHOP_TOKEN && LIVE_ACTIONS.size > 0;
       return send(res, 200, JSON.stringify({
@@ -175,9 +188,11 @@ const server = http.createServer((req, res) => {
         armable,
         liveActions: armable ? [...LIVE_ACTIONS] : [],
         store: SHOP_STORE,
-        counts: { new: +r[0] || 0, onshopify: +r[1] || 0, published: +r[2] || 0,
+        counts: { netnew: +r[8] || 0, new: +r[0] || 0, onshopify: +r[1] || 0, published: +r[2] || 0,
                   unpublished: +r[3] || 0, archived: +r[4] || 0, all: +r[5] || 0,
-                  noimage: +r[7] || 0 },
+                  noimage: +r[7] || 0,
+                  // how many "new" rows are actually already on Shopify (the dup overlap)
+                  newButOnShopify: (+r[0] || 0) - (+r[8] || 0) },
         newestCrawl: r[6] || null,
       }));
     }
@@ -216,6 +231,21 @@ const server = http.createServer((req, res) => {
       }));
       return send(res, 200, JSON.stringify({ count: cnt, offset, limit, items }));
     }
+    // Bare id list for the current filter β€” powers "select first N / ALL matching"
+    // without rendering the rows. Same WHERE/ORDER as the listing so "first N"
+    // means the first N the user would see. ids only (ints), so even the full
+    // 197k catalog is a small payload. limit=0/absent β†’ all (hard cap 250k).
+    if (u.pathname === '/api/ids') {
+      const status = STATUS_FILTERS[u.searchParams.get('status')] ? u.searchParams.get('status') : 'new';
+      const vendor = (u.searchParams.get('vendor') || '').replace(/[^a-z0-9_]/gi, '').slice(0, 64);
+      const sort = SORTS[u.searchParams.get('sort')] ? u.searchParams.get('sort') : 'newest';
+      const term = (u.searchParams.get('q') || '').slice(0, 80);
+      const limit = Math.min(Math.max(parseInt(u.searchParams.get('limit'), 10) || 0, 0), 250000);
+      const where = buildWhere({ status, vendor, term });
+      const rows = q(`SELECT vc.id FROM vendor_catalog vc ${SP_JOIN} ${where}
+                      ORDER BY ${SORTS[sort]}${limit ? ` LIMIT ${limit}` : ''}`);
+      return send(res, 200, JSON.stringify({ ids: rows.map(r => +r[0]) }));
+    }
     // Generalized action endpoint β€” stages launch / publish / unpublish / archive.
     // Actions in LIVE_ACTIONS additionally fire a real Shopify write.
     // /api/launch kept as a back-compat alias (action='launch').

← b8c32b4 Panel items B+D: No-image filter (12,156 rows) + structured  Β·  back to New Import Viewer  Β·  NET NEW view: new MINUS already-on-Shopify (Steve: 'new - on bde393a β†’