Windows compatibility

This commit is contained in:
2026-08-20 12:01:31 +02:00
parent 7c366a9ca4
commit ac4ab782b2
20 changed files with 634 additions and 134 deletions
+65 -14
View File
@@ -1,7 +1,7 @@
#+title: Script #+title: Script
#+author: Sébastien Miquel #+author: Sébastien Miquel
#+date: 14-03-2026 #+date: 14-03-2026
# Time-stamp: <20-08-26 09:59> # Time-stamp: <20-08-26 11:37>
#+OPTIONS: #+OPTIONS:
* Méta * 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 Cette situation s'améliorera peut-être, mais faciliter l'utilisation
de ce système n'est pas une priorité. 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 ** Requirements
*** Python *** Python
@@ -74,6 +91,34 @@ ou éventuellement, la renseigner directement dans le fichier
Copier `default_config.py` en `config.py`. Éventuellement le modifier. 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 ** Correction d'un paquet de copies
1. Créer un fichier =names= dans le dossier courant, avec les 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) suite)
3. Suivre les instructions suivantes 3. Suivre les instructions suivantes
* Étapes et Script * É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é ** Prétraitement de l'énoncé
Dans le dossier de l'évaluation, mettre les fichiers suivants de l'évaluation : 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=. 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 à Retourne tous les pdf de 180°, si la photocopie a été faite à
l'envers. l'envers.
2. =./rename_to_copie.sh Interro= 2. =python copies_tools.py rename Interro=
change le nom des copies en =Copie{id}.pdf= change le nom des copies en =Copie{id}.pdf=
3. =python page_splitter.py Interro= 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 2. =python correction.py Interro --limit 240= OU
=python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU =python correction.py Interro/Par\ label/Ex\ 2/Group_1.jpg= OU
=python correction.py Interro --overwrite= =python correction.py Interro --overwrite=
=python correction.py Interro --pro-by-label= (needs `labels_for_pro`)
Fais les requêtes de correction à Gemini. Fais les requêtes de correction à Gemini.
@@ -259,20 +308,20 @@ OU
3. =python export.py Interro= (gestion perso) 3. =python export.py Interro= (gestion perso)
Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR= Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR=
(par défaut =Export=). (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. Il faut ensuite annoter les fichiers dans `EXPORT_DIR` avec une
À automatiser, aussi c'est lent… tablette graphique.
** Lecture de la correction manuscrite ** 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 1. =python import.py Interro=
=Import=), puis y copier ou synchroniser les fichiers depuis la tablette.
Une fois les corrections manuelles appliquées aux fichiers Une fois les corrections manuelles appliquées aux fichiers
=Concat.pdf=, il faut enregistrer le fichier annoté au même endroit, =Concat.pdf=, il faut enregistrer le fichier annoté au même endroit,
sous le nom =Concat_annotated.pdf=. sous le nom =Concat_annotated.pdf=.
2. =python reading_annotations.py Interro= 2. =python reading_annotations.py Interro=
@@ -290,8 +339,9 @@ OU
+ un fichier =score.json= qui contient les notes par question + un fichier =score.json= qui contient les notes par question
Si un nom est =Unknown= : renommer à la main le dossier et le fichier dedans. 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_annotations.py --update-score Interro`
- `python reading_grouped_annotations.py --update-score Interro` - `python reading_grouped_annotations.py --update-score Interro`
pour mettre à jour les scores dans les images. pour mettre à jour les scores dans les images.
@@ -309,6 +359,7 @@ OU
+ update the copies from =miqmacs.fr/admin=. + update the copies from =miqmacs.fr/admin=.
6. (gestion perso) Impression d'une copie. Via Evince » print to pdf. 6. (gestion perso) Impression d'une copie. Via Evince » print to pdf.
* Autres
** Recorrection d'une seule copie (peu testé) ** Recorrection d'une seule copie (peu testé)
!! Attention, refaire ne marchera pas si tu fais une annotation non !! Attention, refaire ne marchera pas si tu fais une annotation non
+27 -18
View File
@@ -1,16 +1,29 @@
import argparse import argparse
import math import math
import sys
import os
import shutil import shutil
import sys
from pathlib import Path from pathlib import Path
import pandas as pd import pandas as pd
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
from config import FINAL_SCORE_FONT_PATH, FINAL_SCORE_ODS_PATH, FINAL_SCORE_OUTPUT_DIR
# Configuration constants # Configuration constants
ODS_PATH = "/home/sebastien/Rust/gestion_classe/Staging/simple_eval.ods" ODS_PATH = Path(FINAL_SCORE_ODS_PATH).expanduser()
OUTPUT_DIR = Path("/home/sebastien/Rust/Server/copies") OUTPUT_DIR = Path(FINAL_SCORE_OUTPUT_DIR).expanduser()
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" # Standard Linux font path
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): def get_rounded_score(score):
"""Round score to one decimal place below (floor).""" """Round score to one decimal place below (floor)."""
@@ -20,7 +33,7 @@ def get_rounded_score(score):
except (ValueError, TypeError): except (ValueError, TypeError):
return None return None
def process_images(base_dir): def process_images(base_dir, output_dir):
# 1. Load Data # 1. Load Data
try: try:
# header=None assumes the file starts directly with data. # header=None assumes the file starts directly with data.
@@ -33,7 +46,7 @@ def process_images(base_dir):
sys.exit(1) sys.exit(1)
# 2. Prepare Output Directory # 2. Prepare Output Directory
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
# 3. Iterate Files # 3. Iterate Files
# Structure: Dir/A Rendre/{name}/{name}.jpg # Structure: Dir/A Rendre/{name}/{name}.jpg
@@ -68,12 +81,7 @@ def process_images(base_dir):
# Dynamic font size (15% of image height) # Dynamic font size (15% of image height)
font_size = int(width * 0.08) font_size = int(width * 0.08)
try: font = score_font(font_size)
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}")
text = str(score) text = str(score)
@@ -90,7 +98,7 @@ def process_images(base_dir):
draw.text((x, y), text, fill=(255, 0, 0), font=font) draw.text((x, y), text, fill=(255, 0, 0), font=font)
# Save # Save
save_path = OUTPUT_DIR / f"{student_name}.jpg" save_path = output_dir / f"{student_name}.jpg"
img.save(save_path) img.save(save_path)
print(f"Processed: {student_name} -> {score}") print(f"Processed: {student_name} -> {score}")
@@ -99,7 +107,7 @@ def process_images(base_dir):
for pdf_path in sorted(search_path.glob("*/*.pdf")): for pdf_path in sorted(search_path.glob("*/*.pdf")):
student_name = pdf_path.stem # Filename without extension 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)) shutil.copy(str(pdf_path), str(save_path))
@@ -111,6 +119,7 @@ if __name__ == "__main__":
args = parser.parse_args() args = parser.parse_args()
OUTPUT_DIR = OUTPUT_DIR / args.dir base_dir = args.dir.expanduser().resolve()
OUTPUT_DIR.mkdir(parents=True, exist_ok=True) output_dir = OUTPUT_DIR / base_dir.name
process_images(args.dir) output_dir.mkdir(parents=True, exist_ok=True)
process_images(base_dir, output_dir)
+2 -2
View File
@@ -103,6 +103,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
root_dir = args.input_path root_dir = args.input_path
validated_labels = utils.read_all_labels(root_dir)
results = annotating.make_dictionary(root_dir) results = annotating.make_dictionary(root_dir)
label_groups = os.path.join(root_dir, "label_groups") label_groups = os.path.join(root_dir, "label_groups")
all_labels = os.path.join(root_dir, "labels") all_labels = os.path.join(root_dir, "labels")
@@ -113,8 +114,7 @@ def main():
print(f"Error: {all_labels} not found.") print(f"Error: {all_labels} not found.")
sys.exit(1) sys.exit(1)
with open(all_labels, 'r') as f: lines = validated_labels
lines = [l.strip() for l in f if l.strip()]
groups = {} groups = {}
for line in lines: for line in lines:
+4
View File
@@ -13,6 +13,7 @@ matplotlib.use('Agg')
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
import annotating import annotating
import utils
from annotating import MARGIN_LEFT, ANNOT_WIDTH from annotating import MARGIN_LEFT, ANNOT_WIDTH
# Global lock for Matplotlib/Latex rendering to prevent race conditions # Global lock for Matplotlib/Latex rendering to prevent race conditions
@@ -230,6 +231,9 @@ if __name__ == "__main__":
else: else:
root_dir = input_path root_dir = input_path
if os.path.exists(os.path.join(root_dir, "labels")):
utils.read_all_labels(root_dir)
if not args.refaire: if not args.refaire:
results = annotating.make_dictionary(root_dir) results = annotating.make_dictionary(root_dir)
+59
View File
@@ -9,6 +9,9 @@ from pathlib import Path
from tkinter import filedialog, messagebox, ttk from tkinter import filedialog, messagebox, ttk
from typing import Any from typing import Any
from platform_utils import WindowsLabelError, validate_windows_labels
from .diagnostics import collect_diagnostics
from .runner import ProcessRunner from .runner import ProcessRunner
from .state import StateStore from .state import StateStore
from .workflow import ( from .workflow import (
@@ -42,6 +45,7 @@ class CopienatorApp(tk.Tk):
) -> None: ) -> None:
super().__init__() super().__init__()
self.repository = repository.resolve() self.repository = repository.resolve()
self.show_personal_steps = show_personal_steps
self.steps = build_workflow(show_personal_steps) self.steps = build_workflow(show_personal_steps)
self.step_by_id = {step.id: step for step in self.steps} self.step_by_id = {step.id: step for step in self.steps}
self.state_store = StateStore() self.state_store = StateStore()
@@ -93,6 +97,7 @@ class CopienatorApp(tk.Tk):
path_entry.bind("<Return>", lambda _event: self._load_evaluation()) path_entry.bind("<Return>", 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="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="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.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( 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 = ttk.Label(self, textvariable=self.info_var, anchor="w", relief="sunken", padding=(6, 3))
status.grid(row=3, column=0, sticky="ew") 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: def _browse_evaluation(self) -> None:
selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository) selected = filedialog.askdirectory(initialdir=self.evaluation_var.get() or self.repository)
if selected: if selected:
@@ -482,6 +528,19 @@ class CopienatorApp(tk.Tk):
if step.is_manual: if step.is_manual:
self._mark_step("success") self._mark_step("success")
return 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(): if not self._validate_arguments():
return return
missing = self._missing_requirements(step) missing = self._missing_requirements(step)
+125
View File
@@ -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 lenvironnement",
)
)
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
+25 -3
View File
@@ -30,6 +30,7 @@ class CommandVariant:
program: str | None program: str | None
kind: str = "python" # python, shell, external, manual kind: str = "python" # python, shell, external, manual
fixed_args: tuple[str, ...] = () fixed_args: tuple[str, ...] = ()
fixed_args_before_positionals: bool = False
dangerous: bool = False dangerous: bool = False
@@ -116,7 +117,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies", "Prétraitement des copies",
"Retourner toutes les copies", "Retourner toutes les copies",
"Rotation facultative de 180° lorsque les scans sont à lenvers.", "Rotation facultative de 180° lorsque les scans sont à lenvers.",
(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"),), arguments=(arg_target("Dossier de l’évaluation"),),
optional=True, optional=True,
), ),
@@ -125,7 +135,16 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
"Prétraitement des copies", "Prétraitement des copies",
"Renommer les PDF en CopieXX.pdf", "Renommer les PDF en CopieXX.pdf",
"Renomme les PDF du dossier d’évaluation dans leur ordre courant.", "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"),), arguments=(arg_target("Dossier de l’évaluation"),),
), ),
StepDefinition( StepDefinition(
@@ -464,8 +483,11 @@ def build_command(
elif spec.flag: elif spec.flag:
options.extend((spec.flag, rendered)) options.extend((spec.flag, rendered))
if variant.fixed_args_before_positionals:
command.extend(variant.fixed_args)
command.extend(positionals) command.extend(positionals)
command.extend(variant.fixed_args) if not variant.fixed_args_before_positionals:
command.extend(variant.fixed_args)
command.extend(options) command.extend(options)
if extra_arguments.strip(): if extra_arguments.strip():
command.extend(shlex.split(extra_arguments, posix=os.name != "nt")) command.extend(shlex.split(extra_arguments, posix=os.name != "nt"))
+109
View File
@@ -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()
+9
View File
@@ -6,6 +6,15 @@ API_KEY = os.environ.get("GEMINI_API_KEY")
EXPORT_DIR = Path("Export") EXPORT_DIR = Path("Export")
IMPORT_DIR = Path("Import") 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 # Modèle pour des choses très légères
MODEL_LITE_ID = "gemini-3.5-flash-lite" MODEL_LITE_ID = "gemini-3.5-flash-lite"
+5
View File
@@ -8,6 +8,7 @@ import subprocess
import tempfile import tempfile
import shutil import shutil
from platform_utils import WindowsLabelError, validate_windows_labels
from utils import compile_to_pdf from utils import compile_to_pdf
def fetch_and_save_sub_text(ex_id, indices, label, text_path): def fetch_and_save_sub_text(ex_id, indices, label, text_path):
@@ -191,6 +192,7 @@ def process_directory(directory):
indexes = data.get('indexes', []) indexes = data.get('indexes', [])
if not indexes: if not indexes:
label = f"Ex {current_ex_num}" label = f"Ex {current_ex_num}"
validate_windows_labels([label])
f_labels.write(f"{label}\n") f_labels.write(f"{label}\n")
fetch_and_save_sub_text(ids, [], label, paths['Text2']) fetch_and_save_sub_text(ids, [], label, paths['Text2'])
fetch_and_save_sub_sol(ids, [], label, paths['Sol2']) fetch_and_save_sub_sol(ids, [], label, paths['Sol2'])
@@ -198,6 +200,7 @@ def process_directory(directory):
for item in indexes: for item in indexes:
suffix = format_indices(item['indices'], problem) suffix = format_indices(item['indices'], problem)
label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "") label = f"Ex {current_ex_num}" + (f" : {suffix}" if suffix else "")
validate_windows_labels([label])
f_labels.write(f"{label}\n") f_labels.write(f"{label}\n")
fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2']) fetch_and_save_sub_text(ids, item['indices'], label, paths['Text2'])
fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2']) fetch_and_save_sub_sol(ids, item['indices'], label, paths['Sol2'])
@@ -243,6 +246,8 @@ def process_directory(directory):
current_ex_num += 1 current_ex_num += 1
except WindowsLabelError:
raise
except json.JSONDecodeError: except json.JSONDecodeError:
print(f"Error decoding JSON in block: {json_str}") print(f"Error decoding JSON in block: {json_str}")
except Exception as e: except Exception as e:
+19 -49
View File
@@ -1,52 +1,36 @@
import argparse
import sys import sys
import os
from pathlib import Path from pathlib import Path
from config import EXPORT_DIR 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) sync_dir.mkdir(parents=True, exist_ok=True)
if not bgnot_dir.is_dir(): if not source_dir.is_dir():
print(f"Erreur : le sous-dossier {bgnot_dir} n'existe pas.") print(f"Erreur : le sous-dossier {source_dir} n'existe pas.")
return return
# Récupération de tous les sous-dossiers subdirs = [directory for directory in source_dir.iterdir() if directory.is_dir()]
subdirs = [d for d in bgnot_dir.iterdir() if d.is_dir()] if source_dir_name == "BGnot" and subdirs:
if not subdirs: all_start_with_copie = all(directory.name.startswith("Copie") for directory in subdirs)
return 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 for subdir in subdirs:
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:
concat_file = subdir / "Concat.pdf" concat_file = subdir / "Concat.pdf"
if not concat_file.is_file(): if not concat_file.is_file():
print(f"Attention : le fichier {concat_file} est introuvable.") print(f"Attention : le fichier {concat_file} est introuvable.")
continue 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__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Move to tablette folder.") 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") parser.add_argument("--refaire", action="store_true", help="Process only copies/labels defined in refaire.json")
args = parser.parse_args() args = parser.parse_args()
root_dir = args.dir
if args.refaire: if args.refaire:
base_dir = Path(root_dir) export_directory(args.dir, "BRnot")
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)
sys.exit(0) sys.exit(0)
else: export_directory(args.dir, "BGnot")
process_directory(root_dir)
+2
View File
@@ -10,6 +10,7 @@ from typing import List, Union
from google import genai from google import genai
from google.genai import types from google.genai import types
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from platform_utils import validate_windows_labels
from utils import compile_to_pdf from utils import compile_to_pdf
def get_lcp(s1: str, s2: str) -> str: def get_lcp(s1: str, s2: str) -> str:
@@ -602,6 +603,7 @@ def process_exam(folder_path: str, restart: bool = False):
break break
labels_list = [item.label for group in grouped_items for item in group if isinstance(item, QuestionItem)] 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 # Save labels and proceed
with open(folder / "labels", 'w', encoding='utf-8') as f_labels: with open(folder / "labels", 'w', encoding='utf-8') as f_labels:
+4 -3
View File
@@ -186,7 +186,7 @@ args = parser.parse_args()
# input_arg = Path(args.input_path) # input_arg = Path(args.input_path)
image_files = [] image_files = []
from utils import natural_key from utils import natural_key, read_all_labels
for path_str in args.input_paths: for path_str in args.input_paths:
input_arg = Path(path_str) input_arg = Path(path_str)
@@ -222,8 +222,9 @@ for path_str in args.input_paths:
else: else:
print(f"Warning: No variants found for {target_file.stem} in {CUTLEFT_DIR}") print(f"Warning: No variants found for {target_file.stem} in {CUTLEFT_DIR}")
labels_txt = (INPUT_DIR / "labels").read_text() all_labels = read_all_labels(INPUT_DIR)
valid_labels_set = set(line.strip() for line in labels_txt.splitlines() if line.strip()) labels_txt = "\n".join(all_labels) + "\n"
valid_labels_set = set(all_labels)
names_path = (INPUT_DIR / "names") names_path = (INPUT_DIR / "names")
if not os.path.exists(names_path): if not os.path.exists(names_path):
names_path = Path("names") names_path = Path("names")
+10 -7
View File
@@ -1,10 +1,11 @@
import os
import sys
import json import json
import os
import re import re
import sys
from collections import defaultdict
from pathlib import Path from pathlib import Path
from collections import defaultdict from platform_utils import replace_with_link_or_copy, safe_filename
def main(): def main():
if len(sys.argv) < 3: if len(sys.argv) < 3:
@@ -54,7 +55,7 @@ def main():
elif len(ids) > 1: elif len(ids) > 1:
print(f"ALERT: Name '{name}' assigned to multiple IDs: {', '.join(ids)}") 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: for copie_id in ids:
path_b = os.path.join(work_dir, f"{bnot_dir}/Copie{copie_id}") 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) src_file = os.path.join(source_folder, src_name)
dst_link = os.path.join(dest_path, dst_name) dst_link = os.path.join(dest_path, dst_name)
try: try:
if os.path.lexists(dst_link): os.remove(dst_link) if os.path.exists(src_file):
if os.path.exists(src_file): os.symlink(src_file, dst_link) method = replace_with_link_or_copy(src_file, dst_link, prefer="symlink")
except Exception as e: 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}") print(f"Error linking {src_name} for {dest_folder_name}: {e}")
# --- 4. Print Unassigned Names --- # --- 4. Print Unassigned Names ---
+12 -8
View File
@@ -1,16 +1,17 @@
import fitz # PyMuPDF import glob
import tkinter as tk
from tkinter import messagebox
from PIL import Image, ImageTk, ImageDraw
import sys
import os import os
import re import re
import glob
import shutil 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 pypdf import PdfReader, PdfWriter
from config import PAGE_SPLITTER_KB from config import PAGE_SPLITTER_KB
from platform_utils import launch_pdf_arranger
# --- Constants --- # --- Constants ---
@@ -169,7 +170,10 @@ class PDFPreviewer:
self.current_zoom = 1.0 self.current_zoom = 1.0
def start_arranger(self): 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): def on_resize(self, event):
""" """
+120
View File
@@ -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"
+17 -16
View File
@@ -1,14 +1,15 @@
import sys
import json import json
import threading
import re
import queue import queue
import subprocess import sys
import threading
import tkinter as tk import tkinter as tk
from tkinter import messagebox
from pathlib import Path from pathlib import Path
from tkinter import messagebox
from PIL import Image, ImageDraw, ImageFont, ImageTk 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") print("o to open pdf, O original pdf, e to emacs part, p to go back, i to interro, click for coordinates")
# --- Configuration & Globals --- # --- Configuration & Globals ---
@@ -336,7 +337,7 @@ class ImageViewer:
a = self.current_json_path.stem.split('_')[0] + ".pdf" a = self.current_json_path.stem.split('_')[0] + ".pdf"
pdf_path = self.current_json_path.with_name(a) pdf_path = self.current_json_path.with_name(a)
print(f"Opening {pdf_path}") print(f"Opening {pdf_path}")
subprocess.Popen(['xdg-open', str(pdf_path.absolute())]) open_path(pdf_path)
def on_open_interro(self, event): def on_open_interro(self, event):
if self.is_viewing and self.current_json_path: if self.is_viewing and self.current_json_path:
@@ -347,25 +348,28 @@ class ImageViewer:
if local_accent.exists(): if local_accent.exists():
pdf_path = str(local_accent) pdf_path = str(local_accent)
elif local_plain.exists(): elif local_plain.exists():
pdf_path = str(local_plain) pdf_path = local_plain
else: else:
# Fallback to the Interro staging directory messagebox.showerror(
pdf_path = f"/home/sebastien/Prépa/Staging/Interro/{self.base_dir.name}.pdf" "PDF not found",
f"Neither {local_accent.name} nor {local_plain.name} exists in {self.base_dir}.",
)
return
print(f"Opening {pdf_path}") print(f"Opening {pdf_path}")
subprocess.Popen(['xdg-open', pdf_path]) open_path(pdf_path)
def on_open_ori_pdf(self, event): def on_open_ori_pdf(self, event):
if self.is_viewing and self.current_json_path: if self.is_viewing and self.current_json_path:
new_filename = self.current_json_path.stem.split('_')[0] + ".pdf" new_filename = self.current_json_path.stem.split('_')[0] + ".pdf"
pdf_path = self.base_dir / "Copies Originales" / new_filename pdf_path = self.base_dir / "Copies Originales" / new_filename
print(f"Opening {pdf_path}") print(f"Opening {pdf_path}")
subprocess.Popen(['xdg-open', str(pdf_path.absolute())]) open_path(pdf_path)
def on_edit(self, event): def on_edit(self, event):
if self.is_viewing and self.current_json_path: if self.is_viewing and self.current_json_path:
print(f"Opening {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): def on_click(self, event):
if not self.is_viewing: return if not self.is_viewing: return
@@ -418,14 +422,11 @@ if __name__ == "__main__":
sys.exit(1) sys.exit(1)
files_to_process = sorted(cutleft_dir.glob("*.jpg")) 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: try:
all_labels = read_all_labels(base_dir) all_labels = read_all_labels(base_dir)
except FileNotFoundError: except FileNotFoundError:
all_labels = [] all_labels = []
valid_labels_set = set(all_labels)
t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels)) t = threading.Thread(target=worker_thread, args=(base_dir, files_to_process, all_labels))
t.daemon = True t.daemon = True
+4
View File
@@ -8,6 +8,8 @@ import shutil
from pathlib import Path from pathlib import Path
from collections import defaultdict from collections import defaultdict
from utils import read_all_labels
carreau = 1000 // 38 carreau = 1000 // 38
@@ -208,6 +210,8 @@ if __name__ == "__main__":
print(f"Error: {input_arg} is not a valid file or directory.") print(f"Error: {input_arg} is not a valid file or directory.")
sys.exit(1) sys.exit(1)
read_all_labels(base_dir)
for pdf_path in pdf_files: for pdf_path in pdf_files:
json_path = pdf_path.with_suffix(".json") json_path = pdf_path.with_suffix(".json")
# print("Debug :", json_path) # print("Debug :", json_path)
+7 -6
View File
@@ -1,15 +1,16 @@
import argparse import argparse
import json
import os import os
import sys import sys
import json
import ezodf
import re
from pathlib import Path 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 # 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" TARGET_DIR_NAME = "A Rendre"
def main(): def main():
@@ -34,7 +35,7 @@ def main():
print(f"Opening ODS file: {ODS_PATH}...") print(f"Opening ODS file: {ODS_PATH}...")
try: try:
doc = ezodf.opendoc(ODS_PATH) doc = ezodf.opendoc(str(ODS_PATH))
except Exception as e: except Exception as e:
print(f"Failed to open ODS: {e}") print(f"Failed to open ODS: {e}")
sys.exit(1) sys.exit(1)
+9 -8
View File
@@ -1,11 +1,15 @@
import re import re
from pathlib import Path from pathlib import Path
from platform_utils import validate_windows_labels
def natural_key(text): def natural_key(text):
return [int(c) if c.isdigit() else c.lower() for c in re.split(r'(\d+)', str(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): 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): def enonce_total(base_dir):
text_dir = Path(base_dir) / 'Text' text_dir = Path(base_dir) / 'Text'
@@ -25,18 +29,15 @@ def enonce_total(base_dir):
import os import os
import shlex import shlex
from platform_utils import open_path
def edit_file_and_enter(file): def edit_file_and_enter(file):
editor = os.environ.get("EDITOR") editor = os.environ.get("EDITOR")
try: try:
if editor: if editor:
subprocess.run(shlex.split(editor) + [str(file)]) subprocess.run(shlex.split(editor, posix=os.name != "nt") + [str(file)])
else: else:
if sys.platform.startswith("linux"): open_path(file)
subprocess.run(["xdg-open", str(file)])
elif sys.platform == "darwin":
subprocess.run(["open", str(file)])
else:
os.startfile(str(file))
input("Press ENTER here once you have saved and closed the text file...") input("Press ENTER here once you have saved and closed the text file...")
except Exception as e: except Exception as e:
print(f"Error running editor: {e}") print(f"Error running editor: {e}")