58 lines
12 KiB
Python
58 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
import argparse, html, json
|
|
from pathlib import Path
|
|
|
|
TEMPLATE = r'''<!doctype html>
|
|
<html lang="it"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
<title>Revisione correzioni EPUB</title>
|
|
<style>
|
|
:root{--bg:#f5f7fb;--panel:#fff;--text:#172033;--muted:#667085;--line:#d9dee8;--blue:#175cd3;--green:#067647;--red:#b42318;--amber:#b54708}
|
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,Segoe UI,sans-serif}
|
|
header{position:sticky;top:0;z-index:4;background:#101828;color:white;padding:14px 20px;box-shadow:0 2px 12px #0003}
|
|
header h1{font-size:20px;margin:0 0 8px}.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.toolbar button,.toolbar select,.toolbar input{border:1px solid #98a2b3;border-radius:7px;padding:8px 10px;background:white;color:#101828}.toolbar button{cursor:pointer;font-weight:650}.toolbar .primary{background:#1570ef;color:white;border-color:#1570ef}.stats{margin-left:auto;font-weight:650}
|
|
main{max-width:1200px;margin:18px auto;padding:0 14px 80px}.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;margin:12px 0;overflow:hidden;box-shadow:0 1px 4px #1018280d}.card-head{display:grid;grid-template-columns:1fr auto;gap:10px;padding:13px 15px;border-bottom:1px solid var(--line)}.title{font-weight:750}.meta{color:var(--muted);font-size:13px}.prob{font:700 14px ui-monospace,monospace}.card-body{padding:15px}.change{font-size:18px;margin-bottom:10px}.old{color:var(--red);text-decoration:line-through}.new{color:var(--green);font-weight:750}.context{background:#f8fafc;border:1px solid #e4e7ec;border-radius:8px;padding:12px;white-space:pre-wrap}.mark-old{background:#fee4e2;color:#912018;padding:1px 3px;border-radius:3px}.mark-new{background:#d1fadf;color:#05603a;padding:1px 3px;border-radius:3px}.alts{margin-top:10px;color:var(--muted);font-size:13px}.actions{display:flex;gap:8px;padding:12px 15px;background:#fcfcfd;border-top:1px solid var(--line)}.actions button{border:1px solid var(--line);border-radius:7px;padding:8px 12px;background:white;cursor:pointer;font-weight:700}.approve.active{background:#d1fadf;border-color:#12b76a;color:#05603a}.reject.active{background:#fee4e2;border-color:#f04438;color:#912018}.skip.active{background:#fef0c7;border-color:#f79009;color:#93370d}.badge{display:inline-block;border-radius:999px;padding:2px 8px;font-size:12px;font-weight:700;background:#eef4ff;color:#3538cd}.hidden{display:none!important}.empty{text-align:center;color:var(--muted);padding:50px}.note,.rewrite{width:100%;margin-top:10px;border:1px solid var(--line);border-radius:7px;padding:8px}.rewrite{min-height:92px;font-family:inherit}.modal{position:fixed;inset:0;background:#101828aa;z-index:20;display:flex;align-items:center;justify-content:center;padding:20px}.modal-box{background:white;border-radius:12px;max-width:560px;width:100%;padding:20px}.modal-row{display:flex;gap:8px;margin-top:12px}.modal-row input{flex:1;padding:10px}
|
|
</style></head><body>
|
|
<header><h1>Revisione correzioni EPUB</h1><div class="toolbar">
|
|
<select id="decision"><option value="REVIEW_HIGH">REVIEW_HIGH</option><option value="AUTO_APPLY">AUTO_APPLY</option><option value="REVIEW_AMBIGUOUS">REVIEW_AMBIGUOUS</option><option value="ALL">Tutte</option></select>
|
|
<select id="status"><option value="pending">Da decidere</option><option value="all">Tutti gli stati</option><option value="approved">Approvati</option><option value="rejected">Rifiutati</option><option value="skipped">Rimandati</option></select>
|
|
<input id="search" placeholder="Cerca parola o contesto">
|
|
<button id="prev">← Precedente</button><button id="next">Successivo →</button>
|
|
<button id="addWord">+ Aggiungi nuova parola</button><button id="export" class="primary">Esporta approvazioni JSON</button><button id="save">Salva sessione</button><button id="reset">Azzera</button>
|
|
<span class="stats" id="stats"></span></div></header>
|
|
<main><div id="cards"></div><div id="empty" class="empty hidden">Nessun caso con questi filtri.</div></main><div id="wordModal" class="modal hidden"><div class="modal-box"><h2>Aggiungi nuova parola</h2><p>La parola o locuzione verrà conservata nei prossimi controlli.</p><div class="modal-row"><input id="newWord" placeholder="es. devushka o ensalada rusa"><button id="confirmWord">Aggiungi</button><button id="cancelWord">Annulla</button></div><input id="wordNote" class="note" placeholder="Nota facoltativa"></div></div>
|
|
<script id="payload" type="application/json">__PAYLOAD__</script>
|
|
<script>
|
|
const payload=JSON.parse(document.getElementById('payload').textContent);const items=payload.items;const KEY='epub-review:'+payload.source;
|
|
let state=JSON.parse(localStorage.getItem(KEY)||'{}');let visible=[];
|
|
const $=id=>document.getElementById(id);const esc=s=>(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
function mark(text,word,cls){const safe=esc(text),w=esc(word);const i=safe.toLocaleLowerCase('it').indexOf(w.toLocaleLowerCase('it'));return i<0?safe:safe.slice(0,i)+`<span class="${cls}">`+safe.slice(i,i+w.length)+'</span>'+safe.slice(i+w.length)}
|
|
function statusOf(id){return state[id]?.status||'pending'}
|
|
function render(){const d=$('decision').value,s=$('status').value,q=$('search').value.trim().toLocaleLowerCase('it');visible=items.filter(x=>(d==='ALL'||x.decision===d)&&(s==='all'||statusOf(x.id)===s)&&(!q||JSON.stringify(x).toLocaleLowerCase('it').includes(q)));$('cards').innerHTML=visible.map(card).join('');$('empty').classList.toggle('hidden',visible.length>0);stats()}
|
|
function card(x){const st=statusOf(x.id),ctxOld=mark(x.context,x.original,'mark-old');const ctxNew=mark(x.context,x.original,'mark-new').replace(esc(x.original),esc(x.proposal));const alts=(x.alternatives||[]).slice(0,5).map(a=>`${esc(a.proposed)} (${(a.probability_proposal??0).toFixed(6)})`).join(' · ');return `<article class="card" id="${x.id}"><div class="card-head"><div><div class="title"><span class="badge">${esc(x.decision)}</span> ${esc(x.rule)}</div><div class="meta">${esc(x.file)} · paragrafo ${x.paragraph} · offset ${x.offset}</div></div><div class="prob">P=${x.probability.toFixed(6)}<br>margine=${(x.margin??0).toFixed(6)}</div></div><div class="card-body"><div class="change"><span class="old">${esc(x.original)}</span> → <span class="new">${esc(x.proposal)}</span></div><div class="context">${ctxOld}</div><div class="alts">Alternative: ${alts||'nessuna'}</div><textarea class="rewrite" data-rewrite="${x.id}" placeholder="Riscrivi qui l'intera frase o paragrafo.">${esc(state[x.id]?.rewrite||'')}</textarea><input class="note" data-note="${x.id}" value="${esc(state[x.id]?.note||'')}" placeholder="Nota facoltativa"></div><div class="actions"><button class="approve ${st==='approved'?'active':''}" onclick="setStatus('${x.id}','approved')">Approva proposta</button><button class="approve ${st==='rewritten'?'active':''}" onclick="setStatus('${x.id}','rewritten')">Salva riscrittura</button><button onclick="preserveFromCase('${x.id}')">Mantieni e ricorda</button><button class="reject ${st==='rejected'?'active':''}" onclick="setStatus('${x.id}','rejected')">Rifiuta</button><button class="skip ${st==='skipped'?'active':''}" onclick="setStatus('${x.id}','skipped')">Rimanda</button></div></article>`}
|
|
window.setStatus=(id,status)=>{const note=document.querySelector(`[data-note="${id}"]`)?.value||state[id]?.note||'';const rewrite=document.querySelector(`[data-rewrite="${id}"]`)?.value||state[id]?.rewrite||'';if(status==='rewritten'&&!rewrite.trim()){alert('Inserisci la frase riscritta.');return}state[id]={status,note,rewrite};localStorage.setItem(KEY,JSON.stringify(state));render()};
|
|
function stats(){const c={pending:0,approved:0,rewritten:0,preserved:0,rejected:0,skipped:0};items.forEach(x=>c[statusOf(x.id)]++);$('stats').textContent=`Visibili ${visible.length}/${items.length} · ✓ ${c.approved} · riscritte ${c.rewritten} · diz. ${c.preserved} · ✕ ${c.rejected} · ? ${c.skipped} · da fare ${c.pending}`}
|
|
function move(dir){const cards=[...document.querySelectorAll('.card')];if(!cards.length)return;const y=scrollY+120;let idx=cards.findIndex(c=>c.offsetTop>y);if(dir<0)idx=Math.max(0,(idx<0?cards.length:idx)-1);else idx=Math.min(cards.length-1,idx<0?cards.length-1:idx);cards[idx].scrollIntoView({behavior:'smooth',block:'start'})}
|
|
$('prev').onclick=()=>move(-1);$('next').onclick=()=>move(1);['decision','status'].forEach(id=>$(id).onchange=render);$('search').oninput=render;
|
|
$('save').onclick=()=>{document.querySelectorAll('[data-note]').forEach(n=>{const id=n.dataset.note;if(state[id])state[id].note=n.value});localStorage.setItem(KEY,JSON.stringify(state));alert('Sessione salvata nel browser.')};
|
|
$('reset').onclick=()=>{if(confirm('Azzera tutte le decisioni?')){state={};localStorage.removeItem(KEY);render()}};
|
|
let preserveWords=JSON.parse(localStorage.getItem(KEY+':preserve')||'[]');
|
|
function addPreserveWord(word,note='',source='manual'){word=(word||'').trim();if(!word)return false;const key=word.toLocaleLowerCase('it');if(!preserveWords.some(x=>x.value.toLocaleLowerCase('it')===key))preserveWords.push({value:word,note,source,created_at:new Date().toISOString()});localStorage.setItem(KEY+':preserve',JSON.stringify(preserveWords));return true}
|
|
window.preserveFromCase=id=>{const x=items.find(v=>v.id===id);if(!x)return;addPreserveWord(x.original,state[id]?.note||'',`case:${id}`);state[id]={...(state[id]||{}),status:'preserved'};localStorage.setItem(KEY,JSON.stringify(state));render()};
|
|
$('addWord').onclick=()=>{$('newWord').value='';$('wordNote').value='';$('wordModal').classList.remove('hidden');$('newWord').focus()};$('cancelWord').onclick=()=>$('wordModal').classList.add('hidden');$('confirmWord').onclick=()=>{if(addPreserveWord($('newWord').value,$('wordNote').value)){$('wordModal').classList.add('hidden');alert('Parola aggiunta.')}else alert('Inserisci una parola.')};
|
|
$('export').onclick=()=>{document.querySelectorAll('[data-note]').forEach(n=>{const id=n.dataset.note;if(state[id])state[id].note=n.value});const approved=items.filter(x=>statusOf(x.id)==='approved').map(x=>({file:x.file,paragraph:x.paragraph,offset:x.offset,original:x.original,replacement:x.proposal,probability:x.probability,margin:x.margin,reason:'Approvata nella pagina di revisione',note:state[x.id]?.note||''}));const sentence_rewrites=items.filter(x=>statusOf(x.id)==='rewritten').map(x=>({file:x.file,paragraph:x.paragraph,original_context:x.context,rewritten_context:(state[x.id]?.rewrite||'').trim(),trigger_original:x.original,trigger_proposal:x.proposal,note:state[x.id]?.note||''}));const rejected=items.filter(x=>statusOf(x.id)==='rejected').map(x=>({id:x.id,original:x.original,replacement:x.proposal,note:state[x.id]?.note||''}));const out={source_report:payload.source,created_at:new Date().toISOString(),approved,sentence_rewrites,preserved:preserveWords,rejected};const blob=new Blob([JSON.stringify(out,null,2)],{type:'application/json'});const a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='approved_changes.json';a.click();URL.revokeObjectURL(a.href)};
|
|
render();
|
|
</script></body></html>'''
|
|
|
|
def main():
|
|
p=argparse.ArgumentParser();p.add_argument('report',type=Path);p.add_argument('--output',type=Path,default=Path('reports/review.html'));a=p.parse_args()
|
|
data=json.loads(a.report.read_text(encoding='utf-8')); selected=[]
|
|
for i,x in enumerate(data.get('groups',[])):
|
|
if x.get('decision') not in {'AUTO_APPLY','REVIEW_HIGH','REVIEW_AMBIGUOUS'}: continue
|
|
best=x.get('best_proposal'); prob=x.get('best_probability')
|
|
if not best or prob is None: continue
|
|
selected.append({'id':f'c{i:05d}','decision':x.get('decision'),'rule':x.get('rule',''),'file':x.get('file',''),'paragraph':x.get('paragraph',0),'offset':x.get('offset',0),'original':x.get('original',''),'proposal':best,'probability':float(prob),'margin':x.get('margin'),'context':x.get('context',''),'alternatives':[z for z in x.get('alternatives',[]) if z.get('scored')]})
|
|
selected.sort(key=lambda x:(0 if x['decision']=='AUTO_APPLY' else 1 if x['decision']=='REVIEW_HIGH' else 2,-x['probability']))
|
|
payload=json.dumps({'source':str(a.report),'items':selected},ensure_ascii=False).replace('</','<\\/')
|
|
a.output.parent.mkdir(parents=True,exist_ok=True);a.output.write_text(TEMPLATE.replace('__PAYLOAD__',payload),encoding='utf-8')
|
|
print('HTML:',a.output);print('Casi:',len(selected));print('AUTO_APPLY:',sum(x['decision']=='AUTO_APPLY' for x in selected));print('REVIEW_HIGH:',sum(x['decision']=='REVIEW_HIGH' for x in selected));print('REVIEW_AMBIGUOUS:',sum(x['decision']=='REVIEW_AMBIGUOUS' for x in selected))
|
|
if __name__=='__main__':main()
|