← 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
M .gitignoreM public/index.htmlA public/vendor-photos.htmlM server.js
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 => ({'&':'&','<':'<','>':'>','"':'"'}[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 →