← back to Dw Photo Capture
server.js
2817 lines
#!/usr/bin/env node
/**
* DW Pattern Photo Capture — mobile-first (iPhone + iPad) camera worklist.
* Walk physical sample books, snap each pattern that has no product photo,
* and the image is saved locally + attached to its Shopify DRAFT product
* (filename = DW SKU, never the mfr# — honors the private-label leak rule).
*
* Pure Node http (zero npm deps). Basic-auth gated. Binds 0.0.0.0 for LAN.
* PORT=9890 node server.js
*/
const http = require('http');
const https = require('https');
const zlib = require('zlib');
const fs = require('fs');
const path = require('path');
const { execFile } = require('child_process');
// FileMaker Cloud client (sample-request lookup + sticker-print flag). Loaded guarded so a missing
// dep / unset creds degrade gracefully instead of crashing the scanner.
let FM = null;
try { FM = require('./fm-client.js'); } catch (e) { console.log('FileMaker client unavailable:', e.message); }
const FM_DB = process.env.FM_DB || 'WALLPAPER';
const FM_LAYOUT = process.env.FM_LAYOUT || 'Sample Requests via email';
const FM_PRINT_FLAG = process.env.FM_PRINT_FLAG_FIELD || ''; // Steve designates a flag field the Mac poller reads
const FM_ENABLED = () => !!(FM && process.env.FM_CLARIS_EMAIL && process.env.FM_CLARIS_PASSWORD && process.env.FM_CLOUD_HOST);
// Today's date as MM/DD/YYYY in the business timezone (Kamatera runs UTC; stamp must be Pacific
// so an evening scan never records tomorrow's date on the FileMaker record). Override via FM_TZ.
function todayMDY() { return new Date().toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' }); }
const VSEARCH_URL = process.env.VSEARCH_URL || 'http://127.0.0.1:9914'; // CLIP visual-search service
// Incoming-samples cache: the FileMaker find on this layout takes ~30s (whole-file scan, can't index
// via API), so refresh the queue in the BACKGROUND and serve it instantly. ≤8-min staleness is fine
// for a "samples coming in" queue. Refreshed at boot + every 8 min + on a stale read.
let _incomingCache = { at: 0, samples: [] }, _incomingLoading = false;
async function refreshIncoming() {
if (_incomingLoading || !FM_ENABLED()) return;
_incomingLoading = true;
try {
const floor = new Date(Date.now() - 120 * 864e5).toLocaleDateString('en-US', { timeZone: process.env.FM_TZ || 'America/Los_Angeles', year: 'numeric', month: '2-digit', day: '2-digit' });
const r = await FM.fmFind(FM_DB, FM_LAYOUT, [{ 'Date Sample Request printed for vendor': '>=' + floor, 'Date WP Sample Sent': '=' }], { limit: 40, portal: [] });
const recs = (r.records || []);
// Pass 1 — spec-first from the in-RAM active CATALOG (instant, $0). Serve this immediately.
const samples = recs.map(rec => { const f = rec.fieldData;
const nk = nmfr(f['Mfr Pattern'] || f['combo sku'] || '');
const hit = nk ? CATALOG.find(x => x.mfr && nmfr(x.mfr) === nk) : null;
return { recordId: rec.recordId, mfr_pattern: f['Mfr Pattern'] || null, combo_sku: f['combo sku'] || null,
series: f['Series'] || null, vid: f['vid'] || null, client: f['company for client fileS'] || null,
vendor_ordered: f['Date Sample Request printed for vendor'] || null, client_ordered: f['today for client'] || null,
image: (hit && hit.image) || null, pattern: (hit && hit.title) || null,
dw_sku: (hit && hit.dw_sku) || null, mfr_sku: (hit && hit.mfr) || null }; });
_incomingCache = { at: Date.now(), samples };
// Pass 2 — best-effort images for the CATALOG misses via identifyUnified (full 267k crossref +
// 173k shopify_products, much wider net). Runs in the BACKGROUND, mutating the served cache in
// place (serve-stale-while-enriching); concurrency-bounded so 40 samples don't fork 80 psql shells.
const misses = samples.filter(s => !s.image);
let idx = 0;
const worker = async () => { while (idx < misses.length) { const s = misses[idx++];
let id = s.mfr_pattern ? await identifyUnified(s.mfr_pattern).catch(() => null) : null;
if ((!id || !id.image) && s.combo_sku) id = await identifyUnified(s.combo_sku).catch(() => null) || id;
if (id) { if (id.image && !s.image) s.image = id.image;
if (!s.pattern) s.pattern = id.pattern || id.title || s.pattern;
if (!s.mfr_sku) s.mfr_sku = id.mfr_sku || s.mfr_sku;
if (!s.dw_sku) s.dw_sku = id.internal_sku_dash || id.internal_sku || s.dw_sku; } } };
await Promise.all(Array.from({ length: Math.min(6, misses.length) }, worker));
} catch (e) { /* keep last good cache */ }
_incomingLoading = false;
}
setTimeout(refreshIncoming, 9000); // warm the cache shortly after boot (once CATALOG is loaded)
setInterval(refreshIncoming, 8 * 60 * 1000); // keep it fresh
const ROOT = __dirname;
const DATA = path.join(ROOT, 'data');
const PHOTOS = path.join(ROOT, 'photos');
const QUEUE_FILE = path.join(DATA, 'queue.json');
const PROGRESS_FILE = path.join(DATA, 'progress.json');
const PORT = process.env.PORT || 9890;
const AUTH_USER = process.env.AUTH_USER || 'admin';
const AUTH_PASS = process.env.AUTH_PASS || 'DW2024!';
// Shopify (for pushing the photo onto the draft product). Token from secrets .env.
const SHOP = 'designer-laboratory-sandbox.myshopify.com';
const API = '2024-10';
let TOKEN = process.env.SHOPIFY_ADMIN_TOKEN || '';
if (!TOKEN) {
try {
for (const l of fs.readFileSync(path.join(process.env.HOME, 'Projects/secrets-manager/.env'), 'utf8').split('\n'))
if (l.startsWith('SHOPIFY_ADMIN_TOKEN=')) { TOKEN = l.split('=').slice(1).join('=').trim(); break; }
} catch (e) {}
}
fs.mkdirSync(PHOTOS, { recursive: true });
// ── EVERY scan is saved (Steve: "EVERY scan must be saved to make app better") ──────────────────
// Persist the raw photo(s) + what the OCR read + engines + resolved identity to an append-only log,
// so mis-reads are reviewable (raw image ⇄ OCR ⇄ resolved SKU) and the corpus can drive engine
// tuning / vendor-spec learning. Best-effort + fully async — a save failure NEVER breaks a scan.
const SCAN_LOG = path.join(DATA, 'scan-log.jsonl');
try { fs.mkdirSync(DATA, { recursive: true }); } catch (e) {}
let _scanN = 0;
function saveScan(imgs, meta) {
try {
const stamp = new Date().toISOString();
const id = stamp.replace(/[:.]/g, '-') + '-' + ((++_scanN) % 100000) + Math.random().toString(36).slice(2, 5);
const strip = s => (s || '').replace(/^data:image\/\w+;base64,/, '');
const images = {};
for (const [k, v] of Object.entries(imgs || {})) {
if (!v) continue;
let b; try { b = Buffer.from(strip(v), 'base64'); } catch (e) { continue; }
if (b.length < 200 || b.length > 12 * 1024 * 1024) continue; // skip empty / oversized
const fn = `scan-${id}-${k}.jpg`;
fs.writeFile(path.join(PHOTOS, fn), b, () => {}); // async, fire-and-forget
images[k] = `/photos/${fn}`;
}
const rec = Object.assign({ id, at: stamp, images }, meta || {});
fs.appendFile(SCAN_LOG, JSON.stringify(rec) + '\n', () => {});
} catch (e) { /* never let logging break a scan */ }
}
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
// POSIX, so a crash or ENOSPC mid-write can never truncate/corrupt the real file (the learned
// vendor_profiles.json etc.). On failure, remove the partial temp and rethrow (same API).
const tmp = `${f}.tmp-${process.pid}`;
try { fs.writeFileSync(tmp, JSON.stringify(o, null, 2)); fs.renameSync(tmp, f); }
catch (e) { try { fs.unlinkSync(tmp); } catch (e2) {} throw e; }
};
// Auto-version: derive a sequential build number from the actual file contents.
// Any change to index.html or server.js → new hash → next build number. Never manual.
const crypto = require('crypto');
function buildLabel() {
let blob = '';
try { blob += fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8'); } catch (e) {}
try { blob += fs.readFileSync(path.join(ROOT, 'server.js'), 'utf8'); } catch (e) {}
const hash = crypto.createHash('md5').update(blob).digest('hex').slice(0, 8);
const reg = loadJSON(path.join(DATA, 'build.json'), { next: 2, map: {} });
if (!reg.map[hash]) { reg.map[hash] = reg.next++; saveJSON(path.join(DATA, 'build.json'), reg); }
return `v${reg.map[hash]} · editor`;
}
// progress.json: { "<dw_sku>": { done, skipped, photo, ts, shopify_pushed, push_err } }
let progress = loadJSON(PROGRESS_FILE, {});
const saveProgress = () => saveJSON(PROGRESS_FILE, progress);
// recents.json / favorites.json: card-shaped entries keyed by dw_sku so the
// "🕘 Recent" and "⭐ Favorites" views render full cards without a Shopify lookup.
// Server-side (not localStorage) so the same list shows on phone AND office.
const RECENTS_FILE = path.join(DATA, 'recents.json');
const FAVORITES_FILE = path.join(DATA, 'favorites.json');
let recents = loadJSON(RECENTS_FILE, {});
let favorites = loadJSON(FAVORITES_FILE, {});
const saveRecents = () => saveJSON(RECENTS_FILE, recents);
const saveFavorites = () => saveJSON(FAVORITES_FILE, favorites);
// Build a card-shaped record from the display meta the client sends.
function cardRecord(dw_sku, meta, extra) {
meta = meta || {};
return Object.assign({
dw_sku, product_id: meta.product_id || null, title: meta.title || dw_sku,
mfr: meta.mfr || '', price: meta.price || null, image: meta.image || null,
status: meta.status || '', keep_images: true
}, extra || {});
}
function shopifyReq(method, path, payload) {
return new Promise((resolve) => {
if (!TOKEN) return resolve({ status: 0, body: null, err: 'no token' });
const data = payload ? JSON.stringify(payload) : null;
const req = https.request({
host: SHOP, path: `/admin/api/${API}${path}`, method,
headers: Object.assign({ 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
data ? { 'Content-Length': Buffer.byteLength(data) } : {})
}, (res) => {
let d = ''; res.on('data', c => d += c);
res.on('end', () => { let b = null; try { b = JSON.parse(d); } catch (e) {} resolve({ status: res.statusCode, body: b, raw: d }); });
});
req.on('error', e => resolve({ status: 0, err: e.message }));
if (data) req.write(data); req.end();
});
}
function gql(query, variables) {
return shopifyReq('POST', '/graphql.json', { query, variables: variables || {} }).then(r => (r.body || {}));
}
// keepOthers=false → clean re-shoot (delete siblings, the Fentucci placeholder workflow).
// keepOthers=true → add the phone photo as the FEATURED image but PRESERVE existing
// imagery — the safe default for arbitrary live SKUs that may already have good
// room/lifestyle shots we must not destroy (customer-facing, hard to reverse).
async function shopifyAttachImage(productId, dwSku, base64, keepOthers = false) {
const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
// new photo becomes the FEATURED image (position 1) — so it's the one shown first
const r = await shopifyReq('POST', `/products/${productId}/images.json`,
{ image: { attachment: base64, filename: `${safe}.jpg`, position: 1 } });
if (!(r.status >= 200 && r.status < 300)) return { ok: false, err: `HTTP ${r.status}: ${(r.raw || '').slice(0, 160)}` };
const newId = r.body && r.body.image && r.body.image.id;
if (!keepOthers) {
// remove older images so the fresh photo is the only/primary one (clean re-shoot)
const all = await shopifyReq('GET', `/products/${productId}/images.json`);
const imgs = (all.body && all.body.images) || [];
for (const im of imgs) { if (im.id !== newId) await shopifyReq('DELETE', `/products/${productId}/images/${im.id}.json`); }
}
return { ok: true, image_id: newId, kept_others: keepOthers };
}
// Append an ADDITIONAL image — no sibling delete, no position:1, so the existing
// featured image is untouched. Used by the multi-photo batch add.
async function shopifyAppendImage(productId, dwSku, base64, idx) {
const safe = (dwSku || ('SKU' + productId)).replace(/[^A-Za-z0-9._-]/g, '');
const r = await shopifyReq('POST', `/products/${productId}/images.json`,
{ image: { attachment: base64, filename: `${safe}-${idx}.jpg` } });
if (!(r.status >= 200 && r.status < 300)) return { ok: false, err: `HTTP ${r.status}: ${(r.raw || '').slice(0, 120)}` };
return { ok: true, image_id: r.body && r.body.image && r.body.image.id };
}
// ── Video upload to a Shopify product (staged upload → productCreateMedia) ──
function gqlFull(query, variables) { return shopifyReq('POST', '/graphql.json', { query, variables: variables || {} }); }
async function shopifyAddVideo(productId, buf, filename, mime) {
// 1) reserve a staged upload slot
const su = await gqlFull(`mutation($in:[StagedUploadInput!]!){ stagedUploadsCreate(input:$in){
stagedTargets{ url resourceUrl parameters{name value} } userErrors{message} } }`,
{ in: [{ resource: 'VIDEO', filename, mimeType: mime, httpMethod: 'POST', fileSize: String(buf.length) }] });
const tgt = su.body && su.body.data && su.body.data.stagedUploadsCreate && su.body.data.stagedUploadsCreate.stagedTargets[0];
if (!tgt) return { ok: false, err: 'stagedUploadsCreate failed: ' + JSON.stringify((su.body || {}).errors || (su.body && su.body.data && su.body.data.stagedUploadsCreate && su.body.data.stagedUploadsCreate.userErrors) || '').slice(0, 200) };
// 2) multipart POST the bytes to the staged target (GCS)
const boundary = '----dwvid' + Buffer.from(filename).toString('hex').slice(0, 12) + buf.length;
const pre = [];
for (const p of tgt.parameters) pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="${p.name}"\r\n\r\n${p.value}\r\n`);
pre.push(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${filename}"\r\nContent-Type: ${mime}\r\n\r\n`);
const body = Buffer.concat([Buffer.from(pre.join(''), 'utf8'), buf, Buffer.from(`\r\n--${boundary}--\r\n`, 'utf8')]);
const up = await new Promise(resolve => {
const U = new URL(tgt.url);
const r = https.request({ host: U.host, path: U.pathname + U.search, method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}`, 'Content-Length': body.length } },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => resolve({ status: res.statusCode, body: d })); });
r.on('error', e => resolve({ status: 0, err: e.message })); r.write(body); r.end();
});
if (up.status < 200 || up.status >= 300) return { ok: false, err: `staged upload HTTP ${up.status}: ${(up.body || up.err || '').slice(0, 160)}` };
// 3) attach the staged video to the product
const cm = await gqlFull(`mutation($id:ID!,$media:[CreateMediaInput!]!){ productCreateMedia(productId:$id, media:$media){
media{ status } mediaUserErrors{ message } } }`,
{ id: `gid://shopify/Product/${productId}`, media: [{ originalSource: tgt.resourceUrl, mediaContentType: 'VIDEO' }] });
const errs = cm.body && cm.body.data && cm.body.data.productCreateMedia && cm.body.data.productCreateMedia.mediaUserErrors;
if (errs && errs.length) return { ok: false, err: errs.map(e => e.message).join('; ') };
return { ok: true, processing: true }; // Shopify transcodes async
}
// Poll a product's VIDEO media processing state (Shopify transcodes async: UPLOADED→PROCESSING→READY|FAILED).
async function shopifyMediaStatus(productId) {
const q = `query($id:ID!){ product(id:$id){ media(first:50){ edges{ node{ mediaContentType status } } } } }`;
const d = await gql(q, { id: `gid://shopify/Product/${productId}` });
const nodes = ((d.data && d.data.product && d.data.product.media && d.data.product.media.edges) || []).map(e => e.node);
const vids = nodes.filter(n => n.mediaContentType === 'VIDEO');
const ready = vids.filter(v => v.status === 'READY').length;
const failed = vids.filter(v => v.status === 'FAILED').length;
const processing = vids.length - ready - failed;
return { total: vids.length, ready, processing, failed, all_ready: vids.length > 0 && processing === 0 && failed === 0 };
}
// After a photo lands, make the SKU live IF it passes the activation gate
// (now has image + a real price + width + a description). Else stays draft.
async function shopifyActivateIfReady(productId) {
const q = `query($id:ID!){ product(id:$id){ status descriptionHtml
imgs:images(first:1){edges{node{id}}}
w:metafield(namespace:"global",key:"width"){value} w2:metafield(namespace:"custom",key:"width"){value}
variants(first:5){edges{node{ title sku price }}} } }`;
const d = await gql(q, { id: `gid://shopify/Product/${productId}` });
const p = d && d.data && d.data.product;
if (!p) return { live: false, reason: 'lookup failed' };
const hasImg = p.imgs.edges.length > 0;
const hasWidth = !!((p.w && p.w.value) || (p.w2 && p.w2.value));
const hasDesc = !!(p.descriptionHtml && p.descriptionHtml.replace(/<[^>]+>/g, '').trim().length > 20);
const roll = p.variants.edges.map(e => e.node).find(v => !(v.sku || '').endsWith('-Sample') && (v.title || '').toLowerCase() !== 'sample');
const hasPrice = roll && parseFloat(roll.price) > 4.25;
if (!(hasImg && hasWidth && hasDesc && hasPrice)) {
const miss = [!hasImg && 'image', !hasPrice && 'price', !hasWidth && 'width', !hasDesc && 'description'].filter(Boolean);
return { live: false, reason: 'needs ' + miss.join(' + ') };
}
if (p.status === 'ACTIVE') return { live: true, already: true };
// set active
const up = await shopifyReq('PUT', `/products/${productId}.json`, { product: { id: productId, status: 'active' } });
if (up.status < 200 || up.status >= 300) return { live: false, reason: `activate HTTP ${up.status}` };
// publish to all sales channels
const pubs = await gql('{ publications(first:50){edges{node{id}}} }');
const ids = ((pubs.data && pubs.data.publications && pubs.data.publications.edges) || []).map(e => ({ publicationId: e.node.id }));
await gql(`mutation($id:ID!,$in:[PublicationInput!]!){ publishablePublish(id:$id,input:$in){ userErrors{message} } }`,
{ id: `gid://shopify/Product/${productId}`, in: ids });
return { live: true, channels: ids.length };
}
function checkAuth(req) {
const h = req.headers.authorization || '';
if (!h.startsWith('Basic ')) return false;
const raw = Buffer.from(h.slice(6), 'base64').toString();
const ci = raw.indexOf(':'); const u = ci < 0 ? raw : raw.slice(0, ci), p = ci < 0 ? '' : raw.slice(ci + 1); // RFC: only first ':' splits
return u === AUTH_USER && p === AUTH_PASS;
}
// ───────────────────────── Remote-shutter pairing layer ─────────────────────────
// Zero-new-dep: SSE (server→client push) + POST (client→server). One implicit room
// since it's one operator — a single desktop "control" page pairs with a single
// phone "cam" page. Either role can reconnect at will; the other side sees a live
// status. No room codes needed (single-user). The desktop clicks SHOOT → we push a
// `shoot` event to the phone → phone grabs a native-res frame, uploads it through the
// EXISTING /api/photo pipeline → phone POSTs a `shot` ping → we push the result to
// the desktop, which pulls the photo and routes it into the normal attach workflow.
const ROOM = {
cam: null, // { res, lastSeen } — the phone's SSE response stream
desk: null, // { res, lastSeen } — the desktop's SSE response stream
camLive: false, // phone reports its getUserMedia stream is live
camInfo: '', // e.g. "1920x1080 · environment"
lastShot: null // { dw_sku, product_id, ok, live, live_reason, push_err, photo, ts }
};
function sseInit(res) {
res.writeHead(200, {
'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive', 'X-Accel-Buffering': 'no'
});
res.write('retry: 3000\n\n'); // tell EventSource to auto-reconnect after 3s
}
function sseSend(slot, event, data) {
const conn = ROOM[slot];
if (!conn || !conn.res) return false;
try { conn.res.write(`event: ${event}\ndata: ${JSON.stringify(data || {})}\n\n`); return true; }
catch (e) { return false; }
}
function pairStatus() {
const now = Date.now();
const fresh = c => c && c.res && (now - c.lastSeen) < 20000;
return {
cam_connected: fresh(ROOM.cam), desk_connected: fresh(ROOM.desk),
cam_live: !!(fresh(ROOM.cam) && ROOM.camLive), cam_info: ROOM.camInfo,
last_shot: ROOM.lastShot
};
}
// Push the current pairing status to BOTH ends so each page's banner stays accurate.
function broadcastStatus() {
const s = pairStatus();
sseSend('cam', 'status', s);
sseSend('desk', 'status', s);
}
// Heartbeat: keep SSE sockets warm (proxies/iOS drop idle streams) + expire stale roles.
setInterval(() => {
for (const slot of ['cam', 'desk']) {
const c = ROOM[slot];
if (c && c.res) { try { c.res.write(': ping\n\n'); } catch (e) { ROOM[slot] = null; } }
}
broadcastStatus();
}, 8000);
function buildQueue() {
const q = loadJSON(QUEUE_FILE, []);
return q.map(item => {
const pr = progress[item.dw_sku] || {};
return { ...item, done: !!pr.done, skipped: !!pr.skipped, photo: pr.photo || null, ts: pr.ts || null,
shopify_pushed: !!pr.shopify_pushed, push_err: pr.push_err || null,
live: !!pr.live, live_reason: pr.live_reason || null };
});
}
function send(res, code, body, headers = {}) {
res.writeHead(code, { 'Content-Type': 'application/json', ...headers });
res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
// ── Color-index (PDP color-dot → catalog-wide "index of many within 10%") ──────
// ONE named tolerance knob. "10%" is expressed as a fraction of the practical
// perceptual CIELAB ΔE76 range (~100 for two clearly-different colors), so 10%
// maps to a ΔE ceiling of ~10 — tight enough to stay same-family, loose enough
// to pull MANY products (verified: 41–1001 in-tolerance across sampled hues).
// Change COLOR_INDEX_TOLERANCE_PCT alone to retune; the ceiling derives from it.
const COLOR_INDEX_TOLERANCE_PCT = 0.10;
const COLOR_INDEX_DELTA_E_CEILING = COLOR_INDEX_TOLERANCE_PCT * 100; // = 10 (ΔE76)
const COLOR_INDEX_PATH = require('path').join(__dirname, 'data', 'color-index.json');
let _colorIndex = null, _colorIndexMtime = 0;
// Load the static index once; hot-reload if the file is regenerated (mtime change).
function loadColorIndex() {
const fs = require('fs');
const st = fs.statSync(COLOR_INDEX_PATH); // throws if missing → handler 503s
if (_colorIndex && st.mtimeMs === _colorIndexMtime) return _colorIndex;
const j = JSON.parse(fs.readFileSync(COLOR_INDEX_PATH, 'utf8'));
_colorIndex = Array.isArray(j.items) ? j.items : [];
_colorIndexMtime = st.mtimeMs;
return _colorIndex;
}
// ── Color-DOTS (shop-by-color WHEEL swatch set) ────────────────────────────────
// The wheel's clickable dots must be REAL DB colors only, each backing >=20
// patterns, ordered by most-used qty, and each visually UNIQUE (ΔE-collapsed).
// Built by scripts/build-color-dots.cjs → data/color-dots.json. Served read-only,
// hot-reloaded on mtime like the color index. See dw-shop-by-color-wheel memory.
const COLOR_DOTS_PATH = require('path').join(__dirname, 'data', 'color-dots.json');
let _colorDots = null, _colorDotsMtime = 0;
function loadColorDots() {
const fs = require('fs');
const st = fs.statSync(COLOR_DOTS_PATH); // throws if missing → handler 503s
if (_colorDots && st.mtimeMs === _colorDotsMtime) return _colorDots;
_colorDots = JSON.parse(fs.readFileSync(COLOR_DOTS_PATH, 'utf8'));
_colorDotsMtime = st.mtimeMs;
return _colorDots;
}
// sRGB hex → CIELAB (D65), matching how product_colors LAB was computed upstream.
function hexToLab(hex) {
hex = hex.replace('#', '');
let r = parseInt(hex.slice(0, 2), 16) / 255,
g = parseInt(hex.slice(2, 4), 16) / 255,
b = parseInt(hex.slice(4, 6), 16) / 255;
const lin = c => (c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92);
r = lin(r); g = lin(g); b = lin(b);
let X = (r * 0.4124 + g * 0.3576 + b * 0.1805) / 0.95047;
let Y = (r * 0.2126 + g * 0.7152 + b * 0.0722);
let Z = (r * 0.0193 + g * 0.1192 + b * 0.9505) / 1.08883;
const f = t => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
return { l: 116 * f(Y) - 16, a: 500 * (f(X) - f(Y)), b: 200 * (f(Y) - f(Z)) };
}
// ── OCR → ranked SKU candidates ────────────────────────────────────────────────
// Allocated ONCE at load (the live scanner hits /api/ocr ~every 600ms, so we never
// want to rebuild these regexes per request). analyzeOcr() is pure — it takes parsed
// rows and returns the response shape, so the ranking is unit-testable without HTTP.
//
// Brand lexicon: when one of these vendor names is printed on the swatch, the biggest
// alnum code on the page is almost certainly its model #/SKU — so we boost a code whose
// prefix matches that brand and let the scanner lock on the FIRST read (topStrong).
const VENDOR_LEX = [
{ name: 'Winfield Thybony', re: /WINFIELD\s*THYBONY|THYBONY/, pfx: /^WD/ },
{ name: 'Fentucci', re: /FENTUCCI/, pfx: /^(GRS|WOS|SG|BA)/ },
{ name: 'Phillip Jeffries', re: /PHILLIP\s*JEFFRIES/, pfx: /^(WC|PJ)/ },
{ name: 'Thibaut', re: /THIBAUT|ANNA\s*FRENCH/, pfx: /^(T|AT|AF)\d/ },
{ name: 'Schumacher', re: /SCHUMACHER/, pfx: /^\d{4,}/ },
];
const STRONG_PFX = /^(GRS-|WD[A-Z]*\d|DWNAT|DW[A-Z]{2})/; // self-evidently a SKU, no brand needed
const CODE_TOKEN = /[A-Z0-9][A-Z0-9-]{2,13}/g; // candidate token shape
// Designer Wallcoverings' OWN header (company name / phone / website) is printed on every DW sample
// sticker — it is NEVER the product identity. The vendor MODEL # is what runs down the SKU, so scrub
// our own branding first: our phone (any punctuation form + fragments), phone-shaped tokens, and the
// DW name/site lines. Configurable via DW_PHONE.
const DW_PHONE = (process.env.DW_PHONE || '8883734564'); // 888-373-4564
const DW_BRAND_RE = /designer\s*wall\s*cover|designerwallcoverings|wallcoverings?\.com|\bDWC\b/i;
const PHONE_TOKEN_RE = /^(1?[-.\s(]*8(?:00|88|77|66|55)[-.\s)]*\d{3}[-.\s]*\d{4}|\d{10})$/; // toll-free / 10-digit
function isDwNoise(tok, dwLabel) {
const s = String(tok); const d = s.replace(/\D/g, '');
if (!d) return false;
if (d === DW_PHONE) return true; // our exact phone
if (d.length >= 7 && DW_PHONE.includes(d)) return true; // a 7+ digit fragment of it (373-4564)
if (PHONE_TOKEN_RE.test(s)) return true; // any 10-digit / toll-free number
if (dwLabel && /^\d+$/.test(s) && d.length >= 3 && DW_PHONE.includes(d)) return true; // phone fragment on OUR label
return false;
}
// ── Field vocabulary: classify a label read into sku# / model# / name / color ──
// Common interior-design colorway names + basic colors, so a printed color name is caught.
const COLOR_WORDS = 'oatmeal|alabaster|greige|celadon|ecru|taupe|linen|flax|dove|pewter|charcoal|ebony|onyx|ivory|cream|bone|chalk|parchment|sand|camel|fawn|mushroom|mocha|espresso|chocolate|walnut|honey|amber|ochre|mustard|saffron|terracotta|rust|clay|brick|coral|blush|rose|blossom|mauve|plum|aubergine|burgundy|claret|scarlet|crimson|cardinal|sage|celery|moss|fern|olive|forest|emerald|jade|hunter|teal|aqua|turquoise|peacock|cerulean|cobalt|navy|indigo|denim|slate|sky|powder|periwinkle|lavender|lilac|violet|amethyst|gold|brass|bronze|copper|champagne|platinum|silver|graphite|smoke|stone|pearl|white|black|grey|gray|beige|blue|green|red|yellow|orange|purple|pink|brown|tan';
const COLOR_RE = new RegExp('\\b(' + COLOR_WORDS + ')\\b', 'i');
const COLOR_LABEL_RE = /colou?r(?:way)?\s*[:#]?\s*([A-Za-z][A-Za-z '\/-]{1,28})/i; // "Color: Oatmeal"
const NAME_LABEL_RE = /(?:pattern|design|collection|name)\s*[:#]?\s*([A-Za-z][A-Za-z0-9 '\/-]{2,34})/i;
const CODE_LABEL_RE = /(?:sku|item|model|mfr|product|pattern)\s*(?:#|no\.?|number)?\s*[:#]?\s*([A-Z0-9][A-Z0-9-]{2,15})/i;
// From an OCR read (rows + analyzeOcr result), pull the human-facing fields the UI highlights:
// sku# (primary code), model#/mfr# (labeled or secondary code), name (pattern/collection), color.
function classifyFields(rows, a) {
const lines = rows.filter(r => !r.bc).map(r => String(r.t).trim()).filter(Boolean);
const raw = lines.join('\n');
const sku = a.top || null;
// color: an explicit "Color: X" wins; else the first known color word anywhere on the label.
let color = null;
const cl = COLOR_LABEL_RE.exec(raw); if (cl) color = cl[1].trim();
if (!color) { const cw = COLOR_RE.exec(raw); if (cw) color = cw[1]; }
if (color) color = color.replace(/\s+/g, ' ').trim().replace(/[ ,.;]+$/, '');
// model#/mfr#: a labeled code that differs from the SKU, else a distinct secondary code token.
let model = null;
const clab = CODE_LABEL_RE.exec(raw);
if (clab && clab[1].toUpperCase().replace(/\s+/g, '') !== sku) model = clab[1].toUpperCase().replace(/\s+/g, '');
if (!model) { const other = (a.candidates || []).find(c => c !== sku); if (other) model = other; }
// name: a labeled Pattern/Collection wins; else the longest alpha line that isn't the vendor,
// the color, or a code-bearing line (marketing prose is otherwise ignored by the scanner).
let name = null;
const nl = NAME_LABEL_RE.exec(raw); if (nl) name = nl[1].trim();
if (!name) {
const vfirst = a.vendor ? a.vendor.toUpperCase().split(/\s+/)[0] : null;
name = lines
.filter(l => /[A-Za-z]/.test(l) && !/\d{3,}/.test(l) && l.replace(/[^A-Za-z]/g, '').length >= 4)
.filter(l => !(vfirst && l.toUpperCase().includes(vfirst)))
.filter(l => !DW_BRAND_RE.test(l)) // never read OUR OWN name as the pattern
.filter(l => !(color && l.toLowerCase() === color.toLowerCase())) // drop a line that's ONLY the color
.filter(l => !COLOR_LABEL_RE.test(l) && !/^(colou?r|pattern|design|collection|sku|item|model|mfr)\b/i.test(l))
.filter(l => !(sku && l.toUpperCase().replace(/\s+/g, '').includes(sku)))
.sort((x, y) => y.length - x.length)[0] || null;
if (name) {
name = name.replace(/\s+/g, ' ').trim().slice(0, 40);
// pattern names often embed the colorway ("Compass Clay") — strip a trailing color word.
if (color) name = name.replace(new RegExp('\\s*\\b' + escapeRe(color) + '\\b\\s*$', 'i'), '').trim();
if (!name) name = null;
}
}
return { sku, model: model || null, name: name || null, color: color || null, barcode: a.barcode || null };
}
// Parse the OCR binary's "<heightThousandths>\t<text>" lines. Height = printed font
// size on the label (taller box = bigger print), which drives "largest letters first".
function parseOcrRows(stdout) {
return (stdout || '').trim().split('\n').filter(Boolean).map(l => {
const i = l.indexOf('\t');
if (i < 0) return { h: 0, t: l };
const tag = l.slice(0, i), rest = l.slice(i + 1);
if (tag === 'BC') { // barcode line: "BC\t<payload>\t<symbology>"
const j = rest.indexOf('\t');
return j < 0 ? { h: 10000, t: rest, bc: true } : { h: 10000, t: rest.slice(0, j), bc: true, sym: rest.slice(j + 1) };
}
return { h: parseInt(tag, 10) || 0, t: rest };
});
}
// Pure: OCR rows → { text, vendor, candidates, top, topStrong }. No I/O.
function analyzeOcr(rows) {
// Barcode payloads are ground truth (exact SKU) — they outrank every OCR'd code.
const barcodes = rows.filter(r => r.bc).map(r => r.t.toUpperCase().replace(/\s+/g, '')).filter(b => b.length >= 4);
const text = rows.filter(r => !r.bc).map(r => r.t).join('\n');
const vlex = LEXICON.find(v => v.re.test(text.toUpperCase())) || null;
const vendor = vlex ? vlex.name : null;
// SKU-likeness tie-breaker: brand-matching prefix first (the swatch told us the vendor),
// then known prefixes (GRS- = Fentucci, WD = Winfield Thybony), dashed/alnum, length.
const skuScore = t =>
((vlex && vlex.pfx.test(t)) ? 140 : 0) + // matches the brand printed on the swatch
(t.startsWith('GRS-') ? 100 : 0) +
(/^WD[A-Z]*\d/.test(t) ? 60 : 0) + // Winfield Thybony WDW2310, WD-prefixed
(t.includes('-') ? 12 : 0) +
(/[A-Z]/.test(t) && /\d/.test(t) ? 20 : 0) +
Math.min(t.length, 12);
// CODE NUMBERS ONLY — alnum tokens holding a digit (WDW2310, GRS-26220, 5255-16). Plain
// names and marketing prose are intentionally ignored: scan the code, never the text.
const dwLabel = DW_BRAND_RE.test(text) || text.replace(/\D/g, '').includes(DW_PHONE); // it's OUR sticker
const seen = new Set(); const codes = [];
for (const r of rows) {
if (r.bc) continue;
for (const t of (r.t.toUpperCase().match(CODE_TOKEN) || [])) {
if (!/\d/.test(t) || t.length < 4 || seen.has(t)) continue; // must hold a digit, >=4 chars
if (isDwNoise(t, dwLabel)) continue; // our phone / phone-shaped → never a SKU
seen.add(t); codes.push({ t, h: r.h, s: skuScore(t) });
}
}
// LARGEST CODE FIRST: font height, then SKU-likeness (vendor prefix), then length.
codes.sort((a, b) => (b.h - a.h) || (b.s - a.s) || (b.t.length - a.t.length));
const hMax = codes.reduce((m, c) => Math.max(m, c.h), 0);
// Candidate order: barcode payloads (ground truth) first, then text codes largest-first.
const candidates = [...new Set([...barcodes, ...codes.map(c => c.t)])];
const top = candidates[0] || null;
const tc = codes[0] || null;
// STRONG = safe to LOCK on the first sighting: a barcode (always), a known SKU prefix,
// a brand-prefix match, or a brand on the swatch + the font-dominant code.
const topStrong = !!top && (
barcodes.length > 0 ||
STRONG_PFX.test(top) || (vlex && vlex.pfx.test(top)) || (!!vendor && tc && tc.h >= hMax)
);
// FRONT (pattern face) vs BACK (printed label): a sample's label side is text/SKU/barcode-dense;
// the pattern face reads as little/no printed text. Cheap ($0) — derived from the OCR already run,
// so the live scanner can tell the user "flip to the label" instead of silently finding nothing.
const textLen = text.replace(/\s+/g, '').length;
const lineCount = rows.filter(r => !r.bc && String(r.t).trim()).length;
let side = 'unknown', sideConf = 0.4, sideReason = 'ambiguous';
if (barcodes.length) { side = 'back'; sideConf = 0.98; sideReason = 'barcode present'; }
else if (codes.length && (vendor || lineCount >= 2)) { side = 'back'; sideConf = 0.9; sideReason = 'SKU code + ' + (vendor ? 'vendor name' : 'printed text'); }
else if (lineCount >= 3 || textLen >= 24) { side = 'back'; sideConf = 0.72; sideReason = 'dense printed text'; }
else if (!codes.length && lineCount <= 1 && textLen < 8) { side = 'front'; sideConf = 0.8; sideReason = 'little/no printed text (pattern face)'; }
const out = { text, vendor, candidates, top, topStrong, barcode: barcodes[0] || null,
side, side_confidence: sideConf, side_reason: sideReason };
// Human-facing fields the live camera highlights in green as they're detected.
out.fields = classifyFields(rows, out);
return out;
}
// ── Vendor sample profiles: "how each vendor labels their samples" ─────────────
// Built broadly from the catalog by build_vendor_profiles.py and refined per-scan by
// /api/learn. Merged with the curated seed VENDOR_LEX into the runtime LEXICON the
// scanner consults — so brand detection + SKU prefix-boosting improve as we learn.
const VENDOR_PROFILES_FILE = path.join(DATA, 'vendor_profiles.json');
const PRIVATE_LABEL_RE = /BREWSTER|YORK|WALLQUEST|CHESAPEAKE|NEXTWALL|SEABROOK|COMMAND ?54|DESIMA|CARLSTEN|NICOLETTE ?MAYER/i;
const escapeRe = s => String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
function brandRe(name) { // vendor name -> flexible OCR detection source string
const words = (String(name).match(/[A-Za-z]+/g) || []).filter(w => w.length >= 3).map(w => w.toUpperCase());
if (!words.length) return null;
if (words.length === 1 && words[0].length < 4) return null; // single short word = noisy
return words.map(escapeRe).join('\\s*');
}
let VENDOR_PROFILES = (loadJSON(VENDOR_PROFILES_FILE, {}).profiles) || {};
function saveVendorProfiles() {
const meta = loadJSON(VENDOR_PROFILES_FILE, {})._meta || {};
meta.updated_at = new Date().toISOString(); meta.vendors = Object.keys(VENDOR_PROFILES).length;
saveJSON(VENDOR_PROFILES_FILE, { _meta: meta, profiles: VENDOR_PROFILES });
}
// Compile one learned profile into the seed's {name, re, pfx} shape.
function profileToLex(name, p) {
let re = null; try { re = p.alias_re ? new RegExp(p.alias_re) : null; } catch (e) { re = null; }
// Require >=2 supporting scans before a prefix biases ranking, so ONE bad scan can't inject
// a wrong prefix into the lexicon. Catalog-built prefixes are already vetted (builder keeps
// n>=3), so this only gates thin learned ones until a 2nd scan confirms them.
const prefs = Object.keys(p.prefixes || {}).filter(k => k && (p.prefixes[k] || 0) >= 2).sort((a, b) => b.length - a.length); // WDW before WD
// A vendor can be BOTH prefixed AND numeric-dominant (Schumacher: DWLK house code + bare
// numbers on the swatch) — express both so the scanner matches either form it sees.
const alts = prefs.map(escapeRe);
if ((p.numeric_share || 0) >= 0.3) alts.push('\\d{3,}');
let pfx = null;
if (alts.length) { try { pfx = new RegExp('^(' + alts.join('|') + ')'); } catch (e) { pfx = null; } }
if (!re && !pfx) return null;
return { name, re: re || /(?!)/, pfx: pfx || /(?!)/, learned: true, private_label: !!p.private_label };
}
// LEXICON = curated seed first (trusted), then learned profiles not already seeded.
let LEXICON = [];
function rebuildLexicon() {
const seeded = new Set(VENDOR_LEX.map(v => v.name.toUpperCase()));
const learned = Object.entries(VENDOR_PROFILES).map(([n, p]) => profileToLex(n, p))
.filter(Boolean).filter(e => !seeded.has(e.name.toUpperCase()));
LEXICON = VENDOR_LEX.concat(learned);
}
rebuildLexicon();
// ── Local VLM brand/logo recognition (Ollama qwen2.5vl, $0) ────────────────────
// Reads a STYLIZED logo/wordmark + typesetting that the deterministic OCR can't, to name
// the vendor when no brand text was cleanly recognized. An on-lock assist, not per-frame.
const OLLAMA_VISION_MODEL = process.env.OLLAMA_VISION_MODEL || 'qwen2.5vl:7b'; // reuses OLLAMA_URL declared with ollamaAsk
// Allow-listed vision models a request may pick. 'fast' => moondream (~3-5s, lower accuracy);
// default qwen2.5vl is more accurate (~15-20s). Anything off-list is ignored (no arbitrary pulls).
const VISION_MODELS = { 'qwen2.5vl:7b': 'qwen2.5vl:7b', 'moondream:latest': 'moondream:latest',
'moondream': 'moondream:latest', 'fast': 'moondream:latest', 'accurate': 'qwen2.5vl:7b', 'llava:13b': 'llava:13b' };
function pickVisionModel(req) { return VISION_MODELS[String(req || '').toLowerCase().trim()] || OLLAMA_VISION_MODEL; }
function ollamaVision(b64, prompt, timeoutMs, model) {
return new Promise(resolve => {
let target; try { target = new URL('/api/generate', OLLAMA_URL); } catch (e) { return resolve({ error: 'bad OLLAMA_URL' }); }
const lib = target.protocol === 'https:' ? https : http;
const payload = JSON.stringify({ model: model || OLLAMA_VISION_MODEL, prompt, images: [b64],
stream: false, format: 'json', options: { temperature: 0 } });
// default 35s is fine once the model is warm; callers expecting a possible COLD model
// load (first call after idle ≈ 50s here) pass a larger budget so they don't false-timeout.
const r = lib.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 35000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve(JSON.parse(d)); } catch (e) { resolve({ error: 'parse' }); } }); });
r.on('error', e => resolve({ error: e.message }));
r.on('timeout', () => { r.destroy(); resolve({ error: 'timeout' }); });
r.write(payload); r.end();
});
}
// ── Pluggable vision engines: macvision (Apple Vision + local Ollama) · gemini · gcv ──
// Steve 2026-07-06: so the scanner runs on Kamatera (Linux), where the macOS `bin/ocr`
// Vision binary and a local Ollama don't exist. Each engine is selectable per request
// ({engine:"gemini"} or ?engine=gcv); engine:"all" fans out to every AVAILABLE engine and
// returns each result for comparison. Default = macvision on macOS, gemini elsewhere.
const GEMINI_API_KEY = process.env.GEMINI_API_KEY || '';
const GEMINI_VISION_MODEL = process.env.GEMINI_VISION_MODEL || 'gemini-2.5-flash';
const GCV_API_KEY = process.env.GCV_API_KEY || process.env.GOOGLE_VISION_API_KEY || '';
const IS_DARWIN = process.platform === 'darwin';
const HAS_MACVISION = IS_DARWIN && fs.existsSync(path.join(ROOT, 'bin/ocr'));
const DEFAULT_ENGINE = (process.env.OCR_ENGINE || (HAS_MACVISION ? 'macvision' : 'gemini')).toLowerCase();
const ALL_ENGINES = ['macvision', 'gemini', 'gcv'];
// Rough per-scan cost so the caller always sees the $ (Steve's "always show costs" rule).
const ENGINE_COST_USD = { macvision: 0, gemini: 0.0006, gcv: 0.0015 };
function engineAvailable(e) {
if (e === 'macvision') return HAS_MACVISION;
if (e === 'gemini') return !!GEMINI_API_KEY;
if (e === 'gcv') return !!GCV_API_KEY;
return false;
}
const availableEngines = () => ALL_ENGINES.filter(engineAvailable);
// Resolve a requested engine to the concrete engine(s) to run. 'all' => every available one;
// an unavailable/unknown pick falls back to the default (then any available) so a scan never dead-ends.
function resolveEngines(req) {
const want = String(req || DEFAULT_ENGINE).toLowerCase().trim();
if (want === 'all') return availableEngines();
if (engineAvailable(want)) return [want];
if (engineAvailable(DEFAULT_ENGINE)) return [DEFAULT_ENGINE];
return availableEngines().slice(0, 1);
}
// FUSED back-label OCR: run every available engine (Google Cloud Vision = literal char fidelity, best
// for exact codes; Gemini = layout/context) and UNION their candidate codes. The catalog's partial +
// trim-tolerant search then recovers a mostly-right read, so more engines = more shots on goal. Engine
// order is GCV → gemini → macvision (literal transcribers first). Plug-and-play: keying GCV_API_KEY auto-
// enables it here with no code change. Cost = sum of the engines that run (shown per scan).
// Client now sends the RAW camera photo (no client-side decode — iOS freeze fix). Normalize server-side:
// ImageMagick converts ANY format (HEIC/JPEG/PNG), auto-orients, and downscales to ≤1600px JPEG so OCR
// engines (GCV needs JPEG/PNG, not HEIC) + storage get a sane image. Best-effort — original on failure.
function normalizeImage(buf) {
return new Promise(resolve => {
let out = [];
const cp = execFile('/usr/bin/convert', ['-', '-auto-orient', '-resize', '1600x1600>', '-quality', '85', 'jpg:-'],
{ maxBuffer: 48 * 1024 * 1024, timeout: 12000, encoding: 'buffer' }, (err, stdout) => {
resolve(err || !stdout || !stdout.length ? buf : stdout); // fall back to the original bytes
});
try { cp.stdin.on('error', () => {}); cp.stdin.write(buf); cp.stdin.end(); } catch (e) { resolve(buf); }
});
}
// normalize a base64 image (any format/size, incl. iOS HEIC) → clean ≤1600px JPEG base64. The client now
// sends RAW camera photos (no client decode — iOS freeze fix), so EVERY ingest point normalizes here.
async function normB64(b64) { if (!b64) return b64; try { return (await normalizeImage(Buffer.from(b64, 'base64'))).toString('base64'); } catch (e) { return b64; } }
const OCR_FUSION_ORDER = ['gcv', 'gemini', 'macvision'];
async function backOcrFused(buf) {
const engines = OCR_FUSION_ORDER.filter(engineAvailable);
if (!engines.length) return null;
const results = (await Promise.all(engines.map(e => runOcr(e, buf).catch(() => null)))).filter(r => r && r.ok);
if (!results.length) return null;
const candidates = [], seen = new Set();
for (const r of results) // GCV candidates lead (literal reads first)
for (const c of [r.barcode, r.top, ...(r.candidates || [])].filter(Boolean)) {
const u = String(c).toUpperCase(); if (!seen.has(u)) { seen.add(u); candidates.push(u); }
}
const pick = k => { for (const r of results) { const v = r[k]; if (v) return v; } return null; };
const fields = {}; for (const r of results) { const f = r.fields || {}; for (const k of ['sku', 'model', 'name', 'color', 'barcode']) if (!fields[k] && f[k]) fields[k] = f[k]; }
return { ok: true, engines: results.map(r => r.engine), top: candidates[0] || null, candidates,
vendor: pick('vendor'), barcode: pick('barcode'), fields, cost_usd: results.reduce((s, r) => s + (r.cost_usd || 0), 0) };
}
// Gemini multimodal (OCR + reasoning identify). Returns { response:<text>, model, error }
// — `response` mirrors Ollama's field so JSON.parse(r.response) callers work unchanged.
function geminiVision(b64, prompt, timeoutMs) {
return new Promise(resolve => {
if (!GEMINI_API_KEY) return resolve({ error: 'no GEMINI_API_KEY', model: GEMINI_VISION_MODEL });
let target; try { target = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_VISION_MODEL}:generateContent?key=${encodeURIComponent(GEMINI_API_KEY)}`); }
catch (e) { return resolve({ error: 'bad gemini url', model: GEMINI_VISION_MODEL }); }
const payload = JSON.stringify({ contents: [{ parts: [{ text: prompt }, { inline_data: { mime_type: 'image/jpeg', data: b64 } }] }],
generationConfig: { temperature: 0, responseMimeType: 'application/json' } });
const r = https.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 30000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
try {
const j = JSON.parse(d);
if (j.error) return resolve({ error: j.error.message || 'gemini error', model: GEMINI_VISION_MODEL });
const parts = (j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || [];
resolve({ response: parts.map(x => x.text || '').join(''), model: GEMINI_VISION_MODEL });
} catch (e) { resolve({ error: 'parse', model: GEMINI_VISION_MODEL }); } }); });
r.on('error', e => resolve({ error: e.message, model: GEMINI_VISION_MODEL }));
r.on('timeout', () => { r.destroy(); resolve({ error: 'timeout', model: GEMINI_VISION_MODEL }); });
r.write(payload); r.end();
});
}
// Fetch a catalog image URL → base64 (bounded), so it can be shown to the vision model. $0.
function fetchImageB64(url, maxBytes) {
return new Promise(resolve => {
let u; try { u = new URL(url); } catch (e) { return resolve(null); }
if (u.protocol !== 'https:' && u.protocol !== 'http:') return resolve(null);
// ask Shopify CDN for a small variant to keep it light
if (/shopify/i.test(u.hostname) && !/[?&]width=/.test(u.href)) u.href += (u.search ? '&' : '?') + 'width=400';
const lib = u.protocol === 'https:' ? https : http;
const rq = lib.get(u.href, { timeout: 6000 }, res => {
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return resolve(fetchImageB64(res.headers.location, maxBytes)); }
if (res.statusCode !== 200) { res.resume(); return resolve(null); }
const chunks = []; let n = 0;
res.on('data', c => { n += c.length; if (n > (maxBytes || 3e6)) { rq.destroy(); resolve(null); } else chunks.push(c); });
res.on('end', () => resolve(Buffer.concat(chunks).toString('base64')));
});
rq.on('error', () => resolve(null)); rq.on('timeout', () => { rq.destroy(); resolve(null); });
});
}
// VISION MATCH: show Gemini the user's ACTUAL photo + candidate catalog images and have it pick the
// one that is the SAME pattern by pixels (not typed attributes). images = [b64,...]; returns
// { best: <0-based idx into images, or -1>, confidence }.
function geminiVisualMatch(userB64, images) {
return new Promise(resolve => {
if (!GEMINI_API_KEY || !images.length) return resolve(null);
const parts = [{ text: `Image 1 is a PHOTO of a physical wallcovering/fabric SAMPLE. The remaining images (2 to ${images.length + 1}) are catalog product photos. Which ONE catalog image is the SAME pattern/material as the sample in image 1? Judge only the actual visual pattern, texture and colors — ignore crop, lighting, angle, and background. Reply ONLY compact JSON: {"match": <the catalog image number 2-${images.length + 1}, or 0 if none clearly match>, "confidence": <0-1>}.` },
{ inline_data: { mime_type: 'image/jpeg', data: userB64 } }];
images.forEach(im => parts.push({ inline_data: { mime_type: 'image/jpeg', data: im } }));
let target; try { target = new URL(`https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_VISION_MODEL}:generateContent?key=${encodeURIComponent(GEMINI_API_KEY)}`); }
catch (e) { return resolve(null); }
const payload = JSON.stringify({ contents: [{ parts }], generationConfig: { temperature: 0, responseMimeType: 'application/json' } });
const rq = https.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: 45000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
try { const j = JSON.parse(d);
const t = ((j.candidates && j.candidates[0] && j.candidates[0].content && j.candidates[0].content.parts) || []).map(x => x.text || '').join('');
const o = JSON.parse(t); const num = parseInt(o.match, 10);
resolve({ best: (num >= 2 ? num - 2 : -1), confidence: (o.confidence ?? null) });
} catch (e) { resolve(null); } }); });
rq.on('error', () => resolve(null)); rq.on('timeout', () => { rq.destroy(); resolve(null); });
rq.write(payload); rq.end();
});
}
// Google Cloud Vision DOCUMENT_TEXT_DETECTION (OCR only — no reasoning). Returns { text, error }.
function gcvOcr(b64, timeoutMs) {
return new Promise(resolve => {
if (!GCV_API_KEY) return resolve({ error: 'no GCV_API_KEY' });
let target; try { target = new URL(`https://vision.googleapis.com/v1/images:annotate?key=${encodeURIComponent(GCV_API_KEY)}`); }
catch (e) { return resolve({ error: 'bad gcv url' }); }
const payload = JSON.stringify({ requests: [{ image: { content: b64 }, features: [{ type: 'DOCUMENT_TEXT_DETECTION' }] }] });
const r = https.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json' }, timeout: timeoutMs || 20000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => {
try {
const j = JSON.parse(d);
if (j.error) return resolve({ error: j.error.message || 'gcv error' });
const r0 = (j.responses && j.responses[0]) || {};
if (r0.error) return resolve({ error: r0.error.message || 'gcv image error' });
resolve({ text: (r0.fullTextAnnotation && r0.fullTextAnnotation.text) || '' });
} catch (e) { resolve({ error: 'parse' }); } }); });
r.on('error', e => resolve({ error: e.message }));
r.on('timeout', () => { r.destroy(); resolve({ error: 'timeout' }); });
r.write(payload); r.end();
});
}
// Cloud OCR returns a flat text block; synthesize analyzeOcr rows from it. Font height is
// unknown from a text-only read, so every line gets a uniform height — analyzeOcr then ranks
// by SKU-likeness (vendor prefix / dashes / length) instead of by font size. Barcodes aren't
// decoded by text OCR (macvision-only via Apple Vision), so cloud reads carry no `bc` ground-truth line.
function textToRows(text) {
return String(text || '').split(/\r?\n/).map(l => l.trim()).filter(Boolean).map(l => ({ h: 100, t: l }));
}
// OCR one JPEG buffer with ONE engine → { engine, ok, cost_usd, ...analyzeOcr(), err }.
function runOcr(engine, buf) {
return new Promise(resolve => {
const cost = ENGINE_COST_USD[engine] || 0;
const b64 = buf.toString('base64');
if (engine === 'macvision') {
const tmp = path.join(PHOTOS, `_ocr-${engine}-${Date.now()}.jpg`);
try { fs.writeFileSync(tmp, buf); } catch (e) { return resolve({ engine, ok: false, cost_usd: cost, err: 'write fail' }); }
return execFile(path.join(ROOT, 'bin/ocr'), [tmp], { timeout: 8000 }, (err, stdout) => {
try { fs.unlinkSync(tmp); } catch (e) {}
if (err) return resolve({ engine, ok: false, cost_usd: cost, err: err.message });
resolve(Object.assign({ engine, ok: true, cost_usd: cost }, analyzeOcr(parseOcrRows(stdout))));
});
}
const OCR_PROMPT = 'You are an OCR engine reading a wallcovering/fabric SAMPLE label. Transcribe EVERY piece of text you see, one text element per line. Preserve SKUs / model codes EXACTLY as printed — do NOT normalize O<->0 or I<->1, do not invent or correct anything. Reply ONLY as compact JSON: {"lines":["<line>","<line>"]}.';
if (engine === 'gemini') {
return geminiVision(b64, OCR_PROMPT, 30000).then(r => {
if (r.error) return resolve({ engine, ok: false, cost_usd: cost, err: r.error });
let lines = []; try { const j = JSON.parse(r.response || '{}'); lines = Array.isArray(j.lines) ? j.lines : []; } catch (e) { lines = String(r.response || '').split(/\r?\n/); }
resolve(Object.assign({ engine, ok: true, cost_usd: cost }, analyzeOcr(textToRows(lines.join('\n')))));
});
}
if (engine === 'gcv') {
return gcvOcr(b64, 20000).then(r => {
if (r.error) return resolve({ engine, ok: false, cost_usd: cost, err: r.error });
resolve(Object.assign({ engine, ok: true, cost_usd: cost }, analyzeOcr(textToRows(r.text))));
});
}
resolve({ engine, ok: false, cost_usd: 0, err: 'unknown engine' });
});
}
// Reasoning vision (brand/pattern identify) for one engine. macvision => local Ollama;
// gemini/gcv => Gemini (GCV is OCR-only, so identify always routes to Gemini there).
// Returns { response:<json-text>, model, error } to match the Ollama shape callers parse.
function visionJSON(engine, b64, prompt, opts) {
opts = opts || {};
if (engine === 'macvision') return ollamaVision(b64, prompt, opts.timeoutMs, opts.model);
return geminiVision(b64, prompt, opts.timeoutMs || 30000);
}
// Fuzzy-match a VLM brand guess to a real known vendor (the VLM mis-spells: "BRUNSWIG"
// -> "Brunschwig & Fils"). Normalized Levenshtein over seed + learned vendor names.
function _lev(a, b) {
const m = a.length, n = b.length; if (!m) return n; if (!n) return m;
let prev = Array.from({ length: n + 1 }, (_, j) => j), cur = new Array(n + 1);
for (let i = 1; i <= m; i++) {
cur[0] = i;
for (let j = 1; j <= n; j++)
cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
[prev, cur] = [cur, prev];
}
return prev[n];
}
function fuzzyVendor(guess) {
const g = String(guess || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
if (g.length < 4) return null;
const names = new Set([...VENDOR_LEX.map(v => v.name), ...Object.keys(VENDOR_PROFILES)]);
const scored = [];
for (const name of names) {
const n = name.toUpperCase().replace(/[^A-Z0-9]/g, ''); if (n.length < 4) continue;
const ratio = _lev(g, n) / Math.max(g.length, n.length);
if (ratio < 0.34) scored.push({ name, n, ratio });
}
if (!scored.length) return null;
scored.sort((a, b) => a.ratio - b.ratio);
const best = scored[0];
// AMBIGUITY GUARD: if a GENUINELY DIFFERENT vendor (different normalized name — so same-brand
// spellings like "Armani Casa"/"Armani/Casa" don't count) is within MARGIN of the winner, the
// read is ambiguous (e.g. "Campbell" matches both Alan Campbell and Nina Campbell) → don't guess.
const rival = scored.find(s => s.n !== best.n);
if (rival && (rival.ratio - best.ratio) < 0.08) return null;
return best.name;
}
// DTD verdict A (2026-06-25, 3/3): fingerprint TIEBREAKER. Only consulted when the name
// match (lexicon + fuzzy) couldn't resolve a vendor — compares the live VLM read of the
// swatch logo (brand text + typeface) against the 20 stored, validated reference-logo
// fingerprints. Gated to the low-confidence branch so it can only help, never regress.
function fingerprintVendor(reads, typeface) {
const nr = String(reads || '').toLowerCase().replace(/[^a-z0-9]/g, '');
const tf = String(typeface || '').toLowerCase().trim();
if (nr.length < 4) return null;
let best = null, bestScore = 0;
for (const [name, p] of Object.entries(VENDOR_PROFILES)) {
if (p.logo_valid !== true || !p.logo_reads) continue; // only validated fingerprints
const fr = String(p.logo_reads).toLowerCase().replace(/[^a-z0-9]/g, '');
if (fr.length < 4) continue;
// reads-as similarity: containment either way (the VLM phrasing varies run to run)
let score = 0;
if (fr === nr) score = 1.0;
else if (fr.includes(nr) || nr.includes(fr)) score = 0.8;
else { const sh = _lev(fr, nr) / Math.max(fr.length, nr.length); if (sh < 0.3) score = 0.6; }
if (!score) continue;
if (tf && p.logo_typeface && tf === String(p.logo_typeface).toLowerCase().trim()) score += 0.1; // typeface agreement
if (score > bestScore) { bestScore = score; best = name; }
}
return bestScore >= 0.6 ? { vendor: best, via: 'fingerprint', score: Math.round(bestScore * 100) / 100 } : null;
}
// Pipe a remote image through same-origin so a <canvas> can read its pixels without a
// cross-origin taint. Shared by /api/current-image (server-derived src) and the
// host-whitelisted /api/imgproxy (client-supplied src). `headers` adds caching/CORS.
function pipeUpstreamImage(src, res, headers) {
https.get(src, ir => {
if ((ir.statusCode || 0) >= 400) { ir.resume(); return send(res, 502, { err: 'upstream ' + ir.statusCode }); }
res.writeHead(200, Object.assign({ 'Content-Type': ir.headers['content-type'] || 'image/jpeg' }, headers || {}));
ir.pipe(res);
}).on('error', e => send(res, 502, { err: e.message }));
}
const appHandler = (req, res) => {
// public (no-auth) paths: health + the home-screen-install assets iOS fetches without creds
const PUBLIC = ['/healthz', '/icon-180.png', '/icon-512.png', '/apple-touch-icon.png', '/manifest.webmanifest', '/apps/similar', '/apps/color-index', '/apps/color-widen', '/apps/color-dots'];
const _p = req.url.split('?')[0];
if (!PUBLIC.includes(_p) && !_p.startsWith('/marketing/') && !checkAuth(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Photo Capture"' });
return res.end('Auth required');
}
const u = new URL(req.url, `http://${req.headers.host}`);
if (u.pathname === '/healthz') return send(res, 200, { ok: true });
// ── Public marketing assets (email images) — TIGHTLY bounded static serve ──
// No auth (allowed in the PUBLIC gate above). NOT a generic file server:
// · Fixed dir public-marketing/ — never user-controlled.
// · Basename-only + strict regex allowlist (images only); rejects any '..' / slash.
// · Resolved path must stay inside the dir (path-traversal guard).
if (_p.startsWith('/marketing/')) {
const name = _p.slice('/marketing/'.length);
if (name.includes('/') || name.includes('..') ||
!/^[A-Za-z0-9._-]+\.(gif|png|jpe?g|webp)$/.test(name)) {
return send(res, 404, { err: 'not found' });
}
const MDIR = path.join(ROOT, 'public-marketing');
const fp = path.join(MDIR, name);
if (!fp.startsWith(MDIR + path.sep)) return send(res, 404, { err: 'not found' });
return fs.readFile(fp, (e, buf) => {
if (e) return send(res, 404, { err: 'not found' });
const ext = name.split('.').pop().toLowerCase();
const ct = { gif: 'image/gif', png: 'image/png', jpg: 'image/jpeg',
jpeg: 'image/jpeg', webp: 'image/webp' }[ext] || 'application/octet-stream';
res.writeHead(200, {
'Content-Type': ct,
'Cache-Control': 'public, max-age=604800',
'Access-Control-Allow-Origin': '*'
});
res.end(buf);
});
}
// ── PDP CLIP "More like this" proxy (GATED-STEP-1, shipped) ─────────────────
// Public (no basic-auth) but TIGHTLY bounded: NOT a generic proxy.
// · SSRF guard: fixed upstream host+path 127.0.0.1:9914/similar — never user-controlled.
// · Input allowlist: ONLY {dw_sku, hex, style, k}; k capped at 50. Anything else dropped.
// · Output allowlist: only public catalog fields (already visible on the storefront).
// · CORS: reflect an ALLOWED storefront origin only (Access-Control-Allow-Origin locked).
// Port 9914 stays loopback-only; this is the ONLY externally reachable surface to it.
if (u.pathname === '/apps/similar') {
const ALLOWED_ORIGINS = new Set([
'https://designerwallcoverings.com',
'https://www.designerwallcoverings.com',
'https://designer-laboratory-sandbox.myshopify.com'
]);
const origin = req.headers.origin || '';
const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
const CORS = {
'Access-Control-Allow-Origin': corsOrigin,
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
let body = '';
req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
const dw_sku = (typeof p.dw_sku === 'string') ? p.dw_sku.trim() : '';
if (!dw_sku || dw_sku.length > 64) return send(res, 400, { err: 'dw_sku required' }, CORS);
// allowlist + sanitize — ONLY these four fields ever reach the loopback service
const hex = (typeof p.hex === 'string') ? p.hex.slice(0, 16) : '';
const style = (typeof p.style === 'string') ? p.style.slice(0, 64) : '';
const k = Math.max(1, Math.min(parseInt(p.k, 10) || 8, 50));
const payload = JSON.stringify({ dw_sku, hex, style, k });
try {
const r = await fetch('http://127.0.0.1:9914/similar', { // FIXED host+path — SSRF-safe
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload,
signal: AbortSignal.timeout(8000)
});
const j = await r.json().catch(() => ({ results: [] }));
// re-emit ONLY public-safe columns (defense in depth even though upstream is already scoped)
const results = Array.isArray(j.results) ? j.results.map(x => ({
dw_sku: x.dw_sku, image: x.image, pattern: x.pattern,
vendor: x.vendor, handle: x.handle || null, score: x.score
})) : [];
return send(res, 200, { ok: true, results }, CORS);
} catch (e) {
return send(res, 502, { ok: false, err: 'similar upstream', results: [] }, CORS);
}
});
return;
}
// ── PDP color-dot "index of many products within 10% tolerance" ─────────────
// (GATED-STEP-2, staged) Steve 2026-07-09: "bring up an index of many images,
// not just that exact hex. tolerance 10% for color to pull more."
// A clicked palette hex resolves to a CATALOG-WIDE grid of products whose
// dominant color is within a perceptual 10% tolerance (CIELAB ΔE76) of the hex,
// drawn from data/color-index.json (one dominant color per ACTIVE product,
// with PRECOMPUTED LAB; generated by scripts/build-color-index.cjs). No DB at
// request time — the index is loaded once and held in memory.
// · Public (no basic-auth) but tightly bounded, same posture as /apps/similar.
// · Input allowlist: ONLY {hex, k}; k capped at 60. Nothing else is read.
// · Output allowlist: only public catalog fields already visible on the store.
// · CORS: reflect an ALLOWED storefront origin only.
// · 10% tolerance is ONE named constant (COLOR_INDEX_TOLERANCE_PCT) → ΔE ceiling.
// ── Shop-by-Color WHEEL dot set (GET) ──
// The data-driven color dots for /pages/shop-by-color: real DB colors only,
// each >=20 patterns, ΔE-collapsed to be visually unique, ordered by qty.
// Read-only GET (no input), public, same CORS posture as the color routes.
if (u.pathname === '/apps/color-dots') {
const ALLOWED_ORIGINS = new Set([
'https://designerwallcoverings.com',
'https://www.designerwallcoverings.com',
'https://designer-laboratory-sandbox.myshopify.com'
]);
const origin = req.headers.origin || '';
const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
const CORS = {
'Access-Control-Allow-Origin': corsOrigin,
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
'Cache-Control': 'public, max-age=3600'
};
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
if (req.method !== 'GET') return send(res, 405, { err: 'GET required' }, CORS);
try {
const d = loadColorDots();
return send(res, 200, d, CORS);
} catch (e) {
return send(res, 503, { ok: false, err: 'color dots unavailable', dots: [] }, CORS);
}
}
if (u.pathname === '/apps/color-index') {
const ALLOWED_ORIGINS = new Set([
'https://designerwallcoverings.com',
'https://www.designerwallcoverings.com',
'https://designer-laboratory-sandbox.myshopify.com'
]);
const origin = req.headers.origin || '';
const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
const CORS = {
'Access-Control-Allow-Origin': corsOrigin,
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
let body = '';
req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
req.on('end', () => {
let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
const rawHex = (typeof p.hex === 'string') ? p.hex.trim() : '';
const hex = /^#?[0-9a-fA-F]{6}$/.test(rawHex) ? ('#' + rawHex.replace('#', '').toLowerCase()) : '';
if (!hex) return send(res, 400, { err: 'hex required (#rrggbb)' }, CORS);
const k = Math.max(1, Math.min(parseInt(p.k, 10) || 36, 60));
try {
const idx = loadColorIndex(); // cached, loaded once
const target = hexToLab(hex);
const ceil = COLOR_INDEX_DELTA_E_CEILING; // 10% → ΔE76 ceiling
const within = [];
for (let i = 0; i < idx.length; i++) {
const it = idx[i];
const dl = it.l - target.l, da = it.a - target.a, db = it.b - target.b;
const de = Math.sqrt(dl * dl + da * da + db * db);
if (de <= ceil) within.push({ it, de });
}
within.sort((x, y) => x.de - y.de); // nearest first
const results = within.slice(0, k).map(w => ({
handle: w.it.h, title: w.it.t, vendor: w.it.v,
hex: w.it.x, image: w.it.i, product_type: w.it.p || '',
delta_e: Math.round(w.de * 10) / 10
}));
return send(res, 200, {
ok: true, hex, tolerance_pct: COLOR_INDEX_TOLERANCE_PCT,
delta_e_ceiling: ceil, total_in_tolerance: within.length, results
}, CORS);
} catch (e) {
return send(res, 503, { ok: false, err: 'color index unavailable', results: [] }, CORS);
}
});
return;
}
// ── Shop-by-Color WHEEL "wider band" index (designerwallcoverings.com/pages/colors) ──
// Same in-memory color index + ΔE76 math as /apps/color-index, but with a wider,
// caller-tunable tolerance (default 15% ≈ ΔE 15) and a higher result cap, so the
// public color-wheel page can surface a BIG set of matches per hue (the PDP dots
// stay on the tighter 10%/k60 path — that route is untouched). Read-only, public,
// same CORS posture. Input allowlist: ONLY {hex, k, ceiling}.
if (u.pathname === '/apps/color-widen') {
const ALLOWED_ORIGINS = new Set([
'https://designerwallcoverings.com',
'https://www.designerwallcoverings.com',
'https://designer-laboratory-sandbox.myshopify.com'
]);
const origin = req.headers.origin || '';
const corsOrigin = ALLOWED_ORIGINS.has(origin) ? origin : 'https://designerwallcoverings.com';
const CORS = {
'Access-Control-Allow-Origin': corsOrigin,
'Vary': 'Origin',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
};
if (req.method === 'OPTIONS') { res.writeHead(204, CORS); return res.end(); }
if (req.method !== 'POST') return send(res, 405, { err: 'POST required' }, CORS);
let body = '';
req.on('data', c => { body += c; if (body.length > 4096) req.destroy(); });
req.on('end', () => {
let p; try { p = JSON.parse(body || '{}'); } catch (e) { return send(res, 400, { err: 'bad json' }, CORS); }
const rawHex = (typeof p.hex === 'string') ? p.hex.trim() : '';
const hex = /^#?[0-9a-fA-F]{6}$/.test(rawHex) ? ('#' + rawHex.replace('#', '').toLowerCase()) : '';
if (!hex) return send(res, 400, { err: 'hex required (#rrggbb)' }, CORS);
const k = Math.max(1, Math.min(parseInt(p.k, 10) || 240, 400));
const ceil = Math.max(5, Math.min(Number(p.ceiling) || 15, 25)); // % ≈ ΔE76 ceiling
const min = Math.max(0, Math.min(parseInt(p.min, 10) || 0, 400)); // guarantee at least this many
try {
const idx = loadColorIndex(); // cached, loaded once
const target = hexToLab(hex);
const scored = []; // ΔE for EVERY product, so we can
for (let i = 0; i < idx.length; i++) { // fall back to nearest-N when the band is thin
const it = idx[i];
const dl = it.l - target.l, da = it.a - target.a, db = it.b - target.b;
scored.push({ it, de: Math.sqrt(dl * dl + da * da + db * db) });
}
scored.sort((x, y) => x.de - y.de); // nearest first
let sel = scored.filter(s => s.de <= ceil); // within the 15% band
const withinCeiling = sel.length;
if (sel.length < min) sel = scored.slice(0, min); // thin band → expand to the nearest `min`
const results = sel.slice(0, k).map(w => ({
handle: w.it.h, title: w.it.t, vendor: w.it.v,
hex: w.it.x, image: w.it.i, product_type: w.it.p || '',
delta_e: Math.round(w.de * 10) / 10
}));
return send(res, 200, {
ok: true, hex, tolerance_pct: ceil / 100, delta_e_ceiling: ceil, min,
total_in_tolerance: withinCeiling, returned: results.length, results
}, CORS);
} catch (e) {
return send(res, 503, { ok: false, err: 'color index unavailable', results: [] }, CORS);
}
});
return;
}
// ── Remote-shutter pairing routes ──
// SSE stream for the PHONE cam page. Registers the cam role; listens for `shoot`.
if (u.pathname === '/cam/events') {
sseInit(res);
ROOM.cam = { res, lastSeen: Date.now() };
sseSend('cam', 'status', pairStatus());
broadcastStatus();
req.on('close', () => { if (ROOM.cam && ROOM.cam.res === res) { ROOM.cam = null; ROOM.camLive = false; broadcastStatus(); } });
return;
}
// SSE stream for the DESKTOP control page. Registers the desk role; listens for `shot`/`status`.
if (u.pathname === '/desk/events') {
sseInit(res);
ROOM.desk = { res, lastSeen: Date.now() };
sseSend('desk', 'status', pairStatus());
broadcastStatus();
req.on('close', () => { if (ROOM.desk && ROOM.desk.res === res) { ROOM.desk = null; broadcastStatus(); } });
return;
}
// Phone reports its camera state (live/info) + keeps its lastSeen fresh.
if (u.pathname === '/api/pair/cam-status' && req.method === 'POST') {
let body = ''; req.on('data', c => body += c);
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
if (ROOM.cam) ROOM.cam.lastSeen = Date.now();
ROOM.camLive = !!p.live; ROOM.camInfo = String(p.info || '');
broadcastStatus();
return send(res, 200, { ok: true });
});
return;
}
// Desktop presses SHOOT → push a `shoot` event to the phone with the target SKU context.
// This does NOT itself write to Shopify — the phone captures + uploads via /api/photo,
// which keeps the exact same gated attach behavior as a manual capture today.
if (u.pathname === '/api/pair/shoot' && req.method === 'POST') {
let body = ''; req.on('data', c => body += c);
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
if (ROOM.desk) ROOM.desk.lastSeen = Date.now();
const st = pairStatus();
if (!st.cam_connected) return send(res, 409, { err: 'phone not connected' });
if (!st.cam_live) return send(res, 409, { err: 'phone camera not live — open /cam on the phone and allow the camera' });
// target = the SKU the desktop selected (or none → phone just captures into the local gallery)
const target = {
dw_sku: p.dw_sku || null, product_id: p.product_id || null,
keep_images: p.keep_images !== false, // default preserve existing imagery (safe)
meta: p.meta || null, nonce: Date.now()
};
const ok = sseSend('cam', 'shoot', target);
return send(res, ok ? 200 : 502, { ok, queued: ok });
});
return;
}
// Phone reports the result of a shot so the desktop can preview + see the attach outcome.
if (u.pathname === '/api/pair/shot' && req.method === 'POST') {
let body = ''; req.on('data', c => body += c);
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { p = {}; }
if (ROOM.cam) ROOM.cam.lastSeen = Date.now();
ROOM.lastShot = Object.assign({ ts: new Date().toISOString() }, p);
sseSend('desk', 'shot', ROOM.lastShot);
return send(res, 200, { ok: true });
});
return;
}
// Status snapshot (polling fallback if a client's SSE is down).
if (u.pathname === '/api/pair/status' && req.method === 'GET') return send(res, 200, pairStatus());
// Camera capability probe page + its result logger (learn what an iPhone/iPad Safari exposes).
if (u.pathname === '/caps') {
const f = path.join(ROOT, 'public/caps.html');
if (!fs.existsSync(f)) return send(res, 404, { err: 'caps.html missing' });
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/caps' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
req.on('end', () => { try { fs.appendFileSync(path.join(ROOT, 'caps.log'), new Date().toISOString() + ' ' + body + '\n'); } catch (e) {} send(res, 200, { ok: true }); });
return;
}
// Desktop control page + phone live-camera page (both behind the existing basic-auth).
if (u.pathname === '/desk') {
const f = path.join(ROOT, 'public/desk.html');
if (!fs.existsSync(f)) return send(res, 404, { err: 'desk.html missing' });
let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
}
if (u.pathname === '/cam') {
const f = path.join(ROOT, 'public/cam.html');
if (!fs.existsSync(f)) return send(res, 404, { err: 'cam.html missing' });
let html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
}
// ── Vendor-profile learning surface ──
// What the app has learned about how each vendor labels their samples.
if (u.pathname === '/api/vendor-profiles' && req.method === 'GET') {
const vendors = Object.entries(VENDOR_PROFILES).map(([name, p]) => Object.assign({ name }, p))
.sort((a, b) => (b.learned_scans || 0) - (a.learned_scans || 0) || (b.n || 0) - (a.n || 0));
return send(res, 200, { total: vendors.length, seeded: VENDOR_LEX.map(v => v.name), vendors });
}
// Observability snapshot for the self-teaching scanner — read-only, in-memory. $0.
if (u.pathname === '/api/stats' && req.method === 'GET') {
const profs = Object.values(VENDOR_PROFILES);
let lastLearn = null, learnTotal = 0;
for (const p of profs) {
learnTotal += (p.learned_scans || 0);
if (p.learned_scans > 0 && p.updated_at && (!lastLearn || p.updated_at > lastLearn)) lastLearn = p.updated_at;
}
return send(res, 200, {
version: buildLabel(),
vendors: profs.length,
seeded: VENDOR_LEX.length,
fingerprinted: profs.filter(p => p.logo_valid === true).length,
learned_scans_total: learnTotal,
last_learn: lastLearn,
recents: Object.keys(recents).length,
catalog_indexed: CATALOG.length,
vision_model: OLLAMA_VISION_MODEL,
});
}
// Refine a vendor's profile from a scan that resolved to a real product. Conservative:
// we only fold in structured signal (the on-swatch code prefix), never raw OCR prose.
if (u.pathname === '/api/learn' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const vendor = String(p.vendor || '').trim();
if (vendor.length > 120) return send(res, 400, { err: 'vendor too long' }); // bound the regex rebuildLexicon compiles
// reject prototype keys: VENDOR_PROFILES['__proto__'] returns Object.prototype (truthy),
// so prof.prefixes[pre] would deref undefined and CRASH the process (DoS). No real vendor
// name is one of these. (proto-pollution guard)
if (vendor === '__proto__' || vendor === 'constructor' || vendor === 'prototype') return send(res, 400, { err: 'invalid vendor' });
const sku = String(p.sku || '').toUpperCase().replace(/\s+/g, '');
if (!vendor || !sku) return send(res, 400, { err: 'vendor + sku required' });
const prof = VENDOR_PROFILES[vendor] || (VENDOR_PROFILES[vendor] = {
n: 0, aliases: [vendor.toUpperCase()], alias_re: brandRe(vendor), prefixes: {},
numeric_share: 0, common_len: 0, examples: [], private_label: PRIVATE_LABEL_RE.test(vendor),
source: 'scan', learned_scans: 0
});
const pre = (sku.match(/^[A-Z]+/) || [''])[0];
if (pre) prof.prefixes[pre] = (prof.prefixes[pre] || 0) + 1;
else prof.numeric_share = Math.min(1, (prof.numeric_share || 0) + 0.05);
if (!prof.examples.includes(sku) && prof.examples.length < 8) prof.examples.push(sku);
// Learn the vendor's SKU SHAPE (WDW2310 → AAA####) so we recognize their code format on sight.
const shape = sku.replace(/[A-Z]/g, 'A').replace(/[0-9]/g, '#');
prof.sku_shapes = prof.sku_shapes || {}; prof.sku_shapes[shape] = (prof.sku_shapes[shape] || 0) + 1;
// Save the vendor "specs" — the colorways + pattern names seen, so the reader learns each
// vendor's vocabulary (bounded lists; deduped, case-normalized).
const addTo = (key, val, cap) => {
val = String(val || '').replace(/\s+/g, ' ').trim().slice(0, 40);
if (!val) return; prof[key] = prof[key] || [];
if (!prof[key].some(x => x.toLowerCase() === val.toLowerCase()) && prof[key].length < cap) prof[key].push(val);
};
if (p.color) addTo('colors', p.color, 40);
if (p.name) addTo('names', p.name, 60);
prof.learned_scans = (prof.learned_scans || 0) + 1;
prof.updated_at = new Date().toISOString();
saveVendorProfiles(); rebuildLexicon();
return send(res, 200, { ok: true, vendor, learned_prefix: pre || null, learned_scans: prof.learned_scans,
sku_shape: shape, colors: (prof.colors || []).length, names: (prof.names || []).length });
});
return;
}
// Learning viewer page (admin, behind the existing basic-auth).
if (u.pathname === '/learn') {
const f = path.join(ROOT, 'public/learn.html');
if (!fs.existsSync(f)) return send(res, 404, { err: 'learn.html missing' });
const html = fs.readFileSync(f, 'utf8').replace(/__VER__/g, buildLabel());
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' }), res.end(html);
}
// Recognize the BRAND by its logo/wordmark + typesetting via the local VLM (qwen2.5vl).
// The assist for when OCR couldn't read the brand text but the logo is visible. $0 (local).
if (u.pathname === '/api/identify' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
const prompt = 'This is a wallcovering or fabric SAMPLE label. Identify the BRAND from its logo or wordmark and note the typesetting. Reply ONLY as compact JSON: {"brand":"<manufacturer/brand or empty if unsure>","confidence":<0-1>,"logo":"<short logo/wordmark description>","typeface":"<e.g. serif wordmark / sans caps / script>","code":"<any SKU or model number visible, else empty>"}';
// engine-pluggable ({engine:...}); macvision keeps the model toggle ({model:.. }/{fast:true} → moondream).
const engine = resolveEngines(p.engine)[0] || DEFAULT_ENGINE;
const useModel = engine === 'macvision' ? pickVisionModel(p.fast ? 'fast' : p.model) : GEMINI_VISION_MODEL;
const r = await visionJSON(engine, b64, prompt, { timeoutMs: p.fast ? 25000 : undefined, model: useModel });
let out = {}; try { out = JSON.parse(r.response || '{}'); } catch (e) { out = {}; }
const brand = (out.brand || '').toString().trim();
// primary: cross-check the VLM's brand guess against the learned lexicon, then fuzzy name.
let matched = brand ? ((LEXICON.find(v => v.re.test(brand.toUpperCase())) || {}).name || fuzzyVendor(brand)) : null;
// TIEBREAKER (DTD-A): only if name match was inconclusive, fall back to the stored
// reference-logo fingerprints (compare the live read + typeface to the 20 validated logos).
let matchVia = matched ? 'name' : null;
if (!matched) {
const fp = fingerprintVendor(brand, out.typeface);
if (fp) { matched = fp.vendor; matchVia = 'fingerprint'; }
}
return send(res, 200, { ok: !r.error, model: useModel, engine, cost_usd: ENGINE_COST_USD[engine] || 0,
brand: brand || null, confidence: (out.confidence ?? null), logo: out.logo || null,
typeface: out.typeface || null, code: (out.code || '').toString().toUpperCase().replace(/\s+/g, '') || null,
vendor: matched, match_via: matchVia, err: r.error || null });
});
return;
}
// Recognize the actual PATTERN/material in a photo (the swatch itself, not the label)
// and surface similar items from the WHOLE unified mirror (~169k products). The local
// VLM reads the pattern's attributes; the local Postgres mirror — whose `tags` are
// AI-enriched with colors/style/motif/material — is ranked by attribute overlap. $0.
if (u.pathname === '/api/recognize' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
const prompt = 'You are looking at a wallcovering or fabric SWATCH — the material itself, NOT a printed label. Describe the PATTERN so it can be matched against a catalog. Reply ONLY as compact JSON: {"description":"<one short sentence>","motif":"<main motif e.g. floral, damask, geometric, grasscloth, stripe, botanical, abstract, ikat, toile>","style":"<e.g. traditional, modern, transitional, scandinavian, art deco, contemporary>","material":"<e.g. grasscloth, non-woven, silk, vinyl, paper, leather>","colors":["<color name>","<color name>"],"background":"<background color name>","scale":"<small | medium | large>","code":"<any SKU or model number printed on it, else empty>"}';
const engine = resolveEngines(p.engine)[0] || DEFAULT_ENGINE;
const r = await visionJSON(engine, b64, prompt, { timeoutMs: 90000 }); // allow for a cold model load
let a = {}; try { a = JSON.parse(r.response || '{}'); } catch (e) { a = {}; }
const terms = similarTerms(a);
let items = []; try { items = await unifiedSimilar(terms); } catch (e) { items = []; }
const code = (a.code || '').toString().toUpperCase().replace(/\s+/g, '');
// VISION MATCH: don't stop at typed attributes — show Gemini the ACTUAL photo + the top
// candidate IMAGES and let it pick the true pixel match, then float it to the front.
let visual = null;
if (items.length && GEMINI_API_KEY && p.visualMatch !== false) {
try {
const top = items.filter(x => x.image).slice(0, 5);
const fetched = (await Promise.all(top.map(x => fetchImageB64(x.image, 2e6))))
.map((bb, i) => ({ bb, item: top[i] })).filter(x => x.bb);
if (fetched.length) {
const vm = await geminiVisualMatch(b64, fetched.map(x => x.bb));
const cost = 0.0006 * (fetched.length + 1);
if (vm && vm.best >= 0 && vm.best < fetched.length) {
const chosen = fetched[vm.best].item;
items = [chosen, ...items.filter(x => x !== chosen)];
visual = { matched_sku: chosen.dw_sku || null, matched_mfr: chosen.mfr || null, matched_title: chosen.title || null, matched_image: chosen.image || null, confidence: vm.confidence, via: 'gemini-visual', compared: fetched.length, cost_usd: cost };
} else { visual = { matched_sku: null, confidence: vm && vm.confidence, via: 'gemini-visual', note: 'no confident visual match', compared: fetched.length, cost_usd: cost }; }
}
} catch (e) { visual = { error: e.message }; }
}
return send(res, 200, { ok: !r.error, model: (engine === 'macvision' ? OLLAMA_VISION_MODEL : GEMINI_VISION_MODEL), engine,
recognized: {
description: a.description || null, motif: a.motif || null, style: a.style || null,
material: a.material || null, colors: Array.isArray(a.colors) ? a.colors.slice(0, 6) : [],
background: a.background || null, scale: a.scale || null, code: code || null
},
visual, terms, total: items.length, items, err: r.error || null });
});
return;
}
// /selfcheck — re-runnable health report. Verifies data integrity + AUTO-CLEANS stale
// photo refs (progress entries pointing at a deleted /photos file = the dead-link class).
if (u.pathname === '/selfcheck') {
const checks = [];
const ck = (name, ok, detail) => checks.push({ name, ok: !!ok, detail });
ck('server', true, 'up');
ck('shopify token', !!TOKEN, TOKEN ? 'present' : 'MISSING — creates/photos will fail');
ck('catalog indexed', CATALOG.length > 0, `${CATALOG.length} live Fentucci`);
ck('lookup index', INDEX.length > 0, `${INDEX.length} total (incl. ${INDEX.filter(x => x.needs_create).length} new-from-sheet)`);
ck('sheet GRS loaded', SHEET.length > 0, `${SHEET.length} GRS rows`);
let queueN = -1; try { queueN = loadJSON(QUEUE_FILE, []).length; } catch (e) {}
ck('worklist queue', queueN >= 0, `${queueN} need a photo`);
// stale local-photo refs → auto-clean so no card 404s
let stale = 0, fixed = 0;
for (const [sku, e] of Object.entries(progress)) {
if (e.photo && e.photo.startsWith('/photos/') && !fs.existsSync(path.join(PHOTOS, path.basename(e.photo)))) {
stale++; if (e.shopify_pushed || e.live || e.created) e.photo = null; else delete progress[sku]; fixed++;
}
}
if (fixed) saveProgress();
ck('stale photo refs', true, stale ? `cleaned ${fixed} dead thumbnail ref(s)` : 'none');
const ok = checks.every(c => c.ok);
const wantsHtml = (req.headers.accept || '').includes('text/html');
if (!wantsHtml) return send(res, 200, { ok, version: buildLabel(), checks, ts: new Date().toISOString() });
const rows = checks.map(c => `<tr><td>${c.ok ? '✅' : '❌'}</td><td>${c.name}</td><td class="d">${c.detail || ''}</td></tr>`).join('');
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store' });
return res.end(`<!doctype html><meta name=viewport content="width=device-width,initial-scale=1"><style>
body{font:15px -apple-system,sans-serif;background:#0f0e0c;color:#f3efe7;margin:0;padding:20px}
h1{font-size:18px;color:#c8a24a;letter-spacing:.04em} .big{font-size:22px;font-weight:800;margin:8px 0}
table{width:100%;border-collapse:collapse;margin-top:10px} td{padding:9px 6px;border-bottom:1px solid #2e2a25;vertical-align:top}
.d{color:#9a9184;font-size:13px} a{color:#c8a24a}</style>
<h1>DW SKU Photos — Self-Check</h1>
<div class="big">${ok ? '✅ All systems go' : '❌ Issues found'} · ${buildLabel()}</div>
<table>${rows}</table>
<p class="d">${new Date().toLocaleString()} · <a href="/selfcheck">re-run</a> · <a href="/">← app</a></p>`);
}
if (u.pathname === '/' || u.pathname === '/index.html') {
// no-store: the UI evolves fast and is read fresh per request — never let a
// browser serve a stale cached page (that hid the photo editor after a deploy).
let html = fs.readFileSync(path.join(ROOT, 'public/index.html'), 'utf8').replace(/__VER__/g, buildLabel());
return res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8', 'Cache-Control': 'no-store, must-revalidate' }),
res.end(html);
}
if (u.pathname === '/api/queue' && req.method === 'GET') {
const q = buildQueue();
return send(res, 200, { total: q.length, done: q.filter(x => x.done).length, items: q });
}
// Recently-updated SKUs (auto-logged on every upload), newest first.
if (u.pathname === '/api/recents' && req.method === 'GET') {
const items = Object.values(recents)
.sort((a, b) => String(b.ts || '').localeCompare(String(a.ts || ''))).slice(0, 40)
.map(x => ({ ...x, fav: !!favorites[x.dw_sku] }));
return send(res, 200, { total: items.length, items });
}
// Starred SKUs the user updates often, most-recently-starred first.
if (u.pathname === '/api/favorites' && req.method === 'GET') {
const items = Object.values(favorites)
.sort((a, b) => String(b.fav_ts || '').localeCompare(String(a.fav_ts || '')))
.map(x => ({ ...x, fav: true }));
return send(res, 200, { total: items.length, items });
}
// Toggle a favorite. Body: { dw_sku, on:bool, meta:{title,mfr,price,image,product_id,status} }
if (u.pathname === '/api/favorite' && req.method === 'POST') {
let body = ''; req.on('data', c => body += c);
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.dw_sku) return send(res, 400, { err: 'dw_sku required' });
if (p.on === false) { delete favorites[p.dw_sku]; }
else { favorites[p.dw_sku] = cardRecord(p.dw_sku, p.meta, { fav_ts: new Date().toISOString() }); }
saveFavorites();
return send(res, 200, { ok: true, fav: p.on !== false, count: Object.keys(favorites).length });
});
return;
}
// Look up ANY Fentucci (TWIL) product — by DW SKU, model #, or name — to add/replace its photo.
if (u.pathname === '/api/lookup' && req.method === 'GET') {
const q = (u.searchParams.get('q') || '').trim().toLowerCase();
if (!q) return send(res, 200, { items: [], indexed: CATALOG.length });
const terms = q.split(/\s+/);
const matches = INDEX.filter(x => {
const hay = `${x.dw_sku} ${x.mfr} ${x.title} ${(x.collections || []).join(' ')}`.toLowerCase();
return terms.every(t => hay.includes(t));
}).slice(0, 80);
return send(res, 200, { total: matches.length, items: matches, indexed: INDEX.length });
}
// Discontinued/deleted SKU resolver → { found, sku, title, status, discontinued, successor }.
// Dash/punct-insensitive; catches base-of-SAMPLE (CHC-216830 == CHC216830 == CHC-216830-SAMPLE).
// The scanner UI falls back to this when the main lookup returns 0 active hits, and renders a RED
// card pointing at the live SUCCESSOR (the product the user should photograph/reorder). $0 local psql.
if (u.pathname === '/api/discontinued' && req.method === 'GET') {
const q = (u.searchParams.get('q') || u.searchParams.get('sku') || '').trim();
if (!q) return send(res, 200, { found: false });
resolveDiscontinued(q)
.then(r => send(res, 200, r || { found: false, sku: q }))
.catch(e => send(res, 200, { found: false, err: e.message }));
return;
}
// All sheet GRS items that don't exist in Shopify yet — the "create on photo" worklist.
if (u.pathname === '/api/new' && req.method === 'GET') {
const items = INDEX.filter(x => x.needs_create);
return send(res, 200, { total: items.length, items });
}
// ALL TWIL/Fentucci items (every product on the line, not just the photo worklist).
if (u.pathname === '/api/twil' && req.method === 'GET') {
const items = INDEX.map(x => ({ ...x, keep_images: !x.needs_create }))
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
return send(res, 200, { total: items.length, items });
}
if (u.pathname === '/api/reindex' && req.method === 'POST') {
buildCatalog().then(() => {}).catch(() => {});
return send(res, 200, { ok: true, indexing: true, current: CATALOG.length });
}
// Look up ANY Shopify product across the WHOLE store — live search, no pre-index.
// Hits Shopify GraphQL on demand so it covers all ~160k SKUs without a heavy boot.
if (u.pathname === '/api/shopify-search' && req.method === 'GET') {
const q = (u.searchParams.get('q') || '').trim();
if (!q) return send(res, 200, { items: [], scope: 'shopify' });
if (!TOKEN) return send(res, 200, { items: [], scope: 'shopify', err: 'no Shopify token' });
shopifySearch(q).then(items => send(res, 200, { total: items.length, items, scope: 'shopify' }))
.catch(e => send(res, 200, { items: [], scope: 'shopify', err: e.message }));
return;
}
// Import AS MUCH INFO AS POSSIBLE from a label photo — a rich Gemini extraction of every field.
if (u.pathname === '/api/extract' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
const b64 = p.dataUrl.replace(/^data:image\/\w+;base64,/, '');
const prompt = 'This image is a product SAMPLE label OR a printed SPEC SHEET for an interior product '
+ '(wallcovering, fabric, trim, drapery, rug, or similar) from ANY manufacturer. Extract EVERY spec you can find. '
+ 'Reply ONLY as compact JSON with EXACTLY these keys (use "" for any field not present, '
+ 'preserve SKU/model codes and numbers EXACTLY, do not invent): '
+ '{"vendor":"<manufacturer/brand>",'
+ '"material":"<product type: Wallcovering / Fabric / Trim / Drapery / Rug / etc.>",'
+ '"mfr_sku":"<manufacturer number / item code>",'
+ '"pattern_name":"<pattern or design name>",'
+ '"color":"<colorway / color name>",'
+ '"collection":"<collection or book name>",'
+ '"width":"<material width, e.g. 27 in / 52 cm>",'
+ '"roll_length":"<length of one roll or bolt, e.g. 5 yds / 11 yds / 8 m>",'
+ '"repeat":"<pattern repeat, e.g. 25.2 in / V 64 cm>",'
+ '"pattern_match":"<match type: straight / half-drop / random / free / reverse>",'
+ '"substrate":"<substrate or contents / composition, e.g. non-woven, grasscloth, vinyl on paper, 100% cotton>",'
+ '"how_sold":"<unit of sale: single roll / double roll / per yard / per panel / per meter>",'
+ '"price":"<numeric price if shown>",'
+ '"price_code":"<any printed price code / tier letter or code>"}.';
const r = await geminiVision(b64, prompt, 30000);
let a = {}; try { a = JSON.parse(r.response || '{}'); } catch (e) { a = {}; }
// map the read vendor to a known catalog vendor (fuzzy)
const vendorMatch = a.vendor ? (fuzzyVendor(a.vendor) || a.vendor) : null;
send(res, 200, { ok: !r.error, fields: a, vendor_matched: vendorMatch, cost_usd: 0.0006, err: r.error || null });
});
return;
}
// Vendor + vid list for the "add new item" dropdown (distinct real vendors from the catalog).
if (u.pathname === '/api/vendors' && req.method === 'GET') {
getVendors().then(list => send(res, 200, { ok: true, vendors: list })).catch(e => send(res, 200, { ok: false, err: e.message, vendors: [] }));
return;
}
// Add a NEW item → Shopify DRAFT + dw_unified staging. dryRun (default) PREVIEWS; commit:true writes.
// Never auto-published (going live is a separate gated step); dedups on mfr#.
if (u.pathname === '/api/create-item' && req.method === 'POST') {
let body = '', _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 25 * 1024 * 1024) _big = true; });
req.on('end', async () => {
if (_big) return send(res, 413, { ok: false, err: 'body too large' });
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!TOKEN) return send(res, 200, { ok: false, err: 'no Shopify token' });
const strip = s => s ? s.replace(/^data:image\/\w+;base64,/, '') : null;
// up to 3 photos (front pattern / back label / detail); front is the primary Shopify image.
const rawPhotos64 = Array.isArray(p.photos) && p.photos.length ? p.photos.map(strip).filter(Boolean)
: (p.dataUrl ? [strip(p.dataUrl)].filter(Boolean) : []);
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)); }
catch (e) { send(res, 500, { ok: false, err: e.message }); }
});
return;
}
// Resolve an EXISTING Shopify product to update (used by the "Update SKU" media flow):
// match the scanned mfr# (or dw_sku) against the in-RAM catalog → return its product_id.
if (u.pathname === '/api/resolve-product' && req.method === 'GET') {
const mfr = (u.searchParams.get('mfr') || '').trim();
const sku = (u.searchParams.get('sku') || '').trim();
const q = mfr || sku;
const done = hit => send(res, 200, { ok: !!hit, found: !!hit, product_id: hit && hit.product_id, dw_sku: hit && hit.dw_sku, title: hit && hit.title, image: hit && hit.image, status: hit && hit.status });
// 1) fast in-RAM catalog (the work queue)
let hit = null;
if (mfr) { const n = nmfr(mfr); hit = CATALOG.find(x => x.mfr && nmfr(x.mfr) === n); }
if (!hit && sku) { const s = sku.toUpperCase(); hit = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s); }
if (hit || !q) return done(hit);
// 2) fall back to a LIVE Shopify search (covers the whole ~169k store, not just the queue)
shopifySearch(q).then(items => {
const n = nmfr(q), s = q.toUpperCase();
// EXACT match only — never fall back to items[0]. A wrong bind here would push media
// LIVE to the wrong customer product (Update mode writes immediately, no approval step).
const best = items.find(x => x.mfr && nmfr(x.mfr) === n)
|| items.find(x => (x.dw_sku || '').toUpperCase() === s) || null;
done(best);
}).catch(() => done(null));
return;
}
// "BEST ID": identify a scanned code against the whole unified catalog (crossref 267k +
// images) BEFORE Shopify/FileMaker. Returns the canonical identity + FileMaker search keys.
if (u.pathname === '/api/identify-unified' && req.method === 'GET') {
const code = (u.searchParams.get('code') || '').trim();
if (!code) return send(res, 400, { err: 'code required' });
identifyUnified(code).then(id => send(res, 200, { ok: !!id, found: !!id, identity: id }))
.catch(e => send(res, 200, { ok: false, found: false, err: e.message }));
return;
}
// Multi-photo identify: fuse ALL clues — BACK label (OCR code → exact crossref) + FRONT pattern
// (CLIP visual search over the catalog). Body: { back, front } dataUrls (either optional).
if (u.pathname === '/api/identify-multi' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 30 * 1024 * 1024) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const strip = s => (s || '').replace(/^data:image\/\w+;base64,/, '');
try {
// BACK → OCR the code → resolve to a canonical identity (exact)
let code = null, codeId = null, backOcr = null;
if (p.back) {
const buf = await normalizeImage(Buffer.from(strip(p.back), 'base64')); // raw camera photo → JPEG ≤1600px
backOcr = await backOcrFused(buf).catch(() => null); // GCV+Gemini fusion (literal codes + layout)
const cands = [backOcr && backOcr.barcode, backOcr && backOcr.top, ...((backOcr && backOcr.candidates) || [])].filter(Boolean);
for (const c of cands) { codeId = await identifyUnified(c).catch(() => null); if (codeId) { code = c; break; } }
}
// QR clue (client-decoded): a raw code OR a URL. We parse code tokens out and resolve them —
// "open silent": we read the QR's payload for identity, we never navigate to it.
let qrUsed = false;
if (!codeId && p.qr) {
// pull SKU-shaped tokens (must contain a digit) so URL/domain words (SHOP, EXAMPLE, COM) don't false-match
const toks = (String(p.qr).toUpperCase().match(/[A-Z0-9][A-Z0-9-]{3,15}/g) || []).filter(t => /\d/.test(t) && !/^\d{1,3}$/.test(t));
for (const t of toks) { codeId = await identifyUnified(t).catch(() => null); if (codeId) { code = t; qrUsed = true; break; } }
}
const backVendor = (backOcr && backOcr.vendor) || null; // vendor name off the full back label
// FRONT → CLIP visual search. MULTI-PHOTO FUSION: real handheld photos are noisy (28% single-shot
// top-1), so if several views of the same sample are sent, fuse them — a product matching across
// multiple views is corroborated and outranks a spurious single-view match.
const rawFronts = (Array.isArray(p.fronts) && p.fronts.length ? p.fronts : (p.front ? [p.front] : [])).slice(0, 5).map(strip).filter(Boolean);
const fronts = await Promise.all(rawFronts.map(async b => { try { return (await normalizeImage(Buffer.from(b, 'base64'))).toString('base64'); } catch (e) { return b; } }));
let visual = [];
if (fronts.length === 1) {
visual = await visualSearch(fronts[0], 8);
} else if (fronts.length > 1) {
const lists = await Promise.all(fronts.map(f => visualSearch(f, 8).catch(() => [])));
const agg = new Map();
for (const list of lists) for (const it of list) {
const key = it.dw_sku || it.vc_id || it.mfr_sku || it.image || JSON.stringify(it);
const e = agg.get(key) || { it, scores: [] }; e.scores.push(it.score || 0); agg.set(key, e);
}
const nViews = lists.length;
visual = Array.from(agg.values()).map(e => {
const mean = e.scores.reduce((a, b) => a + b, 0) / e.scores.length;
const bonus = 0.05 * ((e.scores.length - 1) / Math.max(1, nViews - 1)); // multi-view corroboration
return Object.assign({}, e.it, { score: Math.min(1, mean + bonus), views: e.scores.length });
}).sort((a, b) => b.score - a.score).slice(0, 8);
}
// vendor clue: with no exact code, prefer visual matches from the vendor printed on the label
if (!codeId && backVendor && visual.length) {
const vlow = backVendor.toLowerCase().split(/\s+/)[0];
visual = visual.slice().sort((a, b) => (String(b.vendor || '').toLowerCase().includes(vlow) ? 1 : 0) - (String(a.vendor || '').toLowerCase().includes(vlow) ? 1 : 0));
}
// FUSE the clues
let primary = null, confidence = 'low', source = null, corroborated = false;
if (codeId) {
primary = { dw_sku: codeId.internal_sku, mfr_sku: codeId.mfr_sku, mfr_code: codeId.mfr_code, vendor: codeId.vendor, pattern: codeId.pattern, image: codeId.image };
source = qrUsed ? 'qr' : 'code'; confidence = 'high';
if (!primary.vendor && backVendor) primary.vendor = backVendor;
corroborated = visual.some(v => (v.dw_sku && codeId.internal_sku && String(v.dw_sku).toUpperCase() === String(codeId.internal_sku).toUpperCase()) || (v.mfr_sku && codeId.mfr_sku && v.mfr_sku === codeId.mfr_sku));
if (corroborated) confidence = 'very-high';
} else if (visual.length) {
const v0 = visual[0];
primary = { dw_sku: v0.dw_sku, mfr_sku: v0.mfr_sku, vendor: v0.vendor || backVendor, pattern: v0.pattern, image: v0.image };
source = 'visual'; confidence = v0.score >= 0.92 ? 'high' : v0.score >= 0.85 ? 'medium' : 'low';
}
// SAVE EVERY SCAN — raw photo(s) + what OCR read + engines + resolved identity (found or not).
saveScan({ back: p.back, front: p.front || (fronts[0] ? 'data:image/jpeg;base64,' + fronts[0] : null) }, {
found: !!primary, source, confidence, corroborated, code,
engines: backOcr && backOcr.engines, cost_usd: backOcr && backOcr.cost_usd,
candidates: backOcr && backOcr.candidates, vendor: backVendor,
resolved: primary && { dw_sku: primary.dw_sku, mfr_sku: primary.mfr_sku, mfr_code: primary.mfr_code, vendor: primary.vendor, pattern: primary.pattern } });
send(res, 200, { ok: true, found: !!primary, primary, confidence, source, corroborated, qr_used: qrUsed, back_vendor: backVendor,
code, code_identity: codeId, visual,
back_read: backOcr && { top: backOcr.top, candidates: backOcr.candidates, vendor: backOcr.vendor, fields: backOcr.fields, engines: backOcr.engines, cost_usd: backOcr.cost_usd } });
} catch (e) { send(res, 500, { err: e.message }); }
});
return;
}
// Sample-request lookup: BEST-ID the scanned item against the unified catalog, then find the
// most-recent OPEN sample request for it in FileMaker (client-ordered filled + vendor-ordered
// filled + WP-sample-sent EMPTY). Returns identity + client name + dates for the print popup.
if (u.pathname === '/api/sample-request' && (req.method === 'GET' || req.method === 'POST')) {
const run = (p) => {
const code = (p.code || p.sku || '').toString().trim();
const mfr = (p.mfr || '').toString().trim();
if (!code && !mfr) return send(res, 400, { err: 'code required' });
if (!FM_ENABLED()) return send(res, 200, { ok: false, found: false, err: 'FileMaker not configured' });
(async () => {
// 1) identify-first against the unified catalog → canonical house/mfr codes (= FileMaker keys)
let id = await identifyUnified(code).catch(() => null);
if (!id && mfr) id = await identifyUnified(mfr).catch(() => null);
const internal = id && id.internal_sku;
const mfrCode = (id && id.mfr_code) || mfr || code;
// 2) FileMaker: OR(combo sku exact, Mfr Pattern begins-with), each AND'd with the 3 date conditions
const dc = { 'today for client': '*', 'Date Sample Request printed for vendor': '*', 'Date WP Sample Sent': '=' };
const query = [];
if (internal) query.push(Object.assign({ 'combo sku': '==' + internal }, dc));
if (mfrCode) query.push(Object.assign({ 'Mfr Pattern': mfrCode + '*' }, dc));
if (!query.length) return send(res, 200, { ok: true, found: false, identity: id });
let r;
try { r = await FM.fmFind(FM_DB, FM_LAYOUT, query, { limit: 1, sort: [{ fieldName: 'today for client', sortOrder: 'descend' }] }); }
catch (e) { return send(res, 200, { ok: false, found: false, identity: id, err: e.message }); }
const rec = r.records[0];
if (!rec) return send(res, 200, { ok: true, found: false, identity: id });
const f = rec.fieldData;
send(res, 200, { ok: true, found: true, recordId: rec.recordId, identity: id,
item: { combo_sku: f['combo sku'] || null, mfr_pattern: f['Mfr Pattern'] || null, series: f['Series'] || null, vid: f['vid'] || null },
client: { name: f['company for client fileS'] || null },
dates: { client_ordered: f['today for client'] || null, vendor_ordered: f['Date Sample Request printed for vendor'] || null, wp_sent: f['Date WP Sample Sent'] || null } });
})();
};
if (req.method === 'GET') return run(Object.fromEntries(u.searchParams));
let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
req.on('end', () => { let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); } run(p); });
return;
}
// INCOMING SAMPLES: what Check Sample In focuses on — samples ordered from the vendor but not yet
// sent to the client (vendor-order date filled, WP-sample-sent empty = still in-flight / arriving).
// Enriched with each sample's image + spec from the unified catalog so we know what's coming in.
if (u.pathname === '/api/incoming-samples' && req.method === 'GET') {
if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured', samples: [] });
if (Date.now() - _incomingCache.at > 8 * 60 * 1000) refreshIncoming(); // stale → refresh in background
return send(res, 200, { ok: true, count: _incomingCache.samples.length, samples: _incomingCache.samples,
cached_at: _incomingCache.at || null, loading: _incomingCache.at === 0 });
}
// Check a physically-arrived sample IN: stamp `Date WP Sample Sent` = today on its record. Per Steve's
// workflow this field IS the received-stamp — an empty value means "still coming in" (exactly what the
// incoming query filters on), so stamping it records the arrival date AND drops the record out of the
// incoming queue automatically.
// NOTE (shared writer): `/api/print-sticker` below ALSO stamps this same `Date WP Sample Sent` field
// (its "sample sent to client" step). Both flows intentionally CLOSE the open-sample request by filling
// this one field, so they can't be distinguished afterward (received-in vs sent-out). That's by-design
// per Steve's model; if the two ever need separate audit trails, add a distinct receive-date field.
if (u.pathname === '/api/mark-received' && req.method === 'POST') {
if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
let body = ''; req.on('data', c => { body += c; if (body.length > 64 * 1024) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { ok: false, err: 'bad json' }); }
const recordId = (p.recordId || '').toString().trim();
if (!recordId) return send(res, 400, { ok: false, err: 'recordId required' });
const today = todayMDY();
try { await FM.fmSet(FM_DB, FM_LAYOUT, recordId, { 'Date WP Sample Sent': today }); }
catch (e) { return send(res, 200, { ok: false, err: e.message }); }
// drop it from the cache immediately so the queue reflects the check-in without waiting for a refresh
_incomingCache.samples = _incomingCache.samples.filter(s => s.recordId !== recordId);
send(res, 200, { ok: true, recordId, received: today });
});
return;
}
// Full product record from the WALLPAPER FileMaker file, keyed by the scanned SKU. WALLPAPER2 is one
// mega-table (product master + sample-request rows share a combo sku), so a lookup returns several
// records — most spec fields blank on the sample rows. We COALESCE the non-empty value per field across
// all matches so the master record's specs surface. 'Basic List of Fields' has the spec fields + NO
// portals (so it can't stall like the sample-request layout does).
if (u.pathname === '/api/wallpaper-details' && req.method === 'GET') {
if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
(async () => {
const sku = (u.searchParams.get('sku') || '').trim(); // house combo sku (DWxx…)
const code = (u.searchParams.get('code') || '').trim(); // mfr pattern (vendor code)
if (!sku && !code) return send(res, 400, { ok: false, err: 'sku or code required' });
const LAYOUT2 = process.env.FM_WP_LAYOUT || 'Basic List of Fields';
// fields to surface: [clean key, FileMaker field name]
const MAP = [['mfr_pattern','Mfr Pattern'],['js_pattern','JS Pattern'],['series','Series'],
['width','Width'],['repeat','Repeat'],['retail','Retail Price'],['net','Net Price'],['cost','Cost'],
['supplier','Supplier'],['roll_size','HP Roll Size'],['minimum','Minimum'],['border','Border'],
['five_ten','Five Ten Code'],['line','Line'],['internal_desc','Internal Description'],['combo_sku','combo sku']];
// FileMaker stores combo sku DASHLESS (CHC217203) but scans read it dashed (CHC-217203). So try
// BOTH the raw AND the alphanumeric-stripped form, on BOTH the combo-sku and Mfr-Pattern fields,
// in one OR-query — auto-recovering the dash mismatch. (`==` = exact-whole-field match.)
const dedupe = a => [...new Set(a.filter(Boolean))];
const forms = dedupe([sku, sku.replace(/[^A-Za-z0-9]/g, ''), code, code.replace(/[^A-Za-z0-9]/g, '')]);
const q = []; for (const v of forms) { q.push({ 'combo sku': '==' + v }); q.push({ 'Mfr Pattern': '==' + v }); }
let recs = [];
try { const r = await FM.fmFind(FM_DB, LAYOUT2, q, { limit: 20, portal: [] }); recs = r.records || []; }
catch (e) { return send(res, 200, { ok: false, err: e.message }); }
if (!recs.length) return send(res, 200, { ok: true, found: false, details: null, matched: 0 });
const details = {};
for (const [key, fld] of MAP) { // coalesce: first non-empty value across matches
for (const rec of recs) { const v = (rec.fieldData[fld] || '').toString().trim(); if (v && v !== '=') { details[key] = v; break; } }
}
send(res, 200, { ok: true, found: true, details, matched: recs.length });
})();
return;
}
// Partial / fuzzy lookup in the WALLPAPER file — "we may miss the prefix". Substring-matches the query
// against combo sku, Mfr Pattern, JS Pattern AND Internal Description (FileMaker `*q*` wildcards, OR
// across fields). Tolerates a mis-scanned trailing char by progressively trimming from the right
// (so "217500" still finds "CHC-21750" on the shared "21750"). Results grouped+coalesced per SKU.
if (u.pathname === '/api/wallpaper-search' && req.method === 'GET') {
if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
(async () => {
const raw = (u.searchParams.get('q') || '').trim();
const alnum = raw.replace(/[^A-Za-z0-9]/g, '');
if (alnum.length < 3) return send(res, 200, { ok: true, candidates: [], note: 'need ≥3 chars' });
const LAYOUT2 = process.env.FM_WP_LAYOUT || 'Basic List of Fields';
const MAP = [['mfr_pattern','Mfr Pattern'],['js_pattern','JS Pattern'],['series','Series'],
['width','Width'],['repeat','Repeat'],['retail','Retail Price'],['net','Net Price'],['cost','Cost'],
['supplier','Supplier'],['roll_size','HP Roll Size'],['minimum','Minimum'],['border','Border'],
['five_ten','Five Ten Code'],['line','Line'],['internal_desc','Internal Description'],['combo_sku','combo sku']];
const FIELDS = ['combo sku', 'Mfr Pattern', 'JS Pattern', 'Internal Description'];
// token list: full alnum, then trimmed 1-char-at-a-time (OCR dropped/added a trailing digit)
const tokens = []; for (let t = alnum; t.length >= 4 && tokens.length < 3; t = t.slice(0, -1)) tokens.push(t);
let recs = [];
for (const tok of tokens) {
const q = FIELDS.map(f => ({ [f]: '*' + tok + '*' })); // array = OR across the 4 fields
try { const r = await FM.fmFind(FM_DB, LAYOUT2, q, { limit: 40, portal: [] }); recs = r.records || []; }
catch (e) { return send(res, 200, { ok: false, err: e.message }); }
if (recs.length) break; // stop at the first token that hits
}
// group + coalesce per SKU (the mega-table has several rows per SKU; specs live on the master row)
const groups = new Map();
for (const rec of recs) { const f = rec.fieldData;
const key = (f['combo sku'] || f['Mfr Pattern'] || ('r' + rec.recordId)).toString();
let g = groups.get(key); if (!g) { g = {}; groups.set(key, g); }
for (const [k, fld] of MAP) { if (!g[k]) { const v = (f[fld] || '').toString().trim(); if (v && v !== '=') g[k] = v; } } }
send(res, 200, { ok: true, candidates: [...groups.values()].slice(0, 25), matched: groups.size, query: alnum });
})();
return;
}
// Review the saved-scan corpus (newest first): raw photo ⇄ what OCR read ⇄ resolved SKU. Powers the
// /scans review page so mis-reads are visible and the app can be improved from real data.
if (u.pathname === '/api/scans' && req.method === 'GET') {
const limit = Math.min(300, Math.max(1, parseInt(u.searchParams.get('limit') || '60', 10) || 60));
const only = u.searchParams.get('filter'); // 'unresolved' → only scans that didn't identify
let lines = [];
try { lines = fs.readFileSync(SCAN_LOG, 'utf8').split('\n').filter(Boolean); } catch (e) {}
const total = lines.length, resolved = lines.reduce((n, l) => n + (/"found":true/.test(l) ? 1 : 0), 0);
let recs = lines.slice(-Math.min(lines.length, limit * 3)).reverse()
.map(l => { try { return JSON.parse(l); } catch (e) { return null; } }).filter(Boolean);
if (only === 'unresolved') recs = recs.filter(r => !r.found);
recs = recs.slice(0, limit);
return send(res, 200, { ok: true, total, resolved, unresolved: total - resolved, count: recs.length, scans: recs });
}
// Scan-review page: browse the saved corpus (photo ⇄ OCR read ⇄ resolved SKU) to spot mis-reads.
if (u.pathname === '/scans' && req.method === 'GET') {
const html = `<!doctype html><meta charset=utf8><meta name=viewport content="width=device-width,initial-scale=1">
<title>Scan review · DW Photo</title><style>
body{margin:0;background:#141310;color:#eadfc6;font:14px/1.4 -apple-system,system-ui,sans-serif}
header{position:sticky;top:0;background:#1a1813;border-bottom:1px solid #2a2721;padding:12px 16px;display:flex;gap:12px;align-items:center;flex-wrap:wrap}
header b{font-size:16px}.stat{font-size:12px;color:#9a917d}.stat b{color:#eadfc6}
button{padding:6px 12px;font-size:13px;border:1px solid #2a2721;border-radius:8px;background:#0f0e0b;color:#c8bfa8;cursor:pointer}button.on{border-color:#2bd06a;color:#2bd06a}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px;padding:16px}
.card{border:1px solid #2a2721;border-radius:12px;overflow:hidden;background:#0f0e0b}
.imgs{display:flex;gap:2px;background:#000}.imgs img{width:100%;height:150px;object-fit:cover;flex:1}
.body{padding:9px 11px}.read{font:12px ui-monospace,Menlo,monospace;color:#c8bfa8;word-break:break-word}
.eng{font-size:10px;color:#8f887a}.res{margin-top:6px;font-size:12px}.ok{color:#2bd06a}.no{color:#e0894a}
.when{font-size:10px;color:#6f6857;margin-top:5px}
</style>
<header><b>📸 Scan review</b><span class="stat" id=stat>…</span>
<button id=all class=on onclick="setF('')">All</button><button id=un onclick="setF('unresolved')">Unresolved only</button>
<button onclick="load()">↻ Refresh</button></header>
<div class=grid id=grid></div>
<script>
let F='';
function setF(f){F=f;document.getElementById('all').className=f?'':'on';document.getElementById('un').className=f?'on':'';load();}
async function load(){
const d=await(await fetch('/api/scans?limit=120'+(F?'&filter='+F:''))).json();
document.getElementById('stat').innerHTML='<b>'+d.total+'</b> scans · <b>'+d.resolved+'</b> resolved · <b>'+d.unresolved+'</b> unresolved';
document.getElementById('grid').innerHTML=(d.scans||[]).map(s=>{
const imgs=Object.values(s.images||{}).map(u=>'<img src="'+u+'" loading=lazy>').join('')||'<div style="height:150px;display:flex;align-items:center;justify-content:center;color:#5a544a">no image</div>';
const cands=(s.candidates||[]).slice(0,6).join(' · ')||'—';
const eng=(s.engines||[]).join('+');
const res=s.found?('<span class=ok>✓ '+[(s.resolved&&(s.resolved.mfr_code||s.resolved.mfr_sku)),(s.resolved&&s.resolved.dw_sku)].filter(Boolean).join(' · ')+'</span> <span class=eng>('+s.source+'/'+s.confidence+')</span>'):'<span class=no>✗ not identified</span>';
const when=new Date(s.at).toLocaleString(undefined,{month:'short',day:'numeric',hour:'numeric',minute:'2-digit'});
return '<div class=card><div class=imgs>'+imgs+'</div><div class=body><div class=read>📄 '+cands+'</div><div class=eng>'+(eng||'')+(s.cost_usd?' · $'+s.cost_usd:'')+'</div><div class=res>'+res+'</div><div class=when>🕓 '+when+'</div></div></div>';
}).join('')||'<p style="padding:16px;color:#8f887a">No scans yet — scan a label and it appears here.</p>';
}
load();
</script>`;
return send(res, 200, html, { 'Content-Type': 'text/html; charset=utf-8' });
}
// Print-sticker "send": STAMP the sample as sent (Date WP Sample Sent = today — Steve's "field to
// fill in") and, if a flag field is configured, ALSO flag the record so the Mac's FileMaker Pro
// poller runs the physical Zebra print. Write is dryRun unless FM_WRITE=1 (protects the live file).
if (u.pathname === '/api/print-sticker' && req.method === 'POST') {
let body = ''; req.on('data', c => { body += c; if (body.length > 16 * 1024) req.destroy(); });
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const recordId = (p.recordId || '').toString().trim();
const sticker = p.sticker === 'address' ? 'address' : 'sku';
if (!recordId) return send(res, 400, { err: 'recordId required' });
if (!FM_ENABLED()) return send(res, 200, { ok: false, err: 'FileMaker not configured' });
const dryRun = process.env.FM_WRITE !== '1';
const fieldData = {};
// Stamp the sent date (US MM/DD/YYYY, matching the file's date format) unless disabled.
if (process.env.FM_STAMP_SENT !== '0') fieldData[process.env.FM_SENT_FIELD || 'Date WP Sample Sent'] = todayMDY();
// Optional poller flag for the physical Zebra print (Mac FileMaker Pro side).
if (FM_PRINT_FLAG) fieldData[FM_PRINT_FLAG] = sticker;
if (!Object.keys(fieldData).length) return send(res, 200, { ok: false, err: 'nothing to write (FM_STAMP_SENT=0 and no FM_PRINT_FLAG_FIELD)' });
// dryRun: report the intended write WITHOUT touching FileMaker (a pre-read on records with big
// portals can stall). Live: direct PATCH via fmSet (no pre-read) so it's fast and can't hang.
if (dryRun) return send(res, 200, { ok: true, committed: false, dryRun: true, sticker, wrote: fieldData });
FM.fmSet(FM_DB, FM_LAYOUT, recordId, fieldData)
.then(() => send(res, 200, { ok: true, committed: true, dryRun: false, sticker, wrote: fieldData }))
.catch(e => send(res, 200, { ok: false, err: e.message }));
});
return;
}
// Ask a question about a SKU — local Ollama answers from the product's own Shopify facts. $0.
if (u.pathname === '/api/ask' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; if (body.length > 1e5) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const question = (p.question || '').trim();
if (!question) return send(res, 400, { err: 'question required' });
try {
const ctx = await productContext(p.product_id);
if (!ctx) return send(res, 200, { answer: "I can't find that product's details." });
const prompt = `You are a concise assistant for a wallcovering showroom. Answer the question using ONLY the product facts below. If a fact isn't listed, say you don't have that detail — don't guess. One or two short sentences.\n\nPRODUCT FACTS:\n${ctx}\n\nQUESTION: ${question}\nANSWER:`;
const answer = await ollamaAsk(prompt);
send(res, 200, { answer: (answer || '').trim() || 'No answer.', model: OLLAMA_MODEL });
} catch (e) { send(res, 200, { answer: 'Sorry — the local AI is unreachable right now.', err: e.message }); }
});
return;
}
if (u.pathname.startsWith('/photos/')) {
const f = path.join(PHOTOS, path.basename(u.pathname));
if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg' }); return res.end(fs.readFileSync(f)); }
return send(res, 404, { err: 'not found' });
}
// App icons + PWA manifest — make "Add to Home Screen" a real app (clean icon, fullscreen).
if (u.pathname === '/icon-180.png' || u.pathname === '/icon-512.png' || u.pathname === '/apple-touch-icon.png') {
const f = path.join(ROOT, 'public', u.pathname === '/apple-touch-icon.png' ? 'icon-180.png' : path.basename(u.pathname));
if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
return send(res, 404, { err: 'not found' });
}
if (u.pathname === '/manifest.webmanifest') {
res.writeHead(200, { 'Content-Type': 'application/manifest+json' });
return res.end(JSON.stringify({
name: 'Mobile DW SKU Photos', short_name: 'DW SKU Photos', start_url: '/?app=1', display: 'standalone',
background_color: '#0f0e0c', theme_color: '#0f0e0c',
icons: [{ src: '/icon-180.png', sizes: '180x180', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any maskable' }]
}));
}
// Photo gallery: list every image on a product (keep/remove/feature from the UI).
if (u.pathname === '/api/images' && req.method === 'GET') {
const pid = u.searchParams.get('pid');
if (!pid) return send(res, 400, { err: 'pid required' });
(async () => {
const r = await shopifyReq('GET', `/products/${pid}/images.json`);
const imgs = ((r.body && r.body.images) || []).map(i => ({ id: i.id, src: i.src, position: i.position }));
send(res, 200, { images: imgs });
})();
return;
}
if ((u.pathname === '/api/image/delete' || u.pathname === '/api/image/feature') && req.method === 'POST') {
let body = ''; req.on('data', c => body += c);
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.pid || !p.image_id) return send(res, 400, { err: 'pid + image_id required' });
if (u.pathname === '/api/image/delete') {
const r = await shopifyReq('DELETE', `/products/${p.pid}/images/${p.image_id}.json`);
return send(res, 200, { ok: r.status >= 200 && r.status < 300 });
}
const r = await shopifyReq('PUT', `/products/${p.pid}/images/${p.image_id}.json`, { image: { id: p.image_id, position: 1 } });
return send(res, 200, { ok: r.status >= 200 && r.status < 300 });
});
return;
}
// Same-origin proxy for a product's CURRENT Shopify image — lets the editor
// load + re-adjust a done item that has no local photo (e.g. shot on another
// device, or a pre-existing image), without a cross-origin canvas taint.
if (u.pathname === '/api/current-image' && req.method === 'GET') {
const pid = u.searchParams.get('pid');
if (!pid) return send(res, 400, { err: 'pid required' });
(async () => {
const r = await shopifyReq('GET', `/products/${pid}/images.json`);
const imgs = (r.body && r.body.images) || [];
const src = imgs.length ? imgs[imgs.length - 1].src : null; // featured/last
if (!src) return send(res, 404, { err: 'no image' });
pipeUpstreamImage(src, res, { 'Cache-Control': 'no-store' });
})();
return;
}
// Generic same-origin image proxy — lets the browser read pattern pixels off a
// catalog image (Shopify CDN sends no CORS header → a direct <img> taints the
// canvas). Host-whitelisted so it can't be turned into an open relay. Used by the
// 🎨 Similar visual re-rank to fingerprint each candidate's actual pattern image.
if (u.pathname === '/api/imgproxy' && req.method === 'GET') {
const src = u.searchParams.get('url') || '';
let su; try { su = new URL(src); } catch (e) { return send(res, 400, { err: 'bad url' }); }
const OK = /(^|\.)(shopify\.com|shopifycdn\.com|myshopify\.com)$/i; // cdn.shopify.com ⊂ .shopify.com
if (su.protocol !== 'https:' || !OK.test(su.hostname)) return send(res, 403, { err: 'host not allowed' });
pipeUpstreamImage(su.href, res, { 'Cache-Control': 'max-age=86400', 'Access-Control-Allow-Origin': '*' });
return;
}
// OCR a photo of a printed mfr#/SKU → ranked SKU candidates. Engine-pluggable:
// {engine:"macvision"|"gemini"|"gcv"|"all"} (or ?engine=). 'all' runs every available
// engine and returns each under by_engine, picking the strongest read as the top-level result.
if (u.pathname === '/api/ocr' && req.method === 'POST') {
let body = '';
req.on('data', c => { body += c; if (body.length > 15 * 1024 * 1024) req.destroy(); });
req.on('end', async () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.dataUrl) return send(res, 400, { err: 'dataUrl required' });
let buf; try { buf = Buffer.from(p.dataUrl.replace(/^data:image\/\w+;base64,/, ''), 'base64'); }
catch (e) { return send(res, 400, { err: 'bad dataUrl' }); }
const engines = resolveEngines(p.engine || u.searchParams.get('engine'));
if (!engines.length) return send(res, 503, { err: 'no OCR engine available', all: ALL_ENGINES, available: availableEngines() });
try {
const results = await Promise.all(engines.map(e => runOcr(e, buf)));
// Best read = barcode ground-truth > strong lock > most candidates (only among ok reads).
const rank = r => (r.ok ? 1 : 0) * ((r.barcode ? 1000 : 0) + (r.topStrong ? 100 : 0) + ((r.candidates || []).length));
const primary = results.length === 1 ? results[0] : results.slice().sort((a, b) => rank(b) - rank(a))[0];
const out = Object.assign({}, primary, { ok: !!(primary && primary.ok),
engine_used: primary && primary.engine, engines_run: engines, available: availableEngines() });
if (results.length > 1) { out.by_engine = {}; results.forEach(r => { out.by_engine[r.engine] = r; }); }
send(res, 200, out);
} catch (e) { send(res, 500, { err: 'ocr fail: ' + e.message }); }
});
return;
}
// Upload a VIDEO to a Shopify product (staged upload → product media).
if (u.pathname === '/api/video' && req.method === 'POST') {
// base64 inflates the file ~33%, so a 150MB clip is ~200MB on the wire → cap at 230MB.
// DON'T req.destroy() (that leaves the client fetch hanging with no response, swallowed by
// its catch → false-green "0 videos"); drain past the cap and reply 413 cleanly on end.
let body = '', tooBig = false;
req.on('data', c => { if (tooBig) return; body += c; if (body.length > 230 * 1024 * 1024) tooBig = true; });
req.on('end', async () => {
if (tooBig) return send(res, 413, { ok: false, err: 'video too large (max ~150MB)' });
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
if (!p.product_id || !p.dataUrl) return send(res, 400, { err: 'product_id + dataUrl required' });
const m = /^data:(video\/\w+);base64,/.exec(p.dataUrl);
const mime = (m && m[1]) || 'video/mp4';
const buf = Buffer.from(p.dataUrl.replace(/^data:[^,]+,/, ''), 'base64');
const safe = String(p.dw_sku || 'video').replace(/[^A-Za-z0-9._-]/g, '');
const r = await shopifyAddVideo(p.product_id, buf, `${safe}.${mime.split('/')[1] || 'mp4'}`, mime);
return send(res, r.ok ? 200 : 500, { ok: r.ok, processing: r.processing, err: r.err, size: buf.length });
});
return;
}
// Poll a product's video transcode state (Shopify processes uploaded video async).
if (u.pathname === '/api/media-status' && req.method === 'GET') {
const pid = (u.searchParams.get('product_id') || '').trim();
if (!pid) return send(res, 400, { err: 'product_id required' });
shopifyMediaStatus(pid).then(s => send(res, 200, Object.assign({ ok: true }, s)))
.catch(e => send(res, 200, { ok: false, err: e.message }));
return;
}
if (u.pathname === '/api/photo' && req.method === 'POST') {
let body = '';
let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 25 * 1024 * 1024) _big = true; });
req.on('end', async () => {
if (_big) return send(res, 413, { ok: false, err: 'body too large' });
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const { dw_sku, product_id, dataUrl, push, keep_images, meta } = p;
if (!dw_sku || !dataUrl) return send(res, 400, { err: 'dw_sku + dataUrl required' });
const b64 = await normB64(dataUrl.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()}.jpg`;
fs.writeFileSync(path.join(PHOTOS, fname), Buffer.from(b64, 'base64'));
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;
const sheet = SHEET_BY_GRS[String(dw_sku).toUpperCase()];
const existingPid = product_id || (CATALOG.find(x => (x.dw_sku || '').toUpperCase() === String(dw_sku).toUpperCase()) || {}).product_id;
if (push !== false && !existingPid && sheet) {
// NEW SKU: create the Shopify product from the sheet's full info + this photo, then go live.
const c = await createFromSheet(sheet, b64);
entry.created = c.ok; entry.shopify_pushed = c.ok; entry.push_err = c.ok ? null : c.err;
entry.live = !!c.live; entry.live_reason = c.live ? null : (c.reason || c.err);
entry.product_id = c.product_id || null;
if (c.ok && c.product_id) CATALOG.push({ product_id: c.product_id, title: c.title, status: c.live ? 'ACTIVE' : 'DRAFT', dw_sku, mfr: sheet.mfr || '', price: sheet.price || null, image: entry.photo, done: true });
} else if (push !== false && existingPid) {
// EXISTING product: attach/replace the photo, then activate if the gate now passes.
// keep_images=true (any-Shopify-SKU mode) preserves existing imagery — never
// wipe good room/lifestyle shots on an arbitrary live product.
const r = await shopifyAttachImage(existingPid, dw_sku, b64, !!keep_images);
entry.shopify_pushed = r.ok; entry.push_err = r.ok ? null : r.err;
entry.product_id = existingPid;
if (r.ok) { const a = await shopifyActivateIfReady(existingPid); entry.live = !!a.live; entry.live_reason = a.reason || null; }
}
progress[dw_sku] = entry; saveProgress();
// log into recents (most-recently-updated first) so frequent SKUs are one tap away.
recents[dw_sku] = cardRecord(dw_sku, meta, {
product_id: entry.product_id || existingPid || (meta && meta.product_id) || null,
image: entry.photo || (meta && meta.image) || null,
status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts
});
saveRecents();
return send(res, 200, { ok: true, dw_sku, photo: entry.photo, created: entry.created, shopify_pushed: entry.shopify_pushed,
push_err: entry.push_err, live: entry.live, live_reason: entry.live_reason });
});
return;
}
// Batch add MULTIPLE photos to an existing product (append, keep featured + siblings).
// Body: { dw_sku, product_id, dataUrls:[...], meta }
if (u.pathname === '/api/photos' && req.method === 'POST') {
let body = '';
let _big = false; req.on('data', c => { if (_big) return; body += c; if (body.length > 60 * 1024 * 1024) _big = true; });
req.on('end', async () => {
if (_big) return send(res, 413, { ok: false, err: 'body too large' });
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const { dw_sku, product_id, dataUrls, meta } = p;
if (!dw_sku || !product_id || !Array.isArray(dataUrls) || !dataUrls.length)
return send(res, 400, { err: 'dw_sku + product_id + dataUrls[] required' });
const urls = dataUrls.slice(0, 12); // cap a single batch
let added = 0; const errors = []; let lastPhoto = null;
for (let i = 0; i < urls.length; i++) {
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) {}
const r = await shopifyAppendImage(product_id, dw_sku, b64, `${Date.now()}-${i}`);
if (r.ok) added++; else errors.push(r.err);
}
const entry = progress[dw_sku] || {};
entry.done = true; entry.skipped = false; entry.photo = lastPhoto || entry.photo;
entry.ts = new Date().toISOString(); entry.shopify_pushed = added > 0; entry.product_id = product_id;
const a = added > 0 ? await shopifyActivateIfReady(product_id) : {};
entry.live = !!a.live; entry.live_reason = a.reason || null;
progress[dw_sku] = entry; saveProgress();
recents[dw_sku] = cardRecord(dw_sku, meta, { product_id, image: entry.photo,
status: entry.live ? 'ACTIVE' : (meta && meta.status) || '', ts: entry.ts });
saveRecents();
return send(res, 200, { ok: added > 0, added, total: urls.length, errors, live: entry.live, live_reason: entry.live_reason });
});
return;
}
if (u.pathname === '/api/skip' && req.method === 'POST') {
let body = ''; req.on('data', c => body += c);
req.on('end', () => {
let p; try { p = JSON.parse(body); } catch (e) { return send(res, 400, { err: 'bad json' }); }
const e = progress[p.dw_sku] || {}; e.skipped = !!p.skipped; progress[p.dw_sku] = e; saveProgress();
return send(res, 200, { ok: true });
});
return;
}
send(res, 404, { err: 'not found' });
};
const server = http.createServer(appHandler);
// HTTPS (self-signed) on PORT+1 so the live camera scanner (getUserMedia needs a secure context) works on the LAN.
try {
const tls = { key: fs.readFileSync(path.join(ROOT, 'certs/key.pem')), cert: fs.readFileSync(path.join(ROOT, 'certs/cert.pem')) };
https.createServer(tls, appHandler).listen(Number(PORT) + 1, '0.0.0.0', () => console.log(`HTTPS (live-scan) on https://0.0.0.0:${Number(PORT) + 1}`));
} catch (e) { console.log('HTTPS off (no cert):', e.message); }
// ── Sheet GRS catalog (every GRS item on the TWIL spreadsheet, incl. not-yet-created) ──
let SHEET = loadJSON(path.join(DATA, 'sheet_grs.json'), []);
const SHEET_BY_GRS = {};
SHEET.forEach(x => { if (x.grs) SHEET_BY_GRS[x.grs.toUpperCase()] = x; });
// Manufacturer-SKU index: normalized vendor code -> { sku (DW house SKU), title, ... }.
// Lets a scanned vendor code (WDW2310, WHF3846.WT.0) resolve to the DW product that
// stores that code only in a metafield. Built by build_mfr_index.py. Keys are normalized.
const MFR_INDEX = loadJSON(path.join(DATA, 'mfr_index.json'), {});
const mfrNorm = s => String(s || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
// Resolve a scanned/typed code to a DW house SKU via the index. Tolerates DW's messy codes:
// exact normalized hit first, then a dropped-trailing-char (prefix) match either direction.
function mfrToHouseSku(q) {
const n = mfrNorm(q);
if (n.length < 4) return null;
if (MFR_INDEX[n] && MFR_INDEX[n].sku) return MFR_INDEX[n].sku; // exact (normalized) — common case
// fuzzy: DW sometimes drops/adds ONE trailing char. Only accept a key within 1 char
// where the shorter is a prefix of the longer — tight enough to avoid wild mismatches.
if (n.length >= 6) {
for (const k of Object.keys(MFR_INDEX)) {
if (Math.abs(k.length - n.length) <= 1 && k.length >= 6 && MFR_INDEX[k].sku &&
(k.startsWith(n) || n.startsWith(k))) return MFR_INDEX[k].sku;
}
}
return null;
}
function sheetTitle(s) {
const name = (s.name || '').trim(), color = (s.color || '').trim();
// drop a redundant color when the "name" already IS the color (some sheet rows duplicate them)
const nl = name.toLowerCase(), cl = color.toLowerCase();
const col = (color && nl !== cl && !nl.includes(cl) && !cl.includes(nl)) ? ' ' + color : '';
if (name) return `${name}${col} Grasscloth Wallcovering | Fentucci`;
if (s.material) return `Fentucci ${s.material} Grasscloth Wallcovering${color ? ' — ' + color : ''}`;
return `Fentucci Grasscloth Wallcovering${color ? ' — ' + color : ''}`;
}
function sheetBody(s) {
const lead = s.name || (s.material ? `Fentucci ${s.material} grasscloth` : 'This Fentucci grasscloth');
if (s.desc && /^is /i.test(s.desc.trim())) return `${lead} ${s.desc.trim()}`;
return s.desc || `${lead} is a natural woven grasscloth wallcovering with handcrafted natural-fiber texture that brings organic warmth to any interior.`;
}
// Create a NEW Shopify product from a sheet GRS row + the captured photo, then go live.
async function createFromSheet(s, b64) {
try {
// dedup: never create if the GRS sku OR the mfr# already exists — attach to that product instead
const dup = CATALOG.find(x => (x.dw_sku || '').toUpperCase() === s.grs.toUpperCase()
|| (s.mfr && nmfr(x.mfr) === nmfr(s.mfr)));
if (dup && dup.product_id) {
const r = await shopifyAttachImage(dup.product_id, dup.dw_sku || s.grs, b64);
const a = r.ok ? await shopifyActivateIfReady(dup.product_id) : {};
return { ok: r.ok, product_id: dup.product_id, title: dup.title || '', live: !!a.live, reason: a.reason || 'matched existing product', dedup: true };
}
const price = s.price && +s.price > 0 ? String(s.price) : null;
const active = !!price; // gate: has image (this photo) + price + width + desc
const title = sheetTitle(s);
const col = s.color || '';
const tags = ['Grasscloth', 'Natural Wallcovering', 'Fentucci', 'TWIL Naturals', 'display_variant'];
if (col) tags.push('color:' + col);
if (!active) tags.push('Needs-Cost');
const roll = { option1: 'Per Yard', sku: s.grs, inventory_management: 'shopify', inventory_quantity: 2026 }; // GRS grasscloth sells per yard
if (price) roll.price = price;
const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: 'single_line_text_field' } : null;
const metafields = [
mf('global', 'width', s.width || '36 Inches'), mf('global', 'length', s.length || '8 Yards'),
mf('global', 'unit_of_measure', 'Priced Per Yard'), mf('dwc', 'order_unit', 'Yard'), mf('global', 'Content', 'Natural Grasscloth'),
mf('global', 'Brand', 'Fentucci'), mf('global', 'Collection', 'TWIL Naturals'),
mf('global', 'Color-Way', col), mf('custom', 'color', col),
mf('custom', 'manufacturer_sku', s.mfr), mf('dwc', 'manufacturer_sku', s.mfr),
mf('custom', 'pattern_name', s.name), mf('dwc', 'pattern_name', s.name)
].filter(Boolean);
const payload = { product: {
title, body_html: sheetBody(s), vendor: 'Fentucci', product_type: 'Wallcovering',
status: active ? 'active' : 'draft', published_scope: 'global', tags: tags.join(', '),
options: [{ name: 'Size' }],
variants: [roll, { option1: 'Sample', sku: s.grs + '-Sample', price: '4.25', inventory_management: 'shopify', inventory_quantity: 2026 }],
images: [{ attachment: b64, filename: `${s.grs}.jpg` }],
metafields
} };
const cr = await shopifyReq('POST', '/products.json', payload);
if (cr.status < 200 || cr.status >= 300) return { ok: false, err: `create HTTP ${cr.status}: ${(cr.raw || '').slice(0, 160)}` };
const pid = cr.body && cr.body.product && cr.body.product.id;
let live = false;
if (active && pid) {
const pubs = await gql('{ publications(first:50){edges{node{id}}} }');
const ids = ((pubs.data && pubs.data.publications && pubs.data.publications.edges) || []).map(e => ({ publicationId: e.node.id }));
await gql(`mutation($id:ID!,$in:[PublicationInput!]!){ publishablePublish(id:$id,input:$in){ userErrors{message} } }`,
{ id: `gid://shopify/Product/${pid}`, in: ids });
live = true;
}
return { ok: true, product_id: pid, title, live, reason: active ? null : 'no price in sheet — created as draft' };
} catch (e) { return { ok: false, err: e.message }; }
}
// Vendor + vid dropdown for the "add new item" form (distinct real vendors from the unified catalog).
let _vendorsCache = { at: 0, list: [] };
function getVendors() {
return new Promise(resolve => {
if (Date.now() - _vendorsCache.at < 10 * 60 * 1000 && _vendorsCache.list.length) return resolve(_vendorsCache.list);
const SQL = `select coalesce(nullif(original_vendor_name,''), vendor_code) vendor, vendor_code vid, count(*) n
from vendor_catalog where vendor_code is not null and vendor_code<>'' group by 1,2 having count(*)>5 order by n desc limit 300`;
execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL], { timeout: 8000, maxBuffer: 4 * 1024 * 1024 }, (err, out) => {
if (err) return resolve(_vendorsCache.list);
const list = (out || '').split('\n').filter(Boolean).map(l => { const [vendor, vid, n] = l.split('\t'); return { vendor, vid, n: +n }; });
_vendorsCache = { at: Date.now(), list }; resolve(list);
});
});
}
// Create a NEW item (arbitrary vendor) as a Shopify DRAFT + a dw_unified staging row. Draft-only,
// never auto-published (going live is a separate gated step). dryRun (default) returns a preview.
async function createNewItem(p, b64, dryRun) {
const mfr = String(p.mfr || '').trim(); const vendor = String(p.vendor || '').trim();
if (!mfr || !vendor) return { ok: false, err: 'mfr + vendor required' };
// dedup: refuse if the mfr# already exists in the catalog (attach instead, don't duplicate)
const dup = CATALOG.find(x => x.mfr && nmfr(x.mfr) === nmfr(mfr));
const dwsku = String(p.dw_sku || '').trim() || (String(p.vid || vendor).slice(0, 4).toUpperCase().replace(/[^A-Z0-9]/g, '') + mfr.replace(/[^A-Za-z0-9]/g, ''));
const name = String(p.name || '').trim(); const color = String(p.color || '').trim();
const price = p.price && +p.price > 0 ? String(p.price) : null;
const title = [name || mfr, color, '|', vendor].filter(Boolean).join(' ').replace(' | ', ' | ');
const preview = { title, dw_sku: dwsku, mfr, vendor, vid: p.vid || null, color, price, status: 'draft', photos: Array.isArray(p._photos64) ? p._photos64.length : (b64 ? 1 : 0), duplicate_of: dup ? (dup.dw_sku || dup.product_id) : null };
if (dup) return { ok: false, duplicate: true, preview, err: `mfr# ${mfr} already exists (${dup.dw_sku || dup.product_id}) — add a photo to it instead` };
if (dryRun) return { ok: true, dryRun: true, preview };
try {
const mf = (ns, key, val) => val ? { namespace: ns, key, value: String(val), type: 'single_line_text_field' } : null;
const variants = [{ option1: 'Roll', sku: dwsku, inventory_management: 'shopify', inventory_quantity: 0 },
{ option1: 'Sample', sku: dwsku + '-Sample', price: '4.25', inventory_management: 'shopify', inventory_quantity: 0 }];
if (price) variants[0].price = price;
const material = (p.material || '').trim() || 'Wallcovering';
const payload = { product: {
title, vendor, product_type: material, status: 'draft', tags: ['new-from-scan', 'display_variant', color ? ('color:' + color) : ''].filter(Boolean).join(', '),
options: [{ name: 'Size' }], variants,
images: (Array.isArray(p._photos64) && p._photos64.length ? p._photos64 : (b64 ? [b64] : []))
.map((att, i) => ({ attachment: att, filename: `${dwsku}${i ? '-' + i : ''}.jpg`, position: i + 1 })),
metafields: [mf('custom', 'manufacturer_sku', mfr), mf('dwc', 'manufacturer_sku', mfr),
mf('custom', 'pattern_name', name), mf('custom', 'color', color), mf('global', 'Brand', vendor),
mf('global', 'Collection', p.collection), mf('global', 'width', p.width), mf('global', 'length', p.roll_length),
mf('global', 'Pattern-Repeat', p.repeat), mf('global', 'Match', p.pattern_match), mf('global', 'Content', p.substrate),
mf('dwc', 'sold_by', p.how_sold), mf('custom', 'price_code', p.price_code), mf('custom', 'material', material)].filter(Boolean)
} };
const cr = await shopifyReq('POST', '/products.json', payload);
if (cr.status < 200 || cr.status >= 300) return { ok: false, err: `Shopify create HTTP ${cr.status}: ${(cr.raw || '').slice(0, 160)}` };
const pid = cr.body && cr.body.product && cr.body.product.id;
// stage into dw_unified (additive table — does NOT touch canonical catalog rows).
// `specs` jsonb keeps the FULL captured spec set — every field off any spec sheet/sample, nothing dropped.
const specsObj = { material, collection: p.collection || '', width: p.width || '', roll_length: p.roll_length || '',
repeat: p.repeat || '', pattern_match: p.pattern_match || '', substrate: p.substrate || '',
how_sold: p.how_sold || '', price_code: p.price_code || '' };
// $$-dollar-quoting: a stray "$$" in ANY value would break the quote → strip it from EVERY
// interpolated string (not just specs) so a vendor/mfr like "Foo $$ Bar" can't malform the SQL.
const esc = s => String(s == null ? '' : s).replace(/\$\$/g, '$');
const specsJson = esc(JSON.stringify(specsObj));
const stageSQL = `insert into new_items_staging (dw_sku, mfr_sku, vendor, vid, pattern_name, color, price, specs, shopify_product_id, created_via)
values ($$${esc(dwsku)}$$,$$${esc(mfr)}$$,$$${esc(vendor)}$$,$$${esc(p.vid || '')}$$,$$${esc(name)}$$,$$${esc(color)}$$,${price || 'NULL'},$$${specsJson}$$::jsonb,${pid || 'NULL'},$$scan$$) on conflict do nothing`;
execFile(PSQL, ['-d', DW_DB, '-c', 'create table if not exists new_items_staging (id bigserial primary key, dw_sku text, mfr_sku text, vendor text, vid text, pattern_name text, color text, price numeric, shopify_product_id bigint, created_via text, created_at timestamptz default now()); alter table new_items_staging add column if not exists specs jsonb; ' + stageSQL], { timeout: 8000 }, () => {});
if (pid) CATALOG.push({ product_id: pid, title, status: 'DRAFT', dw_sku: dwsku, mfr, price, image: null, done: false });
return { ok: true, product_id: pid, dw_sku: dwsku, title, status: 'draft', preview };
} catch (e) { return { ok: false, err: e.message }; }
}
// In-memory index of ALL non-archived Fentucci products (for "find any SKU" lookup).
let CATALOG = [], INDEX = [];
const nmfr = s => (s || '').trim().toUpperCase().replace(/T$/, ''); // normalize mfr (WOS3467T == wos3467)
function rebuildIndex() {
const haveSku = new Set(CATALOG.map(x => (x.dw_sku || '').toUpperCase()));
const haveMfr = new Set(CATALOG.map(x => nmfr(x.mfr)).filter(Boolean));
// a sheet item is genuinely NEW only if neither its GRS sku NOR its mfr# already exists (dedup)
const sheetOnly = SHEET.filter(s => !haveSku.has(s.grs.toUpperCase()) && !(s.mfr && haveMfr.has(nmfr(s.mfr)))).map(s => ({
product_id: null, title: sheetTitle(s), status: 'NEW', dw_sku: s.grs, mfr: s.mfr || '',
price: s.price || null, image: s.img1 && /^https?:/.test(s.img1) ? s.img1 : null,
collections: ['TWIL Naturals', 'Grasscloth'], needs_create: true, done: false
}));
INDEX = CATALOG.concat(sheetOnly);
console.log(`lookup index: ${CATALOG.length} live + ${sheetOnly.length} new-from-sheet = ${INDEX.length}`);
}
// ── all-dw internal full-catalog feed ─────────────────────────────────────────
// The browsable CATALOG used to be Fentucci-only. It now sources the WHOLE dw_unified
// catalog (every non-archived SKU, all vendors) from all.designerwallcoverings.com's
// auth-gated /api/catalog-full — the single leak-sanitized source of truth. So the
// scanner's lookup/browse ("/api/lookup", "/api/twil") and the incoming-sample photo
// match cover every line, not just one. Fallback chain keeps it robust: live feed →
// last-good disk cache → legacy Fentucci Shopify crawl (never leaves the app empty).
const ALL_DW_URL = process.env.ALL_DW_URL || 'http://127.0.0.1:9958';
const ALL_DW_AUTH = process.env.ALL_DW_AUTH || 'admin:DW2024!';
const CATALOG_CACHE = path.join(DATA, 'all-catalog.json');
function fetchAllDwCatalog() {
return new Promise((resolve, reject) => {
const u = new URL(ALL_DW_URL + '/api/catalog-full');
const lib = u.protocol === 'https:' ? https : http;
const req = lib.request({
hostname: u.hostname, port: u.port, path: u.pathname, method: 'GET',
headers: { 'Authorization': 'Basic ' + Buffer.from(ALL_DW_AUTH).toString('base64'), 'Accept-Encoding': 'gzip' },
}, resp => {
if (resp.statusCode !== 200) { resp.resume(); return reject(new Error('feed HTTP ' + resp.statusCode)); }
const stream = /gzip/.test(resp.headers['content-encoding'] || '') ? resp.pipe(zlib.createGunzip()) : resp;
const chunks = [];
stream.on('data', c => chunks.push(c));
stream.on('end', () => { try { resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); } catch (e) { reject(e); } });
stream.on('error', reject);
});
req.setTimeout(45000, () => req.destroy(new Error('feed timeout')));
req.on('error', reject); req.end();
});
}
// Map an all-dw feed row → the flat item shape the scanner UI cards expect.
function feedRowToItem(r) {
return {
product_id: r.product_id || null, title: r.title || '', status: r.status || '',
dw_sku: r.sku || '', mfr: r.mfr_number || '', price: r.price != null ? String(r.price) : null,
image: r.image || null, vendor: r.vendor || '',
collections: [].concat(r.styles || [], r.materials || []),
keep_images: true, done: false,
};
}
async function buildCatalog() {
// 1) live feed
try {
const feed = await fetchAllDwCatalog();
if (feed && Array.isArray(feed.rows) && feed.rows.length) {
CATALOG = feed.rows.map(feedRowToItem);
try { fs.writeFileSync(CATALOG_CACHE, JSON.stringify(feed)); } catch (e) { /* cache best-effort */ }
console.log(`catalog indexed: ${CATALOG.length} products from all-dw feed`);
return rebuildIndex();
}
throw new Error('feed empty');
} catch (e) {
console.log('all-dw feed unavailable:', e.message);
}
// 2) last-good disk cache
try {
const feed = JSON.parse(fs.readFileSync(CATALOG_CACHE, 'utf8'));
if (feed && Array.isArray(feed.rows) && feed.rows.length) {
CATALOG = feed.rows.map(feedRowToItem);
console.log(`catalog indexed: ${CATALOG.length} products from disk cache (feed down)`);
return rebuildIndex();
}
} catch (e) { /* no cache yet */ }
// 3) legacy Fentucci-only Shopify crawl — never leave the scanner empty
return buildCatalogFentucci();
}
async function buildCatalogFentucci() {
const Q = `query($c:String){products(first:100,query:"vendor:Fentucci",after:$c){pageInfo{hasNextPage endCursor}
edges{node{legacyResourceId title status
imgs:images(first:1){edges{node{src}}}
cols:collections(first:8){edges{node{title}}}
mf:metafield(namespace:"custom",key:"manufacturer_sku"){value}
mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}
v:variants(first:3){edges{node{sku title price}}}}}}}`;
let out = [], cursor = null, pages = 0;
try {
if (!TOKEN) { console.log('catalog fallback skipped — no Shopify token'); return; }
while (pages < 60) {
const r = await gql(Q, { c: cursor });
const pr = r.data && r.data.products;
if (!pr) break;
for (const e of pr.edges) {
const n = e.node;
if (n.status === 'ARCHIVED') continue;
const roll = n.v.edges.map(x => x.node).find(x => !(x.sku || '').endsWith('-Sample') && (x.title || '').toLowerCase() !== 'sample') || (n.v.edges[0] && n.v.edges[0].node);
out.push({
product_id: n.legacyResourceId, title: n.title, status: n.status,
dw_sku: (roll || {}).sku || '', mfr: (n.mf || {}).value || (n.mf2 || {}).value || '',
price: (roll || {}).price || null,
image: (n.imgs.edges[0] && n.imgs.edges[0].node.src) || null,
collections: (n.cols.edges || []).map(c => c.node.title),
done: false
});
}
pages++;
if (pr.pageInfo.hasNextPage) cursor = pr.pageInfo.endCursor; else break;
}
CATALOG = out;
console.log(`catalog indexed: ${CATALOG.length} Fentucci products (legacy fallback)`);
rebuildIndex();
} catch (e) { console.log('catalog index error:', e.message); }
}
// Map a Shopify product GraphQL node → the flat item shape the UI cards expect.
function nodeToItem(n, extra) {
const roll = n.v.edges.map(x => x.node).find(x => !(x.sku || '').endsWith('-Sample') && (x.title || '').toLowerCase() !== 'sample') || (n.v.edges[0] && n.v.edges[0].node);
return Object.assign({
product_id: n.legacyResourceId, title: n.title, status: n.status,
dw_sku: (roll || {}).sku || '', mfr: (n.mf || {}).value || (n.mf2 || {}).value || '',
price: (roll || {}).price || null,
image: (n.imgs.edges[0] && n.imgs.edges[0].node.src) || null,
vendor: n.vendor || '', done: false
}, extra || {});
}
// Live store-wide search: one GraphQL call, wildcard match on sku/title/vendor.
// keep_images:true is stamped on every result so a photo update on an arbitrary
// live product ADDS as featured without wiping existing imagery.
const _PNODE = `legacyResourceId title status vendor
imgs:images(first:1){edges{node{src}}}
cols:collections(first:6){edges{node{title}}}
mf:metafield(namespace:"custom",key:"manufacturer_sku"){value}
mf2:metafield(namespace:"dwc",key:"manufacturer_sku"){value}
v:variants(first:5){edges{node{sku title price}}}`;
// ── Local Ollama Q&A (free, on-LAN) ──────────────────────────────────────────
const OLLAMA_URL = process.env.OLLAMA_URL || 'http://127.0.0.1:11434';
const OLLAMA_MODEL = process.env.OLLAMA_MODEL || 'qwen2.5:latest';
function ollamaAsk(prompt) {
return new Promise((resolve, reject) => {
const ou = new URL(OLLAMA_URL + '/api/generate');
const data = JSON.stringify({ model: OLLAMA_MODEL, prompt, stream: false, options: { num_predict: 160, temperature: 0.2 } });
const lib = ou.protocol === 'https:' ? https : http;
const r = lib.request({ hostname: ou.hostname, port: ou.port, path: ou.pathname, method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } }, resp => {
let d = ''; resp.on('data', c => d += c); resp.on('end', () => { try { resolve((JSON.parse(d).response) || ''); } catch (e) { reject(e); } });
});
r.setTimeout(30000, () => r.destroy(new Error('ollama timeout')));
r.on('error', reject); r.write(data); r.end();
});
}
// Pull a product's facts (basics + spec-like metafields) → a compact context block for the LLM.
async function productContext(productId) {
if (!productId) return null;
const gid = `gid://shopify/Product/${String(productId).replace(/\D/g, '')}`;
const Q = `query($id:ID!){ product(id:$id){ title vendor productType tags
variants(first:3){edges{node{sku price}}}
metafields(first:50){edges{node{key value}}} } }`;
const r = await gql(Q, { id: gid });
const node = r.data && r.data.product;
if (!node) return null;
const vs = node.variants.edges.map(e => e.node);
const v = vs.find(x => x.sku && !/-sample$/i.test(x.sku)) || vs[0] || {}; // prefer the main (non-sample) variant
const facts = [`Title: ${node.title}`];
if (node.vendor) facts.push(`Vendor: ${node.vendor}`);
if (node.productType) facts.push(`Type: ${node.productType}`);
if (v.price) facts.push(`Price: $${v.price}`);
if (v.sku) facts.push(`SKU: ${v.sku}`);
const WANT = /(width|content|repeat|fire|flam|weight|care|material|origin|backing|finish|colou?r|usage|durab|clean|length|yard|coverage|railroad|match)/i;
const seen = new Set();
for (const e of (node.metafields.edges || [])) {
const k = e.node.key, val = (e.node.value || '').toString();
if (!val || val.length > 120 || /^https?:/.test(val) || /^[[{]/.test(val)) continue; // skip urls / json blobs
if (WANT.test(k) && !seen.has(k)) { seen.add(k); facts.push(`${k.replace(/_/g, ' ')}: ${val}`); }
}
if (node.tags && node.tags.length) facts.push(`Tags: ${node.tags.slice(0, 12).join(', ')}`);
return facts.join('\n');
}
async function shopifySearch(q) {
// If the query is a single code-like token (has a digit), try the manufacturer-SKU
// index FIRST — a scanned vendor code (WDW2310) resolves to its DW house SKU (CHC-…)
// and we search by that, since Shopify can't search the mfr# metafield by value.
const one = q.trim();
if (!/\s/.test(one) && /\d/.test(one)) {
const house = mfrToHouseSku(one);
if (house && mfrNorm(house) !== mfrNorm(one)) q = house; // rewrite to the house SKU
}
const terms = q.split(/\s+/).filter(Boolean).slice(0, 4)
.map(t => t.replace(/["\\():*]/g, '')).filter(Boolean);
if (!terms.length) return [];
// 1) product search (sku/title/vendor) + 2) products inside any matching COLLECTION — in parallel
const pstr = terms.map(t => `(sku:*${t}* OR title:*${t}* OR vendor:*${t}*)`).join(' AND ');
const cstr = terms.map(t => `title:*${t}*`).join(' AND ');
const PQ = `query($q:String){ products(first:50, query:$q){ edges{node{ ${_PNODE} } } } }`;
const CQ = `query($q:String){ collections(first:5, query:$q){ edges{node{ title products(first:40){edges{node{ ${_PNODE} }}} }}} }`;
const [pr, cr] = await Promise.all([gql(PQ, { q: pstr }), gql(CQ, { q: cstr })]);
const byId = new Map();
const add = (n, fromCol) => {
if (!n || n.status === 'ARCHIVED') return;
let it = byId.get(n.legacyResourceId);
if (!it) { it = nodeToItem(n, { keep_images: true, scope: 'shopify' }); it.collections = (n.cols ? n.cols.edges.map(e => e.node.title) : []); byId.set(n.legacyResourceId, it); }
if (fromCol && !it.collections.includes(fromCol)) it.collections.push(fromCol);
};
for (const e of ((pr.data && pr.data.products && pr.data.products.edges) || [])) add(e.node);
for (const ce of ((cr.data && cr.data.collections && cr.data.collections.edges) || []))
for (const pe of ce.node.products.edges) add(pe.node, ce.node.title);
return Array.from(byId.values()).slice(0, 80);
}
// ── Local dw_unified mirror: pattern-similarity search ───────────────────────
// This app carries zero runtime deps (see package.json), so — exactly like the
// Swift OCR binary — we reach Postgres by shelling to `psql` rather than pulling
// in a driver. The local mirror IS the whole store (~169k products) and, crucially,
// carries the AI-enriched `tags` (colors / style / motif / material) that turn a
// VLM's pattern read into real "similar items" instead of a brittle title match.
const PSQL = [process.env.PSQL, '/opt/homebrew/opt/postgresql@14/bin/psql',
'/opt/homebrew/bin/psql', '/usr/local/bin/psql', '/usr/bin/psql']
.find(p => p && fs.existsSync(p)) || 'psql';
const DW_DB = process.env.DW_UNIFIED_DB || 'dw_unified';
// words too generic to discriminate (every wallcovering tag-set carries them)
const SIM_STOP = new Set(['wallcovering', 'wallcoverings', 'wallpaper', 'fabric', 'fabrics',
'multi', 'color', 'colour', 'interior', 'designer', 'showroom', 'line', 'non', 'woven',
'nonwoven', 'needs', 'image', 'price', 'width', 'pattern', 'wall', 'background']);
// VLM attribute object → a clean, deduped, injection-safe term list for tag matching.
// Every term is reduced to [a-z0-9 &-] (quotes/semicolons/backslashes stripped), so the
// terms can be embedded straight into the ILIKE patterns below with no escape risk.
function similarTerms(attrs) {
const raw = [];
const push = v => { if (v) String(v).split(/[,/;&]| and /i).forEach(t => raw.push(t)); };
push(attrs.motif); push(attrs.style); push(attrs.material); push(attrs.background);
(attrs.colors || []).forEach(push); push(attrs.pattern); push(attrs.type);
const seen = new Set(), out = [];
for (let t of raw) {
t = String(t).toLowerCase().replace(/[^a-z0-9 &-]/g, ' ').replace(/\s+/g, ' ').trim();
if (t.length < 3 || SIM_STOP.has(t) || seen.has(t)) continue;
seen.add(t); out.push(t);
if (out.length >= 8) break;
}
return out;
}
// Rank ACTIVE products by how many recognized attribute-terms appear in their enriched
// tags / title / pattern-name, newest-priced first. Returns standard card-shaped items
// (keep_images:true so updating a photo on a match ADDS as featured, never wipes).
function unifiedSimilar(terms) {
return new Promise(resolve => {
const t = terms.filter(Boolean).slice(0, 8);
if (!t.length) return resolve([]);
const hay = "lower(coalesce(tags,'')||' '||coalesce(title,'')||' '||coalesce(pattern_name,''))";
// score = how many recognized terms this row matches; the WHERE is just score>0 (≥1 match).
const score = t.map(x => `(case when ${hay} like '%${x}%' then 1 else 0 end)`).join('+');
const SQL = `select regexp_replace(coalesce(shopify_id,''),'\\D','','g') as product_id,
regexp_replace(coalesce(dw_sku,''),'-?sample$','','i'), coalesce(mfr_sku,''), coalesce(title,''), coalesce(vendor,''),
coalesce(case when price>4.25 then price end, retail_price, price),
coalesce(image_url,''), upper(coalesce(status,'')), (${score}) as score
from shopify_products
where status ilike 'active' and image_url is not null and image_url <> '' and (${score}) > 0
order by score desc, (coalesce(price,0)>4.25) desc, updated_at_shopify desc nulls last
limit 48`;
execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL],
{ timeout: 8000, maxBuffer: 8 * 1024 * 1024 }, (err, stdout) => {
if (err) return resolve([]);
const items = (stdout || '').split('\n').filter(Boolean).map(line => {
const [product_id, dw_sku, mfr, title, vendor, price, image, status, sc] = line.split('\t');
return {
product_id: product_id || null, dw_sku: dw_sku || '', mfr: mfr || '',
title: title || '', vendor: vendor || '',
price: price ? parseFloat(price) : null, image: image || null,
status: status || '', score: parseInt(sc, 10) || 0, keep_images: true, scope: 'unified'
};
});
resolve(items);
});
});
}
// ── "BEST ID": identify a scanned code against the WHOLE unified catalog BEFORE any Shopify or
// FileMaker lookup. dw_sku_crossref (267k) is the key — it shares FileMaker's code vocabulary
// (crossref.mfr_sku == FMP "Mfr Pattern", crossref.internal_sku == FMP "combo sku"), so the
// identity it returns is also the FileMaker search key. Image/pattern enrich best-effort from
// the 173k shopify_products (the vendor_catalog dw_sku namespace doesn't join cleanly). $0 local psql.
function identifyUnified(code) {
return new Promise(resolve => {
const norm = String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); // alnum-only → injection-safe
if (norm.length < 3) return resolve(null);
// CTE resolves ONE crossref row first, then a single lateral image lookup (no per-row blowup).
const SQL = `with m as (
select internal_sku, internal_sku_dash, mfr_sku, vendor_code, coalesce(vendor_name,'') vendor_name,
upper(regexp_replace(split_part(mfr_sku,' ',1),'[^A-Za-z0-9]','','g')) mfr_norm
from dw_sku_crossref
where upper(regexp_replace(split_part(mfr_sku,' ',1),'[^A-Za-z0-9]','','g'))='${norm}'
or upper(regexp_replace(coalesce(internal_sku,''),'[^A-Za-z0-9]','','g'))='${norm}'
or upper(regexp_replace(coalesce(internal_sku_dash,''),'[^A-Za-z0-9]','','g'))='${norm}'
order by (upper(regexp_replace(split_part(mfr_sku,' ',1),'[^A-Za-z0-9]','','g'))='${norm}') desc
limit 1)
select m.internal_sku, m.internal_sku_dash, m.mfr_sku, m.vendor_code, m.vendor_name,
coalesce(sp.title,''), coalesce(sp.image_url,''), coalesce(sp.pattern_name,'')
from m left join lateral (
select title, image_url, pattern_name from shopify_products
where upper(regexp_replace(coalesce(mfr_sku,''),'[^A-Za-z0-9]','','g'))=m.mfr_norm
and image_url is not null and image_url<>'' limit 1) sp on true`;
execFile(PSQL, ['-d', DW_DB, '-tAF', '\t', '-c', SQL],
{ timeout: 8000, maxBuffer: 2 * 1024 * 1024 }, (err, stdout) => {
if (err) return resolve(null);
const line = (stdout || '').split('\n').filter(Boolean)[0];
if (!line) return resolve(null);
const [internal_sku, internal_dash, mfr_sku, vendor_code, vendor, title, image, pattern] = line.split('\t');
resolve({
internal_sku: internal_sku || null, internal_sku_dash: internal_dash || null,
mfr_sku: mfr_sku || null, mfr_code: (mfr_sku || '').split(' ')[0] || null,
vendor_code: vendor_code || null, vendor: vendor || null,
title: title || null, image: image || null, pattern: pattern || null, matched: norm
});
});
});
}
// ── Discontinued-SKU resolver + live successor ───────────────────────────────
// Ported from ~/.claude/skills/substitutefinder/find.py (resolve_successor + target query).
// A typed SKU like CHC-216830 (or CHC216830, or CHC-216830-SAMPLE) resolves — dash/punct-
// insensitively, and by de-dashed PREFIX so the -SAMPLE variant is caught — to a stored row
// (e.g. CHC-216830-SAMPLE / DELETED_FROM_SHOPIFY). If that row is NOT active/draft it's flagged
// discontinued and linked to its live SUCCESSOR: the nearest ACTIVE product whose name matches
// the dead item's real pattern — resolved via a COPY-OF-<pattern> mfr hint (often only on a
// SIBLING sharing the dead title) then the dead base title itself. $0 local psql.
const _pgLit = s => "'" + String(s == null ? '' : s).replace(/'/g, "''") + "'";
function _pgq(sql) {
return new Promise(resolve => {
execFile(PSQL, ['-d', DW_DB, '--csv', '-v', 'ON_ERROR_STOP=1', '-c', sql],
{ timeout: 8000, maxBuffer: 4 * 1024 * 1024 }, (err, out) => {
if (err) return resolve([]);
const lines = String(out || '').split('\n').filter(Boolean);
if (lines.length < 1) return resolve([]);
// minimal CSV parse (handles quoted fields with embedded commas/quotes)
const parse = line => {
const f = []; let cur = '', q = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (q) { if (ch === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else q = false; } else cur += ch; }
else if (ch === '"') q = true;
else if (ch === ',') { f.push(cur); cur = ''; }
else cur += ch;
}
f.push(cur); return f;
};
const hdr = parse(lines[0]);
resolve(lines.slice(1).map(l => { const c = parse(l); const o = {}; hdr.forEach((h, i) => o[h] = c[i] == null ? '' : c[i]); return o; }));
});
});
}
const _cleanTitle = s => String(s || '').replace(/�/g, '').replace(/ +/g, ' ').trim();
function _succName(s) {
s = String(s || '').replace(/copy[\s_-]*of[\s_-]*/ig, ''); // drop COPY-OF-
s = s.replace(/[\s_-]+\d+$/, ''); // drop trailing -1..-8
s = s.replace(/[_-]+/g, ' ').trim(); // dashes/underscores -> spaces
return s.replace(/\s*(wall\s?paper|wall\s?covering)\s*$/i, '').trim(); // drop generic suffix
}
async function resolveSuccessor(mfr, title) {
const names = [_succName(mfr)];
const baseTitle = String(title || '').replace(/\s*\|.*$/, '').trim();
if (baseTitle.length >= 4) {
// harvest COPY-OF-<x> hints from any row sharing this dead title (often a sibling carries it)
const sibs = await _pgq(`select coalesce(mfr_sku,'') mfr from shopify_products
where title ilike '%'||${_pgLit(baseTitle)}||'%' and mfr_sku ~* 'copy' limit 10;`);
for (const r of sibs) names.push(_succName(r.mfr));
names.push(baseTitle);
}
const seen = new Set(), ordered = [];
for (let n of names) { n = (n || '').trim(); if (n.length >= 4 && !seen.has(n.toLowerCase())) { seen.add(n.toLowerCase()); ordered.push(n); } }
for (const name of ordered) {
const lit = _pgLit(name);
const rows = await _pgq(`select dw_sku, coalesce(title,'') title, coalesce(handle,'') handle
from shopify_products
where lower(status) in ('active','draft') and image_url is not null
and dw_sku is not null and dw_sku <> '' and title ilike '%'||${lit}||'%'
order by (title !~* 'memo|sample') desc,
(lower(title) like lower(${lit})||'%') desc, dw_sku
limit 1;`);
if (rows.length) { const r = rows[0]; return { sku: r.dw_sku, title: _cleanTitle(r.title), handle: r.handle }; }
}
return null;
}
// Resolve a (usually discontinued/deleted) SKU + its live successor. Returns null if nothing matches.
async function resolveDiscontinued(query) {
const q = String(query || '').trim();
if (!q) return null;
const qbase = q.replace(/-sample$/i, ''); // CHC-216830-SAMPLE typed → base CHC-216830
const lit = _pgLit(qbase);
// dash/punct-insensitive base match + de-dashed PREFIX (catches the -SAMPLE suffix); include
// archived/deleted. Prefer exact dw_sku, then a live one if the base has both, then archived.
const tgt = await _pgq(`select dw_sku, coalesce(title,'') title, coalesce(status,'') status,
coalesce(handle,'') handle, coalesce(mfr_sku,'') mfr
from shopify_products
where regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') like regexp_replace(lower(${lit}),'[^a-z0-9]','','g')||'%'
or dw_sku ilike ${lit} or sku ilike ${lit}
or handle ilike ('%'||${lit}||'%') or mfr_sku ilike ${lit}
order by (dw_sku ilike ${lit}) desc,
(lower(status) in ('active','draft')) desc,
(regexp_replace(lower(dw_sku),'[^a-z0-9]','','g') = regexp_replace(lower(${lit}),'[^a-z0-9]','','g')) desc,
(lower(status)='archived') desc
limit 1;`);
if (!tgt.length) return null;
const t = tgt[0];
const status = (t.status || '').trim();
const discontinued = !['active', 'draft'].includes(status.toLowerCase());
let successor = null;
if (discontinued) {
successor = await resolveSuccessor(t.mfr, t.title);
if (!successor) {
// last-resort: nearest active product whose title shares the dead base title
const base = String(t.title || '').replace(/\s*\|.*$/, '').trim();
if (base.length >= 4) {
const rows = await _pgq(`select dw_sku, coalesce(title,'') title, coalesce(handle,'') handle
from shopify_products where lower(status) in ('active','draft') and image_url is not null
and dw_sku is not null and dw_sku <> '' and title ilike '%'||${_pgLit(base)}||'%'
order by (title !~* 'memo|sample') desc, dw_sku limit 1;`);
if (rows.length) successor = { sku: rows[0].dw_sku, title: _cleanTitle(rows[0].title), handle: rows[0].handle };
}
}
}
return {
found: true, sku: t.dw_sku, title: _cleanTitle(t.title),
status: status.toUpperCase(), discontinued, successor
};
}
// Query the CLIP visual-search service (front/pattern photo → visually-nearest catalog products).
function visualSearch(b64, k) {
return new Promise(resolve => {
let target; try { target = new URL(VSEARCH_URL + '/search'); } catch (e) { return resolve([]); }
const lib = target.protocol === 'https:' ? https : http;
const payload = JSON.stringify({ image: b64, k: k || 8 });
const rq = lib.request(target, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) }, timeout: 20000 },
res => { let d = ''; res.on('data', c => d += c); res.on('end', () => { try { resolve((JSON.parse(d).results) || []); } catch (e) { resolve([]); } }); });
rq.on('error', () => resolve([])); rq.on('timeout', () => { rq.destroy(); resolve([]); });
rq.write(payload); rq.end();
});
}
server.listen(PORT, '0.0.0.0', () => {
console.log(`DW Photo Capture on http://0.0.0.0:${PORT} (Shopify push: ${TOKEN ? 'ON' : 'OFF — no token'}, sheet GRS: ${SHEET.length})`);
rebuildIndex(); // sheet-only items searchable immediately
buildCatalog(); // load the WHOLE dw_unified catalog from the all-dw feed
// keep the browsable catalog fresh (feed refreshes its own snapshot every 10 min)
setInterval(() => buildCatalog().catch(() => {}), 15 * 60 * 1000);
});