Initial commit for finalisation web interface
This commit is contained in:
1 parent
b295f871a7
commit
c36642e8de
33 files changed
+2744
-137
No files matched your search
@@ -11,3 +11,5 @@ config.py
|
||||
.copienator-gui.json
|
||||
.copienator/
|
||||
tmp/
|
||||
/web/node_modules/
|
||||
.copienator-finalisation.lock
|
||||
@@ -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)
|
||||
|
||||
@@ -109,6 +109,8 @@ def execute(
|
||||
) -> int:
|
||||
args = parser.parse_args(argv)
|
||||
try:
|
||||
from .evaluation_lock import command_lock
|
||||
with command_lock(args):
|
||||
result = handler(args)
|
||||
except CliError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
|
||||
@@ -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>
|
||||
@@ -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 ?"
|
||||
|
||||
@@ -13,109 +13,7 @@ from copienator import EvaluationWorkspace
|
||||
from copienator.platform import open_path, 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
|
||||
from copienator.return_identity import (RETURN_FOLDER_PATTERN, NameIssue, find_name_issues, original_copy_path, rename_return_copy)
|
||||
|
||||
|
||||
class GivingNamesPanel(ttk.LabelFrame):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
+3
-1
@@ -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/*"]
|
||||
@@ -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')
|
||||
@@ -0,0 +1 @@
|
||||
<!doctype html><html lang="fr"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Copienator · Finalisation</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||
Generated
+1254
File diff suppressed because it is too large.
Load diff
@@ -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"}}
|
||||
@@ -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<State|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState('');
|
||||
const [copyFilter,setCopyFilter]=useState(''),[labelFilter,setLabelFilter]=useState(''),[limit,setLimit]=useState(20);
|
||||
const [edits,setEdits]=useState<Record<string,Edit>>({}),[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 <main className="end"><h1>Session terminée</h1><p>Vous pouvez reprendre le travail dans Copienator.</p></main>;
|
||||
if(!state)return <main className="end"><h1>Copienator</h1><p>{error||'Chargement de la finalisation…'}</p><button onClick={refresh}>Réessayer</button></main>;
|
||||
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<string,number>();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 <>
|
||||
<header inert={!!cut||!!zoom}><div className="brand">C<span>Copienator</span></div><div className="heading"><div className="eyebrow">FINALISATION · {state.mode}</div><h1>{state.evaluation}</h1></div><button className="quiet" disabled={disabled||!!pending} onClick={stop}>Fermer et reprendre dans le GUI</button></header>
|
||||
<main inert={!!cut||!!zoom}>
|
||||
<section className="toolbar"><div><h2>Vérifier les copies</h2><p>Retrouvez une copie, une question, ou leur intersection.</p></div><div className="filters"><label>Copie<input type="text" inputMode="numeric" list="copies" placeholder="Toutes les copies" value={copyFilter} onChange={e=>{setCopyFilter(e.target.value.replace(/\D/g,''));setLimit(20)}}/></label><datalist id="copies">{state.copies.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</datalist><label>Label<select value={labelFilter} onChange={e=>{setLabelFilter(e.target.value);setLimit(20)}}><option value="">Tous les labels</option>{state.labels.map(l=><option key={l}>{l}</option>)}</select></label><button onClick={()=>{setCopyFilter('');setLabelFilter('');setLimit(20)}}>Réinitialiser</button></div></section>
|
||||
{error&&<div role="alert" className="alert">{error}<button onClick={refresh}>Recharger les données</button><small>Les saisies non enregistrées restent dans la page.</small></div>}
|
||||
{notice&&<p role="status" className="notice">{notice}</p>}
|
||||
<section className="publish"><div><strong>{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'}</strong><small>Les sorties comprennent les images, PDF et le dossier A Rendre configurés.</small></div><button disabled={disabled||!pending} onClick={()=>save(Object.keys(edits))}>Tout enregistrer</button>{pending>0&&<button disabled={disabled} onClick={()=>setEdits({})}>Annuler les saisies</button>}<label className="check"><input type="checkbox" checked={applyDraft} disabled={disabled} onChange={e=>setApplyDraft(e.target.checked)}/>Appliquer aussi le brouillon de transferts{draftDiff?' (différent du résultat actuel)':''}</label><button className="primary" disabled={disabled||!!pending} onClick={regenerate}>Régénérer les sorties</button></section>
|
||||
<fieldset disabled={disabled}>
|
||||
<details className="panel"><summary>Identités et remarques générales <span>{issues.length} nom(s) à vérifier</span></summary><p>Les remarques sont composées en LaTeX. Leur ordre est celui de la liste.</p>{matches.map(c=>{const general=value('general',c.id,'',c.general) as string[];const changeGeneral=(next:string[])=>edit('general',c.id,'',next);return <section className="identity" key={c.id}><div className="identity-name"><strong>Copie {c.id}</strong><label>Nom<input value={value('name',c.id,'',c.name) as string} onChange={e=>edit('name',c.id,'',e.target.value)}/></label>{issues.includes(c)&&<span className="badge warning">Nom à vérifier</span>}<a target="_blank" rel="noreferrer" href={documentUrl(c.id,'original')}>Voir l’originale ↗</a><button disabled={!edits[keyOf('name',c.id)]} onClick={()=>save([keyOf('name',c.id)])}>Enregistrer le nom</button></div><div className="general">{general.map((text,i)=><div className="remark-row" key={i}><textarea aria-label={`Remarque générale ${i+1}, copie ${c.id}`} value={text} onChange={e=>changeGeneral(general.map((v,j)=>i===j?e.target.value:v))}/><button disabled={i===0} aria-label="Monter" onClick={()=>{const a=[...general];[a[i-1],a[i]]=[a[i],a[i-1]];changeGeneral(a)}}>↑</button><button disabled={i===general.length-1} aria-label="Descendre" onClick={()=>{const a=[...general];[a[i+1],a[i]]=[a[i],a[i+1]];changeGeneral(a)}}>↓</button><button onClick={()=>changeGeneral(general.filter((_,j)=>j!==i))}>Supprimer</button></div>)}<button onClick={()=>changeGeneral([...general,''])}>+ Remarque générale</button><button disabled={!edits[keyOf('general',c.id)]} onClick={()=>save([keyOf('general',c.id)])}>Enregistrer les remarques</button></div></section>})}</details>
|
||||
<details className="panel"><summary>Transferts et coupes <span>{draftDiff?'Brouillon non appliqué':'Brouillon identique aux modifications appliquées'}</span></summary><p>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 ».</p><div className="draft-list">{draft.split('\n').map((line,index,lines)=>line.trim()&&!line.trim().startsWith('###')?<div className="draft-row" key={index}><code>{line}</code><button aria-label={`Monter l’instruction ${index}`} disabled={index===0||lines[index-1].startsWith('###')} onClick={()=>{const next=[...lines];[next[index-1],next[index]]=[next[index],next[index-1]];edit('draft','','',next.join('\n'))}}>↑</button><button aria-label={`Descendre l’instruction ${index}`} disabled={index===lines.length-1||!lines[index+1].trim()} onClick={()=>{const next=[...lines];[next[index+1],next[index]]=[next[index],next[index+1]];edit('draft','','',next.join('\n'))}}>↓</button><button onClick={()=>edit('draft','','',lines.filter((_,i)=>i!==index).join('\n'))}>Retirer</button></div>:null)}</div><details><summary>Éditer les instructions en texte</summary><textarea className="instructions" aria-label="Brouillon des transferts" value={draft} onChange={e=>edit('draft','','',e.target.value)}/></details><button disabled={!edits[keyOf('draft')]} onClick={()=>save([keyOf('draft')])}>Enregistrer le brouillon</button><details><summary>Instructions actuellement appliquées</summary><pre>{state.applied}</pre></details></details>
|
||||
<div className="results"><h2>{cards.length} réponse{cards.length!==1?'s':''}</h2><span>{!copyFilter&&!labelFilter?'Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification':'Images annotées après lecture manuscrite'}</span></div>
|
||||
<div className="answer-grid">{cards.slice(0,limit).map(({c,a})=><article className="answer" key={c.id+'|'+a.label}><div className="card-heading"><div><span className="eyebrow">COPIE {c.id} · {c.name}</span><h3>{a.label}</h3></div><span className="badge">{!a.info.present?'Absente':!a.info.not_empty?'Réponse vide':a.info.touched?'Dans le PDF':'Hors PDF filtré'}</span></div><div className="image-space">{a.image?<button className="image-button" aria-label={`Agrandir copie ${c.id} ${a.label}`} onClick={()=>setZoom({copy:c.id,label:a.label})}><img loading="lazy" src={imageUrl(c.id,a.label,false,state.revision)} alt={`Réponse annotée — copie ${c.id}, ${a.label}`}/><span>Agrandir ↗</span></button>:<p className="empty">Aucune image pour ce label.</p>}</div><div className="answer-form"><label>Score<input inputMode="decimal" value={value('score',c.id,a.label,a.score) as string} onChange={e=>edit('score',c.id,a.label,e.target.value)}/></label><label>Remarque personnelle<textarea placeholder="Ajouter une remarque (LaTeX accepté)" value={value('remark',c.id,a.label,a.remark) as string} onChange={e=>edit('remark',c.id,a.label,e.target.value)}/></label></div><div className="card-actions"><button disabled={!edits[keyOf('score',c.id,a.label)]&&!edits[keyOf('remark',c.id,a.label)]} onClick={()=>save([keyOf('score',c.id,a.label),keyOf('remark',c.id,a.label)])}>Enregistrer</button><button disabled={!a.editableBody} onClick={()=>setCut({copy:c.id,label:a.label})}>Couper / transférer</button><a href={documentUrl(c.id,'context',a.label)} target="_blank" rel="noreferrer">Contexte ↗</a><a href={documentUrl(c.id,'question',a.label)} target="_blank" rel="noreferrer">Énoncé ↗</a><a href={documentUrl(c.id,'solution',a.label)} target="_blank" rel="noreferrer">Corrigé ↗</a><a href={documentUrl(c.id,'output')} target="_blank" rel="noreferrer">PDF final ↗</a></div></article>)}</div>
|
||||
{cards.length===0&&<p className="empty">Aucune réponse ne correspond à ces filtres.</p>}{cards.length>limit&&<button className="load-more" onClick={()=>setLimit(limit+20)}>Afficher les 20 suivantes</button>}
|
||||
</fieldset>
|
||||
{(state.job.log||state.job.running)&&<details className="panel" open={state.job.status==='failed'}><summary>Journal de régénération · {state.job.status==='failed'?'Échec / résultat incomplet':state.job.running?'En cours':'Terminée'}</summary><pre className="log">{state.job.log}</pre></details>}
|
||||
</main>
|
||||
{cut&&<CutEditor copy={cut.copy} source={cut.label} labels={state.labels} mode={state.mode} revision={state.revision} draft={draft} close={()=>setCut(null)} accept={text=>{edit('draft','','',text);setCut(null);setNotice('Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.')}}/>}
|
||||
{zoom&&<Viewer copy={zoom.copy} label={zoom.label} revision={state.revision} close={()=>setZoom(null)}/>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function Viewer({copy,label,revision,close}:{copy:string;label:string;revision:string;close:()=>void}){
|
||||
const [scale,setScale]=useState(100);
|
||||
useEffect(()=>{const f=(e:KeyboardEvent)=>{if(e.key==='Escape')close()};window.addEventListener('keydown',f);return()=>window.removeEventListener('keydown',f)},[]);
|
||||
return <div className="overlay"><section className="modal viewer" role="dialog" aria-modal="true" aria-label={`Copie ${copy} ${label}`}><div className="modal-head"><h2>Copie {copy} · {label}</h2><label>Zoom <input type="range" min="40" max="200" value={scale} onChange={e=>setScale(Number(e.target.value))}/>{scale}%</label><button autoFocus onClick={close}>Fermer</button></div><div className="zoom-scroll"><img src={imageUrl(copy,label,false,revision)} alt={`Copie ${copy}, ${label}`} style={{width:scale+'%',maxWidth:'none'}}/></div></section></div>;
|
||||
}
|
||||
|
||||
function CutEditor({copy,source,labels,mode,revision,draft,close,accept}:{copy:string;source:string;labels:string[];mode:string;revision:string;draft:string;close:()=>void;accept:(text:string)=>void}){
|
||||
const [target,setTarget]=useState(labels.find(l=>l!==source)||''),[operation,setOperation]=useState('cut'),[percent,setPercent]=useState(50),[keep,setKeep]=useState('1'),[merge,setMerge]=useState('>'),[before,setBefore]=useState(false),[error,setError]=useState(''),[preview,setPreview]=useState<Record<string,string|null>|null>(null),[working,setWorking]=useState(false);
|
||||
const operator=operation==='cut'?`c{${percent}}${keep}${merge}`:operation;
|
||||
const line=`Copie${copy} ${source} ${operator} ${before?'|':''}${target}${before?'':'|'}`;
|
||||
const candidate=draft.trimEnd()+'\n'+line+'\n';
|
||||
useEffect(()=>setPreview(null),[target,operation,percent,keep,merge,before,draft]);
|
||||
const check=async()=>{setWorking(true);setError('');try{setPreview(await api('preview',{copy,source,target,text:candidate}))}catch(e){setError(String(e))}finally{setWorking(false)}};
|
||||
return <div className="overlay"><section className="modal" role="dialog" aria-modal="true" aria-label="Couper ou transférer une réponse"><div className="modal-head"><div><span className="eyebrow">COPIE {copy} · {mode}</span><h2>Couper ou transférer</h2></div><button autoFocus onClick={close}>Fermer</button></div><p>Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe.</p>{error&&<p role="alert" className="alert">{error}</p>}<div className="cut-controls"><label>Destination<select value={target} onChange={e=>setTarget(e.target.value)}>{labels.filter(l=>l!==source).map(l=><option key={l}>{l}</option>)}</select></label><label>Opération<select value={operation} onChange={e=>setOperation(e.target.value)}><option value="cut">Couper la source</option><option value="x>">Déplacer et ajouter</option><option value="->">Copier et ajouter</option><option value="xx">Déplacer et remplacer</option><option value="-x">Copier et remplacer</option><option value="xs">Vider la source</option><option value="sx">Vider la cible</option><option value="ss">Conserver les deux</option></select></label>{operation==='cut'&&<><label>Coupe : {percent}%<input type="range" min="1" max="99" step="0.1" value={percent} onChange={e=>setPercent(Number(e.target.value))}/></label><label>Conserver à la source<select value={keep} onChange={e=>setKeep(e.target.value)}><option value="1">La partie haute</option><option value="2">La partie basse</option></select></label><label>À la destination<select value={merge} onChange={e=>setMerge(e.target.value)}><option value=">">Ajouter le fragment</option><option value="x">Remplacer le corps</option></select></label></>}<label className="check"><input type="checkbox" checked={before} onChange={e=>setBefore(e.target.checked)}/>Placer avant la cible</label></div><div className="compare"><div><h3>Source · {source}</h3><div className="cut-image" onClick={e=>{if(operation==='cut'){const r=e.currentTarget.getBoundingClientRect();setPercent(Math.min(99,Math.max(1,Math.round((e.clientY-r.top)/r.height*1000)/10)))}}}><img src={imageUrl(copy,source,true,revision)} alt="Corps source sans en-tête"/>{operation==='cut'&&<div className="cut-line" style={{top:percent+'%'}}><span>{percent}%</span></div>}</div></div><div><h3>Cible · {target}</h3><BodyImage key={target} copy={copy} label={target} revision={revision}/></div></div><code className="instruction-line">{line}</code><div className="modal-actions"><button disabled={working||!target} onClick={check}>{working?'Calcul de l’aperçu…':'Aperçu du résultat'}</button><button className="primary" disabled={!preview||working} onClick={()=>accept(candidate)}>Ajouter au brouillon</button></div>{preview&&<><h3>Résultat proposé après toutes les instructions du brouillon</h3><div className="compare">{[source,target].map(label=><div key={label}><h4>{label}</h4>{preview[label]?<img src={preview[label]!} alt={'Aperçu '+label}/>:<p className="empty">Corps vide</p>}</div>)}</div></>}<p className="muted">Une instruction par source ; une cible ne peut pas être source. Modifiez ou retirez une instruction existante dans le panneau « Transferts et coupes ».</p></section></div>;
|
||||
}
|
||||
function BodyImage({copy,label,revision}:{copy:string;label:string;revision:string}){const [missing,setMissing]=useState(false);return missing?<p className="empty">Pas de corps existant. Le transfert peut remplir cette cible.</p>:<img src={imageUrl(copy,label,true,revision)} alt="Corps cible sans en-tête" onError={()=>setMissing(true)}/>}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(<App/>);
|
||||
@@ -0,0 +1,3 @@
|
||||
: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:white;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:white;border-color:#205c48}.primary:hover:not(:disabled){background:#174633;color:white}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:white}.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:white;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:white;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:white;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:white;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:white;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}.compLine truncated
|
||||
|
||||
.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%}}
|
||||
@@ -0,0 +1 @@
|
||||
{"compilerOptions":{"target":"ES2022","lib":["ES2022","DOM","DOM.Iterable"],"module":"ESNext","moduleResolution":"bundler","jsx":"react-jsx","strict":true,"skipLibCheck":true,"noEmit":true},"include":["src"]}
|
||||
@@ -0,0 +1,2 @@
|
||||
import {defineConfig} from 'vite';
|
||||
export default defineConfig({base:'./',build:{outDir:'../copienator/web_static',emptyOutDir:true}});
|
||||
Reference in new issue
Block a user