← back to Feature Harvest
server.js
205 lines
#!/usr/bin/env node
/*
* feature-harvest — interactive review deck for a year of shipped apps (CNCP wins),
* triaging each into a "Add to DW" / "Add to WPB" / "Both" / "Skip" pile so the best
* ideas get ported into designerwallcoverings.com or wallpapersback.com.
*
* Zero npm dependencies (pure Node http). Basic-auth gated (admin / DW2024!).
* Source of truth for verdicts = data/verdicts.json (server-side, durable).
* Corpus = data/wins-snapshot.json, refreshable live from CNCP (:3333).
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const ROOT = __dirname;
const DATA = path.join(ROOT, 'data');
const SNAP = path.join(DATA, 'wins-snapshot.json');
const ITEMS = path.join(DATA, 'items.json');
const VERDICTS = path.join(DATA, 'verdicts.json');
const RECS = path.join(DATA, 'officer-recs.json');
const THUMBS = path.join(DATA, 'thumbs');
const THUMB_MANIFEST = path.join(THUMBS, 'manifest.json');
const CNCP_WINS = 'http://127.0.0.1:3333/api/wins';
const USER = process.env.FH_USER || 'admin';
const PASS = process.env.FH_PASS || 'DW2024!';
const PREFERRED_PORTS = [9771, 9779, 9787, 9793, 0];
// ---- tiny helpers -------------------------------------------------
function readJSON(file, fallback) {
try { return JSON.parse(fs.readFileSync(file, 'utf8')); }
catch { return fallback; }
}
function writeJSON(file, obj) {
fs.writeFileSync(file, JSON.stringify(obj, null, 2));
}
function send(res, code, body, type = 'application/json') {
res.writeHead(code, { 'Content-Type': type, 'Cache-Control': 'no-store' });
res.end(typeof body === 'string' ? body : JSON.stringify(body));
}
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 === USER && p === PASS;
}
// Fetch JSON over http (Node http, no deps). Resolves null on any failure.
function fetchJSON(url, cb) {
const req = http.get(url, { timeout: 8000 }, (r) => {
let d = '';
r.on('data', (c) => (d += c));
r.on('end', () => { try { cb(JSON.parse(d)); } catch { cb(null); } });
});
req.on('error', () => cb(null));
req.on('timeout', () => { req.destroy(); cb(null); });
}
// ---- corpus + verdicts --------------------------------------------
function loadWins() { return readJSON(SNAP, []); }
function loadItems() {
const items = readJSON(ITEMS, loadWins());
const man = readJSON(THUMB_MANIFEST, {});
const recs = readJSON(RECS, {});
for (const it of items) {
const m = man[it.id];
it.thumb = m && m.ok ? m.file : null; // null → UI shows gradient poster
if (it.source === 'skill' || it.source === 'agent') {
const key = (it.title || '').toLowerCase();
const idkey = (it.id.split(':')[1] || '').toLowerCase();
it.rec = recs[key] || recs[idkey] || null; // officer recommendation, if any
}
}
return items;
}
function loadVerdicts() { return readJSON(VERDICTS, {}); }
if (!fs.existsSync(VERDICTS)) writeJSON(VERDICTS, {});
function exportMarkdown() {
const list = loadItems();
const byId = Object.fromEntries(list.map((w) => [w.id, w]));
const v = loadVerdicts();
const piles = { dw: [], wpb: [], both: [] };
for (const [id, rec] of Object.entries(v)) {
if (!piles[rec.decision]) continue;
const w = byId[id];
if (w) piles[rec.decision].push({ w, note: rec.note });
}
const fmt = (rows) =>
rows.length
? rows.map(({ w, note }) =>
`### ${w.title}\n` +
`*${w.source} · ${w.project} · ${w.date}*${w.commit ? ` · \`${w.commit}\`` : ''}${w.path ? `\n\`${w.path}\`` : ''}\n\n` +
`${w.summary || ''}\n\n` +
(w.value ? `**Value:** ${w.value}\n\n` : '') +
(note ? `**Steve's note:** ${note}\n\n` : '')
).join('\n---\n\n')
: '_(none yet)_\n';
return (
`# Feature Harvest — Shortlist\n\nGenerated ${new Date().toLocaleString()}\n\n` +
`## ➜ Add to Designer Wallcoverings (${piles.dw.length})\n\n${fmt(piles.dw)}\n\n` +
`## ➜ Add to Wallpaper's Back (${piles.wpb.length})\n\n${fmt(piles.wpb)}\n\n` +
`## ➜ Add to Both (${piles.both.length})\n\n${fmt(piles.both)}\n`
);
}
// ---- thumbnail rebuild job (async, progress-tracked) --------------
let thumbJob = { running: false, done: 0, total: 0, ok: 0, startedAt: 0, error: '' };
function startThumbJob() {
if (thumbJob.running) return;
thumbJob = { running: true, done: 0, total: 0, ok: 0, startedAt: Date.now(), error: '' };
const child = require('child_process').spawn('node', [path.join(ROOT, 'build-thumbs.js')], { cwd: ROOT });
const onChunk = (buf) => {
const s = buf.toString();
let m, re = /(\d+)\/(\d+)/g; // last "done/total" wins per chunk
while ((m = re.exec(s))) { thumbJob.done = +m[1]; thumbJob.total = +m[2]; }
const ok = s.match(/thumbnails:\s*(\d+)\/(\d+)/);
if (ok) { thumbJob.ok = +ok[1]; thumbJob.total = +ok[2]; }
};
child.stdout.on('data', onChunk);
child.stderr.on('data', () => {}); // ignore deprecation noise
child.on('error', (e) => { thumbJob.running = false; thumbJob.error = e.message; });
child.on('close', () => { thumbJob.running = false; });
}
// ---- routes -------------------------------------------------------
const server = http.createServer((req, res) => {
if (!authed(req)) {
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="feature-harvest"' });
return res.end('Auth required');
}
const u = new URL(req.url, 'http://x');
if (u.pathname === '/favicon.ico') {
res.writeHead(204); return res.end(); // silence browser favicon 404
}
if (u.pathname === '/' || u.pathname === '/index.html') {
return send(res, 200, fs.readFileSync(path.join(ROOT, 'public', 'index.html'), 'utf8'), 'text/html');
}
if (u.pathname === '/api/items') {
return send(res, 200, { items: loadItems(), verdicts: loadVerdicts() });
}
if (u.pathname === '/api/verdict' && req.method === 'POST') {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
let p;
try { p = JSON.parse(body); } catch { return send(res, 400, { error: 'bad json' }); }
const v = loadVerdicts();
if (p.decision === 'unset') delete v[p.id];
else v[p.id] = { decision: p.decision, note: p.note || '', ts: Date.now() };
writeJSON(VERDICTS, v);
send(res, 200, { ok: true });
});
return;
}
if (u.pathname === '/api/refresh' && req.method === 'POST') {
return fetchJSON(CNCP_WINS, (data) => {
if (Array.isArray(data) && data.length) {
writeJSON(SNAP, data);
// rebuild unified corpus (wins + dw-apps + wpb-tools)
require('child_process').execFile('node', [path.join(ROOT, 'build-corpus.js')], (err) => {
send(res, 200, { ok: !err, count: loadItems().length, wins: data.length });
});
} else {
send(res, 200, { ok: false, error: 'CNCP unreachable — kept cached corpus', count: loadItems().length });
}
});
}
if (u.pathname === '/api/export') {
return send(res, 200, exportMarkdown(), 'text/markdown; charset=utf-8');
}
if (u.pathname === '/api/thumbs' && req.method === 'POST') {
startThumbJob();
return send(res, 200, { ...thumbJob });
}
if (u.pathname === '/api/thumbs/status') {
return send(res, 200, { ...thumbJob });
}
if (u.pathname.startsWith('/thumb/')) {
const file = path.basename(u.pathname.slice('/thumb/'.length)); // basename → no traversal
const fp = path.join(THUMBS, file);
if (file.endsWith('.jpg') && fs.existsSync(fp)) {
res.writeHead(200, { 'Content-Type': 'image/jpeg', 'Cache-Control': 'max-age=300' });
return fs.createReadStream(fp).pipe(res);
}
return send(res, 404, { error: 'no thumb' });
}
send(res, 404, { error: 'not found' });
});
// ---- listen with port fallback ------------------------------------
(function listen(i) {
const port = PREFERRED_PORTS[i];
server.once('error', (e) => {
if (e.code === 'EADDRINUSE' && i < PREFERRED_PORTS.length - 1) listen(i + 1);
else throw e;
});
server.listen(port, () => {
const p = server.address().port;
console.log(`\n feature-harvest → http://127.0.0.1:${p}`);
console.log(` login: ${USER} / ${PASS}`);
console.log(` corpus: ${loadItems().length} items (wins + DW apps + WPB tools)\n`);
});
})(0);