← 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);