β 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
M public/index.htmlM server.js
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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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 β