← back to Crazy News Channel
daily-cartoons/build_review.py
332 lines
#!/usr/bin/env python3
"""Render the P24 cartoon review queue from queue/<date>/<slug>/meta.json.
python3 daily-cartoons/build_review.py # write static review.html (read-only fallback)
python3 daily-cartoons/build_review.py --live --stdout # live markup for review-server.mjs
ONE template serves both modes (TK-12243). In the static file (opened via file://) the
select checkboxes and the Approve/Delete buttons are rendered DISABLED with a note to open
the page via review-server.mjs; in live mode (rendered per request by the server) they
call its /api/approve, /api/delete and /api/restore endpoints."""
import html, json, os, sys
import common as C
def items():
out = []
for d in C.queue_dates(): # skips queue/_trash
dp = os.path.join(C.QUEUE, d)
for s in sorted(os.listdir(dp)):
mp = os.path.join(dp, s, "meta.json")
if os.path.exists(mp):
m = json.load(open(mp)); m["_rel"] = f"queue/{d}/{s}"
m["_date"], m["_slug"] = d, s
m["_has_clip"] = os.path.exists(os.path.join(dp, s, "clip.mp4"))
m["_has_poster"] = os.path.exists(os.path.join(dp, s, "poster.jpg"))
out.append(m)
return out
def card(m, live):
e = lambda x: html.escape(str(x or ""))
media = (f'<video controls preload="none" poster="{e(m["_rel"])}/poster.jpg" src="{e(m["_rel"])}/clip.mp4"></video>'
if m["_has_clip"] else (f'<img src="{e(m["_rel"])}/poster.jpg" alt="">' if m["_has_poster"] else '<div class="nomedia">no media</div>'))
story_id = m.get("story_id")
cmd = (f'python3 daily-cartoons/approve.py {m["_slug"]} --date {m["_date"]}' + (f' --story-id {story_id}' if story_id
else ' --story-id <story-id>'))
story_html = (f'<p class="src"><a href="{e(m.get("story_url"))}" target="_blank" rel="noopener noreferrer">📰 {e(m.get("story_title"))}</a>'
f' <span class="chip">{e(m.get("story_source"))}</span></p>' if m.get("story_url")
else '<p class="src no-story">no linked article — pre-TK-12237 item, pass --story-id to approve</p>')
prompts = f'<p><b>image:</b> {e(m.get("image_prompt"))}</p>'
if m.get("motion_prompt"):
prompts += f'<p><b>motion:</b> {e(m.get("motion_prompt"))}</p>'
status = m.get("status") or "queued"
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 “{title}”">Approve</button>')
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 “{title}”"> <span>Select</span></label>
{media}
<div class="body">
<h2>{title}</h2>
<p class="cap">“{e(m.get("caption"))}”</p>
{story_html}
<div class="chips"><span class="chip when" title="{e(m.get("created_at"))}" data-iso="{e(m.get("created_at"))}">🕓 {e(m.get("created_at"))}</span>
<span class="chip">${m.get("cost",{}).get("total",0):.3f}</span><span class="chip rq-status st-{e(status)}">{e(status)}</span></div>
<details><summary>prompts</summary>{prompts}</details>
<code class="cmd" title="click to copy">{cmd}</code>
<p class="rq-err" role="alert" hidden></p>
</div>
<div class="rq-actions">
{picker}{appr_btn}
<button type="button" class="rq-btn rq-delete" data-act="delete"{dis} aria-label="Delete “{title}”">Delete</button>
</div>
</article>'''
# Plain (non-f) string so the JS needs no brace-doubling. Talks to review-server.mjs only
# when body[data-mode="live"]; in the static file every action control is disabled.
SCRIPT = r'''<script>
const g=document.getElementById('grid'),S=document.getElementById('sort'),D=document.getElementById('dens');
const LIVE=document.body.dataset.mode==='live';
const ls=(k,v)=>{try{return v===undefined?localStorage.getItem(k):localStorage.setItem(k,v)}catch(e){return null}};
document.querySelectorAll('.when').forEach(el=>{const d=new Date(el.dataset.iso);if(!isNaN(d))el.textContent='🕓 '+d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'})});
function sort(){const c=[...g.querySelectorAll('.card')],v=S.value;c.sort((a,b)=>v==='title'?a.dataset.title.localeCompare(b.dataset.title):v==='cost'?b.dataset.cost-a.dataset.cost:v==='old'?a.dataset.created.localeCompare(b.dataset.created):b.dataset.created.localeCompare(a.dataset.created));c.forEach(x=>g.appendChild(x));ls('p24q-sort',v)}
function dens(){g.style.setProperty('--min',D.value+'px');ls('p24q-dens',D.value)}
S.value=ls('p24q-sort')||'new';D.value=ls('p24q-dens')||340;S.onchange=sort;D.oninput=dens;sort();dens();
document.querySelectorAll('.cmd').forEach(el=>el.onclick=()=>navigator.clipboard&&navigator.clipboard.writeText(el.textContent));
if(LIVE){
const bar=document.getElementById('rq-bulk'),cnt=document.getElementById('rq-count');
const bA=document.getElementById('rq-bulk-approve'),bD=document.getElementById('rq-bulk-delete');
const modal=document.getElementById('rq-modal'),mText=document.getElementById('rq-modal-text');
const mOk=document.getElementById('rq-modal-ok'),mNo=document.getElementById('rq-modal-cancel');
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 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(){
const sel=selected(),toApprove=sel.filter(c=>c.dataset.status!=='approved');
cards().forEach(c=>c.classList.toggle('rq-selected',c.querySelector('.rq-check').checked));
bar.hidden=sel.length===0;
cnt.textContent=sel.length+' selected';
bA.textContent='Approve '+toApprove.length;bA.disabled=busy||toApprove.length===0;
bD.textContent='Delete '+sel.length;bD.disabled=busy||sel.length===0;
}
function setBusy(v){busy=v;document.body.classList.toggle('rq-busy',v);document.querySelectorAll('.rq-actions .rq-btn').forEach(b=>{if(!b.closest('.rq-approved')||b.dataset.act==='delete')b.disabled=v});refresh()}
function showToast(msg,undoItems){
tText.textContent=msg;tUndo.hidden=!(undoItems&&undoItems.length);toast.hidden=false;
clearTimeout(toastTimer);toastTimer=setTimeout(()=>{toast.hidden=true},undoItems&&undoItems.length?20000:8000);
}
async function post(path,items){
const r=await fetch(path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({items})});
let j={};try{j=await r.json()}catch(e){}
if(!r.ok)throw new Error(j.error||('HTTP '+r.status));
return j.results||[];
}
function markErr(c,msg){const p=c.querySelector('.rq-err');p.textContent=msg;p.hidden=!msg}
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,bulk){
list=list.filter(c=>c.dataset.status!=='approved');if(!list.length||busy)return;
// 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,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)}
}
async function del(list){
if(!list.length||busy)return;setBusy(true);
try{
const res=await post('/api/delete',list.map(key));const removed=[];let bad=0;
res.forEach(r=>{const c=byKey(r);if(!c)return;if(r.ok){removed.push({item:{date:r.date,slug:r.slug},el:c});c.remove()}else{markErr(c,'Delete failed: '+(r.error||'unknown error'));bad++}});
lastRemoved=removed;
showToast(`Deleted ${removed.length} (moved to queue/_trash)`+(bad?` · ${bad} failed`:''),removed.map(x=>x.item));
}catch(e){showToast('Delete failed: '+e.message)}
finally{setBusy(false)}
}
async function undo(){
const back=lastRemoved;if(!back.length||busy)return;setBusy(true);
try{
const res=await post('/api/restore',back.map(x=>x.item));let ok=0;
res.forEach(r=>{const x=back.find(b=>b.item.date===r.date&&b.item.slug===r.slug);if(r.ok&&x){x.el.querySelector('.rq-check').checked=false;g.appendChild(x.el);ok++}});
lastRemoved=[];sort();showToast(`Restored ${ok}`+(ok<back.length?` · ${back.length-ok} failed`:''));
}catch(e){showToast('Restore failed: '+e.message)}
finally{setBusy(false)}
}
let pending=null,lastFocus=null;
function confirmDelete(list){
if(!list.length)return;pending=list;lastFocus=document.activeElement;
mText.textContent=list.length===1?`Delete “${list[0].dataset.title}”? It moves to queue/_trash and can be undone.`:`Delete ${list.length} cartoons? They move to queue/_trash and can be undone.`;
modal.hidden=false;mNo.focus();
}
function closeModal(){modal.hidden=true;pending=null;if(lastFocus&&document.contains(lastFocus))lastFocus.focus()}
mNo.onclick=closeModal;
mOk.onclick=()=>{const l=pending;closeModal();del(l)};
modal.addEventListener('keydown',ev=>{
if(ev.key==='Escape'){ev.preventDefault();closeModal()}
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')){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(),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>'''
def render(live=False):
its = items()
its.sort(key=lambda m: m.get("created_at", ""), reverse=True)
total = sum(m.get("cost", {}).get("total", 0) for m in its)
mode = "live" if live else "static"
note = ('<p class="rq-mode-note">Live review — Approve copies into <code>cartoons/</code> locally (nothing deploys); '
'Delete moves to <code>queue/_trash</code> (undoable).</p>' if live else
'<p class="rq-mode-note rq-static-note">Read-only snapshot — open via review-server to act '
'(<code>node daily-cartoons/review-server.mjs</code>, see README).</p>')
sel_all = '<button type="button" class="rq-mini" id="rq-select-all">Select all</button>' if live else ''
live_ui = '''<div class="rq-bulk" id="rq-bulk" role="region" aria-label="Bulk actions" hidden>
<span class="rq-count" id="rq-count" aria-live="polite">0 selected</span>
<button type="button" class="rq-btn rq-approve" id="rq-bulk-approve">Approve 0</button>
<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>
<div class="rq-modal-btns"><button type="button" class="rq-btn rq-cancel" id="rq-modal-cancel">Cancel</button>
<button type="button" class="rq-btn rq-delete" id="rq-modal-ok">Delete</button></div></div>
</div>
<div class="rq-toast" id="rq-toast" role="status" aria-live="polite" hidden><span id="rq-toast-text"></span>
<button type="button" class="rq-mini" id="rq-toast-undo" hidden>Undo</button></div>''' if live else ''
# TK-12226: link ONLY the shared stylesheet (assets/style.css) — no
# per-page <style> block, no inline style attrs. Layout lives in style.css under
# body.cartoon-queue-page (review-queue action classes are rq-*).
return f'''<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>P24 Cartoon Queue</title>
<link rel="stylesheet" href="../assets/style.css"></head><body class="cartoon-queue-page" data-mode="{mode}">
<header><div><h1>🖋️ P24 Daily Cartoon Queue</h1>
<p class="note">{len(its)} queued · total spend ${total:.3f} · LOCAL review only — approving copies into <code>cartoons/</code> locally; nothing deploys.</p>
{note}</div>
<div class="ctrl">{sel_all}<label>Sort <select id="sort"><option value="new">Newest</option><option value="old">Oldest</option><option value="title">Title A→Z</option><option value="cost">Cost ↓</option></select></label>
<label>Size <input id="dens" type="range" min="220" max="640" step="20" value="340"></label></div></header>
<main id="grid">{"".join(card(m, live) for m in its) or "<p>Queue is empty — run daily-cartoons/run-daily.sh</p>"}</main>
{live_ui}
{SCRIPT}</body></html>'''
def build():
page = render(live=False)
open(os.path.join(C.DC, "review.html"), "w").write(page)
return page.count('class="card rq-card')
if __name__ == "__main__":
if "--stdout" in sys.argv:
sys.stdout.write(render(live="--live" in sys.argv))
else:
print("review.html:", build(), "items")