diff --git a/Readme.org b/Readme.org index 031386a..fd161dd 100644 --- a/Readme.org +++ b/Readme.org @@ -1,7 +1,7 @@ #+title: Script #+author: Sébastien Miquel #+date: 14-03-2026 -# Time-stamp: <20-08-26 09:59> +# Time-stamp: <20-08-26 11:37> #+OPTIONS: * Méta @@ -38,6 +38,23 @@ examples du rendu final (dans le sous dossier =BGnot=). Cette situation s'améliorera peut-être, mais faciliter l'utilisation de ce système n'est pas une priorité. +** Limitations + +Pour l'instant, la correction est faite question par question : le LLM +n'a accès qu'à la partie de la copie correspondant à une question +fixée. Ça ne gère donc pas les cas où un argument a été donné dans une +question précédente ou autre. + +*** Noms de labels sous Windows + +Certains labels sont utilisés directement comme noms de fichiers et de +dossiers. Les labels contenant notamment =:= ne sont pas acceptés par +Windows. Sous Windows, les scripts refusent ces labels avant de +poursuivre et affichent la liste des valeurs à corriger. Aucune +conversion silencieuse n'est appliquée ; utiliser par exemple +=Ex 1 - a)= au lieu de =Ex 1 : a)=. Le diagnostic du GUI permet de les +repérer avant de lancer une étape. + ** Requirements *** Python @@ -74,6 +91,34 @@ ou éventuellement, la renseigner directement dans le fichier Copier `default_config.py` en `config.py`. Éventuellement le modifier. +*** Interface graphique + +Lancer l'assistant avec : + +#+BEGIN_SRC bash +python gui.py +#+END_SRC + +On peut aussi ouvrir directement une évaluation avec +=python gui.py Interro=. L'interface conserve l'état et l'historique +des étapes dans =Interro/.copienator-gui.json=, et les sorties complètes +dans =Interro/.copienator/logs/=. Une relance d'une étape antérieure ne +supprime aucun résultat ; les étapes suivantes sont seulement marquées +comme étant à revalider. + +La variable =SHOW_PERSONAL_STEPS= de =config.py= permet d'afficher ou de +masquer les étapes propres au workflow personnel. + +Le bouton =Diagnostic…= vérifie les modules Python, Poppler, LaTeX, +PDF Arranger et la configuration Gemini. Sous Windows, les exécutables +externes doivent être accessibles depuis =PATH=. Quand la création de +liens symboliques ou physiques n'est pas autorisée, l'export et la +préparation de =A Rendre= utilisent automatiquement une copie normale. + +Les chemins des étapes personnelles peuvent être adaptés avec +=CURRENT_SCORE_ODS_PATH=, =FINAL_SCORE_ODS_PATH=, +=FINAL_SCORE_OUTPUT_DIR= et =FINAL_SCORE_FONT_PATH= dans =config.py=. + ** Correction d'un paquet de copies 1. Créer un fichier =names= dans le dossier courant, avec les @@ -82,7 +127,12 @@ Copier `default_config.py` en `config.py`. Éventuellement le modifier. suite) 3. Suivre les instructions suivantes + * Étapes et Script + +Utiliser `python gui.py` ou `python gui.py Interro` pour lancer un GUI +qui suit automatiquement les étapes décrites ci-dessous. + ** Prétraitement de l'énoncé Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation : @@ -127,11 +177,11 @@ Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation Mettre les copies scannées au format pdf dans =Interro=. - 1. =./rotate_all.sh Interro= (facultatif) + 1. =python copies_tools.py rotate Interro= (facultatif) Retourne tous les pdf de 180°, si la photocopie a été faite à l'envers. - 2. =./rename_to_copie.sh Interro= + 2. =python copies_tools.py rename Interro= change le nom des copies en =Copie{id}.pdf= 3. =python page_splitter.py Interro= @@ -198,7 +248,6 @@ Optional : Set proxy with ~export HTTPS_PROXY="http://10.0.0.1:3128"~ 2. =python correction.py Interro --limit 240= OU =python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU =python correction.py Interro --overwrite= - =python correction.py Interro --pro-by-label= (needs `labels_for_pro`) Fais les requêtes de correction à Gemini. @@ -259,20 +308,20 @@ OU 3. =python export.py Interro= (gestion perso) Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR= (par défaut =Export=). - - Les mettre dans le dossier racine de la tablette, et renommer en =aaa=. - - Vider =Syncthing/Annotées= sur la tablette et localement. - À automatiser, aussi c'est lent… + +Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une +tablette graphique. ** Lecture de la correction manuscrite - 1. =python import.py Interro= (gestion perso) +_Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut +=Import=), puis y copier ou synchroniser les fichiers depuis la tablette. - _Before_ : vider le dossier configuré par =IMPORT_DIR= (par défaut - =Import=), puis y copier ou synchroniser les fichiers depuis la tablette. + 1. =python import.py Interro= -Une fois les corrections manuelles appliquées aux fichiers -=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit, -sous le nom =Concat_annotated.pdf=. + Une fois les corrections manuelles appliquées aux fichiers + =Concat.pdf=, il faut enregistrer le fichier annoté au même endroit, + sous le nom =Concat_annotated.pdf=. 2. =python reading_annotations.py Interro= @@ -290,8 +339,9 @@ OU + un fichier =score.json= qui contient les notes par question Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans. + 4. Éventuellement, faire des modifications manuelles aux =score.json=. - 4. On peut faire des changements manuels aux =score.json= ici, puis + Puis - `python reading_annotations.py --update-score Interro` - `python reading_grouped_annotations.py --update-score Interro` pour mettre à jour les scores dans les images. @@ -309,6 +359,7 @@ OU + update the copies from =miqmacs.fr/admin=. 6. (gestion perso) Impression d'une copie. Via Evince » print to pdf. +* Autres ** Recorrection d'une seule copie (peu testé) !! Attention, refaire ne marchera pas si tu fais une annotation non diff --git a/add_final_score.py b/add_final_score.py index 799ee24..e93272b 100644 --- a/add_final_score.py +++ b/add_final_score.py @@ -1,16 +1,29 @@ import argparse import math -import sys -import os import shutil +import sys from pathlib import Path + import pandas as pd from PIL import Image, ImageDraw, ImageFont +from config import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR + # Configuration constants -ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/simple_eval.ods" -OUTPUT_DIR = Path("/home/sebastien/Rust/Server/copies") -FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # Standard Linux font path +ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser() +OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser() + + +def score_font(size): + candidates = [FINAL_SCORE_FONT_PATH, "DejaVuSans-Bold.ttf", "arial.ttf"] + for candidate in candidates: + if not candidate: + continue + try: + return ImageFont.truetype(str(candidate), size) + except OSError: + continue + return ImageFont.load_default() def get_rounded_score(score): """Round score to one decimal place below (floor).""" @@ -20,7 +33,7 @@ def get_rounded_score(score): except (ValueError, TypeError): return None -def process_images(base_dir): +def process_images(base_dir, output_dir): # 1. Load Data try: # header=None assumes the file starts directly with data. @@ -33,7 +46,7 @@ def process_images(base_dir): sys.exit(1) # 2. Prepare Output Directory - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + output_dir.mkdir(parents=True, exist_ok=True) # 3. Iterate Files # Structure: Dir/A Rendre/{name}/{name}.jpg @@ -68,12 +81,7 @@ def process_images(base_dir): # Dynamic font size (15% of image height) font_size = int(width * 0.08) - try: - font = ImageFont.truetype(FONT_PATH, font_size) - except IOError: - # Fallback if specific font not found - font = ImageFont.load_default() - print(f"Warning: System font not found, using default for {student_name}") + font = score_font(font_size) text = str(score) @@ -90,7 +98,7 @@ def process_images(base_dir): draw.text((x, y), text, fill=(255, 0, 0), font=font) # Save - save_path = OUTPUT_DIR / f"{student_name}.jpg" + save_path = output_dir / f"{student_name}.jpg" img.save(save_path) print(f"Processed: {student_name} -> {score}") @@ -99,7 +107,7 @@ def process_images(base_dir): for pdf_path in sorted(search_path.glob("*/*.pdf")): student_name = pdf_path.stem # Filename without extension - save_path = OUTPUT_DIR / f"{student_name}.pdf" + save_path = output_dir / f"{student_name}.pdf" shutil.copy(str(pdf_path), str(save_path)) @@ -111,6 +119,7 @@ if __name__ == "__main__": args = parser.parse_args() - OUTPUT_DIR = OUTPUT_DIR / args.dir - OUTPUT_DIR.mkdir(parents=True, exist_ok=True) - process_images(args.dir) + base_dir = args.dir.expanduser().resolve() + output_dir = OUTPUT_DIR / base_dir.name + output_dir.mkdir(parents=True, exist_ok=True) + process_images(base_dir, output_dir) diff --git a/annotating_by_label.py b/annotating_by_label.py index 2a2497b..91647c8 100644 --- a/annotating_by_label.py +++ b/annotating_by_label.py @@ -103,6 +103,7 @@ def main(): args = parser.parse_args() root_dir = args.input_path + validated_labels = utils.read_all_labels(root_dir) results = annotating.make_dictionary(root_dir) label_groups = os.path.join(root_dir, "label_groups") all_labels = os.path.join(root_dir, "labels") @@ -113,8 +114,7 @@ def main(): print(f"Error: {all_labels} not found.") sys.exit(1) - with open(all_labels, 'r') as f: - lines = [l.strip() for l in f if l.strip()] + lines = validated_labels groups = {} for line in lines: diff --git a/annotating_with_checks.py b/annotating_with_checks.py index 61b83a9..cbb58f3 100644 --- a/annotating_with_checks.py +++ b/annotating_with_checks.py @@ -13,6 +13,7 @@ matplotlib.use('Agg') from PIL import Image, ImageDraw, ImageFont import annotating +import utils from annotating import MARGIN_LEFT, ANNOT_WIDTH # Global lock for Matplotlib/Latex rendering to prevent race conditions @@ -230,6 +231,9 @@ if __name__ == "__main__": else: root_dir = input_path + if os.path.exists(os.path.join(root_dir, "labels")): + utils.read_all_labels(root_dir) + if not args.refaire: results = annotating.make_dictionary(root_dir) diff --git a/copienator_gui/app.py b/copienator_gui/app.py index f7c7a07..b02a8a6 100644 --- a/copienator_gui/app.py +++ b/copienator_gui/app.py @@ -9,6 +9,9 @@ from pathlib import Path from tkinter import filedialog, messagebox, ttk from typing import Any +from platform_utils import WindowsLabelError, validate_windows_labels + +from .diagnostics import collect_diagnostics from .runner import ProcessRunner from .state import StateStore from .workflow import ( @@ -42,6 +45,7 @@ class CopienatorApp(tk.Tk): ) -> None: super().__init__() self.repository = repository.resolve() + self.show_personal_steps = show_personal_steps self.steps = build_workflow(show_personal_steps) self.step_by_id = {step.id: step for step in self.steps} self.state_store = StateStore() @@ -93,6 +97,7 @@ class CopienatorApp(tk.Tk): path_entry.bind("", lambda _event: self._load_evaluation()) ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(row=0, column=2, padx=6) ttk.Button(top, text="Charger", command=self._load_evaluation).grid(row=0, column=3) + ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=4, padx=(6, 0)) ttk.Label(top, text="Clé Gemini").grid(row=1, column=0, sticky="w", padx=(0, 8), pady=(7, 0)) ttk.Entry(top, textvariable=self.api_key_var, show="•", width=32).grid( @@ -194,6 +199,47 @@ class CopienatorApp(tk.Tk): status = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3)) status.grid(row=3, column=0, sticky="ew") + def _show_diagnostics(self) -> None: + checks = collect_diagnostics( + self.show_personal_steps, self.api_key_var.get(), self.evaluation + ) + dialog = tk.Toplevel(self) + dialog.title("Diagnostic Copienator") + dialog.geometry("760x480") + dialog.transient(self) + dialog.columnconfigure(0, weight=1) + dialog.rowconfigure(1, weight=1) + + required_missing = sum(1 for check in checks if check.required and not check.ok) + summary = ( + "Tous les prérequis obligatoires sont disponibles." + if required_missing == 0 + else f"{required_missing} prérequis obligatoire(s) manquant(s)." + ) + ttk.Label(dialog, text=summary, padding=10, font=("TkDefaultFont", 11, "bold")).grid( + row=0, column=0, sticky="w" + ) + + tree = ttk.Treeview(dialog, columns=("state", "need", "detail"), show="headings") + tree.heading("state", text="État") + tree.heading("need", text="Niveau") + tree.heading("detail", text="Composant et détail") + tree.column("state", width=85, anchor="center") + tree.column("need", width=100, anchor="center") + tree.column("detail", width=530) + for check in checks: + tree.insert( + "", + "end", + values=( + "OK" if check.ok else "Manquant", + "Obligatoire" if check.required else "Optionnel", + f"{check.name} — {check.detail}", + ), + ) + tree.grid(row=1, column=0, sticky="nsew", padx=10) + ttk.Button(dialog, text="Fermer", command=dialog.destroy).grid(row=2, column=0, pady=10) + def _browse_evaluation(self) -> None: selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository) if selected: @@ -482,6 +528,19 @@ class CopienatorApp(tk.Tk): if step.is_manual: self._mark_step("success") return + if os.name == "nt" and step.id != "statement": + labels_path = evaluation / "labels" + if labels_path.is_file(): + labels = [ + line.strip() + for line in labels_path.read_text(encoding="utf-8").splitlines() + if line.strip() + ] + try: + validate_windows_labels(labels) + except WindowsLabelError as exc: + messagebox.showerror("Labels incompatibles avec Windows", str(exc)) + return if not self._validate_arguments(): return missing = self._missing_requirements(step) diff --git a/copienator_gui/diagnostics.py b/copienator_gui/diagnostics.py new file mode 100644 index 0000000..74e8eb9 --- /dev/null +++ b/copienator_gui/diagnostics.py @@ -0,0 +1,125 @@ +from __future__ import annotations + +import importlib.util +import os +import shutil +import sys +from dataclasses import dataclass +from pathlib import Path + +from platform_utils import windows_filename_problems + + +@dataclass(frozen=True) +class DiagnosticCheck: + name: str + ok: bool + detail: str + required: bool = True + + +def module_available(module: str) -> bool: + try: + return importlib.util.find_spec(module) is not None + except (ImportError, ModuleNotFoundError, ValueError): + return False + + +def collect_diagnostics( + show_personal_steps: bool, + api_key_override: str = "", + evaluation: Path | None = None, +) -> list[DiagnosticCheck]: + checks = [ + DiagnosticCheck( + "Système", + os.name == "nt" or sys.platform.startswith("linux"), + f"{sys.platform} — Linux et Windows sont pris en charge", + ), + DiagnosticCheck("Python", True, sys.executable), + ] + + modules = { + "numpy": "numpy", + "pandas": "pandas", + "matplotlib": "matplotlib", + "Pillow": "PIL", + "pydantic": "pydantic", + "pypdf": "pypdf", + "pdf2image": "pdf2image", + "reportlab": "reportlab", + "img2pdf": "img2pdf", + "PyMuPDF": "fitz", + "ftfy": "ftfy", + "ezodf": "ezodf", + "Google GenAI": "google.genai", + } + checks.extend( + DiagnosticCheck(label, module_available(module), f"Module Python : {module}") + for label, module in modules.items() + ) + + programs = ( + ("Poppler", ("pdftoppm", "pdftocairo"), True), + ("LaTeX", ("pdflatex",), True), + ("PDF Arranger", ("pdf-arranger", "pdfarranger"), False), + ) + for label, candidates, required in programs: + executable = next((shutil.which(candidate) for candidate in candidates if shutil.which(candidate)), None) + detail = executable or "Introuvable dans PATH" + checks.append(DiagnosticCheck(label, executable is not None, detail, required)) + + api_key = api_key_override.strip() or os.environ.get("GEMINI_API_KEY") + checks.append( + DiagnosticCheck( + "Clé Gemini", + bool(api_key), + "GEMINI_API_KEY est définie" if api_key else "À saisir dans le GUI ou dans l’environnement", + ) + ) + + if evaluation and (evaluation / "labels").is_file(): + labels = [ + line.strip() + for line in (evaluation / "labels").read_text(encoding="utf-8").splitlines() + if line.strip() + ] + unsafe = [label for label in labels if windows_filename_problems(label)] + detail = ( + "Tous les labels sont utilisables comme noms de fichiers" + if not unsafe + else "Caractères Windows interdits : " + ", ".join(unsafe[:3]) + ) + if len(unsafe) > 3: + detail += f" (+{len(unsafe) - 3})" + checks.append( + DiagnosticCheck( + "Labels compatibles Windows", + not unsafe, + detail, + os.name == "nt", + ) + ) + + if show_personal_steps: + checks.append( + DiagnosticCheck( + "gestion_classe", + shutil.which("gestion_classe") is not None, + shutil.which("gestion_classe") or "Introuvable dans PATH", + False, + ) + ) + try: + from config import CURRENT_SCORE_ODS_PATH, FINAL_SCORE_ODS_PATH + + for label, configured_path in ( + ("ODS courant", CURRENT_SCORE_ODS_PATH), + ("ODS final", FINAL_SCORE_ODS_PATH), + ): + path = Path(configured_path).expanduser() + checks.append(DiagnosticCheck(label, path.is_file(), str(path), False)) + except ImportError: + checks.append(DiagnosticCheck("Configuration personnelle", False, "Variables absentes", False)) + + return checks diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 50d7e6e..e00d7a2 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -30,6 +30,7 @@ class CommandVariant: program: str | None kind: str = "python" # python, shell, external, manual fixed_args: tuple[str, ...] = () + fixed_args_before_positionals: bool = False dangerous: bool = False @@ -116,7 +117,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Prétraitement des copies", "Retourner toutes les copies", "Rotation facultative de 180° lorsque les scans sont à l’envers.", - (CommandVariant("rotate", "Rotation", "rotate_all.sh", "shell"),), + ( + CommandVariant( + "rotate", + "Rotation", + "copies_tools.py", + "python", + ("rotate",), + fixed_args_before_positionals=True, + ), + ), arguments=(arg_target("Dossier de l’évaluation"),), optional=True, ), @@ -125,7 +135,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Prétraitement des copies", "Renommer les PDF en CopieXX.pdf", "Renomme les PDF du dossier d’évaluation dans leur ordre courant.", - (CommandVariant("rename", "Renommage", "rename_to_copie.sh", "shell"),), + ( + CommandVariant( + "rename", + "Renommage", + "copies_tools.py", + "python", + ("rename",), + fixed_args_before_positionals=True, + ), + ), arguments=(arg_target("Dossier de l’évaluation"),), ), StepDefinition( @@ -464,8 +483,11 @@ def build_command( elif spec.flag: options.extend((spec.flag, rendered)) + if variant.fixed_args_before_positionals: + command.extend(variant.fixed_args) command.extend(positionals) - command.extend(variant.fixed_args) + if not variant.fixed_args_before_positionals: + command.extend(variant.fixed_args) command.extend(options) if extra_arguments.strip(): command.extend(shlex.split(extra_arguments, posix=os.name != "nt")) diff --git a/copies_tools.py b/copies_tools.py new file mode 100644 index 0000000..9310a89 --- /dev/null +++ b/copies_tools.py @@ -0,0 +1,109 @@ +from __future__ import annotations + +import argparse +import uuid +from pathlib import Path + +from pypdf import PdfReader, PdfWriter + + +def copy_pdfs(directory: Path) -> list[Path]: + directory = directory.expanduser().resolve() + if not directory.is_dir(): + raise NotADirectoryError(f"Dossier introuvable : {directory}") + return sorted( + ( + path + for path in directory.glob("*.pdf") + if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"} + ), + key=lambda path: path.name.casefold(), + ) + + +def rotate_pdf(path: Path) -> None: + temporary = path.with_name(f".{path.stem}.rotate-{uuid.uuid4().hex}.pdf") + try: + with path.open("rb") as source, temporary.open("wb") as destination: + reader = PdfReader(source) + writer = PdfWriter() + for page in reader.pages: + writer.add_page(page.rotate(180)) + if reader.metadata: + metadata = { + str(key): str(value) + for key, value in reader.metadata.items() + if value is not None + } + writer.add_metadata(metadata) + writer.write(destination) + temporary.replace(path) + finally: + if temporary.exists(): + temporary.unlink() + + +def rotate_all(directory: Path) -> int: + files = copy_pdfs(directory) + for path in files: + rotate_pdf(path) + print(f"Rotated: {path.name}") + if not files: + print("No PDF copies found.") + return len(files) + + +def rename_all(directory: Path) -> list[tuple[Path, Path]]: + directory = directory.expanduser().resolve() + files = copy_pdfs(directory) + width = max(2, len(str(len(files)))) + plan = [ + (source, directory / f"Copie{index:0{width}d}.pdf") + for index, source in enumerate(files, start=1) + ] + staged: list[tuple[Path, Path, Path]] = [] + + try: + for source, destination in plan: + temporary = directory / f".copienator-rename-{uuid.uuid4().hex}.pdf" + source.replace(temporary) + staged.append((source, temporary, destination)) + + completed: list[tuple[Path, Path, Path]] = [] + try: + for source, temporary, destination in staged: + temporary.replace(destination) + completed.append((source, temporary, destination)) + print(f"Renamed: {source.name} -> {destination.name}") + except OSError: + for _source, temporary, destination in completed: + if destination.exists(): + destination.replace(temporary) + raise + except OSError: + for source, temporary, _destination in staged: + if temporary.exists(): + temporary.replace(source) + raise + + if not plan: + print("No PDF copies found.") + return [(source, destination) for source, _temporary, destination in staged] + + +def main() -> None: + parser = argparse.ArgumentParser(description="Prepare scanned PDF copies.") + subparsers = parser.add_subparsers(dest="operation", required=True) + for operation in ("rotate", "rename"): + subparser = subparsers.add_parser(operation) + subparser.add_argument("directory", type=Path) + args = parser.parse_args() + + if args.operation == "rotate": + rotate_all(args.directory) + else: + rename_all(args.directory) + + +if __name__ == "__main__": + main() diff --git a/default_config.py b/default_config.py index 5786d94..bf9cd3c 100644 --- a/default_config.py +++ b/default_config.py @@ -6,6 +6,15 @@ API_KEY = os.environ.get("GEMINI_API_KEY") EXPORT_DIR = Path("Export") IMPORT_DIR = Path("Import") +# Les étapes gestion_classe, ODS et publication sont masquées par défaut. +SHOW_PERSONAL_STEPS = False + +# Chemins utilisés uniquement par les étapes personnelles. +CURRENT_SCORE_ODS_PATH = Path("current_eval.ods") +FINAL_SCORE_ODS_PATH = Path("simple_eval.ods") +FINAL_SCORE_OUTPUT_DIR = Path("Server") / "copies" +FINAL_SCORE_FONT_PATH = None + # Modèle pour des choses très légères MODEL_LITE_ID = "gemini-3.5-flash-lite" diff --git a/enonce_info.py b/enonce_info.py index 5cf4e19..843faa2 100644 --- a/enonce_info.py +++ b/enonce_info.py @@ -8,6 +8,7 @@ import subprocess import tempfile import shutil +from platform_utils import WindowsLabelError, validate_windows_labels from utils import compile_to_pdf def fetch_and_save_sub_text(ex_id, indices, label, text_path): @@ -191,6 +192,7 @@ def process_directory(directory): indexes = data.get('indexes', []) if not indexes: label = f"Ex {current_ex_num}" + validate_windows_labels([label]) f_labels.write(f"{label}\n") fetch_and_save_sub_text(ids, [], label, paths['Text2']) fetch_and_save_sub_sol(ids, [], label, paths['Sol2']) @@ -198,6 +200,7 @@ def process_directory(directory): for item in indexes: suffix = format_indices(item['indices'], problem) label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "") + validate_windows_labels([label]) f_labels.write(f"{label}\n") fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2']) fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2']) @@ -243,6 +246,8 @@ def process_directory(directory): current_ex_num += 1 + except WindowsLabelError: + raise except json.JSONDecodeError: print(f"Error decoding JSON in block: {json_str}") except Exception as e: diff --git a/export.py b/export.py index d24024f..00b0dde 100644 --- a/export.py +++ b/export.py @@ -1,52 +1,36 @@ +import argparse import sys -import os from pathlib import Path from config import EXPORT_DIR +from platform_utils import replace_with_link_or_copy -def process_directory(dir_arg): - # Résolution des chemins - base_dir = Path(dir_arg) - bgnot_dir = base_dir / "BGnot" - sync_dir = EXPORT_DIR / dir_arg - # Création du dossier de destination s'il n'existe pas +def export_directory(base_dir, source_dir_name): + base_dir = Path(base_dir).expanduser().resolve() + source_dir = base_dir / source_dir_name + sync_dir = Path(EXPORT_DIR).expanduser() / base_dir.name sync_dir.mkdir(parents=True, exist_ok=True) - if not bgnot_dir.is_dir(): - print(f"Erreur : le sous-dossier {bgnot_dir} n'existe pas.") + if not source_dir.is_dir(): + print(f"Erreur : le sous-dossier {source_dir} n'existe pas.") return - # Récupération de tous les sous-dossiers - subdirs = [d for d in bgnot_dir.iterdir() if d.is_dir()] - if not subdirs: - return + subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()] + if source_dir_name == "BGnot" and subdirs: + all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs) + if not all_start_with_copie: + subdirs = [directory for directory in subdirs if not directory.name.startswith("Copie")] - # Application des règles de sélection - all_start_with_copie = all(d.name.startswith("Copie") for d in subdirs) - if all_start_with_copie: - chosen_dirs = subdirs - else: - chosen_dirs = [d for d in subdirs if not d.name.startswith("Copie")] - - # Traitement des dossiers sélectionnés - for subdir in chosen_dirs: + for subdir in subdirs: concat_file = subdir / "Concat.pdf" - if not concat_file.is_file(): print(f"Attention : le fichier {concat_file} est introuvable.") continue + destination = sync_dir / f"{subdir.name}.pdf" + method = replace_with_link_or_copy(concat_file, destination, prefer="hardlink") + print(f"Exported: {destination} ({method})") - symlink_path = sync_dir / f"{subdir.name}.pdf" - - # Supprime le lien précédent s'il existe pour éviter une erreur - if symlink_path.is_symlink() or symlink_path.exists(): - symlink_path.unlink() - - # Création du lien symbolique (pointe vers le chemin absolu pour éviter les problèmes) - os.link(concat_file.absolute(), symlink_path) - -import argparse if __name__ == "__main__": parser = argparse.ArgumentParser(description="Move to tablette folder.") @@ -54,21 +38,7 @@ if __name__ == "__main__": parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json") args = parser.parse_args() - root_dir = args.dir - if args.refaire: - base_dir = Path(root_dir) - brnot_dir = base_dir / "BRnot" - sync_dir = EXPORT_DIR / root_dir - sync_dir.mkdir(parents=True, exist_ok=True) - - for f in brnot_dir.iterdir(): - concat_file = f / "Concat.pdf" - if f.is_dir() and concat_file.is_file(): - symlink_path = sync_dir / f"{f.name}.pdf" - if symlink_path.exists(): - symlink_path.unlink() - os.link(concat_file.absolute(), symlink_path) + export_directory(args.dir, "BRnot") sys.exit(0) - else: - process_directory(root_dir) + export_directory(args.dir, "BGnot") diff --git a/gemini_for_enonce.py b/gemini_for_enonce.py index e507887..ed7a94d 100644 --- a/gemini_for_enonce.py +++ b/gemini_for_enonce.py @@ -10,6 +10,7 @@ from typing import List, Union from google import genai from google.genai import types from concurrent.futures import ThreadPoolExecutor +from platform_utils import validate_windows_labels from utils import compile_to_pdf def get_lcp(s1: str, s2: str) -> str: @@ -602,6 +603,7 @@ def process_exam(folder_path: str, restart: bool = False): break labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)] + validate_windows_labels(labels_list) # Save labels and proceed with open(folder / "labels", 'w', encoding='utf-8') as f_labels: diff --git a/gemini_for_labels.py b/gemini_for_labels.py index f9231fc..35daf67 100644 --- a/gemini_for_labels.py +++ b/gemini_for_labels.py @@ -186,7 +186,7 @@ args = parser.parse_args() # input_arg = Path(args.input_path) image_files = [] -from utils import natural_key +from utils import natural_key, read_all_labels for path_str in args.input_paths: input_arg = Path(path_str) @@ -222,8 +222,9 @@ for path_str in args.input_paths: else: print(f"Warning: No variants found for {target_file.stem} in {CUTLEFT_DIR}") -labels_txt = (INPUT_DIR / "labels").read_text() -valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip()) +all_labels = read_all_labels(INPUT_DIR) +labels_txt = "\n".join(all_labels) + "\n" +valid_labels_set = set(all_labels) names_path = (INPUT_DIR / "names") if not os.path.exists(names_path): names_path = Path("names") diff --git a/giving_names.py b/giving_names.py index ca85698..fd14c9c 100644 --- a/giving_names.py +++ b/giving_names.py @@ -1,10 +1,11 @@ -import os -import sys import json +import os import re +import sys +from collections import defaultdict from pathlib import Path -from collections import defaultdict +from platform_utils import replace_with_link_or_copy, safe_filename def main(): if len(sys.argv) < 3: @@ -54,7 +55,7 @@ def main(): elif len(ids) > 1: print(f"ALERT: Name '{name}' assigned to multiple IDs: {', '.join(ids)}") - safe_name = re.sub(r'[<>:"/\\|?*]', '', name).strip() + safe_name = safe_filename(name) for copie_id in ids: path_b = os.path.join(work_dir, f"{bnot_dir}/Copie{copie_id}") @@ -81,9 +82,11 @@ def main(): src_file = os.path.join(source_folder, src_name) dst_link = os.path.join(dest_path, dst_name) try: - if os.path.lexists(dst_link): os.remove(dst_link) - if os.path.exists(src_file): os.symlink(src_file, dst_link) - except Exception as e: + if os.path.exists(src_file): + method = replace_with_link_or_copy(src_file, dst_link, prefer="symlink") + if method == "copy": + print(f"Copied {src_name} for {dest_folder_name} (links unavailable)") + except OSError as e: print(f"Error linking {src_name} for {dest_folder_name}: {e}") # --- 4. Print Unassigned Names --- diff --git a/page_splitter.py b/page_splitter.py index 39571de..343bf1d 100644 --- a/page_splitter.py +++ b/page_splitter.py @@ -1,16 +1,17 @@ -import fitz # PyMuPDF -import tkinter as tk -from tkinter import messagebox -from PIL import Image, ImageTk, ImageDraw -import sys +import glob import os import re -import glob import shutil -import subprocess +import sys +import tkinter as tk +from tkinter import messagebox + +import fitz # PyMuPDF +from PIL import Image, ImageDraw, ImageTk from pypdf import PdfReader, PdfWriter from config import PAGE_SPLITTER_KB +from platform_utils import launch_pdf_arranger # --- Constants --- @@ -169,7 +170,10 @@ class PDFPreviewer: self.current_zoom = 1.0 def start_arranger(self): - subprocess.Popen(["pdf-arranger", self.pdf_path]) + try: + launch_pdf_arranger(self.pdf_path) + except FileNotFoundError as exc: + messagebox.showerror("PDF Arranger", str(exc)) def on_resize(self, event): """ diff --git a/platform_utils.py b/platform_utils.py new file mode 100644 index 0000000..8746e90 --- /dev/null +++ b/platform_utils.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path +from typing import Literal + +WINDOWS_RESERVED_NAMES = { + "CON", + "PRN", + "AUX", + "NUL", + *(f"COM{number}" for number in range(1, 10)), + *(f"LPT{number}" for number in range(1, 10)), +} + + +class WindowsLabelError(ValueError): + pass + + +def windows_filename_problems(value: str) -> list[str]: + problems = [] + forbidden = sorted({character for character in value if character in '<>:"/\\|?*'}) + if forbidden: + problems.append("caractères interdits " + " ".join(repr(char) for char in forbidden)) + if any(ord(character) < 32 for character in value): + problems.append("caractère de contrôle") + if value.endswith((" ", ".")): + problems.append("espace ou point final") + if not value or value in {".", ".."}: + problems.append("nom vide ou réservé") + elif value.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES: + problems.append("nom système réservé") + return problems + + +def validate_windows_labels( + labels: list[str] | tuple[str, ...], platform_name: str | None = None +) -> None: + """Reject labels that cannot be used as filenames on native Windows.""" + if (platform_name or os.name) != "nt": + return + invalid = [] + for label in labels: + problems = windows_filename_problems(label) + if problems: + invalid.append((label, problems)) + if not invalid: + return + details = "\n".join( + f" - {label!r}: {', '.join(problems)}" for label, problems in invalid + ) + raise WindowsLabelError( + "Labels incompatibles avec Windows :\n" + f"{details}\n" + "Modifiez ces labels avant de poursuivre (par exemple, remplacez ':' par ' - ')." + ) + + +def safe_filename(value: str, fallback: str = "Unknown") -> str: + """Return a filename component accepted by both Linux and Windows.""" + cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", value).strip().rstrip(". ") + if not cleaned: + cleaned = fallback + if cleaned.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES: + cleaned = f"_{cleaned}" + return cleaned + + +def open_path(path: str | Path) -> None: + """Open a file with the desktop's default application.""" + target = str(Path(path).expanduser().resolve()) + if os.name == "nt": + os.startfile(target) # type: ignore[attr-defined] + elif sys.platform.startswith("linux"): + subprocess.Popen(["xdg-open", target]) + else: + raise RuntimeError(f"Unsupported platform: {sys.platform}") + + +def launch_pdf_arranger(path: str | Path) -> None: + executable = shutil.which("pdf-arranger") or shutil.which("pdfarranger") + if not executable: + raise FileNotFoundError( + "PDF Arranger is not installed or is not available in PATH." + ) + subprocess.Popen([executable, str(Path(path).expanduser().resolve())]) + + +def replace_with_link_or_copy( + source: str | Path, + destination: str | Path, + *, + prefer: Literal["hardlink", "symlink"] = "hardlink", +) -> str: + """Replace destination with a link, falling back to a regular copy.""" + source_path = Path(source).expanduser().resolve() + destination_path = Path(destination).expanduser() + destination_path.parent.mkdir(parents=True, exist_ok=True) + + if os.path.lexists(destination_path): + destination_path.unlink() + + strategies = (prefer, "symlink" if prefer == "hardlink" else "hardlink") + for strategy in strategies: + try: + if strategy == "hardlink": + os.link(source_path, destination_path) + else: + os.symlink(source_path, destination_path) + return strategy + except (NotImplementedError, OSError): + continue + + shutil.copy2(source_path, destination_path) + return "copy" diff --git a/plotting.py b/plotting.py index eb3b227..72b805a 100644 --- a/plotting.py +++ b/plotting.py @@ -1,14 +1,15 @@ -import sys import json -import threading -import re import queue -import subprocess +import sys +import threading import tkinter as tk -from tkinter import messagebox from pathlib import Path +from tkinter import messagebox + from PIL import Image, ImageDraw, ImageFont, ImageTk +from platform_utils import open_path + print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interro, click for coordinates") # --- Configuration & Globals --- @@ -336,7 +337,7 @@ class ImageViewer: a = self.current_json_path.stem.split('_')[0] + ".pdf" pdf_path = self.current_json_path.with_name(a) print(f"Opening {pdf_path}") - subprocess.Popen(['xdg-open', str(pdf_path.absolute())]) + open_path(pdf_path) def on_open_interro(self, event): if self.is_viewing and self.current_json_path: @@ -347,25 +348,28 @@ class ImageViewer: if local_accent.exists(): pdf_path = str(local_accent) elif local_plain.exists(): - pdf_path = str(local_plain) + pdf_path = local_plain else: - # Fallback to the Interro staging directory - pdf_path = f"/home/sebastien/Prépa/Staging/Interro/{self.base_dir.name}.pdf" + messagebox.showerror( + "PDF not found", + f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.", + ) + return print(f"Opening {pdf_path}") - subprocess.Popen(['xdg-open', pdf_path]) + open_path(pdf_path) def on_open_ori_pdf(self, event): if self.is_viewing and self.current_json_path: new_filename = self.current_json_path.stem.split('_')[0] + ".pdf" pdf_path = self.base_dir / "Copies Originales" / new_filename print(f"Opening {pdf_path}") - subprocess.Popen(['xdg-open', str(pdf_path.absolute())]) + open_path(pdf_path) def on_edit(self, event): if self.is_viewing and self.current_json_path: print(f"Opening {self.current_json_path}") - subprocess.Popen(['xdg-open', str(self.current_json_path.absolute())]) + open_path(self.current_json_path) def on_click(self, event): if not self.is_viewing: return @@ -418,14 +422,11 @@ if __name__ == "__main__": sys.exit(1) files_to_process = sorted(cutleft_dir.glob("*.jpg")) - labels_txt = (base_dir / "labels").read_text() - valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip()) - - try: all_labels = read_all_labels(base_dir) except FileNotFoundError: all_labels = [] + valid_labels_set = set(all_labels) t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels)) t.daemon = True diff --git a/splitting_int.py b/splitting_int.py index 94cf318..99e0ae4 100644 --- a/splitting_int.py +++ b/splitting_int.py @@ -8,6 +8,8 @@ import shutil from pathlib import Path from collections import defaultdict +from utils import read_all_labels + carreau = 1000 // 38 @@ -208,6 +210,8 @@ if __name__ == "__main__": print(f"Error: {input_arg} is not a valid file or directory.") sys.exit(1) + read_all_labels(base_dir) + for pdf_path in pdf_files: json_path = pdf_path.with_suffix(".json") # print("Debug :", json_path) diff --git a/update_ods.py b/update_ods.py index 4b175eb..745e133 100644 --- a/update_ods.py +++ b/update_ods.py @@ -1,15 +1,16 @@ import argparse +import json import os import sys -import json -import ezodf -import re from pathlib import Path -from utils import natural_key, read_all_labels +import ezodf + +from config import CURRENT_SCORE_ODS_PATH +from utils import read_all_labels # Configuration -ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/current_eval.ods" +ODS_PATH = Path(CURRENT_SCORE_ODS_PATH).expanduser() TARGET_DIR_NAME = "A Rendre" def main(): @@ -34,7 +35,7 @@ def main(): print(f"Opening ODS file: {ODS_PATH}...") try: - doc = ezodf.opendoc(ODS_PATH) + doc = ezodf.opendoc(str(ODS_PATH)) except Exception as e: print(f"Failed to open ODS: {e}") sys.exit(1) diff --git a/utils.py b/utils.py index c0023d6..5d05928 100644 --- a/utils.py +++ b/utils.py @@ -1,11 +1,15 @@ import re from pathlib import Path +from platform_utils import validate_windows_labels + def natural_key(text): return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(text))] def read_all_labels(base_dir): - return list(filter(None, (Path(base_dir) / "labels").read_text().splitlines())) + labels = list(filter(None, (Path(base_dir) / "labels").read_text().splitlines())) + validate_windows_labels(labels) + return labels def enonce_total(base_dir): text_dir = Path(base_dir) / 'Text' @@ -25,18 +29,15 @@ def enonce_total(base_dir): import os import shlex +from platform_utils import open_path + def edit_file_and_enter(file): editor = os.environ.get("EDITOR") try: if editor: - subprocess.run(shlex.split(editor) + [str(file)]) + subprocess.run(shlex.split(editor, posix=os.name != "nt") + [str(file)]) else: - if sys.platform.startswith("linux"): - subprocess.run(["xdg-open", str(file)]) - elif sys.platform == "darwin": - subprocess.run(["open", str(file)]) - else: - os.startfile(str(file)) + open_path(file) input("Press ENTER here once you have saved and closed the text file...") except Exception as e: print(f"Error running editor: {e}")