← back to Paul Conrad Cartoons
server.js
249 lines
// Inkwell — Editorial Cartoon Archive. Private local reference tool (no auth, not deployed).
// Serves public/, the public-domain cartoon gallery (data/pd-cartoons.json) and the imported P24 art (data/p24.json).
// data/cartoons.json (research records) is kept on disk as source material but is no longer served.
//
// TK-12230 naming rule: the served UI and every API response must never contain the name of the
// real cartoonist the (no longer served) research records document; sendClean() refuses to send
// any response that still contains the name.
const express = require('express');
const path = require('path');
const fs = require('fs');
const app = express();
const PORT = process.env.PORT || 9933;
const BANNED = /conrad/i;
function neutralText(s) {
return String(s)
.replace(/\bPaul\s+(Francis\s+)?Conrad['’]s\b/gi, "the artist's")
.replace(/\bPaul\s+(Francis\s+)?Conrad\b/gi, 'the artist')
.replace(/\bConrad\s+estate\b/gi, "the artist's estate")
.replace(/\bConrad['’]s\b/gi, "the artist's")
.replace(/\bConrad\b/gi, 'the artist')
.replace(/conrad/gi, 'artist');
}
function sendClean(res, obj) {
const body = JSON.stringify(obj);
if (BANNED.test(body)) return res.status(500).json({ error: 'response blocked: naming rule (TK-12230)' });
res.type('application/json').send(body);
}
function readJson(rel) {
return JSON.parse(fs.readFileSync(path.join(__dirname, rel), 'utf8'));
}
app.use(express.static(path.join(__dirname, 'public')));
// Public-domain cartoons from museum / library / university open-access APIs
// (built by scripts/fetch-pd-cartoons.mjs). Replaces the text-only research-records section.
app.get('/api/pd-cartoons', (req, res) => {
try {
sendClean(res, readJson('data/pd-cartoons.json'));
} catch (err) {
res.status(500).json({ error: 'could not read data/pd-cartoons.json', detail: neutralText(err.message) });
}
});
// All imported P24 photos + cartoons. Optional ?type=photo|cartoon.
app.get('/api/p24', (req, res) => {
try {
const doc = readJson('data/p24.json');
const type = req.query.type;
const items = type ? doc.items.filter(i => i.type === type) : doc.items;
sendClean(res, { ...doc, items });
} catch (err) {
res.status(500).json({ error: 'could not read data/p24.json', detail: neutralText(err.message) });
}
});
// ---- Curation (TK-12241): Shadow Man cartoons + Model Arena ideas ------------------------------
// Both collections carry a per-item status: pending | approved | deleted. Mutations are POST-only,
// LOOPBACK-ONLY (this is a no-auth local tool; a LAN client must not be able to curate), run the
// response through sendClean(), and append one line per action to data/shadowman-actions.jsonl.
// Deleting a Shadow Man piece MOVES its image to generator/out/_trash/ (never unlinks); restore
// moves it back. Approving an arena idea appends a brief to generator/idea-queue.json (the queue
// generator/compose-prompts.mjs --from-queue turns into the next render batch); restore removes it.
const DATA_DIR = path.join(__dirname, 'data');
const PUB_SM = path.join(__dirname, 'public', 'shadowman');
const TRASH = path.join(__dirname, 'generator', 'out', '_trash');
const QUEUE = path.join(__dirname, 'generator', 'idea-queue.json');
const ACTIONS = path.join(DATA_DIR, 'shadowman-actions.jsonl');
app.use(express.json({ limit: '64kb' }));
function writeJsonAtomic(file, obj) {
const tmp = file + '.tmp';
fs.writeFileSync(tmp, JSON.stringify(obj, null, 2) + '\n');
fs.renameSync(tmp, file);
}
function loopbackOnly(req, res, next) {
const ip = req.socket.remoteAddress || '';
if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') return next();
res.status(403).json({ error: 'curation is loopback-only' });
}
function listFrom(doc, query) {
const { theme, era, model, status } = query;
return doc.items.filter(i => (!theme || i.theme === theme) && (!era || i.era === era) && (!model || i.model === model) && (!status || i.status === status));
}
function moveFile(from, to) {
fs.mkdirSync(path.dirname(to), { recursive: true });
fs.renameSync(from, to);
}
const COLLECTIONS = {
shadowman: {
file: path.join(DATA_DIR, 'shadowman.json'),
onDelete(item) {
const src = path.join(PUB_SM, item.id + '.jpg');
if (fs.existsSync(src)) moveFile(src, path.join(TRASH, item.id + '.jpg'));
},
onRestore(item) {
const t = path.join(TRASH, item.id + '.jpg');
if (item.status === 'deleted' && fs.existsSync(t)) moveFile(t, path.join(PUB_SM, item.id + '.jpg'));
},
},
'arena-ideas': {
file: path.join(DATA_DIR, 'arena-ideas.json'),
onApprove(item) {
const q = fs.existsSync(QUEUE) ? JSON.parse(fs.readFileSync(QUEUE, 'utf8')) : { note: 'Approved Model Arena ideas waiting to be rendered. node generator/compose-prompts.mjs --from-queue turns un-batched entries into generator/out/batch-queue-<date>.json.', items: [] };
if (!q.items.some(b => b.id === item.id)) {
q.items.push({ id: item.id, theme: item.theme, era: null, title: item.title, scene: item.scene, caption: item.caption, source: 'model-arena:' + item.model, queued_at: new Date().toISOString(), batched_at: null });
writeJsonAtomic(QUEUE, q);
}
},
onDelete(item) { dequeue(item); },
onRestore(item) { dequeue(item); },
},
};
// Un-approving (restore) or deleting an approved idea pulls its brief back out of the queue,
// unless it was already handed to a render batch (then the batch is the record).
function dequeue(item) {
if (item.status !== 'approved' || !fs.existsSync(QUEUE)) return;
const q = JSON.parse(fs.readFileSync(QUEUE, 'utf8'));
const before = q.items.length;
q.items = q.items.filter(b => b.id !== item.id || b.batched_at);
if (q.items.length !== before) writeJsonAtomic(QUEUE, q);
}
for (const [name, col] of Object.entries(COLLECTIONS)) {
app.get('/api/' + name, (req, res) => {
try {
const doc = readJson(path.relative(__dirname, col.file));
sendClean(res, { ...doc, items: listFrom(doc, req.query) });
} catch (err) {
res.status(500).json({ error: `could not read ${path.basename(col.file)}`, detail: neutralText(err.message) });
}
});
for (const action of ['approve', 'delete', 'restore']) {
app.post(`/api/${name}/${action}`, loopbackOnly, (req, res) => {
try {
const ids = Array.isArray(req.body && req.body.ids) ? [...new Set(req.body.ids.map(String))] : [];
if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
const doc = JSON.parse(fs.readFileSync(col.file, 'utf8'));
const byId = new Map(doc.items.map(i => [i.id, i]));
const unknown = ids.filter(id => !byId.has(id));
if (unknown.length) return res.status(404).json({ error: 'unknown ids', ids: unknown });
const now = new Date().toISOString();
const changed = [];
for (const id of ids) {
const it = byId.get(id);
const next = action === 'approve' ? 'approved' : action === 'delete' ? 'deleted' : 'pending';
if (it.status === next) continue;
if (action === 'approve' && it.status === 'deleted') continue; // restore first
if (action === 'approve' && col.onApprove) col.onApprove(it);
if (action === 'delete' && col.onDelete) col.onDelete(it);
if (action === 'restore' && col.onRestore) col.onRestore(it);
it.status = next;
it.status_at = now;
changed.push(id);
}
writeJsonAtomic(col.file, doc);
fs.appendFileSync(ACTIONS, JSON.stringify({ ts: now, collection: name, action, ids, changed }) + '\n');
sendClean(res, { ok: true, action, changed, items: doc.items });
} catch (err) {
res.status(500).json({ error: `${action} failed`, detail: neutralText(err.message) });
}
});
}
}
// ---- Article links (TK-12247): each Shadow Man piece can be linked to ONE P24 story ---------------
// GET /api/stories lists the P24 story set by running the P24 repo's own daily-cartoons/extract_stories.mjs
// (read-only, sandboxed vm parse; P24_DIR overrides the checkout, default ~/Projects/crazy-news-channel).
// POST /api/shadowman/link {id, story_id|null} links / unlinks by hand (match_by:"manual"); loopback-only
// like the other curation mutations, and the story id must exist in that same story list.
const P24_BASE = (process.env.P24_BASE || 'http://127.0.0.1:9934/').replace(/\/?$/, '/');
let storyCache = { at: 0, list: null };
async function stories() {
if (storyCache.list && Date.now() - storyCache.at < 60000) return storyCache.list;
const lib = await import('./scripts/lib-stories.mjs');
const dir = lib.p24Dir();
const taken = new Map();
for (const c of lib.readManifest(dir)) if (c.story_id && !String(c.id).startsWith('shadowman-')) taken.set(c.story_id, c.title);
const list = lib.loadStories(dir).map(s => ({
id: s.id, source: s.source, source_label: lib.storySourceLabel(s), headline: s.headline, summary: s.summary,
tags: s.tags, url: lib.storyUrl(s), published_at: s.publishedAt, has_cartoon: taken.has(s.id) ? taken.get(s.id) : null,
}));
storyCache = { at: Date.now(), list };
return list;
}
app.get('/api/stories', async (req, res) => {
try {
const all = await stories();
const q = String(req.query.q || '').trim().toLowerCase();
const words = q ? q.split(/\s+/) : [];
const items = words.length ? all.filter(s => { const hay = [s.id, s.headline, s.summary, s.source_label, ...(s.tags || [])].join(' ').toLowerCase(); return words.every(w => hay.includes(w)); }) : all;
sendClean(res, { p24_base: P24_BASE, total: all.length, count: items.length, items });
} catch (err) {
res.status(500).json({ error: 'could not list P24 stories', detail: neutralText(err.message) });
}
});
app.post('/api/shadowman/link', loopbackOnly, async (req, res) => {
try {
const id = req.body && typeof req.body.id === 'string' ? req.body.id : '';
const sid = req.body && req.body.story_id;
if (!id) return res.status(400).json({ error: 'id required' });
if (sid !== null && typeof sid !== 'string') return res.status(400).json({ error: 'story_id must be a string or null' });
const file = COLLECTIONS.shadowman.file;
const doc = JSON.parse(fs.readFileSync(file, 'utf8'));
const it = doc.items.find(i => i.id === id);
if (!it) return res.status(404).json({ error: 'unknown id', id });
let story = null;
if (sid !== null) {
story = (await stories()).find(s => s.id === sid);
if (!story) return res.status(400).json({ error: 'unknown story_id', story_id: sid });
}
const now = new Date().toISOString();
const before = it.story_id || null;
Object.assign(it, {
story_id: story ? story.id : null,
story_title: story ? story.headline : null,
story_url: story ? story.url : null,
story_source: story ? story.source_label : null,
match_score: null,
match_reason: story ? 'Linked by hand in Inkwell' : 'Unlinked by hand in Inkwell',
match_by: 'manual',
matched_at: now,
});
writeJsonAtomic(file, doc);
fs.appendFileSync(ACTIONS, JSON.stringify({ ts: now, collection: 'shadowman', action: story ? 'link' : 'unlink', ids: [id], story_id: it.story_id, previous_story_id: before }) + '\n');
sendClean(res, { ok: true, action: story ? 'link' : 'unlink', id, story_id: it.story_id, items: doc.items });
} catch (err) {
res.status(500).json({ error: 'link failed', detail: neutralText(err.message) });
}
});
app.get('/health', (req, res) => res.json({ ok: true, app: 'inkwell' }));
app.listen(PORT, () => {
console.log(`Inkwell — Editorial Cartoon Archive running at http://localhost:${PORT}`);
});