[object Object]

← back to Dw Photo Capture

Save every uploaded photo by vendor: photos/vendors/<Vendor>/ archive + index + /vendor-photos gallery + one-time backfill (TK-12162)

0260eb6f9c1ad6f25b0d1e71019d1e9fc369191a · 2026-09-24 16:20:04 -0700 · Steve Abrams

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NR59q54WMPw4GdNgi2z1jM

Files touched

Diff

commit 0260eb6f9c1ad6f25b0d1e71019d1e9fc369191a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Sep 24 16:20:04 2026 -0700

    Save every uploaded photo by vendor: photos/vendors/<Vendor>/ archive + index + /vendor-photos gallery + one-time backfill (TK-12162)
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01NR59q54WMPw4GdNgi2z1jM
---
 .gitignore                |   2 +
 public/index.html         |   2 +
 public/vendor-photos.html |  78 ++++++++++++++++++++++++++++++++
 server.js                 | 111 ++++++++++++++++++++++++++++++++++++++++++++--
 4 files changed, 190 insertions(+), 3 deletions(-)

diff --git a/.gitignore b/.gitignore
index d51d6fb..8b947d5 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,3 +21,5 @@ data/color-index.json
 
 # TK-12126 camera commit:true round-trip proof — fsync'd exec-ledger, runtime-generated
 data/camera-committrue-proof-ledger.jsonl
+data/vendor-photos.jsonl
+data/vendor-photos.backfilled
diff --git a/public/index.html b/public/index.html
index 81fe0ea..4daaa60 100644
--- a/public/index.html
+++ b/public/index.html
@@ -469,6 +469,7 @@
       <a href="/simple-instant">Instant Film</a>
       <a href="/simple-probooth">Pro Booth</a>
       <a href="/simple-wizard">Guided Wizard</a>
+      <a href="/vendor-photos">Vendor Photos</a>
     </nav>
   </div>
 </div>
@@ -519,6 +520,7 @@
   <a href="/simple-instant">Instant Film</a>
   <a href="/simple-probooth">Pro Booth</a>
   <a href="/simple-wizard">Guided Wizard</a>
+  <a href="/vendor-photos">Vendor Photos</a>
 </nav>
 <style>
   .other-apps{display:flex;flex-wrap:wrap;gap:8px;align-items:center;justify-content:center;
diff --git a/public/vendor-photos.html b/public/vendor-photos.html
new file mode 100644
index 0000000..7b79687
--- /dev/null
+++ b/public/vendor-photos.html
@@ -0,0 +1,78 @@
+<!doctype html>
+<html lang="en"><head>
+<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Vendor Photos · DW</title>
+<style>
+  :root{--bg:#0f0e0c;--card:#1b1916;--ink:#f3efe7;--muted:#9a9184;--line:#2e2a25;--gold:#c8a24a;--cardw:220px}
+  *{box-sizing:border-box} body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.4 -apple-system,system-ui,sans-serif}
+  header{position:sticky;top:0;z-index:5;background:var(--bg);border-bottom:1px solid var(--line);padding:12px 16px}
+  h1{margin:0 0 10px;font-size:18px} h1 a{color:var(--muted);font-size:13px;text-decoration:none;margin-left:8px}
+  .controls{display:flex;flex-wrap:wrap;gap:10px;align-items:center}
+  select,input[type=search]{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:7px 9px;font-size:14px}
+  .dens{color:var(--muted);font-size:13px;display:flex;align-items:center;gap:6px}
+  .vendors{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
+  .vendors button{background:var(--card);color:var(--ink);border:1px solid var(--line);border-radius:999px;padding:5px 11px;font-size:13px;cursor:pointer}
+  .vendors button.on{border-color:var(--gold);color:var(--gold)}
+  .vendors b{color:var(--muted);font-weight:400;margin-left:4px}
+  main{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--cardw),1fr));gap:10px;padding:14px 16px}
+  .card{background:var(--card);border:1px solid var(--line);border-radius:10px;overflow:hidden}
+  .card img{width:100%;aspect-ratio:1;object-fit:cover;display:block;background:#000}
+  .card .meta{padding:8px 10px;font-size:12px;display:grid;gap:3px}
+  .card .sku{font-weight:600;font-size:13px}
+  .card .ven,.card .src{color:var(--muted)}
+  .when{color:var(--gold)}
+  .empty{padding:40px 16px;color:var(--muted);text-align:center}
+</style></head>
+<body>
+<header>
+  <h1>Vendor Photos <span id="count" style="color:var(--muted);font-weight:400;font-size:14px"></span><a href="/">← capture</a></h1>
+  <div class="controls">
+    <input type="search" id="q" placeholder="Search SKU or vendor">
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="oldest">Oldest</option>
+      <option value="vendor">Vendor A→Z</option>
+      <option value="sku">SKU A→Z</option>
+    </select>
+    <label class="dens">Size <input type="range" id="dens" min="120" max="420" step="20" value="220"></label>
+  </div>
+  <div class="vendors" id="vendors"></div>
+</header>
+<main id="grid"></main>
+<script>
+const $ = s => document.querySelector(s);
+const ls = { get(k,d){ try{ return localStorage.getItem(k) ?? d; }catch(e){ return d; } }, set(k,v){ try{ localStorage.setItem(k,v); }catch(e){} } };
+const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+const fmtDate = iso => { const d = new Date(iso); return isNaN(d) ? '' : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); };
+let all = [], vendor = ls.get('vp.vendor',''), sort = ls.get('vp.sort','newest');
+$('#sort').value = sort; $('#dens').value = ls.get('vp.dens','220');
+document.documentElement.style.setProperty('--cardw', $('#dens').value + 'px');
+function render(){
+  const q = $('#q').value.trim().toLowerCase();
+  let rows = all.filter(r => (!vendor || r.vendor === vendor) && (!q || (r.dw_sku||'').toLowerCase().includes(q) || r.vendor.toLowerCase().includes(q)));
+  const by = { newest:(a,b)=>b.at.localeCompare(a.at), oldest:(a,b)=>a.at.localeCompare(b.at),
+    vendor:(a,b)=>a.vendor.localeCompare(b.vendor)||b.at.localeCompare(a.at), sku:(a,b)=>(a.dw_sku||'').localeCompare(b.dw_sku||'') };
+  rows.sort(by[sort] || by.newest);
+  $('#count').textContent = rows.length + ' of ' + all.length;
+  $('#grid').innerHTML = rows.length ? rows.map(r => `<div class="card">
+      <a href="${esc(r.path)}" target="_blank" rel="noopener noreferrer"><img loading="lazy" src="${esc(r.path)}" alt="${esc(r.dw_sku||'')}"></a>
+      <div class="meta"><div class="sku">${esc(r.dw_sku || '—')}</div><div class="ven">${esc(r.vendor)}</div>
+      <div class="when" title="${esc(r.at)}">🕓 ${esc(fmtDate(r.at))}</div><div class="src">${esc(r.source||'')}</div></div></div>`).join('')
+    : '<div class="empty">No photos yet.</div>';
+}
+function renderVendors(counts){
+  const names = Object.keys(counts).sort((a,b)=>a.localeCompare(b));
+  if (vendor && !counts[vendor]) vendor = '';
+  $('#vendors').innerHTML = `<button data-v="" class="${vendor?'':'on'}">All<b>${all.length}</b></button>` +
+    names.map(v => `<button data-v="${esc(v)}" class="${v===vendor?'on':''}">${esc(v)}<b>${counts[v]}</b></button>`).join('');
+}
+$('#vendors').addEventListener('click', e => { const b = e.target.closest('button'); if (!b) return;
+  vendor = b.dataset.v; ls.set('vp.vendor', vendor);
+  document.querySelectorAll('#vendors button').forEach(x => x.classList.toggle('on', x === b)); render(); });
+$('#sort').addEventListener('change', e => { sort = e.target.value; ls.set('vp.sort', sort); render(); });
+$('#dens').addEventListener('input', e => { document.documentElement.style.setProperty('--cardw', e.target.value + 'px'); ls.set('vp.dens', e.target.value); });
+$('#q').addEventListener('input', render);
+fetch(location.origin + '/api/vendor-photos').then(r => r.json()).then(d => { all = d.photos || []; renderVendors(d.vendors || {}); render(); })
+  .catch(() => { $('#grid').innerHTML = '<div class="empty">Could not load photos.</div>'; });
+</script>
+</body></html>
diff --git a/server.js b/server.js
index 7786369..ff90349 100644
--- a/server.js
+++ b/server.js
@@ -143,6 +143,80 @@ function saveScan(imgs, meta) {
     fs.appendFile(SCAN_LOG, JSON.stringify(rec) + '\n', () => {});
   } catch (e) { /* never let logging break a scan */ }
 }
+
+// ── Per-vendor photo archive: every uploaded photo is ALSO filed under
+//    photos/vendors/<Vendor>/ and indexed in data/vendor-photos.jsonl, so all photos are
+//    kept and browsable by vendor (/vendor-photos). Hard-links the already-written file (no
+//    extra disk), falls back to a copy. Never throws — archiving must not break an upload.
+const VENDOR_DIR = path.join(PHOTOS, 'vendors');
+const VENDOR_LOG = path.join(DATA, 'vendor-photos.jsonl');
+const safeSeg = (s, n) => String(s || '').replace(/[^A-Za-z0-9 ._&-]/g, '').replace(/\s+/g, ' ').trim().slice(0, n);
+function vendorForSku(dw_sku) {
+  const s = String(dw_sku || '').toUpperCase().replace(/-SAMPLE$/, '');   // sample shots share the base SKU's vendor
+  if (!s) return '';
+  const hit = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s);
+  if (hit && hit.vendor) return hit.vendor;
+  if (SHEET_BY_GRS[s]) return 'Fentucci';                       // TWIL GRS sheet items are created as Fentucci
+  const pr = progress[dw_sku] || progress[s];
+  return (pr && (pr.vendor || (pr.meta && pr.meta.vendor))) || '';
+}
+function archiveVendorPhoto(srcFile, { vendor, dw_sku, source, at, name } = {}) {
+  try {
+    const v = safeSeg(vendor || vendorForSku(dw_sku), 80) || '_unassigned';
+    const dir = path.join(VENDOR_DIR, v);
+    if (!dir.startsWith(VENDOR_DIR + path.sep)) return null;
+    fs.mkdirSync(dir, { recursive: true });
+    const base = safeSeg(name, 200).replace(/ /g, '_') || path.basename(srcFile);
+    const dest = path.join(dir, base);
+    if (!fs.existsSync(dest)) { try { fs.linkSync(srcFile, dest); } catch (e) { fs.copyFileSync(srcFile, dest); } }
+    const rec = { at: at || new Date().toISOString(), vendor: v, dw_sku: dw_sku || null, source: source || null,
+      path: '/photos/vendors/' + encodeURIComponent(v) + '/' + encodeURIComponent(base) };
+    fs.appendFileSync(VENDOR_LOG, JSON.stringify(rec) + '\n');
+    return rec;
+  } catch (e) { console.error('[vendor-photos] archive failed', srcFile, e.message); return null; }
+}
+// Write a base64 JPEG straight into the vendor archive (for photos that were never saved locally,
+// e.g. the create-item photos that only went to Shopify).
+function archiveVendorB64(b64, { vendor, dw_sku, source, tag }) {
+  try {
+    const safe = String(dw_sku || 'nosku').replace(/[^A-Za-z0-9._-]/g, '');
+    const f = path.join(PHOTOS, `${safe}-${Date.now()}-${tag || 0}.jpg`);
+    fs.writeFileSync(f, Buffer.from(b64, 'base64'));
+    return archiveVendorPhoto(f, { vendor, dw_sku, source });
+  } catch (e) { console.error('[vendor-photos] b64 archive failed', e.message); return null; }
+}
+// One-time backfill of photos saved before the archive existed (flat photos/<SKU>-*.jpg and
+// batch/<session>/). Runs once the catalog is loaded; marker file makes it idempotent.
+const VENDOR_BACKFILL_MARK = path.join(DATA, 'vendor-photos.backfilled');
+function backfillVendorPhotos() {
+  if (fs.existsSync(VENDOR_BACKFILL_MARK) || !CATALOG.length) return;
+  let n = 0;
+  const done = new Set();                                        // files already archived live — don't re-file them
+  try { for (const l of fs.readFileSync(VENDOR_LOG, 'utf8').split('\n')) { try { done.add(decodeURIComponent(JSON.parse(l).path.split('/').pop())); } catch (e) {} } } catch (e) {}
+  try {
+    for (const f of fs.readdirSync(PHOTOS)) {
+      if (!/\.jpe?g$/i.test(f) || /^(scan-|_ocr-)/.test(f)) continue;
+      const full = path.join(PHOTOS, f); if (!fs.statSync(full).isFile()) continue;
+      const sku = f.replace(/-\d{10,}(-\d+)?\.jpe?g$/i, '');
+      if (done.has(f)) continue;
+      if (archiveVendorPhoto(full, { dw_sku: sku, source: 'backfill', at: fs.statSync(full).mtime.toISOString() })) n++;
+    }
+    const mdir = path.join(DATA, 'batch-sessions');
+    if (fs.existsSync(mdir)) for (const m of fs.readdirSync(mdir)) {
+      for (const line of fs.readFileSync(path.join(mdir, m), 'utf8').split('\n')) {
+        let r; try { r = JSON.parse(line); } catch (e) { continue; }
+        for (const p of Object.values(r.paths || {})) {
+          const full = path.join(PHOTOS, decodeURIComponent(p.replace(/^\/photos\//, '')));
+          if (done.has(r.sessionId + '_' + path.basename(full))) continue;
+          if (full.startsWith(PHOTOS + path.sep) && fs.existsSync(full) &&
+              archiveVendorPhoto(full, { vendor: r.vendor, dw_sku: r.sku, source: 'backfill-batch', at: r.at, name: r.sessionId + '_' + path.basename(full) })) n++;
+        }
+      }
+    }
+    fs.writeFileSync(VENDOR_BACKFILL_MARK, new Date().toISOString() + ' ' + n + '\n');
+    console.log('[vendor-photos] backfilled', n);
+  } catch (e) { console.error('[vendor-photos] backfill failed', e.message); }
+}
 const loadJSON = (f, d) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch (e) { return d; } };
 const saveJSON = (f, o) => {
   // ATOMIC write: serialize to a temp file then rename over the target. rename() is atomic on
@@ -1346,6 +1420,25 @@ const appHandler = (req, res) => {
     return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
   }
 
+  // ── Per-vendor photo archive viewer (behind the global Basic-auth wall).
+  if (u.pathname === '/vendor-photos') {
+    const f = path.join(ROOT, 'public/vendor-photos.html');
+    return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(fs.readFileSync(f, 'utf8'));
+  }
+  if (u.pathname === '/api/vendor-photos' && req.method === 'GET') {
+    backfillVendorPhotos();
+    const rows = [];
+    try {
+      for (const line of fs.readFileSync(VENDOR_LOG, 'utf8').split('\n')) {
+        if (!line) continue; try { rows.push(JSON.parse(line)); } catch (e) {}
+      }
+    } catch (e) { /* no log yet */ }
+    const seen = new Set(), out = [];
+    for (const r of rows) { if (seen.has(r.path)) continue; seen.add(r.path); out.push(r); }   // dedupe (backfill + re-archive)
+    const vendors = {}; out.forEach(r => { vendors[r.vendor] = (vendors[r.vendor] || 0) + 1; });
+    return send(res, 200, { ok: true, total: out.length, vendors, photos: out.reverse() });
+  }
+
   // ── Batch Shoot Mode (production-line capture for 400+ samples) — additive, self-contained page.
   //    Runs the iPad's OWN camera (not the SSE-slaved /cam remote flow). See public/batch.html.
   if (u.pathname === '/batch') {
@@ -1389,7 +1482,8 @@ const appHandler = (req, res) => {
         catch (e) { errors.push(k + ': decode'); continue; }
         if (buf.length < 200 || buf.length > 40 * 1024 * 1024) { errors.push(k + ': size ' + buf.length); continue; }
         const fname = `${safeSku}_${sidePart}${k}.jpg`;
-        try { fs.writeFileSync(path.join(dir, fname), buf); paths[k] = `/photos/batch/${safeSession}/${fname}`; }
+        try { fs.writeFileSync(path.join(dir, fname), buf); paths[k] = `/photos/batch/${safeSession}/${fname}`;
+              archiveVendorPhoto(path.join(dir, fname), { vendor, dw_sku: safeSku, source: 'batch', name: safeSession + '_' + fname }); }
         catch (e) { errors.push(k + ': write'); }
       }
       // durable per-session manifest (append-only) so a session is reconstructable off-device
@@ -1773,7 +1867,16 @@ const appHandler = (req, res) => {
       const photos64 = await Promise.all(rawPhotos64.map(normB64));   // raw camera photos → clean JPEG for Shopify
       p._photos64 = photos64;
       const b64 = photos64[0] || null;
-      try { send(res, 200, await createNewItem(p, b64, p.commit !== true)); }
+      try {
+        const out = await createNewItem(p, b64, p.commit !== true);
+        if (p.commit === true && out && out.ok) {
+          const sku = out.dw_sku || (out.preview && out.preview.dw_sku) || p.mfr;
+          const vend = (out.preview && out.preview.vendor) || out.vendor || p.vendor;
+          (p._photos64 && p._photos64.length ? p._photos64 : (b64 ? [b64] : []))
+            .forEach((ph, i) => archiveVendorB64(ph, { vendor: vend, dw_sku: sku, source: 'create-item', tag: i }));
+        }
+        send(res, 200, out);
+      }
       catch (e) { send(res, 500, { ok: false, err: e.message }); }
     });
     return;
@@ -2313,6 +2416,7 @@ load();
       const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
       const fname = `${safe}-${Date.now()}.jpg`;
       fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
+      archiveVendorPhoto(path.join(PHOTOS, fname), { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photo' });
       const entry = progress[dw_sku] || {};
       entry.done = true; entry.skipped = false; entry.photo = `/photos/${fname}`; entry.ts = new Date().toISOString();
       entry.live = false; entry.live_reason = null; entry.created = false;
@@ -2365,7 +2469,8 @@ load();
         const b64 = await normB64(String(urls[i]).replace(/^data:image\/\w+;base64,/, ''));   // raw photo → clean JPEG
         const safe = String(dw_sku).replace(/[^A-Za-z0-9._-]/g, '');
         const fname = `${safe}-${Date.now()}-${i}.jpg`;
-        try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`; } catch (e) {}
+        try { fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64')); lastPhoto = `/photos/${fname}`;
+              archiveVendorPhoto(path.join(PHOTOS, fname), { vendor: p.vendor || (meta && meta.vendor), dw_sku, source: 'photos' }); } catch (e) {}
         const r = await shopifyAppendImage(product_id, dw_sku, b64, `${Date.now()}-${i}`);
         if (r.ok) added++; else errors.push(r.err);
       }

← 07b7e8c deploy: exclude ops/ (Tres Tintas restore maps/scripts are l  ·  back to Dw Photo Capture  ·  Nightly additive rsync of vendor photo archive to Henry + la 2653995 →