import React, {useEffect, useState} from 'react'; import {createRoot} from 'react-dom/client'; import './style.css'; type Answer = {label:string;score:string;remark:string;image:boolean;editableBody:boolean;info:{present?:boolean;not_empty?:boolean;touched?:boolean}}; type Copy = {id:string;name:string;finalScore?:string|null;general:string[];answers:Answer[]}; type Job = {running:boolean;status:string;log:string}; type State = {evaluation:string;mode:string;labels:string[];copies:Copy[];expectedNames:string[]|null;draft:string;applied:string;revision:string;dirty:boolean;job:Job}; type Edit = {kind:string;copy:string;label:string;value:string|string[]}; async function api(path:string, data?:unknown) { const response=await fetch('/api/'+path, data===undefined?{}:{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); if(!response.ok){let message;try{const payload=await response.json();message=typeof payload.detail==='string'?payload.detail:JSON.stringify(payload.detail)}catch{message=await response.text().catch(()=>response.statusText)}throw new Error(message||'Le serveur ne répond pas.');} return response.json(); } const imageUrl=(copy:string,label:string,body=false,revision='')=>'/api/image?'+new URLSearchParams({copy,label,body:String(body),v:revision}); const documentUrl=(copy:string,kind:string,label='')=>'/api/document?'+new URLSearchParams({copy,kind,label}); const keyOf=(kind:string,copy='',label='')=>JSON.stringify([kind,copy,label]); const searchName=(text:string)=>text.normalize('NFD').replace(/\p{M}/gu,'').toLocaleLowerCase('fr').trim(); const copyName=(copy:Copy)=>`${copy.name} · copie ${copy.id}`; function App(){ const [state,setState]=useState(null),[error,setError]=useState(''),[notice,setNotice]=useState(''); const [copyFilter,setCopyFilter]=useState(''),[labelFilter,setLabelFilter]=useState(''),[limit,setLimit]=useState(20); const [edits,setEdits]=useState>({}),[busy,setBusy]=useState(false),[applyDraft,setApplyDraft]=useState(false); const [cut,setCut]=useState<{copy:string;label:string}|null>(null),[zoom,setZoom]=useState<{copy:string;label:string}|null>(null); const [stopped,setStopped]=useState(false); const [onlyNameIssues,setOnlyNameIssues]=useState(false); const [nameQuery,setNameQuery]=useState(''); const [incremental,setIncremental]=useState(true); const refresh=async()=>{try{setState(await api('state'));setError('')}catch(e){setError(String(e))}}; useEffect(()=>{void refresh()},[]); useEffect(()=>{const protect=(e:BeforeUnloadEvent)=>{if(Object.keys(edits).length){e.preventDefault();e.returnValue=''}};window.addEventListener('beforeunload',protect);return()=>window.removeEventListener('beforeunload',protect)},[edits]); useEffect(()=>{if(!state?.job.running)return;const timer=setInterval(async()=>{try{const job:Job=await api('job');setState(s=>s?{...s,job}:s);if(!job.running){await refresh();setNotice(job.status==='success'?'Les sorties ont été régénérées.':'La régénération est incomplète. Consultez le journal.')}}catch(e){setError(String(e))}},1000);return()=>clearInterval(timer)},[state?.job.running]); const value=(kind:string,copy:string,label:string,fallback:string|string[])=>edits[keyOf(kind,copy,label)]?.value??fallback; const edit=(kind:string,copy:string,label:string,v:string|string[])=>setEdits(old=>({...old,[keyOf(kind,copy,label)]:{kind,copy,label,value:v}})); const save=async(keys:string[])=>{if(!state)return;setBusy(true);setError('');let latest=state;try{for(const key of keys){const change=edits[key];if(!change)continue;latest=await api('change',{...change,revision:latest.revision});setState(latest);setEdits(old=>{const next={...old};delete next[key];return next})}setNotice('Modifications enregistrées.')}catch(e){setError(String(e))}finally{setBusy(false)}}; const regenerate=async()=>{if(!state)return;setBusy(true);setError('');try{const job=await api('regenerate',{revision:state.revision,apply_draft:applyDraft,incremental});setState({...state,job});setNotice('')}catch(e){setError(String(e))}finally{setBusy(false)}}; const stop=async()=>{try{await api('stop',{});setStopped(true)}catch(e){setError(String(e))}}; if(stopped)return

Session terminée

Vous pouvez reprendre le travail dans Copienator.

; if(!state)return

Copienator

{error||'Chargement de la finalisation…'}

; const disabled=busy||state.job.running; const pending=Object.keys(edits).length; const draft=value('draft','','',state.draft) as string; const draftDiff=state.draft.trim()!==state.applied.trim(); const selectedCopy=copyFilter?state.copies.find(c=>Number(c.id)===Number(copyFilter)):undefined; const nameChoices=[...state.copies].sort((a,b)=>a.name.localeCompare(b.name,'fr')||Number(a.id)-Number(b.id)); const selectName=(text:string)=>{ setLimit(20); const selection=state.copies.find(c=>copyName(c)===text); const exact=state.copies.filter(c=>searchName(c.name)===searchName(text)); const copy=selection||(exact.length===1?exact[0]:undefined); setCopyFilter(copy?.id||''); setNameQuery(copy?'':text); }; const matches=state.copies.filter(c=>(!copyFilter||Number(c.id)===Number(copyFilter)) &&(!nameQuery||searchName(c.name).includes(searchName(nameQuery)))); const cards=matches.flatMap(c=>c.answers.filter(a=>!labelFilter||a.label===labelFilter).map(a=>({c,a}))); const names=new Map();state.copies.forEach(c=>names.set(c.name.toLowerCase(),(names.get(c.name.toLowerCase())||0)+1)); const issues=state.copies.filter(c=>c.name.toLowerCase()==='unknown'||(names.get(c.name.toLowerCase())||0)>1); const identityMatches=matches.filter(c=>!onlyNameIssues||issues.includes(c)); const assignedNames=new Set(state.copies.map(c=>c.name.trim().toLocaleLowerCase('fr'))); const missingNames=state.expectedNames?.filter(name=>!assignedNames.has(name.trim().toLocaleLowerCase('fr'))); return <>
CCopienator
FINALISATION · {state.mode}

{state.evaluation}

Vérifier les copies

Retrouvez une copie, une question, ou leur intersection.

{state.copies.map(c=>)}{nameChoices.filter(c=>!nameQuery||searchName(c.name).includes(searchName(nameQuery))).map(c=>
{matches.length===1&&(copyFilter||nameQuery)&&matches[0].finalScore!=null&&
Copie {matches[0].id} · {matches[0].name}Score final exporté : {matches[0].finalScore.replace('.', ',')}
} {error&&
{error}Les saisies non enregistrées restent dans la page.
} {notice&&

{notice}

}
{state.job.running?'Régénération en cours…':pending?`${pending} modification(s) non enregistrée(s)`:state.dirty?'Enregistré · sorties à régénérer':'Modifications enregistrées'}Les sorties comprennent les images, PDF et le dossier A Rendre configurés.
{pending>0&&}
Identités et remarques générales {issues.length} nom(s) à vérifier
Noms manquants{missingNames?` (${missingNames.length})`:''}{missingNames===undefined?

Liste des noms attendus introuvable (fichier names).

:missingNames.length?
    {missingNames.map(name=>
  • {name}
  • )}
:

Tous les noms attendus sont attribués.

}

Les remarques sont composées en LaTeX. Leur ordre est celui de la liste.

{identityMatches.length===0&&

Aucune copie ne correspond aux filtres sélectionnés.

}{identityMatches.map(c=>{const general=value('general',c.id,'',c.general) as string[];const changeGeneral=(next:string[])=>edit('general',c.id,'',next);return
Copie {c.id}{issues.includes(c)&&Nom à vérifier}Voir l’originale ↗
{general.map((text,i)=>