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