[object Object]

← back to Crazy News Channel

p24 review queue: article picker for cards with no story_id (TK-12243)

432e63e47356afaa5ea95e55b8461362704a9fa5 · 2026-09-25 11:16:19 -0700 · Steve

Pre-article-pipeline queue items have no story_id, so Approve failed with
approve.py's "--story-id required". Live cards without one now show a
"Link an article" picker above Approve/Delete: up to 3 keyword-overlap
suggestions ($0, no LLM) + typeahead search over every extract_stories.mjs
article, grouped Real news / P24. Approve sends {date,slug,story_id};
bulk Approve N refuses inline and lists the cards missing an article.

Server: GET /api/stories; /api/approve accepts optional story_id, validates
it against the extracted stories (unknown/malformed -> per-item error),
persists story_id/title/url/source into meta.json before running
approve.py --story-id. extract_stories.mjs honours P24_SITE_DIR.
CSS: scoped rq-picker rules; overflow-x:clip on the queue page so the
sticky bulk bar actually pins to the viewport. Static review.html markup
unchanged (picker is live-only).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq

Files touched

Diff

commit 432e63e47356afaa5ea95e55b8461362704a9fa5
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Sep 25 11:16:19 2026 -0700

    p24 review queue: article picker for cards with no story_id (TK-12243)
    
    Pre-article-pipeline queue items have no story_id, so Approve failed with
    approve.py's "--story-id required". Live cards without one now show a
    "Link an article" picker above Approve/Delete: up to 3 keyword-overlap
    suggestions ($0, no LLM) + typeahead search over every extract_stories.mjs
    article, grouped Real news / P24. Approve sends {date,slug,story_id};
    bulk Approve N refuses inline and lists the cards missing an article.
    
    Server: GET /api/stories; /api/approve accepts optional story_id, validates
    it against the extracted stories (unknown/malformed -> per-item error),
    persists story_id/title/url/source into meta.json before running
    approve.py --story-id. extract_stories.mjs honours P24_SITE_DIR.
    CSS: scoped rq-picker rules; overflow-x:clip on the queue page so the
    sticky bulk bar actually pins to the viewport. Static review.html markup
    unchanged (picker is live-only).
    
    Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01XizSgLiFuNbjY418z94Ewq
---
 assets/style.css                   |  39 ++++++++++++
 daily-cartoons/README.md           |  12 +++-
 daily-cartoons/build_review.py     | 127 ++++++++++++++++++++++++++++++++++---
 daily-cartoons/extract_stories.mjs |   3 +-
 daily-cartoons/review-server.mjs   |  53 ++++++++++++++--
 5 files changed, 217 insertions(+), 17 deletions(-)

diff --git a/assets/style.css b/assets/style.css
index ee6aa98..b1eab52 100644
--- a/assets/style.css
+++ b/assets/style.css
@@ -1089,6 +1089,10 @@ body.cartoon-queue-page .cmd {
    daily-cartoons/review-server.mjs (body[data-mode="live"]); the static review.html renders
    the same controls disabled (body[data-mode="static"]). */
 body.cartoon-queue-page [hidden] { display: none !important; }
+/* The site-wide overflow-x:hidden on html+body turns body into a (non-scrolling) scroll
+   container, so the sticky bulk bar pinned to body instead of the viewport and sat off-screen.
+   overflow-x:clip still clips horizontally but creates no scroll container. */
+html:has(body.cartoon-queue-page), body.cartoon-queue-page { overflow-x: clip; }
 body.cartoon-queue-page .rq-card { display: flex; flex-direction: column; position: relative; }
 body.cartoon-queue-page .rq-card .body { flex: 1; }
 body.cartoon-queue-page .rq-card.rq-selected { outline: 4px solid var(--focus); outline-offset: -4px; }
@@ -1140,6 +1144,41 @@ body.cartoon-queue-page .rq-toast {
   display: flex; align-items: center; gap: 12px; padding: 10px 14px; max-width: calc(100% - 32px);
   background: var(--text); color: var(--bg); border: 2px solid var(--ink); font-weight: 700;
 }
+body.cartoon-queue-page .rq-bulk-err {
+  order: -1; flex: 1 1 100%; margin: 0; padding: 8px 10px; background: var(--panel); border: 3px solid #b3261e;
+  color: #b3261e; font-weight: 700; font-size: .95rem;
+}
+/* Article picker (rq-picker): shown above Approve/Delete on queue cards with no story_id. */
+body.cartoon-queue-page .rq-picker {
+  display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 3px dashed #b3261e; background: var(--bg);
+}
+body.cartoon-queue-page .rq-picker.rq-picked { border-style: solid; border-color: #13804a; }
+body.cartoon-queue-page .rq-picker-h { margin: 0; font-weight: 700; }
+body.cartoon-queue-page .rq-req { color: #b3261e; font-weight: 400; font-size: .85rem; }
+body.cartoon-queue-page .rq-sugg-h, body.cartoon-queue-page .rq-pk-l { margin: 4px 0 0; font-size: .8rem; font-weight: 700; color: var(--text-dim); }
+body.cartoon-queue-page .rq-sugg { display: flex; flex-direction: column; gap: 6px; }
+body.cartoon-queue-page .rq-sugg-none { margin: 0; font-size: .85rem; color: var(--text-dim); }
+body.cartoon-queue-page .rq-sugg-none.rq-pk-bad { color: #b3261e; font-weight: 700; }
+body.cartoon-queue-page .rq-sugg-btn {
+  display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; min-height: 48px;
+  padding: 6px 10px; text-align: left; font: 600 .9rem/1.25 inherit; background: var(--panel); color: var(--text);
+  border: 2px solid var(--ink); cursor: pointer;
+}
+body.cartoon-queue-page .rq-sugg-btn:hover { background: var(--chrome); }
+body.cartoon-queue-page .rq-sugg-btn[aria-pressed="true"] { border-color: #13804a; box-shadow: inset 0 0 0 2px #13804a; }
+body.cartoon-queue-page .rq-sugg-t { min-width: 0; }
+body.cartoon-queue-page .rq-sugg-src { flex: none; }
+body.cartoon-queue-page .rq-pk-q {
+  width: 100%; min-height: 48px; padding: 0 10px; border: 2px solid var(--ink); background: var(--panel); color: var(--text);
+}
+body.cartoon-queue-page .rq-pk-s {
+  width: 100%; min-height: 150px; padding: 2px; border: 2px solid var(--ink); background: var(--panel); color: var(--text); font-size: .9rem;
+}
+body.cartoon-queue-page .rq-pk-s option { padding: 4px 6px; }
+body.cartoon-queue-page .rq-sugg-btn:focus-visible, body.cartoon-queue-page .rq-pk-q:focus-visible,
+body.cartoon-queue-page .rq-pk-s:focus-visible { outline: 3px solid var(--focus); outline-offset: 2px; }
+body.cartoon-queue-page .rq-pk-chosen { margin: 0; font-size: .85rem; color: var(--text-dim); }
+body.cartoon-queue-page .rq-pk-chosen.rq-pk-ok { color: #13804a; font-weight: 700; }
 
 /* ---- body.cartoon-page-simple: the minimal single-image layout emitted
    by daily-cartoons/approve.py for every newly-approved cartoon page (TK-
diff --git a/daily-cartoons/README.md b/daily-cartoons/README.md
index 8988982..1fb1578 100644
--- a/daily-cartoons/README.md
+++ b/daily-cartoons/README.md
@@ -59,14 +59,20 @@ review, Ctrl-C when done. It does not touch the `p24` site server on :9934.
 
 - Every card has a **Select** checkbox and two big full-width buttons below it: **Approve** (green)
   and **Delete** (red). Selecting ≥1 card shows a sticky bulk bar: *Approve N · Delete N · Select all · Clear*.
-- **Approve** runs `approve.py <slug> --date <date>` per item (execFile, sequential — no shell), so it
-  needs a `story_id` in meta.json (pre-TK-12237 items show the approve.py error on the card instead).
+- **Approve** runs `approve.py <slug> --date <date> --story-id <id>` per item (execFile, sequential — no
+  shell). Cards whose meta.json has no `story_id` (pre-TK-12237) show a **Link an article** picker above
+  the buttons: up to 3 keyword-overlap suggestions ($0, no LLM) + a typeahead search over every
+  `extract_stories.mjs` article, grouped Real news / P24. Approve sends the chosen `story_id`; the server
+  validates it against the extracted stories (unknown → per-item error), writes
+  `story_id/story_title/story_url/story_source` into meta.json (as generate.py does), then runs approve.py.
+  Single Approve with no article chosen → inline card error; bulk **Approve N** refuses and lists the cards.
   Approved cards show **Approved ✓** and cannot be approved twice (UI + server both refuse).
+- `GET /api/stories` (auth) → `{count, stories:[{id, source, headline, tags, sourceName, sourceUrl, publishedAt}]}`.
 - **Delete** asks in an in-page dialog, then **moves** `queue/<date>/<slug>/` →
   `queue/_trash/<date>/<slug>/` (never `rm`). The toast's **Undo** moves it back (`POST /api/restore`);
   manual undo = `mv daily-cartoons/queue/_trash/<date>/<slug> daily-cartoons/queue/<date>/`.
   `generate.py` / `build_review.py` skip `queue/_trash`.
-- API: `POST /api/approve|delete|restore` with `{"items":[{"date":"YYYY-MM-DD","slug":"a-z0-9-"}]}`
+- API: `POST /api/approve|delete|restore` with `{"items":[{"date":"YYYY-MM-DD","slug":"a-z0-9-","story_id":"optional (approve)"}]}`
   (JSON content-type required; date/slug strictly validated, traversal → 400). Every action is appended
   to `daily-cartoons/logs/review-actions.jsonl` (`{ts, action, items, results}`).
 - The page is rendered live per request by `build_review.py --live --stdout` — the SAME template as
diff --git a/daily-cartoons/build_review.py b/daily-cartoons/build_review.py
index e4d20b6..dee035a 100755
--- a/daily-cartoons/build_review.py
+++ b/daily-cartoons/build_review.py
@@ -42,10 +42,31 @@ def card(m, live):
     approved = status == "approved"
     dis = "" if live else " disabled"
     title = e(m.get("title"))
+    # Article picker (live only): cards made before the article pipeline have no story_id, and
+    # approve.py refuses those. The picker sits ABOVE Approve/Delete; review-server validates +
+    # persists the chosen story_id into meta.json before running approve.py --story-id.
+    needs_story = live and not approved and not story_id
+    if needs_story:
+        story_html = '<p class="src no-story">no linked article yet — pick one in “Link an article” below to approve</p>'
+    picker = ""
+    if needs_story:
+        pid = f'pk-{m["_date"]}-{m["_slug"]}'
+        picker = (f'<div class="rq-picker" role="group" aria-labelledby="{pid}-h" '
+                  f'data-kw-a="{e(m.get("title"))} {e(m.get("caption"))}" data-kw-b="{e(m.get("image_prompt"))}">\n'
+                  f'    <p class="rq-picker-h" id="{pid}-h">Link an article <span class="rq-req">(required to approve)</span></p>\n'
+                  f'    <p class="rq-sugg-h" id="{pid}-sh">Suggested</p>\n'
+                  f'    <div class="rq-sugg" role="group" aria-labelledby="{pid}-sh"><p class="rq-sugg-none">Loading articles…</p></div>\n'
+                  f'    <label class="rq-pk-l" for="{pid}-q">Search all articles (headline or tag)</label>\n'
+                  f'    <input type="search" class="rq-pk-q" id="{pid}-q" autocomplete="off" spellcheck="false" '
+                  f'placeholder="e.g. senate, algorithm, bubble" aria-controls="{pid}-s">\n'
+                  f'    <label class="rq-pk-l" for="{pid}-s">Article</label>\n'
+                  f'    <select class="rq-pk-s" id="{pid}-s" size="6"></select>\n'
+                  f'    <p class="rq-pk-chosen" aria-live="polite">No article chosen yet.</p>\n'
+                  f'  </div>\n    ')
     appr_btn = (f'<button type="button" class="rq-btn rq-approve" data-act="approve" disabled aria-disabled="true">Approved ✓</button>'
                 if approved else
                 f'<button type="button" class="rq-btn rq-approve" data-act="approve"{dis} aria-label="Approve &ldquo;{title}&rdquo;">Approve</button>')
-    return f'''<article class="card rq-card{' rq-approved' if approved else ''}" data-date="{e(m["_date"])}" data-slug="{e(m["_slug"])}" data-status="{e(status)}" data-created="{e(m.get("created_at"))}" data-title="{title}" data-cost="{m.get("cost",{}).get("total",0)}">
+    return f'''<article class="card rq-card{' rq-approved' if approved else ''}{' rq-needs-story' if needs_story else ''}" data-date="{e(m["_date"])}" data-slug="{e(m["_slug"])}" data-status="{e(status)}" data-created="{e(m.get("created_at"))}" data-title="{title}" data-cost="{m.get("cost",{}).get("total",0)}">
   <label class="rq-select"><input type="checkbox" class="rq-check"{dis} aria-label="Select &ldquo;{title}&rdquo;"> <span>Select</span></label>
   {media}
   <div class="body">
@@ -59,7 +80,7 @@ def card(m, live):
     <p class="rq-err" role="alert" hidden></p>
   </div>
   <div class="rq-actions">
-    {appr_btn}
+    {picker}{appr_btn}
     <button type="button" class="rq-btn rq-delete" data-act="delete"{dis} aria-label="Delete &ldquo;{title}&rdquo;">Delete</button>
   </div>
 </article>'''
@@ -83,7 +104,10 @@ if(LIVE){
   const toast=document.getElementById('rq-toast'),tText=document.getElementById('rq-toast-text'),tUndo=document.getElementById('rq-toast-undo');
   const cards=()=>[...g.querySelectorAll('.rq-card')];
   const selected=()=>cards().filter(c=>c.querySelector('.rq-check').checked);
-  const key=c=>({date:c.dataset.date,slug:c.dataset.slug});
+  const bErr=document.getElementById('rq-bulk-err');
+  const key=c=>c.dataset.storyId?{date:c.dataset.date,slug:c.dataset.slug,story_id:c.dataset.storyId}:{date:c.dataset.date,slug:c.dataset.slug};
+  const needsStory=c=>c.classList.contains('rq-needs-story')&&!c.dataset.storyId&&c.dataset.status!=='approved';
+  function bulkErr(msg){bErr.textContent=msg||'';bErr.hidden=!msg}
   const byKey=it=>g.querySelector(`.rq-card[data-date="${it.date}"][data-slug="${it.slug}"]`);
   let busy=false,toastTimer=null,lastRemoved=[];
   function refresh(){
@@ -106,18 +130,36 @@ if(LIVE){
     return j.results||[];
   }
   function markErr(c,msg){const p=c.querySelector('.rq-err');p.textContent=msg;p.hidden=!msg}
-  function markApproved(c){
+  function showSource(c,r){
+    if(!r||!r.story_url)return;
+    const p=document.createElement('p');p.className='src';
+    const a=document.createElement('a');a.href=r.story_url;a.target='_blank';a.rel='noopener noreferrer';a.textContent='📰 '+(r.story_title||'linked article');
+    const s=document.createElement('span');s.className='chip';s.textContent=r.story_source||'';
+    p.append(a,' ',s);
+    const old=c.querySelector('.body .src');if(old)old.replaceWith(p);else c.querySelector('.body .cap').after(p);
+  }
+  function markApproved(c,r){
+    const pk=c.querySelector('.rq-picker');if(pk)pk.remove();
+    c.classList.remove('rq-needs-story');showSource(c,r);
     c.dataset.status='approved';c.classList.add('rq-approved');
     const st=c.querySelector('.rq-status');st.textContent='approved';st.className='chip rq-status st-approved';
     const b=c.querySelector('.rq-approve');b.textContent='Approved ✓';b.disabled=true;b.setAttribute('aria-disabled','true');b.removeAttribute('aria-label');
     c.querySelector('.rq-check').checked=false;markErr(c,'');
   }
-  async function approve(list){
+  async function approve(list,bulk){
     list=list.filter(c=>c.dataset.status!=='approved');if(!list.length||busy)return;
-    setBusy(true);
+    // Every cartoon must link a real article (TK-12158): refuse up front, name the cards, send nothing.
+    const missing=list.filter(needsStory);
+    if(missing.length){
+      missing.forEach(c=>markErr(c,'Choose an article above before approving.'));
+      if(bulk)bulkErr(`Not approved — ${missing.length} selected card${missing.length>1?'s have':' has'} no article chosen: `+missing.map(c=>'“'+c.dataset.title+'”').join(', ')+'. Pick an article on each (or unselect it), then Approve again.');
+      else{const q=missing[0].querySelector('.rq-pk-q');if(q)q.focus()}
+      return;
+    }
+    bulkErr('');setBusy(true);
     try{
       const res=await post('/api/approve',list.map(key));let ok=0,bad=0;
-      res.forEach(r=>{const c=byKey(r);if(!c)return;if(r.ok){markApproved(c);ok++}else{markErr(c,'Approve failed: '+(r.error||'unknown error'));bad++}});
+      res.forEach(r=>{const c=byKey(r);if(!c)return;if(r.ok){markApproved(c,r);ok++}else{markErr(c,'Approve failed: '+(r.error||'unknown error'));bad++}});
       showToast(`Approved ${ok}`+(bad?` · ${bad} failed (see card)`:''));
     }catch(e){showToast('Approve failed: '+e.message)}
     finally{setBusy(false)}
@@ -155,18 +197,84 @@ if(LIVE){
     if(ev.key==='Tab'){const f=[mNo,mOk];const i=f.indexOf(document.activeElement);ev.preventDefault();f[(i+(ev.shiftKey?f.length-1:1))%f.length].focus()}
   });
   modal.addEventListener('click',ev=>{if(ev.target===modal)closeModal()});
-  g.addEventListener('change',ev=>{if(ev.target.classList.contains('rq-check'))refresh()});
+  g.addEventListener('change',ev=>{if(ev.target.classList.contains('rq-check')){bulkErr('');refresh()}});
   g.addEventListener('click',ev=>{
     const b=ev.target.closest('.rq-actions .rq-btn');if(!b||b.disabled)return;const c=b.closest('.rq-card');
     if(b.dataset.act==='approve')approve([c]);else confirmDelete([c]);
   });
-  bA.onclick=()=>approve(selected());
+  bA.onclick=()=>approve(selected(),true);
   bD.onclick=()=>confirmDelete(selected());
   const selAll=()=>{cards().forEach(c=>c.querySelector('.rq-check').checked=true);refresh()};
   document.getElementById('rq-bulk-all').onclick=selAll;
   document.getElementById('rq-select-all').onclick=selAll;
   document.getElementById('rq-bulk-clear').onclick=()=>{cards().forEach(c=>c.querySelector('.rq-check').checked=false);refresh()};
   tUndo.onclick=undo;
+  // ---- Article picker for cards with no story_id. Suggestions = plain keyword overlap between the
+  // cartoon (title+caption weighted 3, image prompt 1) and each story's headline+tags. $0, no LLM.
+  const STOP=new Set('the and for with from that this into over under after about are was were has have had its his her their they them than then what when where which who why how not but you your our out all any one two new more most very just also will can may off onto upon while says said via amid near per'.split(' '));
+  const STYLE=new Set('black white single panel editorial cartoon classic 1970s newspaper page style pen ink loose confident slightly jagged brush contour line lines dense directional crosshatching scratchboard like highlight highlights cut heavy solid figure figures invented anonymous caricature caricatures real person high contrast stark absurd drawn color colour gradient gradients render photorealism vector clip art text letter letters caption speech bubble bubbles watermark signature hand made'.split(' '));
+  const stem=w=>w.length>4&&w.endsWith('s')&&!w.endsWith('ss')?w.slice(0,-1):w;
+  const toks=(s,extra)=>new Set((String(s||'').toLowerCase().match(/[a-z0-9]+/g)||[]).filter(w=>w.length>=3&&!STOP.has(w)&&!(extra&&extra.has(w))).map(stem).filter(w=>!(extra&&extra.has(w))));
+  const srcLabel=s=>s.source==='real-news'?(s.sourceName||'Real news'):'P24';
+  let STORIES=[],SBYID=new Map();
+  function score(pk,s){
+    const A=pk._a||(pk._a=toks(pk.dataset.kwA)),B=pk._b||(pk._b=toks(pk.dataset.kwB,STYLE));
+    // Outlet names ride along in real-news tags ("the new york times" would match a caption's
+    // "time"), so drop source-name tokens; a headline hit counts double a tag-only hit.
+    const src=toks(s.sourceName),H=toks(s.headline,src),T=toks((s.tags||[]).join(' '),src);
+    let n=0;new Set([...H,...T]).forEach(t=>{const w=(A.has(t)?3:B.has(t)?1:0)*(H.has(t)?2:1);n+=w});return n;
+  }
+  function choose(c,id){
+    const s=SBYID.get(id);if(!s)return;const pk=c.querySelector('.rq-picker');if(!pk)return;
+    c.dataset.storyId=id;
+    const sel=pk.querySelector('.rq-pk-s');if([...sel.options].some(o=>o.value===id))sel.value=id;
+    pk.querySelectorAll('.rq-sugg-btn').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.id===id)));
+    const ch=pk.querySelector('.rq-pk-chosen');ch.textContent='Linked: '+s.headline+' · '+srcLabel(s);ch.classList.add('rq-pk-ok');
+    pk.classList.add('rq-picked');markErr(c,'');
+    const cmd=c.querySelector('.cmd');if(cmd)cmd.textContent=`python3 daily-cartoons/approve.py ${c.dataset.slug} --date ${c.dataset.date} --story-id ${id}`;
+    if(!bErr.hidden&&!selected().some(needsStory))bulkErr('');
+  }
+  function fill(pk){
+    const c=pk.closest('.rq-card'),sel=pk.querySelector('.rq-pk-s');
+    const terms=pk.querySelector('.rq-pk-q').value.toLowerCase().split(/\s+/).filter(Boolean);
+    const hit=s=>{const h=(s.headline+' '+(s.tags||[]).join(' ')+' '+(s.sourceName||'')+' '+s.id).toLowerCase();return terms.every(t=>h.includes(t))};
+    sel.textContent='';let n=0;
+    [['real-news','Real news'],['p24','P24 stories']].forEach(([src,label])=>{
+      const list=STORIES.filter(s=>s.source===src&&hit(s));if(!list.length)return;
+      const og=document.createElement('optgroup');og.label=`${label} (${list.length})`;
+      list.forEach(s=>{const o=document.createElement('option');o.value=s.id;o.textContent=s.headline+' — '+srcLabel(s);o.title=s.headline+' — '+srcLabel(s)+' ['+s.id+']';og.appendChild(o);n++});
+      sel.appendChild(og);
+    });
+    if(!n){const o=document.createElement('option');o.disabled=true;o.textContent='No articles match “'+terms.join(' ')+'”';sel.appendChild(o)}
+    if(c.dataset.storyId&&[...sel.options].some(o=>o.value===c.dataset.storyId))sel.value=c.dataset.storyId;
+    return n;
+  }
+  function initPicker(pk){
+    const c=pk.closest('.rq-card'),q=pk.querySelector('.rq-pk-q'),sel=pk.querySelector('.rq-pk-s'),box=pk.querySelector('.rq-sugg');
+    const top=STORIES.map(s=>({s,n:score(pk,s)})).filter(x=>x.n>0).sort((a,b)=>b.n-a.n||(b.s.publishedAt||'').localeCompare(a.s.publishedAt||'')).slice(0,3);
+    box.textContent='';
+    if(!top.length){const p=document.createElement('p');p.className='rq-sugg-none';p.textContent='No keyword matches — search below.';box.appendChild(p)}
+    top.forEach(({s})=>{
+      const b=document.createElement('button');b.type='button';b.className='rq-sugg-btn';b.dataset.id=s.id;b.setAttribute('aria-pressed','false');
+      const h=document.createElement('span');h.className='rq-sugg-t';h.textContent=s.headline;
+      const src=document.createElement('span');src.className='chip rq-sugg-src';src.textContent=srcLabel(s);
+      b.append(h,src);b.onclick=()=>choose(c,s.id);box.appendChild(b);
+    });
+    fill(pk);
+    q.addEventListener('input',()=>fill(pk));
+    q.addEventListener('keydown',ev=>{
+      if(ev.key==='ArrowDown'){ev.preventDefault();const o=[...sel.options].find(x=>!x.disabled);if(o&&!sel.value)sel.value=o.value;sel.focus()}
+      if(ev.key==='Enter'){ev.preventDefault();const o=[...sel.options].find(x=>!x.disabled);if(o)choose(c,o.value)}
+    });
+    sel.addEventListener('change',()=>{if(sel.value)choose(c,sel.value)});
+    sel.addEventListener('keydown',ev=>{if(ev.key==='Enter'&&sel.value){ev.preventDefault();choose(c,sel.value)}});
+  }
+  const pickers=[...g.querySelectorAll('.rq-picker')];
+  if(pickers.length){
+    fetch('/api/stories').then(r=>r.ok?r.json():Promise.reject(new Error('HTTP '+r.status))).then(j=>{
+      STORIES=j.stories||[];SBYID=new Map(STORIES.map(s=>[s.id,s]));pickers.forEach(initPicker);
+    }).catch(e=>pickers.forEach(pk=>{const p=pk.querySelector('.rq-sugg-none');if(p){p.textContent='Could not load articles: '+e.message;p.classList.add('rq-pk-bad')}}));
+  }
   refresh();
 }
 </script>'''
@@ -187,6 +295,7 @@ def render(live=False):
   <button type="button" class="rq-btn rq-delete" id="rq-bulk-delete">Delete 0</button>
   <button type="button" class="rq-mini" id="rq-bulk-all">Select all</button>
   <button type="button" class="rq-mini" id="rq-bulk-clear">Clear</button>
+  <p class="rq-bulk-err" id="rq-bulk-err" role="alert" hidden></p>
 </div>
 <div class="rq-modal" id="rq-modal" role="dialog" aria-modal="true" aria-labelledby="rq-modal-title" hidden>
   <div class="rq-modal-box"><h2 id="rq-modal-title">Delete cartoon?</h2><p id="rq-modal-text"></p>
diff --git a/daily-cartoons/extract_stories.mjs b/daily-cartoons/extract_stories.mjs
index ea07661..06e9d17 100644
--- a/daily-cartoons/extract_stories.mjs
+++ b/daily-cartoons/extract_stories.mjs
@@ -18,7 +18,8 @@ import vm from 'node:vm';
 import { fileURLToPath } from 'node:url';
 
 const HERE = path.dirname(fileURLToPath(import.meta.url));
-const SITE = path.resolve(HERE, '..');
+// Mirror common.py: P24_SITE_DIR (tests / review-server against a COPY) reads THAT copy's data.
+const SITE = process.env.P24_SITE_DIR ? path.resolve(process.env.P24_SITE_DIR) : path.resolve(HERE, '..');
 
 const ctx = { window: {}, console };
 vm.createContext(ctx);
diff --git a/daily-cartoons/review-server.mjs b/daily-cartoons/review-server.mjs
index 109da2f..0d6b736 100644
--- a/daily-cartoons/review-server.mjs
+++ b/daily-cartoons/review-server.mjs
@@ -8,7 +8,10 @@
 // LOCAL ONLY: binds 127.0.0.1, Basic Auth. Renders the review page live from the queue via
 // build_review.py --live --stdout (same template as the static review.html), serves
 // assets/style.css + queue media, and exposes:
-//   POST /api/approve {items:[{date,slug}]} → runs approve.py per item (execFile, no shell)
+//   GET  /api/stories                       → every article from extract_stories.mjs (the picker's list)
+//   POST /api/approve {items:[{date,slug,story_id?}]} → per item: if story_id is given, validate it
+//        against extract_stories.mjs, persist story_id/story_title/story_url/story_source into
+//        meta.json (like generate.py does), then run approve.py --story-id (execFile, no shell)
 //   POST /api/delete  {items:[...]}         → MOVES queue/<date>/<slug> → queue/_trash/<date>/<slug> (never rm)
 //   POST /api/restore {items:[...]}         → moves it back
 // Every action is appended to daily-cartoons/logs/review-actions.jsonl. Nothing deploys.
@@ -37,6 +40,7 @@ const MAX_BODY = 64 * 1024;
 
 const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
 const SLUG_RE = /^[a-z0-9-]{1,120}$/;
+const STORY_RE = /^[A-Za-z0-9._:-]{1,200}$/;
 
 class HttpError extends Error { constructor(code, msg) { super(msg); this.code = code; } }
 
@@ -59,11 +63,31 @@ function parseItems(body) {
   for (const it of body.items) {
     itemPath(QUEUE, it); // throws 400 on anything invalid — whole request is rejected
     const k = `${it.date}/${it.slug}`;
-    if (!seen.has(k)) { seen.add(k); out.push({ date: it.date, slug: it.slug }); }
+    if (it.story_id !== undefined && it.story_id !== null && typeof it.story_id !== 'string')
+      throw new HttpError(400, `story_id must be a string (${k})`);
+    if (!seen.has(k)) { seen.add(k); out.push({ date: it.date, slug: it.slug, ...(it.story_id ? { story_id: it.story_id } : {}) }); }
   }
   return out;
 }
 
+// Article list for the picker + story_id validation. Same extractor generate.py uses (sandboxed
+// vm parse of real-news-data.js / stories-data.js), cached until either data file changes.
+let storyCache = { key: null, list: null };
+async function loadStories() {
+  const files = ['real-news-data.js', 'stories-data.js'].map((f) => path.join(SITE, f));
+  const key = (await Promise.all(files.map((f) => fsp.stat(f).then((st) => `${st.mtimeMs}:${st.size}`, () => '-')))).join('|');
+  if (storyCache.key === key && storyCache.list) return storyCache.list;
+  const r = await run(process.execPath, [path.join(HERE, 'extract_stories.mjs')]);
+  if (r.code !== 0) throw new HttpError(500, 'extract_stories.mjs failed: ' + (r.stderr.trim().split('\n').pop() || `exit ${r.code}`));
+  let list;
+  try { list = JSON.parse(r.stdout); } catch { throw new HttpError(500, 'extract_stories.mjs returned invalid JSON'); }
+  if (!Array.isArray(list)) throw new HttpError(500, 'extract_stories.mjs did not return an array');
+  storyCache = { key, list };
+  return list;
+}
+// Mirrors generate.py story_url(): real news links out, P24 stories link to the on-site article.
+const storyUrl = (a) => (a.source === 'real-news' ? a.sourceUrl || null : `index.html#/article/${a.id}`);
+
 const exists = (p) => fsp.access(p).then(() => true, () => false);
 const readMeta = async (dir) => JSON.parse(await fsp.readFile(path.join(dir, 'meta.json'), 'utf8'));
 
@@ -83,7 +107,22 @@ async function approveOne(it) {
   if (!(await exists(path.join(dir, 'meta.json')))) return { ...it, ok: false, error: 'not in queue' };
   const before = await readMeta(dir);
   if (before.status === 'approved') return { ...it, ok: false, already: true, error: 'already approved' };
-  const r = await run(PY, [path.join(HERE, 'approve.py'), it.slug, '--date', it.date]);
+  let storyId = before.story_id || null;
+  if (it.story_id) {
+    if (!STORY_RE.test(it.story_id)) return { ...it, ok: false, error: `invalid story_id: ${JSON.stringify(it.story_id)}` };
+    const art = (await loadStories()).find((a) => a.id === it.story_id);
+    if (!art) return { ...it, ok: false, error: `unknown story_id: ${it.story_id} (not in real-news-data.js / stories-data.js)` };
+    // Persist the link BEFORE approve.py runs, so approve.py renders the Source link from meta.json.
+    const m = { ...before, story_id: art.id, story_title: art.headline, story_url: storyUrl(art), story_source: art.source,
+      story_linked_by: 'review-server', story_linked_at: new Date().toISOString(),
+      ...(before.story_id && before.story_id !== art.id ? { story_id_previous: before.story_id } : {}) };
+    const mp = path.join(dir, 'meta.json'), tmp = mp + '.tmp';
+    await fsp.writeFile(tmp, JSON.stringify(m, null, 2));
+    await fsp.rename(tmp, mp);
+    storyId = art.id;
+  }
+  if (!storyId) return { ...it, ok: false, error: 'no linked article — choose one in the article picker first' };
+  const r = await run(PY, [path.join(HERE, 'approve.py'), it.slug, '--date', it.date, '--story-id', storyId]);
   const output = (r.stdout + r.stderr).trim().slice(-2000);
   if (r.code !== 0) return { ...it, ok: false, error: (r.stderr.trim().split('\n').pop() || `approve.py exit ${r.code}`), output };
   const m = await readMeta(dir);
@@ -91,7 +130,8 @@ async function approveOne(it) {
     m.status = 'approved'; m.approved_at = m.approved_at || new Date().toISOString();
     await fsp.writeFile(path.join(dir, 'meta.json'), JSON.stringify(m, null, 2));
   }
-  return { ...it, ok: true, site_file: m.site_file || null, output };
+  return { ...it, ok: true, site_file: m.site_file || null, story_id: m.story_id || storyId,
+    story_title: m.story_title || null, story_url: m.story_url || null, story_source: m.story_source || null, output };
 }
 
 async function moveDir(src, dst) {
@@ -205,6 +245,11 @@ async function handle(req, res) {
   }
 
   if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, { error: 'method not allowed' });
+  if (p === '/api/stories') {
+    const list = await loadStories();
+    return send(res, 200, { count: list.length, stories: list.map(({ id, source, headline, tags, sourceName, sourceUrl, publishedAt }) =>
+      ({ id, source, headline, tags, sourceName, sourceUrl, publishedAt })) });
+  }
   if (p === '/' || p === '/daily-cartoons' || p === '/daily-cartoons/') return send(res, 302, '', 'text/plain', { Location: '/daily-cartoons/review.html' });
   if (p === '/daily-cartoons/review.html') return send(res, 200, await renderLive(), 'text/html; charset=utf-8');
   if (p === '/assets/style.css') return sendFile(req, res, path.join(SITE, 'assets', 'style.css'));

← 43607e2 p24: publish 13 approved Shadow Man cartoons to the Cartoon  ·  back to Crazy News Channel  ·  auto-data-snapshot: 2026-09-25T11:23:40 (3 data files) — .cl 95c7f77 →