Initial commit for finalisation web interface
This commit is contained in:
1 parent
b295f871a7
commit
c36642e8de
33 files changed
+2745
-138
No files matched your search
@@ -55,6 +55,8 @@ class AnnotationReadCache:
|
||||
self.signatures[copy_id] = sha256(payload).hexdigest()
|
||||
|
||||
def unchanged(self, copy_id: str) -> bool:
|
||||
if not (self.workspace.root / self.mode / f"Copie{copy_id}" / "review_snapshot.json").is_file():
|
||||
return False
|
||||
directory = self.workspace.root / self.mode / f"Copie{copy_id}"
|
||||
try:
|
||||
state = read_json(directory / STATE_FILE)
|
||||
|
||||
+3
-1
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from copienator import review_snapshot
|
||||
|
||||
import argparse
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
@@ -160,6 +163,7 @@ def apply_actions_and_regenerate(
|
||||
*,
|
||||
update_score: bool = False,
|
||||
label_notes: dict[str, Image.Image] | None = None,
|
||||
review_metadata: dict | None = None,
|
||||
) -> ExitCode:
|
||||
"""Apply annotations and atomically merge the regenerated student files."""
|
||||
output_dir = workspace.annotation_dir("checks") / f"Copie{student_id}"
|
||||
@@ -168,15 +172,17 @@ def apply_actions_and_regenerate(
|
||||
content["result"].get("error") == "empty-answer"
|
||||
for content in data[student_id].values()
|
||||
)
|
||||
if not bnote_path.is_file() and not only_empty:
|
||||
if review_metadata is None and not bnote_path.is_file() and not only_empty:
|
||||
print(f" Missing {bnote_path}")
|
||||
return ExitCode.PARTIAL
|
||||
bnote_data = read_json(bnote_path) if bnote_path.is_file() else {"images": []}
|
||||
bnote_data = review_metadata if review_metadata is not None else (read_json(bnote_path) if bnote_path.is_file() else {"images": []})
|
||||
if not isinstance(bnote_data, dict):
|
||||
raise TypeError(f"Expected a JSON object in {bnote_path}")
|
||||
|
||||
labels_data = data[student_id]
|
||||
apply_checkbox_actions(labels_data, actions, print)
|
||||
reviewed_data = deepcopy(labels_data)
|
||||
captured_notes = {}
|
||||
apply_custom_remarks(workspace.root, student_id, labels_data)
|
||||
score_path = output_dir / "score.json"
|
||||
preserve_score_file = update_score and score_path.is_file()
|
||||
@@ -217,6 +223,8 @@ def apply_actions_and_regenerate(
|
||||
hmin = int(image_info.get("hmin", 0))
|
||||
hmax = int(image_info.get("hmax", 0))
|
||||
sub_note = notes_layer.crop((0, hmin, notes_layer.width, hmax))
|
||||
if sub_note is not None:
|
||||
captured_notes[label] = {"img": sub_note}
|
||||
has_notes = has_significant_notes(sub_note)
|
||||
|
||||
pdf_path = Path(content["pdf_path"])
|
||||
@@ -288,6 +296,7 @@ def apply_actions_and_regenerate(
|
||||
workspace.root, [(label, dirty_images[label]) for label in filtered_labels],
|
||||
all_labels, staging / "Concat_F.pdf", general=general,
|
||||
)
|
||||
review_snapshot.stage(output_dir, staging, reviewed_data, captured_notes, engine="standard", bnote=bnote_data)
|
||||
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
|
||||
header_heights, build_answer_info(scores, labels_data, answer_labels),
|
||||
filtered_labels, scores)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from copienator import review_snapshot
|
||||
|
||||
import argparse
|
||||
import concurrent.futures
|
||||
import unicodedata
|
||||
@@ -231,12 +234,14 @@ def apply_actions_and_regenerate_grouped(
|
||||
update_score: bool = False,
|
||||
annotation_dir: str = "BGnot",
|
||||
selected_labels: set[str] | None = None,
|
||||
force_filtered: set[str] | None = None,
|
||||
) -> tuple[ExitCode, str]:
|
||||
"""Regenerate a copy, preserving reviewed images outside the redo selection."""
|
||||
logs = [f"\nProcessing compilation for: Copie{student_id}"]
|
||||
output_dir = workspace.root / annotation_dir / f"Copie{student_id}"
|
||||
labels_data = data.get(student_id, {})
|
||||
apply_checkbox_actions(labels_data, actions, logs.append)
|
||||
reviewed_data = deepcopy(labels_data)
|
||||
apply_custom_remarks(workspace.root, student_id, labels_data)
|
||||
score_path = output_dir / "score.json"
|
||||
preserve_score_file = update_score and score_path.is_file()
|
||||
@@ -400,7 +405,7 @@ def apply_actions_and_regenerate_grouped(
|
||||
perfect = float(scores[label]) >= 4.0 and all(
|
||||
feedback.get("to_delete", False) for feedback in feedbacks
|
||||
)
|
||||
if not perfect or has_notes:
|
||||
if not perfect or has_notes or label in (force_filtered or set()):
|
||||
filtered_labels.append(label)
|
||||
extras = (
|
||||
get_extra_pdfs_as_images(workspace.root, label, annotating, all_labels,
|
||||
@@ -450,6 +455,10 @@ def apply_actions_and_regenerate_grouped(
|
||||
[(label, dirty_images[label]) for label in filtered_labels],
|
||||
all_labels, staging / "Concat_F.pdf", general=general,
|
||||
)
|
||||
if annotation_dir in {"Bnot", "BGnot"}:
|
||||
review_snapshot.stage(output_dir, staging, reviewed_data, label_notes,
|
||||
engine="grouped", selected=selected_labels,
|
||||
force_filtered=(set(filtered_labels) - selected_labels) if selected_labels else (force_filtered or ()))
|
||||
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
|
||||
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
|
||||
filtered_labels, scores)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Re-render saved handwriting interpretation with current scores and remarks."""
|
||||
from copienator import EvaluationWorkspace, ExitCode, evaluation_parser, execute, workspace_from_args, utils
|
||||
from copienator.review_snapshot import load, FILENAME
|
||||
from copienator.commands.reading_annotations import apply_actions_and_regenerate
|
||||
from copienator.commands.reading_grouped_annotations import apply_actions_and_regenerate_grouped
|
||||
|
||||
|
||||
def run(workspace: EvaluationWorkspace, mode: str):
|
||||
directories = sorted((p for p in (workspace.root / mode).glob('Copie*')
|
||||
if p.is_dir() and p.name[5:].isdigit() and (p / 'score.json').is_file()))
|
||||
# Validate every snapshot before starting to publish any copy.
|
||||
for directory in directories:
|
||||
if not (directory / FILENAME).is_file():
|
||||
raise ValueError(f'{directory.name} : résultat de lecture sauvegardé absent ; relisez les annotations manuscrites.')
|
||||
load(workspace.root, directory)
|
||||
status = ExitCode.SUCCESS
|
||||
for directory in directories:
|
||||
cid = directory.name[5:]
|
||||
saved, data, notes = load(workspace.root, directory)
|
||||
if saved['engine'] == 'standard':
|
||||
result = apply_actions_and_regenerate(workspace, {cid: data}, cid, [], None,
|
||||
utils.read_all_labels(workspace.root), update_score=True,
|
||||
label_notes={label: note['img'] for label, note in notes.items()},
|
||||
review_metadata=saved['bnote'])
|
||||
else:
|
||||
result, log = apply_actions_and_regenerate_grouped(workspace, {cid: data}, cid, [], notes,
|
||||
utils.read_all_labels(workspace.root), update_score=True, annotation_dir=mode,
|
||||
force_filtered=set(saved.get('force_filtered', [])))
|
||||
print(log, flush=True)
|
||||
if result != ExitCode.SUCCESS:
|
||||
status = ExitCode.PARTIAL
|
||||
return status
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = evaluation_parser('Regenerate from saved reviewed inputs without rescanning handwriting')
|
||||
parser.add_argument('--annotation-dir', choices=('Bnot', 'BGnot'), required=True)
|
||||
return execute(parser, argv, lambda args: run(workspace_from_args(args), args.annotation_dir))
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -0,0 +1,145 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from copienator import EvaluationWorkspace
|
||||
from copienator.platform import safe_filename
|
||||
|
||||
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class NameIssue:
|
||||
folder: Path
|
||||
name: str
|
||||
copy_id: str
|
||||
reasons: tuple[str, ...]
|
||||
|
||||
|
||||
def find_name_issues(evaluation: Path) -> list[NameIssue]:
|
||||
"""Return unknown and duplicated names currently present in A Rendre."""
|
||||
return_dir = EvaluationWorkspace(evaluation).return_dir
|
||||
parsed: list[tuple[Path, str, str]] = []
|
||||
if return_dir.is_dir():
|
||||
for folder in return_dir.iterdir():
|
||||
if not folder.is_dir() or folder.is_symlink():
|
||||
continue
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if match:
|
||||
parsed.append(
|
||||
(folder, match.group("name"), match.group("copy_id"))
|
||||
)
|
||||
|
||||
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
|
||||
issues = []
|
||||
for folder, name, copy_id in parsed:
|
||||
reasons = []
|
||||
if name.casefold() == "unknown":
|
||||
reasons.append("nom inconnu")
|
||||
elif counts[name.casefold()] > 1:
|
||||
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
|
||||
if reasons:
|
||||
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
|
||||
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
|
||||
|
||||
|
||||
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
|
||||
workspace = EvaluationWorkspace(evaluation)
|
||||
filename = f"Copie{copy_id}.pdf"
|
||||
for candidate in (
|
||||
workspace.original_copies_dir / filename,
|
||||
workspace.copies_dir / filename,
|
||||
workspace.root / filename,
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def rename_return_copy(folder: Path, requested_name: str) -> Path:
|
||||
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if not folder.is_dir() or folder.is_symlink() or match is None:
|
||||
raise ValueError(f"Dossier de copie invalide : {folder}")
|
||||
if folder.parent.name != "A Rendre":
|
||||
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
|
||||
|
||||
entered_name = requested_name.strip()
|
||||
if not entered_name:
|
||||
raise ValueError("Saisissez un nom.")
|
||||
name = safe_filename(entered_name, fallback="")
|
||||
if not name:
|
||||
raise ValueError("Le nom ne contient aucun caractère utilisable.")
|
||||
|
||||
copy_id = match.group("copy_id")
|
||||
destination = folder.with_name(f"{name} ({copy_id})")
|
||||
if destination != folder and os.path.lexists(destination):
|
||||
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
|
||||
|
||||
assets: dict[str, Path] = {}
|
||||
for child in folder.iterdir():
|
||||
suffix = child.suffix.casefold()
|
||||
if suffix not in {".jpg", ".pdf"}:
|
||||
continue
|
||||
if suffix in assets:
|
||||
raise ValueError(
|
||||
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
|
||||
)
|
||||
assets[suffix] = child
|
||||
|
||||
renames: list[tuple[Path, Path]] = []
|
||||
for suffix, source in assets.items():
|
||||
target = source.with_name(f"{name}{suffix}")
|
||||
if target != source and os.path.lexists(target):
|
||||
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
|
||||
if target != source:
|
||||
renames.append((source, target))
|
||||
|
||||
completed: list[tuple[Path, Path]] = []
|
||||
try:
|
||||
for source, target in renames:
|
||||
source.rename(target)
|
||||
completed.append((source, target))
|
||||
if destination != folder:
|
||||
folder.rename(destination)
|
||||
except OSError:
|
||||
for source, target in reversed(completed):
|
||||
if os.path.lexists(target) and not os.path.lexists(source):
|
||||
target.rename(source)
|
||||
raise
|
||||
return destination
|
||||
|
||||
|
||||
|
||||
def return_folders(root: Path) -> dict[int, Path]:
|
||||
result = {}
|
||||
for folder in (root / "A Rendre").glob("*"):
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||||
if not folder.is_dir() or folder.is_symlink() or not match:
|
||||
continue
|
||||
key = int(match["copy_id"])
|
||||
if key in result:
|
||||
raise ValueError(f"Plusieurs dossiers A Rendre pour la copie {key}.")
|
||||
result[key] = folder
|
||||
return result
|
||||
|
||||
|
||||
def set_copy_name(root: Path, copy_id: str, name: str) -> None:
|
||||
from copienator import atomic_update_json
|
||||
name = name.strip()
|
||||
if not name or not safe_filename(name, fallback=""):
|
||||
raise ValueError("Saisissez un nom utilisable.")
|
||||
metadata = root / "Copies" / f"Copie{copy_id}.json"
|
||||
if not metadata.is_file():
|
||||
raise ValueError("Métadonnées de copie absentes.")
|
||||
folder = return_folders(root).get(int(copy_id))
|
||||
renamed = rename_return_copy(folder, name) if folder else None
|
||||
try:
|
||||
atomic_update_json(metadata, lambda data: data.update(name=name), default_factory=dict)
|
||||
except Exception:
|
||||
if renamed and renamed != folder:
|
||||
rename_return_copy(renamed, RETURN_FOLDER_PATTERN.fullmatch(folder.name)["name"])
|
||||
raise
|
||||
@@ -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
|
||||
@@ -0,0 +1,58 @@
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from pathlib import Path
|
||||
from copienator import atomic_update_json, read_json
|
||||
|
||||
def read_scores(path: Path) -> dict:
|
||||
scores = read_json(annotation_score_for_return(path))
|
||||
if not isinstance(scores, dict):
|
||||
raise ValueError(f"score.json doit contenir un objet JSON : {path}")
|
||||
return scores
|
||||
|
||||
|
||||
def set_score(path: Path, label: str, value: str) -> str:
|
||||
try:
|
||||
number = Decimal(value.strip().replace(",", "."))
|
||||
except InvalidOperation:
|
||||
raise ValueError("Le score doit être un nombre.") from None
|
||||
if not number.is_finite() or number < 0:
|
||||
raise ValueError("Le score doit être un nombre positif ou nul.")
|
||||
previous = ""
|
||||
|
||||
def update(scores):
|
||||
nonlocal previous
|
||||
if not isinstance(scores, dict) or label not in scores:
|
||||
raise ValueError(f"Label absent de score.json : {label}")
|
||||
previous = str(scores[label])
|
||||
scores[label] = str(number)
|
||||
|
||||
# A Rendre/score.json is often a symlink to the annotation score file.
|
||||
# Resolve it before atomic replacement so the link continues to work.
|
||||
atomic_update_json(annotation_score_for_return(path).resolve(strict=True), update, default_factory=dict)
|
||||
return previous
|
||||
|
||||
|
||||
|
||||
def annotation_score_for_return(path: Path) -> Path:
|
||||
"""Resolve fallback return copies as well as symlinks to their source scores."""
|
||||
if path.is_symlink() or path.parent.parent.name != 'A Rendre':
|
||||
return path.resolve()
|
||||
from copienator.return_identity import RETURN_FOLDER_PATTERN
|
||||
match = RETURN_FOLDER_PATTERN.fullmatch(path.parent.name)
|
||||
if not match:
|
||||
return path
|
||||
root = path.parent.parent.parent
|
||||
cid = match['copy_id']
|
||||
sources = read_json(root / '.copienator' / 'return_sources.json', default={})
|
||||
mode = sources.get(str(int(cid)))
|
||||
modes = (mode,) if mode in ('Bnot', 'BGnot', 'Anot') else ('Bnot', 'BGnot', 'Anot')
|
||||
candidates = [p for m in modes for p in (root / m).glob('Copie*/score.json')
|
||||
if p.parent.name[5:].isdigit() and int(p.parent.name[5:]) == int(cid)]
|
||||
if len(candidates) > 1:
|
||||
raise ValueError('Source des scores ambiguë : relancez la préparation de A Rendre dans le mode souhaité.')
|
||||
return candidates[0] if candidates else path
|
||||
|
||||
|
||||
def remember_return_source(root: Path, copy_id: str, source: Path):
|
||||
path = root / '.copienator' / 'return_sources.json'
|
||||
path.parent.mkdir(exist_ok=True)
|
||||
atomic_update_json(path, lambda data: data.update({str(int(copy_id)): source.parent.name}), default_factory=dict)
|
||||
@@ -0,0 +1 @@
|
||||
:root{font-family:Inter,ui-sans-serif,system-ui,sans-serif;color:#23323a;background:#f3f5f4;font-synthesis:none;font-size:14px}*{box-sizing:border-box}body{margin:0}button,input,select,textarea{font:inherit}button,a,input,select,textarea{outline-offset:3px}button{border:1px solid #c8d3ce;border-radius:7px;background:#fff;color:#244b40;padding:9px 13px;cursor:pointer;font-weight:550}button:hover:not(:disabled){background:#e9f1ed;border-color:#769b88}button:disabled{opacity:.45;cursor:default}.primary{background:#205c48;color:#fff;border-color:#205c48}.primary:hover:not(:disabled){background:#174633;color:#fff}a{color:#21654e;text-decoration:none}a:hover{text-decoration:underline}header{display:flex;align-items:center;gap:32px;padding:20px 36px;border-bottom:1px solid #dbe3de;background:#fff}.brand{display:flex;align-items:center;gap:10px;font-size:23px;font-weight:800;color:#28684f}.brand span{font-size:16px;font-weight:650}.heading{flex:1;border-left:1px solid #dbe3de;padding-left:30px}h1{font-size:22px;margin:3px 0 0}h2{font-size:20px;margin:0 0 8px}h3{font-size:19px;margin:7px 0}p{line-height:1.55;margin:8px 0;color:#56666b}.eyebrow{font-size:11px;letter-spacing:.09em;color:#5c7470;font-weight:650}main{max-width:1540px;margin:auto;padding:30px 36px 60px}.toolbar{display:flex;align-items:end;justify-content:space-between;gap:24px;margin-bottom:24px}.filters{display:flex;gap:12px;align-items:end}label{display:flex;flex-direction:column;gap:6px;color:#405650;font-size:12px;font-weight:600}input,select,textarea{border:1px solid #c5d1cb;border-radius:6px;background:#fff;padding:9px 10px;color:#263d34;font-size:14px;min-width:0}textarea{resize:vertical;min-height:76px;line-height:1.5}input:focus,textarea:focus,select:focus{outline:2px solid #80b69c;border-color:#4d8269}input[type=checkbox]{width:16px;height:16px;accent-color:#205c48}.check{display:flex;flex-direction:row;align-items:center;max-width:310px;font-size:12px;line-height:1.4}.publish{display:flex;align-items:center;gap:16px;background:#e8f0eb;border:1px solid #d0e0d5;border-radius:10px;padding:17px 20px;margin:20px 0}.publish>div{flex:1}.publish small{display:block;margin-top:5px;color:#5b6e64}.panel{padding:18px 20px;background:#fff;border:1px solid #d9e2dd;border-radius:9px;margin-bottom:14px}summary{cursor:pointer;font-weight:600}summary span{float:right;font-size:12px;font-weight:400;color:#65766f}.identity{border-top:1px solid #e1e7e3;padding:18px 0;display:grid;grid-template-columns:280px 1fr;gap:28px}.identity-name{display:flex;flex-direction:column;gap:10px;align-items:start}.identity-name label{width:100%}.general button{margin:5px 6px 0 0}.remark-row{display:flex;align-items:center;gap:6px;margin-bottom:8px}.remark-row textarea{flex:1}.instructions{width:100%;font-family:ui-monospace,monospace;min-height:140px;margin:10px 0}.panel details{margin-top:16px}.results{display:flex;justify-content:space-between;align-items:center;margin:30px 0 12px}.results span{color:#677872;font-size:12px}.answer-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:22px}.answer{border:1px solid #d9e1dd;background:#fff;border-radius:10px;overflow:hidden}.card-heading{display:flex;justify-content:space-between;align-items:center;padding:18px 20px;gap:12px}.badge{font-size:11px;background:#edf1ee;color:#5a6e62;border-radius:4px;padding:5px 7px;white-space:nowrap}.warning{background:#fff2d8;color:#8a620b}.image-space{height:290px;overflow:hidden;background:#e7ece9;border-top:1px solid #e2e8e3;border-bottom:1px solid #e2e8e3;display:flex;justify-content:center;align-items:start;padding:12px}.image-button{position:relative;border:0;padding:0;background:#fff;width:100%;height:100%;overflow:hidden;border-radius:0}.image-button img{width:100%;display:block}.image-button span{position:absolute;bottom:8px;right:8px;background:#203d32e8;color:#fff;border-radius:5px;padding:7px 10px;font-size:12px}.answer-form{display:grid;grid-template-columns:88px 1fr;gap:15px;padding:18px 20px 10px}.card-actions{display:flex;flex-wrap:wrap;align-items:center;gap:10px;padding:8px 20px 20px}.card-actions a{font-size:12px}.empty{padding:32px;color:#77877e;font-style:italic;text-align:center}.load-more{display:block;margin:24px auto}.alert{background:#fff0eb;color:#8f3827;border:1px solid #e9b6a9;border-radius:7px;padding:12px;display:flex;gap:12px;align-items:center}.alert small{font-size:12px}.notice{color:#216044}.muted{font-size:12px}fieldset{padding:0;margin:0;border:0;min-width:0}.overlay{position:fixed;inset:0;background:#142d23b8;z-index:10;display:flex;align-items:center;justify-content:center;padding:24px}.modal{width:min(1300px,100%);max-height:94vh;overflow:auto;border-radius:12px;background:#f8faf8;padding:24px;box-shadow:0 24px 80px #001c2e55}.modal-head{display:flex;justify-content:space-between;align-items:center;gap:20px}.cut-controls{display:flex;gap:15px;flex-wrap:wrap;margin:20px 0}.compare{display:grid;grid-template-columns:1fr 1fr;gap:24px}.compare>div{mLine truncated
|
||||
@@ -0,0 +1,15 @@
|
||||
(function(){const G=document.createElement("link").relList;if(G&&G.supports&&G.supports("modulepreload"))return;for(const q of document.querySelectorAll('link[rel="modulepreload"]'))y(q);new MutationObserver(q=>{for(const $ of q)if($.type==="childList")for(const rl of $.addedNodes)rl.tagName==="LINK"&&rl.rel==="modulepreload"&&y(rl)}).observe(document,{childList:!0,subtree:!0});function Y(q){const $={};return q.integrity&&($.integrity=q.integrity),q.referrerPolicy&&($.referrerPolicy=q.referrerPolicy),q.crossOrigin==="use-credentials"?$.credentials="include":q.crossOrigin==="anonymous"?$.credentials="omit":$.credentials="same-origin",$}function y(q){if(q.ep)return;q.ep=!0;const $=Y(q);fetch(q.href,$)}})();var zs={exports:{}},nn={};var lv;function ih(){if(lv)return nn;lv=1;var _=Symbol.for("react.transitional.element"),G=Symbol.for("react.fragment");function Y(y,q,$){var rl=null;if($!==void 0&&(rl=""+$),q.key!==void 0&&(rl=""+q.key),"key"in q){$={};for(var Gl in q)Gl!=="key"&&($[Gl]=q[Gl])}else $=q;return q=$.ref,{$$typeof:_,type:y,key:rl,ref:q!==void 0?q:null,props:$}}return nn.Fragment=G,nn.jsx=Y,nn.jsxs=Y,nn}var tv;function ch(){return tv||(tv=1,zs.exports=ih()),zs.exports}var d=ch(),_s={exports:{}},Z={};var uv;function fh(){if(uv)return Z;uv=1;var _=Symbol.for("react.transitional.element"),G=Symbol.for("react.portal"),Y=Symbol.for("react.fragment"),y=Symbol.for("react.strict_mode"),q=Symbol.for("react.profiler"),$=Symbol.for("react.consumer"),rl=Symbol.for("react.context"),Gl=Symbol.for("react.forward_ref"),ul=Symbol.for("react.suspense"),Dl=Symbol.for("react.memo"),R=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),M=Symbol.for("react.view_transition"),_l=Symbol.iterator;function Xl(s){return s===null||typeof s!="object"?null:(s=_l&&s[_l]||s["@@iterator"],typeof s=="function"?s:null)}var Ul={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},il=Object.assign,et={};function Ol(s,E,D){this.props=s,this.context=E,this.refs=et,this.updater=D||Ul}Ol.prototype.isReactComponent={},Ol.prototype.setState=function(s,E){if(typeof s!="object"&&typeof s!="function"&&s!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,s,E,"setState")},Ol.prototype.forceUpdate=function(s){this.updater.enqueueForceUpdate(this,s,"forceUpdate")};function gt(){}gt.prototype=Ol.prototype;function Ql(s,E,D){this.props=s,this.context=E,this.refs=et,this.updater=D||Ul}var at=Ql.prototype=new gt;at.constructor=Ql,il(at,Ol.prototype),at.isPureReactComponent=!0;var xl=Array.isArray;function K(){}var F={H:null,A:null,T:null,S:null},Vl=Object.prototype.hasOwnProperty;function Nl(s,E,D){var j=D.ref;return{$$typeof:_,type:s,key:E,ref:j!==void 0?j:null,props:D}}function pl(s,E){return Nl(s.type,E,s.props)}function Ll(s){return typeof s=="object"&&s!==null&&s.$$typeof===_}function jt(s){var E={"=":"=0",":":"=2"};return"$"+s.replace(/[=:]/g,function(D){return E[D]})}var L=/\/+/g;function hl(s,E){return typeof s=="object"&&s!==null&&s.key!=null?jt(""+s.key):E.toString(36)}function C(s){switch(s.status){case"fulfilled":return s.value;case"rejected":throw s.reason;default:switch(typeof s.status=="string"?s.then(K,K):(s.status="pending",s.then(function(E){s.status==="pending"&&(s.status="fulfilled",s.value=E)},function(E){s.status==="pending"&&(s.status="rejected",s.reason=E)})),s.status){case"fulfilled":return s.value;case"rejected":throw s.reason}}throw s}function B(s,E,D,j,k){var al=typeof s;(al==="undefined"||al==="boolean")&&(s=null);var sl=!1;if(s===null)sl=!0;else switch(al){case"bigint":case"string":case"number":sl=!0;break;case"object":switch(s.$$typeof){case _:case G:sl=!0;break;case R:return sl=s._init,B(sl(s._payload),E,D,j,k)}}if(sl)return k=k(s),sl=j===""?"."+hl(s,0):j,xl(k)?(D="",sl!=null&&(D=sl.replace(L,"$&/")+"/"),B(k,E,D,"",function(nu){return nu})):k!=null&&(Ll(k)&&(k=pl(k,D+(k.key==null||s&&s.key===k.key?"":(""+k.key).replace(L,"$&/")+"/")+sl)),E.push(k)),1;sl=0;var x=j===""?".":j+":";if(xl(s))for(var V=0;V<s.length;V++)j=s[V],al=x+hl(j,V),sl+=B(j,E,D,al,k);else if(V=Xl(s),typeof V=="function")for(s=V.call(s),V=0;!(j=s.next()).done;)j=j.value,al=x+hl(j,V++),sl+=B(j,E,D,al,k);else if(al==="object"){if(typeof s.then=="function")return B(C(s),E,D,j,k);throw E=String(s),Error("Objects are not valid as a React child (found: "+(E==="[object Object]"?"object with keys {"+Object.keys(s).join(", ")+"}":E)+"). If you meant to render a collection of children, use an array instead.")}return sl}function X(s,E,D){if(s==null)return s;var j=[],k=0;return B(s,j,"","",function(al){return E.call(D,al,k++)}),j}function cl(s){if(s._status===-1){var E=s._result,D=E();D.then(function(j){(s._status===0||s._status===-1)&&(s._status=1,s._result=j,D.status===void 0&&(D.status="fulfilled",D.value=j))},function(j){(s._status===0||s._status===-1)&&(s._status=2,s._result=j,D.statLine truncated
|
||||
at`)?" (<anonymous>)":-1<u.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+Vi+l+js}var Zi=!1;function Li(l,t){if(!l||Zi)return"";Zi=!0;var u=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var e={DetermineComponentFrameRoot:function(){try{if(t){var z=function(){throw Error()};if(Object.defineProperty(z.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(z,[])}catch(A){var r=A}Reflect.construct(l,[],z)}else{try{z.call()}catch(A){r=A}z=!1;try{var g=Object.getOwnPropertyDescriptor(l.prototype,"props");Object.defineProperty(l.prototype,"props",{configurable:!0,set:function(){throw Error()}}),z=!0,new l}finally{z&&(g!==void 0?Object.defineProperty(l.prototype,"props",g):delete l.prototype.props)}}}else{try{throw Error()}catch(A){r=A}(z=l())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(A){if(A&&r&&typeof A.stack=="string")return[A.stack,r.stack]}return[null,null]}};e.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(e.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(e.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=e.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
|
||||
`),m=c.split(`
|
||||
`);for(a=e=0;e<f.length&&!f[e].includes("DetermineComponentFrameRoot");)e++;for(;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;if(e===f.length||a===m.length)for(e=f.length-1,a=m.length-1;1<=e&&0<=a&&f[e]!==m[a];)a--;for(;1<=e&&0<=a;e--,a--)if(f[e]!==m[a]){if(e!==1||a!==1)do if(e--,a--,0>a||f[e]!==m[a]){var S=`
|
||||
`+f[e].replace(" at new "," at ");return l.displayName&&S.includes("<anonymous>")&&(S=S.replace("<anonymous>",l.displayName)),S}while(1<=e&&0<=a);break}}}finally{Zi=!1,Error.prepareStackTrace=u}return(u=l?l.displayName||l.name:"")?zu(u):""}function ov(l,t){switch(l.tag){case 26:case 27:case 5:return zu(l.type);case 16:return zu("Lazy");case 13:return l.child!==t&&t!==null?zu("Suspense Fallback"):zu("Suspense");case 19:return zu("SuspenseList");case 0:case 15:return Li(l.type,!1);case 11:return Li(l.type.render,!1);case 1:return Li(l.type,!0);case 31:return zu("Activity");case 30:return zu("ViewTransition");default:return""}}function Ms(l){try{var t="",u=null;do t+=ov(l,u),u=l,l=l.return;while(l);return t}catch(e){return`
|
||||
Error generating stack: `+e.message+`
|
||||
`+e.stack}}var Ki=Object.prototype.hasOwnProperty,Ji=_.unstable_scheduleCallback,wi=_.unstable_cancelCallback,rv=_.unstable_shouldYield,dv=_.unstable_requestPaint,St=_.unstable_now,vv=_.unstable_getCurrentPriorityLevel,Us=_.unstable_ImmediatePriority,ps=_.unstable_UserBlockingPriority,dn=_.unstable_NormalPriority,mv=_.unstable_LowPriority,Rs=_.unstable_IdlePriority,yv=_.log,hv=_.unstable_setDisableYieldValue,da=null,bt=null;function _u(l){if(typeof yv=="function"&&hv(l),bt&&typeof bt.setStrictMode=="function")try{bt.setStrictMode(da,l)}catch{}}var Tt=Math.clz32?Math.clz32:bv,gv=Math.log,Sv=Math.LN2;function bv(l){return l>>>=0,l===0?32:31-(gv(l)/Sv|0)|0}var vn=256,mn=262144,yn=4194304;function Wu(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&-l;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function hn(l,t,u){var e=l.pendingLanes;if(e===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=e&134217727;return c!==0?(e=c&~n,e!==0?a=Wu(e):(i&=c,i!==0?a=Wu(i):u||(u=c&~l,u!==0&&(a=Wu(u))))):(c=e&~n,c!==0?a=Wu(c):i!==0?a=Wu(i):u||(u=e&~l,u!==0&&(a=Wu(u)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,u=t&-t,n>=u||n===32&&(u&4194048)!==0)?t:a}function va(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function xs(l,t){(t&8)!==0&&(t|=t&32);var u=l.entangledLanes;if(u!==0)for(l=l.entanglements,u&=t;0<u;){var e=31-Tt(u),a=1<<e;t|=l[e],u&=~a}return t}function Tv(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Hs(){var l=yn;return yn<<=1,(yn&62914560)===0&&(yn=4194304),l}function $i(l){for(var t=[],u=0;31>u;u++)t.push(l);return t}function ma(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Ev(l,t,u,e,a,n){var i=l.pendingLanes;l.pendingLanes=u,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=u,l.entangledLanes&=u,l.errorRecoveryDisabledLanes&=u,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,m=l.hiddenUpdates;for(u=i&~u;0<u;){var S=31-Tt(u),z=1<<S;c[S]=0,f[S]=-1;var r=m[S];if(r!==null)for(m[S]=null,S=0;S<r.length;S++){var g=r[S];g!==null&&(g.lane&=-536870913)}u&=~z}e!==0&&qs(l,e,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function qs(l,t,u){l.pendingLanes|=t,l.suspendedLanes&=~t;var e=31-Tt(t);l.entangledLanes|=t,l.entanglements[e]=l.entanglements[e]|1073741824|u&261930}function Ys(l,t){var u=l.entangledLanes|=t;for(l=l.entanglements;u;){var e=31-Tt(u),a=1<<e;a&t|l[e]&t&&(l[e]|=t),u&=~a}}function Bs(l,t){var u=t&-t;return u=(u&42)!==0?1:Fi(u),(u&(l.suspendedLanes|t))!==0?0:u}function Fi(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function Wi(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Gs(){var l=V.p;return l!==0?l:(l=window.event,l===void 0?32:w0(l.type))}function Xs(l,t){var u=V.p;try{return V.p=l,t()}finally{V.p=u}}var iu=Math.random().toString(36).slice(2),Wl="__reactFiber$"+iu,rt="__reactProps$"+iu,Ee="__reactContainer$"+iu,Qs="__reactEvents$"+iu,zv="__reactListeners$"+iu,_v="__reactHandles$"+iu,Vs="__reactResources$"+iu,ya="__reactMarker$"+iu,gn="__reactLoad$"+iu;function Sn(l){delete l[Wl],delete l[rt],delete l[zv],delete l[_v]}function ku(l){var t;if(t=l[Wl])return t;for(var u=l.parentNode;u;){if(t=u[Ee]||u[Wl]){if(u=t.alternate,t.child!==null||u!==null&&u.child!==null)for(l=C0(l);l!==null;){if(u=l[Wl])return u;l=C0(l)}return t}l=u,u=l.parentNode}return null}function ze(l){if(l=l[Wl]||l[Ee]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function ha(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(y(33))}function _e(l){var t=l[Vs];return t||(t=l[Vs]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Kl(l){l[ya]=!0}function Zs(l){l[gn]=void 0}var Ls=new Set,Ks={};function Iu(l,t){Oe(l,t),Oe(l+"Capture",t)}function
Line truncated
|
||||
`).replace(oy,"")}function c0(l,t){return t=i0(t),i0(l)===t}function yl(l,t,u,e,a,n){switch(u){case"children":if(typeof e=="string")t==="body"||t==="textarea"&&e===""||Ae(l,e);else if(typeof e=="number"||typeof e=="bigint")t!=="body"&&Ae(l,""+e);else return;break;case"className":Tn(l,"class",e);break;case"tabIndex":Tn(l,"tabindex",e);break;case"dir":case"role":case"viewBox":case"width":case"height":Tn(l,u,e);break;case"style":to(l,e,n);return;case"data":if(t!=="object"){Tn(l,"data",e);break}case"src":case"href":if(e===""&&(t!=="a"||u!=="href")){l.removeAttribute(u);break}if(e==null||typeof e=="function"||typeof e=="symbol"||typeof e=="boolean"){l.removeAttribute(u);break}e=En(e),l.setAttribute(u,e);break;case"action":case"formAction":if(typeof e=="function"){l.setAttribute(u,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof n=="function"&&(u==="formAction"?(t!=="input"&&yl(l,t,"name",a.name,a,null),yl(l,t,"formEncType",a.formEncType,a,null),yl(l,t,"formMethod",a.formMethod,a,null),yl(l,t,"formTarget",a.formTarget,a,null)):(yl(l,t,"encType",a.encType,a,null),yl(l,t,"method",a.method,a,null),yl(l,t,"target",a.target,a,null)));if(e==null||typeof e=="symbol"||typeof e=="boolean"){l.removeAttribute(u);break}e=En(e),l.setAttribute(u,e);break;case"onClick":e!=null&&(l.onclick=$t);return;case"onScroll":e!=null&&P("scroll",l);return;case"onScrollEnd":e!=null&&P("scrollend",l);return;case"dangerouslySetInnerHTML":if(e!=null){if(typeof e!="object"||!("__html"in e))throw Error(y(61));if(u=e.__html,u!=null){if(a.children!=null)throw Error(y(60));n?.__html!==u&&(l.innerHTML=u)}}break;case"multiple":l.multiple=e&&typeof e!="function"&&typeof e!="symbol";break;case"muted":l.muted=e&&typeof e!="function"&&typeof e!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(e==null||typeof e=="function"||typeof e=="boolean"||typeof e=="symbol"){l.removeAttribute("xlink:href");break}u=En(e),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",u);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":e!=null&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,e):l.removeAttribute(u);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"credentialless":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":e&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,""):l.removeAttribute(u);break;case"capture":case"download":e===!0?l.setAttribute(u,""):e!==!1&&e!=null&&typeof e!="function"&&typeof e!="symbol"?l.setAttribute(u,e):l.removeAttribute(u);break;case"cols":case"rows":case"size":case"span":e!=null&&typeof e!="function"&&typeof e!="symbol"&&!isNaN(e)&&1<=e?l.setAttribute(u,e):l.removeAttribute(u);break;case"rowSpan":case"start":e==null||typeof e=="function"||typeof e=="symbol"||isNaN(e)?l.removeAttribute(u):l.setAttribute(u,e);break;case"popover":P("beforetoggle",l),P("toggle",l),bn(l,"popover",e);break;case"xlinkActuate":cu(l,"http://www.w3.org/1999/xlink","xlink:actuate",e);break;case"xlinkArcrole":cu(l,"http://www.w3.org/1999/xlink","xlink:arcrole",e);break;case"xlinkRole":cu(l,"http://www.w3.org/1999/xlink","xlink:role",e);break;case"xlinkShow":cu(l,"http://www.w3.org/1999/xlink","xlink:show",e);break;case"xlinkTitle":cu(l,"http://www.w3.org/1999/xlink","xlink:title",e);break;case"xlinkType":cu(l,"http://www.w3.org/1999/xlink","xlink:type",e);break;case"xmlBase":cu(l,"http://www.w3.org/XML/1998/namespace","xml:base",e);break;case"xmlLang":cu(l,"http://www.w3.org/XML/1998/namespace","xml:lang",e);break;case"xmlSpace":cu(l,"http://www.w3.org/XML/1998/namespace","xml:space",e);break;case"is":bn(l,"is",e);break;case"innerText":case"textContent":return;default:if(!(2<u.length)||u[0]!=="o"&&u[0]!=="O"||u[1]!=="n"&&u[1]!=="N")u=jv.get(u)||u,bn(l,u,e);else return}fl=!0}function Pf(l,t,u,e,a,n){switch(u){case"style":to(l,e,n);return;case"dangerouslySetInnerHTML":if(e!=null){if(typeof e!="object"||!("__html"in e))throw Error(y(61));if(u=e.__html,u!=null){if(a.children!=null)throw Error(y(60));n?.__html!==u&&(l.innerHTML=u)}}break;case"children":if(typeof e=="string")Ae(l,e);else if(typeof e=="number"||typeof e=="bigint")Ae(l,""+e);else return;break;case"onScroll":e!=null&&P("scroll",l);return;case"onScrollEnd":e!=null&&P("scrollend",l);return;case"onClick":e!=null
Line truncated
|
||||
`).map((O,N,s)=>O.trim()&&!O.trim().startsWith("###")?d.jsxs("div",{className:"draft-row",children:[d.jsx("code",{children:O}),d.jsx("button",{"aria-label":`Monter l’instruction ${N}`,disabled:N===0||s[N-1].startsWith("###"),onClick:()=>{const E=[...s];[E[N-1],E[N]]=[E[N],E[N-1]],Nl("draft","","",E.join(`
|
||||
`))},children:"↑"}),d.jsx("button",{"aria-label":`Descendre l’instruction ${N}`,disabled:N===s.length-1||!s[N+1].trim(),onClick:()=>{const E=[...s];[E[N+1],E[N]]=[E[N],E[N+1]],Nl("draft","","",E.join(`
|
||||
`))},children:"↓"}),d.jsx("button",{onClick:()=>Nl("draft","","",s.filter((E,D)=>D!==N).join(`
|
||||
`)),children:"Retirer"})]},N):null)}),d.jsxs("details",{children:[d.jsx("summary",{children:"Éditer les instructions en texte"}),d.jsx("textarea",{className:"instructions","aria-label":"Brouillon des transferts",value:C,onChange:O=>Nl("draft","","",O.target.value)})]}),d.jsx("button",{disabled:!M[Gt("draft")],onClick:()=>pl([Gt("draft")]),children:"Enregistrer le brouillon"}),d.jsxs("details",{children:[d.jsx("summary",{children:"Instructions actuellement appliquées"}),d.jsx("pre",{children:_.applied})]})]}),d.jsxs("div",{className:"results",children:[d.jsxs("h2",{children:[cl.length," réponse",cl.length!==1?"s":""]}),d.jsx("span",{children:!rl&&!ul?"Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification":"Images annotées après lecture manuscrite"})]}),d.jsx("div",{className:"answer-grid",children:cl.slice(0,R).map(({c:O,a:N})=>d.jsxs("article",{className:"answer",children:[d.jsxs("div",{className:"card-heading",children:[d.jsxs("div",{children:[d.jsxs("span",{className:"eyebrow",children:["COPIE ",O.id," · ",O.name]}),d.jsx("h3",{children:N.label})]}),d.jsx("span",{className:"badge",children:N.info.present?N.info.not_empty?N.info.touched?"Dans le PDF":"Hors PDF filtré":"Réponse vide":"Absente"})]}),d.jsx("div",{className:"image-space",children:N.image?d.jsxs("button",{className:"image-button","aria-label":`Agrandir copie ${O.id} ${N.label}`,onClick:()=>at({copy:O.id,label:N.label}),children:[d.jsx("img",{loading:"lazy",src:Gi(O.id,N.label,!1,_.revision),alt:`Réponse annotée — copie ${O.id}, ${N.label}`}),d.jsx("span",{children:"Agrandir ↗"})]}):d.jsx("p",{className:"empty",children:"Aucune image pour ce label."})}),d.jsxs("div",{className:"answer-form",children:[d.jsxs("label",{children:["Score",d.jsx("input",{inputMode:"decimal",value:Vl("score",O.id,N.label,N.score),onChange:s=>Nl("score",O.id,N.label,s.target.value)})]}),d.jsxs("label",{children:["Remarque personnelle",d.jsx("textarea",{placeholder:"Ajouter une remarque (LaTeX accepté)",value:Vl("remark",O.id,N.label,N.remark),onChange:s=>Nl("remark",O.id,N.label,s.target.value)})]})]}),d.jsxs("div",{className:"card-actions",children:[d.jsx("button",{disabled:!M[Gt("score",O.id,N.label)]&&!M[Gt("remark",O.id,N.label)],onClick:()=>pl([Gt("score",O.id,N.label),Gt("remark",O.id,N.label)]),children:"Enregistrer"}),d.jsx("button",{disabled:!N.editableBody,onClick:()=>gt({copy:O.id,label:N.label}),children:"Couper / transférer"}),d.jsx("a",{href:fn(O.id,"context",N.label),target:"_blank",rel:"noreferrer",children:"Contexte ↗"}),d.jsx("a",{href:fn(O.id,"question",N.label),target:"_blank",rel:"noreferrer",children:"Énoncé ↗"}),d.jsx("a",{href:fn(O.id,"solution",N.label),target:"_blank",rel:"noreferrer",children:"Corrigé ↗"}),d.jsx("a",{href:fn(O.id,"output"),target:"_blank",rel:"noreferrer",children:"PDF final ↗"})]})]},O.id+"|"+N.label))}),cl.length===0&&d.jsx("p",{className:"empty",children:"Aucune réponse ne correspond à ces filtres."}),cl.length>R&&d.jsx("button",{className:"load-more",onClick:()=>b(R+20),children:"Afficher les 20 suivantes"})]}),(_.job.log||_.job.running)&&d.jsxs("details",{className:"panel",open:_.job.status==="failed",children:[d.jsxs("summary",{children:["Journal de régénération · ",_.job.status==="failed"?"Échec / résultat incomplet":_.job.running?"En cours":"Terminée"]}),d.jsx("pre",{className:"log",children:_.job.log})]})]}),Ol&&d.jsx(Sh,{copy:Ol.copy,source:Ol.label,labels:_.labels,mode:_.mode,revision:_.revision,draft:C,close:()=>gt(null),accept:O=>{Nl("draft","","",O),gt(null),$("Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.")}}),Ql&&d.jsx(gh,{copy:Ql.copy,label:Ql.label,revision:_.revision,close:()=>at(null)})]})}function gh({copy:_,label:G,revision:Y,close:y}){const[q,$]=Sl.useState(100);return Sl.useEffect(()=>{const rl=Gl=>{Gl.key==="Escape"&&y()};return window.addEventListener("keydown",rl),()=>window.removeEventListener("keydown",rl)},[]),d.jsx("div",{className:"overlay",children:d.jsxs("section",{className:"modal viewer",role:"dialog","aria-modal":"true","aria-label":`Copie ${_} ${G}`,children:[d.jsxs("div",{className:"modal-head",children:[d.jsxs("h2",{children:["Copie ",_," · ",G]}),d.jsxs("label",{children:["Zoom ",d.jsx("input",{type:"range",min:"40",max:"200",value:q,onChange:rl=>$(Number(rl.target.value))}),q,"%"]}),d.jsx("button",{autoFocus:!0,onClick:y,children:"Fermer"})]}),d.jsx("div",{className:"zoom-scroll",children:d.jsx("img",{src:Gi(_,G,!1,Y),alt:`Copie ${_}, ${G}`,style:{width:q+"%",maxWidth:"none"}})})]})})}function Sh({copy:_,source:G,labels:Y,mode:y,revision:q,draft:$,close:rl,accept:Gl}){const[ul,Dl]=Sl.useState(Y.find(L=>L!==G)||""),[R,b]=Sl.useState("cut"),[M,_l]=Sl.useState(50),[Xl,Ul]=Sl.useState("1"),[il,et]=Sl.useState(">"),[Ol,gt]=Sl.useState(!1),[Ql,at]=Sl.useState(""),[xl,K]=Sl.useState(null),[F,Vl]=Sl.useState(!1),Nl=R==="cut"?`c{${M}}${Xl}${il}`:R,pl=`Copie${_} ${G} ${Nl} ${Ol?"|":""}${ul}${Ol?"":"|Line truncated
|
||||
`+pl+`
|
||||
`;Sl.useEffect(()=>K(null),[ul,R,M,Xl,il,Ol,$]);const jt=async()=>{Vl(!0),at("");try{K(await oa("preview",{copy:_,source:G,target:ul,text:Ll}))}catch(L){at(String(L))}finally{Vl(!1)}};return d.jsx("div",{className:"overlay",children:d.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-label":"Couper ou transférer une réponse",children:[d.jsxs("div",{className:"modal-head",children:[d.jsxs("div",{children:[d.jsxs("span",{className:"eyebrow",children:["COPIE ",_," · ",y]}),d.jsx("h2",{children:"Couper ou transférer"})]}),d.jsx("button",{autoFocus:!0,onClick:rl,children:"Fermer"})]}),d.jsx("p",{children:"Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe."}),Ql&&d.jsx("p",{role:"alert",className:"alert",children:Ql}),d.jsxs("div",{className:"cut-controls",children:[d.jsxs("label",{children:["Destination",d.jsx("select",{value:ul,onChange:L=>Dl(L.target.value),children:Y.filter(L=>L!==G).map(L=>d.jsx("option",{children:L},L))})]}),d.jsxs("label",{children:["Opération",d.jsxs("select",{value:R,onChange:L=>b(L.target.value),children:[d.jsx("option",{value:"cut",children:"Couper la source"}),d.jsx("option",{value:"x>",children:"Déplacer et ajouter"}),d.jsx("option",{value:"->",children:"Copier et ajouter"}),d.jsx("option",{value:"xx",children:"Déplacer et remplacer"}),d.jsx("option",{value:"-x",children:"Copier et remplacer"}),d.jsx("option",{value:"xs",children:"Vider la source"}),d.jsx("option",{value:"sx",children:"Vider la cible"}),d.jsx("option",{value:"ss",children:"Conserver les deux"})]})]}),R==="cut"&&d.jsxs(d.Fragment,{children:[d.jsxs("label",{children:["Coupe : ",M,"%",d.jsx("input",{type:"range",min:"1",max:"99",step:"0.1",value:M,onChange:L=>_l(Number(L.target.value))})]}),d.jsxs("label",{children:["Conserver à la source",d.jsxs("select",{value:Xl,onChange:L=>Ul(L.target.value),children:[d.jsx("option",{value:"1",children:"La partie haute"}),d.jsx("option",{value:"2",children:"La partie basse"})]})]}),d.jsxs("label",{children:["À la destination",d.jsxs("select",{value:il,onChange:L=>et(L.target.value),children:[d.jsx("option",{value:">",children:"Ajouter le fragment"}),d.jsx("option",{value:"x",children:"Remplacer le corps"})]})]})]}),d.jsxs("label",{className:"check",children:[d.jsx("input",{type:"checkbox",checked:Ol,onChange:L=>gt(L.target.checked)}),"Placer avant la cible"]})]}),d.jsxs("div",{className:"compare",children:[d.jsxs("div",{children:[d.jsxs("h3",{children:["Source · ",G]}),d.jsxs("div",{className:"cut-image",onClick:L=>{if(R==="cut"){const hl=L.currentTarget.getBoundingClientRect();_l(Math.min(99,Math.max(1,Math.round((L.clientY-hl.top)/hl.height*1e3)/10)))}},children:[d.jsx("img",{src:Gi(_,G,!0,q),alt:"Corps source sans en-tête"}),R==="cut"&&d.jsx("div",{className:"cut-line",style:{top:M+"%"},children:d.jsxs("span",{children:[M,"%"]})})]})]}),d.jsxs("div",{children:[d.jsxs("h3",{children:["Cible · ",ul]}),d.jsx(bh,{copy:_,label:ul,revision:q},ul)]})]}),d.jsx("code",{className:"instruction-line",children:pl}),d.jsxs("div",{className:"modal-actions",children:[d.jsx("button",{disabled:F||!ul,onClick:jt,children:F?"Calcul de l’aperçu…":"Aperçu du résultat"}),d.jsx("button",{className:"primary",disabled:!xl||F,onClick:()=>Gl(Ll),children:"Ajouter au brouillon"})]}),xl&&d.jsxs(d.Fragment,{children:[d.jsx("h3",{children:"Résultat proposé après toutes les instructions du brouillon"}),d.jsx("div",{className:"compare",children:[G,ul].map(L=>d.jsxs("div",{children:[d.jsx("h4",{children:L}),xl[L]?d.jsx("img",{src:xl[L],alt:"Aperçu "+L}):d.jsx("p",{className:"empty",children:"Corps vide"})]},L))})]}),d.jsx("p",{className:"muted",children:"Une instruction par source ; une cible ne peut pas être source. Modifiez ou retirez une instruction existante dans le panneau « Transferts et coupes »."})]})})}function bh({copy:_,label:G,revision:Y}){const[y,q]=Sl.useState(!1);return y?d.jsx("p",{className:"empty",children:"Pas de corps existant. Le transfert peut remplir cette cible."}):d.jsx("img",{src:Gi(_,G,!0,Y),alt:"Corps cible sans en-tête",onError:()=>q(!0)})}yh.createRoot(document.getElementById("root")).render(d.jsx(hh,{}));
|
||||
@@ -0,0 +1,3 @@
|
||||
<!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Copienator · Finalisation</title> <script type="module" crossorigin src="./assets/index-B8v-OSfB.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/index-B6ukjHTb.css">
|
||||
</head><body><div id="root"></div></body></html>
|
||||
Reference in new issue
Block a user