← back to Japan Enrich
viewer-local/server.js
646 lines
#!/usr/bin/env node
/**
* Staging viewer for the Sangetsu + Lilycolor onboarding catalogs (OFFLINE / read-only).
* Reads the two staging JSONL files, flattens them to a uniform SKU row, and serves a
* paginated /api/skus endpoint behind a sort + density + infinite-scroll grid.
*
* HARD: read-only. No Shopify, no dw_unified, no publish. Pure local preview of staged data.
* node viewer/server.js [port] (default 9931)
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const zlib = require('zlib');
const PORT = parseInt(process.argv[2] || '9931', 10);
const DIR = path.join(__dirname, '..', 'staging');
const LILY = path.join(DIR, 'lilycolor-unified-staging.jsonl'); // trade pricebook + specs + price + image manifest
const SANG = path.join(DIR, 'sangetsu-staging.jsonl');
const GREEN = path.join(DIR, 'greenland-staging.jsonl'); // china. scope: Greenland vendor (pattern→colorways)
const PUBLIC = path.join(__dirname, 'public');
const HENRY_IMG = '/Volumes/Henry/dw-lily-images'; // selectively-fetched pattern swatches
const GREEN_IMG = path.join(__dirname, '..', 'images', 'greenland'); // downscaled colorway swatches (enrich_greenland.py)
const SANG_IMG = path.join(__dirname, '..', 'images', 'sangetsu-sw'); // downscaled colorway swatches (make_sangetsu_swatches.py)
const readJsonl = (f) => (fs.existsSync(f)
? fs.readFileSync(f, 'utf8').trim().split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean)
: []);
// ── import queue (local, append-only, REVERSIBLE) ───────────────────────────────
// The "⤓ Import" button enqueues a scoped SKU here. This records INTENT only —
// promotion into canonical dw_unified / Shopify is a SEPARATE, Steve-gated step
// (db/promote-import-queue.js). Nothing customer-facing happens from the viewer.
const IMPORT_QUEUE = path.join(__dirname, '..', 'staging', 'import-queue.jsonl');
const readQueue = () => readJsonl(IMPORT_QUEUE);
const writeQueue = (arr) => fs.writeFileSync(IMPORT_QUEUE, arr.map((r) => JSON.stringify(r)).join('\n') + (arr.length ? '\n' : ''));
function readBody(req) {
return new Promise((resolve) => {
let b = ''; req.on('data', (c) => { b += c; if (b.length > 8e6) req.destroy(); });
req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } });
req.on('error', () => resolve({}));
});
}
// --- enrichment overlay (hex / color_family / style / material) ----------------
// The local-hybrid enricher ($0, Pillow + qwen2.5vl) emits full staging rows + an
// `enrich` block keyed by mfr_sku. Read whichever enriched file(s) exist (later wins).
// Sangetsu has no image manifests yet, so it stays unenriched and degrades gracefully.
const normSku = (s) => String(s || '').replace(/[^A-Za-z0-9]/g, '').toUpperCase();
// Locally-extracted Lilycolor colorway (C) swatches: images/all_c/LIS-42001_..._C_01.jpg
// Index by normalized SKU (filename token before first "_") so cards have real swatches
// even when preview_images is empty and the Henry volume isn't mounted.
const ALLC_DIR = path.join(__dirname, '..', 'images', 'all_c');
const allcIndex = {};
try {
if (fs.existsSync(ALLC_DIR)) for (const fn of fs.readdirSync(ALLC_DIR)) {
if (/\.(jpe?g|png)$/i.test(fn)) { const k = normSku(fn.split('_')[0]); if (!allcIndex[k]) allcIndex[k] = path.join(ALLC_DIR, fn); }
}
} catch {}
// Greenland downscaled swatches are saved as images/greenland/<normSku>.jpg by
// enrich_greenland.py — a Set of which SKUs have a local swatch, so cards can serve
// /img/greenland/<sku> (self-contained) and fall back to the remote CDN url otherwise.
let greenSwatches = new Set();
const loadGreenSwatches = () => {
const s = new Set();
try { if (fs.existsSync(GREEN_IMG)) for (const fn of fs.readdirSync(GREEN_IMG)) if (/\.jpe?g$/i.test(fn)) s.add(fn.replace(/\.jpe?g$/i, '')); } catch {}
greenSwatches = s;
};
loadGreenSwatches();
// Sangetsu downscaled swatches (images/sangetsu-sw/<normSku>.jpg) — same self-contained
// pattern as Greenland so japan. cards serve /img/sangetsu/<sku> instead of the vendor CDN.
let sangSwatches = new Set();
const loadSangSwatches = () => {
const s = new Set();
try { if (fs.existsSync(SANG_IMG)) for (const fn of fs.readdirSync(SANG_IMG)) if (/\.jpe?g$/i.test(fn)) s.add(fn.replace(/\.jpe?g$/i, '')); } catch {}
sangSwatches = s;
};
loadSangSwatches();
// REAL per-pattern Sangetsu distributor scraped from the .psg-single-brands-main brand
// link (Koroseal / Eijffinger / J.Josephson / Omexco / …), keyed by source_url. Replaces
// the old host-derived 'Goodrich (Thailand)'. Blank when a pattern isn't scraped yet.
const SANG_BRANDS_FILE = path.join(DIR, 'sangetsu-brands.json');
let SANG_BRANDS = {};
const loadSangBrands = () => { try { SANG_BRANDS = JSON.parse(fs.readFileSync(SANG_BRANDS_FILE, 'utf8')); } catch { SANG_BRANDS = {}; } };
loadSangBrands();
// ── visual match: distributor catalog colors (computed locally, compute_distributor_colors.py) ──
// distributor label → dw_unified vendor_code (only these have a DW catalog to match against)
const DIST_VENDOR = {
'Koroseal': 'koroseal', 'Cole & Son': 'cole_son', 'Harlequin': 'harlequin',
'Versa Design Surfaces': 'versa_ds', 'Carlisle & Co': 'carlisle', 'Versace': 'versace',
'Colefax & Fowler': 'colefax_fowler', 'Nicolette Mayer': 'nicolette_mayer',
// sub-vendors that also have their own dw_unified catalog (Sanderson Design Group + luxe brands)
'Morris & Co': 'william_morris', 'Missoni': 'missoni', 'Zoffany': 'zoffany',
'Armani Casa': 'armani_casa', 'Vahallan': 'vahallan',
// (Casadeco/Sanderson/Scion/Omexco/Eijffinger/Texam/Texdecor/Greenland/P3TEC/Altagamma/Jwall/
// La Scala/Jannelli have NO dw catalog → visual match via reverse-image (Lens) + source page)
};
const DIST_COLORS_FILE = path.join(DIR, 'distributor-colors.jsonl');
let DIST_COLORS = {}; // vendor_code -> [ {sku,name,color_name,image_url,product_url,hex,palette,family,hue} ]
const loadDistColors = () => {
const m = {};
for (const r of readJsonl(DIST_COLORS_FILE)) { if (r && r.vendor) (m[r.vendor] = m[r.vendor] || []).push(r); }
DIST_COLORS = m;
};
loadDistColors();
const hexRGB = (h) => { h = String(h || '').replace('#', ''); return [0, 2, 4].map((i) => parseInt(h.substr(i, 2), 16) || 0); };
const rgbDist = (a, b) => { const x = hexRGB(a), y = hexRGB(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
// palette-weighted distance (dominant 50% / 2nd 30% / 3rd 20%) — a single ground color isn't
// enough for patterned wallcovering, so weight the top-3 swatches (contrarian fix).
const WT = [0.5, 0.3, 0.2];
const paletteDist = (pa, pb) => {
pa = pa && pa.length ? pa : []; pb = pb && pb.length ? pb : [];
if (!pa.length || !pb.length) return 999;
let d = 0; for (let i = 0; i < 3; i++) d += WT[i] * rgbDist(pa[Math.min(i, pa.length - 1)], pb[Math.min(i, pb.length - 1)]);
return d;
};
const STOP = new Set(['the', 'and', 'for', 'red', 'tan', 'new', 'old', 'big', 'wall', 'wallpaper', 'wallcovering', 'collection', 'vol', 'grey', 'gray', 'blue']);
const wordset = (s) => new Set(String(s || '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').split(/\s+/).filter((w) => w.length > 3 && !STOP.has(w)));
// distributor → subdomain slug map, so <slug>.designerwallcoverings.com is a per-distributor
// microsite (e.g. koroseal. / eijffinger. / j-josephson.). Built from the scraped brand map.
let DIST_SLUGS = {};
const buildDistSlugs = () => {
const m = { lilycolor: 'Lilycolor (direct)' };
for (const v of Object.values(SANG_BRANDS)) if (v && v.slug && v.distributor) m[v.slug] = v.distributor;
DIST_SLUGS = m;
};
buildDistSlugs();
const ENRICH_FILES = [
process.env.ENRICH_FILE, // explicit override
path.join(DIR, 'lilycolor-enriched.jsonl'), // post-deploy: beside staging
path.join(__dirname, '..', 'out', 'lilycolor-enriched.jsonl'), // live local run
path.join(__dirname, '..', 'out', 'pilot-enriched.jsonl'), // local pilot / full backfill
path.join(DIR, 'sangetsu-enriched.jsonl'), // post-deploy: beside staging
path.join(__dirname, '..', 'out', 'sangetsu-enriched.jsonl'), // Sangetsu per-colorway enrich (keyed by colorway SKU)
path.join(DIR, 'greenland-enriched.jsonl'), // post-deploy: beside staging
path.join(__dirname, '..', 'out', 'greenland-enriched.jsonl'), // Greenland per-colorway hex/family (keyed by colorway SKU)
].filter(Boolean);
function loadEnrich() {
const idx = {};
for (const f of ENRICH_FILES) {
for (const r of readJsonl(f)) {
if (r && r.enrich && r.mfr_sku) idx[normSku(r.mfr_sku)] = r.enrich;
}
}
return idx;
}
let ENRICH = loadEnrich();
// 12 color families (fixed order for the Color sort + swatch nav), with a representative dot hex.
const FAMILY_ORDER = ['Red', 'Orange/Brown', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Neutral/Beige', 'Black', 'Grey', 'White'];
// ── derived facet helpers (the "and more" dimensions) ───────────────────────────
// Price bands on the Japan LIST price (¥/m) — fixed order so the chips read low→high.
// NB: labels MUST stay comma-free — facet multi-select is CSV-encoded in the query string.
const PRICE_BANDS = [
{ key: '≤¥1k', test: (y) => y <= 1000 },
{ key: '¥1k–2k', test: (y) => y > 1000 && y <= 2000 },
{ key: '¥2k–4k', test: (y) => y > 2000 && y <= 4000 },
{ key: '¥4k–8k', test: (y) => y > 4000 && y <= 8000 },
{ key: '¥8k+', test: (y) => y > 8000 },
];
const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
const priceBand = (yen) => { if (!yen) return null; const b = PRICE_BANDS.find((x) => x.test(yen)); return b ? b.key : null; };
// Fire/flammability class — bucket the Japanese code (不燃 / 準不燃 / 防火) or Sangetsu Type II/III.
const fireClass = (s) => {
if (!s) return null;
if (/^不燃/.test(s)) return 'Non-combustible 不燃';
if (/^準不燃/.test(s)) return 'Semi-noncomb 準不燃';
if (/^防火/.test(s)) return 'Fire-retardant 防火';
if (/^type/i.test(s)) return s.replace(/^type/i, 'Type').trim();
return s.split(/\s+/)[0];
};
const collectionName = (c) => (c === 'ImportSelection' ? 'Import Selection' : c) || null;
// ── distributor / purchase channel ──────────────────────────────────────────────
// The MILL (source: Sangetsu / Lilycolor / Greenland) is NOT always who we order from.
// Sangetsu is a mill whose catalog is only buyable through its regional DISTRIBUTOR,
// Goodrich — the site we actually scraped (sangetsu-goodrich.co.th). So each row also
// carries a buy-from channel, derived from source + source_url host, separate from the
// brand badge. Lilycolor & Greenland sell direct (channel == mill) → dist_direct:true.
const DIST_BY_HOST = [
[/sangetsu-goodrich\.co\.th$/i, 'Goodrich (Thailand)'],
[/sangetsu-goodrich\.vn$/i, 'Goodrich (Vietnam)'],
[/goodrichglobal\.com$/i, 'Goodrich (Singapore)'],
];
function distInfo(source, url) {
let host = '';
try { host = new URL(url).hostname.replace(/^www\./, ''); } catch {}
for (const [re, label] of DIST_BY_HOST) if (re.test(host)) return { distributor: label, dist_direct: false };
if (source === 'sangetsu') return { distributor: 'Goodrich (Thailand)', dist_direct: false }; // sangetsu w/o a recognized host: same label as .co.th so the facet never splits
if (source === 'lilycolor') return { distributor: 'Lilycolor (direct)', dist_direct: true };
if (source === 'greenland') return { distributor: 'Greenland (direct)', dist_direct: true };
return { distributor: null, dist_direct: false };
}
// Flatten both vendors to one uniform SKU row: {source, sku, title, width, image, url, tags[]}
function buildRows() {
const rows = [];
// Lilycolor: unified trade row — one SKU, full specs + price + image manifest
const lilyRows = readJsonl(LILY);
// colorway grouping: one pattern (sku_range_raw) holds N colorway SKUs.
// Build pattern → [colorway SKUs] so each card can show its siblings as vendor-style chips.
const patterns = new Map();
for (const r of lilyRows) {
const key = (r.sku_range_raw || r.mfr_sku || '').trim();
if (!key) continue;
if (!patterns.has(key)) patterns.set(key, []);
patterns.get(key).push(r.mfr_sku);
}
// siblings = the OTHER colorways in this pattern, each tinted by its own enriched hex (grey until enriched)
const siblingChip = (s) => { const se = ENRICH[normSku(s)] || null; return { sku: s, hex: (se && se.hex && se.hex[0]) ? se.hex[0].hex : null, family: se ? se.color_family : null }; };
for (const r of lilyRows) {
const e = ENRICH[normSku(r.mfr_sku)] || null;
const key = (r.sku_range_raw || r.mfr_sku || '').trim();
const group = patterns.get(key) || [r.mfr_sku];
const siblings = group.filter((s) => s !== r.mfr_sku).map(siblingChip);
// prefer the locally-fetched clean swatch on Henry; else shop-CDN swatch-first preview
const imgVal = ((allcIndex[normSku(r.mfr_sku)] || fs.existsSync(path.join(HENRY_IMG, `${r.mfr_sku}.jpg`))) ? `/img/lily/${r.mfr_sku}` : (r.preview_images && r.preview_images[0])) || null;
rows.push({
source: 'lilycolor',
sku: r.mfr_sku,
prefix: r.mfr_prefix || null,
title: r.title_en || r.title_ja || r.mfr_sku, // draft EN title (enrich_lilycolor_titles.py) → JP → SKU
catalog: r.source_catalog || null, // WILL / LIGHT / V-wall / MATERIALS / Import Selection
collection: collectionName(r.source_catalog), // facet: vendor catalog/book
width: r.width || null, // facet: roll width
composition: (r.functions && r.functions.length) ? r.functions.join(', ') : null,
functions: r.functions || [], // facet (multi): performance functions
fire: (r.fire_ratings && r.fire_ratings[0]) || null,
fire_class: fireClass((r.fire_ratings && r.fire_ratings[0]) || null), // facet: fire class
price_yen: r.list_price_yen || null, // Japan LIST price (¥), not confirmed net cost
price_band: priceBand(r.list_price_yen || null), // facet: ¥ price band
repeat: (r.repeat_tate_cm && r.repeat_yoko_cm) ? `${r.repeat_tate_cm}×${r.repeat_yoko_cm}cm` : null,
image: imgVal,
image_state: imgVal ? 'Has swatch' : ((r.image_count || 0) ? 'Archived only' : 'No image'), // facet: swatch availability
image_count: r.image_count || 0,
image_kinds: r.image_kinds || [],
url: r.shop_handle ? `https://shop.lilycolor.co.jp/products/${r.shop_handle}` : null,
...distInfo('lilycolor', null), // facet/chip: buy-from channel (Lilycolor sells direct)
is_sample: false,
// colorway family — this card + its sibling colorways (vendor-style "other colors of this pattern")
colorway_count: group.length,
siblings,
// enrichment overlay (null/false when this SKU hasn't been image-enriched yet)
enriched: !!e,
color_family: e ? e.color_family : null,
style: e ? e.style : null,
material: e ? e.material : null,
hex: (e && e.hex && e.hex[0]) ? e.hex[0].hex : null,
palette: (e && e.hex) ? e.hex.slice(0, 5).map((x) => x.hex) : [],
});
}
// Sangetsu: one PATTERN explodes to N colorway SKUs, each with its own full-res image
for (const r of readJsonl(SANG)) {
const imgs = r.sku_images || {};
const cw = r.colorway_skus || [];
for (const sku of cw) {
// per-colorway enrichment (hex/family per colorway; style/material fanned from the pattern-level vision pass)
const e = ENRICH[normSku(sku)] || null;
const siblings = cw.filter((s) => s !== sku).map(siblingChip);
// prefer the self-contained local swatch; fall back to the vendor CDN url
const sImg = (sangSwatches.has(normSku(sku)) ? `/img/sangetsu/${sku}` : (imgs[sku] || null)) || null;
rows.push({
source: 'sangetsu',
sku,
prefix: r.mfr_prefix || null,
title: r.pattern || sku,
collection: null, // Sangetsu has no vendor-book facet yet
width: (r.spec && r.spec.width) || null, // facet: roll width (sparse for Sangetsu)
composition: (r.spec && r.spec.is_grasscloth) ? 'Grasscloth' : null,
functions: (r.spec && r.spec.is_metallic) ? ['metallic'] : [], // facet (multi): scant for Sangetsu
fire: (r.spec && r.spec.type) || null,
fire_class: fireClass((r.spec && r.spec.type) || null), // facet: fire class (Type II/III)
price_yen: null,
price_band: null, // no confirmed Sangetsu price yet
image: sImg,
image_state: sImg ? 'Has swatch' : 'No image', // facet: swatch availability
url: r.source_url || null,
// real distributor from the scraped brand map (blank until scraped) — NOT host-derived
distributor: (SANG_BRANDS[r.source_url] && SANG_BRANDS[r.source_url].distributor) || null,
dist_direct: false,
distributor_slug: (SANG_BRANDS[r.source_url] && SANG_BRANDS[r.source_url].slug) || null,
is_sample: false,
colorway_count: cw.length,
siblings,
// enrichment overlay (null/false until this colorway's image is fetched + enriched)
enriched: !!e,
color_family: e ? e.color_family : null,
style: e ? e.style : null,
material: e ? e.material : null,
hex: (e && e.hex && e.hex[0]) ? e.hex[0].hex : null,
palette: (e && e.hex) ? e.hex.slice(0, 5).map((x) => x.hex) : [],
});
}
}
// Greenland (china. scope): one PATTERN → N colorway SKUs (model), each with its own
// remote thumbnail. Cover-image-only patterns (no colorway rows in the vendor feed)
// fall back to a single pattern card so all 880 patterns render, not just the ~221
// that have per-colorway breakdowns. No enrichment/price yet — degrades gracefully.
for (const r of readJsonl(GREEN)) {
const skus = r.colorway_skus || [];
const imgs = r.sku_images || {};
const colors = r.sku_color || {};
const emit = skus.length ? skus : [r.product_code || `GL-${r.product_id}`];
for (const sku of emit) {
const isCw = skus.length > 0;
const e = ENRICH[normSku(sku)] || null; // per-colorway hex/family once enriched
const siblings = isCw ? skus.filter((s) => s !== sku).map((s) => siblingChip(s)) : [];
rows.push({
source: 'greenland',
sku,
prefix: r.mfr_prefix || null,
title: r.pattern || sku,
catalog: r.category || null, // top ("Wallcovering")
collection: r.material || null, // facet: material book (Sisal/Cork/…)
width: null,
composition: r.material || null,
functions: [],
fire: null,
fire_class: null,
price_yen: null,
price_band: null,
image: (greenSwatches.has(normSku(sku)) ? `/img/greenland/${sku}` : ((isCw ? imgs[sku] : r.cover_img) || r.cover_img)) || null,
image_state: ((greenSwatches.has(normSku(sku))) || (isCw ? imgs[sku] : r.cover_img)) ? 'Has swatch' : 'No image',
url: r.source_url || null,
...distInfo('greenland', r.source_url), // facet/chip: buy-from channel (Greenland direct)
is_sample: false,
colorway_count: isCw ? skus.length : 1,
siblings,
// material comes straight from the vendor category tree (reliable); hex/color_family
// land per-colorway once enrich_greenland.py (Pillow, $0 local) writes its overlay.
enriched: !!e,
color_family: e ? e.color_family : null,
style: e ? e.style : null,
material: r.material || (e && e.material) || null, // fresh staging (clean fiber) wins over enriched copy
color_name: colors[sku] || null, // per-colorway vendor color name
hex: (e && e.hex && e.hex[0]) ? e.hex[0].hex : null,
palette: (e && e.hex) ? e.hex.slice(0, 5).map((x) => x.hex) : [],
});
}
}
return rows;
}
let ROWS = buildRows();
const reload = () => { loadGreenSwatches(); loadSangSwatches(); loadSangBrands(); buildDistSlugs(); loadDistColors(); ENRICH = loadEnrich(); ROWS = buildRows(); };
try { if (fs.existsSync(DIST_COLORS_FILE)) fs.watchFile(DIST_COLORS_FILE, { interval: 10000 }, loadDistColors); } catch {}
try { if (fs.existsSync(SANG_BRANDS_FILE)) fs.watchFile(SANG_BRANDS_FILE, { interval: 5000 }, reload); } catch {}
fs.watchFile(SANG, { interval: 4000 }, reload); // pick up Sangetsu staging as it grows
fs.watchFile(LILY, { interval: 8000 }, reload);
try { if (fs.existsSync(GREEN)) fs.watchFile(GREEN, { interval: 8000 }, reload); } catch {}
// pick up enriched rows live as the local-hybrid job writes them
for (const f of ENRICH_FILES) { try { if (fs.existsSync(f)) fs.watchFile(f, { interval: 8000 }, reload); } catch {} }
let lastImgCount = -1; // re-resolve primary images as swatches land on Henry
setInterval(() => {
try { const n = fs.existsSync(HENRY_IMG) ? fs.readdirSync(HENRY_IMG).length : 0; if (n !== lastImgCount) { lastImgCount = n; reload(); } } catch {}
}, 6000);
const sorters = {
newest: null, // server natural order
sku: (a, b) => String(a.sku).localeCompare(String(b.sku)),
title: (a, b) => String(a.title).localeCompare(String(b.title)),
width: (a, b) => (parseFloat(a.width) || 0) - (parseFloat(b.width) || 0),
price: (a, b) => (a.price_yen || 1e9) - (b.price_yen || 1e9),
images: (a, b) => (b.image_count || 0) - (a.image_count || 0),
// group by color family (fixed wheel order), then SKU — unenriched sink to the end
color: (a, b) => { const r = (x) => { const i = FAMILY_ORDER.indexOf(x.color_family); return i < 0 ? 99 : i; }; return r(a) - r(b) || String(a.sku).localeCompare(String(b.sku)); },
style: (a, b) => String(a.style || '~~').localeCompare(String(b.style || '~~')) || String(a.sku).localeCompare(String(b.sku)),
material: (a, b) => String(a.material || '~~').localeCompare(String(b.material || '~~')) || String(a.sku).localeCompare(String(b.sku)),
distributor: (a, b) => String(a.distributor || '~~').localeCompare(String(b.distributor || '~~')) || String(a.sku).localeCompare(String(b.sku)),
};
// shared filter — opts.skip names a facet dimension to EXCLUDE (for drill-down facet counts)
function parseFilters(u) {
const csv = (k) => new Set((u.searchParams.get(k) || '').split(',').map((s) => s.trim()).filter(Boolean));
return {
source: u.searchParams.get('source') || 'all',
q: (u.searchParams.get('q') || '').toLowerCase(),
colors: csv('colors'), styles: csv('styles'), materials: csv('materials'),
collections: csv('collections'), widths: csv('widths'), fires: csv('fires'),
funcs: csv('funcs'), prices: csv('prices'), images: csv('images'), distributors: csv('distributors'),
enriched: u.searchParams.get('enriched') || 'all',
};
}
function applyFilters(rows, f, opts = {}) {
let out = rows;
if (f.source && f.source !== 'all') out = out.filter((r) => r.source === f.source);
if (f.q) out = out.filter((r) => (r.sku + ' ' + r.title).toLowerCase().includes(f.q));
if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => r.color_family && f.colors.has(r.color_family));
if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => r.style && f.styles.has(r.style));
if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => r.material && f.materials.has(r.material));
if (opts.skip !== 'collection' && f.collections.size) out = out.filter((r) => r.collection && f.collections.has(r.collection));
if (opts.skip !== 'width' && f.widths.size) out = out.filter((r) => r.width && f.widths.has(r.width));
if (opts.skip !== 'fire' && f.fires.size) out = out.filter((r) => r.fire_class && f.fires.has(r.fire_class));
if (opts.skip !== 'funcs' && f.funcs.size) out = out.filter((r) => r.functions && r.functions.some((x) => f.funcs.has(x)));
if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => r.price_band && f.prices.has(r.price_band));
if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => r.image_state && f.images.has(r.image_state));
if (opts.skip !== 'distributor' && f.distributors.size) out = out.filter((r) => r.distributor && f.distributors.has(r.distributor));
if (opts.skip !== 'enriched' && f.enriched === 'enriched') out = out.filter((r) => r.enriched);
if (opts.skip !== 'enriched' && f.enriched === 'unenriched') out = out.filter((r) => !r.enriched);
return out;
}
// Admin Basic Auth — enforced only when REQUIRE_AUTH is set (on Kamatera);
// local 9931 stays open. Creds default admin/DW2024!, override via BASIC_AUTH="user:pass".
const REQUIRE_AUTH = !!process.env.REQUIRE_AUTH;
const [AUTH_USER, AUTH_PASS] = (process.env.BASIC_AUTH || 'admin:DW2024!').split(':');
function authed(req) {
const h = req.headers.authorization || '';
if (!h.startsWith('Basic ')) return false;
const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
return u === AUTH_USER && p === AUTH_PASS;
}
// ── per-host catalog scope ──────────────────────────────────────────────────────
// china.designerwallcoverings.com → Greenland-vendor catalog ONLY (source:'greenland').
// japan. (or anything else / localhost) → the Japanese catalog, with any greenland row
// EXCLUDED. The scope pre-filters ROWS BEFORE any user filter and overrides f.source so
// the two hosts can never leak into each other.
function hostScope(req) {
const host = String(req.headers.host || '').toLowerCase().split(':')[0];
if (host.startsWith('china.')) {
return { id: 'china', label: 'China — Greenland', sub: 'Greenland vendor · staged offline · read-only preview',
sources: ['greenland'], tabs: [{ src: 'greenland', label: 'Greenland' }] };
}
// per-distributor microsite: <slug>.designerwallcoverings.com → only that distributor's rows.
// Colliding brands (koroseal/harlequin/cole-son already own a subdomain) use <slug>-sg;
// strip the -sg so both koroseal. and koroseal-sg. resolve to the same distributor scope.
const sub = host.split('.')[0].replace(/-sg$/, '');
if (DIST_SLUGS[sub] && !['japan', 'china', 'www', 'localhost'].includes(sub)) {
const label = DIST_SLUGS[sub];
return { id: 'dist-' + sub, label, sub: `${label} · Sangetsu distribution · internal · read-only`,
sources: ['lilycolor', 'sangetsu'], distributor: label,
tabs: [{ src: 'sangetsu', label: 'Sangetsu' }] };
}
// japan + local dev + anything else: the existing Japanese catalog, greenland excluded
return { id: 'japan', label: 'Sangetsu + Lilycolor', sub: 'staged offline · read-only preview',
sources: ['lilycolor', 'sangetsu'], tabs: [{ src: 'lilycolor', label: 'Lilycolor' }, { src: 'sangetsu', label: 'Sangetsu' }] };
}
// rows visible for this host (the pool every count + filter runs against);
// a distributor microsite pre-filters to that distributor's rows.
const scopedRows = (scope) => (scope.distributor
? ROWS.filter((r) => scope.sources.includes(r.source) && r.distributor === scope.distributor)
: ROWS.filter((r) => scope.sources.includes(r.source)));
// gzip JSON responses when the client accepts it (the light /api/products list is
// ~15.9k rows — gzip takes it from ~7MB to ~1MB; internal-line-viewer spec).
function sendJson(req, res, obj) {
const body = JSON.stringify(obj);
if (/\bgzip\b/.test(String(req.headers['accept-encoding'] || ''))) {
zlib.gzip(body, (e, buf) => {
if (e) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(body); }
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip' });
res.end(buf);
});
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(body);
}
const server = http.createServer(async (req, res) => {
if (REQUIRE_AUTH && !authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="DW Japan/China — admin preview"' });
return res.end('Authentication required');
}
const u = new URL(req.url, `http://localhost:${PORT}`);
const scope = hostScope(req);
const POOL = scopedRows(scope);
// LIGHT full list for the client-side internal-line-viewer front end: every scoped row,
// siblings capped (big Sangetsu patterns explode quadratically otherwise), no heavy fields.
if (u.pathname === '/api/products') {
const SIB_MAX = 12;
const products = POOL.map((r) => ({
source: r.source, sku: r.sku, prefix: r.prefix, title: r.title, collection: r.collection,
width: r.width, repeat: r.repeat || null, composition: r.composition, functions: r.functions || [],
fire: r.fire, fire_class: r.fire_class, price_yen: r.price_yen, price_band: r.price_band,
image: r.image, image_state: r.image_state, image_count: r.image_count || 0,
url: r.url, colorway_count: r.colorway_count, siblings: (r.siblings || []).slice(0, SIB_MAX),
distributor: r.distributor, dist_direct: !!r.dist_direct,
enriched: !!r.enriched, color_family: r.color_family, style: r.style, material: r.material,
hex: r.hex, palette: r.palette || [], color_name: r.color_name || null,
}));
return sendJson(req, res, {
total: products.length, scope: scope.id,
family_order: FAMILY_ORDER, price_order: PRICE_ORDER, products,
});
}
// tell the client which host scope it's in so it can set the H1/subtitle + hide the
// irrelevant source tabs (china shows only Greenland; japan shows Lilycolor+Sangetsu).
if (u.pathname === '/api/scope') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ id: scope.id, label: scope.label, sub: scope.sub, tabs: scope.tabs }));
return;
}
// ── import queue: enqueue INTENT for onboarding (reversible; no prod write) ──
if (u.pathname === '/api/import-queue') {
const q = readQueue();
return sendJson(req, res, { count: q.length, skus: q.map((r) => r.sku), items: q });
}
if (u.pathname === '/api/import/clear' && req.method === 'POST') {
writeQueue([]);
return sendJson(req, res, { count: 0, cleared: true });
}
if (u.pathname === '/api/import' && req.method === 'POST') {
const body = await readBody(req);
const want = Array.isArray(body.skus) ? body.skus : (body.sku ? [body.sku] : []);
const bySku = new Map(POOL.map((r) => [String(r.sku), r])); // scope pool: china can't queue a japan sku
const q = readQueue();
const have = new Set(q.map((r) => String(r.sku)));
let queued = 0, already = 0, missing = 0;
for (const raw of want) {
const sku = String(raw);
if (have.has(sku)) { already++; continue; }
const r = bySku.get(sku);
if (!r) { missing++; continue; }
q.push({ queued_at: new Date().toISOString(), scope: scope.id, source: r.source, sku: r.sku,
distributor: r.distributor || null, dist_direct: !!r.dist_direct, title: r.title || null,
collection: r.collection || null, url: r.url || null, image: r.image || null,
price_yen: r.price_yen || null, status: 'queued' });
have.add(sku); queued++;
}
writeQueue(q);
return sendJson(req, res, { queued, already, missing, count: q.length });
}
if (u.pathname === '/api/import/remove' && req.method === 'POST') {
const body = await readBody(req);
const rm = new Set((Array.isArray(body.skus) ? body.skus : (body.sku ? [body.sku] : [])).map(String));
const q = readQueue().filter((r) => !rm.has(String(r.sku)));
writeQueue(q);
return sendJson(req, res, { count: q.length, removed: [...rm] });
}
// ── visual match: click a Sangetsu item → color-ranked candidates from its distributor's DW catalog ──
if (u.pathname === '/api/match') {
const sku = u.searchParams.get('sku') || '';
const dist = u.searchParams.get('dist') || '';
// disambiguate by distributor first (SKUs repeat across patterns → wrong-vendor bug); fall back to sku-only
const item = POOL.find((r) => String(r.sku) === sku && (!dist || r.distributor === dist)) || POOL.find((r) => String(r.sku) === sku);
if (!item) return sendJson(req, res, { error: 'not found' });
const vendor = DIST_VENDOR[item.distributor] || null;
const cands = vendor ? (DIST_COLORS[vendor] || []) : [];
const iw = wordset(item.title);
const ip = (item.palette && item.palette.length) ? item.palette : (item.hex ? [item.hex] : []);
let ranked = [];
if (ip.length && cands.length) {
ranked = cands.map((c) => {
const overlap = Math.min([...wordset(c.name)].filter((w) => iw.has(w)).length, 2); // capped
return { c, d: paletteDist(ip, (c.palette && c.palette.length) ? c.palette : [c.hex]) - overlap * 30 };
}).sort((a, b) => a.d - b.d).slice(0, 12);
} else if (cands.length) {
ranked = cands.map((c) => ({ c, d: [...wordset(c.name)].filter((w) => iw.has(w)).length ? 0 : 1 }))
.sort((a, b) => a.d - b.d).slice(0, 12);
}
const pubimg = (item.image && /^https?:/.test(item.image)) ? item.image : null;
return sendJson(req, res, {
item: { sku: item.sku, title: item.title, image: item.image, hex: item.hex || null, palette: ip,
color_family: item.color_family || null, distributor: item.distributor || null, url: item.url || null },
vendor, catalog_size: cands.length,
matches: ranked.map(({ c, d }) => ({ sku: c.sku, name: c.name, color_name: c.color_name, image: c.image_url,
product_url: c.product_url, hex: c.hex, palette: c.palette, family: c.family, score: Math.round(d) })),
methods: {
dw_unified: !!vendor,
reverse_image: pubimg ? `https://lens.google.com/uploadbyurl?url=${encodeURIComponent(pubimg)}`
: `https://www.google.com/search?tbm=isch&q=${encodeURIComponent((item.distributor || '') + ' ' + item.title)}`,
source: item.url || null,
},
});
}
if (u.pathname === '/api/skus') {
const f = parseFilters(u);
// host scope overrides the user-supplied source so a stale localStorage tab can't
// request lilycolor on china (or greenland on japan); clamp to in-scope sources only.
if (!scope.sources.includes(f.source)) f.source = 'all';
const sort = u.searchParams.get('sort') || 'newest';
const offset = parseInt(u.searchParams.get('offset') || '0', 10);
const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10), 500);
let rows = applyFilters(POOL, f);
const counts = { all: POOL.length };
for (const s of scope.sources) counts[s] = POOL.filter((r) => r.source === s).length;
if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
const page = rows.slice(offset, offset + limit);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ total: rows.length, counts, offset, limit, rows: page }));
return;
}
// facet counts — each dimension counted against the OTHER active filters (drill-down)
if (u.pathname === '/api/facets') {
const f = parseFilters(u);
if (!scope.sources.includes(f.source)) f.source = 'all';
const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
const tallyArr = (rows, key) => { const m = {}; for (const r of rows) { for (const v of (r[key] || [])) m[v] = (m[v] || 0) + 1; } return m; };
const enrRows = applyFilters(POOL, f, { skip: 'enriched' });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
color_family: tally(applyFilters(POOL, f, { skip: 'color' }), 'color_family'),
style: tally(applyFilters(POOL, f, { skip: 'style' }), 'style'),
material: tally(applyFilters(POOL, f, { skip: 'material' }), 'material'),
collection: tally(applyFilters(POOL, f, { skip: 'collection' }), 'collection'),
width: tally(applyFilters(POOL, f, { skip: 'width' }), 'width'),
fire_class: tally(applyFilters(POOL, f, { skip: 'fire' }), 'fire_class'),
functions: tallyArr(applyFilters(POOL, f, { skip: 'funcs' }), 'functions'),
price_band: tally(applyFilters(POOL, f, { skip: 'price' }), 'price_band'),
image_state: tally(applyFilters(POOL, f, { skip: 'image' }), 'image_state'),
distributor: tally(applyFilters(POOL, f, { skip: 'distributor' }), 'distributor'),
price_order: PRICE_ORDER,
enriched: enrRows.filter((r) => r.enriched).length,
unenriched: enrRows.filter((r) => !r.enriched).length,
total: applyFilters(POOL, f).length,
family_order: FAMILY_ORDER,
}));
return;
}
// locally-fetched Lily pattern swatches from Henry
if (u.pathname.startsWith('/img/lily/')) {
const sku = decodeURIComponent(u.pathname.slice('/img/lily/'.length)).replace(/[^A-Za-z0-9-]/g, '');
const f = path.join(HENRY_IMG, `${sku}.jpg`);
if (fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
const f2 = allcIndex[normSku(sku)];
if (f2 && fs.existsSync(f2)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f2)); }
res.writeHead(404); return res.end('no image');
}
if (u.pathname.startsWith('/img/greenland/')) {
const sku = decodeURIComponent(u.pathname.slice('/img/greenland/'.length));
const f = path.join(GREEN_IMG, `${normSku(sku)}.jpg`);
if (f.startsWith(GREEN_IMG) && fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
res.writeHead(404); return res.end('no image');
}
if (u.pathname.startsWith('/img/sangetsu/')) {
const sku = decodeURIComponent(u.pathname.slice('/img/sangetsu/'.length));
const f = path.join(SANG_IMG, `${normSku(sku)}.jpg`);
if (f.startsWith(SANG_IMG) && fs.existsSync(f)) { res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=86400' }); return res.end(fs.readFileSync(f)); }
res.writeHead(404); return res.end('no image');
}
// static
let p = u.pathname === '/' ? '/index.html' : u.pathname;
const file = path.join(PUBLIC, path.normalize(p).replace(/^(\.\.[/\\])+/, ''));
if (!file.startsWith(PUBLIC) || !fs.existsSync(file)) { res.writeHead(404); res.end('not found'); return; }
const ext = path.extname(file);
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css' }[ext] || 'text/plain';
res.writeHead(200, { 'Content-Type': mime });
res.end(fs.readFileSync(file));
});
server.listen(PORT, () => {
console.log(`Staging viewer → http://localhost:${PORT} (lilycolor ${ROWS.filter((r) => r.source === 'lilycolor').length} / sangetsu ${ROWS.filter((r) => r.source === 'sangetsu').length} / greenland ${ROWS.filter((r) => r.source === 'greenland').length} SKUs)`);
});