Initial commit for finalisation web interface

This commit is contained in:
sebastien committed 2026-11-01 08:41:37 +01:00
1 parent b295f871a7
commit c36642e8de
33 files changed
+2745 -138

No files matched your search

+2
View File
@@ -55,6 +55,8 @@ class AnnotationReadCache:
self.signatures[copy_id] = sha256(payload).hexdigest()
def unchanged(self, copy_id: str) -> bool:
if not (self.workspace.root / self.mode / f"Copie{copy_id}" / "review_snapshot.json").is_file():
return False
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
try:
state = read_json(directory / STATE_FILE)
+3 -1
View File
@@ -109,7 +109,9 @@ def execute(
) -> int:
args = parser.parse_args(argv)
try:
result = handler(args)
from .evaluation_lock import command_lock
with command_lock(args):
result = handler(args)
except CliError as exc:
print(f"Error: {exc}", file=sys.stderr)
return int(exc.exit_code)
+3
View File
@@ -155,6 +155,9 @@ def build_cleanup_plan(workspace: EvaluationWorkspace) -> CleanupPlan:
kept_files = set(processed_copies)
kept_files.add(workspace.correction_file)
lock_file = workspace.root / ".copienator-finalisation.lock"
if lock_file in files:
kept_files.add(lock_file)
kept_files.update(_return_artifacts(workspace))
kept_files.update(
path
+33
View File
@@ -0,0 +1,33 @@
"""Optional browser workflow; all existing finalisation commands stay available."""
from pathlib import Path
import time
import webbrowser
from copienator import evaluation_parser
def main(argv=None):
parser = evaluation_parser('Open the local finalisation workspace')
parser.add_argument('--annotation-dir', choices=('BGnot', 'Bnot'), default='BGnot')
parser.add_argument('--no-browser', action='store_true')
args = parser.parse_args(argv)
from copienator.finalisation_server import WebSession
session = None
try:
session = WebSession(Path(args.evaluation).resolve(), args.annotation_dir)
print(session.url, flush=True)
if not args.no_browser:
webbrowser.open(session.url)
while session.alive:
time.sleep(.2)
except KeyboardInterrupt:
if session:
print('Fermeture après la fin du traitement en cours…', flush=True)
while session.service.job['running']:
time.sleep(.2)
session.request_stop()
session.thread.join()
except Exception as exc:
print(f'Erreur : {exc}', flush=True)
return 1
return 0
+11 -3
View File
@@ -18,6 +18,7 @@ from copienator import (
)
from copienator.platform import replace_with_link_or_copy, safe_filename
from copienator.return_answers import publish_answer_returns
from copienator.score_editing import remember_return_source
ANNOTATION_CHOICES = ("BGnot", "Bnot", "Anot")
@@ -119,7 +120,9 @@ def update_named_return_answers(
destination,
answers_only=True,
)
remember_return_source(workspace.root, copy_id, source_folder)
if refresh_named:
replace_with_link_or_copy(source_folder / "score.json", destination / "score.json", prefer="symlink")
stem = destination.name[:match.start()].rstrip()
for source_name, suffix, enabled in (
("Concat.jpg", ".jpg", configuration.RETURN_JPEG_ENABLED),
@@ -167,6 +170,9 @@ def prepare_named_returns(
print(f"Error processing {json_path}: {exc}", file=sys.stderr)
had_errors = True
# A manually renamed folder is authoritative, keyed by stable copy ID.
from copienator.return_identity import return_folders
existing_returns = return_folders(workspace.root)
assigned_names: set[str] = set()
for name, copy_ids in copies_map.items():
if name == "Unknown":
@@ -190,17 +196,19 @@ def prepare_named_returns(
continue
assigned_names.add(name)
destination = workspace.return_dir / f"{safe_name} ({copy_id})"
destination = existing_returns.get(int(copy_id), workspace.return_dir / f"{safe_name} ({copy_id})")
output_name = destination.name.rsplit(" (", 1)[0]
destination.mkdir(parents=True, exist_ok=True)
try:
publish_answer_returns(workspace.root, source_folder, destination)
remember_return_source(workspace.root, copy_id, source_folder)
except (OSError, TypeError, ValueError) as exc:
print(f"Error preparing answers for {destination.name}: {exc}", file=sys.stderr)
had_errors = True
continue
links = (
("Concat.jpg", f"{safe_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{safe_name}.pdf", configuration.RETURN_PDF_ENABLED),
("Concat.jpg", f"{output_name}.jpg", configuration.RETURN_JPEG_ENABLED),
("Concat_F.pdf", f"{output_name}.pdf", configuration.RETURN_PDF_ENABLED),
("score.json", "score.json", True),
)
for source_name, destination_name, enabled in links:
+11 -2
View File
@@ -1,5 +1,8 @@
from __future__ import annotations
from copy import deepcopy
from copienator import review_snapshot
import argparse
from collections.abc import Sequence
from pathlib import Path
@@ -160,6 +163,7 @@ def apply_actions_and_regenerate(
*,
update_score: bool = False,
label_notes: dict[str, Image.Image] | None = None,
review_metadata: dict | None = None,
) -> ExitCode:
"""Apply annotations and atomically merge the regenerated student files."""
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
@@ -168,15 +172,17 @@ def apply_actions_and_regenerate(
content["result"].get("error") == "empty-answer"
for content in data[student_id].values()
)
if not bnote_path.is_file() and not only_empty:
if review_metadata is None and not bnote_path.is_file() and not only_empty:
print(f" Missing {bnote_path}")
return ExitCode.PARTIAL
bnote_data = read_json(bnote_path) if bnote_path.is_file() else {"images": []}
bnote_data = review_metadata if review_metadata is not None else (read_json(bnote_path) if bnote_path.is_file() else {"images": []})
if not isinstance(bnote_data, dict):
raise TypeError(f"Expected a JSON object in {bnote_path}")
labels_data = data[student_id]
apply_checkbox_actions(labels_data, actions, print)
reviewed_data = deepcopy(labels_data)
captured_notes = {}
apply_custom_remarks(workspace.root, student_id, labels_data)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
@@ -217,6 +223,8 @@ def apply_actions_and_regenerate(
hmin = int(image_info.get("hmin", 0))
hmax = int(image_info.get("hmax", 0))
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
if sub_note is not None:
captured_notes[label] = {"img": sub_note}
has_notes = has_significant_notes(sub_note)
pdf_path = Path(content["pdf_path"])
@@ -288,6 +296,7 @@ def apply_actions_and_regenerate(
workspace.root, [(label, dirty_images[label]) for label in filtered_labels],
all_labels, staging / "Concat_F.pdf", general=general,
)
review_snapshot.stage(output_dir, staging, reviewed_data, captured_notes, engine="standard", bnote=bnote_data)
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels),
filtered_labels, scores)
@@ -1,5 +1,8 @@
from __future__ import annotations
from copy import deepcopy
from copienator import review_snapshot
import argparse
import concurrent.futures
import unicodedata
@@ -231,12 +234,14 @@ def apply_actions_and_regenerate_grouped(
update_score: bool = False,
annotation_dir: str = "BGnot",
selected_labels: set[str] | None = None,
force_filtered: set[str] | None = None,
) -> tuple[ExitCode, str]:
"""Regenerate a copy, preserving reviewed images outside the redo selection."""
logs = [f"\nProcessing compilation for: Copie{student_id}"]
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
labels_data = data.get(student_id, {})
apply_checkbox_actions(labels_data, actions, logs.append)
reviewed_data = deepcopy(labels_data)
apply_custom_remarks(workspace.root, student_id, labels_data)
score_path = output_dir / "score.json"
preserve_score_file = update_score and score_path.is_file()
@@ -400,7 +405,7 @@ def apply_actions_and_regenerate_grouped(
perfect = float(scores[label]) >= 4.0 and all(
feedback.get("to_delete", False) for feedback in feedbacks
)
if not perfect or has_notes:
if not perfect or has_notes or label in (force_filtered or set()):
filtered_labels.append(label)
extras = (
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels,
@@ -450,6 +455,10 @@ def apply_actions_and_regenerate_grouped(
[(label, dirty_images[label]) for label in filtered_labels],
all_labels, staging / "Concat_F.pdf", general=general,
)
if annotation_dir in {"Bnot", "BGnot"}:
review_snapshot.stage(output_dir, staging, reviewed_data, label_notes,
engine="grouped", selected=selected_labels,
force_filtered=(set(filtered_labels) - selected_labels) if selected_labels else (force_filtered or ()))
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
filtered_labels, scores)
@@ -0,0 +1,38 @@
"""Re-render saved handwriting interpretation with current scores and remarks."""
from copienator import EvaluationWorkspace, ExitCode, evaluation_parser, execute, workspace_from_args, utils
from copienator.review_snapshot import load, FILENAME
from copienator.commands.reading_annotations import apply_actions_and_regenerate
from copienator.commands.reading_grouped_annotations import apply_actions_and_regenerate_grouped
def run(workspace: EvaluationWorkspace, mode: str):
directories = sorted((p for p in (workspace.root / mode).glob('Copie*')
if p.is_dir() and p.name[5:].isdigit() and (p / 'score.json').is_file()))
# Validate every snapshot before starting to publish any copy.
for directory in directories:
if not (directory / FILENAME).is_file():
raise ValueError(f'{directory.name} : résultat de lecture sauvegardé absent ; relisez les annotations manuscrites.')
load(workspace.root, directory)
status = ExitCode.SUCCESS
for directory in directories:
cid = directory.name[5:]
saved, data, notes = load(workspace.root, directory)
if saved['engine'] == 'standard':
result = apply_actions_and_regenerate(workspace, {cid: data}, cid, [], None,
utils.read_all_labels(workspace.root), update_score=True,
label_notes={label: note['img'] for label, note in notes.items()},
review_metadata=saved['bnote'])
else:
result, log = apply_actions_and_regenerate_grouped(workspace, {cid: data}, cid, [], notes,
utils.read_all_labels(workspace.root), update_score=True, annotation_dir=mode,
force_filtered=set(saved.get('force_filtered', [])))
print(log, flush=True)
if result != ExitCode.SUCCESS:
status = ExitCode.PARTIAL
return status
def main(argv=None):
parser = evaluation_parser('Regenerate from saved reviewed inputs without rescanning handwriting')
parser.add_argument('--annotation-dir', choices=('Bnot', 'BGnot'), required=True)
return execute(parser, argv, lambda args: run(workspace_from_args(args), args.annotation_dir))
+2
View File
@@ -41,6 +41,8 @@ COMMANDS: dict[str, Command] = {
"import": Command("import_annotations", "Import handwritten annotations"),
"read-annotations": Command("reading_annotations", "Read checkable annotations"),
"read-grouped": Command("reading_grouped_annotations", "Read grouped annotations"),
"regenerate-reviewed": Command("regenerate_reviewed", "Regenerate saved reviewed annotations"),
"finalise-web": Command("finalise_web", "Open the optional local finalisation workspace"),
"modify-annotations": Command("modify_annotations", "Modify reviewed answer bodies"),
"giving-names": Command("giving_names", "Name copies and prepare A Rendre"),
"update-ods": Command("update_ods", "Update the configured score spreadsheet"),
+67
View File
@@ -0,0 +1,67 @@
"""OS-backed evaluation lease; released automatically on process exit."""
from __future__ import annotations
import json
import os
import secrets
from pathlib import Path
class EvaluationLock:
def __init__(self, root: Path):
self.path = root / '.copienator-finalisation.lock'
self.stream = None
self.token = secrets.token_urlsafe(32)
def __enter__(self):
from copienator import CliError
self.stream = self.path.open('a+b')
try:
if os.name == 'nt':
import msvcrt
self.stream.seek(0)
if not self.stream.read(1):
self.stream.write(b' ')
self.stream.flush()
self.stream.seek(0)
msvcrt.locking(self.stream.fileno(), msvcrt.LK_NBLCK, 1)
else:
import fcntl
fcntl.flock(self.stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
self.stream.close()
self.stream = None
raise CliError('Cette évaluation est utilisée par un autre traitement ou la finalisation web.') from None
self.stream.seek(0)
self.stream.truncate()
self.stream.write(json.dumps({'pid': os.getpid(), 'token': self.token}).encode())
self.stream.flush()
return self
def __exit__(self, *args):
if self.stream:
self.stream.close()
self.stream = None
def command_lock(args):
from contextlib import nullcontext
from copienator import EvaluationWorkspace
root = getattr(args, 'evaluation', None)
if root is None and getattr(args, 'target', None):
try:
root = EvaluationWorkspace.discover(Path(args.target).resolve()).root
except Exception:
return nullcontext()
if root is None or not Path(root).is_dir():
return nullcontext()
root = Path(root).resolve()
token = os.environ.get('COPIENATOR_FINALISATION_TOKEN')
if token:
try:
data = json.loads((root / '.copienator-finalisation.lock').read_text())
if secrets.compare_digest(token, data['token']):
return nullcontext()
except (OSError, ValueError, KeyError):
pass
return EvaluationLock(root)
+189
View File
@@ -0,0 +1,189 @@
"""Shared, file-compatible services for the optional finalisation browser."""
from __future__ import annotations
from hashlib import sha256
from pathlib import Path
import json
import os
import subprocess
import sys
import threading
from copienator import atomic_write_json, read_json, utils
from copienator import custom_remarks, general_remarks
from copienator.annotation_modifications import DRAFT, APPLIED, BASELINE, read_instructions, validate_instructions
from copienator.return_identity import return_folders, RETURN_FOLDER_PATTERN, set_copy_name
from copienator.score_editing import set_score
class Finalisation:
def __init__(self, root: Path, mode: str, token: str, lock_fd: int | None = None):
self.root = root.resolve()
if mode not in ('Bnot', 'BGnot'):
raise ValueError('Choisissez Bnot ou BGnot.')
self.mode, self.token = mode, token
self.lock_fd = lock_fd
self.labels = utils.read_all_labels(self.root)
self.mutex = threading.RLock()
self.closing = False
self.job = {'running': False, 'status': 'idle', 'log': ''}
self.dirty_path = self.root / '.copienator' / f'finalisation-{mode}.json'
def copies(self):
return sorted((p for p in (self.root / self.mode).glob('Copie*')
if p.is_dir() and p.name[5:].isdigit() and (p / 'score.json').is_file()),
key=lambda p: int(p.name[5:]))
def directory(self, copy: str):
matches = [p for p in self.copies() if copy.isdecimal() and int(p.name[5:]) == int(copy)]
if len(matches) != 1:
raise ValueError('Copie introuvable ou ambiguë.')
return matches[0]
def label(self, label: str):
if label not in self.labels or Path(label).name != label or label in ('.', '..'):
raise ValueError('Label inconnu.')
return label
def draft(self):
path = self.root / DRAFT
return path.read_text() if path.exists() else f'### Source: {self.mode}\n'
def revision(self):
paths = [self.root / DRAFT, self.root / 'custom_remarks.json', self.root / 'general_remarks.json',
self.root / self.mode / APPLIED, self.dirty_path]
paths += list((self.root / 'Copies').glob('Copie*.json'))
paths += [p / 'score.json' for p in self.copies()]
digest = sha256()
for p in sorted(paths):
digest.update(str(p).encode())
digest.update(p.read_bytes() if p.exists() else b'absent')
for key, folder in sorted(return_folders(self.root).items()):
digest.update(f'{key}:{folder.name}'.encode())
return digest.hexdigest()
def state(self):
with self.mutex:
folders = return_folders(self.root)
copies = []
for directory in self.copies():
cid = directory.name[5:]
metadata = read_json(self.root / 'Copies' / f'Copie{cid}.json', default={})
folder = folders.get(int(cid))
name = RETURN_FOLDER_PATTERN.fullmatch(folder.name)['name'] if folder else metadata.get('name', 'Unknown')
scores = read_json(directory / 'score.json')
info = read_json(directory / 'info.json', default={})
base = read_json(directory / BASELINE, default={})
copies.append({'id': cid, 'name': name, 'general': general_remarks.remarks_for_copy(self.root, cid),
'answers': [{'label': label, 'score': str(scores.get(label, '')),
'remark': custom_remarks.remarks_for_copy(self.root, cid).get(label, ''),
'image': (directory / f'{label}.jpg').is_file(),
'editableBody': label in base.get('images', {}),
'info': info.get(label, {})} for label in self.labels]})
applied = self.root / self.mode / APPLIED
applied_text = applied.read_text() if applied.exists() else f'### Source: {self.mode}\n'
return {'evaluation': self.root.name, 'mode': self.mode, 'labels': self.labels, 'copies': copies,
'draft': self.draft(), 'applied': applied_text, 'revision': self.revision(),
'dirty': read_json(self.dirty_path, default={}).get('dirty', False), 'job': dict(self.job)}
def save(self, data):
with self.mutex:
if self.job['running'] or self.closing:
raise ValueError('Régénération en cours ; attendez la fin avant de modifier.')
if data['revision'] != self.revision():
raise ValueError('Les données ont changé. Rechargez la page avant de réessayer.')
kind, value = data['kind'], data['value']
if kind == 'draft':
instructions = read_instructions(value, self.mode)
validate_instructions(self.root, self.mode, instructions)
# Same filesystem and atomic replacement; retain the GUI-readable format.
from copienator.filesystem import staged_files
with staged_files(self.root) as staging:
(staging / DRAFT).write_text(value, encoding='utf-8')
else:
directory = self.directory(data['copy'])
cid = directory.name[5:]
if kind == 'score':
set_score(directory / 'score.json', self.label(data['label']), value)
elif kind == 'remark':
custom_remarks.set_remark(self.root, cid, self.label(data['label']), value)
elif kind == 'general':
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
raise ValueError('Liste de remarques invalide.')
general_remarks.set_remarks(self.root, cid, value)
elif kind == 'name':
set_copy_name(self.root, cid, value)
else:
raise ValueError('Modification inconnue.')
self.dirty_path.parent.mkdir(exist_ok=True)
atomic_write_json(self.dirty_path, {'dirty': True})
return self.state()
def start_job(self, revision: str, apply_draft: bool):
with self.mutex:
if self.job['running'] or self.closing or revision != self.revision():
raise ValueError('Traitement déjà en cours ou données modifiées. Rechargez la page.')
if apply_draft:
validate_instructions(self.root, self.mode, read_instructions(self.draft(), self.mode))
if not (self.root / DRAFT).exists():
(self.root / DRAFT).write_text(self.draft())
self.job = {'running': True, 'status': 'running', 'log': ''}
threading.Thread(target=self._run_job, args=(apply_draft,), daemon=True).start()
return dict(self.job)
def _run_job(self, apply_draft):
try:
self._regenerate(apply_draft)
except Exception as exc:
with self.mutex:
self.job.update(status='failed', log=self.job['log'] + '\n' + str(exc))
finally:
with self.mutex:
self.job['running'] = False
def _regenerate(self, apply_draft):
from copienator.return_answers import load_return_answer_options
options = load_return_answer_options(self.root)
reader = 'read-grouped' if self.mode == 'BGnot' else 'read-annotations'
commands = [[reader, str(self.root), '--update-score']]
# The readers expose BooleanOptionalAction for supplementary answer options.
for name, enabled in options.items():
commands[0].append(f'--{"" if enabled else "no-"}return-answers-{name}')
if apply_draft:
commands.append(['modify-annotations', str(self.root), '--annotation-dir', self.mode])
commands.append(['giving-names', str(self.root), self.mode])
from copienator.review_snapshot import FILENAME
if all((directory / FILENAME).is_file() for directory in self.copies()):
commands[0] = ['regenerate-reviewed', str(self.root), '--annotation-dir', self.mode]
elif (self.root / 'BRnot').is_dir():
with self.mutex:
self.job.update(running=False, status='failed', log='Résultat de lecture sauvegardé absent pour une évaluation avec reprise. Relisez le passage principal puis fusionnez la reprise avant de régénérer dans le navigateur. Aucun résultat n’a été remplacé.')
return
env = dict(os.environ, COPIENATOR_FINALISATION_TOKEN=self.token, PYTHONUNBUFFERED='1')
from copienator import configuration
if configuration.CONFIG_PATH is not None:
env['COPIENATOR_CONFIG'] = str(configuration.CONFIG_PATH)
worker_options = {'pass_fds': (self.lock_fd,)} if os.name != 'nt' and self.lock_fd is not None else {}
try:
for command in commands:
with self.mutex:
self.job['log'] += f'\n→ {command[0]}\n'
with subprocess.Popen([sys.executable, '-m', 'copienator', *command],
cwd=Path(__file__).resolve().parent.parent, env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, **worker_options) as process:
for line in process.stdout:
with self.mutex:
self.job['log'] = (self.job['log'] + line)[-100000:]
if process.wait() != 0:
raise RuntimeError(f'{command[0]} a échoué ou est incomplet. Consultez le journal puis relancez.')
with self.mutex:
self.dirty_path.parent.mkdir(exist_ok=True)
atomic_write_json(self.dirty_path, {'dirty': False})
self.job['status'] = 'success'
except Exception as exc:
with self.mutex:
self.job['status'] = 'failed'
self.job['log'] += f'\n{exc}\n'
finally:
with self.mutex:
self.job['running'] = False
+225
View File
@@ -0,0 +1,225 @@
"""Loopback-only web UI, served by the same Python installation as Copienator."""
from __future__ import annotations
import base64
from io import BytesIO
from pathlib import Path
import secrets
import socket
import threading
import time
from fastapi import FastAPI, Request, HTTPException
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, StrictStr, Field
import uvicorn
from copienator import CliError
from copienator.annotation_modifications import baseline, body_image, edited_bodies, read_instructions, validate_instructions
from copienator.evaluation_lock import EvaluationLock
from copienator.finalisation import Finalisation
from copienator.return_identity import original_copy_path
class Change(BaseModel):
revision: StrictStr
kind: StrictStr
value: StrictStr | list[StrictStr]
copy_id: StrictStr = Field(default='', alias='copy')
label: StrictStr = ''
class Regenerate(BaseModel):
revision: str
apply_draft: bool = False
class Preview(BaseModel):
copy_id: str = Field(alias="copy")
text: str
source: str
target: str
def create_app(service: Finalisation, browser_token: str, stop=lambda: None):
app = FastAPI(docs_url=None, redoc_url=None, openapi_url=None)
cookie_name = 'copienator_' + browser_token[:12]
@app.middleware('http')
async def local_session(request: Request, call_next):
host = request.headers.get('host', '').split(':')[0]
if host not in ('127.0.0.1', 'localhost', 'testserver'):
return Response(status_code=403)
origin = request.headers.get('origin')
if origin and origin != str(request.base_url).rstrip('/'):
return Response(status_code=403)
if request.url.path == '/' and secrets.compare_digest(request.query_params.get('token', ''), browser_token):
response = RedirectResponse('/', status_code=303)
response.set_cookie(cookie_name, browser_token, httponly=True, samesite='strict')
return response
if not secrets.compare_digest(request.cookies.get(cookie_name, ''), browser_token):
return Response('Ouvrez le lien fourni par Copienator.', status_code=403)
response = await call_next(request)
response.headers['Cache-Control'] = 'no-store'
response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Referrer-Policy'] = 'no-referrer'
response.headers['Content-Security-Policy'] = "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'"
return response
@app.exception_handler(ValueError)
@app.exception_handler(CliError)
@app.exception_handler(OSError)
async def invalid(request, exc):
return JSONResponse({'detail': str(exc)}, status_code=409)
@app.get('/api/state')
def state():
return service.state()
@app.get('/api/job')
def job():
with service.mutex:
return dict(service.job)
@app.post('/api/change')
def change(data: Change):
if data.kind != 'general' and not isinstance(data.value, str):
raise ValueError('Texte attendu.')
return service.save(data.model_dump(by_alias=True))
@app.post('/api/regenerate')
def regenerate(data: Regenerate):
return service.start_job(data.revision, data.apply_draft)
@app.post('/api/stop')
def shutdown():
with service.mutex:
if service.job['running']:
raise ValueError('Attendez la fin de la régénération avant de fermer le serveur.')
stop()
return {'stopped': True}
@app.get('/api/image')
def image(copy: str, label: str, body: bool = False):
directory = service.directory(copy)
service.label(label)
if body:
result = body_image(directory, label)
output = BytesIO()
result.save(output, format='PNG')
return Response(output.getvalue(), media_type='image/png')
path = directory / f'{label}.jpg'
if not path.is_file():
raise HTTPException(404, 'Aucune image pour cette réponse.')
return FileResponse(path)
@app.get('/api/document')
def document(copy: str, kind: str, label: str = ''):
directory = service.directory(copy)
if kind == 'original':
path = original_copy_path(service.root, directory.name[5:])
elif kind == 'output':
path = directory / 'Concat_F.pdf'
elif kind == 'context':
from copienator import utils
from pypdf import PdfWriter
paths = utils.pdf_images_of_contexts(service.root, service.label(label), service.labels)
if not paths:
raise HTTPException(404, 'Aucun contexte pour ce label.')
writer = PdfWriter()
for item in paths:
if not Path(item).resolve().is_relative_to(service.root):
raise HTTPException(403)
writer.append(item)
buffer = BytesIO()
writer.write(buffer)
return Response(buffer.getvalue(), media_type='application/pdf')
elif kind in ('question', 'solution'):
path = service.root / ('Text2' if kind == 'question' else 'Sol2') / f'{service.label(label)}.pdf'
else:
raise ValueError('Document inconnu.')
if path is None or not path.is_file():
raise HTTPException(404, 'Ce document est absent.')
if not path.resolve().is_relative_to(service.root):
raise HTTPException(403)
return FileResponse(path, media_type='application/pdf')
@app.post('/api/preview')
def preview(data: Preview):
with service.mutex:
if service.job['running']:
raise ValueError('Régénération en cours.')
directory = service.directory(data.copy_id)
service.label(data.source)
service.label(data.target)
instructions = read_instructions(data.text, service.mode)
validate_instructions(service.root, service.mode, instructions)
_, bodies, _ = edited_bodies(directory, baseline(directory),
[i for i in instructions if i.copy_id == directory.name[5:]])
result = {}
for label in (data.source, data.target):
image = bodies.get(label)
if image:
buffer = BytesIO()
image.save(buffer, format='PNG')
result[label] = 'data:image/png;base64,' + base64.b64encode(buffer.getvalue()).decode()
else:
result[label] = None
return result
app.mount('/', StaticFiles(directory=Path(__file__).parent / 'web_static', html=True), name='web')
return app
class WebSession:
def __init__(self, root: Path, mode: str):
self.lease = EvaluationLock(root)
self.lease.__enter__()
try:
self.service = Finalisation(root, mode, self.lease.token, self.lease.stream.fileno())
if not self.service.copies():
raise ValueError('Lisez les annotations manuscrites dans ce mode avant d’ouvrir la finalisation web.')
self.token = secrets.token_urlsafe(32)
self.socket = socket.socket()
self.socket.bind(('127.0.0.1', 0))
self.port = self.socket.getsockname()[1]
self.app = create_app(self.service, self.token, self.request_stop)
self.server = uvicorn.Server(uvicorn.Config(self.app, log_level='warning', access_log=False))
self.thread = threading.Thread(target=self._run, daemon=True)
self.thread.start()
deadline = time.monotonic() + 5
while not self.server.started and self.thread.is_alive() and time.monotonic() < deadline:
time.sleep(.02)
if not self.server.started:
self.server.should_exit = True
raise RuntimeError('Le serveur web n’a pas démarré.')
except Exception:
self.lease.__exit__()
raise
@property
def url(self):
return f'http://127.0.0.1:{self.port}/?token={self.token}'
def _run(self):
try:
self.server.run(sockets=[self.socket])
finally:
# Never release the GUI/CLI lease while a generation worker is active.
while self.service.job['running']:
time.sleep(.1)
self.socket.close()
self.lease.__exit__()
def request_stop(self):
with self.service.mutex:
if self.service.job['running']:
raise ValueError('Attendez la fin de la régénération.')
self.service.closing = True
self.server.should_exit = True
@property
def alive(self):
return self.thread.is_alive()
+145
View File
@@ -0,0 +1,145 @@
from __future__ import annotations
import os
import re
from collections import Counter
from dataclasses import dataclass
from pathlib import Path
from copienator import EvaluationWorkspace
from copienator.platform import safe_filename
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
@dataclass(frozen=True, slots=True)
class NameIssue:
folder: Path
name: str
copy_id: str
reasons: tuple[str, ...]
def find_name_issues(evaluation: Path) -> list[NameIssue]:
"""Return unknown and duplicated names currently present in A Rendre."""
return_dir = EvaluationWorkspace(evaluation).return_dir
parsed: list[tuple[Path, str, str]] = []
if return_dir.is_dir():
for folder in return_dir.iterdir():
if not folder.is_dir() or folder.is_symlink():
continue
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
if match:
parsed.append(
(folder, match.group("name"), match.group("copy_id"))
)
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
issues = []
for folder, name, copy_id in parsed:
reasons = []
if name.casefold() == "unknown":
reasons.append("nom inconnu")
elif counts[name.casefold()] > 1:
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
if reasons:
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
workspace = EvaluationWorkspace(evaluation)
filename = f"Copie{copy_id}.pdf"
for candidate in (
workspace.original_copies_dir / filename,
workspace.copies_dir / filename,
workspace.root / filename,
):
if candidate.is_file():
return candidate
return None
def rename_return_copy(folder: Path, requested_name: str) -> Path:
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
if not folder.is_dir() or folder.is_symlink() or match is None:
raise ValueError(f"Dossier de copie invalide : {folder}")
if folder.parent.name != "A Rendre":
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
entered_name = requested_name.strip()
if not entered_name:
raise ValueError("Saisissez un nom.")
name = safe_filename(entered_name, fallback="")
if not name:
raise ValueError("Le nom ne contient aucun caractère utilisable.")
copy_id = match.group("copy_id")
destination = folder.with_name(f"{name} ({copy_id})")
if destination != folder and os.path.lexists(destination):
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
assets: dict[str, Path] = {}
for child in folder.iterdir():
suffix = child.suffix.casefold()
if suffix not in {".jpg", ".pdf"}:
continue
if suffix in assets:
raise ValueError(
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
)
assets[suffix] = child
renames: list[tuple[Path, Path]] = []
for suffix, source in assets.items():
target = source.with_name(f"{name}{suffix}")
if target != source and os.path.lexists(target):
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
if target != source:
renames.append((source, target))
completed: list[tuple[Path, Path]] = []
try:
for source, target in renames:
source.rename(target)
completed.append((source, target))
if destination != folder:
folder.rename(destination)
except OSError:
for source, target in reversed(completed):
if os.path.lexists(target) and not os.path.lexists(source):
target.rename(source)
raise
return destination
def return_folders(root: Path) -> dict[int, Path]:
result = {}
for folder in (root / "A Rendre").glob("*"):
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
if not folder.is_dir() or folder.is_symlink() or not match:
continue
key = int(match["copy_id"])
if key in result:
raise ValueError(f"Plusieurs dossiers A Rendre pour la copie {key}.")
result[key] = folder
return result
def set_copy_name(root: Path, copy_id: str, name: str) -> None:
from copienator import atomic_update_json
name = name.strip()
if not name or not safe_filename(name, fallback=""):
raise ValueError("Saisissez un nom utilisable.")
metadata = root / "Copies" / f"Copie{copy_id}.json"
if not metadata.is_file():
raise ValueError("Métadonnées de copie absentes.")
folder = return_folders(root).get(int(copy_id))
renamed = rename_return_copy(folder, name) if folder else None
try:
atomic_update_json(metadata, lambda data: data.update(name=name), default_factory=dict)
except Exception:
if renamed and renamed != folder:
rename_return_copy(renamed, RETURN_FOLDER_PATTERN.fullmatch(folder.name)["name"])
raise
+71
View File
@@ -0,0 +1,71 @@
"""Rendering inputs captured after handwriting interpretation, before user overrides.
Optional for the legacy readers. The web renderer consumes this state without
rescanning annotation PDFs. A selective redo merges only the selected labels.
"""
from copy import deepcopy
from hashlib import sha256
import json
from pathlib import Path
from PIL import Image
from copienator import atomic_write_json, read_json
FILENAME = 'review_snapshot.json'
def stage(output, staging, data, notes, *, engine, bnote=None, selected=None, force_filtered=()):
previous = read_json(output / FILENAME, default=None)
if selected and previous is None:
# Old preserved images cannot tell us their original feedback and ink.
return
if previous and previous['engine'] == 'standard' and engine == 'grouped':
geometry = {item['label']: item for item in previous['bnote']['images']}
for label, note in previous['notes'].items():
note['old_header_h'] = geometry.get(label, {}).get('header_height', 0)
if 'layout' in geometry.get(label, {}):
note['layout'] = geometry[label]['layout']
retained = set(force_filtered)
if selected:
base = read_json(output / 'annotation_baseline.json', default={})
retained.update(set(base.get('filtered', [])) - set(selected))
selected = set(selected or data)
if previous:
# Preserve raw inputs for the labels not re-read in a selective redo.
merged_data = previous['data'] if selected != set(data) else {}
merged_notes = previous['notes'] if selected != set(data) else {}
else:
merged_data, merged_notes = {}, {}
for label in selected:
if label in data:
content = deepcopy(data[label])
path = Path(content['pdf_path']).resolve()
root = output.parent.parent.resolve()
content['pdf_path'] = str(path.relative_to(root)) if path.is_relative_to(root) else str(path)
merged_data[label] = content
merged_notes.pop(label, None)
note = notes.get(label)
if note is not None:
filename = '.review-note-' + sha256(label.encode()).hexdigest() + '.png'
note['img'].save(staging / filename)
merged_notes[label] = {k: v for k, v in note.items() if k != 'img'} | {'file': filename}
payload = {'version': 1, 'engine': engine, 'data': merged_data, 'notes': merged_notes, 'bnote': bnote, 'force_filtered': sorted(retained)}
# Roundtrip normalizes tuples while rejecting unsupported rendering data.
atomic_write_json(staging / FILENAME, json.loads(json.dumps(payload)))
def load(root, output):
saved = read_json(output / FILENAME)
if saved.get('version') != 1:
raise ValueError('Version du résultat de lecture inconnue. Relisez les annotations.')
data = saved['data']
for content in data.values():
path = Path(content['pdf_path'])
content['pdf_path'] = path if path.is_absolute() else root / path
notes = {}
for label, metadata in saved['notes'].items():
filename = metadata['file']
if Path(filename).name != filename:
raise ValueError('Image manuscrite invalide.')
with Image.open(output / filename) as image:
notes[label] = {k: v for k, v in metadata.items() if k != 'file'} | {'img': image.copy()}
return saved, data, notes
+58
View File
@@ -0,0 +1,58 @@
from decimal import Decimal, InvalidOperation
from pathlib import Path
from copienator import atomic_update_json, read_json
def read_scores(path: Path) -> dict:
scores = read_json(annotation_score_for_return(path))
if not isinstance(scores, dict):
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
return scores
def set_score(path: Path, label: str, value: str) -> str:
try:
number = Decimal(value.strip().replace(",", "."))
except InvalidOperation:
raise ValueError("Le score doit être un nombre.") from None
if not number.is_finite() or number < 0:
raise ValueError("Le score doit être un nombre positif ou nul.")
previous = ""
def update(scores):
nonlocal previous
if not isinstance(scores, dict) or label not in scores:
raise ValueError(f"Label absent de score.json : {label}")
previous = str(scores[label])
scores[label] = str(number)
# A Rendre/score.json is often a symlink to the annotation score file.
# Resolve it before atomic replacement so the link continues to work.
atomic_update_json(annotation_score_for_return(path).resolve(strict=True), update, default_factory=dict)
return previous
def annotation_score_for_return(path: Path) -> Path:
"""Resolve fallback return copies as well as symlinks to their source scores."""
if path.is_symlink() or path.parent.parent.name != 'A Rendre':
return path.resolve()
from copienator.return_identity import RETURN_FOLDER_PATTERN
match = RETURN_FOLDER_PATTERN.fullmatch(path.parent.name)
if not match:
return path
root = path.parent.parent.parent
cid = match['copy_id']
sources = read_json(root / '.copienator' / 'return_sources.json', default={})
mode = sources.get(str(int(cid)))
modes = (mode,) if mode in ('Bnot', 'BGnot', 'Anot') else ('Bnot', 'BGnot', 'Anot')
candidates = [p for m in modes for p in (root / m).glob('Copie*/score.json')
if p.parent.name[5:].isdigit() and int(p.parent.name[5:]) == int(cid)]
if len(candidates) > 1:
raise ValueError('Source des scores ambiguë : relancez la préparation de A Rendre dans le mode souhaité.')
return candidates[0] if candidates else path
def remember_return_source(root: Path, copy_id: str, source: Path):
path = root / '.copienator' / 'return_sources.json'
path.parent.mkdir(exist_ok=True)
atomic_update_json(path, lambda data: data.update({str(int(copy_id)): source.parent.name}), default_factory=dict)
@@ -0,0 +1 @@
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#23323a;background:#f3f5f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:3px}button{border:1px solid #c8d3ce;border-radius:7px;background:#fff;color:#244b40;padding:9px 13px;cursor:pointer;font-weight:550}button:hover:not(:disabled){background:#e9f1ed;border-color:#769b88}button:disabled{opacity:.45;cursor:default}.primary{background:#205c48;color:#fff;border-color:#205c48}.primary:hover:not(:disabled){background:#174633;color:#fff}a{color:#21654e;text-decoration:none}a:hover{text-decoration:underline}header{display:flex;align-items:center;gap:32px;padding:20px 36px;border-bottom:1px solid #dbe3de;background:#fff}.brand{display:flex;align-items:center;gap:10px;font-size:23px;font-weight:800;color:#28684f}.brand span{font-size:16px;font-weight:650}.heading{flex:1;border-left:1px solid #dbe3de;padding-left:30px}h1{font-size:22px;margin:3px 0 0}h2{font-size:20px;margin:0 0 8px}h3{font-size:19px;margin:7px 0}p{line-height:1.55;margin:8px 0;color:#56666b}.eyebrow{font-size:11px;letter-spacing:.09em;color:#5c7470;font-weight:650}main{max-width:1540px;margin:auto;padding:30px 36px 60px}.toolbar{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.filters{display:flex;gap:12px;align-items:end}label{display:flex;flex-direction:column;gap:6px;color:#405650;font-size:12px;font-weight:600}input,select,textarea{border:1px solid #c5d1cb;border-radius:6px;background:#fff;padding:9px 10px;color:#263d34;font-size:14px;min-width:0}textarea{resize:vertical;min-height:76px;line-height:1.5}input:focus,textarea:focus,select:focus{outline:2px solid #80b69c;border-color:#4d8269}input[type=checkbox]{width:16px;height:16px;accent-color:#205c48}.check{display:flex;flex-direction:row;align-items:center;max-width:310px;font-size:12px;line-height:1.4}.publish{display:flex;align-items:center;gap:16px;background:#e8f0eb;border:1px solid #d0e0d5;border-radius:10px;padding:17px 20px;margin:20px 0}.publish>div{flex:1}.publish small{display:block;margin-top:5px;color:#5b6e64}.panel{padding:18px 20px;background:#fff;border:1px solid #d9e2dd;border-radius:9px;margin-bottom:14px}summary{cursor:pointer;font-weight:600}summary span{float:right;font-size:12px;font-weight:400;color:#65766f}.identity{border-top:1px solid #e1e7e3;padding:18px 0;display:grid;grid-template-columns:280px 1fr;gap:28px}.identity-name{display:flex;flex-direction:column;gap:10px;align-items:start}.identity-name label{width:100%}.general button{margin:5px 6px 0 0}.remark-row{display:flex;align-items:center;gap:6px;margin-bottom:8px}.remark-row textarea{flex:1}.instructions{width:100%;font-family:ui-monospace,monospace;min-height:140px;margin:10px 0}.panel details{margin-top:16px}.results{display:flex;justify-content:space-between;align-items:center;margin:30px 0 12px}.results span{color:#677872;font-size:12px}.answer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.answer{border:1px solid #d9e1dd;background:#fff;border-radius:10px;overflow:hidden}.card-heading{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;gap:12px}.badge{font-size:11px;background:#edf1ee;color:#5a6e62;border-radius:4px;padding:5px 7px;white-space:nowrap}.warning{background:#fff2d8;color:#8a620b}.image-space{height:290px;overflow:hidden;background:#e7ece9;border-top:1px solid #e2e8e3;border-bottom:1px solid #e2e8e3;display:flex;justify-content:center;align-items:start;padding:12px}.image-button{position:relative;border:0;padding:0;background:#fff;width:100%;height:100%;overflow:hidden;border-radius:0}.image-button img{width:100%;display:block}.image-button span{position:absolute;bottom:8px;right:8px;background:#203d32e8;color:#fff;border-radius:5px;padding:7px 10px;font-size:12px}.answer-form{display:grid;grid-template-columns:88px 1fr;gap:15px;padding:18px 20px 10px}.card-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:8px 20px 20px}.card-actions a{font-size:12px}.empty{padding:32px;color:#77877e;font-style:italic;text-align:center}.load-more{display:block;margin:24px auto}.alert{background:#fff0eb;color:#8f3827;border:1px solid #e9b6a9;border-radius:7px;padding:12px;display:flex;gap:12px;align-items:center}.alert small{font-size:12px}.notice{color:#216044}.muted{font-size:12px}fieldset{padding:0;margin:0;border:0;min-width:0}.overlay{position:fixed;inset:0;background:#142d23b8;z-index:10;display:flex;align-items:center;justify-content:center;padding:24px}.modal{width:min(1300px,100%);max-height:94vh;overflow:auto;border-radius:12px;background:#f8faf8;padding:24px;box-shadow:0 24px 80px #001c2e55}.modal-head{display:flex;justify-content:space-between;align-items:center;gap:20px}.cut-controls{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}.compare{display:grid;grid-template-columns:1fr 1fr;gap:24px}.compare>div{mLine truncated
@@ -0,0 +1,15 @@
(function(){const G=document.createElement("link").relList;if(G&&G.supports&&G.supports("modulepreload"))return;for(const q of document.querySelectorAll('link[rel="modulepreload"]'))y(q);new MutationObserver(q=>{for(const $ of q)if($.type==="childList")for(const rl of $.addedNodes)rl.tagName==="LINK"&&rl.rel==="modulepreload"&&y(rl)}).observe(document,{childList:!0,subtree:!0});function Y(q){const $={};return q.integrity&&($.integrity=q.integrity),q.referrerPolicy&&($.referrerPolicy=q.referrerPolicy),q.crossOrigin==="use-credentials"?$.credentials="include":q.crossOrigin==="anonymous"?$.credentials="omit":$.credentials="same-origin",$}function y(q){if(q.ep)return;q.ep=!0;const $=Y(q);fetch(q.href,$)}})();var zs={exports:{}},nn={};var lv;function ih(){if(lv)return nn;lv=1;var _=Symbol.for("react.transitional.element"),G=Symbol.for("react.fragment");function Y(y,q,$){var rl=null;if($!==void 0&&(rl=""+$),q.key!==void 0&&(rl=""+q.key),"key"in q){$={};for(var Gl in q)Gl!=="key"&&($[Gl]=q[Gl])}else $=q;return q=$.ref,{$$typeof:_,type:y,key:rl,ref:q!==void 0?q:null,props:$}}return nn.Fragment=G,nn.jsx=Y,nn.jsxs=Y,nn}var tv;function ch(){return tv||(tv=1,zs.exports=ih()),zs.exports}var d=ch(),_s={exports:{}},Z={};var uv;function fh(){if(uv)return Z;uv=1;var _=Symbol.for("react.transitional.element"),G=Symbol.for("react.portal"),Y=Symbol.for("react.fragment"),y=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),$=Symbol.for("react.consumer"),rl=Symbol.for("react.context"),Gl=Symbol.for("react.forward_ref"),ul=Symbol.for("react.suspense"),Dl=Symbol.for("react.memo"),R=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),M=Symbol.for("react.view_transition"),_l=Symbol.iterator;function Xl(s){return s===null||typeof s!="object"?null:(s=_l&&s[_l]||s["@@iterator"],typeof s=="function"?s:null)}var Ul={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},il=Object.assign,et={};function Ol(s,E,D){this.props=s,this.context=E,this.refs=et,this.updater=D||Ul}Ol.prototype.isReactComponent={},Ol.prototype.setState=function(s,E){if(typeof s!="object"&&typeof s!="function"&&s!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,E,"setState")},Ol.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")};function gt(){}gt.prototype=Ol.prototype;function Ql(s,E,D){this.props=s,this.context=E,this.refs=et,this.updater=D||Ul}var at=Ql.prototype=new gt;at.constructor=Ql,il(at,Ol.prototype),at.isPureReactComponent=!0;var xl=Array.isArray;function K(){}var F={H:null,A:null,T:null,S:null},Vl=Object.prototype.hasOwnProperty;function Nl(s,E,D){var j=D.ref;return{$$typeof:_,type:s,key:E,ref:j!==void 0?j:null,props:D}}function pl(s,E){return Nl(s.type,E,s.props)}function Ll(s){return typeof s=="object"&&s!==null&&s.$$typeof===_}function jt(s){var E={"=":"=0",":":"=2"};return"$"+s.replace(/[=:]/g,function(D){return E[D]})}var L=/\/+/g;function hl(s,E){return typeof s=="object"&&s!==null&&s.key!=null?jt(""+s.key):E.toString(36)}function C(s){switch(s.status){case"fulfilled":return s.value;case"rejected":throw s.reason;default:switch(typeof s.status=="string"?s.then(K,K):(s.status="pending",s.then(function(E){s.status==="pending"&&(s.status="fulfilled",s.value=E)},function(E){s.status==="pending"&&(s.status="rejected",s.reason=E)})),s.status){case"fulfilled":return s.value;case"rejected":throw s.reason}}throw s}function B(s,E,D,j,k){var al=typeof s;(al==="undefined"||al==="boolean")&&(s=null);var sl=!1;if(s===null)sl=!0;else switch(al){case"bigint":case"string":case"number":sl=!0;break;case"object":switch(s.$$typeof){case _:case G:sl=!0;break;case R:return sl=s._init,B(sl(s._payload),E,D,j,k)}}if(sl)return k=k(s),sl=j===""?"."+hl(s,0):j,xl(k)?(D="",sl!=null&&(D=sl.replace(L,"$&/")+"/"),B(k,E,D,"",function(nu){return nu})):k!=null&&(Ll(k)&&(k=pl(k,D+(k.key==null||s&&s.key===k.key?"":(""+k.key).replace(L,"$&/")+"/")+sl)),E.push(k)),1;sl=0;var x=j===""?".":j+":";if(xl(s))for(var V=0;V<s.length;V++)j=s[V],al=x+hl(j,V),sl+=B(j,E,D,al,k);else if(V=Xl(s),typeof V=="function")for(s=V.call(s),V=0;!(j=s.next()).done;)j=j.value,al=x+hl(j,V++),sl+=B(j,E,D,al,k);else if(al==="object"){if(typeof s.then=="function")return B(C(s),E,D,j,k);throw E=String(s),Error("Objects are not valid as a React child (found: "+(E==="[object Object]"?"object with keys {"+Object.keys(s).join(", ")+"}":E)+"). If you meant to render a collection of children, use an array instead.")}return sl}function X(s,E,D){if(s==null)return s;var j=[],k=0;return B(s,j,"","",function(al){return E.call(D,al,k++)}),j}function cl(s){if(s._status===-1){var E=s._result,D=E();D.then(function(j){(s._status===0||s._status===-1)&&(s._status=1,s._result=j,D.status===void 0&&(D.status="fulfilled",D.value=j))},function(j){(s._status===0||s._status===-1)&&(s._status=2,s._result=j,D.statLine truncated
at`)?" (<anonymous>)":-1<u.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Vi+l+js}var Zi=!1;function Li(l,t){if(!l||Zi)return"";Zi=!0;var u=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var e={DetermineComponentFrameRoot:function(){try{if(t){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(A){var r=A}Reflect.construct(l,[],z)}else{try{z.call()}catch(A){r=A}z=!1;try{var g=Object.getOwnPropertyDescriptor(l.prototype,"props");Object.defineProperty(l.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new l}finally{z&&(g!==void 0?Object.defineProperty(l.prototype,"props",g):delete l.prototype.props)}}}else{try{throw Error()}catch(A){r=A}(z=l())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(A){if(A&&r&&typeof A.stack=="string")return[A.stack,r.stack]}return[null,null]}};e.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(e.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(e.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=e.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
`),m=c.split(`
`);for(a=e=0;e<f.length&&!f[e].includes("DetermineComponentFrameRoot");)e++;for(;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;if(e===f.length||a===m.length)for(e=f.length-1,a=m.length-1;1<=e&&0<=a&&f[e]!==m[a];)a--;for(;1<=e&&0<=a;e--,a--)if(f[e]!==m[a]){if(e!==1||a!==1)do if(e--,a--,0>a||f[e]!==m[a]){var S=`
`+f[e].replace(" at new "," at ");return l.displayName&&S.includes("<anonymous>")&&(S=S.replace("<anonymous>",l.displayName)),S}while(1<=e&&0<=a);break}}}finally{Zi=!1,Error.prepareStackTrace=u}return(u=l?l.displayName||l.name:"")?zu(u):""}function ov(l,t){switch(l.tag){case 26:case 27:case 5:return zu(l.type);case 16:return zu("Lazy");case 13:return l.child!==t&&t!==null?zu("Suspense Fallback"):zu("Suspense");case 19:return zu("SuspenseList");case 0:case 15:return Li(l.type,!1);case 11:return Li(l.type.render,!1);case 1:return Li(l.type,!0);case 31:return zu("Activity");case 30:return zu("ViewTransition");default:return""}}function Ms(l){try{var t="",u=null;do t+=ov(l,u),u=l,l=l.return;while(l);return t}catch(e){return`
Error generating stack: `+e.message+`
`+e.stack}}var Ki=Object.prototype.hasOwnProperty,Ji=_.unstable_scheduleCallback,wi=_.unstable_cancelCallback,rv=_.unstable_shouldYield,dv=_.unstable_requestPaint,St=_.unstable_now,vv=_.unstable_getCurrentPriorityLevel,Us=_.unstable_ImmediatePriority,ps=_.unstable_UserBlockingPriority,dn=_.unstable_NormalPriority,mv=_.unstable_LowPriority,Rs=_.unstable_IdlePriority,yv=_.log,hv=_.unstable_setDisableYieldValue,da=null,bt=null;function _u(l){if(typeof yv=="function"&&hv(l),bt&&typeof bt.setStrictMode=="function")try{bt.setStrictMode(da,l)}catch{}}var Tt=Math.clz32?Math.clz32:bv,gv=Math.log,Sv=Math.LN2;function bv(l){return l>>>=0,l===0?32:31-(gv(l)/Sv|0)|0}var vn=256,mn=262144,yn=4194304;function Wu(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&-l;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function hn(l,t,u){var e=l.pendingLanes;if(e===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=e&134217727;return c!==0?(e=c&~n,e!==0?a=Wu(e):(i&=c,i!==0?a=Wu(i):u||(u=c&~l,u!==0&&(a=Wu(u))))):(c=e&~n,c!==0?a=Wu(c):i!==0?a=Wu(i):u||(u=e&~l,u!==0&&(a=Wu(u)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,u=t&-t,n>=u||n===32&&(u&4194048)!==0)?t:a}function va(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function xs(l,t){(t&8)!==0&&(t|=t&32);var u=l.entangledLanes;if(u!==0)for(l=l.entanglements,u&=t;0<u;){var e=31-Tt(u),a=1<<e;t|=l[e],u&=~a}return t}function Tv(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hs(){var l=yn;return yn<<=1,(yn&62914560)===0&&(yn=4194304),l}function $i(l){for(var t=[],u=0;31>u;u++)t.push(l);return t}function ma(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Ev(l,t,u,e,a,n){var i=l.pendingLanes;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=u,l.entangledLanes&=u,l.errorRecoveryDisabledLanes&=u,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,m=l.hiddenUpdates;for(u=i&~u;0<u;){var S=31-Tt(u),z=1<<S;c[S]=0,f[S]=-1;var r=m[S];if(r!==null)for(m[S]=null,S=0;S<r.length;S++){var g=r[S];g!==null&&(g.lane&=-536870913)}u&=~z}e!==0&&qs(l,e,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function qs(l,t,u){l.pendingLanes|=t,l.suspendedLanes&=~t;var e=31-Tt(t);l.entangledLanes|=t,l.entanglements[e]=l.entanglements[e]|1073741824|u&261930}function Ys(l,t){var u=l.entangledLanes|=t;for(l=l.entanglements;u;){var e=31-Tt(u),a=1<<e;a&t|l[e]&t&&(l[e]|=t),u&=~a}}function Bs(l,t){var u=t&-t;return u=(u&42)!==0?1:Fi(u),(u&(l.suspendedLanes|t))!==0?0:u}function Fi(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function Wi(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Gs(){var l=V.p;return l!==0?l:(l=window.event,l===void 0?32:w0(l.type))}function Xs(l,t){var u=V.p;try{return V.p=l,t()}finally{V.p=u}}var iu=Math.random().toString(36).slice(2),Wl="__reactFiber$"+iu,rt="__reactProps$"+iu,Ee="__reactContainer$"+iu,Qs="__reactEvents$"+iu,zv="__reactListeners$"+iu,_v="__reactHandles$"+iu,Vs="__reactResources$"+iu,ya="__reactMarker$"+iu,gn="__reactLoad$"+iu;function Sn(l){delete l[Wl],delete l[rt],delete l[zv],delete l[_v]}function ku(l){var t;if(t=l[Wl])return t;for(var u=l.parentNode;u;){if(t=u[Ee]||u[Wl]){if(u=t.alternate,t.child!==null||u!==null&&u.child!==null)for(l=C0(l);l!==null;){if(u=l[Wl])return u;l=C0(l)}return t}l=u,u=l.parentNode}return null}function ze(l){if(l=l[Wl]||l[Ee]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function ha(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(y(33))}function _e(l){var t=l[Vs];return t||(t=l[Vs]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Kl(l){l[ya]=!0}function Zs(l){l[gn]=void 0}var Ls=new Set,Ks={};function Iu(l,t){Oe(l,t),Oe(l+"Capture",t)}function Line truncated
`).replace(oy,"")}function c0(l,t){return t=i0(t),i0(l)===t}function yl(l,t,u,e,a,n){switch(u){case"children":if(typeof e=="string")t==="body"||t==="textarea"&&e===""||Ae(l,e);else if(typeof e=="number"||typeof e=="bigint")t!=="body"&&Ae(l,""+e);else return;break;case"className":Tn(l,"class",e);break;case"tabIndex":Tn(l,"tabindex",e);break;case"dir":case"role":case"viewBox":case"width":case"height":Tn(l,u,e);break;case"style":to(l,e,n);return;case"data":if(t!=="object"){Tn(l,"data",e);break}case"src":case"href":if(e===""&&(t!=="a"||u!=="href")){l.removeAttribute(u);break}if(e==null||typeof e=="function"||typeof e=="symbol"||typeof e=="boolean"){l.removeAttribute(u);break}e=En(e),l.setAttribute(u,e);break;case"action":case"formAction":if(typeof e=="function"){l.setAttribute(u,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof n=="function"&&(u==="formAction"?(t!=="input"&&yl(l,t,"name",a.name,a,null),yl(l,t,"formEncType",a.formEncType,a,null),yl(l,t,"formMethod",a.formMethod,a,null),yl(l,t,"formTarget",a.formTarget,a,null)):(yl(l,t,"encType",a.encType,a,null),yl(l,t,"method",a.method,a,null),yl(l,t,"target",a.target,a,null)));if(e==null||typeof e=="symbol"||typeof e=="boolean"){l.removeAttribute(u);break}e=En(e),l.setAttribute(u,e);break;case"onClick":e!=null&&(l.onclick=$t);return;case"onScroll":e!=null&&P("scroll",l);return;case"onScrollEnd":e!=null&&P("scrollend",l);return;case"dangerouslySetInnerHTML":if(e!=null){if(typeof e!="object"||!("__html"in e))throw Error(y(61));if(u=e.__html,u!=null){if(a.children!=null)throw Error(y(60));n?.__html!==u&&(l.innerHTML=u)}}break;case"multiple":l.multiple=e&&typeof e!="function"&&typeof e!="symbol";break;case"muted":l.muted=e&&typeof e!="function"&&typeof e!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(e==null||typeof e=="function"||typeof e=="boolean"||typeof e=="symbol"){l.removeAttribute("xlink:href");break}u=En(e),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",u);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":e!=null&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,e):l.removeAttribute(u);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":e&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,""):l.removeAttribute(u);break;case"capture":case"download":e===!0?l.setAttribute(u,""):e!==!1&&e!=null&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,e):l.removeAttribute(u);break;case"cols":case"rows":case"size":case"span":e!=null&&typeof e!="function"&&typeof e!="symbol"&&!isNaN(e)&&1<=e?l.setAttribute(u,e):l.removeAttribute(u);break;case"rowSpan":case"start":e==null||typeof e=="function"||typeof e=="symbol"||isNaN(e)?l.removeAttribute(u):l.setAttribute(u,e);break;case"popover":P("beforetoggle",l),P("toggle",l),bn(l,"popover",e);break;case"xlinkActuate":cu(l,"http://www.w3.org/1999/xlink","xlink:actuate",e);break;case"xlinkArcrole":cu(l,"http://www.w3.org/1999/xlink","xlink:arcrole",e);break;case"xlinkRole":cu(l,"http://www.w3.org/1999/xlink","xlink:role",e);break;case"xlinkShow":cu(l,"http://www.w3.org/1999/xlink","xlink:show",e);break;case"xlinkTitle":cu(l,"http://www.w3.org/1999/xlink","xlink:title",e);break;case"xlinkType":cu(l,"http://www.w3.org/1999/xlink","xlink:type",e);break;case"xmlBase":cu(l,"http://www.w3.org/XML/1998/namespace","xml:base",e);break;case"xmlLang":cu(l,"http://www.w3.org/XML/1998/namespace","xml:lang",e);break;case"xmlSpace":cu(l,"http://www.w3.org/XML/1998/namespace","xml:space",e);break;case"is":bn(l,"is",e);break;case"innerText":case"textContent":return;default:if(!(2<u.length)||u[0]!=="o"&&u[0]!=="O"||u[1]!=="n"&&u[1]!=="N")u=jv.get(u)||u,bn(l,u,e);else return}fl=!0}function Pf(l,t,u,e,a,n){switch(u){case"style":to(l,e,n);return;case"dangerouslySetInnerHTML":if(e!=null){if(typeof e!="object"||!("__html"in e))throw Error(y(61));if(u=e.__html,u!=null){if(a.children!=null)throw Error(y(60));n?.__html!==u&&(l.innerHTML=u)}}break;case"children":if(typeof e=="string")Ae(l,e);else if(typeof e=="number"||typeof e=="bigint")Ae(l,""+e);else return;break;case"onScroll":e!=null&&P("scroll",l);return;case"onScrollEnd":e!=null&&P("scrollend",l);return;case"onClick":e!=null Line truncated
`).map((O,N,s)=>O.trim()&&!O.trim().startsWith("###")?d.jsxs("div",{className:"draft-row",children:[d.jsx("code",{children:O}),d.jsx("button",{"aria-label":`Monter l’instruction ${N}`,disabled:N===0||s[N-1].startsWith("###"),onClick:()=>{const E=[...s];[E[N-1],E[N]]=[E[N],E[N-1]],Nl("draft","","",E.join(`
`))},children:"↑"}),d.jsx("button",{"aria-label":`Descendre l’instruction ${N}`,disabled:N===s.length-1||!s[N+1].trim(),onClick:()=>{const E=[...s];[E[N+1],E[N]]=[E[N],E[N+1]],Nl("draft","","",E.join(`
`))},children:"↓"}),d.jsx("button",{onClick:()=>Nl("draft","","",s.filter((E,D)=>D!==N).join(`
`)),children:"Retirer"})]},N):null)}),d.jsxs("details",{children:[d.jsx("summary",{children:"Éditer les instructions en texte"}),d.jsx("textarea",{className:"instructions","aria-label":"Brouillon des transferts",value:C,onChange:O=>Nl("draft","","",O.target.value)})]}),d.jsx("button",{disabled:!M[Gt("draft")],onClick:()=>pl([Gt("draft")]),children:"Enregistrer le brouillon"}),d.jsxs("details",{children:[d.jsx("summary",{children:"Instructions actuellement appliquées"}),d.jsx("pre",{children:_.applied})]})]}),d.jsxs("div",{className:"results",children:[d.jsxs("h2",{children:[cl.length," réponse",cl.length!==1?"s":""]}),d.jsx("span",{children:!rl&&!ul?"Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification":"Images annotées après lecture manuscrite"})]}),d.jsx("div",{className:"answer-grid",children:cl.slice(0,R).map(({c:O,a:N})=>d.jsxs("article",{className:"answer",children:[d.jsxs("div",{className:"card-heading",children:[d.jsxs("div",{children:[d.jsxs("span",{className:"eyebrow",children:["COPIE ",O.id," · ",O.name]}),d.jsx("h3",{children:N.label})]}),d.jsx("span",{className:"badge",children:N.info.present?N.info.not_empty?N.info.touched?"Dans le PDF":"Hors PDF filtré":"Réponse vide":"Absente"})]}),d.jsx("div",{className:"image-space",children:N.image?d.jsxs("button",{className:"image-button","aria-label":`Agrandir copie ${O.id} ${N.label}`,onClick:()=>at({copy:O.id,label:N.label}),children:[d.jsx("img",{loading:"lazy",src:Gi(O.id,N.label,!1,_.revision),alt:`Réponse annotée — copie ${O.id}, ${N.label}`}),d.jsx("span",{children:"Agrandir ↗"})]}):d.jsx("p",{className:"empty",children:"Aucune image pour ce label."})}),d.jsxs("div",{className:"answer-form",children:[d.jsxs("label",{children:["Score",d.jsx("input",{inputMode:"decimal",value:Vl("score",O.id,N.label,N.score),onChange:s=>Nl("score",O.id,N.label,s.target.value)})]}),d.jsxs("label",{children:["Remarque personnelle",d.jsx("textarea",{placeholder:"Ajouter une remarque (LaTeX accepté)",value:Vl("remark",O.id,N.label,N.remark),onChange:s=>Nl("remark",O.id,N.label,s.target.value)})]})]}),d.jsxs("div",{className:"card-actions",children:[d.jsx("button",{disabled:!M[Gt("score",O.id,N.label)]&&!M[Gt("remark",O.id,N.label)],onClick:()=>pl([Gt("score",O.id,N.label),Gt("remark",O.id,N.label)]),children:"Enregistrer"}),d.jsx("button",{disabled:!N.editableBody,onClick:()=>gt({copy:O.id,label:N.label}),children:"Couper / transférer"}),d.jsx("a",{href:fn(O.id,"context",N.label),target:"_blank",rel:"noreferrer",children:"Contexte ↗"}),d.jsx("a",{href:fn(O.id,"question",N.label),target:"_blank",rel:"noreferrer",children:"Énoncé ↗"}),d.jsx("a",{href:fn(O.id,"solution",N.label),target:"_blank",rel:"noreferrer",children:"Corrigé ↗"}),d.jsx("a",{href:fn(O.id,"output"),target:"_blank",rel:"noreferrer",children:"PDF final ↗"})]})]},O.id+"|"+N.label))}),cl.length===0&&d.jsx("p",{className:"empty",children:"Aucune réponse ne correspond à ces filtres."}),cl.length>R&&d.jsx("button",{className:"load-more",onClick:()=>b(R+20),children:"Afficher les 20 suivantes"})]}),(_.job.log||_.job.running)&&d.jsxs("details",{className:"panel",open:_.job.status==="failed",children:[d.jsxs("summary",{children:["Journal de régénération · ",_.job.status==="failed"?"Échec / résultat incomplet":_.job.running?"En cours":"Terminée"]}),d.jsx("pre",{className:"log",children:_.job.log})]})]}),Ol&&d.jsx(Sh,{copy:Ol.copy,source:Ol.label,labels:_.labels,mode:_.mode,revision:_.revision,draft:C,close:()=>gt(null),accept:O=>{Nl("draft","","",O),gt(null),$("Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.")}}),Ql&&d.jsx(gh,{copy:Ql.copy,label:Ql.label,revision:_.revision,close:()=>at(null)})]})}function gh({copy:_,label:G,revision:Y,close:y}){const[q,$]=Sl.useState(100);return Sl.useEffect(()=>{const rl=Gl=>{Gl.key==="Escape"&&y()};return window.addEventListener("keydown",rl),()=>window.removeEventListener("keydown",rl)},[]),d.jsx("div",{className:"overlay",children:d.jsxs("section",{className:"modal viewer",role:"dialog","aria-modal":"true","aria-label":`Copie ${_} ${G}`,children:[d.jsxs("div",{className:"modal-head",children:[d.jsxs("h2",{children:["Copie ",_," · ",G]}),d.jsxs("label",{children:["Zoom ",d.jsx("input",{type:"range",min:"40",max:"200",value:q,onChange:rl=>$(Number(rl.target.value))}),q,"%"]}),d.jsx("button",{autoFocus:!0,onClick:y,children:"Fermer"})]}),d.jsx("div",{className:"zoom-scroll",children:d.jsx("img",{src:Gi(_,G,!1,Y),alt:`Copie ${_}, ${G}`,style:{width:q+"%",maxWidth:"none"}})})]})})}function Sh({copy:_,source:G,labels:Y,mode:y,revision:q,draft:$,close:rl,accept:Gl}){const[ul,Dl]=Sl.useState(Y.find(L=>L!==G)||""),[R,b]=Sl.useState("cut"),[M,_l]=Sl.useState(50),[Xl,Ul]=Sl.useState("1"),[il,et]=Sl.useState(">"),[Ol,gt]=Sl.useState(!1),[Ql,at]=Sl.useState(""),[xl,K]=Sl.useState(null),[F,Vl]=Sl.useState(!1),Nl=R==="cut"?`c{${M}}${Xl}${il}`:R,pl=`Copie${_} ${G} ${Nl} ${Ol?"|":""}${ul}${Ol?"":"|Line truncated
`+pl+`
`;Sl.useEffect(()=>K(null),[ul,R,M,Xl,il,Ol,$]);const jt=async()=>{Vl(!0),at("");try{K(await oa("preview",{copy:_,source:G,target:ul,text:Ll}))}catch(L){at(String(L))}finally{Vl(!1)}};return d.jsx("div",{className:"overlay",children:d.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-label":"Couper ou transférer une réponse",children:[d.jsxs("div",{className:"modal-head",children:[d.jsxs("div",{children:[d.jsxs("span",{className:"eyebrow",children:["COPIE ",_," · ",y]}),d.jsx("h2",{children:"Couper ou transférer"})]}),d.jsx("button",{autoFocus:!0,onClick:rl,children:"Fermer"})]}),d.jsx("p",{children:"Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe."}),Ql&&d.jsx("p",{role:"alert",className:"alert",children:Ql}),d.jsxs("div",{className:"cut-controls",children:[d.jsxs("label",{children:["Destination",d.jsx("select",{value:ul,onChange:L=>Dl(L.target.value),children:Y.filter(L=>L!==G).map(L=>d.jsx("option",{children:L},L))})]}),d.jsxs("label",{children:["Opération",d.jsxs("select",{value:R,onChange:L=>b(L.target.value),children:[d.jsx("option",{value:"cut",children:"Couper la source"}),d.jsx("option",{value:"x>",children:"Déplacer et ajouter"}),d.jsx("option",{value:"->",children:"Copier et ajouter"}),d.jsx("option",{value:"xx",children:"Déplacer et remplacer"}),d.jsx("option",{value:"-x",children:"Copier et remplacer"}),d.jsx("option",{value:"xs",children:"Vider la source"}),d.jsx("option",{value:"sx",children:"Vider la cible"}),d.jsx("option",{value:"ss",children:"Conserver les deux"})]})]}),R==="cut"&&d.jsxs(d.Fragment,{children:[d.jsxs("label",{children:["Coupe : ",M,"%",d.jsx("input",{type:"range",min:"1",max:"99",step:"0.1",value:M,onChange:L=>_l(Number(L.target.value))})]}),d.jsxs("label",{children:["Conserver à la source",d.jsxs("select",{value:Xl,onChange:L=>Ul(L.target.value),children:[d.jsx("option",{value:"1",children:"La partie haute"}),d.jsx("option",{value:"2",children:"La partie basse"})]})]}),d.jsxs("label",{children:["À la destination",d.jsxs("select",{value:il,onChange:L=>et(L.target.value),children:[d.jsx("option",{value:">",children:"Ajouter le fragment"}),d.jsx("option",{value:"x",children:"Remplacer le corps"})]})]})]}),d.jsxs("label",{className:"check",children:[d.jsx("input",{type:"checkbox",checked:Ol,onChange:L=>gt(L.target.checked)}),"Placer avant la cible"]})]}),d.jsxs("div",{className:"compare",children:[d.jsxs("div",{children:[d.jsxs("h3",{children:["Source · ",G]}),d.jsxs("div",{className:"cut-image",onClick:L=>{if(R==="cut"){const hl=L.currentTarget.getBoundingClientRect();_l(Math.min(99,Math.max(1,Math.round((L.clientY-hl.top)/hl.height*1e3)/10)))}},children:[d.jsx("img",{src:Gi(_,G,!0,q),alt:"Corps source sans en-tête"}),R==="cut"&&d.jsx("div",{className:"cut-line",style:{top:M+"%"},children:d.jsxs("span",{children:[M,"%"]})})]})]}),d.jsxs("div",{children:[d.jsxs("h3",{children:["Cible · ",ul]}),d.jsx(bh,{copy:_,label:ul,revision:q},ul)]})]}),d.jsx("code",{className:"instruction-line",children:pl}),d.jsxs("div",{className:"modal-actions",children:[d.jsx("button",{disabled:F||!ul,onClick:jt,children:F?"Calcul de l’aperçu…":"Aperçu du résultat"}),d.jsx("button",{className:"primary",disabled:!xl||F,onClick:()=>Gl(Ll),children:"Ajouter au brouillon"})]}),xl&&d.jsxs(d.Fragment,{children:[d.jsx("h3",{children:"Résultat proposé après toutes les instructions du brouillon"}),d.jsx("div",{className:"compare",children:[G,ul].map(L=>d.jsxs("div",{children:[d.jsx("h4",{children:L}),xl[L]?d.jsx("img",{src:xl[L],alt:"Aperçu "+L}):d.jsx("p",{className:"empty",children:"Corps vide"})]},L))})]}),d.jsx("p",{className:"muted",children:"Une instruction par source ; une cible ne peut pas être source. Modifiez ou retirez une instruction existante dans le panneau « Transferts et coupes »."})]})})}function bh({copy:_,label:G,revision:Y}){const[y,q]=Sl.useState(!1);return y?d.jsx("p",{className:"empty",children:"Pas de corps existant. Le transfert peut remplir cette cible."}):d.jsx("img",{src:Gi(_,G,!0,Y),alt:"Corps cible sans en-tête",onError:()=>q(!0)})}yh.createRoot(document.getElementById("root")).render(d.jsx(hh,{}));
+3
View File
@@ -0,0 +1,3 @@
<!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Copienator · Finalisation</title> <script type="module" crossorigin src="./assets/index-B8v-OSfB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B6ukjHTb.css">
</head><body><div id="root"></div></body></html>