Improve web finalisation navigation, caching and Anot support

This commit is contained in:
sebastien committed 2026-11-03 11:38:39 +01:00
1 parent 5de70dbc0d
commit b069805988
21 files changed
+339 -58

No files matched your search

+2 -1
View File
@@ -41,7 +41,8 @@ class AnnotationReadCache:
common = (_files_digest(shared), load_return_answer_options(root),
configuration.RETURN_ANSWERS_ENABLED,
configuration.RETURN_JPEG_ENABLED, configuration.RETURN_PDF_ENABLED,
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
getattr(configuration.LATEX_ANOT_BEFORE, "template", configuration.LATEX_ANOT_BEFORE),
configuration.LATEX_ANOT_AFTER)
remarks = load_remarks(root)
general = general_remarks.load_remarks(root)
for copy_id, labels in data.items():
+11 -1
View File
@@ -1,5 +1,6 @@
import argparse
import math
import re
import shutil
import sys
from decimal import Decimal, InvalidOperation, ROUND_CEILING
@@ -37,7 +38,7 @@ def get_rounded_score(score):
"""Round score to one decimal place below (floor)."""
try:
val = float(score)
return math.floor(val * 10) / 10
return math.floor(val * 10) / 10 if math.isfinite(val) else None
except (ValueError, TypeError):
return None
@@ -130,6 +131,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
print(f"Error: Directory '{search_path}' not found.")
sys.exit(1)
exported_marks = {}
for student_source in sorted(path for path in search_path.iterdir() if path.is_dir()):
image_paths = sorted(student_source.glob("*.jpg"))
pdf_paths = sorted(student_source.glob("*.pdf"))
@@ -159,6 +161,7 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
if score is None:
print(f"Error: Invalid score '{raw_score}' for '{student_name}'.")
else:
score_published = not image_paths
# 5. Process Images
for img_path in image_paths:
try:
@@ -180,13 +183,20 @@ def process_images(base_dir, output_dir, *, copy_merged_histogramme=False):
draw.text((x, y), text, fill=(255, 0, 0), font=font)
img.save(student_output / img_path.name)
score_published = True
print(f"Processed: {student_name} -> {score}")
except Exception as e:
print(f"Error processing image for '{student_name}': {e}")
match = re.search(r'\((\d+)\)$', student_source.name)
if match and score_published:
exported_marks[str(int(match[1]))] = {'name': student_name, 'score': str(score)}
for pdf_path in pdf_paths:
shutil.copy2(pdf_path, student_output / pdf_path.name)
from copienator.final_scores import FINAL_SCORES_FILE
atomic_write_json(base_dir / FINAL_SCORES_FILE, {'version': 1, 'copies': exported_marks})
write_score_statistics(search_path, output_dir)
copy_histogram(output_dir, copy_merged_histogramme=copy_merged_histogramme)
+10 -1
View File
@@ -516,6 +516,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
d_notes = dict.fromkeys(all_labels, "")
label_images = []
answer_labels = []
reviewed_layouts = {}
sorted_labels = sorted(labels_data.items(), key=lambda item: natural_key(item[0]))
for label, content in sorted_labels:
@@ -534,7 +535,7 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
result = content.get('result', {})
coordinates = content.get('coordinates', (0, 0))
d_notes[label] = str(result.get('score', 0))
final_img, _ = compose_label_image(
final_img, header_height = compose_label_image(
base_img,
label,
result,
@@ -543,6 +544,8 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
render_fn=render_real_latex_text,
source_pdf=pdf_full_path,
)
reviewed_layouts[label] = {'img': None, 'old_header_h': header_height,
'layout': final_img.info.get('annotation_layout', layout_for(pdf_full_path))}
final_img.save(staging / f"{label}.jpg")
if result.get('error', "") != "empty-answer":
label_images.append(final_img)
@@ -561,7 +564,13 @@ def process_student(student_id, labels_data, root_dir, all_labels, overwrite):
canvas.paste(image, (0, current_y))
current_y += image.height
canvas.save(staging / "Concat.jpg")
if not problems:
from copienator.review_snapshot import stage
stage(output_dir, staging, labels_data, reviewed_layouts, engine='grouped')
# An all-empty copy still has valid individual return images.
if not problems:
from copienator.reviewed_cache import ReviewedReadCache
ReviewedReadCache(EvaluationWorkspace(Path(root_dir)), "Anot", [output_dir]).remember([student_id])
return "partial" if problems else "success"
+1 -1
View File
@@ -8,7 +8,7 @@ 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('--annotation-dir', choices=('BGnot', 'Bnot', 'Anot'), default='BGnot')
parser.add_argument('--no-browser', action='store_true')
args = parser.parse_args(argv)
from copienator.finalisation_server import WebSession
@@ -455,7 +455,7 @@ 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"}:
if annotation_dir in {"Bnot", "BGnot", "Anot"}:
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 ()))
+21 -1
View File
@@ -8,6 +8,26 @@ from copienator.commands.reading_grouped_annotations import apply_actions_and_re
def run(workspace: EvaluationWorkspace, mode: str, *, incremental: bool = False):
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()))
# Legacy Anot outputs predate saved rendering inputs; create them without
# replacing any existing score or annotated image.
missing = [d for d in directories if not (d / FILENAME).is_file()]
if mode == 'Anot' and missing:
from copienator.annotation_data import load_annotation_data
from copienator.annotation_layout import layout_for
from copienator.filesystem import staged_files
from copienator.review_snapshot import stage
loaded = load_annotation_data(workspace)
if loaded.warnings:
raise ValueError("Relancez la génération Anot : " + "; ".join(loaded.warnings))
for directory in missing:
cid = directory.name[5:]
if cid not in loaded.data:
raise ValueError(f"Données Anot absentes pour {directory.name}.")
data = loaded.data[cid]
notes = {label: {'img': None, 'old_header_h': 0, 'layout': layout_for(content['pdf_path'])}
for label, content in data.items()}
with staged_files(directory) as staging:
stage(directory, staging, data, notes, engine='grouped')
from copienator.reviewed_cache import ReviewedReadCache
cache = ReviewedReadCache(workspace, mode, directories) if incremental else None
if cache:
@@ -47,6 +67,6 @@ def run(workspace: EvaluationWorkspace, mode: str, *, incremental: bool = False)
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)
parser.add_argument('--annotation-dir', choices=('Bnot', 'BGnot', 'Anot'), required=True)
parser.add_argument("--incremental", action="store_true", help="Skip unchanged copies")
return execute(parser, argv, lambda args: run(workspace_from_args(args), args.annotation_dir, incremental=args.incremental))
+46
View File
@@ -0,0 +1,46 @@
"""Last exported overall marks, keyed by stable copy ID rather than student name."""
from pathlib import Path
from copienator import read_json, configuration
from copienator.return_identity import return_folders, RETURN_FOLDER_PATTERN
FINAL_SCORES_FILE = Path('.copienator') / 'final_scores.json'
def exported_scores(root: Path, repository: Path) -> dict[str, str]:
saved = root / FINAL_SCORES_FILE
if saved.is_file():
data = read_json(saved)
return {str(int(cid)): entry['score'] for cid, entry in data.get('copies', {}).items()
if cid.isdecimal() and isinstance(entry, dict) and isinstance(entry.get('score'), str)}
# Older exports have no machine-readable overall mark. Only use their ODS
# when it predates the exported media; a newer sheet may contain other marks.
output = Path(configuration.FINAL_SCORE_OUTPUT_DIR).expanduser()
ods = Path(configuration.FINAL_SCORE_ODS_PATH).expanduser()
output = output if output.is_absolute() else repository / output
ods = ods if ods.is_absolute() else repository / ods
if not ods.is_file() or not (output / root.name).is_dir():
return {}
candidates = {}
for cid, folder in return_folders(root).items():
name = RETURN_FOLDER_PATTERN.fullmatch(folder.name)['name']
media = [p for p in (output / root.name / name).glob('*')
if p.suffix.lower() in ('.jpg', '.pdf') and p.is_file()]
if media and ods.stat().st_mtime_ns <= min(p.stat().st_mtime_ns for p in media):
candidates[str(cid)] = name
if not candidates:
return {}
try:
import pandas as pd
from copienator.commands.add_final_score import get_rounded_score
sheet = pd.read_excel(ods, engine='odf', header=None)
values = dict(zip(sheet[0], sheet[1]))
result = {}
for cid, name in candidates.items():
score = get_rounded_score(values.get(name))
if score is not None:
result[cid] = str(score)
return result
except (OSError, ValueError, KeyError, ImportError, OverflowError):
return {}
+11 -5
View File
@@ -20,8 +20,8 @@ class Finalisation:
def __init__(self, root: Path, mode: str, token: str, lock_fd: int | None = None, *, repository: Path | None = None):
self.root = root.resolve()
self.repository = (repository or Path.cwd()).resolve()
if mode not in ('Bnot', 'BGnot'):
raise ValueError('Choisissez Bnot ou BGnot.')
if mode not in ('Bnot', 'BGnot', 'Anot'):
raise ValueError('Choisissez Anot, Bnot ou BGnot.')
self.mode, self.token = mode, token
self.lock_fd = lock_fd
self.labels = utils.read_all_labels(self.root)
@@ -66,6 +66,8 @@ class Finalisation:
def state(self):
with self.mutex:
folders = return_folders(self.root)
from copienator.final_scores import exported_scores
final_scores = exported_scores(self.root, self.repository)
copies = []
for directory in self.copies():
cid = directory.name[5:]
@@ -75,7 +77,7 @@ class Finalisation:
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),
copies.append({'id': cid, 'name': name, 'finalScore': final_scores.get(str(int(cid))), '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(),
@@ -98,6 +100,8 @@ class Finalisation:
raise ValueError('Les données ont changé. Rechargez la page avant de réessayer.')
kind, value = data['kind'], data['value']
if kind == 'draft':
if self.mode == 'Anot':
raise ValueError('Les coupes et transferts nécessitent Bnot ou BGnot.')
instructions = read_instructions(value, self.mode)
validate_instructions(self.root, self.mode, instructions)
# Same filesystem and atomic replacement; retain the GUI-readable format.
@@ -137,6 +141,8 @@ class Finalisation:
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 and self.mode == 'Anot':
raise ValueError('Les coupes et transferts nécessitent Bnot ou BGnot.')
if apply_draft:
validate_instructions(self.root, self.mode, read_instructions(self.draft(), self.mode))
if not (self.root / DRAFT).exists():
@@ -166,7 +172,7 @@ class Finalisation:
outputs = []
for directory in directories:
info = read_json(directory / 'info.json', default={})
paths = [directory / 'info.json', directory / BASELINE]
paths = [directory / 'info.json', directory / (FILENAME if self.mode == 'Anot' else BASELINE)]
paths += [directory / f'{label}.jpg' for label, entry in info.items() if entry['present']]
paths += list(directory.glob('Concat*.*'))
if any(not path.is_file() for path in paths):
@@ -194,7 +200,7 @@ class Finalisation:
commands = [['giving-names', str(self.root), self.mode]]
with self.mutex:
self.job['log'] += 'Noms seuls modifiés : conservation des images et PDF de correction.\n'
elif all((directory / FILENAME).is_file() for directory in self.copies()):
elif self.mode == 'Anot' or 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:
+1 -1
View File
@@ -181,7 +181,7 @@ class WebSession:
try:
self.service = Finalisation(root, mode, self.lease.token, self.lease.stream.fileno(), repository=repository)
if not self.service.copies():
raise ValueError('Lisez les annotations manuscrites dans ce mode avant d’ouvrir la finalisation web.')
raise ValueError('Générez les copies Anot, ou lisez les annotations Bnot/BGnot, avant d’ouvrir la finalisation web.')
self.token = secrets.token_urlsafe(32)
self.socket = socket.socket()
self.socket.bind(('127.0.0.1', 0))
+3 -2
View File
@@ -29,10 +29,11 @@ class ReviewedReadCache(AnnotationReadCache):
shared = [root / 'labels']
for folder in ('Text2', 'Sol2'):
shared.extend((root / folder).glob('*.pdf'))
common = (_files_digest(shared), configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
common = (_files_digest(shared), getattr(configuration.LATEX_ANOT_BEFORE, "template", configuration.LATEX_ANOT_BEFORE),
configuration.LATEX_ANOT_AFTER)
remarks, general = load_remarks(root), general_remarks.load_remarks(root)
applied = root / mode / APPLIED
instructions = read_instructions(applied.read_text(), mode) if applied.is_file() else []
instructions = read_instructions(applied.read_text(), mode) if applied.is_file() and mode != "Anot" else []
for directory in directories:
cid = directory.name[5:]
saved = read_json(directory / FILENAME)
@@ -1 +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
: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
@@ -1,15 +0,0 @@
(function(){const X=document.createElement("link").relList;if(X&&X.supports&&X.supports("modulepreload"))return;for(const B of document.querySelectorAll('link[rel="modulepreload"]'))h(B);new MutationObserver(B=>{for(const $ of B)if($.type==="childList")for(const sl of $.addedNodes)sl.tagName==="LINK"&&sl.rel==="modulepreload"&&h(sl)}).observe(document,{childList:!0,subtree:!0});function G(B){const $={};return B.integrity&&($.integrity=B.integrity),B.referrerPolicy&&($.referrerPolicy=B.referrerPolicy),B.crossOrigin==="use-credentials"?$.credentials="include":B.crossOrigin==="anonymous"?$.credentials="omit":$.credentials="same-origin",$}function h(B){if(B.ep)return;B.ep=!0;const $=G(B);fetch(B.href,$)}})();var zs={exports:{}},cn={};var lv;function iy(){if(lv)return cn;lv=1;var N=Symbol.for("react.transitional.element"),X=Symbol.for("react.fragment");function G(h,B,$){var sl=null;if($!==void 0&&(sl=""+$),B.key!==void 0&&(sl=""+B.key),"key"in B){$={};for(var Bl in B)Bl!=="key"&&($[Bl]=B[Bl])}else $=B;return B=$.ref,{$$typeof:N,type:h,key:sl,ref:B!==void 0?B:null,props:$}}return cn.Fragment=X,cn.jsx=G,cn.jsxs=G,cn}var tv;function cy(){return tv||(tv=1,zs.exports=iy()),zs.exports}var r=cy(),Ns={exports:{}},L={};var ev;function fy(){if(ev)return L;ev=1;var N=Symbol.for("react.transitional.element"),X=Symbol.for("react.portal"),G=Symbol.for("react.fragment"),h=Symbol.for("react.strict_mode"),B=Symbol.for("react.profiler"),$=Symbol.for("react.consumer"),sl=Symbol.for("react.context"),Bl=Symbol.for("react.forward_ref"),el=Symbol.for("react.suspense"),Al=Symbol.for("react.memo"),R=Symbol.for("react.lazy"),T=Symbol.for("react.activity"),M=Symbol.for("react.view_transition"),zl=Symbol.iterator;function Gl(o){return o===null||typeof o!="object"?null:(o=zl&&o[zl]||o["@@iterator"],typeof o=="function"?o:null)}var Dl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},il=Object.assign,et={};function Nl(o,O,x){this.props=o,this.context=O,this.refs=et,this.updater=x||Dl}Nl.prototype.isReactComponent={},Nl.prototype.setState=function(o,O){if(typeof o!="object"&&typeof o!="function"&&o!=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,o,O,"setState")},Nl.prototype.forceUpdate=function(o){this.updater.enqueueForceUpdate(this,o,"forceUpdate")};function St(){}St.prototype=Nl.prototype;function Xl(o,O,x){this.props=o,this.context=O,this.refs=et,this.updater=x||Dl}var ut=Xl.prototype=new St;ut.constructor=Xl,il(ut,Nl.prototype),ut.isPureReactComponent=!0;var Rl=Array.isArray;function K(){}var k={H:null,A:null,T:null,S:null},it=Object.prototype.hasOwnProperty;function Jl(o,O,x){var H=x.ref;return{$$typeof:N,type:o,key:O,ref:H!==void 0?H:null,props:x}}function wl(o,O){return Jl(o.type,O,o.props)}function Ml(o){return typeof o=="object"&&o!==null&&o.$$typeof===N}function at(o){var O={"=":"=0",":":"=2"};return"$"+o.replace(/[=:]/g,function(x){return O[x]})}var Z=/\/+/g;function gl(o,O){return typeof o=="object"&&o!==null&&o.key!=null?at(""+o.key):O.toString(36)}function j(o){switch(o.status){case"fulfilled":return o.value;case"rejected":throw o.reason;default:switch(typeof o.status=="string"?o.then(K,K):(o.status="pending",o.then(function(O){o.status==="pending"&&(o.status="fulfilled",o.value=O)},function(O){o.status==="pending"&&(o.status="rejected",o.reason=O)})),o.status){case"fulfilled":return o.value;case"rejected":throw o.reason}}throw o}function Q(o,O,x,H,F){var b=typeof o;(b==="undefined"||b==="boolean")&&(o=null);var _=!1;if(o===null)_=!0;else switch(b){case"bigint":case"string":case"number":_=!0;break;case"object":switch(o.$$typeof){case N:case X:_=!0;break;case R:return _=o._init,Q(_(o._payload),O,x,H,F)}}if(_)return F=F(o),_=H===""?"."+gl(o,0):H,Rl(F)?(x="",_!=null&&(x=_.replace(Z,"$&/")+"/"),Q(F,O,x,"",function(ol){return ol})):F!=null&&(Ml(F)&&(F=wl(F,x+(F.key==null||o&&o.key===F.key?"":(""+F.key).replace(Z,"$&/")+"/")+_)),O.push(F)),1;_=0;var A=H===""?".":H+":";if(Rl(o))for(var D=0;D<o.length;D++)H=o[D],b=A+gl(H,D),_+=Q(H,O,x,b,F);else if(D=Gl(o),typeof D=="function")for(o=D.call(o),D=0;!(H=o.next()).done;)H=H.value,b=A+gl(H,D++),_+=Q(H,O,x,b,F);else if(b==="object"){if(typeof o.then=="function")return Q(j(o),O,x,H,F);throw O=String(o),Error("Objects are not valid as a React child (found: "+(O==="[object Object]"?"object with keys {"+Object.keys(o).join(", ")+"}":O)+"). If you meant to render a collection of children, use an array instead.")}return _}function Y(o,O,x){if(o==null)return o;var H=[],F=0;return Q(o,H,"","",function(b){return O.call(x,b,F++)}),H}function ul(o){if(o._status===-1){var O=o._result,x=O();x.then(function(H){(o._status===0||o._status===-1)&&(o._status=1,o._result=H,x.status===void 0&&(x.status="fulfilled",x.value=H))},function(H){(o._status===0||o._status===-1)&&(o._status=2,o._result=H,x.status===void 0&&(x.status="reLine truncated
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Vi+l+Ds}var Zi=!1;function Li(l,t){if(!l||Zi)return"";Zi=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={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(C){var d=C}Reflect.construct(l,[],z)}else{try{z.call()}catch(C){d=C}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(C){d=C}(z=l())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(C){if(C&&d&&typeof C.stack=="string")return[C.stack,d.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=u.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
`),m=c.split(`
`);for(a=u=0;u<f.length&&!f[u].includes("DetermineComponentFrameRoot");)u++;for(;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;if(u===f.length||a===m.length)for(u=f.length-1,a=m.length-1;1<=u&&0<=a&&f[u]!==m[a];)a--;for(;1<=u&&0<=a;u--,a--)if(f[u]!==m[a]){if(u!==1||a!==1)do if(u--,a--,0>a||f[u]!==m[a]){var S=`
`+f[u].replace(" at new "," at ");return l.displayName&&S.includes("<anonymous>")&&(S=S.replace("<anonymous>",l.displayName)),S}while(1<=u&&0<=a);break}}}finally{Zi=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?_e(e):""}function ov(l,t){switch(l.tag){case 26:case 27:case 5:return _e(l.type);case 16:return _e("Lazy");case 13:return l.child!==t&&t!==null?_e("Suspense Fallback"):_e("Suspense");case 19:return _e("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 _e("Activity");case 30:return _e("ViewTransition");default:return""}}function Ms(l){try{var t="",e=null;do t+=ov(l,e),e=l,l=l.return;while(l);return t}catch(u){return`
Error generating stack: `+u.message+`
`+u.stack}}var Ki=Object.prototype.hasOwnProperty,Ji=N.unstable_scheduleCallback,wi=N.unstable_cancelCallback,rv=N.unstable_shouldYield,dv=N.unstable_requestPaint,Tt=N.unstable_now,vv=N.unstable_getCurrentPriorityLevel,ps=N.unstable_ImmediatePriority,Us=N.unstable_UserBlockingPriority,vn=N.unstable_NormalPriority,mv=N.unstable_LowPriority,Rs=N.unstable_IdlePriority,hv=N.log,yv=N.unstable_setDisableYieldValue,va=null,Et=null;function Oe(l){if(typeof hv=="function"&&yv(l),Et&&typeof Et.setStrictMode=="function")try{Et.setStrictMode(va,l)}catch{}}var zt=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 mn=256,hn=262144,yn=4194304;function Ie(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 gn(l,t,e){var u=l.pendingLanes;if(u===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=u&134217727;return c!==0?(u=c&~n,u!==0?a=Ie(u):(i&=c,i!==0?a=Ie(i):e||(e=c&~l,e!==0&&(a=Ie(e))))):(c=u&~n,c!==0?a=Ie(c):i!==0?a=Ie(i):e||(e=u&~l,e!==0&&(a=Ie(e)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,e=t&-t,n>=e||n===32&&(e&4194048)!==0)?t:a}function ma(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function xs(l,t){(t&8)!==0&&(t|=t&32);var e=l.entangledLanes;if(e!==0)for(l=l.entanglements,e&=t;0<e;){var u=31-zt(e),a=1<<u;t|=l[u],e&=~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=[],e=0;31>e;e++)t.push(l);return t}function ha(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Ev(l,t,e,u,a,n){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,m=l.hiddenUpdates;for(e=i&~e;0<e;){var S=31-zt(e),z=1<<S;c[S]=0,f[S]=-1;var d=m[S];if(d!==null)for(m[S]=null,S=0;S<d.length;S++){var g=d[S];g!==null&&(g.lane&=-536870913)}e&=~z}u!==0&&qs(l,u,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function qs(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var u=31-zt(t);l.entangledLanes|=t,l.entanglements[u]=l.entanglements[u]|1073741824|e&261930}function Ys(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var u=31-zt(e),a=1<<u;a&t|l[u]&t&&(l[u]|=t),e&=~a}}function Bs(l,t){var e=t&-t;return e=(e&42)!==0?1:Fi(e),(e&(l.suspendedLanes|t))!==0?0:e}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=D.p;return l!==0?l:(l=window.event,l===void 0?32:w0(l.type))}function Xs(l,t){var e=D.p;try{return D.p=l,t()}finally{D.p=e}}var fe=Math.random().toString(36).slice(2),Fl="__reactFiber$"+fe,dt="__reactProps$"+fe,zu="__reactContainer$"+fe,Qs="__reactEvents$"+fe,zv="__reactListeners$"+fe,Nv="__reactHandles$"+fe,Vs="__reactResources$"+fe,ya="__reactMarker$"+fe,Sn="__reactLoad$"+fe;function bn(l){delete l[Fl],delete l[dt],delete l[zv],delete l[Nv]}function Pe(l){var t;if(t=l[Fl])return t;for(var e=l.parentNode;e;){if(t=e[zu]||e[Fl]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=C0(l);l!==null;){if(e=l[Fl])return e;l=C0(l)}return t}l=e,e=l.parentNode}return null}function Nu(l){if(l=l[Fl]||l[zu]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function ga(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(h(33))}function _u(l){var t=l[Vs];return t||(t=l[Vs]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function Vl(l){l[ya]=!0}function Zs(l){l[Sn]=void 0}var Ls=new Set,Ks={};function lu(l,t){Ou(l,t),Ou(l+"Capture",t)}function Line truncated
`).replace(oh,"")}function c0(l,t){return t=i0(t),i0(l)===t}function hl(l,t,e,u,a,n){switch(e){case"children":if(typeof u=="string")t==="body"||t==="textarea"&&u===""||Cu(l,u);else if(typeof u=="number"||typeof u=="bigint")t!=="body"&&Cu(l,""+u);else return;break;case"className":En(l,"class",u);break;case"tabIndex":En(l,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":En(l,e,u);break;case"style":to(l,u,n);return;case"data":if(t!=="object"){En(l,"data",u);break}case"src":case"href":if(u===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=zn(u),l.setAttribute(e,u);break;case"action":case"formAction":if(typeof u=="function"){l.setAttribute(e,"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"&&(e==="formAction"?(t!=="input"&&hl(l,t,"name",a.name,a,null),hl(l,t,"formEncType",a.formEncType,a,null),hl(l,t,"formMethod",a.formMethod,a,null),hl(l,t,"formTarget",a.formTarget,a,null)):(hl(l,t,"encType",a.encType,a,null),hl(l,t,"method",a.method,a,null),hl(l,t,"target",a.target,a,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=zn(u),l.setAttribute(e,u);break;case"onClick":u!=null&&(l.onclick=kt);return;case"onScroll":u!=null&&P("scroll",l);return;case"onScrollEnd":u!=null&&P("scrollend",l);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(h(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(h(60));n?.__html!==e&&(l.innerHTML=e)}}break;case"multiple":l.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":l.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){l.removeAttribute("xlink:href");break}e=zn(u),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);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":u&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":u===!0?l.setAttribute(e,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?l.setAttribute(e,u):l.removeAttribute(e);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?l.removeAttribute(e):l.setAttribute(e,u);break;case"popover":P("beforetoggle",l),P("toggle",l),Tn(l,"popover",u);break;case"xlinkActuate":se(l,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":se(l,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":se(l,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":se(l,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":se(l,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":se(l,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":se(l,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":se(l,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":se(l,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":Tn(l,"is",u);break;case"innerText":case"textContent":return;default:if(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")e=Dv.get(e)||e,Tn(l,e,u);else return}cl=!0}function Pf(l,t,e,u,a,n){switch(e){case"style":to(l,u,n);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(h(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(h(60));n?.__html!==e&&(l.innerHTML=e)}}break;case"children":if(typeof u=="string")Cu(l,u);else if(typeof u=="number"||typeof u=="bigint")Cu(l,""+u);else return;break;case"onScroll":u!=null&&P("scroll",l);return;case"onScrollEnd":u!=null&&P("scrollend",l);return;case"onClick":u!=null Line truncated
`).map((b,_,A)=>b.trim()&&!b.trim().startsWith("###")?r.jsxs("div",{className:"draft-row",children:[r.jsx("code",{children:b}),r.jsx("button",{"aria-label":`Monter l’instruction ${_}`,disabled:_===0||A[_-1].startsWith("###"),onClick:()=>{const D=[...A];[D[_-1],D[_]]=[D[_],D[_-1]],Z("draft","","",D.join(`
`))},children:"↑"}),r.jsx("button",{"aria-label":`Descendre l’instruction ${_}`,disabled:_===A.length-1||!A[_+1].trim(),onClick:()=>{const D=[...A];[D[_+1],D[_]]=[D[_],D[_+1]],Z("draft","","",D.join(`
`))},children:"↓"}),r.jsx("button",{onClick:()=>Z("draft","","",A.filter((D,ol)=>ol!==_).join(`
`)),children:"Retirer"})]},_):null)}),r.jsxs("details",{children:[r.jsx("summary",{children:"Éditer les instructions en texte"}),r.jsx("textarea",{className:"instructions","aria-label":"Brouillon des transferts",value:al,onChange:b=>Z("draft","","",b.target.value)})]}),r.jsx("button",{disabled:!M[Vt("draft")],onClick:()=>gl([Vt("draft")]),children:"Enregistrer le brouillon"}),r.jsxs("details",{children:[r.jsx("summary",{children:"Instructions actuellement appliquées"}),r.jsx("pre",{children:N.applied})]})]}),r.jsxs("div",{className:"results",children:[r.jsxs("h2",{children:[bt.length," réponse",bt.length!==1?"s":""]}),r.jsx("span",{children:!sl&&!el?"Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification":"Images annotées après lecture manuscrite"})]}),r.jsx("div",{className:"answer-grid",children:bt.slice(0,R).map(({c:b,a:_})=>r.jsxs("article",{className:"answer",children:[r.jsxs("div",{className:"card-heading",children:[r.jsxs("div",{children:[r.jsxs("span",{className:"eyebrow",children:["COPIE ",b.id," · ",b.name]}),r.jsx("h3",{children:_.label})]}),r.jsx("span",{className:"badge",children:_.info.present?_.info.not_empty?_.info.touched?"Dans le PDF":"Hors PDF filtré":"Réponse vide":"Absente"})]}),r.jsx("div",{className:"image-space",children:_.image?r.jsxs("button",{className:"image-button","aria-label":`Agrandir copie ${b.id} ${_.label}`,onClick:()=>ut({copy:b.id,label:_.label}),children:[r.jsx("img",{loading:"lazy",src:Xi(b.id,_.label,!1,N.revision),alt:`Réponse annotée — copie ${b.id}, ${_.label}`}),r.jsx("span",{children:"Agrandir ↗"})]}):r.jsx("p",{className:"empty",children:"Aucune image pour ce label."})}),r.jsxs("div",{className:"answer-form",children:[r.jsxs("label",{children:["Score",r.jsx("input",{inputMode:"decimal",value:at("score",b.id,_.label,_.score),onChange:A=>Z("score",b.id,_.label,A.target.value)})]}),r.jsxs("label",{children:["Remarque personnelle",r.jsx("textarea",{placeholder:"Ajouter une remarque (LaTeX accepté)",value:at("remark",b.id,_.label,_.remark),onChange:A=>Z("remark",b.id,_.label,A.target.value)})]})]}),r.jsxs("div",{className:"card-actions",children:[r.jsx("button",{disabled:!M[Vt("score",b.id,_.label)]&&!M[Vt("remark",b.id,_.label)],onClick:()=>gl([Vt("score",b.id,_.label),Vt("remark",b.id,_.label)]),children:"Enregistrer"}),r.jsx("button",{disabled:!_.editableBody,onClick:()=>St({copy:b.id,label:_.label}),children:"Couper / transférer"}),r.jsx("a",{href:sn(b.id,"context",_.label),target:"_blank",rel:"noreferrer",children:"Contexte ↗"}),r.jsx("a",{href:sn(b.id,"question",_.label),target:"_blank",rel:"noreferrer",children:"Énoncé ↗"}),r.jsx("a",{href:sn(b.id,"solution",_.label),target:"_blank",rel:"noreferrer",children:"Corrigé ↗"}),r.jsx("a",{href:sn(b.id,"output"),target:"_blank",rel:"noreferrer",children:"PDF final ↗"})]})]},b.id+"|"+_.label))}),bt.length===0&&r.jsx("p",{className:"empty",children:"Aucune réponse ne correspond à ces filtres."}),bt.length>R&&r.jsx("button",{className:"load-more",onClick:()=>T(R+20),children:"Afficher les 20 suivantes"})]}),(N.job.log||N.job.running)&&r.jsxs("details",{className:"panel",open:N.job.status==="failed",children:[r.jsxs("summary",{children:["Journal de régénération · ",N.job.status==="failed"?"Échec / résultat incomplet":N.job.running?"En cours":"Terminée"]}),r.jsx("pre",{className:"log",children:N.job.log})]})]}),Nl&&r.jsx(Sy,{copy:Nl.copy,source:Nl.label,labels:N.labels,mode:N.mode,revision:N.revision,draft:al,close:()=>St(null),accept:b=>{Z("draft","","",b),St(null),$("Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.")}}),Xl&&r.jsx(gy,{copy:Xl.copy,label:Xl.label,revision:N.revision,close:()=>ut(null)})]})}function gy({copy:N,label:X,revision:G,close:h}){const[B,$]=dl.useState(100);return dl.useEffect(()=>{const sl=Bl=>{Bl.key==="Escape"&&h()};return window.addEventListener("keydown",sl),()=>window.removeEventListener("keydown",sl)},[]),r.jsx("div",{className:"overlay",children:r.jsxs("section",{className:"modal viewer",role:"dialog","aria-modal":"true","aria-label":`Copie ${N} ${X}`,children:[r.jsxs("div",{className:"modal-head",children:[r.jsxs("h2",{children:["Copie ",N," · ",X]}),r.jsxs("label",{children:["Zoom ",r.jsx("input",{type:"range",min:"40",max:"200",value:B,onChange:sl=>$(Number(sl.target.value))}),B,"%"]}),r.jsx("button",{autoFocus:!0,onClick:h,children:"Fermer"})]}),r.jsx("div",{className:"zoom-scroll",children:r.jsx("img",{src:Xi(N,X,!1,G),alt:`Copie ${N}, ${X}`,style:{width:B+"%",maxWidth:"none"}})})]})})}function Sy({copy:N,source:X,labels:G,mode:h,revision:B,draft:$,close:sl,accept:Bl}){const[el,Al]=dl.useState(G.find(Z=>Z!==X)||""),[R,T]=dl.useState("cut"),[M,zl]=dl.useState(50),[Gl,Dl]=dl.useState("1"),[il,et]=dl.useState(">"),[Nl,St]=dl.useState(!1),[Xl,ut]=dl.useState(""),[Rl,K]=dl.useState(null),[k,it]=dl.useState(!1),Jl=R==="cut"?`c{${M}}${Gl}${il}`:R,wl=`Copie${N} ${X} ${Jl} ${Nl?"|":""}${el}${Nl?"":"|"}Line truncated
`+wl+`
`;dl.useEffect(()=>K(null),[el,R,M,Gl,il,Nl,$]);const at=async()=>{it(!0),ut("");try{K(await ra("preview",{copy:N,source:X,target:el,text:Ml}))}catch(Z){ut(String(Z))}finally{it(!1)}};return r.jsx("div",{className:"overlay",children:r.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-label":"Couper ou transférer une réponse",children:[r.jsxs("div",{className:"modal-head",children:[r.jsxs("div",{children:[r.jsxs("span",{className:"eyebrow",children:["COPIE ",N," · ",h]}),r.jsx("h2",{children:"Couper ou transférer"})]}),r.jsx("button",{autoFocus:!0,onClick:sl,children:"Fermer"})]}),r.jsx("p",{children:"Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe."}),Xl&&r.jsx("p",{role:"alert",className:"alert",children:Xl}),r.jsxs("div",{className:"cut-controls",children:[r.jsxs("label",{children:["Destination",r.jsx("select",{value:el,onChange:Z=>Al(Z.target.value),children:G.filter(Z=>Z!==X).map(Z=>r.jsx("option",{children:Z},Z))})]}),r.jsxs("label",{children:["Opération",r.jsxs("select",{value:R,onChange:Z=>T(Z.target.value),children:[r.jsx("option",{value:"cut",children:"Couper la source"}),r.jsx("option",{value:"x>",children:"Déplacer et ajouter"}),r.jsx("option",{value:"->",children:"Copier et ajouter"}),r.jsx("option",{value:"xx",children:"Déplacer et remplacer"}),r.jsx("option",{value:"-x",children:"Copier et remplacer"}),r.jsx("option",{value:"xs",children:"Vider la source"}),r.jsx("option",{value:"sx",children:"Vider la cible"}),r.jsx("option",{value:"ss",children:"Conserver les deux"})]})]}),R==="cut"&&r.jsxs(r.Fragment,{children:[r.jsxs("label",{children:["Coupe : ",M,"%",r.jsx("input",{type:"range",min:"1",max:"99",step:"0.1",value:M,onChange:Z=>zl(Number(Z.target.value))})]}),r.jsxs("label",{children:["Conserver à la source",r.jsxs("select",{value:Gl,onChange:Z=>Dl(Z.target.value),children:[r.jsx("option",{value:"1",children:"La partie haute"}),r.jsx("option",{value:"2",children:"La partie basse"})]})]}),r.jsxs("label",{children:["À la destination",r.jsxs("select",{value:il,onChange:Z=>et(Z.target.value),children:[r.jsx("option",{value:">",children:"Ajouter le fragment"}),r.jsx("option",{value:"x",children:"Remplacer le corps"})]})]})]}),r.jsxs("label",{className:"check",children:[r.jsx("input",{type:"checkbox",checked:Nl,onChange:Z=>St(Z.target.checked)}),"Placer avant la cible"]})]}),r.jsxs("div",{className:"compare",children:[r.jsxs("div",{children:[r.jsxs("h3",{children:["Source · ",X]}),r.jsxs("div",{className:"cut-image",onClick:Z=>{if(R==="cut"){const gl=Z.currentTarget.getBoundingClientRect();zl(Math.min(99,Math.max(1,Math.round((Z.clientY-gl.top)/gl.height*1e3)/10)))}},children:[r.jsx("img",{src:Xi(N,X,!0,B),alt:"Corps source sans en-tête"}),R==="cut"&&r.jsx("div",{className:"cut-line",style:{top:M+"%"},children:r.jsxs("span",{children:[M,"%"]})})]})]}),r.jsxs("div",{children:[r.jsxs("h3",{children:["Cible · ",el]}),r.jsx(by,{copy:N,label:el,revision:B},el)]})]}),r.jsx("code",{className:"instruction-line",children:wl}),r.jsxs("div",{className:"modal-actions",children:[r.jsx("button",{disabled:k||!el,onClick:at,children:k?"Calcul de l’aperçu…":"Aperçu du résultat"}),r.jsx("button",{className:"primary",disabled:!Rl||k,onClick:()=>Bl(Ml),children:"Ajouter au brouillon"})]}),Rl&&r.jsxs(r.Fragment,{children:[r.jsx("h3",{children:"Résultat proposé après toutes les instructions du brouillon"}),r.jsx("div",{className:"compare",children:[X,el].map(Z=>r.jsxs("div",{children:[r.jsx("h4",{children:Z}),Rl[Z]?r.jsx("img",{src:Rl[Z],alt:"Aperçu "+Z}):r.jsx("p",{className:"empty",children:"Corps vide"})]},Z))})]}),r.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 by({copy:N,label:X,revision:G}){const[h,B]=dl.useState(!1);return h?r.jsx("p",{className:"empty",children:"Pas de corps existant. Le transfert peut remplir cette cible."}):r.jsx("img",{src:Xi(N,X,!0,G),alt:"Corps cible sans en-tête",onError:()=>B(!0)})}hy.createRoot(document.getElementById("root")).render(r.jsx(yy,{}));
@@ -0,0 +1,15 @@
(function(){const X=document.createElement("link").relList;if(X&&X.supports&&X.supports("modulepreload"))return;for(const Y of document.querySelectorAll('link[rel="modulepreload"]'))h(Y);new MutationObserver(Y=>{for(const k of Y)if(k.type==="childList")for(const ol of k.addedNodes)ol.tagName==="LINK"&&ol.rel==="modulepreload"&&h(ol)}).observe(document,{childList:!0,subtree:!0});function B(Y){const k={};return Y.integrity&&(k.integrity=Y.integrity),Y.referrerPolicy&&(k.referrerPolicy=Y.referrerPolicy),Y.crossOrigin==="use-credentials"?k.credentials="include":Y.crossOrigin==="anonymous"?k.credentials="omit":k.credentials="same-origin",k}function h(Y){if(Y.ep)return;Y.ep=!0;const k=B(Y);fetch(Y.href,k)}})();var Ns={exports:{}},fn={};var ev;function fy(){if(ev)return fn;ev=1;var T=Symbol.for("react.transitional.element"),X=Symbol.for("react.fragment");function B(h,Y,k){var ol=null;if(k!==void 0&&(ol=""+k),Y.key!==void 0&&(ol=""+Y.key),"key"in Y){k={};for(var ql in Y)ql!=="key"&&(k[ql]=Y[ql])}else k=Y;return Y=k.ref,{$$typeof:T,type:h,key:ol,ref:Y!==void 0?Y:null,props:k}}return fn.Fragment=X,fn.jsx=B,fn.jsxs=B,fn}var uv;function sy(){return uv||(uv=1,Ns.exports=fy()),Ns.exports}var o=sy(),_s={exports:{}},K={};var av;function oy(){if(av)return K;av=1;var T=Symbol.for("react.transitional.element"),X=Symbol.for("react.portal"),B=Symbol.for("react.fragment"),h=Symbol.for("react.strict_mode"),Y=Symbol.for("react.profiler"),k=Symbol.for("react.consumer"),ol=Symbol.for("react.context"),ql=Symbol.for("react.forward_ref"),cl=Symbol.for("react.suspense"),pl=Symbol.for("react.memo"),x=Symbol.for("react.lazy"),b=Symbol.for("react.activity"),j=Symbol.for("react.view_transition"),Al=Symbol.iterator;function Vl(r){return r===null||typeof r!="object"?null:(r=Al&&r[Al]||r["@@iterator"],typeof r=="function"?r:null)}var xl={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},rl=Object.assign,nt={};function Cl(r,_,R){this.props=r,this.context=_,this.refs=nt,this.updater=R||xl}Cl.prototype.isReactComponent={},Cl.prototype.setState=function(r,_){if(typeof r!="object"&&typeof r!="function"&&r!=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,r,_,"setState")},Cl.prototype.forceUpdate=function(r){this.updater.enqueueForceUpdate(this,r,"forceUpdate")};function Tt(){}Tt.prototype=Cl.prototype;function Zl(r,_,R){this.props=r,this.context=_,this.refs=nt,this.updater=R||xl}var it=Zl.prototype=new Tt;it.constructor=Zl,rl(it,Cl.prototype),it.isPureReactComponent=!0;var Yl=Array.isArray;function w(){}var ll={H:null,A:null,T:null,S:null},ft=Object.prototype.hasOwnProperty;function Rl(r,_,R){var M=R.ref;return{$$typeof:T,type:r,key:_,ref:M!==void 0?M:null,props:R}}function Ll(r,_){return Rl(r.type,_,r.props)}function Kl(r){return typeof r=="object"&&r!==null&&r.$$typeof===T}function xt(r){var _={"=":"=0",":":"=2"};return"$"+r.replace(/[=:]/g,function(R){return _[R]})}var $=/\/+/g;function zl(r,_){return typeof r=="object"&&r!==null&&r.key!=null?xt(""+r.key):_.toString(36)}function A(r){switch(r.status){case"fulfilled":return r.value;case"rejected":throw r.reason;default:switch(typeof r.status=="string"?r.then(w,w):(r.status="pending",r.then(function(_){r.status==="pending"&&(r.status="fulfilled",r.value=_)},function(_){r.status==="pending"&&(r.status="rejected",r.reason=_)})),r.status){case"fulfilled":return r.value;case"rejected":throw r.reason}}throw r}function q(r,_,R,M,I){var al=typeof r;(al==="undefined"||al==="boolean")&&(r=null);var fl=!1;if(r===null)fl=!0;else switch(al){case"bigint":case"string":case"number":fl=!0;break;case"object":switch(r.$$typeof){case T:case X:fl=!0;break;case x:return fl=r._init,q(fl(r._payload),_,R,M,I)}}if(fl)return I=I(r),fl=M===""?"."+zl(r,0):M,Yl(I)?(R="",fl!=null&&(R=fl.replace($,"$&/")+"/"),q(I,_,R,"",function(st){return st})):I!=null&&(Kl(I)&&(I=Ll(I,R+(I.key==null||r&&r.key===I.key?"":(""+I.key).replace($,"$&/")+"/")+fl)),_.push(I)),1;fl=0;var U=M===""?".":M+":";if(Yl(r))for(var Z=0;Z<r.length;Z++)M=r[Z],al=U+zl(M,Z),fl+=q(M,_,R,al,I);else if(Z=Vl(r),typeof Z=="function")for(r=Z.call(r),Z=0;!(M=r.next()).done;)M=M.value,al=U+zl(M,Z++),fl+=q(M,_,R,al,I);else if(al==="object"){if(typeof r.then=="function")return q(A(r),_,R,M,I);throw _=String(r),Error("Objects are not valid as a React child (found: "+(_==="[object Object]"?"object with keys {"+Object.keys(r).join(", ")+"}":_)+"). If you meant to render a collection of children, use an array instead.")}return fl}function V(r,_,R){if(r==null)return r;var M=[],I=0;return q(r,M,"","",function(al){return _.call(R,al,I++)}),M}function yl(r){if(r._status===-1){var _=r._result,R=_();R.then(function(M){(r._status===0||r._status===-1)&&(r._status=1,r._result=M,R.status===void 0&&(R.status="fulfilled",R.value=M))},function(M){(r._status===0||r._status===-1)&&(r._status=2,r._result=M,R.staLine truncated
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
`+Zi+l+Ms}var Li=!1;function Ki(l,t){if(!l||Li)return"";Li=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={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(C){var d=C}Reflect.construct(l,[],z)}else{try{z.call()}catch(C){d=C}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(C){d=C}(z=l())&&typeof z.catch=="function"&&z.catch(function(){})}}catch(C){if(C&&d&&typeof C.stack=="string")return[C.stack,d.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var a=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");a&&a.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var n=u.DetermineComponentFrameRoot(),i=n[0],c=n[1];if(i&&c){var f=i.split(`
`),m=c.split(`
`);for(a=u=0;u<f.length&&!f[u].includes("DetermineComponentFrameRoot");)u++;for(;a<m.length&&!m[a].includes("DetermineComponentFrameRoot");)a++;if(u===f.length||a===m.length)for(u=f.length-1,a=m.length-1;1<=u&&0<=a&&f[u]!==m[a];)a--;for(;1<=u&&0<=a;u--,a--)if(f[u]!==m[a]){if(u!==1||a!==1)do if(u--,a--,0>a||f[u]!==m[a]){var S=`
`+f[u].replace(" at new "," at ");return l.displayName&&S.includes("<anonymous>")&&(S=S.replace("<anonymous>",l.displayName)),S}while(1<=u&&0<=a);break}}}finally{Li=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?Oe(e):""}function dv(l,t){switch(l.tag){case 26:case 27:case 5:return Oe(l.type);case 16:return Oe("Lazy");case 13:return l.child!==t&&t!==null?Oe("Suspense Fallback"):Oe("Suspense");case 19:return Oe("SuspenseList");case 0:case 15:return Ki(l.type,!1);case 11:return Ki(l.type.render,!1);case 1:return Ki(l.type,!0);case 31:return Oe("Activity");case 30:return Oe("ViewTransition");default:return""}}function Us(l){try{var t="",e=null;do t+=dv(l,e),e=l,l=l.return;while(l);return t}catch(u){return`
Error generating stack: `+u.message+`
`+u.stack}}var Ji=Object.prototype.hasOwnProperty,wi=T.unstable_scheduleCallback,$i=T.unstable_cancelCallback,vv=T.unstable_shouldYield,mv=T.unstable_requestPaint,zt=T.unstable_now,hv=T.unstable_getCurrentPriorityLevel,xs=T.unstable_ImmediatePriority,Rs=T.unstable_UserBlockingPriority,mn=T.unstable_NormalPriority,yv=T.unstable_LowPriority,Hs=T.unstable_IdlePriority,gv=T.log,Sv=T.unstable_setDisableYieldValue,ma=null,Nt=null;function Ae(l){if(typeof gv=="function"&&Sv(l),Nt&&typeof Nt.setStrictMode=="function")try{Nt.setStrictMode(ma,l)}catch{}}var _t=Math.clz32?Math.clz32:Ev,bv=Math.log,Tv=Math.LN2;function Ev(l){return l>>>=0,l===0?32:31-(bv(l)/Tv|0)|0}var hn=256,yn=262144,gn=4194304;function Pe(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 Sn(l,t,e){var u=l.pendingLanes;if(u===0)return 0;var a=0,n=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var c=u&134217727;return c!==0?(u=c&~n,u!==0?a=Pe(u):(i&=c,i!==0?a=Pe(i):e||(e=c&~l,e!==0&&(a=Pe(e))))):(c=u&~n,c!==0?a=Pe(c):i!==0?a=Pe(i):e||(e=u&~l,e!==0&&(a=Pe(e)))),a===0?0:t!==0&&t!==a&&(t&n)===0&&(n=a&-a,e=t&-t,n>=e||n===32&&(e&4194048)!==0)?t:a}function ha(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function qs(l,t){(t&8)!==0&&(t|=t&32);var e=l.entangledLanes;if(e!==0)for(l=l.entanglements,e&=t;0<e;){var u=31-_t(e),a=1<<u;t|=l[u],e&=~a}return t}function zv(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 Ys(){var l=gn;return gn<<=1,(gn&62914560)===0&&(gn=4194304),l}function Fi(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function ya(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Nv(l,t,e,u,a,n){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var c=l.entanglements,f=l.expirationTimes,m=l.hiddenUpdates;for(e=i&~e;0<e;){var S=31-_t(e),z=1<<S;c[S]=0,f[S]=-1;var d=m[S];if(d!==null)for(m[S]=null,S=0;S<d.length;S++){var g=d[S];g!==null&&(g.lane&=-536870913)}e&=~z}u!==0&&Bs(l,u,0),n!==0&&a===0&&l.tag!==0&&(l.suspendedLanes|=n&~(i&~t))}function Bs(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var u=31-_t(t);l.entangledLanes|=t,l.entanglements[u]=l.entanglements[u]|1073741824|e&261930}function Gs(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var u=31-_t(e),a=1<<u;a&t|l[u]&t&&(l[u]|=t),e&=~a}}function Xs(l,t){var e=t&-t;return e=(e&42)!==0?1:Wi(e),(e&(l.suspendedLanes|t))!==0?0:e}function Wi(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 ki(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Qs(){var l=Z.p;return l!==0?l:(l=window.event,l===void 0?32:F0(l.type))}function Vs(l,t){var e=Z.p;try{return Z.p=l,t()}finally{Z.p=e}}var oe=Math.random().toString(36).slice(2),Il="__reactFiber$"+oe,mt="__reactProps$"+oe,Nu="__reactContainer$"+oe,Zs="__reactEvents$"+oe,_v="__reactListeners$"+oe,Ov="__reactHandles$"+oe,Ls="__reactResources$"+oe,ga="__reactMarker$"+oe,bn="__reactLoad$"+oe;function Tn(l){delete l[Il],delete l[mt],delete l[_v],delete l[Ov]}function lu(l){var t;if(t=l[Il])return t;for(var e=l.parentNode;e;){if(t=e[Nu]||e[Il]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=D0(l);l!==null;){if(e=l[Il])return e;l=D0(l)}return t}l=e,e=l.parentNode}return null}function _u(l){if(l=l[Il]||l[Nu]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function Sa(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(h(33))}function Ou(l){var t=l[Ls];return t||(t=l[Ls]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function $l(l){l[ga]=!0}function Ks(l){l[bn]=void 0}var Js=new Set,ws={};function tu(l,t){Au(l,t),Au(l+"Capture",t)}function Line truncated
`).replace(dh,"")}function s0(l,t){return t=f0(t),f0(l)===t}function bl(l,t,e,u,a,n){switch(e){case"children":if(typeof u=="string")t==="body"||t==="textarea"&&u===""||ju(l,u);else if(typeof u=="number"||typeof u=="bigint")t!=="body"&&ju(l,""+u);else return;break;case"className":zn(l,"class",u);break;case"tabIndex":zn(l,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":zn(l,e,u);break;case"style":uo(l,u,n);return;case"data":if(t!=="object"){zn(l,"data",u);break}case"src":case"href":if(u===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=Nn(u),l.setAttribute(e,u);break;case"action":case"formAction":if(typeof u=="function"){l.setAttribute(e,"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"&&(e==="formAction"?(t!=="input"&&bl(l,t,"name",a.name,a,null),bl(l,t,"formEncType",a.formEncType,a,null),bl(l,t,"formMethod",a.formMethod,a,null),bl(l,t,"formTarget",a.formTarget,a,null)):(bl(l,t,"encType",a.encType,a,null),bl(l,t,"method",a.method,a,null),bl(l,t,"target",a.target,a,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){l.removeAttribute(e);break}u=Nn(u),l.setAttribute(e,u);break;case"onClick":u!=null&&(l.onclick=It);return;case"onScroll":u!=null&&ul("scroll",l);return;case"onScrollEnd":u!=null&&ul("scrollend",l);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(h(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(h(60));n?.__html!==e&&(l.innerHTML=e)}}break;case"multiple":l.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":l.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){l.removeAttribute("xlink:href");break}e=Nn(u),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);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":u&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":u===!0?l.setAttribute(e,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?l.setAttribute(e,u):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?l.setAttribute(e,u):l.removeAttribute(e);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?l.removeAttribute(e):l.setAttribute(e,u);break;case"popover":ul("beforetoggle",l),ul("toggle",l),En(l,"popover",u);break;case"xlinkActuate":re(l,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":re(l,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":re(l,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":re(l,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":re(l,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":re(l,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":re(l,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":re(l,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":re(l,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":En(l,"is",u);break;case"innerText":case"textContent":return;default:if(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")e=Mv.get(e)||e,En(l,e,u);else return}dl=!0}function ls(l,t,e,u,a,n){switch(e){case"style":uo(l,u,n);return;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(h(61));if(e=u.__html,e!=null){if(a.children!=null)throw Error(h(60));n?.__html!==e&&(l.innerHTML=e)}}break;case"children":if(typeof u=="string")ju(l,u);else if(typeof u=="number"||typeof u=="bigint")ju(l,""+u);else return;break;case"onScroll":u!=null&&ul("scroll",l);return;case"onScrollEnd":u!=null&&ul("scrollend",l);return;case"onClick":u Line truncated
`).map((N,O,J)=>N.trim()&&!N.trim().startsWith("###")?o.jsxs("div",{className:"draft-row",children:[o.jsx("code",{children:N}),o.jsx("button",{"aria-label":`Monter l’instruction ${O}`,disabled:O===0||J[O-1].startsWith("###"),onClick:()=>{const L=[...J];[L[O-1],L[O]]=[L[O],L[O-1]],A("draft","","",L.join(`
`))},children:"↑"}),o.jsx("button",{"aria-label":`Descendre l’instruction ${O}`,disabled:O===J.length-1||!J[O+1].trim(),onClick:()=>{const L=[...J];[L[O+1],L[O]]=[L[O],L[O+1]],A("draft","","",L.join(`
`))},children:"↓"}),o.jsx("button",{onClick:()=>A("draft","","",J.filter((L,G)=>G!==O).join(`
`)),children:"Retirer"})]},O):null)}),o.jsxs("details",{children:[o.jsx("summary",{children:"Éditer les instructions en texte"}),o.jsx("textarea",{className:"instructions","aria-label":"Brouillon des transferts",value:Et,onChange:N=>A("draft","","",N.target.value)})]}),o.jsx("button",{disabled:!j[Zt("draft")],onClick:()=>q([Zt("draft")]),children:"Enregistrer le brouillon"}),o.jsxs("details",{children:[o.jsx("summary",{children:"Instructions actuellement appliquées"}),o.jsx("pre",{children:T.applied})]})]}),o.jsxs("div",{className:"results",children:[o.jsxs("h2",{children:[I.length," réponse",I.length!==1?"s":""]}),o.jsx("span",{children:!ol&&!cl?"Vue d’ensemble · sélectionnez une copie ou un label pour cibler la vérification":"Images annotées après lecture manuscrite"})]}),o.jsx("div",{className:"answer-grid",children:I.slice(0,x).map(({c:N,a:O})=>o.jsxs("article",{className:"answer",children:[o.jsxs("div",{className:"card-heading",children:[o.jsxs("div",{children:[o.jsxs("span",{className:"eyebrow",children:["COPIE ",N.id," · ",N.name]}),o.jsx("h3",{children:O.label})]}),o.jsx("span",{className:"badge",children:O.info.present?O.info.not_empty?O.info.touched?"Dans le PDF":"Hors PDF filtré":"Réponse vide":"Absente"})]}),o.jsx("div",{className:"image-space",children:O.image?o.jsxs("button",{className:"image-button","aria-label":`Agrandir copie ${N.id} ${O.label}`,onClick:()=>it({copy:N.id,label:O.label}),children:[o.jsx("img",{loading:"lazy",src:Qi(N.id,O.label,!1,T.revision),alt:`Réponse annotée — copie ${N.id}, ${O.label}`}),o.jsx("span",{children:"Agrandir ↗"})]}):o.jsx("p",{className:"empty",children:"Aucune image pour ce label."})}),o.jsxs("div",{className:"answer-form",children:[o.jsxs("label",{children:["Score",o.jsx("input",{inputMode:"decimal",value:zl("score",N.id,O.label,O.score),onChange:J=>A("score",N.id,O.label,J.target.value)})]}),o.jsxs("label",{children:["Remarque personnelle",o.jsx("textarea",{placeholder:"Ajouter une remarque (LaTeX accepté)",value:zl("remark",N.id,O.label,O.remark),onChange:J=>A("remark",N.id,O.label,J.target.value)})]})]}),o.jsxs("div",{className:"card-actions",children:[o.jsx("button",{disabled:!j[Zt("score",N.id,O.label)]&&!j[Zt("remark",N.id,O.label)],onClick:()=>q([Zt("score",N.id,O.label),Zt("remark",N.id,O.label)]),children:"Enregistrer"}),o.jsx("button",{disabled:!O.editableBody,onClick:()=>Tt({copy:N.id,label:O.label}),children:"Couper / transférer"}),o.jsx("a",{href:on(N.id,"context",O.label),target:"_blank",rel:"noreferrer",children:"Contexte ↗"}),o.jsx("a",{href:on(N.id,"question",O.label),target:"_blank",rel:"noreferrer",children:"Énoncé ↗"}),o.jsx("a",{href:on(N.id,"solution",O.label),target:"_blank",rel:"noreferrer",children:"Corrigé ↗"}),o.jsx("a",{href:on(N.id,"output"),target:"_blank",rel:"noreferrer",children:"PDF final ↗"})]})]},N.id+"|"+O.label))}),I.length===0&&o.jsx("p",{className:"empty",children:"Aucune réponse ne correspond à ces filtres."}),I.length>x&&o.jsx("button",{className:"load-more",onClick:()=>b(x+20),children:"Afficher les 20 suivantes"})]}),(T.job.log||T.job.running)&&o.jsxs("details",{className:"panel",open:T.job.status==="failed",children:[o.jsxs("summary",{children:["Journal de régénération · ",T.job.status==="failed"?"Échec / résultat incomplet":T.job.running?"En cours":"Terminée"]}),o.jsx("pre",{className:"log",children:T.job.log})]})]}),Cl&&o.jsx(Ty,{copy:Cl.copy,source:Cl.label,labels:T.labels,mode:T.mode,revision:T.revision,draft:Et,close:()=>Tt(null),accept:N=>{A("draft","","",N),Tt(null),k("Instruction ajoutée au brouillon. Enregistrez-le avant de l’appliquer.")}}),Zl&&o.jsx(by,{copy:Zl.copy,label:Zl.label,revision:T.revision,close:()=>it(null)})]})}function by({copy:T,label:X,revision:B,close:h}){const[Y,k]=ml.useState(100);return ml.useEffect(()=>{const ol=ql=>{ql.key==="Escape"&&h()};return window.addEventListener("keydown",ol),()=>window.removeEventListener("keydown",ol)},[]),o.jsx("div",{className:"overlay",children:o.jsxs("section",{className:"modal viewer",role:"dialog","aria-modal":"true","aria-label":`Copie ${T} ${X}`,children:[o.jsxs("div",{className:"modal-head",children:[o.jsxs("h2",{children:["Copie ",T," · ",X]}),o.jsxs("label",{children:["Zoom ",o.jsx("input",{type:"range",min:"40",max:"200",value:Y,onChange:ol=>k(Number(ol.target.value))}),Y,"%"]}),o.jsx("button",{autoFocus:!0,onClick:h,children:"Fermer"})]}),o.jsx("div",{className:"zoom-scroll",children:o.jsx("img",{src:Qi(T,X,!1,B),alt:`Copie ${T}, ${X}`,style:{width:Y+"%",maxWidth:"none"}})})]})})}function Ty({copy:T,source:X,labels:B,mode:h,revision:Y,draft:k,close:ol,accept:ql}){const[cl,pl]=ml.useState(B.find($=>$!==X)||""),[x,b]=ml.useState("cut"),[j,Al]=ml.useState(50),[Vl,xl]=ml.useState("1"),[rl,nt]=ml.useState(">"),[Cl,Tt]=ml.useState(!1),[Zl,it]=ml.useState(""),[Yl,w]=ml.useState(null),[ll,ft]=ml.useState(!1),Rl=x==="cut"?`c{${j}}${Vl}${rl}`:x,Ll=`Copie${T} ${X} ${Rl} ${Cl?"|":""}${cl}${Cl?"":"|"}`,Kl=k Line truncated
`+Ll+`
`;ml.useEffect(()=>w(null),[cl,x,j,Vl,rl,Cl,k]);const xt=async()=>{ft(!0),it("");try{w(await va("preview",{copy:T,source:X,target:cl,text:Kl}))}catch($){it(String($))}finally{ft(!1)}};return o.jsx("div",{className:"overlay",children:o.jsxs("section",{className:"modal",role:"dialog","aria-modal":"true","aria-label":"Couper ou transférer une réponse",children:[o.jsxs("div",{className:"modal-head",children:[o.jsxs("div",{children:[o.jsxs("span",{className:"eyebrow",children:["COPIE ",T," · ",h]}),o.jsx("h2",{children:"Couper ou transférer"})]}),o.jsx("button",{autoFocus:!0,onClick:ol,children:"Fermer"})]}),o.jsx("p",{children:"Seul le corps de la réponse est déplacé. Cliquez sur l’image source pour placer la coupe."}),Zl&&o.jsx("p",{role:"alert",className:"alert",children:Zl}),o.jsxs("div",{className:"cut-controls",children:[o.jsxs("label",{children:["Destination",o.jsx("select",{value:cl,onChange:$=>pl($.target.value),children:B.filter($=>$!==X).map($=>o.jsx("option",{children:$},$))})]}),o.jsxs("label",{children:["Opération",o.jsxs("select",{value:x,onChange:$=>b($.target.value),children:[o.jsx("option",{value:"cut",children:"Couper la source"}),o.jsx("option",{value:"x>",children:"Déplacer et ajouter"}),o.jsx("option",{value:"->",children:"Copier et ajouter"}),o.jsx("option",{value:"xx",children:"Déplacer et remplacer"}),o.jsx("option",{value:"-x",children:"Copier et remplacer"}),o.jsx("option",{value:"xs",children:"Vider la source"}),o.jsx("option",{value:"sx",children:"Vider la cible"}),o.jsx("option",{value:"ss",children:"Conserver les deux"})]})]}),x==="cut"&&o.jsxs(o.Fragment,{children:[o.jsxs("label",{children:["Coupe : ",j,"%",o.jsx("input",{type:"range",min:"1",max:"99",step:"0.1",value:j,onChange:$=>Al(Number($.target.value))})]}),o.jsxs("label",{children:["Conserver à la source",o.jsxs("select",{value:Vl,onChange:$=>xl($.target.value),children:[o.jsx("option",{value:"1",children:"La partie haute"}),o.jsx("option",{value:"2",children:"La partie basse"})]})]}),o.jsxs("label",{children:["À la destination",o.jsxs("select",{value:rl,onChange:$=>nt($.target.value),children:[o.jsx("option",{value:">",children:"Ajouter le fragment"}),o.jsx("option",{value:"x",children:"Remplacer le corps"})]})]})]}),o.jsxs("label",{className:"check",children:[o.jsx("input",{type:"checkbox",checked:Cl,onChange:$=>Tt($.target.checked)}),"Placer avant la cible"]})]}),o.jsxs("div",{className:"compare",children:[o.jsxs("div",{children:[o.jsxs("h3",{children:["Source · ",X]}),o.jsxs("div",{className:"cut-image",onClick:$=>{if(x==="cut"){const zl=$.currentTarget.getBoundingClientRect();Al(Math.min(99,Math.max(1,Math.round(($.clientY-zl.top)/zl.height*1e3)/10)))}},children:[o.jsx("img",{src:Qi(T,X,!0,Y),alt:"Corps source sans en-tête"}),x==="cut"&&o.jsx("div",{className:"cut-line",style:{top:j+"%"},children:o.jsxs("span",{children:[j,"%"]})})]})]}),o.jsxs("div",{children:[o.jsxs("h3",{children:["Cible · ",cl]}),o.jsx(Ey,{copy:T,label:cl,revision:Y},cl)]})]}),o.jsx("code",{className:"instruction-line",children:Ll}),o.jsxs("div",{className:"modal-actions",children:[o.jsx("button",{disabled:ll||!cl,onClick:xt,children:ll?"Calcul de l’aperçu…":"Aperçu du résultat"}),o.jsx("button",{className:"primary",disabled:!Yl||ll,onClick:()=>ql(Kl),children:"Ajouter au brouillon"})]}),Yl&&o.jsxs(o.Fragment,{children:[o.jsx("h3",{children:"Résultat proposé après toutes les instructions du brouillon"}),o.jsx("div",{className:"compare",children:[X,cl].map($=>o.jsxs("div",{children:[o.jsx("h4",{children:$}),Yl[$]?o.jsx("img",{src:Yl[$],alt:"Aperçu "+$}):o.jsx("p",{className:"empty",children:"Corps vide"})]},$))})]}),o.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 Ey({copy:T,label:X,revision:B}){const[h,Y]=ml.useState(!1);return h?o.jsx("p",{className:"empty",children:"Pas de corps existant. Le transfert peut remplir cette cible."}):o.jsx("img",{src:Qi(T,X,!0,B),alt:"Corps cible sans en-tête",onError:()=>Y(!0)})}gy.createRoot(document.getElementById("root")).render(o.jsx(Sy,{}));
+2 -2
View File
@@ -1,3 +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-D6hPIrMO.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-NHKKhh88.css">
<!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-QxonPU25.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CZ3oiwq9.css">
</head><body><div id="root"></div></body></html>
+28 -15
View File
@@ -365,7 +365,7 @@ class CopienatorApp(tk.Tk):
)
self.finalisation_web_button.grid(row=0, column=6, padx=(6, 0))
attach_tooltip(self.finalisation_web_button,
"Disponible après une première lecture des annotations manuscrites (Bnot ou BGnot).")
"Disponible après génération Anot ou lecture des annotations manuscrites Bnot/BGnot.")
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))
@@ -552,7 +552,7 @@ class CopienatorApp(tk.Tk):
return any(
directory.is_dir() and directory.name[5:].isdigit()
and (directory / "score.json").is_file() and (directory / "info.json").is_file()
for mode in ("Bnot", "BGnot")
for mode in ("Bnot", "BGnot", "Anot")
for directory in (evaluation / mode).glob("Copie*")
)
@@ -564,7 +564,7 @@ class CopienatorApp(tk.Tk):
messagebox.showerror("Évaluation absente", "Chargez d’abord une évaluation.")
return
if not self._has_finalisation_outputs():
messagebox.showinfo("Finalisation web", "Exécutez d’abord « Lire les annotations manuscrites ».")
messagebox.showinfo("Finalisation web", "Générez d’abord les annotations Anot, ou lisez les annotations manuscrites Bnot/BGnot.")
return
self._save_current_form()
dialog = tk.Toplevel(self)
@@ -573,10 +573,17 @@ class CopienatorApp(tk.Tk):
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.")
text = tk.StringVar(value="Choisissez le dossier des copies générées ou relues.")
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")
preferred_mode = annotation_mode_for_reader(self.state_store.step("read_annotations"))
ready_modes = [mode for mode in ANNOTATION_DIRECTORIES
if any((d / "score.json").is_file() and (d / "info.json").is_file()
for d in (self.evaluation / mode).glob("Copie*"))]
annotation = self.state_store.step("annotation")
if annotation.get("last_run_variant", annotation.get("variant")) == "simple" and "Anot" in ready_modes:
preferred_mode = "Anot"
mode = tk.StringVar(value=preferred_mode if preferred_mode in ready_modes else ready_modes[0])
choice = ttk.Combobox(frame, textvariable=mode, values=ready_modes, state="readonly")
choice.pack(fill="x", pady=8)
url = tk.StringVar()
link = ttk.Entry(frame, textvariable=url, state="readonly", width=65)
@@ -597,15 +604,21 @@ class CopienatorApp(tk.Tk):
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")
if session.service.mode == "Anot":
if data["dirty"] or session.service.job["status"] != "idle":
self.state_store.invalidate_after(self._progression_ids("annotation"), "annotation")
self.state_store.update_step("annotation", variant="simple", status="stale" if data["dirty"] else "success")
self.state_store.update_step("giving_names", status="stale" if data["dirty"] else "detected")
else:
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()
+25 -5
View File
@@ -6,8 +6,9 @@ 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
Après génération des annotations **Anot**, ou après **Lire les annotations
manuscrites** pour **Bnot/BGnot**, cliquez sur **Finalisation web…** en haut du GUI,
choisissez le dossier disponible, 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.
@@ -26,7 +27,10 @@ restant sur disque n’empêche pas la prochaine ouverture.
## Vérifier et modifier
- **Copie** seule : toutes les réponses de cette copie.
- **Copie** seule : toutes les réponses de cette copie. Le champ **Nom** propose
une complétion ; vous pouvez chercher une partie du nom, sans tenir compte des
accents ou des majuscules. Choisir un nom renseigne l’identifiant de copie, et
inversement. Les suggestions indiquent l’identifiant pour distinguer les doublons.
- **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.
@@ -45,6 +49,19 @@ les modifications avant de régénérer. Si une autre page a changé les fichier
une sauvegarde périmée est refusée ; **Recharger les données** actualise les
valeurs enregistrées en conservant les saisies locales à réexaminer.
## Score final exporté
Après **Ajouter le score final**, sélectionner une copie par identifiant ou nom
fait apparaître son **Score final exporté** au-dessus des réponses. Cette note
est celle du dernier export, arrondie comme sur le JPEG ; modifier les scores par
question ne la recalcule pas. Le passage enregistre désormais les notes dans
`.copienator/final_scores.json`, par identifiant, afin de conserver leur association
après un changement de nom.
Pour les anciens exports, la note peut être récupérée du fichier ODS uniquement
si ce fichier n’est pas plus récent que les documents exportés. Sinon, relancez
**Ajouter le score final** pour enregistrer la note de façon explicite.
## Brouillon, application et régénération
**Ajouter au brouillon** ne modifie pas les images. **Enregistrer le brouillon**
@@ -106,8 +123,11 @@ repassez par **Lire les annotations manuscrites** dans le workflow existant avan
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.
Le mode **Anot** donne accès aux noms, scores, remarques et à leur régénération
dès la génération initiale, sans lecture manuscrite intermédiaire. Les coupes et
transferts restent réservés à **Bnot/BGnot**, qui disposent des limites exactes
des corps de réponses relus. Les anciens résultats Anot peuvent créer leur état
de rendu lors de la première régénération, sans remplacer leurs scores enregistrés.
## Développement
+2
View File
@@ -103,6 +103,8 @@ class AddFinalScoreTests(unittest.TestCase):
self.assertFalse((student / "answers" / "001 - Ex 1.jpg").exists())
self.assertEqual((output / "histogramme.pdf").read_bytes(), b"histogram")
self.assertEqual(read_json(output / "stat.json"), {"Ex 1": "4.0"})
self.assertEqual(read_json(root / '.copienator/final_scores.json'),
{'version': 1, 'copies': {'1': {'name': 'Student', 'score': '12.3'}}})
def test_omits_answers_directory_when_it_was_not_generated(self):
with tempfile.TemporaryDirectory() as temporary:
+42
View File
@@ -0,0 +1,42 @@
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import pandas as pd
from copienator import atomic_write_json
from copienator.final_scores import exported_scores
class ExportedFinalScoresTests(unittest.TestCase):
def test_legacy_export_uses_only_sheet_predating_export(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)/'Interro'
(root/'A Rendre/Alice (01)').mkdir(parents=True)
ods = root/'marks.ods'
ods.write_bytes(b'sheet')
image = root/'output/Interro/Alice/Alice.jpg'
image.parent.mkdir(parents=True)
image.write_bytes(b'export')
os.utime(ods, ns=(1000000,1000000))
os.utime(image, ns=(2000000,2000000))
with patch('copienator.final_scores.configuration.FINAL_SCORE_OUTPUT_DIR',root/'output'), \
patch('copienator.final_scores.configuration.FINAL_SCORE_ODS_PATH',ods), \
patch('pandas.read_excel',return_value=pd.DataFrame({0:['Alice'],1:[12.39]})) as read:
self.assertEqual(exported_scores(root,root), {'1':'12.3'})
os.utime(ods, ns=(3000000,3000000))
read.reset_mock()
self.assertEqual(exported_scores(root,root), {})
read.assert_not_called()
def test_no_final_mark_before_export_and_saved_marks_do_not_read_live_sheet(self):
with tempfile.TemporaryDirectory() as temporary:
root=Path(temporary)
with patch('copienator.final_scores.configuration.FINAL_SCORE_OUTPUT_DIR',root/'output'), \
patch('copienator.final_scores.configuration.FINAL_SCORE_ODS_PATH',root/'missing.ods'), \
patch('pandas.read_excel') as read:
self.assertEqual(exported_scores(root,root), {})
atomic_write_json(root/'.copienator/final_scores.json',
{'version':1,'copies':{'01':{'score':'0.0','name':'Alice'}}})
self.assertEqual(exported_scores(root,root), {'1':'0.0'})
read.assert_not_called()
+93 -1
View File
@@ -212,6 +212,14 @@ class WebTests(unittest.TestCase):
self.service._regenerate(False)
self.assertEqual(calls, ['regenerate-reviewed', 'giving-names'])
def test_exported_final_score_is_bound_to_copy_id_after_rename(self):
self.assertIsNone(self.client.get('/api/state').json()['copies'][0]['finalScore'])
atomic_write_json(self.root/'.copienator/final_scores.json',
{'version':1,'copies':{'1':{'name':'Old name','score':'12.3'}}})
self.assertEqual(self.client.get('/api/state').json()['copies'][0]['finalScore'], '12.3')
self.save('name', 'New name')
self.assertEqual(self.client.get('/api/state').json()['copies'][0]['finalScore'], '12.3')
def test_os_lock_blocks_cli_and_releases(self):
with EvaluationLock(self.root):
with self.assertRaises(CliError):
@@ -277,7 +285,7 @@ class SnapshotTests(unittest.TestCase):
from copienator.commands import regenerate_reviewed
from copienator.commands import annotating
from copienator.custom_remarks import set_remark
for mode in ('Bnot', 'BGnot'):
for mode in ('Bnot', 'BGnot', 'Anot'):
with self.subTest(mode=mode), tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
seed(root, mode)
@@ -331,6 +339,27 @@ class WebGuiTests(unittest.TestCase):
app.state_store.evaluation = None
self.assertEqual(state(), 'disabled')
def test_anot_generation_enables_button_and_selects_anot(self):
from copienator_gui.app import CopienatorApp
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
seed(root, 'Anot')
app = CopienatorApp(Path.cwd(), False, root)
self.addCleanup(app.destroy)
app._update_controls()
self.assertEqual(str(app.finalisation_web_button.cget('state')), 'normal')
app._open_finalisation_web()
dialog = app.grab_current()
def descendants(widget):
for child in widget.winfo_children():
yield child
yield from descendants(child)
combo = next(w for w in descendants(dialog) if w.winfo_class() == 'TCombobox')
self.assertEqual(combo.get(), 'Anot')
self.assertEqual(tuple(combo.cget('values')), ('Anot',))
dialog.grab_release()
dialog.destroy()
def test_modal_lock_and_return_to_existing_workflow(self):
from copienator_gui.app import CopienatorApp
from copienator.finalisation_server import WebSession
@@ -457,3 +486,66 @@ class IncrementalReviewedTests(unittest.TestCase):
(root/'A Rendre/Bob (02)/info.json').unlink()
run(1)
self.assertTrue((root/'A Rendre/Bob (02)/info.json').is_file())
class CrossProcessCacheTests(unittest.TestCase):
def test_reviewed_cache_survives_worker_restart_and_selects_only_two_score_edits(self):
from copienator.review_snapshot import stage
import shutil
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
seed(root, 'BGnot')
output = root/'BGnot/Copie01'
data = {'Ex 1': {'pdf_path':root/'Copies/Copie01/Ex 1.pdf',
'result':{'score':3,'feedback':[]},'coordinates':[0,0]}}
with staged_files(output) as staging:
stage(output, staging, data, {}, engine='grouped')
for cid in ('02','03','04'):
shutil.copytree(output,root/'BGnot'/f'Copie{cid}')
script = '''
import json, sys
from pathlib import Path
from copienator import EvaluationWorkspace, configuration
from copienator.reviewed_cache import ReviewedReadCache
from string import Template
configuration.LATEX_ANOT_BEFORE = Template(sys.argv[3])
root=Path(sys.argv[1])
directories=sorted((root/'BGnot').glob('Copie*'))
cache=ReviewedReadCache(EvaluationWorkspace(root),'BGnot',directories)
if sys.argv[2]=='remember':
cache.remember([d.name[5:] for d in directories])
print(json.dumps([d.name for d in directories if not cache.unchanged(d.name[5:])]))
'''
def run(action='check', template='Stable ${width_in} template'):
return __import__('json').loads(subprocess.check_output(
[sys.executable,'-c',script,str(root),action,template],text=True))
self.assertEqual(run('remember'), [])
self.assertEqual(run(), [])
set_score(root/'BGnot/Copie01/score.json','Ex 1','1')
set_score(root/'BGnot/Copie03/score.json','Ex 1','2')
self.assertEqual(run(), ['Copie01','Copie03'])
self.assertEqual(run('remember'), [])
self.assertEqual(run(), [])
self.assertEqual(run(template='Changed template'), ['Copie01','Copie02','Copie03','Copie04'])
def test_legacy_reader_cache_template_is_stable_across_workers(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
seed(root)
atomic_write_json(root/'Bnot/Copie01/review_snapshot.json', {})
script = '''
import json, sys
from pathlib import Path
from string import Template
from copienator import EvaluationWorkspace, configuration
from copienator.annotation_read_cache import AnnotationReadCache
configuration.LATEX_ANOT_BEFORE=Template('Stable ${width_in} template')
root=Path(sys.argv[1])
data={'01':{'Ex 1':{'pdf_path':root/'Copies/Copie01/Ex 1.pdf'}}}
cache=AnnotationReadCache(EvaluationWorkspace(root),'Bnot',data)
if sys.argv[2]=='remember': cache.remember(['01'])
print(json.dumps(cache.unchanged('01')))
'''
for action in ('remember','check'):
result=subprocess.check_output([sys.executable,'-c',script,str(root),action],text=True)
self.assertEqual(result.strip(),'true')
+20 -5
View File
@@ -3,7 +3,7 @@ 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 Copy = {id:string;name:string;finalScore?:string|null;general:string[];answers:Answer[]};
type Job = {running:boolean;status:string;log:string};
type State = {evaluation:string;mode:string;labels:string[];copies:Copy[];expectedNames:string[]|null;draft:string;applied:string;revision:string;dirty:boolean;job:Job};
type Edit = {kind:string;copy:string;label:string;value:string|string[]};
@@ -15,6 +15,8 @@ async function api(path:string, data?:unknown) {
const imageUrl=(copy:string,label:string,body=false,revision='')=>'/api/image?'+new URLSearchParams({copy,label,body:String(body),v:revision});
const documentUrl=(copy:string,kind:string,label='')=>'/api/document?'+new URLSearchParams({copy,kind,label});
const keyOf=(kind:string,copy='',label='')=>JSON.stringify([kind,copy,label]);
const searchName=(text:string)=>text.normalize('NFD').replace(/\p{M}/gu,'').toLocaleLowerCase('fr').trim();
const copyName=(copy:Copy)=>`${copy.name} · copie ${copy.id}`;
function App(){
const [state,setState]=useState<State|null>(null),[error,setError]=useState(''),[notice,setNotice]=useState('');
@@ -23,6 +25,7 @@ function App(){
const [cut,setCut]=useState<{copy:string;label:string}|null>(null),[zoom,setZoom]=useState<{copy:string;label:string}|null>(null);
const [stopped,setStopped]=useState(false);
const [onlyNameIssues,setOnlyNameIssues]=useState(false);
const [nameQuery,setNameQuery]=useState('');
const [incremental,setIncremental]=useState(true);
const refresh=async()=>{try{setState(await api('state'));setError('')}catch(e){setError(String(e))}};
useEffect(()=>{void refresh()},[]);
@@ -39,7 +42,18 @@ function App(){
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 selectedCopy=copyFilter?state.copies.find(c=>Number(c.id)===Number(copyFilter)):undefined;
const nameChoices=[...state.copies].sort((a,b)=>a.name.localeCompare(b.name,'fr')||Number(a.id)-Number(b.id));
const selectName=(text:string)=>{
setLimit(20);
const selection=state.copies.find(c=>copyName(c)===text);
const exact=state.copies.filter(c=>searchName(c.name)===searchName(text));
const copy=selection||(exact.length===1?exact[0]:undefined);
setCopyFilter(copy?.id||'');
setNameQuery(copy?'':text);
};
const matches=state.copies.filter(c=>(!copyFilter||Number(c.id)===Number(copyFilter))
&&(!nameQuery||searchName(c.name).includes(searchName(nameQuery))));
const cards=matches.flatMap(c=>c.answers.filter(a=>!labelFilter||a.label===labelFilter).map(a=>({c,a})));
const names=new Map<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);
@@ -49,13 +63,14 @@ function App(){
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>
<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,''));setNameQuery('');setLimit(20)}}/></label><datalist id="copies">{state.copies.map(c=><option key={c.id} value={c.id}>{c.name}</option>)}</datalist><label>Nom<input type="text" list="copy-names" placeholder="Rechercher un nom…" autoComplete="off" value={selectedCopy?copyName(selectedCopy):nameQuery} onChange={e=>selectName(e.target.value)}/></label><datalist id="copy-names">{nameChoices.filter(c=>!nameQuery||searchName(c.name).includes(searchName(nameQuery))).map(c=><option key={c.id} value={copyName(c)}/>)}</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('');setNameQuery('');setLabelFilter('');setLimit(20)}}>Réinitialiser</button></div></section>
{matches.length===1&&(copyFilter||nameQuery)&&matches[0].finalScore!=null&&<div className="final-score" role="status"><span>Copie {matches[0].id} · {matches[0].name}</span><strong>Score final exporté : {matches[0].finalScore.replace('.', ',')}</strong></div>}
{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><label className="check"><input type="checkbox" checked={incremental} disabled={disabled} onChange={e=>setIncremental(e.target.checked)}/>Ignorer les copies inchangées</label><button className="primary" disabled={disabled||!!pending} onClick={regenerate}>Régénérer les sorties</button></section>
<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||state.mode==='Anot'} onChange={e=>setApplyDraft(e.target.checked)}/>Appliquer aussi le brouillon de transferts{draftDiff?' (différent du résultat actuel)':''}</label><label className="check"><input type="checkbox" checked={incremental} disabled={disabled} onChange={e=>setIncremental(e.target.checked)}/>Ignorer les copies inchangées</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><div className="identity-overview"><strong>Noms manquants{missingNames?` (${missingNames.length})`:''}</strong>{missingNames===undefined?<p>Liste des noms attendus introuvable (fichier names).</p>:missingNames.length?<ul className="missing-names">{missingNames.map(name=><li key={name}>{name}</li>)}</ul>:<p>Tous les noms attendus sont attribués.</p>}<label className="check"><input type="checkbox" checked={onlyNameIssues} onChange={e=>setOnlyNameIssues(e.target.checked)}/>Afficher uniquement les copies « Nom à vérifier »</label></div><p>Les remarques sont composées en LaTeX. Leur ordre est celui de la liste.</p>{identityMatches.length===0&&<p>Aucune copie ne correspond aux filtres sélectionnés.</p>}{identityMatches.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>
{state.mode!=='Anot'&&<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>}
+4
View File
@@ -3,3 +3,7 @@
.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%}}
.identity-overview{margin-top:18px;padding:16px;background:#f3f6f4;border-radius:7px}.identity-overview .check{max-width:none;margin-top:14px}.missing-names{display:flex;flex-wrap:wrap;gap:6px 24px;padding-left:20px;margin:10px 0 16px}
.filters{flex-wrap:wrap;justify-content:flex-end}.filters input[list="copy-names"]{width:245px}
.final-score{display:flex;align-items:center;justify-content:space-between;gap:16px;flex-wrap:wrap;padding:16px 20px;border:1px solid #b9d4c4;background:#e8f0eb;border-radius:9px;margin:16px 0}.final-score strong{font-size:20px;color:#205c48}