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