← back to Paul Conrad Cartoons
scripts/export-p24-shadowman.mjs
114 lines
#!/usr/bin/env node
// export-p24-shadowman.mjs — copy APPROVED Shadow Man pieces into the P24 site (TK-12241). $0, local.
// Usage: node scripts/export-p24-shadowman.mjs [--dest=~/Projects/crazy-news-channel] [--test-include-pending]
// Reads data/shadowman.json, keeps ONLY status === 'approved' (Steve approves in Inkwell), copies each
// jpg to <dest>/cartoons/shadow-man/<id>.jpg, removes stale jpgs there, and writes
// <dest>/cartoons/shadow-man-data.js (window.P24_SHADOW_MAN).
// TK-12247: every exported piece WITH a story_id (linked in Inkwell / by scripts/match-shadowman-articles.mjs)
// also gets a single-image cartoon page <dest>/cartoons/shadowman-<date>-<n>.html rendered from the P24
// repo's shared daily-cartoons/cartoon-page.template.html (the same template approve.py uses) and a
// manifest.js entry {id:"shadowman-…", story_id, …} so index.html shows it on that article's card.
// Idempotent: each run rewrites ONLY manifest entries whose id starts with "shadowman-" (drops the ones
// no longer linked / approved, e.g. after an unlink) and deletes stale cartoons/shadowman-*.html pages;
// every other manifest entry is left byte-for-byte as it was. Never touches the daily queue / Shorts.
// --test-include-pending exports pending pieces too; it exists only for verification on a scratch copy
// and must never be used against the real P24 checkout. --dest may also be given as P24_DIR.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadStories, storyCategories } from './lib-stories.mjs';
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
const args = Object.fromEntries(process.argv.slice(2).map(a => { const m = a.match(/^--([^=]+)=(.*)$/); return m ? [m[1], m[2]] : [a.replace(/^--/, ''), true]; }));
const DEST = path.resolve((args.dest || process.env.P24_DIR || '~/Projects/crazy-news-channel').replace(/^~/, process.env.HOME));
const NAME = new RegExp(['con', 'rad'].join(''), 'i');
const ok = (s) => s === 'approved' || (args['test-include-pending'] && s === 'pending');
// ---- Article pages + manifest entries (TK-12247) ----
const STORIES = new Map(loadStories(DEST).map(st => [st.id, st]));
const CATS = storyCategories(DEST);
const CART = path.join(DEST, 'cartoons');
const pageId = (id) => 'shadowman-' + String(id).replace(/^shadowman-/, '').replace(/^(\d{8})T\d{6}-/, '$1-');
// A stored story_url is either absolute (real-news outlet link) or P24-site-root-relative; pages live in cartoons/.
const fromCartoons = (u) => (/^https?:/i.test(u) ? u : '../' + u);
const escHtml = (s) => String(s == null ? '' : s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
const unquote = (s) => String(s || '').trim().replace(/^["“]+|["”]+$/g, '').trim();
function linkedStory(i) {
if (!i.story_id) return null;
const st = STORIES.get(i.story_id);
if (!st) { console.error(`WARN: ${i.id} links story_id "${i.story_id}" which is not in the P24 story list — page/manifest entry skipped`); return null; }
return st;
}
function articleFields(i) {
const st = linkedStory(i);
if (!st) return {};
return { story_id: st.id, story_title: i.story_title || st.headline, story_url: fromCartoons(i.story_url || (st.sourceUrl || `index.html#/article/${encodeURIComponent(st.id)}`)), page: `${pageId(i.id)}.html` };
}
function renderPage(fields) {
const tpl = fs.readFileSync(path.join(DEST, 'daily-cartoons', 'cartoon-page.template.html'), 'utf8').replace(/\n+$/, '');
return tpl.replace(/\{\{(\w+)\}\}/g, (_, k) => { if (!(k in fields)) throw new Error('template placeholder without value: ' + k); return fields[k]; });
}
// Python json.dumps(indent=2) (ensure_ascii) == JSON.stringify(indent 2) with non-ASCII as lowercase \uXXXX,
// so entries we do not own round-trip byte-for-byte.
const pyJson = (v) => JSON.stringify(v, null, 2).replace(/[\u007f-]/g, c => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
const doc = JSON.parse(fs.readFileSync(path.join(ROOT, 'data', 'shadowman.json'), 'utf8'));
const pick = doc.items.filter(i => ok(i.status || 'pending'));
const imgDir = path.join(DEST, 'cartoons', 'shadow-man');
fs.mkdirSync(imgDir, { recursive: true });
const keep = new Set();
const items = pick.map(i => {
const f = `${i.id}.jpg`;
fs.copyFileSync(path.join(ROOT, 'public', 'shadowman', f), path.join(imgDir, f));
keep.add(f);
return { id: i.id, title: i.title, caption: i.caption, theme: i.theme, era: i.era, created_at: i.created_at, signature: i.signature, src: `shadow-man/${f}`, ...articleFields(i) };
});
for (const f of fs.readdirSync(imgDir)) if (f.endsWith('.jpg') && !keep.has(f)) fs.unlinkSync(path.join(imgDir, f));
const js = `// Generated by paul-conrad-cartoons/scripts/export-p24-shadowman.mjs (TK-12241) — do not edit by hand.\n// Only pieces approved in the Inkwell curation UI are exported.\nwindow.P24_SHADOW_MAN = ${JSON.stringify({ exported_at: new Date().toISOString(), items }, null, 2)};\n`;
const safe = js.replace(/^\/\/ Generated by [^\n]*\n/, '// Generated by the Inkwell export script (TK-12241) — do not edit by hand.\n');
if (NAME.test(safe)) { console.error('FAIL: naming rule hit in export'); process.exit(1); }
fs.writeFileSync(path.join(DEST, 'cartoons', 'shadow-man-data.js'), safe);
// Pages + manifest entries for linked pieces.
const entries = [];
const keepPages = new Set();
for (const i of pick) {
const st = linkedStory(i);
if (!st) continue;
const pid = pageId(i.id);
const af = articleFields(i);
const src = `shadow-man/${i.id}.jpg`;
const html = renderPage({
title: escHtml(i.title),
media: `<img src="${escHtml(src)}" alt="${escHtml(i.title)}">`,
caption: escHtml(unquote(i.caption)),
source: `<p class="src"><a href="${escHtml(af.story_url)}" target="_blank" rel="noopener noreferrer">Source: ${escHtml(af.story_title)}</a></p>`,
credit: `Shadow Man — original AI-generated editorial cartoon, signed “Shadow Man”. Invented figures only.`,
});
if (NAME.test(html)) { console.error(`FAIL: naming rule hit in page ${pid}`); process.exit(1); }
fs.writeFileSync(path.join(CART, `${pid}.html`), html);
keepPages.add(`${pid}.html`);
const outlet = (st.sourceName || '').toLowerCase();
const storyTags = (st.tags || []).filter(t => String(t).toLowerCase() !== outlet);
const cat = CATS.get(st.id) || {};
entries.push({
id: pid, title: i.title, file: `${pid}.html`, created_at: i.created_at, category: 'Political Cartoon',
section: cat.category || 'politics', blurb: unquote(i.caption), thumb: src, story_id: st.id, ai_generated: true,
style_reference: 'shadow-man', tags: [...new Set([...storyTags, 'editorial cartoon', 'shadow man'])],
});
}
for (const f of fs.readdirSync(CART)) if (/^shadowman-.*\.html$/.test(f) && !keepPages.has(f)) fs.unlinkSync(path.join(CART, f));
const mf = path.join(CART, 'manifest.js');
const msrc = fs.readFileSync(mf, 'utf8');
const mm = msrc.match(/(window\.P24_CARTOONS\s*=\s*)(\[[\s\S]*\])(\s*;\s*)$/);
if (!mm) { console.error('FAIL: could not locate window.P24_CARTOONS array in manifest.js'); process.exit(1); }
const arr = JSON.parse(mm[2]);
const others = arr.filter(x => !String(x.id).startsWith('shadowman-'));
const next = [...others, ...entries];
const before = mm.index + mm[1].length;
const out = msrc.slice(0, before) + pyJson(next) + mm[3];
if (NAME.test(pyJson(entries))) { console.error('FAIL: naming rule hit in manifest entries'); process.exit(1); }
fs.writeFileSync(mf, out);
console.log(`manifest: ${others.length} other entries untouched, ${arr.length - others.length} old shadowman-* entries replaced by ${entries.length}; pages: ${[...keepPages].join(', ') || 'none'}`);
console.log(`exported ${items.length} ${args['test-include-pending'] ? 'approved+pending (TEST)' : 'approved'} pieces -> ${path.relative(process.env.HOME, DEST)}/cartoons/shadow-man/`);