← back to Photo Cleanup

server.js

413 lines

#!/usr/bin/env node
// Photo Cleanup — review tool for the 42-file Steven Abrams candidate list.
// Lists every candidate, lets Steve view a thumbnail + metadata, and "remove"
// (move to ~/.Trash/photo-cleanup/<ts>/), with undo within the session.
//
// Hard rules:
//   1. Files are NEVER `rm`'d. Always moved to a trash dir for recovery.
//   2. Only paths under $HOME are removable; symlinks are not followed.
//   3. No network access; localhost-only.

const http = require('http');
const fs = require('fs');
const path = require('path');
const url = require('url');
const cp = require('child_process');

const PORT = Number(process.env.PORT || 9775);
const CANDIDATE_FILE = process.env.CANDIDATES || '/tmp/steven-abrams-candidates-v2.txt';
const TRASH_DIR = path.join(process.env.HOME, '.Trash', 'photo-cleanup-' + new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19));
const HOME = process.env.HOME;

fs.mkdirSync(TRASH_DIR, { recursive: true });
const undoLog = []; // [{ original, trashed }]

function readCandidates() {
  if (!fs.existsSync(CANDIDATE_FILE)) return [];
  return fs.readFileSync(CANDIDATE_FILE, 'utf8').split('\n').map((s) => s.trim()).filter(Boolean);
}

function safeStat(p) {
  try { return fs.statSync(p); } catch (_) { return null; }
}

function classifyByName(name) {
  const lower = name.toLowerCase();
  if (/^w2[-_]|tax|1099|paystub/.test(lower)) return 'document';
  if (/lattice|wallpaper|pattern|mockup|dig-?\d+|colorway|colourway/.test(lower)) return 'pattern';
  if (/avatar|founder|headshot|portrait|selfie/.test(lower)) return 'photo';
  if (/logo|icon|favicon|brand/.test(lower)) return 'logo';
  if (/robot|illustration|render/.test(lower)) return 'illustration';
  return 'unknown';
}

const cache = new Map();
function metrics(p) {
  if (cache.has(p)) return cache.get(p);
  const out = { pixelWidth: null, pixelHeight: null, bright: null, spread: null };
  try {
    const info = cp.execSync(`sips -g pixelWidth -g pixelHeight ${JSON.stringify(p)}`, { stdio: ['ignore','pipe','ignore'], timeout: 5000 }).toString();
    const w = info.match(/pixelWidth:\s+(\d+)/); if (w) out.pixelWidth = Number(w[1]);
    const h = info.match(/pixelHeight:\s+(\d+)/); if (h) out.pixelHeight = Number(h[1]);
  } catch (_) {}
  const bmpPath = path.join(TRASH_DIR, '__px_' + Math.random().toString(36).slice(2) + '.bmp');
  try {
    cp.execSync(`sips -s format bmp -z 4 4 ${JSON.stringify(p)} --out ${JSON.stringify(bmpPath)}`, { stdio: ['ignore','ignore','ignore'], timeout: 5000 });
    const buf = fs.readFileSync(bmpPath);
    const offset = buf.readUInt32LE(10);
    const pixels = [];
    for (let i = 0; i < 16; i++) {
      const px = offset + i * 3;
      if (px + 2 >= buf.length) break;
      pixels.push([buf[px+2], buf[px+1], buf[px]]);
    }
    if (pixels.length) {
      const all = pixels.flat();
      out.bright = +(all.reduce((a,b) => a+b, 0) / all.length).toFixed(1);
      out.spread = Math.max(...all) - Math.min(...all);
    }
  } catch (_) {
  } finally {
    // Always remove the scratch BMP, even if sips partially wrote it before failing.
    try { fs.unlinkSync(bmpPath); } catch (_) {}
  }
  cache.set(p, out);
  return out;
}

function buildList() {
  const candidates = readCandidates();
  const rows = [];
  for (const p of candidates) {
    const st = safeStat(p);
    if (!st) continue;
    const name = path.basename(p);
    const m = metrics(p);
    rows.push({
      path: p,
      name,
      dir: path.dirname(p).replace(HOME, '~'),
      ext: path.extname(p).toLowerCase().slice(1),
      size: st.size,
      mtime: st.mtimeMs,
      kind: classifyByName(name),
      width: m.pixelWidth,
      height: m.pixelHeight,
      bright: m.bright,
      spread: m.spread,
    });
  }
  return rows;
}

let LIST_CACHE = null;
function ensureList() { if (!LIST_CACHE) LIST_CACHE = buildList(); return LIST_CACHE; }

function isUnderHome(p) {
  const abs = path.resolve(p);
  return abs.startsWith(HOME + path.sep) || abs === HOME;
}

function moveToTrash(originalPath) {
  if (!isUnderHome(originalPath)) throw new Error('path is outside $HOME — refusing');
  const lst = fs.lstatSync(originalPath);
  if (lst.isSymbolicLink()) throw new Error('refusing to move a symlink');
  const trashedName = Date.now() + '_' + Math.random().toString(36).slice(2, 8) + '_' + path.basename(originalPath);
  const trashedPath = path.join(TRASH_DIR, trashedName);
  fs.renameSync(originalPath, trashedPath);
  undoLog.push({ original: originalPath, trashed: trashedPath, at: new Date().toISOString() });
  return trashedPath;
}

function restoreFromTrash(originalPath) {
  const idx = undoLog.findIndex((u) => u.original === originalPath);
  if (idx < 0) throw new Error('no undo record for ' + originalPath);
  const entry = undoLog[idx];
  if (!fs.existsSync(entry.trashed)) throw new Error('trashed file no longer exists');
  fs.renameSync(entry.trashed, entry.original);
  undoLog.splice(idx, 1);
  return entry.original;
}

function readJsonBody(req) {
  return new Promise((resolve, reject) => {
    let buf = '';
    req.on('data', (c) => { buf += c; if (buf.length > 65536) { req.destroy(); reject(new Error('body too large')); } });
    req.on('end', () => { try { resolve(JSON.parse(buf || '{}')); } catch (e) { reject(e); } });
    req.on('error', reject);
  });
}

function send(res, status, body, ct = 'application/json') {
  res.writeHead(status, { 'content-type': ct + (ct.startsWith('text/') ? '; charset=utf-8' : '') });
  res.end(typeof body === 'string' ? body : JSON.stringify(body));
}

function streamFile(res, p) {
  try {
    const st = fs.statSync(p);
    const ext = path.extname(p).toLowerCase().slice(1);
    const mime = {
      jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif',
      webp: 'image/webp', tif: 'image/tiff', tiff: 'image/tiff', bmp: 'image/bmp',
      svg: 'image/svg+xml', heic: 'image/heic',
    }[ext] || 'application/octet-stream';
    res.writeHead(200, { 'content-type': mime, 'content-length': st.size });
    fs.createReadStream(p).pipe(res);
  } catch (e) { send(res, 404, { error: e.message }); }
}

function thumbnail(res, p) {
  try {
    const hash = require('crypto').createHash('sha1').update(p).digest('hex').slice(0, 12);
    const cached = path.join(TRASH_DIR, 'thumb_' + hash + '.jpg');
    if (!fs.existsSync(cached)) {
      cp.execSync(`sips -s format jpeg -Z 320 ${JSON.stringify(p)} --out ${JSON.stringify(cached)}`, { stdio: ['ignore','ignore','ignore'], timeout: 8000 });
    }
    streamFile(res, cached);
  } catch (e) { send(res, 415, { error: 'thumbnail failed: ' + e.message }); }
}

function pageHtml() {
  return `<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"/>
<title>Photo Cleanup — Steve / Abrams candidates</title>
<style>
  :root { --bg:#0f1115; --fg:#f3f3f3; --muted:#8b94a3; --card:#1a1d24; --border:#2a2e38; --accent:#27c97a; --danger:#ef5350; }
  * { box-sizing: border-box; }
  body { margin:0; background:var(--bg); color:var(--fg); font:14px ui-sans-serif,-apple-system,system-ui,sans-serif; }
  header { padding: 18px 24px; border-bottom: 1px solid var(--border); display:flex; gap:14px; align-items:center; flex-wrap:wrap; position: sticky; top:0; background: var(--bg); z-index: 10; }
  header h1 { margin:0; font-size: 17px; }
  header .stats { color: var(--muted); font-size: 13px; }
  header select, header input, header button { background: var(--card); color: var(--fg); border: 1px solid var(--border); padding: 8px 12px; border-radius: 10px; font: inherit; }
  header button { cursor: pointer; }
  header button:hover { border-color: var(--accent); }
  .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 14px; padding: 18px; }
  .card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; display:flex; flex-direction:column; }
  .card .thumb { width: 100%; aspect-ratio: 1/1; background: #000 center/cover no-repeat; cursor: pointer; }
  .card .meta { padding: 10px 12px; display: grid; gap: 4px; }
  .card .name { font-weight: 600; word-break: break-all; }
  .card .dir { color: var(--muted); font-size: 12px; word-break: break-all; }
  .card .pills { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 4px; }
  .card .pill { font-size: 11px; padding: 2px 8px; border-radius: 999px; background: rgba(255,255,255,.08); }
  .card .pill.kind-photo { background: rgba(39,201,122,.18); color: #5fe39a; }
  .card .pill.kind-pattern { background: rgba(255,193,7,.15); color: #ffd560; }
  .card .pill.kind-document { background: rgba(239,83,80,.15); color: #ff8a85; }
  .card .pill.kind-logo, .card .pill.kind-illustration, .card .pill.kind-unknown { color: var(--muted); }
  .card .actions { padding: 10px 12px; border-top: 1px solid var(--border); display:flex; gap: 8px; }
  .card .actions button { flex:1; padding: 8px; border-radius: 8px; cursor: pointer; border: 1px solid var(--border); background: transparent; color: var(--fg); font: inherit; }
  .card .actions .remove { color: #ff8a85; border-color: rgba(239,83,80,.35); }
  .card .actions .remove:hover { background: rgba(239,83,80,.15); }
  .card .actions .keep { color: var(--accent); border-color: rgba(39,201,122,.35); }
  .card .actions .keep:hover { background: rgba(39,201,122,.15); }
  .card.removed { opacity: .4; }
  .card.removed .actions .remove, .card.removed .actions .keep { display: none; }
  .card .actions .undo { color: #ffd560; border-color: rgba(255,213,96,.35); }
  .card .actions .undo:hover { background: rgba(255,213,96,.15); }
  .toast { position: fixed; bottom: 18px; left: 50%; transform: translateX(-50%); background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 10px 16px; font-size: 13px; opacity: 0; transition: opacity .2s; }
  .toast.show { opacity: 1; }
  .empty { padding: 60px 20px; text-align: center; color: var(--muted); }
</style>
</head><body>
<header>
  <h1>Photo Cleanup</h1>
  <span class="stats" id="stats">…</span>
  <span style="flex:1"></span>
  <label>Sort
    <select id="sort">
      <option value="kind">Kind (photos first)</option>
      <option value="name-asc">Name A → Z</option>
      <option value="name-desc">Name Z → A</option>
      <option value="size-desc">Size ↓ (largest first)</option>
      <option value="size-asc">Size ↑ (smallest first)</option>
      <option value="bright-asc">Brightness ↑ (darkest first)</option>
      <option value="bright-desc">Brightness ↓ (lightest first)</option>
      <option value="spread-asc">Spread ↑ (most uniform first)</option>
      <option value="mtime-desc">Newest first</option>
      <option value="mtime-asc">Oldest first</option>
      <option value="dir-asc">Folder A → Z</option>
      <option value="ext-asc">File type</option>
    </select>
  </label>
  <label>Filter
    <select id="kindFilter">
      <option value="">All kinds</option>
      <option value="photo">Photos only</option>
      <option value="pattern">Patterns only</option>
      <option value="document">Documents only</option>
      <option value="logo">Logos only</option>
      <option value="illustration">Illustrations only</option>
      <option value="unknown">Unclassified</option>
    </select>
  </label>
  <input type="search" id="q" placeholder="Filter by path / name…" style="min-width:200px"/>
  <button id="show-removed" type="button">Show removed</button>
  <button id="refresh" type="button">Refresh</button>
</header>
<div id="grid" class="grid"></div>
<div class="toast" id="toast"></div>
<script>
const state = { rows: [], removedPaths: new Set(), showRemoved: false };

function fmtBytes(b) {
  if (!b) return '';
  if (b > 1<<20) return (b/(1<<20)).toFixed(1) + ' MB';
  if (b > 1024) return (b/1024).toFixed(0) + ' KB';
  return b + ' B';
}
function fmtMtime(ms) { return new Date(ms).toLocaleDateString(); }

function showToast(msg) {
  const t = document.getElementById('toast');
  t.textContent = msg;
  t.classList.add('show');
  clearTimeout(showToast._t);
  showToast._t = setTimeout(() => t.classList.remove('show'), 1800);
}

function render() {
  const q = document.getElementById('q').value.toLowerCase();
  const kindF = document.getElementById('kindFilter').value;
  const sort = document.getElementById('sort').value;

  let rows = state.rows.slice();
  if (kindF) rows = rows.filter(r => r.kind === kindF);
  if (q) rows = rows.filter(r => (r.path + ' ' + r.name).toLowerCase().includes(q));
  if (!state.showRemoved) rows = rows.filter(r => !state.removedPaths.has(r.path));

  const cmp = {
    'kind': (a,b) => {
      const order = { photo:0, illustration:1, logo:2, unknown:3, pattern:4, document:5 };
      return (order[a.kind] - order[b.kind]) || a.name.localeCompare(b.name);
    },
    'name-asc':   (a,b) => a.name.localeCompare(b.name),
    'name-desc':  (a,b) => b.name.localeCompare(a.name),
    'size-desc':  (a,b) => (b.size||0) - (a.size||0),
    'size-asc':   (a,b) => (a.size||0) - (b.size||0),
    'bright-asc': (a,b) => (a.bright ?? 999) - (b.bright ?? 999),
    'bright-desc':(a,b) => (b.bright ?? -1) - (a.bright ?? -1),
    'spread-asc': (a,b) => (a.spread ?? 999) - (b.spread ?? 999),
    'mtime-desc': (a,b) => (b.mtime||0) - (a.mtime||0),
    'mtime-asc':  (a,b) => (a.mtime||0) - (b.mtime||0),
    'dir-asc':    (a,b) => a.dir.localeCompare(b.dir) || a.name.localeCompare(b.name),
    'ext-asc':    (a,b) => (a.ext||'').localeCompare(b.ext||'') || a.name.localeCompare(b.name),
  }[sort] || (() => 0);
  rows.sort(cmp);

  const grid = document.getElementById('grid');
  grid.innerHTML = rows.length ? '' : '<div class="empty">No files match the current filters.</div>';
  rows.forEach(r => {
    const removed = state.removedPaths.has(r.path);
    const card = document.createElement('div');
    card.className = 'card' + (removed ? ' removed' : '');
    const thumb = '/thumb?path=' + encodeURIComponent(r.path);
    const full = '/file?path=' + encodeURIComponent(r.path);
    const pills = [
      \`<span class="pill kind-\${r.kind}">\${r.kind}</span>\`,
      r.ext ? \`<span class="pill">.\${r.ext}</span>\` : '',
      r.size ? \`<span class="pill">\${fmtBytes(r.size)}</span>\` : '',
      r.width ? \`<span class="pill">\${r.width}×\${r.height}</span>\` : '',
      r.bright != null ? \`<span class="pill">b=\${r.bright}</span>\` : '',
      r.spread != null ? \`<span class="pill">s=\${r.spread}</span>\` : '',
      \`<span class="pill">\${fmtMtime(r.mtime)}</span>\`,
    ].filter(Boolean).join('');
    const actions = removed
      ? \`<button class="undo" data-path="\${encodeURIComponent(r.path)}">Undo</button>\`
      : \`<button class="keep" data-path="\${encodeURIComponent(r.path)}">Keep</button>
         <button class="remove" data-path="\${encodeURIComponent(r.path)}">Remove</button>\`;
    card.innerHTML = \`
      <div class="thumb" style="background-image:url('\${thumb}')" data-full="\${full}" title="Click to open full size"></div>
      <div class="meta">
        <div class="name">\${r.name}</div>
        <div class="dir">\${r.dir}</div>
        <div class="pills">\${pills}</div>
      </div>
      <div class="actions">\${actions}</div>\`;
    grid.appendChild(card);
  });

  const total = state.rows.length;
  const removedN = state.removedPaths.size;
  document.getElementById('stats').textContent = \`\${total} candidates · \${removedN} removed · \${total - removedN} remaining\`;
}

async function load() {
  const r = await fetch('/api/list').then(r => r.json());
  state.rows = r.rows || [];
  // restore session state from server-side trash log so refresh works
  const t = await fetch('/api/trash').then(r => r.json()).catch(() => ({ entries: [] }));
  state.removedPaths = new Set((t.entries || []).map(e => e.original));
  render();
}

document.addEventListener('click', async (e) => {
  const remove = e.target.closest('.remove');
  const keep = e.target.closest('.keep');
  const undo = e.target.closest('.undo');
  const thumb = e.target.closest('.thumb');
  if (thumb && thumb.dataset.full) { window.open(thumb.dataset.full, '_blank'); return; }
  if (keep) {
    const p = decodeURIComponent(keep.dataset.path);
    showToast('Kept: ' + p.split('/').pop());
    return;
  }
  if (remove) {
    const p = decodeURIComponent(remove.dataset.path);
    const r = await fetch('/api/remove', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path: p }) }).then(r => r.json());
    if (r.ok) { state.removedPaths.add(p); showToast('Moved to trash: ' + p.split('/').pop()); render(); }
    else { alert('Could not remove: ' + (r.error || 'unknown')); }
  }
  if (undo) {
    const p = decodeURIComponent(undo.dataset.path);
    const r = await fetch('/api/restore', { method:'POST', headers:{'content-type':'application/json'}, body: JSON.stringify({ path: p }) }).then(r => r.json());
    if (r.ok) { state.removedPaths.delete(p); showToast('Restored: ' + p.split('/').pop()); render(); }
    else { alert('Could not restore: ' + (r.error || 'unknown')); }
  }
});

document.getElementById('sort').addEventListener('change', render);
document.getElementById('kindFilter').addEventListener('change', render);
document.getElementById('q').addEventListener('input', render);
document.getElementById('refresh').addEventListener('click', load);
document.getElementById('show-removed').addEventListener('click', () => {
  state.showRemoved = !state.showRemoved;
  document.getElementById('show-removed').textContent = state.showRemoved ? 'Hide removed' : 'Show removed';
  render();
});

load();
</script>
</body></html>`;
}

const server = http.createServer(async (req, res) => {
  try {
    const u = url.parse(req.url, true);
    if (req.method === 'GET' && u.pathname === '/')             return send(res, 200, pageHtml(), 'text/html');
    if (req.method === 'GET' && u.pathname === '/api/list')     return send(res, 200, { rows: ensureList(), trashDir: TRASH_DIR });
    if (req.method === 'GET' && u.pathname === '/api/trash')    return send(res, 200, { entries: undoLog, trashDir: TRASH_DIR });
    if (req.method === 'GET' && u.pathname === '/thumb')        return thumbnail(res, u.query.path);
    if (req.method === 'GET' && u.pathname === '/file')         return streamFile(res, u.query.path);
    if (req.method === 'POST' && u.pathname === '/api/remove') {
      const body = await readJsonBody(req);
      if (!body.path) return send(res, 400, { ok: false, error: 'path required' });
      try { const t = moveToTrash(body.path); return send(res, 200, { ok: true, trashed: t }); }
      catch (e) { return send(res, 400, { ok: false, error: e.message }); }
    }
    if (req.method === 'POST' && u.pathname === '/api/restore') {
      const body = await readJsonBody(req);
      if (!body.path) return send(res, 400, { ok: false, error: 'path required' });
      try { const orig = restoreFromTrash(body.path); return send(res, 200, { ok: true, restored: orig }); }
      catch (e) { return send(res, 400, { ok: false, error: e.message }); }
    }
    send(res, 404, { error: 'not found' });
  } catch (e) { send(res, 500, { error: e.message }); }
});

server.listen(PORT, '127.0.0.1', () => {
  console.log(`Photo Cleanup listening on http://127.0.0.1:${PORT}`);
  console.log(`Trash dir: ${TRASH_DIR}`);
  console.log(`Candidates from: ${CANDIDATE_FILE} (${readCandidates().length} entries)`);
});