diff --git a/.gitignore b/.gitignore index b1e0bad..410a067 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ config.py .copienator-gui.json .copienator/ tmp/ +/web/node_modules/ +.copienator-finalisation.lock diff --git a/copienator/annotation_read_cache.py b/copienator/annotation_read_cache.py index 8de4ed8..d1ec702 100644 --- a/copienator/annotation_read_cache.py +++ b/copienator/annotation_read_cache.py @@ -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) diff --git a/copienator/cli.py b/copienator/cli.py index 2b9cbae..bb112de 100644 --- a/copienator/cli.py +++ b/copienator/cli.py @@ -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) diff --git a/copienator/commands/clean.py b/copienator/commands/clean.py index 455bdb8..6d5dc27 100644 --- a/copienator/commands/clean.py +++ b/copienator/commands/clean.py @@ -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 diff --git a/copienator/commands/finalise_web.py b/copienator/commands/finalise_web.py new file mode 100644 index 0000000..1432a6b --- /dev/null +++ b/copienator/commands/finalise_web.py @@ -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 diff --git a/copienator/commands/giving_names.py b/copienator/commands/giving_names.py index 44a149e..8638fef 100644 --- a/copienator/commands/giving_names.py +++ b/copienator/commands/giving_names.py @@ -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: diff --git a/copienator/commands/reading_annotations.py b/copienator/commands/reading_annotations.py index 8afd16b..1035250 100644 --- a/copienator/commands/reading_annotations.py +++ b/copienator/commands/reading_annotations.py @@ -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) diff --git a/copienator/commands/reading_grouped_annotations.py b/copienator/commands/reading_grouped_annotations.py index 5ed9dfd..1031783 100644 --- a/copienator/commands/reading_grouped_annotations.py +++ b/copienator/commands/reading_grouped_annotations.py @@ -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) diff --git a/copienator/commands/regenerate_reviewed.py b/copienator/commands/regenerate_reviewed.py new file mode 100644 index 0000000..d3f60f3 --- /dev/null +++ b/copienator/commands/regenerate_reviewed.py @@ -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)) diff --git a/copienator/dispatcher.py b/copienator/dispatcher.py index 3087eb7..7052277 100644 --- a/copienator/dispatcher.py +++ b/copienator/dispatcher.py @@ -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"), diff --git a/copienator/evaluation_lock.py b/copienator/evaluation_lock.py new file mode 100644 index 0000000..60921c2 --- /dev/null +++ b/copienator/evaluation_lock.py @@ -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) diff --git a/copienator/finalisation.py b/copienator/finalisation.py new file mode 100644 index 0000000..97e8642 --- /dev/null +++ b/copienator/finalisation.py @@ -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 diff --git a/copienator/finalisation_server.py b/copienator/finalisation_server.py new file mode 100644 index 0000000..c2882bd --- /dev/null +++ b/copienator/finalisation_server.py @@ -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() diff --git a/copienator/return_identity.py b/copienator/return_identity.py new file mode 100644 index 0000000..a466857 --- /dev/null +++ b/copienator/return_identity.py @@ -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.+) \((?P\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 diff --git a/copienator/review_snapshot.py b/copienator/review_snapshot.py new file mode 100644 index 0000000..de49793 --- /dev/null +++ b/copienator/review_snapshot.py @@ -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 diff --git a/copienator/score_editing.py b/copienator/score_editing.py new file mode 100644 index 0000000..52ed08d --- /dev/null +++ b/copienator/score_editing.py @@ -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) diff --git a/copienator/web_static/assets/index-B6ukjHTb.css b/copienator/web_static/assets/index-B6ukjHTb.css new file mode 100644 index 0000000..2e040ef --- /dev/null +++ b/copienator/web_static/assets/index-B6ukjHTb.css @@ -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{min-width:0}.compare img{width:100%;display:block}.cut-image{position:relative;cursor:crosshair}.cut-line{position:absolute;left:0;right:0;height:0;border-top:2px dashed #e15635;pointer-events:none}.cut-line span{position:absolute;right:0;top:0;background:#e15635;color:#fff;padding:3px}.instruction-line{display:block;background:#e7eee9;padding:12px;margin:18px 0;overflow:auto}.modal-actions{display:flex;gap:12px}.viewer{display:flex;flex-direction:column;height:94vh}.zoom-scroll{overflow:auto;flex:1;background:#e2e8e3;margin-top:16px}.zoom-scroll img{display:block}.log{white-space:pre-wrap;background:#15281f;color:#dcecdf;padding:20px;max-height:400px;overflow:auto}.end{max-width:650px;margin:15vh auto}pre{white-space:pre-wrap;overflow-wrap:anywhere}@media(max-width:1000px){header{padding:16px;gap:16px}.brand span{display:none}main{padding:20px}.toolbar,.publish{flex-wrap:wrap}.toolbar>div,.publish>div{width:100%}.answer-grid,.identity{grid-template-columns:1fr}.compare{gap:12px}}@media(max-width:600px){.filters{flex-wrap:wrap}.heading{padding-left:15px}.quiet{max-width:150px}.compare{grid-template-columns:1fr}.overlay{padding:8px}.modal{padding:16px}.remark-row{flex-wrap:wrap}.remark-row textarea{flex-basis:100%}.results span{display:none}}.draft-list{margin:16px 0}.draft-row{display:flex;align-items:center;gap:8px;padding:8px 0;border-bottom:1px solid #e2e8e3}.draft-row code{flex:1;overflow-wrap:anywhere}.draft-row button{padding:6px 9px}.publish>div{min-width:190px}@media(max-width:1000px){.publish>div{flex:1 0 100%}} diff --git a/copienator/web_static/assets/index-B8v-OSfB.js b/copienator/web_static/assets/index-B8v-OSfB.js new file mode 100644 index 0000000..5231325 --- /dev/null +++ b/copienator/web_static/assets/index-B8v-OSfB.js @@ -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>>1,el=C[cl];if(0>>1;clq(N,X))sq(E,N)?(C[cl]=E,C[s]=X,cl=s):(C[cl]=N,C[O]=X,cl=O);else if(sq(E,X))C[cl]=E,C[s]=X,cl=s;else break l}}return B}function q(C,B){var X=C.sortIndex-B.sortIndex;return X!==0?X:C.id-B.id}if(_.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var $=performance;_.unstable_now=function(){return $.now()}}else{var rl=Date,Gl=rl.now();_.unstable_now=function(){return rl.now()-Gl}}var ul=[],Dl=[],R=1,b=null,M=3,_l=!1,Xl=!1,Ul=!1,il=!1,et=typeof setTimeout=="function"?setTimeout:null,Ol=typeof clearTimeout=="function"?clearTimeout:null,gt=typeof setImmediate<"u"?setImmediate:null;function Ql(C){for(var B=Y(Dl);B!==null;){if(B.callback===null)y(Dl);else if(B.startTime<=C)y(Dl),B.sortIndex=B.expirationTime,G(ul,B);else break;B=Y(Dl)}}function at(C){if(Ul=!1,Ql(C),!Xl)if(Y(ul)!==null)Xl=!0,xl||(xl=!0,Ll());else{var B=Y(Dl);B!==null&&hl(at,B.startTime-C)}}var xl=!1,K=-1,F=5,Vl=-1;function Nl(){return il?!0:!(_.unstable_now()-VlC&&Nl());){var cl=b.callback;if(typeof cl=="function"){b.callback=null,M=b.priorityLevel;var el=cl(b.expirationTime<=C);if(C=_.unstable_now(),typeof el=="function"){b.callback=el,Ql(C),B=!0;break t}b===Y(ul)&&y(ul),Ql(C)}else y(ul);b=Y(ul)}if(b!==null)B=!0;else{var ot=Y(Dl);ot!==null&&hl(at,ot.startTime-C),B=!1}}break l}finally{b=null,M=X,_l=!1}B=void 0}}finally{B?Ll():xl=!1}}}var Ll;if(typeof gt=="function")Ll=function(){gt(pl)};else if(typeof MessageChannel<"u"){var jt=new MessageChannel,L=jt.port2;jt.port1.onmessage=pl,Ll=function(){L.postMessage(null)}}else Ll=function(){et(pl,0)};function hl(C,B){K=et(function(){C(_.unstable_now())},B)}_.unstable_IdlePriority=5,_.unstable_ImmediatePriority=1,_.unstable_LowPriority=4,_.unstable_NormalPriority=3,_.unstable_Profiling=null,_.unstable_UserBlockingPriority=2,_.unstable_cancelCallback=function(C){C.callback=null},_.unstable_forceFrameRate=function(C){0>C||125cl?(C.sortIndex=X,G(Dl,C),Y(ul)===null&&C===Y(Dl)&&(Ul?(Ol(K),K=-1):Ul=!0,hl(at,X-cl))):(C.sortIndex=el,G(ul,C),Xl||_l||(Xl=!0,xl||(xl=!0,Ll()))),C},_.unstable_shouldYield=Nl,_.unstable_wrapCallback=function(C){var B=M;return function(){var X=M;M=B;try{return C.apply(this,arguments)}finally{M=X}}}})(As)),As}var nv;function oh(){return nv||(nv=1,Ns.exports=sh()),Ns.exports}var Cs={exports:{}},ut={};var iv;function rh(){if(iv)return ut;iv=1;var _=Ds();function G(R){var b="https://react.dev/errors/"+R;if(1"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(_)}catch(G){console.error(G)}}return _(),Cs.exports=rh(),Cs.exports}var fv;function vh(){if(fv)return cn;fv=1;var _=oh(),G=Ds(),Y=dh();function y(l){var t="https://react.dev/errors/"+l;if(1be||(l.current=Xi[be],Xi[be]=null,be--)}function Tl(l,t){be++,Xi[be]=l.current,l.current=t}var wt=Jt(null),ra=Jt(null),Eu=Jt(null),sn=Jt(null);function on(l,t){switch(Tl(Eu,t),Tl(ra,l),Tl(wt,null),t.nodeType){case 9:case 11:l=(l=t.documentElement)&&(l=l.namespaceURI)?s0(l):0;break;default:if(l=t.tagName,t=t.namespaceURI)t=s0(t),l=o0(t,l);else switch(l){case"svg":l=1;break;case"math":l=2;break;default:l=0}}Fl(wt),Tl(wt,l)}function Te(){Fl(wt),Fl(ra),Fl(Eu)}function Qi(l){var t=l.memoizedState;t!==null&&(ca._currentValue=t.memoizedState,Tl(sn,l)),t=wt.current;var u=o0(t,l.type);t!==u&&(Tl(ra,l),Tl(wt,u))}function rn(l){ra.current===l&&(Fl(wt),Fl(ra)),sn.current===l&&(Fl(sn),ca._currentValue=nu)}var Vi,js;function zu(l){if(Vi===void 0)try{throw Error()}catch(u){var t=u.stack.trim().match(/\n( *(at )?)/);Vi=t&&t[1]||"",js=-1)":-1a||f[e]!==m[a]){var S=` +`+f[e].replace(" at new "," at ");return l.displayName&&S.includes("")&&(S=S.replace("",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;0u;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"||typeof window.document>"u"||typeof window.document.createElement>"u"),ac=!1;if(fu)try{var Sa={};Object.defineProperty(Sa,"passive",{get:function(){ac=!0}}),window.addEventListener("test",Sa,Sa),window.removeEventListener("test",Sa,Sa)}catch{ac=!1}var Ou=null,nc=null,zn=null;function ao(){if(zn)return zn;var l,t=nc,u=t.length,e,a="value"in Ou?Ou.value:Ou.textContent,n=a.length;for(l=0;l=Ea),oo=" ",ro=!1;function vo(l,t){switch(l){case"keyup":return em.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function mo(l){return l=l.detail,typeof l=="object"&&"data"in l?l.data:null}var je=!1;function nm(l,t){switch(l){case"compositionend":return mo(t);case"keypress":return t.which!==32?null:(ro=!0,oo);case"textInput":return l=t.data,l===oo&&ro?null:l;default:return null}}function im(l,t){if(je)return l==="compositionend"||!oc&&vo(l,t)?(l=ao(),zn=nc=Ou=null,je=!1,l):null;switch(l){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1"u")return null;try{return l.activeElement||l.body}catch{return l.body}}function zo(l){for(;l&&l.firstChild;)l=l.firstChild;return l}function _o(l,t){var u=zo(l);l=0;for(var e;u;){if(u.nodeType===3){if(e=l+u.textContent.length,l<=t&&e>=t)return{node:u,offset:t-l};l=e}l:{for(;u;){if(u.nextSibling){u=u.nextSibling;break l}u=u.parentNode}u=void 0}u=zo(u)}}function Oo(l,t){return l&&t?l===t?!0:l&&l.nodeType===3?!1:t&&t.nodeType===3?Oo(l,t.parentNode):"contains"in l?l.contains(t):l.compareDocumentPosition?!!(l.compareDocumentPosition(t)&16):!1:!1}function No(l){l=l!=null&&l.ownerDocument!=null&&l.ownerDocument.defaultView!=null?l.ownerDocument.defaultView:window;for(var t=vc(l.document);t instanceof l.HTMLIFrameElement;){try{var u=typeof t.contentWindow.location.href=="string"}catch{u=!1}if(u)l=t.contentWindow;else break;t=vc(l.document)}return t}function mc(l){var t=l&&l.nodeName&&l.nodeName.toLowerCase();return t&&(t==="input"&&(l.type==="text"||l.type==="search"||l.type==="tel"||l.type==="url"||l.type==="password")||t==="textarea"||l.contentEditable==="true")}var mm=fu&&"documentMode"in document&&11>=document.documentMode,Me=null,yc=null,Na=null,hc=!1;function Ao(l,t,u){var e=u.window===u?u.document:u.nodeType===9?u:u.ownerDocument;hc||Me==null||Me!==vc(e)||(e=Me,"selectionStart"in e&&mc(e)?e={start:e.selectionStart,end:e.selectionEnd}:(e=(e.ownerDocument&&e.ownerDocument.defaultView||window).getSelection(),e={anchorNode:e.anchorNode,anchorOffset:e.anchorOffset,focusNode:e.focusNode,focusOffset:e.focusOffset}),Na&&Oa(Na,e)||(Na=e,e=Ci(yc,"onSelect"),0>=i,a-=i,Ft=1<<32-Tt(t)+a|u<Q?(Bl=H,H=null):Bl=H.sibling;var nl=r(v,H,h[Q],T);if(nl===null){H===null&&(H=Bl);break}l&&H&&nl.alternate===null&&t(v,H),o=n(nl,o,Q),tl===null?U=nl:tl.sibling=nl,tl=nl,H=Bl}if(Q===h.length)return u(v,H),W&&du(v,Q),U;if(H===null){for(;QQ?(Bl=H,H=null):Bl=H.sibling;var Fu=r(v,H,nl.value,T);if(Fu===null){H===null&&(H=Bl);break}l&&H&&Fu.alternate===null&&t(v,H),o=n(Fu,o,Q),tl===null?U=Fu:tl.sibling=Fu,tl=Fu,H=Bl}if(nl.done)return u(v,H),W&&du(v,Q),U;if(H===null){for(;!nl.done;Q++,nl=h.next())nl=z(v,nl.value,T),nl!==null&&(o=n(nl,o,Q),tl===null?U=nl:tl.sibling=nl,tl=nl);return W&&du(v,Q),U}for(H=e(H);!nl.done;Q++,nl=h.next())nl=g(H,v,Q,nl.value,T),nl!==null&&(l&&(Bl=nl.alternate,Bl!==null&&H.delete(Bl.key===null?Q:Bl.key)),o=n(nl,o,Q),tl===null?U=nl:tl.sibling=nl,tl=nl);return l&&H.forEach(function(nh){return t(v,nh)}),W&&du(v,Q),U}function w(v,o,h,T){if(typeof h=="object"&&h!==null&&h.type===pl&&h.key===null&&h.props.ref===void 0&&(h=h.props.children),typeof h=="object"&&h!==null){switch(h.$$typeof){case Vl:l:{for(var U=h.key;o!==null;){if(o.key===U){if(U=h.type,U===pl){if(o.tag===7){u(v,o.sibling),T=a(o,h.props.children),Mu(T,h),T.return=v,v=T;break l}}else if(o.elementType===U||typeof U=="object"&&U!==null&&U.$$typeof===el&&se(U)===o.type){u(v,o.sibling),T=a(o,h.props),Mu(T,h),T.return=v,v=T;break l}u(v,o);break}else t(v,o);o=o.sibling}h.type===pl?(T=ue(h.props.children,v.mode,T,h.key),Mu(T,h),T.return=v,v=T):(T=pn(h.type,h.key,h.props,null,v.mode,T),Mu(T,h),T.return=v,v=T)}return i(v);case Nl:l:{for(U=h.key;o!==null;){if(o.key===U)if(o.tag===4&&o.stateNode.containerInfo===h.containerInfo&&o.stateNode.implementation===h.implementation){u(v,o.sibling),T=a(o,h.children||[]),T.return=v,v=T;break l}else{u(v,o);break}else t(v,o);o=o.sibling}T=_c(h,v.mode,T),T.return=v,v=T}return i(v);case el:return h=se(h),w(v,o,h,T)}if(sl(h))return A(v,o,h,T);if(j(h)){if(U=j(h),typeof U!="function")throw Error(y(150));return h=U.call(h),p(v,o,h,T)}if(typeof h.then=="function")return w(v,o,Vn(h),T);if(h.$$typeof===hl)return w(v,o,Bn(v,h),T);Zn(v,h)}return typeof h=="string"&&h!==""||typeof h=="number"||typeof h=="bigint"?(h=""+h,o!==null&&o.tag===6?(u(v,o.sibling),T=a(o,h),T.return=v,v=T):(u(v,o),T=zc(h,v.mode,T),T.return=v,v=T),i(v)):u(v,o)}return function(v,o,h,T){try{Ua=0;var U=w(v,o,h,T);return Ge=null,U}catch(H){if(H===Be||H===Xn)throw H;var tl=dt(29,H,null,v.mode);return tl.lanes=T,tl.return=v,tl}}}var re=Wo(!0),ko=Wo(!1),Uu=!1;function Rc(l){l.updateQueue={baseState:l.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function xc(l,t){l=l.updateQueue,t.updateQueue===l&&(t.updateQueue={baseState:l.baseState,firstBaseUpdate:l.firstBaseUpdate,lastBaseUpdate:l.lastBaseUpdate,shared:l.shared,callbacks:null})}function pu(l){return{lane:l,tag:0,payload:null,callback:null,next:null}}function Ru(l,t,u){var e=l.updateQueue;if(e===null)return null;if(e=e.shared,(ol&2)!==0){var a=e.pending;return a===null?t.next=t:(t.next=a.next,a.next=t),e.pending=t,t=Un(l),xo(l,null,u),t}return Mn(l,e,t,u),Un(l)}function pa(l,t,u){if(t=t.updateQueue,t!==null&&(t=t.shared,(u&4194048)!==0)){var e=t.lanes;e&=l.pendingLanes,u|=e,t.lanes=u,Ys(l,u)}}function Hc(l,t){var u=l.updateQueue,e=l.alternate;if(e!==null&&(e=e.updateQueue,u===e)){var a=null,n=null;if(u=u.firstBaseUpdate,u!==null){do{var i={lane:u.lane,tag:u.tag,payload:u.payload,callback:null,next:null};n===null?a=n=i:n=n.next=i,u=u.next}while(u!==null);n===null?a=n=t:n=n.next=t}else a=n=t;u={baseState:e.baseState,firstBaseUpdate:a,lastBaseUpdate:n,shared:e.shared,callbacks:e.callbacks},l.updateQueue=u;return}l=u.lastBaseUpdate,l===null?u.firstBaseUpdate=t:l.next=t,u.lastBaseUpdate=t}var qc=!1;function Ra(){if(qc){var l=Ye;if(l!==null)throw l}}function xa(l,t,u,e){qc=!1;var a=l.updateQueue;Uu=!1;var n=a.firstBaseUpdate,i=a.lastBaseUpdate,c=a.shared.pending;if(c!==null){a.shared.pending=null;var f=c,m=f.next;f.next=null,i===null?n=m:i.next=m,i=f;var S=l.alternate;S!==null&&(S=S.updateQueue,c=S.lastBaseUpdate,c!==i&&(c===null?S.firstBaseUpdate=m:c.next=m,S.lastBaseUpdate=f))}if(n!==null){var z=a.baseState;i=0,S=m=f=null,c=n;do{var r=c.lane&-536870913,g=r!==c.lane;if(g?(ll&r)===r:(e&r)===r){r!==0&&r===ce&&(qc=!0),S!==null&&(S=S.next={lane:0,tag:c.tag,payload:c.payload,callback:null,next:null});l:{var A=l,p=c;r=t;var w=u;switch(p.tag){case 1:if(A=p.payload,typeof A=="function"){z=A.call(w,z,r);break l}z=A;break l;case 3:A.flags=A.flags&-65537|128;case 0:if(A=p.payload,r=typeof A=="function"?A.call(w,z,r):A,r==null)break l;z=K({},z,r);break l;case 2:Uu=!0}}r=c.callback,r!==null&&(l.flags|=64,g&&(l.flags|=8192),g=a.callbacks,g===null?a.callbacks=[r]:g.push(r))}else g={lane:r,tag:c.tag,payload:c.payload,callback:c.callback,next:null},S===null?(m=S=g,f=z):S=S.next=g,i|=r;if(c=c.next,c===null){if(c=a.shared.pending,c===null)break;g=c,c=g.next,g.next=null,a.lastBaseUpdate=g,a.shared.pending=null}}while(!0);S===null&&(f=z),a.baseState=f,a.firstBaseUpdate=m,a.lastBaseUpdate=S,n===null&&(a.shared.lanes=0),Xu|=i,l.lanes=i,l.memoizedState=z}}function Io(l,t){if(typeof l!="function")throw Error(y(191,l));l.call(t)}function Po(l,t){var u=l.callbacks;if(u!==null)for(l.callbacks=null,l=0;ln?n:8;var i=x.T,c={};c.types=i!==null?i.types:null,x.T=c,uf(l,!1,t,u);try{var f=a(),m=x.S;if(m!==null&&m(c,f),f!==null&&typeof f=="object"&&typeof f.then=="function"){var S=Nm(f,e);Ba(l,t,S,Ct(l))}else Ba(l,t,e,Ct(l))}catch(z){Ba(l,t,{then:function(){},status:"rejected",reason:z},Ct())}finally{V.p=n,i!==null&&c.types!==null&&(i.types=c.types),x.T=i}}function Um(){}function lf(l,t,u,e){if(l.tag!==5)throw Error(y(476));var a=Ur(l).queue;Mr(l,a,t,nu,u===null?Um:function(){return pr(l),u(e)})}function Ur(l){var t=l.memoizedState;if(t!==null)return t;t={memoizedState:nu,baseState:nu,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:nu},next:null};var u={};return t.next={memoizedState:u,baseState:u,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:hu,lastRenderedState:u},next:null},l.memoizedState=t,l=l.alternate,l!==null&&(l.memoizedState=t),t}function pr(l){var t=Ur(l);t.next===null&&(t=l.alternate.memoizedState),Ba(l,t.next.queue,{},Ct())}function tf(){return kl(ca)}function Rr(){return Rl().memoizedState}function xr(){return Rl().memoizedState}function pm(l){for(var t=l.return;t!==null;){switch(t.tag){case 24:case 3:var u=Ct();l=pu(u);var e=Ru(t,l,u);e!==null&&(ht(e,t,u),pa(e,t,u)),t={cache:jc()},l.payload=t;return}t=t.return}}function Rm(l,t,u){var e=Ct();u={lane:e,revertLane:0,gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null},Pn(l)?qr(t,u):(u=Tc(l,t,u,e),u!==null&&(ht(u,l,e),Yr(u,t,e)))}function Hr(l,t,u){var e=Ct();Ba(l,t,u,e)}function Ba(l,t,u,e){var a={lane:e,revertLane:0,gesture:null,action:u,hasEagerState:!1,eagerState:null,next:null};if(Pn(l))qr(t,a);else{var n=l.alternate;if(l.lanes===0&&(n===null||n.lanes===0)&&(n=t.lastRenderedReducer,n!==null))try{var i=t.lastRenderedState,c=n(i,u);if(a.hasEagerState=!0,a.eagerState=c,zt(c,i))return Mn(l,t,a,0),bl===null&&jn(),!1}catch{}if(u=Tc(l,t,a,e),u!==null)return ht(u,l,e),Yr(u,t,e),!0}return!1}function uf(l,t,u,e){if(e={lane:2,revertLane:wf(),gesture:null,action:e,hasEagerState:!1,eagerState:null,next:null},Pn(l)){if(t)throw Error(y(479))}else t=Tc(l,u,e,2),t!==null&&ht(t,l,2)}function Pn(l){var t=l.alternate;return l===J||t!==null&&t===J}function qr(l,t){Xe=Jn=!0;var u=l.pending;u===null?t.next=t:(t.next=u.next,u.next=t),l.pending=t}function Yr(l,t,u){if((u&4194048)!==0){var e=t.lanes;e&=l.pendingLanes,u|=e,t.lanes=u,Ys(l,u)}}var li={readContext:kl,use:Fn,useCallback:jl,useContext:jl,useEffect:jl,useImperativeHandle:jl,useLayoutEffect:jl,useInsertionEffect:jl,useMemo:jl,useReducer:jl,useRef:jl,useState:jl,useDebugValue:jl,useDeferredValue:jl,useTransition:jl,useSyncExternalStore:jl,useId:jl,useHostTransitionStatus:jl,useFormState:jl,useActionState:jl,useOptimistic:jl,useMemoCache:jl,useCacheRefresh:jl,useEffectEvent:jl},Br={readContext:kl,use:Fn,useCallback:function(l,t){return ct().memoizedState=[l,t===void 0?null:t],l},useContext:kl,useEffect:Er,useImperativeHandle:function(l,t,u){u=u!=null?u.concat([l]):null,kn(4194308,4,Nr.bind(null,t,l),u)},useLayoutEffect:function(l,t){return kn(4194308,4,l,t)},useInsertionEffect:function(l,t){kn(4,2,l,t)},useMemo:function(l,t){var u=ct();t=t===void 0?null:t;var e=l();if(de){_u(!0);try{l()}finally{_u(!1)}}return u.memoizedState=[e,t],e},useReducer:function(l,t,u){var e=ct();if(u!==void 0){var a=u(t);if(de){_u(!0);try{u(t)}finally{_u(!1)}}}else a=t;return e.memoizedState=e.baseState=a,l={pending:null,lanes:0,dispatch:null,lastRenderedReducer:l,lastRenderedState:a},e.queue=l,l=l.dispatch=Rm.bind(null,J,l),[e.memoizedState,l]},useRef:function(l){var t=ct();return l={current:l},t.memoizedState=l},useState:function(l){l=Fc(l);var t=l.queue,u=Hr.bind(null,J,t);return t.dispatch=u,[l.memoizedState,u]},useDebugValue:Ic,useDeferredValue:function(l,t){var u=ct();return Pc(u,l,t)},useTransition:function(){var l=Fc(!1);return l=Mr.bind(null,J,l.queue,!0,!1),ct().memoizedState=l,[!1,l]},useSyncExternalStore:function(l,t,u){var e=J,a=ct();if(W){if(u===void 0)throw Error(y(407));u=u()}else{if(u=t(),bl===null)throw Error(y(349));(ll&127)!==0||nr(e,t,u)}a.memoizedState=u;var n={value:u,getSnapshot:t};return a.queue=n,Er(cr.bind(null,e,n,l),[l]),e.flags|=2048,Ve(9,{destroy:void 0},ir.bind(null,e,n,u,t),null),u},useId:function(){var l=ct(),t=bl.identifierPrefix;if(W){var u=Wt,e=Ft;u=(e&~(1<<32-Tt(e)-1)).toString(32)+u,t="_"+t+"R_"+u,u=wn++,0<\/script>",n=n.removeChild(n.firstChild);break;case"select":n=typeof e.is=="string"?i.createElement("select",{is:e.is}):i.createElement("select"),e.multiple?n.multiple=!0:e.size&&(n.size=e.size);break;default:n=typeof e.is=="string"?i.createElement(a,{is:e.is}):i.createElement(a)}}n[Wl]=t,n[rt]=e;l:for(i=t.child;i!==null;){if(i.tag===5||i.tag===6)n.appendChild(i.stateNode);else if(i.tag!==4&&i.tag!==27&&i.child!==null){i.child.return=i,i=i.child;continue}if(i===t)break l;for(;i.sibling===null;){if(i.return===null||i.return===t)break l;i=i.return}i.sibling.return=i.return,i=i.sibling}t.stateNode=n;l:switch(tt(n,a,e),a){case"button":case"input":case"select":case"textarea":e=!!e.autoFocus;break l;case"img":e=!0;break l;default:e=!1}e&&Su(t)}}return zl(t),t.subtreeFlags&=-33554433,hf(t,t.type,l===null?null:l.memoizedProps,t.pendingProps,u),null;case 6:if(l&&t.stateNode!=null)l.memoizedProps!==e&&Su(t);else{if(typeof e!="string"&&t.stateNode===null)throw Error(y(166));if(l=Eu.current,qe(t)){if(l=t.stateNode,u=t.memoizedProps,e=null,a=Jl,a!==null)switch(a.tag){case 27:case 5:e=a.memoizedProps}l[Wl]=t,l=!!(l.nodeValue===u||e!==null&&e.suppressHydrationWarning===!0||c0(l.nodeValue,u)),l||Du(t,!0)}else l=ka(l).createTextNode(e),l[Wl]=t,t.stateNode=l}return zl(t),null;case 31:if(u=t.memoizedState,l===null||l.memoizedState!==null){if(e=qe(t),u!==null){if(l===null){if(!e)throw Error(y(318));if(l=t.memoizedState,l=l!==null?l.dehydrated:null,!l)throw Error(y(557));l[Wl]=t}else ee(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;zl(t),l=!1}else u=Ac(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=u),l=!0;if(!l)return t.flags&256?(_t(t),t):(_t(t),null);if((t.flags&128)!==0)throw Error(y(558))}return zl(t),null;case 13:if(e=t.memoizedState,l===null||l.memoizedState!==null&&l.memoizedState.dehydrated!==null){if(a=qe(t),e!==null&&e.dehydrated!==null){if(l===null){if(!a)throw Error(y(318));if(a=t.memoizedState,a=a!==null?a.dehydrated:null,!a)throw Error(y(317));a[Wl]=t}else ee(),(t.flags&128)===0&&(t.memoizedState=null),t.flags|=4;zl(t),a=!1}else a=Ac(),l!==null&&l.memoizedState!==null&&(l.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?(_t(t),t):(_t(t),null)}return _t(t),(t.flags&128)!==0?(t.lanes=u,t):(u=e!==null,l=l!==null&&l.memoizedState!==null,u&&(e=t.child,a=null,e.alternate!==null&&e.alternate.memoizedState!==null&&e.alternate.memoizedState.cachePool!==null&&(a=e.alternate.memoizedState.cachePool.pool),n=null,e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),n!==a&&(e.flags|=2048)),u!==l&&u&&(t.child.flags|=8192),ii(t,t.updateQueue),zl(t),null);case 4:return Te(),l===null&&kf(t.stateNode.containerInfo),t.flags|=67108864,zl(t),null;case 10:return mu(t.type),zl(t),null;case 19:if(Xc(t),e=t.memoizedState,e===null)return zl(t),null;if(a=(t.flags&128)!==0,n=e.rendering,n===null)if(a)Xa(e,!1);else{if(Ml!==0||l!==null&&(l.flags&128)!==0)for(l=t.child;l!==null;){if(n=Kn(l),n!==null){for(t.flags|=128,Xa(e,!1),l=n.updateQueue,t.updateQueue=l,ii(t,l),t.subtreeFlags=0,l=u,u=t.child;u!==null;)Ho(u,l),u=u.sibling;return Ha(t,Pl.current&1|2),W&&du(t,e.treeForkCount),t.child}l=l.sibling}e.tail!==null&&St()>Si&&(t.flags|=128,a=!0,Xa(e,!1),t.lanes=4194304)}else{if(!a)if(l=Kn(n),l!==null){if(t.flags|=128,a=!0,l=l.updateQueue,t.updateQueue=l,ii(t,l),Xa(e,!0),e.tail===null&&e.tailMode!=="collapsed"&&e.tailMode!=="visible"&&!n.alternate&&!W)return zl(t),null}else 2*St()-e.renderingStartTime>Si&&u!==536870912&&(t.flags|=128,a=!0,Xa(e,!1),t.lanes=4194304);e.isBackwards?(n.sibling=t.child,t.child=n):(l=e.last,l!==null?l.sibling=n:t.child=n,e.last=n)}if(e.tail!==null){l=e.tail;l:{for(u=l;u!==null;){if(u.alternate!==null){u=!1;break l}u=u.sibling}u=!0}return e.rendering=l,e.tail=l.sibling,e.renderingStartTime=St(),l.sibling=null,n=Pl.current,n=a?n&1|2:n&1,e.tailMode==="visible"||e.tailMode==="collapsed"||!u||W?Ha(t,n):(u=n,Tl(Il,t),Tl(Pl,u),nt===null&&(nt=t)),W&&du(t,e.treeForkCount),l}return zl(t),null;case 22:case 23:return _t(t),Bc(),e=t.memoizedState!==null,l!==null?l.memoizedState!==null!==e&&(t.flags|=8192):e&&(t.flags|=8192),e?(u&536870912)!==0&&(t.flags&128)===0&&(zl(t),t.subtreeFlags&6&&(t.flags|=8192)):zl(t),u=t.updateQueue,u!==null&&ii(t,u.retryQueue),u=null,l!==null&&l.memoizedState!==null&&l.memoizedState.cachePool!==null&&(u=l.memoizedState.cachePool.pool),e=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(e=t.memoizedState.cachePool.pool),e!==u&&(t.flags|=2048),l!==null&&Fl(fe),null;case 24:return u=null,l!==null&&(u=l.memoizedState.cache),t.memoizedState.cache!==u&&(t.flags|=2048),mu(Hl),zl(t),null;case 25:return null;case 30:return t.flags|=33554432,zl(t),null}throw Error(y(156,t.tag))}function Xm(l,t){switch(Oc(t),t.tag){case 1:return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 3:return mu(Hl),Te(),l=t.flags,(l&65536)!==0&&(l&128)===0?(t.flags=l&-65537|128,t):null;case 26:case 27:case 5:return rn(t),null;case 31:if(t.memoizedState!==null){if(_t(t),t.alternate===null)throw Error(y(340));ee()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 13:if(_t(t),l=t.memoizedState,l!==null&&l.dehydrated!==null){if(t.alternate===null)throw Error(y(340));ee()}return l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 19:return Xc(t),l=t.flags,l&65536?(t.flags=l&-65537|128,l=t.memoizedState,l!==null&&(l.rendering=null,l.tail=null),t.flags|=4,t):null;case 4:return Te(),null;case 10:return mu(t.type),null;case 22:case 23:return _t(t),Bc(),l!==null&&Fl(fe),l=t.flags,l&65536?(t.flags=l&-65537|128,t):null;case 24:return mu(Hl),null;case 25:return null;default:return null}}function sd(l,t){switch(Oc(t),t.tag){case 3:mu(Hl),Te();break;case 26:case 27:case 5:rn(t);break;case 4:Te();break;case 31:t.memoizedState!==null&&_t(t);break;case 13:_t(t);break;case 19:Xc(t);break;case 10:mu(t.type);break;case 22:case 23:_t(t),Bc(),l!==null&&Fl(fe);break;case 24:mu(Hl)}}function Qa(l,t){try{var u=t.updateQueue,e=u!==null?u.lastEffect:null;if(e!==null){var a=e.next;u=a;do{if((u.tag&l)===l){e=void 0;var n=u.create,i=u.inst;e=n(),i.destroy=e}u=u.next}while(u!==a)}}catch(c){ml(t,t.return,c)}}function Yu(l,t,u){try{var e=t.updateQueue,a=e!==null?e.lastEffect:null;if(a!==null){var n=a.next;e=n;do{if((e.tag&l)===l){var i=e.inst,c=i.destroy;if(c!==void 0){i.destroy=void 0,a=t;var f=u,m=c;try{m()}catch(S){ml(a,f,S)}}}e=e.next}while(e!==n)}}catch(S){ml(t,t.return,S)}}function od(l){var t=l.updateQueue;if(t!==null){var u=l.stateNode;try{Po(t,u)}catch(e){ml(l,l.return,e)}}}function rd(l,t,u){u.props=ve(l.type,l.memoizedProps),u.state=l.memoizedState;try{u.componentWillUnmount()}catch(e){ml(l,t,e)}}function kt(l,t){try{var u=l.ref;if(u!==null){switch(l.tag){case 26:case 27:case 5:var e=l.stateNode;break;case 30:var a=l.stateNode,n=su(l.memoizedProps,a);(a.ref===null||a.ref.name!==n)&&(a.ref=S0(n)),e=a.ref;break;case 7:if(l.stateNode===null){var i=new Dt(l);b(l.child,!1,My,i,void 0,void 0),l.stateNode=i}e=l.stateNode;break;default:e=l.stateNode}typeof u=="function"?l.refCleanup=u(e):u.current=e}}catch(c){ml(l,t,c)}}function lt(l,t){var u=l.ref,e=l.refCleanup;if(u!==null)if(typeof e=="function")try{e()}catch(a){ml(l,t,a)}finally{l.refCleanup=null,l=l.alternate,l!=null&&(l.refCleanup=null)}else if(typeof u=="function")try{u(null)}catch(a){ml(l,t,a)}else u.current=null}function ci(l,t){if((l.tag===5||l.tag===27||l.tag===6)&&l.alternate===null&&t!==null)for(var u=0;u title"))),tt(e,t,u),e[Wl]=l,Kl(e),t=e;break l;case"link":if(n=H0("link","href",a).get(t+(u.href||""))){for(i=0;iw&&(i=w,w=p,p=i);var v=_o(c,p),o=_o(c,w);if(v&&o&&(g.rangeCount!==1||g.anchorNode!==v.node||g.anchorOffset!==v.offset||g.focusNode!==o.node||g.focusOffset!==o.offset)){var h=z.createRange();h.setStart(v.node,v.offset),g.removeAllRanges(),p>w?(g.addRange(h),g.extend(o.node,o.offset)):(h.setEnd(o.node,o.offset),g.addRange(h))}}}}for(z=[],g=c;g=g.parentNode;)g.nodeType===1&&z.push({element:g,left:g.scrollLeft,top:g.scrollTop});for(typeof c.focus=="function"&&c.focus(),c=0;cu?32:u,x.T=null,u=Yf,Yf=null;var n=Lt,i=eu;if(Cl=0,ge=Lt=null,eu=0,(ol&6)!==0)throw Error(y(331));var c=ol;if(ol|=4,xd(n.current),Ud(n,n.current,i,u),ol=c,$a(0,!1),bt&&typeof bt.onPostCommitFiberRoot=="function")try{bt.onPostCommitFiberRoot(da,n)}catch{}return!0}finally{V.p=a,x.T=e,Fd(l,t)}}function Wd(l,t,u){t=pt(u,t),t=nf(l.stateNode,t,2),l=Ru(l,t,2),l!==null&&(ma(l,2),au(l))}function ml(l,t,u){if(l.tag===3)Wd(l,l,u);else for(;t!==null;){if(t.tag===3){Wd(t,l,u);break}else if(t.tag===1){var e=t.stateNode;if(typeof t.type.getDerivedStateFromError=="function"||typeof e.componentDidCatch=="function"&&(Qu===null||!Qu.has(e))){l=pt(u,l),u=Jr(2),e=Ru(t,u,2),e!==null&&(wr(u,e,t,l),ma(e,2),au(e));break}}t=t.return}}function Lf(l,t,u){var e=l.pingCache;if(e===null){e=l.pingCache=new Lm;var a=new Set;e.set(t,a)}else a=e.get(t),a===void 0&&(a=new Set,e.set(t,a));a.has(u)||(xf=!0,a.add(u),l=Im.bind(null,l,t,u),t.then(l,l))}function Im(l,t,u){var e=l.pingCache;e!==null&&e.delete(t),l.pingedLanes|=l.suspendedLanes&u,l.warmLanes&=~u,bl===l&&(ll&u)===u&&((Ml===4||Ml===3&&(ll&62914560)===ll&&300>St()-gi)&&(ol&2)===0?Pe(l,0):hi|=u,$e===ll&&($e=0)),au(l)}function kd(l,t){t===0&&(t=Hs()),l=te(l,t),l!==null&&(ma(l,t),au(l))}function Pm(l){var t=l.memoizedState,u=0;t!==null&&(u=t.retryLane),kd(l,u)}function ly(l,t){var u=0;switch(l.tag){case 31:case 13:var e=l.stateNode,a=l.memoizedState;a!==null&&(u=a.retryLane);break;case 19:e=l.stateNode;break;case 22:e=l.stateNode._retryCache;break;default:throw Error(y(314))}e!==null&&e.delete(t),kd(l,u)}function ty(l,t){return Ji(l,t)}var ta=null,ua=null,Kf=!1,Ni=!1,Jf=!1,Zu=0;function au(l){l!==ua&&l.next===null&&(ua===null?ta=ua=l:ua=ua.next=l),Ni=!0,Kf||(Kf=!0,ey())}function $a(l,t){if(!Jf&&Ni){Jf=!0;do for(var u=!1,e=ta;e!==null;){if(l!==0){var a=e.pendingLanes;if(a===0)var n=0;else{var i=e.suspendedLanes,c=e.pingedLanes;n=(1<<31-Tt(42|l)+1)-1,n&=a&~(i&~c),n=n&201326741?n&201326741|1:n?n|2:0}n!==0&&(u=!0,t0(e,n))}else n=ll,n=hn(e,e===bl?n:0,e.cancelPendingCommit!==null||e.timeoutHandle!==-1),(n&3)===0||va(e,n)||(u=!0,t0(e,n));e=e.next}while(u);Jf=!1}}function uy(){Id()}function Id(){Ni=Kf=!1;var l=0;Zu!==0&&my()&&(l=Zu);for(var t=St(),u=null,e=ta;e!==null;){var a=e.next,n=Pd(e,t);n===0?(e.next=null,u===null?ta=a:u.next=a,a===null&&(ua=u)):(u=e,(l!==0||(n&3)!==0)&&(Ni=!0)),e=a}Cl!==0&&Cl!==5||$a(l),Zu!==0&&(Zu=0)}function Pd(l,t){for(var u=l.suspendedLanes,e=l.pingedLanes,a=l.expirationTimes,n=l.pendingLanes&-62914561;0c)break;var S=f.transferSize,z=f.initiatorType;S&&f0(z)&&(f=f.responseEnd,i+=S*(fMi){p.length=A;break}h=new Promise(Ty.bind(h)),p.push(h)}}}if(0"u"?null:document;function U0(l,t,u){var e=aa;if(e&&typeof t=="string"&&t){var a=Mt(t);a='link[rel="'+l+'"][href="'+a+'"]',typeof u=="string"&&(a+='[crossorigin="'+u+'"]'),M0.has(a)||(M0.add(a),l={rel:l,crossOrigin:u,href:t},e.querySelector(a)===null&&(t=e.createElement("link"),tt(t,"link",l),Kl(t),e.head.appendChild(t)))}}function Gy(l){Tu.D(l),U0("dns-prefetch",l,null)}function Xy(l,t){Tu.C(l,t),U0("preconnect",l,t)}function Qy(l,t,u){Tu.L(l,t,u);var e=aa;if(e&&l&&t){var a='link[rel="preload"][as="'+Mt(t)+'"]';t==="image"&&u&&u.imageSrcSet?(a+='[imagesrcset="'+Mt(u.imageSrcSet)+'"]',typeof u.imageSizes=="string"&&(a+='[imagesizes="'+Mt(u.imageSizes)+'"]')):a+='[href="'+Mt(l)+'"]';var n=a;switch(t){case"style":n=na(l);break;case"script":n=ia(l)}if(!(Bt.has(n)||(l=K({rel:"preload",href:t==="image"&&u&&u.imageSrcSet?void 0:l,as:t},u),Bt.set(n,l),e.querySelector(a)!==null||t==="style"&&e.querySelector(Pa(n))||t==="script"&&e.querySelector(ln(n))))){var i=e.createElement("link");tt(i,"link",l),t==="style"&&(i[gn]=!0,i.onload=i.onerror=function(){Zs(i)}),Kl(i),e.head.appendChild(i)}}}function Vy(l,t){Tu.m(l,t);var u=aa;if(u&&l){var e=t&&typeof t.as=="string"?t.as:"script",a='link[rel="modulepreload"][as="'+Mt(e)+'"][href="'+Mt(l)+'"]',n=a;switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":n=ia(l)}if(!Bt.has(n)&&(l=K({rel:"modulepreload",href:l},t),Bt.set(n,l),u.querySelector(a)===null)){switch(e){case"audioworklet":case"paintworklet":case"serviceworker":case"sharedworker":case"worker":case"script":if(u.querySelector(ln(n)))return}e=u.createElement("link"),tt(e,"link",l),Kl(e),u.head.appendChild(e)}}}function Zy(l,t,u){Tu.S(l,t,u);var e=aa;if(e&&l){var a=_e(e).hoistableStyles,n=na(l);t=t||"default";var i=a.get(n);if(!i){var c={loading:0,preload:null};if(i=e.querySelector(Pa(n)))c.loading=5;else{l=K({rel:"stylesheet",href:l,"data-precedence":t},u),(u=Bt.get(n))&&vs(l,u);var f=i=e.createElement("link");Kl(f),tt(f,"link",l),f._p=new Promise(function(m,S){f.onload=m,f.onerror=S}),f.addEventListener("load",function(){c.loading|=1}),f.addEventListener("error",function(){c.loading|=2}),c.loading|=4,Di(i,t,e)}i={type:"stylesheet",instance:i,count:1,state:c},a.set(n,i)}}}function Ly(l,t){Tu.X(l,t);var u=aa;if(u&&l){var e=_e(u).hoistableScripts,a=ia(l),n=e.get(a);n||(n=u.querySelector(ln(a)),n||(l=K({src:l,async:!0},t),(t=Bt.get(a))&&ms(l,t),n=u.createElement("script"),Kl(n),tt(n,"link",l),u.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(a,n))}}function Ky(l,t){Tu.M(l,t);var u=aa;if(u&&l){var e=_e(u).hoistableScripts,a=ia(l),n=e.get(a);n||(n=u.querySelector(ln(a)),n||(l=K({src:l,async:!0,type:"module"},t),(t=Bt.get(a))&&ms(l,t),n=u.createElement("script"),Kl(n),tt(n,"link",l),u.head.appendChild(n)),n={type:"script",instance:n,count:1,state:null},e.set(a,n))}}function p0(l,t,u,e){var a=(a=Eu.current)?Ia(a):null;if(!a)throw Error(y(446));switch(l){case"meta":case"title":return null;case"style":return typeof u.precedence=="string"&&typeof u.href=="string"?(u=na(u.href),t=_e(a).hoistableStyles,e=t.get(u),e||(e={type:"style",instance:null,count:0,state:null},t.set(u,e)),e):{type:"void",instance:null,count:0,state:null};case"link":if(u.rel==="stylesheet"&&typeof u.href=="string"&&typeof u.precedence=="string"){l=na(u.href);var n=_e(a).hoistableStyles,i=n.get(l);if(i||(a=a.ownerDocument||a,i={type:"stylesheet",instance:null,count:0,state:{loading:0,preload:null}},n.set(l,i),(n=a.querySelector(Pa(l)))?n._p||(i.instance=n,i.state.loading=5):(n=Bt.get(l),n||(n={rel:"preload",as:"style",href:u.href,crossOrigin:u.crossOrigin,integrity:u.integrity,media:u.media,hrefLang:u.hrefLang,referrerPolicy:u.referrerPolicy},Bt.set(l,n)),Jy(a,l,n,i.state))),t&&e===null)throw Error(y(528,""));return i}if(t&&e!==null)throw Error(y(529,""));return null;case"script":return t=u.async,u=u.src,typeof u=="string"&&t&&typeof t!="function"&&typeof t!="symbol"?(u=ia(u),t=_e(a).hoistableScripts,e=t.get(u),e||(e={type:"script",instance:null,count:0,state:null},t.set(u,e)),e):{type:"void",instance:null,count:0,state:null};default:throw Error(y(444,l))}}function na(l){return'href="'+Mt(l)+'"'}function Pa(l){return'link[rel="stylesheet"]['+l+"]"}function R0(l){return K({},l,{"data-precedence":l.precedence,precedence:null})}function Jy(l,t,u,e){if(t=l.querySelector('link[rel="preload"][as="style"]['+t+"]")){if(t[gn]!==!0){e.loading=1;return}}else t=l.createElement("link"),t[gn]=!0,t.onload=t.onerror=Zs.bind(null,t),tt(t,"link",u),Kl(t),l.head.appendChild(t);e.preload=t,t.addEventListener("load",function(){return e.loading|=1}),t.addEventListener("error",function(){return e.loading|=2})}function ia(l){return'[src="'+Mt(l)+'"]'}function ln(l){return"script[async]"+l}function x0(l,t,u){if(t.count++,t.instance===null)switch(t.type){case"style":var e=l.querySelector('style[data-href~="'+Mt(u.href)+'"]');if(e)return t.instance=e,Kl(e),e;var a=K({},u,{"data-href":u.href,"data-precedence":u.precedence,href:null,precedence:null});return e=(l.ownerDocument||l).createElement("style"),Kl(e),tt(e,"style",a),Di(e,u.precedence,l),t.instance=e;case"stylesheet":a=na(u.href);var n=l.querySelector(Pa(a));if(n)return t.state.loading|=4,t.instance=n,Kl(n),n;e=R0(u),(a=Bt.get(a))&&vs(e,a),n=(l.ownerDocument||l).createElement("link"),Kl(n);var i=n;return i._p=new Promise(function(c,f){i.onload=c,i.onerror=f}),tt(n,"link",e),t.state.loading|=4,Di(n,u.precedence,l),t.instance=n;case"script":return n=ia(u.src),(a=l.querySelector(ln(n)))?(t.instance=a,Kl(a),a):(e=u,(a=Bt.get(n))&&(e=K({},u),ms(e,a)),l=l.ownerDocument||l,a=l.createElement("script"),Kl(a),tt(a,"link",e),l.head.appendChild(a),t.instance=a);case"void":return null;default:throw Error(y(443,t.type))}else t.type==="stylesheet"&&(t.state.loading&4)===0&&(e=t.instance,t.state.loading|=4,Di(e,u.precedence,l));return t.instance}function Di(l,t,u){for(var e=u.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),a=e.length?e[e.length-1]:null,n=a,i=0;i title"):null)}function wy(l,t,u){if(u===1||t.itemProp!=null)return!1;switch(l){case"meta":case"title":return!0;case"style":if(typeof t.precedence!="string"||typeof t.href!="string"||t.href==="")break;return!0;case"link":if(typeof t.rel!="string"||typeof t.href!="string"||t.href===""||t.onLoad||t.onError)break;return t.rel==="stylesheet"?(l=t.disabled,typeof t.precedence=="string"&&l==null):!0;case"script":if(t.async&&typeof t.async!="function"&&typeof t.async!="symbol"&&!t.onLoad&&!t.onError&&t.src&&typeof t.src=="string")return!0}return!1}function q0(l,t){return l==="img"&&t.src!=null&&t.src!==""&&t.onLoad==null&&t.loading!=="lazy"}function Y0(l){return!(l.type==="stylesheet"&&(l.state.loading&3)===0)}function B0(l){return(l.width||100)*(l.height||100)*(typeof devicePixelRatio=="number"?devicePixelRatio:1)*.25}function G0(l,t){typeof t.decode=="function"&&(l.imgCount++,t.complete||(l.imgBytes+=B0(t),l.suspenseyImages.push(t)),l=Wy.bind(l),t.decode().then(l,l))}function $y(l,t,u,e){if(u.type==="stylesheet"&&(typeof e.media!="string"||matchMedia(e.media).matches!==!1)&&(u.state.loading&4)===0){if(u.instance===null){var a=na(e.href),n=t.querySelector(Pa(a));if(n){t=n._p,t!==null&&typeof t=="object"&&typeof t.then=="function"&&(l.count++,l=tn.bind(l),t.then(l,l)),u.state.loading|=4,u.instance=n,Kl(n);return}n=t.ownerDocument||t,e=R0(e),(a=Bt.get(a))&&vs(e,a),n=n.createElement("link"),Kl(n);var i=n;i._p=new Promise(function(c,f){i.onload=c,i.onerror=f}),tt(n,"link",e),u.instance=n}l.stylesheets===null&&(l.stylesheets=new Map),l.stylesheets.set(u,t),(t=u.state.preload)&&(u.state.loading&3)===0&&(l.count++,u=tn.bind(l),t.addEventListener("load",u),t.addEventListener("error",u))}}var Mi=0;function Fy(l,t){return l.stylesheets&&l.count===0&&pi(l,l.stylesheets),0Mi?50:800)+t);return l.unsuspend=u,function(){l.unsuspend=null,clearTimeout(e),clearTimeout(a)}}:null}function X0(l){if(l.count===0&&(l.imgCount===0||!l.waitingForImages)){if(l.stylesheets)pi(l,l.stylesheets);else if(l.unsuspend){var t=l.unsuspend;l.unsuspend=null,t()}}}function tn(){this.count--,X0(this)}function Wy(){this.imgCount--,X0(this)}var Ui=null;function pi(l,t){l.stylesheets=null,l.unsuspend!==null&&(l.count++,Ui=new Map,t.forEach(ky,l),Ui=null,tn.call(l))}function ky(l,t){if(!(t.state.loading&4)){var u=Ui.get(l);if(u)var e=u.get(null);else{u=new Map,Ui.set(l,u);for(var a=l.querySelectorAll("link[data-precedence],style[data-precedence]"),n=0;n"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(_)}catch(G){console.error(G)}}return _(),Os.exports=vh(),Os.exports}var yh=mh();async function oa(_,G){const Y=await fetch("/api/"+_,G===void 0?{}:{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify(G)});if(!Y.ok){let y;try{const q=await Y.json();y=typeof q.detail=="string"?q.detail:JSON.stringify(q.detail)}catch{y=await Y.text().catch(()=>Y.statusText)}throw new Error(y||"Le serveur ne répond pas.")}return Y.json()}const Gi=(_,G,Y=!1,y="")=>"/api/image?"+new URLSearchParams({copy:_,label:G,body:String(Y),v:y}),fn=(_,G,Y="")=>"/api/document?"+new URLSearchParams({copy:_,kind:G,label:Y}),Gt=(_,G="",Y="")=>JSON.stringify([_,G,Y]);function hh(){const[_,G]=Sl.useState(null),[Y,y]=Sl.useState(""),[q,$]=Sl.useState(""),[rl,Gl]=Sl.useState(""),[ul,Dl]=Sl.useState(""),[R,b]=Sl.useState(20),[M,_l]=Sl.useState({}),[Xl,Ul]=Sl.useState(!1),[il,et]=Sl.useState(!1),[Ol,gt]=Sl.useState(null),[Ql,at]=Sl.useState(null),[xl,K]=Sl.useState(!1),F=async()=>{try{G(await oa("state")),y("")}catch(O){y(String(O))}};Sl.useEffect(()=>{F()},[]),Sl.useEffect(()=>{const O=N=>{Object.keys(M).length&&(N.preventDefault(),N.returnValue="")};return window.addEventListener("beforeunload",O),()=>window.removeEventListener("beforeunload",O)},[M]),Sl.useEffect(()=>{if(!_?.job.running)return;const O=setInterval(async()=>{try{const N=await oa("job");G(s=>s&&{...s,job:N}),N.running||(await F(),$(N.status==="success"?"Les sorties ont été régénérées.":"La régénération est incomplète. Consultez le journal."))}catch(N){y(String(N))}},1e3);return()=>clearInterval(O)},[_?.job.running]);const Vl=(O,N,s,E)=>M[Gt(O,N,s)]?.value??E,Nl=(O,N,s,E)=>_l(D=>({...D,[Gt(O,N,s)]:{kind:O,copy:N,label:s,value:E}})),pl=async O=>{if(!_)return;Ul(!0),y("");let N=_;try{for(const s of O){const E=M[s];E&&(N=await oa("change",{...E,revision:N.revision}),G(N),_l(D=>{const j={...D};return delete j[s],j}))}$("Modifications enregistrées.")}catch(s){y(String(s))}finally{Ul(!1)}},Ll=async()=>{if(_){Ul(!0),y("");try{const O=await oa("regenerate",{revision:_.revision,apply_draft:il});G({..._,job:O}),$("")}catch(O){y(String(O))}finally{Ul(!1)}}},jt=async()=>{try{await oa("stop",{}),K(!0)}catch(O){y(String(O))}};if(xl)return d.jsxs("main",{className:"end",children:[d.jsx("h1",{children:"Session terminée"}),d.jsx("p",{children:"Vous pouvez reprendre le travail dans Copienator."})]});if(!_)return d.jsxs("main",{className:"end",children:[d.jsx("h1",{children:"Copienator"}),d.jsx("p",{children:Y||"Chargement de la finalisation…"}),d.jsx("button",{onClick:F,children:"Réessayer"})]});const L=Xl||_.job.running,hl=Object.keys(M).length,C=Vl("draft","","",_.draft),B=_.draft.trim()!==_.applied.trim(),X=_.copies.filter(O=>!rl||Number(O.id)===Number(rl)),cl=X.flatMap(O=>O.answers.filter(N=>!ul||N.label===ul).map(N=>({c:O,a:N}))),el=new Map;_.copies.forEach(O=>el.set(O.name.toLowerCase(),(el.get(O.name.toLowerCase())||0)+1));const ot=_.copies.filter(O=>O.name.toLowerCase()==="unknown"||(el.get(O.name.toLowerCase())||0)>1);return d.jsxs(d.Fragment,{children:[d.jsxs("header",{inert:!!Ol||!!Ql,children:[d.jsxs("div",{className:"brand",children:["C",d.jsx("span",{children:"Copienator"})]}),d.jsxs("div",{className:"heading",children:[d.jsxs("div",{className:"eyebrow",children:["FINALISATION · ",_.mode]}),d.jsx("h1",{children:_.evaluation})]}),d.jsx("button",{className:"quiet",disabled:L||!!hl,onClick:jt,children:"Fermer et reprendre dans le GUI"})]}),d.jsxs("main",{inert:!!Ol||!!Ql,children:[d.jsxs("section",{className:"toolbar",children:[d.jsxs("div",{children:[d.jsx("h2",{children:"Vérifier les copies"}),d.jsx("p",{children:"Retrouvez une copie, une question, ou leur intersection."})]}),d.jsxs("div",{className:"filters",children:[d.jsxs("label",{children:["Copie",d.jsx("input",{type:"text",inputMode:"numeric",list:"copies",placeholder:"Toutes les copies",value:rl,onChange:O=>{Gl(O.target.value.replace(/\D/g,"")),b(20)}})]}),d.jsx("datalist",{id:"copies",children:_.copies.map(O=>d.jsx("option",{value:O.id,children:O.name},O.id))}),d.jsxs("label",{children:["Label",d.jsxs("select",{value:ul,onChange:O=>{Dl(O.target.value),b(20)},children:[d.jsx("option",{value:"",children:"Tous les labels"}),_.labels.map(O=>d.jsx("option",{children:O},O))]})]}),d.jsx("button",{onClick:()=>{Gl(""),Dl(""),b(20)},children:"Réinitialiser"})]})]}),Y&&d.jsxs("div",{role:"alert",className:"alert",children:[Y,d.jsx("button",{onClick:F,children:"Recharger les données"}),d.jsx("small",{children:"Les saisies non enregistrées restent dans la page."})]}),q&&d.jsx("p",{role:"status",className:"notice",children:q}),d.jsxs("section",{className:"publish",children:[d.jsxs("div",{children:[d.jsx("strong",{children:_.job.running?"Régénération en cours…":hl?`${hl} modification(s) non enregistrée(s)`:_.dirty?"Enregistré · sorties à régénérer":"Modifications enregistrées"}),d.jsx("small",{children:"Les sorties comprennent les images, PDF et le dossier A Rendre configurés."})]}),d.jsx("button",{disabled:L||!hl,onClick:()=>pl(Object.keys(M)),children:"Tout enregistrer"}),hl>0&&d.jsx("button",{disabled:L,onClick:()=>_l({}),children:"Annuler les saisies"}),d.jsxs("label",{className:"check",children:[d.jsx("input",{type:"checkbox",checked:il,disabled:L,onChange:O=>et(O.target.checked)}),"Appliquer aussi le brouillon de transferts",B?" (différent du résultat actuel)":""]}),d.jsx("button",{className:"primary",disabled:L||!!hl,onClick:Ll,children:"Régénérer les sorties"})]}),d.jsxs("fieldset",{disabled:L,children:[d.jsxs("details",{className:"panel",children:[d.jsxs("summary",{children:["Identités et remarques générales ",d.jsxs("span",{children:[ot.length," nom(s) à vérifier"]})]}),d.jsx("p",{children:"Les remarques sont composées en LaTeX. Leur ordre est celui de la liste."}),X.map(O=>{const N=Vl("general",O.id,"",O.general),s=E=>Nl("general",O.id,"",E);return d.jsxs("section",{className:"identity",children:[d.jsxs("div",{className:"identity-name",children:[d.jsxs("strong",{children:["Copie ",O.id]}),d.jsxs("label",{children:["Nom",d.jsx("input",{value:Vl("name",O.id,"",O.name),onChange:E=>Nl("name",O.id,"",E.target.value)})]}),ot.includes(O)&&d.jsx("span",{className:"badge warning",children:"Nom à vérifier"}),d.jsx("a",{target:"_blank",rel:"noreferrer",href:fn(O.id,"original"),children:"Voir l’originale ↗"}),d.jsx("button",{disabled:!M[Gt("name",O.id)],onClick:()=>pl([Gt("name",O.id)]),children:"Enregistrer le nom"})]}),d.jsxs("div",{className:"general",children:[N.map((E,D)=>d.jsxs("div",{className:"remark-row",children:[d.jsx("textarea",{"aria-label":`Remarque générale ${D+1}, copie ${O.id}`,value:E,onChange:j=>s(N.map((k,al)=>D===al?j.target.value:k))}),d.jsx("button",{disabled:D===0,"aria-label":"Monter",onClick:()=>{const j=[...N];[j[D-1],j[D]]=[j[D],j[D-1]],s(j)},children:"↑"}),d.jsx("button",{disabled:D===N.length-1,"aria-label":"Descendre",onClick:()=>{const j=[...N];[j[D+1],j[D]]=[j[D],j[D+1]],s(j)},children:"↓"}),d.jsx("button",{onClick:()=>s(N.filter((j,k)=>k!==D)),children:"Supprimer"})]},D)),d.jsx("button",{onClick:()=>s([...N,""]),children:"+ Remarque générale"}),d.jsx("button",{disabled:!M[Gt("general",O.id)],onClick:()=>pl([Gt("general",O.id)]),children:"Enregistrer les remarques"})]})]},O.id)})]}),d.jsxs("details",{className:"panel",children:[d.jsxs("summary",{children:["Transferts et coupes ",d.jsx("span",{children:B?"Brouillon non appliqué":"Brouillon identique aux modifications appliquées"})]}),d.jsx("p",{children:"Les en-têtes et scores restent à leur label. Enregistrer un brouillon ne l’applique pas. Pour annuler un transfert, retirez sa ligne puis régénérez en cochant « Appliquer aussi le brouillon »."}),d.jsx("div",{className:"draft-list",children:C.split(` +`).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?"":"|"}`,Ll=$.trimEnd()+` +`+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,{})); diff --git a/copienator/web_static/index.html b/copienator/web_static/index.html new file mode 100644 index 0000000..9d2c8f6 --- /dev/null +++ b/copienator/web_static/index.html @@ -0,0 +1,3 @@ +Copienator · Finalisation + +
diff --git a/copienator_gui/app.py b/copienator_gui/app.py index ce5f9d9..9b3cf0a 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -279,6 +279,7 @@ class CopienatorApp(tk.Tk): self.step_by_id = {step.id: step for step in self.steps} self.state_store = StateStore() self.runner = ProcessRunner() + self.web_session = None self.active_step_id: str | None = None self.current_step: StepDefinition | None = None self.arg_vars: dict[str, tk.Variable] = {} @@ -359,6 +360,7 @@ class CopienatorApp(tk.Tk): top, text="Ouvrir l’énoncé", command=self._open_statement_pdf ) self.open_statement_button.grid(row=0, column=5, padx=(6, 0)) + ttk.Button(top, text="Finalisation web…", command=self._open_finalisation_web).grid(row=0, column=6, padx=(6, 0)) ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=7, padx=(6, 0)) ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0)) @@ -536,6 +538,107 @@ class CopienatorApp(tk.Tk): status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3)) status.grid(row=3, column=0, sticky="ew") + def _open_finalisation_web(self) -> None: + if self.web_session is not None or self.runner.running or self.active_step_id or self.batch_monitor.active: + messagebox.showinfo("Traitement en cours", "Terminez le traitement ou la surveillance en cours avant d’ouvrir la finalisation web.") + return + if not self.evaluation: + messagebox.showerror("Évaluation absente", "Chargez d’abord une évaluation.") + return + self._save_current_form() + dialog = tk.Toplevel(self) + dialog.title("Finalisation web") + dialog.transient(self) + dialog.resizable(False, False) + frame = ttk.Frame(dialog, padding=24) + frame.pack(fill="both", expand=True) + text = tk.StringVar(value="Choisissez le mode utilisé pour lire les annotations manuscrites.") + ttk.Label(frame, textvariable=text, wraplength=500).pack(pady=(0, 12)) + mode = tk.StringVar(value=annotation_mode_for_reader(self.state_store.step("read_annotations"))) + choice = ttk.Combobox(frame, textvariable=mode, values=("BGnot", "Bnot"), state="readonly") + choice.pack(fill="x", pady=8) + url = tk.StringVar() + link = ttk.Entry(frame, textvariable=url, state="readonly", width=65) + link.pack(fill="x", pady=8) + + def open_browser(): + import webbrowser + if self.web_session: + webbrowser.open(self.web_session.url) + + def restore(): + session = self.web_session + self.web_session = None + dialog.grab_release() + dialog.destroy() + if session: + data = session.service.state() + reader = self.state_store.step("read_annotations") + values = dict(reader.get("values", {})) + values["update_score"] = True + if data["dirty"] or session.service.job["status"] != "idle": + self.state_store.invalidate_after(self._progression_ids("read_annotations"), "read_annotations") + self.state_store.update_step("read_annotations", variant="standard" if session.service.mode == "Bnot" else "grouped") + if data["dirty"] or session.service.job["status"] == "failed": + self.state_store.update_step("read_annotations", status="stale", values=values) + self.state_store.update_step("giving_names", status="stale") + elif session.service.job["status"] == "success": + self.state_store.update_step("read_annotations", status="success", values=values) + self.state_store.update_step("giving_names", status="detected") + self._populate_tree() + self._render_step() + self._update_controls() + + def poll(): + session = self.web_session + if session is None: + return + if not session.alive: + restore() + return + running = session.service.job["running"] + close_button.configure(state="disabled" if running else "normal") + text.set("Régénération en cours. Le GUI reste verrouillé jusqu’à la fin." if running else + "Finalisation ouverte dans le navigateur. Le GUI est verrouillé pendant cette session.") + dialog.after(300, poll) + + def stop(): + if self.web_session: + try: + self.web_session.request_stop() + text.set("Fermeture du serveur…") + except ValueError as exc: + messagebox.showinfo("Traitement en cours", str(exc), parent=dialog) + else: + restore() + + def start(): + try: + from copienator.finalisation_server import WebSession + self.web_session = WebSession(self.evaluation, mode.get()) + except Exception as exc: + messagebox.showerror("Finalisation web", str(exc), parent=dialog) + return + choice.configure(state="disabled") + start_button.configure(state="disabled") + url.set(self.web_session.url) + browser_button.configure(state="normal") + close_button.configure(text="Arrêter le serveur et reprendre") + open_browser() + poll() + + actions = ttk.Frame(frame) + actions.pack(pady=12) + start_button = ttk.Button(actions, text="Ouvrir", command=start) + start_button.pack(side="left", padx=4) + browser_button = ttk.Button(actions, text="Rouvrir le navigateur", command=open_browser, state="disabled") + browser_button.pack(side="left", padx=4) + close_button = ttk.Button(actions, text="Annuler", command=stop) + close_button.pack(side="left", padx=4) + dialog.protocol("WM_DELETE_WINDOW", stop) + dialog.grab_set() + dialog.focus_set() + def _show_diagnostics(self) -> None: checks = collect_diagnostics( self.show_personal_steps, self.api_key_var.get(), self.evaluation @@ -1617,6 +1720,8 @@ class CopienatorApp(tk.Tk): return True def _run_current_step(self, *, syncthing_ready=False, review_command=None, orientation_repair=False) -> None: + if self.web_session is not None: + return step = self.current_step evaluation = self.evaluation if orientation_repair and (not step or step.id != 'labels'): @@ -2328,6 +2433,9 @@ class CopienatorApp(tk.Tk): self.tree.see("correction") def _on_close(self) -> None: + if self.web_session is not None: + messagebox.showinfo("Finalisation web", "Arrêtez le serveur depuis la fenêtre de finalisation avant de fermer le GUI.") + return if self.runner.running: if not messagebox.askyesno( "Traitement en cours", "Un script est encore actif. Le forcer à s’arrêter et fermer l’application ?" diff --git a/copienator_gui/giving_names.py b/copienator_gui/giving_names.py index 920f978..cc2be16 100644 --- a/copienator_gui/giving_names.py +++ b/copienator_gui/giving_names.py @@ -13,109 +13,7 @@ from copienator import EvaluationWorkspace from copienator.platform import open_path, safe_filename -RETURN_FOLDER_PATTERN = re.compile(r"^(?P.+) \((?P\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 +from copienator.return_identity import (RETURN_FOLDER_PATTERN, NameIssue, find_name_issues, original_copy_path, rename_return_copy) class GivingNamesPanel(ttk.LabelFrame): diff --git a/copienator_gui/scores.py b/copienator_gui/scores.py index f8dcb32..3c09c34 100644 --- a/copienator_gui/scores.py +++ b/copienator_gui/scores.py @@ -32,33 +32,7 @@ def score_path_for_id(evaluation: Path, copy_id: str) -> Path: return matches[0] -def read_scores(path: Path) -> dict: - scores = read_json(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(path.resolve(strict=True), update, default_factory=dict) - return previous +from copienator.score_editing import read_scores, set_score class ScoreEditorPanel(ttk.Frame): diff --git a/docs/finalisation_web.md b/docs/finalisation_web.md new file mode 100644 index 0000000..e87ff3d --- /dev/null +++ b/docs/finalisation_web.md @@ -0,0 +1,117 @@ +# Finalisation dans le navigateur + +La finalisation web est une **alternative facultative** aux quatre panneaux de +finalisation. Les panneaux existants et les commandes restent disponibles. +Elle fonctionne sur cet ordinateur, sans hébergement ni nouvel appel à Gemini. + +## Ouvrir + +Après **Lire les annotations manuscrites**, cliquez sur **Finalisation web…** +en haut du GUI, choisissez **Bnot** ou **BGnot**, puis **Ouvrir**. Le GUI reste +verrouillé par une fenêtre modale tant que le serveur fonctionne. Le bouton +**Arrêter le serveur et reprendre** ferme la session et recharge les éditeurs. +Fermer seulement l’onglet du navigateur ne ferme pas le serveur. + +La commande équivalente est : + +```sh +python -m copienator finalise-web CHEMIN_EVALUATION --annotation-dir BGnot +``` + +`--no-browser` affiche le lien sans ouvrir automatiquement le navigateur. +Les commandes utilisant le gestionnaire CLI commun respectent le verrou de +l’évaluation. Une régénération en cours doit finir avant l’arrêt normal du serveur. +Le verrou système est libéré à l’arrêt du processus ; le fichier de verrou +restant sur disque n’empêche pas la prochaine ouverture. + +## Vérifier et modifier + +- **Copie** seule : toutes les réponses de cette copie. +- **Label** seul : toutes les copies pour ce label. +- Les deux : leur intersection. Sans filtre, la page présente toutes les réponses + par lots de vingt, avec chargement différé des images. +- Cliquez sur une réponse pour l’agrandir. Les liens donnent accès au contexte, + à l’énoncé, au corrigé et au PDF final lorsqu’ils existent. +- Modifiez scores et remarques personnelles sous les images. Les remarques + acceptent le LaTeX utilisé dans le workflow existant. +- **Identités et remarques générales** permet de corriger les noms, d’ouvrir + l’originale et d’ajouter, supprimer ou réordonner les remarques de chaque copie. +- **Couper / transférer** montre exclusivement les corps de réponse. Cliquez sur + la source pour placer la coupe, ou réglez son pourcentage. Choisissez la cible, + la partie conservée et le placement, puis vérifiez **Aperçu du résultat**. + +Les saisies locales sont conservées lors d’un changement de filtre. Enregistrez +les modifications avant de régénérer. Si une autre page a changé les fichiers, +une sauvegarde périmée est refusée ; **Recharger les données** actualise les +valeurs enregistrées en conservant les saisies locales à réexaminer. + +## Brouillon, application et régénération + +**Ajouter au brouillon** ne modifie pas les images. **Enregistrer le brouillon** +écrit le même `annotation_modifications.txt` que le GUI. Le fichier +`Bnot/annotation_modifications_applied.txt` ou son équivalent BGnot reste le +relevé des instructions effectivement appliquées. + +**Régénérer les sorties** conserve par défaut ces instructions appliquées. Cochez +**Appliquer aussi le brouillon de transferts** pour remplacer les instructions +appliquées par le brouillon enregistré. Retirer une ligne du brouillon puis +l’appliquer annule le transfert correspondant. Les contraintes existantes restent +valables : une instruction par source, une cible ne peut pas être source, aucun +déplacement automatique des scores ou des en-têtes. + +La régénération actualise les réponses, les documents combinés et **A Rendre** +selon les options de sortie existantes, en conservant les noms personnalisés. +Le journal indique les erreurs et les résultats incomplets. La chaîne s’arrête +à la première étape non réussie ; les modifications enregistrées restent +récupérables pour une nouvelle tentative. Les publications gardent les garanties +transactionnelles des moteurs existants (par copie pour la lecture, ensemble des +copies concernées pour les transferts), pas une transaction globale de toute +l’évaluation. + +## Compatibilité et état de lecture + +Les scores restent dans le dossier d’annotations choisi ; les remarques utilisent +`custom_remarks.json` et `general_remarks.json`. Les éditeurs existants utilisent +la même source de scores, y compris lorsque **A Rendre** contient des copies de +fichiers plutôt que des liens. La préparation des retours mémorise cette source +et retrouve les dossiers renommés par leur identifiant de copie. + +Les lecteurs enregistrent maintenant `review_snapshot.json` et des images +`.review-note-*.png` : les données de correction après interprétation des cases, +la géométrie et les couches manuscrites, avant ajout des remarques personnelles. +Le navigateur utilise ces données pour reconstruire les en-têtes sans relire les +PDF manuscrits ni accumuler les remarques. Les reprises sélectives fusionnent +leurs seules questions avec l’état précédemment lu. Les baselines sans perte +restent la référence des coupes et transferts. + +Pour une ancienne évaluation sans cet état, une régénération web ordinaire peut +le créer avec le lecteur existant et les scores conservés. En présence de BRnot, +la régénération refuse cette conversion automatique : il faut disposer d’une +lecture du passage principal puis de la reprise avec cette version pour conserver +l’interprétation complète. L’affichage et les éditeurs restent accessibles. + +Si vous importez de nouvelles annotations ou refaites la correction/découpe, +repassez par **Lire les annotations manuscrites** dans le workflow existant avant +de revenir au navigateur. `regenerate-reviewed` réutilise l’interprétation +sauvegardée ; il ne recherche pas de nouvelles annotations. + +Anot reste géré par le workflow existant. La finalisation web concerne Bnot et +BGnot, les deux modes qui disposent de corps de réponses éditables. + +## Développement + +L’API FastAPI/Uvicorn est dans `copienator/finalisation_server.py`, les opérations +dans `copienator/finalisation.py`. Le client React/TypeScript est dans `web/`. +Le paquet Python inclut les fichiers construits dans `copienator/web_static/` : +Node n’est pas nécessaire pour utiliser l’application installée. + +```sh +cd web +npm ci +npm run build +``` + +Le serveur écoute uniquement sur `127.0.0.1`, sur un port libre, et exige un jeton +de session échangé contre un cookie HttpOnly. Les requêtes d’une autre origine +sont refusées. Les chemins des documents sont déterminés à partir des identifiants +validés, jamais fournis librement par le navigateur. diff --git a/docs/guide_utilisateur_gui.md b/docs/guide_utilisateur_gui.md index 7b3a9a9..36762ac 100644 --- a/docs/guide_utilisateur_gui.md +++ b/docs/guide_utilisateur_gui.md @@ -635,3 +635,12 @@ Vérifiez le **Passage principal**, puis exécutez. Contrôlez une question refa **Effet :** supprime définitivement les fichiers de travail et la progression de l’interface en conservant les livrables prévus. Attendez la fin des relectures et reprises. Vérifiez les résultats de tous les élèves dans `A Rendre` et sauvegardez l’évaluation si vous voulez pouvoir reprendre le travail. Choisissez d’abord **Prévisualiser sans supprimer**, puis examinez la liste. Ne lancez **Nettoyage définitif** qu’une fois certain de ne plus avoir besoin des intermédiaires : les PDF traités, textes de l’énoncé, corrections, journaux et livrables de `A Rendre` sont conservés, mais une reprise exigera de régénérer les données supprimées. + +## Alternative : finalisation dans le navigateur + +Le bouton **Finalisation web…** ouvre une interface locale facultative après la +lecture des annotations Bnot ou BGnot. Elle réunit les réponses filtrées par copie +et/ou label, les scores, remarques, noms et coupes/transferts. Le GUI est verrouillé +pendant la session ; les fichiers restent compatibles avec les panneaux existants. +Voir [le guide de finalisation web](finalisation_web.md) pour l’enregistrement, +l’application des brouillons, la régénération et les anciennes évaluations. diff --git a/pyproject.toml b/pyproject.toml index ff0fff8..7df9a07 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ version = "0.1.0" description = "Workflow assistant for preparing and correcting scanned exams" requires-python = ">=3.11" dependencies = [ + "fastapi>=0.115", + "uvicorn>=0.30", "numpy", "opencv-python-headless>=4.8", "pandas", @@ -35,4 +37,4 @@ py-modules = ["default_config"] include = ["copienator*", "copienator_gui*"] [tool.setuptools.package-data] -copienator = ["data/*.txt"] +copienator = ["data/*.txt", "web_static/*", "web_static/assets/*"] diff --git a/tests/test_finalisation_web.py b/tests/test_finalisation_web.py new file mode 100644 index 0000000..1689e25 --- /dev/null +++ b/tests/test_finalisation_web.py @@ -0,0 +1,273 @@ +from __future__ import annotations + +from io import BytesIO, StringIO +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import unittest +from unittest.mock import patch + +from fastapi.testclient import TestClient +from PIL import Image + +from copienator import CliError, EvaluationWorkspace, atomic_write_json, read_json +from copienator.annotation_modifications import APPLIED, DRAFT, stage_baseline +from copienator.commands import modify_annotations, giving_names +from copienator.evaluation_lock import EvaluationLock +from copienator.filesystem import staged_files +from copienator.finalisation import Finalisation +from copienator.finalisation_server import create_app +from copienator.return_identity import rename_return_copy +from copienator.score_editing import read_scores, set_score + + +def seed(root, mode='Bnot'): + (root / 'Copies' / 'Copie01').mkdir(parents=True, exist_ok=True) + (root / 'labels').write_text('Ex 1\nEx 2\nAbsent\n') + atomic_write_json(root / 'Copies' / 'Copie01.json', {'name': 'Unknown'}) + scores = {'Ex 1': '3', 'Ex 2': '4', 'Absent': ''} + info = {label: {'present': label != 'Absent', 'not_empty': label != 'Absent', 'touched': False, 'score': score} + for label, score in scores.items()} + images = {'Ex 1': Image.new('RGB', (100, 120), 'blue'), 'Ex 2': Image.new('RGB', (100, 80), 'green')} + output = root / mode / 'Copie01' + with staged_files(output) as staging: + atomic_write_json(staging / 'score.json', scores) + atomic_write_json(staging / 'info.json', info) + for label, image in images.items(): + image.save(staging / f'{label}.jpg') + images['Ex 1'].save(staging / 'Concat.jpg') + stage_baseline(root, mode, output, staging, images, {'Ex 1': 20, 'Ex 2': 10}, info, [], scores) + + +class WebTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = Path(self.temp.name) + seed(self.root) + self.service = Finalisation(self.root, 'Bnot', 'worker-token') + self.client = TestClient(create_app(self.service, 'browser-token')) + self.addCleanup(self.client.close) + self.client.get('/?token=browser-token') + + def save(self, kind, value, copy='01', label='Ex 1'): + response = self.client.post('/api/change', json={'kind': kind, 'value': value, 'copy': copy, + 'label': label, 'revision': self.service.revision()}) + self.assertEqual(response.status_code, 200, response.text) + return response.json() + + def test_session_origin_and_paths(self): + outsider = TestClient(create_app(self.service, 'browser-token')) + self.assertEqual(outsider.get('/api/state').status_code, 403) + self.assertEqual(self.client.get('/api/state', headers={'Host': 'evil.example'}).status_code, 403) + self.assertEqual(self.client.post('/api/stop', headers={'Origin':'https://evil.example'}).status_code, 403) + self.assertEqual(self.client.get('/api/image', params={'copy':'../', 'label':'Ex 1'}).status_code, 409) + self.assertEqual(self.client.get('/api/image', params={'copy':'01', 'label':'../labels'}).status_code, 409) + self.assertEqual(self.client.get('/api/document', params={'copy':'01', 'kind':'question','label':'Absent'}).status_code, 404) + self.assertEqual(self.client.get('/').status_code, 200) + + def test_browser_and_existing_editor_share_scores_and_remarks(self): + for mode in ('Bnot', 'BGnot'): + with self.subTest(mode=mode): + seed(self.root, mode) + self.service = Finalisation(self.root, mode, 'worker-token') + self.client = TestClient(create_app(self.service, 'browser-token')) + self.client.get('/?token=browser-token') + self.save('score', '2,5') + score = self.root / mode / 'Copie01/score.json' + self.assertEqual(read_scores(score)['Ex 1'], '2.5') + set_score(score, 'Ex 1', '1') + self.assertEqual(self.service.state()['copies'][0]['answers'][0]['score'], '1') + self.save('remark', 'Une remarque $x^2$.') + self.save('general', ['Première', 'Deuxième']) + self.save('general', ['Deuxième', 'Première']) + self.assertEqual(self.service.state()['copies'][0]['general'], ['Deuxième','Première']) + self.save('remark', '') + self.assertEqual(self.service.state()['copies'][0]['answers'][0]['remark'], '') + + def test_stale_tab_and_busy_writes_rejected(self): + old = self.service.revision() + self.save('score', '2') + response = self.client.post('/api/change', json={'kind':'score','value':'1','copy':'01','label':'Ex 1','revision':old}) + self.assertEqual(response.status_code, 409) + self.service.job['running'] = True + self.assertEqual(self.client.post('/api/stop').status_code, 409) + response = self.client.post('/api/change', json={'kind':'score','value':'1','copy':'01','label':'Ex 1','revision':self.service.revision()}) + self.assertEqual(response.status_code, 409) + self.service.job['running'] = False + + def test_draft_preview_apply_reapply_and_undo(self): + text = '### Source: Bnot\nCopie01 Ex 1 c{40}1> Ex 2|\n' + before = (self.root / 'Bnot/Copie01/Ex 1.jpg').read_bytes() + self.save('draft', text) + self.assertEqual((self.root / 'Bnot/Copie01/Ex 1.jpg').read_bytes(), before) + self.assertFalse((self.root / 'Bnot' / APPLIED).exists()) + result = self.client.post('/api/preview', json={'copy':'01','source':'Ex 1','target':'Ex 2','text':text}) + self.assertEqual(result.status_code, 200, result.text) + import base64 + rendered = Image.open(BytesIO(base64.b64decode(result.json()['Ex 1'].split(',')[1]))) + self.assertEqual(rendered.height, 40) # cut uses 100px body, not 120px image + modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot') + applied = (self.root / 'Bnot/Copie01/Ex 2.jpg').read_bytes() + modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot') + self.assertEqual((self.root / 'Bnot/Copie01/Ex 2.jpg').read_bytes(), applied) + self.save('draft', '### Source: Bnot\n') + self.assertEqual((self.root / 'Bnot' / APPLIED).read_text(), text) + modify_annotations.run(EvaluationWorkspace(self.root), annotation_dir='Bnot') + self.assertEqual(Image.open(self.root / 'Bnot/Copie01/Ex 1.jpg').height, 120) + + def test_invalid_draft_preserves_previous_file(self): + self.save('draft', '### Source: Bnot\n') + for text in ('### Source: BGnot\n', '### Source: Bnot\nCopie01 Ex 1 -> Ex 2|\nCopie01 Ex 2 -> Absent|\n'): + response = self.client.post('/api/change', json={'kind':'draft','value':text,'revision':self.service.revision()}) + self.assertEqual(response.status_code, 409) + self.assertEqual((self.root / DRAFT).read_text(), '### Source: Bnot\n') + + @patch('copienator.commands.giving_names.configuration.RETURN_ANSWERS_ENABLED', False) + def test_names_and_fallback_scores_survive_both_workflows(self): + workspace = EvaluationWorkspace(self.root) + self.save('name', 'Alice') + self.assertEqual(giving_names.prepare_named_returns(workspace, 'Bnot'), 0) + folder = rename_return_copy(self.root / 'A Rendre/Alice (01)', 'Alice corrigée') + self.assertEqual(self.service.state()['copies'][0]['name'], 'Alice corrigée') + self.assertEqual(giving_names.prepare_named_returns(workspace, 'Bnot'), 0) + self.assertEqual(list((self.root / 'A Rendre').iterdir()), [folder]) + score = folder / 'score.json' + score.unlink() + atomic_write_json(score, {'Ex 1':'3','Ex 2':'4','Absent':''}) + set_score(score, 'Ex 1', '1') + self.assertEqual(read_scores(self.root / 'Bnot/Copie01/score.json')['Ex 1'], '1') + self.save('score', '2') + self.assertEqual(read_scores(score)['Ex 1'], '2') + self.save('name', 'Alice finale') + self.assertTrue((self.root / 'A Rendre/Alice finale (01)/Alice finale.jpg').is_file()) + + def test_worker_order_flags_and_failure(self): + calls = [] + class Process: + stdout = StringIO('done\n') + def __init__(self, command, **kwargs): + calls.append(command) + def __enter__(self): return self + def __exit__(self, *args): pass + def wait(self): return 0 + with patch('copienator.finalisation.subprocess.Popen', Process): + self.service._regenerate(True) + self.assertEqual([c[3] for c in calls], ['read-annotations','modify-annotations','giving-names']) + self.assertTrue(any('return-answers-question' in v for v in calls[0])) + self.assertEqual(self.service.job['status'], 'success') + calls.clear() + with patch.object(Process, 'wait', return_value=4), patch('copienator.finalisation.subprocess.Popen', Process): + self.service._regenerate(True) + self.assertEqual(len(calls), 1) + self.assertEqual(self.service.job['status'], 'failed') + + def test_os_lock_blocks_cli_and_releases(self): + with EvaluationLock(self.root): + with self.assertRaises(CliError): + with EvaluationLock(self.root): + pass + result = subprocess.run([sys.executable,'-m','copienator','giving-names',str(self.root),'Bnot'], capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('utilisée', result.stderr) + with EvaluationLock(self.root): + pass + + +class SnapshotTests(unittest.TestCase): + def test_selective_redo_merges_raw_inputs_and_preserves_handwriting(self): + from copienator.review_snapshot import stage, load + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + seed(root) + output = root / 'Bnot/Copie01' + data = {l: {'pdf_path': root / 'Copies/Copie01' / f'{l}.pdf', + 'result': {'score': 3, 'feedback': []}, 'coordinates': [0, 0]} + for l in ('Ex 1', 'Ex 2')} + ink = Image.new('RGBA', (100, 120), (255, 0, 0, 255)) + bnote = {'images': [{'label':'Ex 1', 'header_height':20}, {'label':'Ex 2','header_height':10}]} + with staged_files(output) as staging: + stage(output, staging, data, {'Ex 1': {'img': ink}}, engine='standard', bnote=bnote) + data['Ex 2']['result']['score'] = 1 + with staged_files(output) as staging: + stage(output, staging, data, {}, engine='grouped', selected={'Ex 2'}, force_filtered={'Ex 1'}) + saved, restored, notes = load(root, output) + self.assertEqual(restored['Ex 1']['result']['score'], 3) + self.assertEqual(restored['Ex 2']['result']['score'], 1) + self.assertEqual(notes['Ex 1']['img'].getpixel((0,0)), (255,0,0,255)) + self.assertEqual(notes['Ex 1']['old_header_h'], 20) + self.assertIn('Ex 1', saved['force_filtered']) + self.assertEqual(restored['Ex 1']['pdf_path'], root / 'Copies/Copie01/Ex 1.pdf') + + def test_cached_regeneration_does_not_accumulate_custom_remarks(self): + from copienator.review_snapshot import stage, load + from copienator.commands import regenerate_reviewed + from copienator.commands import annotating + from copienator.custom_remarks import set_remark + for mode in ('Bnot', 'BGnot'): + with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + seed(root, mode) + output = root / mode / 'Copie01' + pdf = root / 'Copies/Copie01/Ex 1.pdf' + pdf.write_bytes(b'placeholder') + data = {'Ex 1': {'pdf_path': pdf, 'result': {'score': 3, 'feedback': []}, 'coordinates': [0,0]}} + with staged_files(output) as staging: + stage(output, staging, data, {}, engine='standard' if mode == 'Bnot' else 'grouped', + bnote={'images':[{'label':'Ex 1','header_height':20}]}) + set_remark(root, '01', 'Ex 1', 'Une seule remarque') + with patch.object(annotating, 'make_base_image', return_value=(Image.new('RGB',(100,100),'white'),0,0)), \ + patch.object(annotating, 'compose_label_image', return_value=(Image.new('RGB',(100,120),'white'),20)) as compose, \ + patch('copienator.commands.reading_grouped_annotations.get_extra_pdfs_as_images', return_value=[]): + for _ in range(2): + self.assertEqual(regenerate_reviewed.run(EvaluationWorkspace(root), mode), 0) + feedback = compose.call_args.args[2]['feedback'] + self.assertEqual(len(feedback), 1) + self.assertEqual(feedback[0]['text'], 'Une seule remarque') + _, raw, _ = load(root, output) + self.assertEqual(raw['Ex 1']['result']['feedback'], []) + + +@unittest.skipUnless(os.environ.get('DISPLAY'), 'Tk requires a display') +class WebGuiTests(unittest.TestCase): + def test_modal_lock_and_return_to_existing_workflow(self): + from copienator_gui.app import CopienatorApp + from copienator.finalisation_server import WebSession + import tkinter as tk + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + seed(root) + app = CopienatorApp(Path.cwd(), False, root) + self.addCleanup(app.destroy) + service = Finalisation(root, 'Bnot', 'token') + class FakeSession: + url = 'http://127.0.0.1:12345/' + alive = True + def request_stop(self): self.alive = False + session = FakeSession() + session.service = service + def descendants(widget): + for child in widget.winfo_children(): + yield child + yield from descendants(child) + def button(text): + return next(w for w in descendants(app) if w.winfo_class() == 'TButton' and w.cget('text') == text) + with patch('copienator.finalisation_server.WebSession', return_value=session), patch('webbrowser.open'): + app._open_finalisation_web() + app.update() + self.assertIsInstance(app.grab_current(), tk.Toplevel) + button('Ouvrir').invoke() + self.assertIs(app.web_session, session) + self.assertEqual(str(button('Ouvrir').cget('state')), 'disabled') + service.save({'kind':'score','copy':'01','label':'Ex 1','value':'1','revision':service.revision()}) + with patch.object(app.runner, 'start') as start: + app._run_current_step() + start.assert_not_called() + button('Arrêter le serveur et reprendre').invoke() + app.after(450, app.quit) + app.mainloop() + self.assertIsNone(app.web_session) + self.assertIsNone(app.grab_current()) + self.assertEqual(app.state_store.step('read_annotations')['status'], 'stale') diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6fb2cc1 --- /dev/null +++ b/web/index.html @@ -0,0 +1 @@ +Copienator · Finalisation
diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..07019ac --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1254 @@ +{ + "name": "copienator-finalisation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "copienator-finalisation", + "version": "1.0.0", + "dependencies": { + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@types/react": "^19.2.0", + "@types/react-dom": "^19.2.0", + "typescript": "^5.9.0", + "vite": "^7.2.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.67.3.tgz", + "integrity": "sha512-o4kGHKu2YRn/jegh1XMs6+1/3SuX6AfWaACGEWavFvN5D6YZd9OrMEBqcIMj0uTeKZr6nTXPhlpkc0IXC6DMXQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.67.3.tgz", + "integrity": "sha512-vfEEFajQ0K5crrtAAyownI2ZFthoY+BTwGnjoMgd4+l3YPuAcweykaMqcMYXT1wJKCnMiSIpNnAroAWix2fJ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-x64/-/rollup-android-x64-4.67.3.tgz", + "integrity": "sha512-/oNPEUU5nVDZmp1CYGDMIGdUBzON5z5RQufJbtjnOD4+CZYt//2ECHRvvmJL3EXLHaqQ7Zjr1dSTlhLCxJJbGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.67.3.tgz", + "integrity": "sha512-Otbw33hEDwrM/jUn3nib5JLJL0vyTE0ZlmU8L44sCS+AJm0JiZs3ZlNUYj141L4k7XxYmOWtf+9cN2Kqog7OzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.67.3.tgz", + "integrity": "sha512-3CNtrc+6X9HL+fzSkKF0o3WkBdO6YBm5PhbLnR9PeQNKLLL7ir4MDtlx6gvP9ZM3Uxjue3xVysAX7+VjWX66Ng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.67.3.tgz", + "integrity": "sha512-KfHwLGjSfgOKHoyzhqqKNhohxNErfYRR2dNWXN6kn5iWMrDNldZopob+Ft/q6aurweYHRz+7M9HuFZ7nvYj5Xg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.67.3.tgz", + "integrity": "sha512-fXWMWNshsQrNi9pq8FJIpTZfLjhLUqDCabW1hq2Qv1kxzz7I63fRjFRNAC0C49xK9CyUngL/tWbSYjpKo2+GRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.67.3.tgz", + "integrity": "sha512-22gepfGL1b0Aay36BYddC4mqvQoUgQwGU/5zrUK2BdJucWgWytbhxY4o3iSIbtZsSsFiiRlNT2xfAjrCsDQGQw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.67.3.tgz", + "integrity": "sha512-l04417gOo3y7xaQu52PeXGgy17dSPAa8qhs4iqxMDHKyAUpldNFsasy0kR+Z7CFifnTgwFGmyukDWLIZYEnyQQ==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.67.3.tgz", + "integrity": "sha512-0SEhbnWkUv29OhSANlnnOKQSWs5KFoJ0FTQ3ZhxZunUjgBagQzzUmLgxpulYSpldXAGOCK5XLvvspoPngjEWpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.67.3.tgz", + "integrity": "sha512-2wLIbgSqKUcEc4DHrQDHOg9LSfgfSr+rAoBj9wromzLMLSclupvEKHTEugGSuGuklrF7wF0dzXo9R5VymKXgcg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.67.3.tgz", + "integrity": "sha512-cV3cQixj3ILfdOmvJeXu6GedaH/Xlm4/qtNAUxvVK0tQnzICits97mwuvWY/DZs0lEZTkbCqZmpO/+ne6hs5iQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.67.3.tgz", + "integrity": "sha512-DTnriHdEmvrGgPWHVoV8LfwkLsmG2oK9GYvlfV6+beMcdVWVYnNC3R6+jL9DhTVOIpv+Ys9A+1i9rM8aeayOZw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.67.3.tgz", + "integrity": "sha512-2+v1frx+8t084SIinkBBURODj1lpTqLZO53INfozQa0+bnCNEGtRFag8Gi6OMV9kEK67zIRIqncfyKe1eIYGHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.67.3.tgz", + "integrity": "sha512-vEi902pAjMHXPXOpU4Jr58jMMiZxTf8kkhis1xiLBtLtlm6oRaX+mgFjOsqMdbGt80lS+bfnTCEsAofbK9isLA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.67.3.tgz", + "integrity": "sha512-1Nm/kYmkKDcc9gJ9cRVsRPBrNUqLEaY5b2zDk2OsQMcWd2nJiFUpQf3q4T1IEVKgyUQSMuiVNDemLXcuKh/f9Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.67.3.tgz", + "integrity": "sha512-bx93yN/wit1aY9lja1lRh2jfRPFDBfeI9kHoytXDi1Jw6dWz2P2zd6B3oKnQOHa5fTtpHmX9/swWOA2+jWLjKw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.67.3.tgz", + "integrity": "sha512-Nt1/uFKauyn2bwGcYaIcF9DA3TORW45XrtdhThm5QPu0TNDpDC5onMNSh8fzvB+klrnLKTCISCw8lR9xpxrEFA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.67.3.tgz", + "integrity": "sha512-K8HqIGH9CD57XmZFo/0T3pafDAzAoB9Iof02FEFvOXg7lBn7vlkLEjHgtZkYp+srWANrlxTSf8BolQM/27kiCw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.67.3.tgz", + "integrity": "sha512-6vTihCOamPjOpgcjkXOFB+Lh+LM4Eb95EPYNHjcovwxsnQrFRIJ7M664fLnGklKFOZutrhR9/o+NfgQLE4Zmdg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.67.3.tgz", + "integrity": "sha512-k5XxCKgPGASSGyXOhXVQQJM6VIRHteqwHBsbwhW55EoimmI2jfkPebwgg/lwcjezpvgyLu8YTTCRsdSrvHKA7A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.67.3.tgz", + "integrity": "sha512-WYqJUhonki85lB0iQ/Ro9BNJiGd7rG7GBZBiB/h4EUBWiUKyI3vq6EZ1aTyy5Y8qVuSIx4ZdqDyAXYiR59Vtvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.67.3.tgz", + "integrity": "sha512-WJgIOxBAnyZy2nXZdSk1xtDP0nmU41qfB47jCZ5VCxCipt2/aoImPtXMyPYB1fzXcpmNsAtYxBCY2d2WFYo00g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.67.3.tgz", + "integrity": "sha512-C0vJ/s69Fll/puogTouGiFkn7kFI7nIAQc8/PTVawth9VNpNYIpv9xrZr6syRaFzSxVhTUt5zoXUFLELGs6vVA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.67.3.tgz", + "integrity": "sha512-UFCIgrIBg2jU6Ajkrx+AwGz0EWpYCRTjr6wh+jBCY+A6r8pCDeeejTtMbbBszRdnmtUCzlXwwA4h/f4f3zPRSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.67.3.tgz", + "integrity": "sha512-tlynfm3LIQtOCjFdT7kjyMnGrl0PbhyldgCfj7b1VxafD6uYiJFjd2ATzmYb+YnzNIzNezpK7XzFzDtPu37IeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.10.tgz", + "integrity": "sha512-lTyOMnOcmRBzdCv6/4zUZCrEhV0xrqQiUZBZtVcbmLHBXKLnN8q89v/wKRF/Q0HbwVsqbIG3o5HDoFl9sduM+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz", + "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz", + "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.1.tgz", + "integrity": "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.28.0" + }, + "peerDependencies": { + "react": "^19.3.1" + } + }, + "node_modules/rollup": { + "version": "4.67.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.67.3.tgz", + "integrity": "sha512-HJXOntSbeXzBfjUQ+YfgcOR+NV+8queObyCqWWOy2YL6bXDFE7vNRgVXlOUyrQqfJnxdXcL27PXlkC0O3ze6jQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.10" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.67.3", + "@rollup/rollup-android-arm64": "4.67.3", + "@rollup/rollup-android-x64": "4.67.3", + "@rollup/rollup-darwin-arm64": "4.67.3", + "@rollup/rollup-darwin-x64": "4.67.3", + "@rollup/rollup-freebsd-arm64": "4.67.3", + "@rollup/rollup-freebsd-x64": "4.67.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.67.3", + "@rollup/rollup-linux-arm-musleabihf": "4.67.3", + "@rollup/rollup-linux-arm64-gnu": "4.67.3", + "@rollup/rollup-linux-arm64-musl": "4.67.3", + "@rollup/rollup-linux-loong64-gnu": "4.67.3", + "@rollup/rollup-linux-loong64-musl": "4.67.3", + "@rollup/rollup-linux-ppc64-gnu": "4.67.3", + "@rollup/rollup-linux-ppc64-musl": "4.67.3", + "@rollup/rollup-linux-riscv64-gnu": "4.67.3", + "@rollup/rollup-linux-riscv64-musl": "4.67.3", + "@rollup/rollup-linux-s390x-gnu": "4.67.3", + "@rollup/rollup-linux-x64-gnu": "4.67.3", + "@rollup/rollup-linux-x64-musl": "4.67.3", + "@rollup/rollup-openbsd-x64": "4.67.3", + "@rollup/rollup-openharmony-arm64": "4.67.3", + "@rollup/rollup-win32-arm64-msvc": "4.67.3", + "@rollup/rollup-win32-ia32-msvc": "4.67.3", + "@rollup/rollup-win32-x64-gnu": "4.67.3", + "@rollup/rollup-win32-x64-msvc": "4.67.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", + "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "7.3.7", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.7.tgz", + "integrity": "sha512-AHEzTORMbi7/7aXp7w7KZ5+DCUdSeLsJz0grY8xHpc9DHdYb0vQu6DqJfm2tOw3gDExNgPgnJTXxM6syYFHaCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..8c4417f --- /dev/null +++ b/web/package.json @@ -0,0 +1 @@ +{"name":"copienator-finalisation","private":true,"version":"1.0.0","type":"module","scripts":{"build":"tsc --noEmit && vite build"},"dependencies":{"react":"^19.2.0","react-dom":"^19.2.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","typescript":"^5.9.0","vite":"^7.2.0"}} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000..27fe632 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,82 @@ +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;general:string[];answers:Answer[]}; +type Job = {running:boolean;status:string;log:string}; +type State = {evaluation:string;mode:string;labels:string[];copies:Copy[];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]); + +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 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});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 matches=state.copies.filter(c=>!copyFilter||Number(c.id)===Number(copyFilter)); + 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); + return <> +
CCopienator
FINALISATION · {state.mode}

{state.evaluation}

+
+

Vérifier les copies

Retrouvez une copie, une question, ou leur intersection.

{state.copies.map(c=>)}
+ {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

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

{matches.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)=>