1679 lines
79 KiB
Python
1679 lines
79 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import queue
|
||
import re
|
||
import shutil
|
||
import tkinter as tk
|
||
from dataclasses import replace
|
||
from pathlib import Path
|
||
from tkinter import filedialog, messagebox, ttk
|
||
from typing import Any
|
||
|
||
from copienator import CliError, EvaluationWorkspace, ExitCode, atomic_write_json
|
||
from copienator.copy_errors import copy_errors
|
||
from copienator.filesystem import staged_files
|
||
from copienator.platform import (
|
||
WindowsLabelError,
|
||
add_platform_executable_paths,
|
||
open_path,
|
||
validate_windows_labels,
|
||
)
|
||
|
||
from .diagnostics import collect_diagnostics
|
||
from .batch_monitor import BatchMonitor
|
||
from .notifications import notify_desktop
|
||
from .manual_resolution import ManualResolutionPanel
|
||
from .refaire import SECTION as REFAIRE_SECTION
|
||
from .refaire import (
|
||
RefaireSelection,
|
||
available_copies,
|
||
load_selection,
|
||
resolve_layout,
|
||
validate_selection,
|
||
)
|
||
from .refaire_sessions import RESTART_WARNING, begin_pass
|
||
from .runner import ProcessRunner
|
||
from .state import StateStore
|
||
from .workflow import (
|
||
CommandVariant,
|
||
StepDefinition,
|
||
build_command,
|
||
build_workflow,
|
||
command_display,
|
||
evaluation_argument,
|
||
value_for_default,
|
||
)
|
||
|
||
STATUS_LABELS = {
|
||
"ready": "Prête",
|
||
"running": "En cours",
|
||
"success": "Réussie",
|
||
"partial": "Partielle",
|
||
"failed": "Échouée",
|
||
"interrupted": "Interrompue",
|
||
"skipped": "Ignorée",
|
||
"stale": "À revalider",
|
||
"detected": "Détectée",
|
||
}
|
||
|
||
DEFAULT_HTTPS_PROXY = "http://10.0.0.1:3128"
|
||
PERSONAL_INTERRO_SOURCE = Path("/home/sebastien/Prépa/Staging/Interro")
|
||
ANNOTATION_DIRECTORIES = ("BGnot", "Bnot", "Anot")
|
||
ANNOTATION_VARIANT_DIRECTORIES = {
|
||
"simple": "Anot",
|
||
"checks": "Bnot",
|
||
"grouped": "BGnot",
|
||
}
|
||
|
||
|
||
def get_personal_interro_files(
|
||
evaluation: Path, source_directory: Path = PERSONAL_INTERRO_SOURCE
|
||
) -> tuple[Path, Path, Path]:
|
||
"""Copy an Interro project's statement sources into the evaluation."""
|
||
match = re.fullmatch(r"Interro(\d+)", evaluation.name)
|
||
if match is None:
|
||
raise ValueError("Le dossier d’évaluation doit s’appeler Interro{id}, avec un identifiant numérique.")
|
||
|
||
project_name = evaluation.name
|
||
source_to_destination = (
|
||
(source_directory / f"{project_name}.pdf", "enonce.pdf"),
|
||
(source_directory / f"{project_name}.tex", "enonce.tex"),
|
||
(source_directory / f"{project_name}c.tex", "correction.tex"),
|
||
)
|
||
missing = [source.name for source, _destination in source_to_destination if not source.is_file()]
|
||
if missing:
|
||
raise FileNotFoundError(
|
||
"Fichier(s) source introuvable(s) dans "
|
||
f"{source_directory} : {', '.join(missing)}"
|
||
)
|
||
|
||
with staged_files(evaluation) as staging:
|
||
for source, destination_name in source_to_destination:
|
||
shutil.copy2(source, staging / destination_name)
|
||
return tuple(evaluation / name for _source, name in source_to_destination)
|
||
|
||
|
||
class Tooltip:
|
||
"""Small delayed tooltip for Tk and ttk widgets."""
|
||
|
||
def __init__(self, widget: tk.Widget, text: str, delay_ms: int = 450) -> None:
|
||
self.widget = widget
|
||
self.text = text
|
||
self.delay_ms = delay_ms
|
||
self._after_id: str | None = None
|
||
self.window: tk.Toplevel | None = None
|
||
widget.bind("<Enter>", self._schedule, add="+")
|
||
widget.bind("<Leave>", self.hide, add="+")
|
||
widget.bind("<ButtonPress>", self.hide, add="+")
|
||
|
||
def _schedule(self, _event: tk.Event | None = None) -> None:
|
||
self.hide()
|
||
self._after_id = self.widget.after(self.delay_ms, self.show)
|
||
|
||
def show(self) -> None:
|
||
self._after_id = None
|
||
if self.window is not None or not self.widget.winfo_exists():
|
||
return
|
||
self.window = tk.Toplevel(self.widget)
|
||
self.window.wm_overrideredirect(True)
|
||
self.window.attributes("-topmost", True)
|
||
x = self.widget.winfo_rootx() + 12
|
||
y = self.widget.winfo_rooty() + self.widget.winfo_height() + 6
|
||
self.window.wm_geometry(f"+{x}+{y}")
|
||
tk.Label(
|
||
self.window,
|
||
text=self.text,
|
||
justify="left",
|
||
wraplength=430,
|
||
background="#fffbd8",
|
||
foreground="#202020",
|
||
relief="solid",
|
||
borderwidth=1,
|
||
padx=8,
|
||
pady=5,
|
||
).pack()
|
||
|
||
def hide(self, _event: tk.Event | None = None) -> None:
|
||
if self._after_id is not None:
|
||
try:
|
||
self.widget.after_cancel(self._after_id)
|
||
except tk.TclError:
|
||
pass
|
||
self._after_id = None
|
||
if self.window is not None:
|
||
self.window.destroy()
|
||
self.window = None
|
||
|
||
|
||
def attach_tooltip(widget: tk.Widget, text: str) -> Tooltip:
|
||
tooltip = Tooltip(widget, text)
|
||
# Keep the tooltip easy to inspect and alive for as long as its widget.
|
||
widget._copienator_tooltip = tooltip # type: ignore[attr-defined]
|
||
return tooltip
|
||
|
||
|
||
def copy_pdf_paths(evaluation: Path) -> list[Path]:
|
||
"""List the most relevant version of each scanned copy."""
|
||
locations = (evaluation / "Copies", evaluation, evaluation / "Copies Originales")
|
||
for location in locations:
|
||
if not location.is_dir():
|
||
continue
|
||
copies = sorted(
|
||
(
|
||
path
|
||
for path in location.glob("*.pdf")
|
||
if path.name.casefold() not in {"enonce.pdf", "énoncé.pdf"}
|
||
),
|
||
key=lambda path: path.name.casefold(),
|
||
)
|
||
if copies:
|
||
return copies
|
||
return []
|
||
|
||
|
||
def detected_annotation_directories(evaluation: Path) -> tuple[str, ...]:
|
||
return tuple(
|
||
name for name in ANNOTATION_DIRECTORIES if (evaluation / name).is_dir()
|
||
)
|
||
|
||
|
||
def plotting_shortcut_lines() -> list[str]:
|
||
try:
|
||
from copienator.configuration import PLOTTING_KB
|
||
except (ImportError, AttributeError):
|
||
from default_config import PLOTTING_KB
|
||
|
||
labels = (
|
||
("OK", "valider et passer à la suivante"),
|
||
("previous", "revenir à la précédente"),
|
||
("edit", "éditer le fichier JSON"),
|
||
("open pdf", "ouvrir la copie traitée"),
|
||
("open original pdf", "ouvrir la copie originale"),
|
||
("open eval", "ouvrir l’énoncé"),
|
||
)
|
||
display_names = {"<Return>": "Entrée", "<Escape>": "Échap"}
|
||
lines = [
|
||
f"{display_names.get(PLOTTING_KB[action], PLOTTING_KB[action])} : {description}"
|
||
for action, description in labels
|
||
]
|
||
lines.append("Échap : fermer la fenêtre")
|
||
return lines
|
||
|
||
|
||
def build_runner_environment(
|
||
base: dict[str, str], api_key: str, proxy: str, use_proxy: bool
|
||
) -> dict[str, str]:
|
||
environment = add_platform_executable_paths(base)
|
||
environment["PYTHONUNBUFFERED"] = "1"
|
||
environment["PYTHONIOENCODING"] = "utf-8"
|
||
if api_key.strip():
|
||
environment["GEMINI_API_KEY"] = api_key.strip()
|
||
environment.pop("HTTPS_PROXY", None)
|
||
environment.pop("https_proxy", None)
|
||
if use_proxy and proxy.strip():
|
||
environment["HTTPS_PROXY"] = proxy.strip()
|
||
return environment
|
||
|
||
|
||
def process_status(return_code: int, interrupted: bool = False) -> str:
|
||
if interrupted or return_code == ExitCode.INTERRUPTED:
|
||
return "interrupted"
|
||
if return_code == ExitCode.SUCCESS:
|
||
return "success"
|
||
if return_code == ExitCode.PARTIAL:
|
||
return "partial"
|
||
return "failed"
|
||
|
||
|
||
def has_manual_conflicts(path: Path) -> bool:
|
||
"""Return whether a manual-resolution file contains an instruction."""
|
||
try:
|
||
lines = path.read_text(encoding="utf-8").splitlines()
|
||
except FileNotFoundError:
|
||
return False
|
||
except (OSError, UnicodeError):
|
||
# If the file cannot be inspected, keep the step visible rather than
|
||
# silently claiming that there is nothing to resolve.
|
||
return True
|
||
return any(line.strip() and not line.lstrip().startswith("###") for line in lines)
|
||
|
||
|
||
class CopienatorApp(tk.Tk):
|
||
def __init__(
|
||
self,
|
||
repository: Path,
|
||
show_personal_steps: bool,
|
||
initial_evaluation: Path | None = None,
|
||
) -> 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()
|
||
self.runner = ProcessRunner()
|
||
self.active_step_id: str | None = None
|
||
self.current_step: StepDefinition | None = None
|
||
self.arg_vars: dict[str, tk.Variable] = {}
|
||
self.copy_paths: dict[str, Path] = {}
|
||
self._rendering = False
|
||
self.refaire_panel: RefaireSelection | None = None
|
||
self.pending_refaire_commands: list[list[str]] = []
|
||
self.refaire_command_index = 0
|
||
|
||
self.title("Copienator — assistant de correction")
|
||
self.geometry("1180x820")
|
||
self.minsize(900, 640)
|
||
self.protocol("WM_DELETE_WINDOW", self._on_close)
|
||
|
||
self.evaluation_var = tk.StringVar()
|
||
self.api_key_var = tk.StringVar(value=os.environ.get("GEMINI_API_KEY", ""))
|
||
self.proxy_var = tk.StringVar(value=DEFAULT_HTTPS_PROXY)
|
||
self.use_proxy_var = tk.BooleanVar(value=False)
|
||
self.verbose_var = tk.BooleanVar(value=False)
|
||
self.copy_var = tk.StringVar()
|
||
self.variant_var = tk.StringVar()
|
||
self.extra_var = tk.StringVar()
|
||
self.command_var = tk.StringVar(value="Sélectionnez une étape.")
|
||
self.info_var = tk.StringVar(value="Choisissez un dossier d’évaluation.")
|
||
self.batch_watch_status = tk.StringVar(value="Vérification automatique arrêtée.")
|
||
self.batch_monitor = BatchMonitor(
|
||
self, self._batch_results_ready, self._batch_watch_status_changed, self._append_console
|
||
)
|
||
|
||
self._build_ui(show_personal_steps)
|
||
self.extra_var.trace_add("write", lambda *_args: self._update_command_preview())
|
||
self.verbose_var.trace_add("write", lambda *_args: self._update_command_preview())
|
||
self.after(60, self._poll_runner)
|
||
|
||
if initial_evaluation and initial_evaluation.is_dir():
|
||
self.evaluation_var.set(str(initial_evaluation.resolve()))
|
||
self._load_evaluation()
|
||
else:
|
||
self._populate_tree()
|
||
|
||
@property
|
||
def evaluation(self) -> Path | None:
|
||
value = self.evaluation_var.get().strip()
|
||
return Path(value).expanduser().resolve() if value else None
|
||
|
||
def _build_ui(self, show_personal_steps: bool) -> None:
|
||
self.columnconfigure(0, weight=1)
|
||
self.rowconfigure(1, weight=3)
|
||
self.rowconfigure(2, weight=2)
|
||
|
||
top = ttk.Frame(self, padding=(10, 8))
|
||
top.grid(row=0, column=0, sticky="ew")
|
||
top.columnconfigure(5, weight=1)
|
||
ttk.Button(top, text="Charger", command=self._load_evaluation).grid(
|
||
row=0, column=0, padx=(0, 8)
|
||
)
|
||
ttk.Label(top, text="Évaluation").grid(row=0, column=1, sticky="w", padx=(0, 8))
|
||
self.evaluation_entry = ttk.Entry(top, textvariable=self.evaluation_var, width=42)
|
||
self.evaluation_entry.grid(row=0, column=2, sticky="w")
|
||
self.evaluation_entry.bind("<Return>", lambda _event: self._load_evaluation())
|
||
self.open_evaluation_button = ttk.Button(
|
||
top, text="Ouvrir le dossier", command=self._open_evaluation_folder
|
||
)
|
||
self.open_evaluation_button.grid(row=0, column=3, padx=(6, 0))
|
||
ttk.Button(top, text="Parcourir…", command=self._browse_evaluation).grid(
|
||
row=0, column=4, padx=6
|
||
)
|
||
ttk.Button(top, text="Diagnostic…", command=self._show_diagnostics).grid(row=0, column=6, 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(
|
||
row=1, column=1, sticky="w", pady=(7, 0)
|
||
)
|
||
environment = ttk.Frame(top)
|
||
environment.grid(row=1, column=2, columnspan=5, sticky="e", pady=(7, 0))
|
||
proxy_toggle = ttk.Checkbutton(
|
||
environment,
|
||
text="Utiliser le proxy HTTPS",
|
||
variable=self.use_proxy_var,
|
||
command=self._toggle_proxy,
|
||
)
|
||
proxy_toggle.pack(side="left", padx=(0, 6))
|
||
attach_tooltip(
|
||
proxy_toggle,
|
||
"Active le proxy HTTPS configuré dans le champ voisin pour les commandes lancées par le GUI.",
|
||
)
|
||
self.proxy_entry = ttk.Entry(environment, textvariable=self.proxy_var, width=28, state="disabled")
|
||
self.proxy_entry.pack(side="left")
|
||
attach_tooltip(
|
||
self.proxy_entry,
|
||
"Adresse du proxy HTTPS transmise aux commandes lorsque l’option de proxy est activée.",
|
||
)
|
||
verbose_toggle = ttk.Checkbutton(
|
||
environment,
|
||
text="Détails d’erreur ⓘ",
|
||
variable=self.verbose_var,
|
||
)
|
||
verbose_toggle.pack(side="left", padx=(10, 0))
|
||
attach_tooltip(
|
||
verbose_toggle,
|
||
"Ajoute --verbose aux commandes compatibles afin d’afficher la trace complète lorsqu’une erreur inattendue survient.",
|
||
)
|
||
profile = "standard + personnel" if show_personal_steps else "standard"
|
||
ttk.Label(environment, text=f"Profil : {profile}").pack(side="left", padx=(12, 0))
|
||
|
||
main_pane = ttk.Panedwindow(self, orient="horizontal")
|
||
main_pane.grid(row=1, column=0, sticky="nsew", padx=10)
|
||
|
||
navigation = ttk.Frame(main_pane, padding=(0, 5, 6, 5))
|
||
self.tree = ttk.Treeview(navigation, columns=("status",), show="tree headings", selectmode="browse")
|
||
self.tree.heading("#0", text="Étape")
|
||
self.tree.heading("status", text="État")
|
||
self.tree.column("#0", minwidth=230, width=280)
|
||
self.tree.column("status", minwidth=90, width=105, anchor="center")
|
||
tree_scroll = ttk.Scrollbar(navigation, orient="vertical", command=self.tree.yview)
|
||
self.tree.configure(yscrollcommand=tree_scroll.set)
|
||
self.tree.pack(side="left", fill="both", expand=True)
|
||
tree_scroll.pack(side="right", fill="y")
|
||
self.tree.bind("<<TreeviewSelect>>", self._on_tree_select)
|
||
main_pane.add(navigation, weight=1)
|
||
|
||
self.detail = ttk.Frame(main_pane, padding=(12, 6, 4, 4))
|
||
self.detail.columnconfigure(0, weight=1)
|
||
self.detail.rowconfigure(3, weight=1)
|
||
self.title_label = ttk.Label(self.detail, text="Sélectionnez une étape", font=("TkDefaultFont", 15, "bold"))
|
||
self.title_label.grid(row=0, column=0, sticky="w")
|
||
self.description_label = ttk.Label(self.detail, text="", wraplength=680, justify="left")
|
||
self.description_label.grid(row=1, column=0, sticky="ew", pady=(5, 8))
|
||
ttk.Style(self).configure("MissingPrerequisite.TLabel", foreground="#c62828")
|
||
self.missing_requirements_label = ttk.Label(
|
||
self.detail,
|
||
text="",
|
||
style="MissingPrerequisite.TLabel",
|
||
wraplength=680,
|
||
justify="left",
|
||
)
|
||
self.missing_requirements_label.grid(row=2, column=0, sticky="ew", pady=(0, 8))
|
||
self.missing_requirements_label.grid_remove()
|
||
form_container = ttk.Frame(self.detail)
|
||
form_container.grid(row=3, column=0, sticky="nsew")
|
||
form_container.columnconfigure(0, weight=1)
|
||
form_container.rowconfigure(0, weight=1)
|
||
self.form_canvas = tk.Canvas(form_container, highlightthickness=0, width=1, height=1)
|
||
self.form_canvas.grid(row=0, column=0, sticky="nsew")
|
||
form_scroll = ttk.Scrollbar(form_container, orient="vertical", command=self.form_canvas.yview)
|
||
form_scroll.grid(row=0, column=1, sticky="ns")
|
||
self.form_canvas.configure(yscrollcommand=form_scroll.set)
|
||
self.form = ttk.Frame(self.form_canvas)
|
||
form_window = self.form_canvas.create_window((0, 0), window=self.form, anchor="nw")
|
||
self.form.bind("<Configure>", lambda _event: self.form_canvas.configure(scrollregion=self.form_canvas.bbox("all")))
|
||
self.form_canvas.bind("<Configure>", lambda event: self.form_canvas.itemconfigure(form_window, width=event.width))
|
||
self.form.columnconfigure(1, weight=1)
|
||
|
||
command_box = ttk.LabelFrame(self.detail, text="Commande", padding=6)
|
||
command_box.grid(row=4, column=0, sticky="ew", pady=(8, 5))
|
||
command_box.columnconfigure(0, weight=1)
|
||
ttk.Label(command_box, textvariable=self.command_var, wraplength=690, justify="left").grid(
|
||
row=0, column=0, sticky="ew"
|
||
)
|
||
self.copy_command_button = ttk.Button(command_box, text="Copier la commande", command=self._copy_command)
|
||
self.copy_command_button.grid(row=1, column=0, sticky="w", pady=(5, 0))
|
||
|
||
buttons = ttk.Frame(self.detail)
|
||
buttons.grid(row=5, column=0, sticky="ew", pady=(5, 0))
|
||
self.previous_button = ttk.Button(buttons, text="← Précédente", command=lambda: self._move_selection(-1))
|
||
self.previous_button.pack(side="left")
|
||
self.next_button = ttk.Button(buttons, text="Suivante →", command=lambda: self._move_selection(1))
|
||
self.next_button.pack(side="left", padx=6)
|
||
self.skip_button = ttk.Button(buttons, text="Marquer ignorée", command=self._skip_step)
|
||
self.skip_button.pack(side="right")
|
||
self.run_button = ttk.Button(buttons, text="Exécuter", command=self._run_current_step)
|
||
self.run_button.pack(side="right", padx=6)
|
||
main_pane.add(self.detail, weight=3)
|
||
|
||
console_frame = ttk.LabelFrame(self, text="Sortie en temps réel", padding=(7, 5))
|
||
console_frame.grid(row=2, column=0, sticky="nsew", padx=10, pady=(6, 8))
|
||
console_frame.columnconfigure(0, weight=1)
|
||
console_frame.rowconfigure(0, weight=1)
|
||
self.console = tk.Text(
|
||
console_frame,
|
||
height=14,
|
||
wrap="word",
|
||
background="#171717",
|
||
foreground="#efefef",
|
||
insertbackground="white",
|
||
state="disabled",
|
||
takefocus=True,
|
||
exportselection=False,
|
||
)
|
||
self.console.bind("<Control-c>", self._copy_console_selection)
|
||
self.console.bind("<Control-Shift-C>", self._copy_console_selection)
|
||
self.console.bind("<Control-a>", self._select_console_all)
|
||
self.console.bind("<Button-1>", lambda _event: self.console.focus_set())
|
||
self.console_menu = tk.Menu(self.console, tearoff=False)
|
||
self.console_menu.add_command(label="Copier la sélection", command=self._copy_console_selection)
|
||
self.console_menu.add_command(label="Tout sélectionner", command=self._select_console_all)
|
||
self.console_menu.add_command(label="Copier toute la sortie", command=self._copy_console_all)
|
||
self.console.bind("<Button-3>", self._show_console_menu)
|
||
if self.tk.call("tk", "windowingsystem") == "aqua":
|
||
self.console.bind("<Command-c>", self._copy_console_selection)
|
||
self.console.bind("<Command-a>", self._select_console_all)
|
||
self.console.bind("<Button-2>", self._show_console_menu)
|
||
console_scroll = ttk.Scrollbar(console_frame, orient="vertical", command=self.console.yview)
|
||
self.console.configure(yscrollcommand=console_scroll.set)
|
||
self.console.grid(row=0, column=0, sticky="nsew")
|
||
console_scroll.grid(row=0, column=1, sticky="ns")
|
||
|
||
console_actions = ttk.Frame(console_frame)
|
||
console_actions.grid(row=1, column=0, columnspan=2, sticky="ew", pady=(5, 0))
|
||
console_actions.columnconfigure(1, weight=1)
|
||
clipboard_actions = ttk.Frame(console_actions)
|
||
clipboard_actions.grid(row=1, column=0, columnspan=6, sticky="w", pady=(5, 0))
|
||
ttk.Button(clipboard_actions, text="Copier la sélection", command=self._copy_console_selection).pack(side="left")
|
||
ttk.Button(clipboard_actions, text="Copier toute la sortie", command=self._copy_console_all).pack(side="left", padx=6)
|
||
ttk.Label(console_actions, text="Réponse au script").grid(row=0, column=0, padx=(0, 6))
|
||
self.stdin_var = tk.StringVar()
|
||
self.stdin_entry = ttk.Entry(console_actions, textvariable=self.stdin_var)
|
||
self.stdin_entry.grid(row=0, column=1, sticky="ew")
|
||
self.stdin_entry.bind("<Return>", lambda _event: self._send_input())
|
||
self.send_button = ttk.Button(console_actions, text="Envoyer", command=self._send_input, state="disabled")
|
||
self.send_button.grid(row=0, column=2, padx=5)
|
||
self.interrupt_button = ttk.Button(
|
||
console_actions, text="Interrompre", command=self._interrupt, state="disabled"
|
||
)
|
||
self.interrupt_button.grid(row=0, column=3, padx=5)
|
||
self.force_button = ttk.Button(
|
||
console_actions, text="Forcer l’arrêt", command=self._force_stop, state="disabled"
|
||
)
|
||
self.force_button.grid(row=0, column=4)
|
||
ttk.Button(console_actions, text="Effacer la console", command=self._clear_console).grid(
|
||
row=0, column=5, padx=(8, 0)
|
||
)
|
||
|
||
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:
|
||
self.evaluation_var.set(selected)
|
||
self._load_evaluation()
|
||
|
||
def _open_evaluation_folder(self) -> None:
|
||
self._open_desktop_path(self.evaluation, "dossier d’évaluation")
|
||
|
||
def _toggle_proxy(self) -> None:
|
||
self.proxy_entry.configure(state="normal" if self.use_proxy_var.get() else "disabled")
|
||
|
||
def _load_evaluation(self) -> None:
|
||
if self.active_step_id or self.runner.running:
|
||
messagebox.showinfo("Traitement en cours", "Attendez la fin du traitement avant de changer d’évaluation.")
|
||
return
|
||
evaluation = self.evaluation
|
||
if not evaluation or not evaluation.is_dir():
|
||
messagebox.showerror("Dossier invalide", "Choisissez un dossier d’évaluation existant.")
|
||
return
|
||
self.batch_monitor.stop()
|
||
self._save_current_form()
|
||
self.state_store.load(evaluation)
|
||
for step in self.steps:
|
||
if self.state_store.step(step.id).get("status") == "running":
|
||
self.state_store.update_step(step.id, status="interrupted")
|
||
self._populate_tree()
|
||
missing = [name for name in ("enonce.pdf", "enonce.tex", "correction.tex") if not (evaluation / name).exists()]
|
||
names_found = (evaluation / "names").exists() or (self.repository / "names").exists()
|
||
details = []
|
||
if missing:
|
||
details.append("manquants : " + ", ".join(missing))
|
||
if not names_found:
|
||
details.append("fichier names introuvable")
|
||
if not self.api_key_var.get():
|
||
details.append("clé Gemini non renseignée")
|
||
self.info_var.set(
|
||
f"Évaluation chargée : {evaluation}"
|
||
+ (" — " + " ; ".join(details) if details else " — prérequis principaux détectés")
|
||
)
|
||
first = self.steps[0].id if self.steps else None
|
||
if first:
|
||
self.tree.selection_set(first)
|
||
self.tree.see(first)
|
||
|
||
def _populate_tree(self) -> None:
|
||
selected = self.current_step.id if self.current_step else None
|
||
opened = {self.tree.item(item, "text"): self.tree.item(item, "open") for item in self.tree.get_children()}
|
||
self.tree.delete(*self.tree.get_children())
|
||
section_items: dict[str, str] = {}
|
||
for step in self.steps:
|
||
if step.section not in section_items:
|
||
section_id = f"section:{len(section_items)}"
|
||
section_items[step.section] = section_id
|
||
self.tree.insert("", "end", iid=section_id, text=step.section, values=("",), open=opened.get(step.section, step.section != REFAIRE_SECTION))
|
||
status = self._step_status(step)
|
||
suffix = " (facultative)" if step.optional else ""
|
||
self.tree.insert(
|
||
section_items[step.section],
|
||
"end",
|
||
iid=step.id,
|
||
text=step.title + suffix,
|
||
values=(STATUS_LABELS.get(status, status),),
|
||
)
|
||
if selected and self.tree.exists(selected):
|
||
self.tree.selection_set(selected)
|
||
|
||
def _step_status(self, step: StepDefinition) -> str:
|
||
if self.state_store.evaluation:
|
||
saved = self.state_store.step(step.id).get("status")
|
||
if saved:
|
||
return str(saved)
|
||
if self._artifacts_exist(step):
|
||
return "detected"
|
||
if step.id == "inputs" and not self._missing_requirements(step):
|
||
return "detected"
|
||
return "ready"
|
||
|
||
def _artifacts_exist(self, step: StepDefinition) -> bool:
|
||
evaluation = self.evaluation
|
||
if not evaluation or not step.artifacts:
|
||
return False
|
||
for pattern in step.artifacts:
|
||
if any(char in pattern for char in "*?["):
|
||
if next(evaluation.glob(pattern), None) is not None:
|
||
return True
|
||
elif (evaluation / pattern).exists():
|
||
return True
|
||
return False
|
||
|
||
def _missing_requirements(self, step: StepDefinition) -> list[str]:
|
||
evaluation = self.evaluation
|
||
if not evaluation:
|
||
return list(step.requires)
|
||
missing = []
|
||
requirements = step.requires
|
||
if step.id == "statement":
|
||
variant = (self.variant_var.get() if self.current_step and self.current_step.id == step.id
|
||
else self.state_store.step(step.id).get("variant", "gemini"))
|
||
if variant == "personal":
|
||
requirements = ("enonce.tex",)
|
||
for pattern in requirements:
|
||
if any(char in pattern for char in "*?["):
|
||
exists = next(evaluation.glob(pattern), None) is not None
|
||
else:
|
||
path = EvaluationWorkspace(evaluation).annotation_dir("refaire") if pattern == "BRnot" else evaluation / pattern
|
||
exists = path.exists()
|
||
if not exists:
|
||
missing.append(pattern)
|
||
if step.id == "inputs" and not ((evaluation / "names").is_file() or (self.repository / "names").is_file()):
|
||
missing.append("names")
|
||
return missing
|
||
|
||
def _reload_inputs(self) -> None:
|
||
if not self.state_store.evaluation or self.active_step_id or self.runner.running:
|
||
return
|
||
missing = self._missing_requirements(self.step_by_id["inputs"])
|
||
self.state_store.update_step("inputs", status="ready" if missing else "success")
|
||
self._populate_tree()
|
||
self._render_step()
|
||
self.info_var.set("Fichiers revérifiés — " + ("manquants : " + ", ".join(missing) if missing else "tous les fichiers d’entrée sont présents."))
|
||
|
||
def _get_input_files(self) -> None:
|
||
evaluation = self.evaluation
|
||
if (
|
||
not self.show_personal_steps
|
||
or not evaluation
|
||
or not self.state_store.evaluation
|
||
or self.active_step_id
|
||
or self.runner.running
|
||
):
|
||
return
|
||
try:
|
||
copied = get_personal_interro_files(evaluation)
|
||
except (OSError, ValueError) as exc:
|
||
messagebox.showerror("Récupération impossible", str(exc))
|
||
return
|
||
|
||
missing = self._missing_requirements(self.step_by_id["inputs"])
|
||
if missing:
|
||
self.state_store.update_step("inputs", status="ready")
|
||
self._populate_tree()
|
||
self._render_step()
|
||
messagebox.showwarning(
|
||
"Prérequis encore manquant",
|
||
"Les trois fichiers ont été copiés, mais l’étape ne peut pas encore "
|
||
"être terminée :\n\n" + "\n".join(missing),
|
||
)
|
||
return
|
||
|
||
self._mark_step("success", automatic=True, reason="Fichiers d’entrée récupérés")
|
||
self.info_var.set(
|
||
"Fichiers récupérés : " + ", ".join(path.name for path in copied)
|
||
)
|
||
self._move_selection_from("inputs", 1)
|
||
|
||
def _on_tree_select(self, _event: tk.Event[Any] | None = None) -> None:
|
||
selection = self.tree.selection()
|
||
if not selection or selection[0].startswith("section:"):
|
||
return
|
||
step = self.step_by_id.get(selection[0])
|
||
if not step:
|
||
return
|
||
if self.current_step and self.current_step.id != step.id:
|
||
self._save_current_form()
|
||
self.current_step = step
|
||
self._render_step()
|
||
self._handle_first_visit(step)
|
||
|
||
def _handle_first_visit(self, step: StepDefinition) -> None:
|
||
if not self.state_store.evaluation:
|
||
return
|
||
entry = self.state_store.step(step.id)
|
||
if entry.get("visited"):
|
||
return
|
||
|
||
# Persist this before scheduling an action so selection callbacks cannot
|
||
# trigger the same automatic behavior twice.
|
||
self.state_store.update_step(step.id, visited=True)
|
||
|
||
if step.skip_for_live_correction:
|
||
correction = self.state_store.step("correction")
|
||
if correction.get("variant", "live") == "live":
|
||
self.after_idle(
|
||
lambda step_id=step.id: self._automatic_skip(
|
||
step_id, "correction immédiate sélectionnée"
|
||
)
|
||
)
|
||
return
|
||
|
||
if step.skip_without_manual_conflicts:
|
||
evaluation = self.evaluation
|
||
conflicts = evaluation / "manual_resolutions.txt" if evaluation else None
|
||
if conflicts is None or not has_manual_conflicts(conflicts):
|
||
self.after_idle(
|
||
lambda step_id=step.id: self._automatic_skip(
|
||
step_id, "aucun conflit manuel détecté"
|
||
)
|
||
)
|
||
return
|
||
|
||
if step.auto_start_first_visit and not entry.get("status") and not self._artifacts_exist(step):
|
||
self.after_idle(lambda step_id=step.id: self._automatic_start(step_id))
|
||
|
||
def _automatic_start(self, step_id: str) -> None:
|
||
if self.runner.running or not self.current_step or self.current_step.id != step_id:
|
||
return
|
||
self.info_var.set(f"{self.current_step.title} : démarrage automatique.")
|
||
self._run_current_step()
|
||
|
||
def _automatic_skip(self, step_id: str, reason: str) -> None:
|
||
if self.runner.running or not self.current_step or self.current_step.id != step_id:
|
||
return
|
||
self._mark_step("skipped", automatic=True, reason=reason)
|
||
self._move_selection_from(step_id, 1)
|
||
|
||
def _render_step(self) -> None:
|
||
step = self.current_step
|
||
if not step:
|
||
return
|
||
self._rendering = True
|
||
redo = step.section == REFAIRE_SECTION
|
||
expanded_form = redo or step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels", "manual_resolution"}
|
||
self.console.configure(height=8 if expanded_form else 14)
|
||
self.rowconfigure(1, weight=4 if expanded_form else 3)
|
||
self.rowconfigure(2, weight=1 if expanded_form else 2)
|
||
form_height = (340 if step.id == "refaire_selection" else 260) if redo else (300 if expanded_form else 200)
|
||
self.form_canvas.configure(height=form_height)
|
||
self.form_canvas.yview_moveto(0)
|
||
for child in self.form.winfo_children():
|
||
child.destroy()
|
||
self.arg_vars.clear()
|
||
self.refaire_panel = None
|
||
entry = self.state_store.step(step.id) if self.state_store.evaluation else {}
|
||
saved_variant = entry.get("variant", step.variants[0].id)
|
||
if saved_variant not in {variant.id for variant in step.variants}:
|
||
saved_variant = step.variants[0].id
|
||
self.variant_var.set(saved_variant)
|
||
self.extra_var.set(str(entry.get("extra", "")))
|
||
self.title_label.configure(text=step.title)
|
||
missing = self._missing_requirements(step)
|
||
description = step.description
|
||
if step.id == "statement" and saved_variant == "personal":
|
||
description = ("Lit les blocs SHEETINFO de enonce.tex et récupère les énoncés, solutions "
|
||
"et barèmes du service personnel (localhost:8080). Crée un groupe par exercice. "
|
||
"Les deux étapes Gemini suivantes restent facultatives.")
|
||
self.description_label.configure(text=description)
|
||
if missing:
|
||
self.missing_requirements_label.configure(
|
||
text="Prérequis non détectés : " + ", ".join(missing)
|
||
)
|
||
self.missing_requirements_label.grid()
|
||
else:
|
||
self.missing_requirements_label.grid_remove()
|
||
|
||
row = self._render_context_controls(step, 0)
|
||
if len(step.variants) > 1:
|
||
ttk.Label(self.form, text="Mode").grid(row=row, column=0, sticky="w", pady=4, padx=(0, 8))
|
||
labels = [variant.label for variant in step.variants]
|
||
combo = ttk.Combobox(self.form, values=labels, state="readonly")
|
||
variant_index = [variant.id for variant in step.variants].index(saved_variant)
|
||
combo.current(variant_index)
|
||
combo.grid(row=row, column=1, sticky="ew", pady=4)
|
||
combo.bind("<<ComboboxSelected>>", lambda _event: self._select_variant(combo.current()))
|
||
row += 1
|
||
|
||
values = entry.get("values", {}) if isinstance(entry.get("values", {}), dict) else {}
|
||
variant = self._current_variant()
|
||
evaluation_arg = self._evaluation_arg()
|
||
for spec in step.arguments:
|
||
if step.section == REFAIRE_SECTION:
|
||
continue
|
||
if spec.variants and variant.id not in spec.variants:
|
||
continue
|
||
value = values.get(spec.name, value_for_default(spec.default, evaluation_arg))
|
||
choices = spec.choices
|
||
if spec.name == "annotation_dir" and step.id in {"export", "import"}:
|
||
choices, value = self._annotation_directory_choices(step.id)
|
||
argument_label = ttk.Label(self.form, text=f"{spec.label} ⓘ")
|
||
argument_label.grid(row=row, column=0, sticky="nw", pady=4, padx=(0, 8))
|
||
attach_tooltip(argument_label, spec.help)
|
||
if spec.kind == "bool":
|
||
variable: tk.Variable = tk.BooleanVar(value=bool(value))
|
||
widget = ttk.Checkbutton(self.form, variable=variable)
|
||
widget.grid(row=row, column=1, sticky="w", pady=4)
|
||
elif spec.kind == "choice":
|
||
variable = tk.StringVar(value=str(value))
|
||
widget = ttk.Combobox(self.form, textvariable=variable, values=choices, state="readonly")
|
||
widget.grid(row=row, column=1, sticky="ew", pady=4)
|
||
else:
|
||
variable = tk.StringVar(value=str(value))
|
||
field = ttk.Frame(self.form)
|
||
field.grid(row=row, column=1, sticky="ew", pady=4)
|
||
field.columnconfigure(0, weight=1)
|
||
widget = ttk.Entry(field, textvariable=variable)
|
||
widget.grid(row=0, column=0, sticky="ew")
|
||
if spec.kind == "path":
|
||
ttk.Button(field, text="Fichier…", command=lambda var=variable: self._browse_target_file(var)).grid(
|
||
row=0, column=1, padx=(5, 0)
|
||
)
|
||
ttk.Button(field, text="Dossier…", command=lambda var=variable: self._browse_target_dir(var)).grid(
|
||
row=0, column=2, padx=(5, 0)
|
||
)
|
||
self.arg_vars[spec.name] = variable
|
||
variable.trace_add("write", lambda *_args: self._update_command_preview())
|
||
attach_tooltip(widget, spec.help)
|
||
row += 1
|
||
|
||
if step.id == "manual_resolution":
|
||
def manual_evaluation():
|
||
target = self.arg_vars.get("target")
|
||
value = target.get().strip() if target else ""
|
||
return (self.repository / value).resolve() if value else self.evaluation
|
||
|
||
self.manual_panel = ManualResolutionPanel(self.form, manual_evaluation)
|
||
self.manual_panel.grid(row=row, column=0, columnspan=2, sticky="nsew", pady=8)
|
||
if "target" in self.arg_vars:
|
||
self.arg_vars["target"].trace_add("write", lambda *_args: self.manual_panel.reload())
|
||
row += 1
|
||
|
||
show_extra = bool(step.extra_arguments_help) and (
|
||
not step.extra_arguments_variants
|
||
or variant.id in step.extra_arguments_variants
|
||
)
|
||
if show_extra and not step.is_manual and step.section != REFAIRE_SECTION:
|
||
extra_label = ttk.Label(self.form, text="Arguments supplémentaires ⓘ")
|
||
extra_label.grid(
|
||
row=row, column=0, sticky="w", pady=(10, 4), padx=(0, 8)
|
||
)
|
||
extra_entry = ttk.Entry(self.form, textvariable=self.extra_var)
|
||
extra_entry.grid(row=row, column=1, sticky="ew", pady=(10, 4))
|
||
attach_tooltip(extra_label, step.extra_arguments_help)
|
||
attach_tooltip(extra_entry, step.extra_arguments_help)
|
||
row += 1
|
||
|
||
self.run_button.configure(text="Enregistrer la sélection" if step.id == "refaire_selection" else ("Marquer terminée" if step.is_manual else "Exécuter"))
|
||
self.skip_button.configure(state="normal" if step.optional else "disabled")
|
||
self._rendering = False
|
||
self._update_command_preview()
|
||
self._update_controls()
|
||
|
||
def _render_context_controls(self, step: StepDefinition, row: int) -> int:
|
||
if step.id == "batch_status":
|
||
controls = ttk.Frame(self.form)
|
||
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=8)
|
||
self.watch_batches_button = ttk.Button(
|
||
controls, text="Vérifier toutes les 5 minutes", command=self._start_batch_watch
|
||
)
|
||
self.watch_batches_button.pack(side="left")
|
||
self.stop_watch_batches_button = ttk.Button(
|
||
controls, text="Arrêter les vérifications", command=self.batch_monitor.stop
|
||
)
|
||
self.stop_watch_batches_button.pack(side="left", padx=8)
|
||
ttk.Label(self.form, textvariable=self.batch_watch_status).grid(
|
||
row=row + 1, column=0, columnspan=2, sticky="w"
|
||
)
|
||
ttk.Label(self.form, text="Gardez Copienator ouvert. Une notification de bureau sera envoyée lorsque tous les résultats seront prêts.",
|
||
wraplength=650).grid(row=row + 2, column=0, columnspan=2, sticky="w", pady=8)
|
||
return row + 3
|
||
if step.id == "rename":
|
||
self.validate_rename_button = ttk.Button(
|
||
self.form, text="Valider sans renommer", command=self._validate_rename_step
|
||
)
|
||
self.validate_rename_button.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 8))
|
||
row += 1
|
||
if step.id == "inputs":
|
||
ttk.Button(self.form, text="Recharger — vérifier à nouveau", command=self._reload_inputs).grid(
|
||
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
|
||
)
|
||
row += 1
|
||
if self.show_personal_steps:
|
||
self.get_input_files_button = ttk.Button(
|
||
self.form, text="Get the files", command=self._get_input_files
|
||
)
|
||
self.get_input_files_button.grid(
|
||
row=row, column=0, columnspan=2, sticky="w", pady=(0, 8)
|
||
)
|
||
row += 1
|
||
return row
|
||
if step.section == REFAIRE_SECTION:
|
||
evaluation = self.evaluation
|
||
if step.id in {"refaire_selection", "refaire_correct"}:
|
||
row = self._correction_folder_buttons(row)
|
||
row = self._refaire_restart_controls(row)
|
||
if step.id == "refaire_selection" and evaluation:
|
||
choices, preferred = self._annotation_directory_choices("export")
|
||
draft = self.state_store.step(step.id).get("values", {})
|
||
self.refaire_panel = RefaireSelection(self.form, evaluation, draft, choices, preferred)
|
||
self.refaire_panel.grid(row=row, column=0, columnspan=2, sticky="nsew")
|
||
else:
|
||
try:
|
||
entries = self._refaire_selection()
|
||
summary = f"{len(entries)} copie(s) sélectionnée(s) :\n" + " ; ".join(f"{name} : {', '.join(labels) or 'toute la copie'}" for name, labels in entries[:5])
|
||
if len(entries) > 5:
|
||
summary += f" ; et {len(entries) - 5} autres (voir la sélection)."
|
||
source = self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "")
|
||
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
|
||
layout = resolve_layout(entries, EvaluationWorkspace(evaluation).read_labels(), choice)
|
||
summary += f"\nPassage principal : {source} — PDF à vérifier : {'par question' if layout == 'grouped' else 'par copie'}"
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
summary = str(exc)
|
||
ttk.Label(self.form, text=summary, wraplength=600, justify="left").grid(row=row, column=0, columnspan=2, sticky="w")
|
||
return row + 1
|
||
if step.id in {"review_persp", "correction"}:
|
||
row = self._correction_folder_buttons(row)
|
||
if step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}:
|
||
evaluation = self.evaluation
|
||
paths = copy_pdf_paths(evaluation) if evaluation else []
|
||
self.copy_paths = {path.name: path for path in paths}
|
||
names = list(self.copy_paths)
|
||
copies = ttk.LabelFrame(self.form, text="Copies détectées", padding=7)
|
||
copies.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||
copies.columnconfigure(0, weight=1)
|
||
summary = (
|
||
f"{len(names)} copie(s) : {', '.join(names)}"
|
||
if names
|
||
else "Aucune copie PDF détectée."
|
||
)
|
||
ttk.Label(copies, text=summary, wraplength=570, justify="left").grid(
|
||
row=0, column=0, columnspan=2, sticky="ew"
|
||
)
|
||
if self.copy_var.get() not in names:
|
||
self.copy_var.set(names[0] if names else "")
|
||
selector = ttk.Combobox(
|
||
copies,
|
||
textvariable=self.copy_var,
|
||
values=names,
|
||
state="readonly" if names else "disabled",
|
||
)
|
||
selector.grid(row=1, column=0, sticky="ew", pady=(7, 0))
|
||
ttk.Button(
|
||
copies,
|
||
text="Afficher la copie",
|
||
command=self._open_selected_copy,
|
||
state="normal" if names else "disabled",
|
||
).grid(row=1, column=1, padx=(6, 0), pady=(7, 0))
|
||
if step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}:
|
||
actions = ttk.Frame(copies)
|
||
actions.grid(row=2, column=0, columnspan=2, sticky="w", pady=(7, 0))
|
||
label = "Refaire la copie sélectionnée" if step.id == "page_splitter" else "Cibler la copie sélectionnée"
|
||
ttk.Button(actions, text=label, command=self._target_selected_copy,
|
||
state="normal" if names else "disabled").pack(side="left")
|
||
ttk.Button(actions, text="Cibler tout le dossier", command=self._target_all_pages).pack(side="left", padx=6)
|
||
help_text = (
|
||
"La reprise utilise l’original conservé. "
|
||
if step.id == "page_splitter"
|
||
else "Seule la copie ciblée sera analysée par Gemini. "
|
||
if step.id == "labels"
|
||
else "Seule la copie ciblée sera traitée. "
|
||
)
|
||
ttk.Label(copies, text=help_text + "Vérifiez la commande puis cliquez sur Exécuter.",
|
||
wraplength=570).grid(row=3, column=0, columnspan=2, sticky="w", pady=(5, 0))
|
||
if step.id in {"page_splitter", "cutleft"}:
|
||
try:
|
||
marked = copy_errors(EvaluationWorkspace(evaluation)) if evaluation else {}
|
||
marked_summary = ", ".join(marked) or "Aucune copie signalée."
|
||
except (CliError, OSError, ValueError, RuntimeError) as exc:
|
||
marked = {}
|
||
marked_summary = f"Lecture des copies signalées impossible : {exc}"
|
||
self.marked_copies_button = ttk.Button(
|
||
copies, text=f"Traiter les copies signalées ({len(marked)})", command=self._run_marked_copies,
|
||
state="normal" if marked and not self.active_step_id and not self.runner.running else "disabled",
|
||
)
|
||
self.marked_copies_button.grid(row=4, column=0, columnspan=2, sticky="w", pady=(7, 0))
|
||
ttk.Label(copies, text=marked_summary, wraplength=570).grid(
|
||
row=5, column=0, columnspan=2, sticky="w")
|
||
row += 1
|
||
|
||
if step.id == "plotting":
|
||
shortcuts = ttk.LabelFrame(self.form, text="Raccourcis clavier", padding=7)
|
||
shortcuts.grid(row=row, column=0, columnspan=2, sticky="ew", pady=(0, 8))
|
||
ttk.Label(
|
||
shortcuts,
|
||
text="\n".join(plotting_shortcut_lines()),
|
||
justify="left",
|
||
).grid(row=0, column=0, sticky="w")
|
||
row += 1
|
||
return row
|
||
|
||
def _refaire_restart_controls(self, row: int) -> int:
|
||
controls = ttk.Frame(self.form)
|
||
controls.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 5))
|
||
running = bool(self.active_step_id) or self.runner.running
|
||
ttk.Button(controls, text="Nouvelle reprise", command=lambda: self._start_refaire_pass(False),
|
||
state="disabled" if running else "normal").pack(side="left", padx=(0, 6))
|
||
ttk.Button(controls, text="Refaire la même sélection", command=lambda: self._start_refaire_pass(True),
|
||
state="disabled" if running else "normal").pack(side="left")
|
||
ttk.Label(self.form, text="« Nouvelle reprise » : à utiliser après avoir importé les résultats et mis à jour les copies finales.",
|
||
wraplength=580, justify="left").grid(row=row + 1, column=0, columnspan=2, sticky="w", pady=(0, 6))
|
||
return row + 2
|
||
|
||
def _start_refaire_pass(self, keep_selection: bool) -> None:
|
||
workspace = self.state_store.workspace
|
||
if workspace is None:
|
||
messagebox.showerror("Évaluation absente", "Chargez d’abord une évaluation.")
|
||
return
|
||
if self.active_step_id or self.runner.running:
|
||
messagebox.showwarning("Traitement en cours", "Attendez la fin du traitement avant de commencer une reprise.")
|
||
return
|
||
try:
|
||
if keep_selection:
|
||
load_selection(workspace.root)
|
||
title = "Refaire la même sélection" if keep_selection else "Nouvelle reprise"
|
||
message = RESTART_WARNING
|
||
if self.state_store.step("refaire_merge").get("status") != "success":
|
||
message += "\n\nLa reprise actuelle n’est pas marquée comme fusionnée. Les résultats non fusionnés ne seront pas intégrés aux copies finales."
|
||
message += "\n\nLes fichiers de la reprise précédente seront conservés. Continuer ?"
|
||
if not messagebox.askyesno(title, message, icon="warning", default="no"):
|
||
return
|
||
self._save_current_form()
|
||
updated, ident = begin_pass(workspace, self.state_store.data, keep_selection=keep_selection)
|
||
self.state_store.data = updated
|
||
# Do not save the old form over the fresh pass during navigation.
|
||
self.current_step = None
|
||
self.refaire_panel = None
|
||
self.arg_vars.clear()
|
||
self._populate_tree()
|
||
self.tree.selection_set("refaire_selection")
|
||
self.tree.see("refaire_selection")
|
||
self.info_var.set(f"Nouvelle reprise : {ident}. Enregistrez la sélection pour continuer.")
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
messagebox.showerror("Nouvelle reprise impossible", str(exc))
|
||
|
||
def _correction_folder_buttons(self, row: int) -> int:
|
||
buttons = ttk.Frame(self.form)
|
||
buttons.grid(row=row, column=0, columnspan=2, sticky="w", pady=(0, 6))
|
||
for directory, label in (("Sol", "Corrigés (Sol)"), ("Persp", "Consignes de notation (Persp)")):
|
||
ttk.Button(buttons, text=label,
|
||
command=lambda name=directory: self._open_desktop_path(self.evaluation / name if self.evaluation else None, f"dossier {name}")).pack(side="left", padx=(0, 6))
|
||
return row + 1
|
||
|
||
def _annotation_directory_choices(self, step_id: str) -> tuple[tuple[str, ...], str]:
|
||
evaluation = self.evaluation
|
||
detected = detected_annotation_directories(evaluation) if evaluation else ()
|
||
choices = detected or ANNOTATION_DIRECTORIES
|
||
|
||
if step_id == "export":
|
||
annotation = self.state_store.step("annotation")
|
||
variant = annotation.get("last_run_variant", annotation.get("variant", "grouped"))
|
||
preferred = ANNOTATION_VARIANT_DIRECTORIES.get(str(variant), "BGnot")
|
||
else:
|
||
export = self.state_store.step("export")
|
||
last_values = export.get("last_run_values", export.get("values", {}))
|
||
preferred = (
|
||
str(last_values.get("annotation_dir", "BGnot"))
|
||
if isinstance(last_values, dict)
|
||
else "BGnot"
|
||
)
|
||
return choices, preferred if preferred in choices else choices[0]
|
||
|
||
def _open_persp(self) -> None:
|
||
evaluation = self.evaluation
|
||
self._open_desktop_path(evaluation / "Persp" if evaluation else None, "dossier Persp")
|
||
|
||
def _open_selected_copy(self) -> None:
|
||
self._open_desktop_path(self.copy_paths.get(self.copy_var.get()), "copie")
|
||
|
||
def _target_selected_copy(self) -> None:
|
||
path = self.copy_paths.get(self.copy_var.get())
|
||
if path and "target" in self.arg_vars:
|
||
if "marked" in self.arg_vars:
|
||
self.arg_vars["marked"].set(False)
|
||
self.arg_vars["target"].set(str(path))
|
||
self.info_var.set(f"Copie ciblée : {path.name} — cliquez sur Exécuter.")
|
||
|
||
def _target_all_pages(self) -> None:
|
||
if "target" in self.arg_vars:
|
||
if "marked" in self.arg_vars:
|
||
self.arg_vars["marked"].set(False)
|
||
self.arg_vars["target"].set(self._evaluation_arg())
|
||
|
||
def _run_marked_copies(self) -> None:
|
||
if (not self.current_step or self.current_step.id not in {"page_splitter", "cutleft"}
|
||
or self.active_step_id or self.runner.running or not self.state_store.evaluation):
|
||
return
|
||
self.arg_vars["target"].set(self._evaluation_arg())
|
||
self.arg_vars["marked"].set(True)
|
||
self._run_current_step()
|
||
|
||
def _open_desktop_path(self, path: Path | None, label: str) -> None:
|
||
if path is None or not path.exists():
|
||
messagebox.showerror("Élément introuvable", f"Le {label} n’existe pas encore.")
|
||
return
|
||
try:
|
||
open_path(path)
|
||
except (OSError, RuntimeError) as exc:
|
||
messagebox.showerror("Ouverture impossible", str(exc))
|
||
|
||
def _select_variant(self, index: int) -> None:
|
||
if not self.current_step or index < 0:
|
||
return
|
||
self._save_current_form()
|
||
self.variant_var.set(self.current_step.variants[index].id)
|
||
if self.state_store.evaluation:
|
||
self.state_store.update_step(self.current_step.id, variant=self.variant_var.get())
|
||
self._render_step()
|
||
|
||
def _current_variant(self) -> CommandVariant:
|
||
assert self.current_step is not None
|
||
selected = self.variant_var.get()
|
||
return next((variant for variant in self.current_step.variants if variant.id == selected), self.current_step.variants[0])
|
||
|
||
def _values(self) -> dict[str, object]:
|
||
if self.current_step and self.current_step.section == REFAIRE_SECTION:
|
||
if self.current_step.id == "refaire_selection" and self.refaire_panel:
|
||
return self.refaire_panel.values()
|
||
return {"target": self._evaluation_arg(), "annotation_dir": self.state_store.step("refaire_selection").get("values", {}).get("annotation_dir", "BGnot")}
|
||
return {name: variable.get() for name, variable in self.arg_vars.items()}
|
||
|
||
def _save_current_form(self) -> None:
|
||
if self._rendering or not self.current_step or not self.state_store.evaluation:
|
||
return
|
||
saved_values = self.state_store.step(self.current_step.id).get("values", {})
|
||
merged_values = dict(saved_values) if isinstance(saved_values, dict) else {}
|
||
merged_values.update(self._values())
|
||
self.state_store.update_step(
|
||
self.current_step.id,
|
||
variant=self.variant_var.get() or self.current_step.variants[0].id,
|
||
values=merged_values,
|
||
extra=(
|
||
self.extra_var.get()
|
||
if self.current_step.extra_arguments_help
|
||
else ""
|
||
),
|
||
)
|
||
|
||
def _evaluation_arg(self) -> str:
|
||
evaluation = self.evaluation
|
||
return evaluation_argument(self.repository, evaluation) if evaluation else "<évaluation>"
|
||
|
||
def _progression_ids(self, step_id: str) -> list[str]:
|
||
redo = step_id.startswith("refaire_")
|
||
return [step.id for step in self.steps if step.id.startswith("refaire_") == redo]
|
||
|
||
def _refaire_selection(self) -> list[list]:
|
||
evaluation = self.evaluation
|
||
if not evaluation or not self.state_store.evaluation:
|
||
raise ValueError("Chargez d’abord une évaluation.")
|
||
entry = self.state_store.step("refaire_selection")
|
||
if entry.get("status") != "success":
|
||
raise ValueError("Enregistrez d’abord les copies et les questions à refaire.")
|
||
values = entry.get("values", {})
|
||
selection = load_selection(evaluation)
|
||
if selection != validate_selection(values.get("selection"), available_copies(evaluation), EvaluationWorkspace(evaluation).read_labels()):
|
||
raise ValueError("La sélection a changé. Enregistrez-la avant de continuer.")
|
||
source = values.get("annotation_dir")
|
||
if source not in ANNOTATION_DIRECTORIES or not (evaluation / source).is_dir():
|
||
raise ValueError("Choisissez un dossier existant pour le passage principal.")
|
||
return selection
|
||
|
||
def _save_refaire_selection(self) -> None:
|
||
if not self.refaire_panel or not self.evaluation:
|
||
return
|
||
try:
|
||
if self.state_store.step("refaire_merge").get("status") == "success":
|
||
messagebox.showinfo("Reprise terminée", "Utilisez « Nouvelle reprise » ou « Refaire la même sélection » pour commencer un autre passage.")
|
||
return
|
||
values = self.refaire_panel.values()
|
||
entries = validate_selection(values["selection"], available_copies(self.evaluation), EvaluationWorkspace(self.evaluation).read_labels())
|
||
source = values["annotation_dir"]
|
||
if source not in detected_annotation_directories(self.evaluation):
|
||
raise ValueError("Choisissez un dossier existant pour le passage principal.")
|
||
workspace = EvaluationWorkspace(self.evaluation)
|
||
output = workspace.annotation_dir("refaire")
|
||
if output.is_dir() and any(output.iterdir()) and workspace.refaire_file.is_file():
|
||
from copienator import read_json
|
||
if read_json(workspace.refaire_file) != entries:
|
||
raise ValueError("Des PDF de vérification existent déjà. Importez et fusionnez leurs résultats, puis utilisez « Nouvelle reprise » pour changer de sélection.")
|
||
if workspace.refaire_session_dir is not None:
|
||
atomic_write_json(workspace.refaire_session_dir / "refaire.json", entries)
|
||
atomic_write_json(workspace.refaire_session_dir / "session.json", {"id": workspace.refaire_session_id, "values": values})
|
||
atomic_write_json(self.evaluation / "refaire.json", entries)
|
||
self._mark_step("success")
|
||
self._move_selection_from("refaire_selection", 1)
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
messagebox.showerror("Sélection à vérifier", str(exc))
|
||
|
||
def _refaire_commands(self) -> list[list[str]]:
|
||
assert self.current_step is not None
|
||
selection = self._refaire_selection()
|
||
values = self._values()
|
||
targets = [self._evaluation_arg()]
|
||
if self.current_step.id in {"refaire_review", "refaire_split"}:
|
||
copies = available_copies(self.evaluation)
|
||
targets = [str(copies[name]) for name, _labels in selection]
|
||
variant = self._current_variant()
|
||
if self.current_step.id == "refaire_annotate":
|
||
choice = self.state_store.step("refaire_selection").get("values", {}).get("layout", "auto")
|
||
layout = resolve_layout(selection, EvaluationWorkspace(self.evaluation).read_labels(), choice)
|
||
variant = replace(variant, program="annotate-grouped" if layout == "grouped" else "annotate-checks")
|
||
return [build_command(
|
||
self.repository,
|
||
self.current_step,
|
||
variant,
|
||
{**values, "target": target},
|
||
self._evaluation_arg(),
|
||
verbose=bool(self.verbose_var.get()),
|
||
) for target in targets]
|
||
|
||
def _make_command(self) -> list[str]:
|
||
if not self.current_step:
|
||
return []
|
||
if self.current_step.section == REFAIRE_SECTION:
|
||
return self._refaire_commands()[0]
|
||
allow_extra = bool(self.current_step.extra_arguments_help) and (
|
||
not self.current_step.extra_arguments_variants
|
||
or self._current_variant().id in self.current_step.extra_arguments_variants
|
||
)
|
||
return build_command(
|
||
self.repository,
|
||
self.current_step,
|
||
self._current_variant(),
|
||
self._values(),
|
||
self._evaluation_arg(),
|
||
self.extra_var.get() if allow_extra else "",
|
||
verbose=bool(self.verbose_var.get()),
|
||
)
|
||
|
||
def _update_command_preview(self) -> None:
|
||
if self._rendering or not self.current_step:
|
||
return
|
||
self.copy_command_button.configure(state="disabled")
|
||
if self.current_step.is_manual:
|
||
self.command_var.set("La sélection sera utilisée pour toutes les étapes de ce parcours." if self.current_step.id == "refaire_selection" else "Étape manuelle — aucune commande ne sera exécutée.")
|
||
return
|
||
try:
|
||
commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
|
||
preview = "\n".join(command_display(command) for command in commands[:2])
|
||
if len(commands) > 2:
|
||
preview += f"\nPuis {len(commands) - 2} autres copies, successivement."
|
||
self.command_var.set(preview)
|
||
self.copy_command_button.configure(state="normal")
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
self.command_var.set(f"Arguments invalides : {exc}")
|
||
|
||
def _copy_text(self, text: str) -> None:
|
||
self.clipboard_clear()
|
||
self.clipboard_append(text)
|
||
|
||
def _copy_command(self) -> None:
|
||
if not self.current_step or self.current_step.is_manual:
|
||
return
|
||
try:
|
||
commands = self._refaire_commands() if self.current_step.section == REFAIRE_SECTION else [self._make_command()]
|
||
self._copy_text("\n".join(command_display(command) for command in commands))
|
||
self.info_var.set("Commande(s) copiée(s) — à exécuter depuis " + str(self.repository))
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
self.info_var.set(f"Copie impossible : {exc}")
|
||
|
||
def _copy_console_selection(self, _event=None) -> str:
|
||
if self.console.tag_ranges("sel"):
|
||
self._copy_text(self.console.get("sel.first", "sel.last"))
|
||
return "break"
|
||
|
||
def _select_console_all(self, _event=None) -> str:
|
||
self.console.tag_add("sel", "1.0", "end-1c")
|
||
self.console.focus_set()
|
||
return "break"
|
||
|
||
def _copy_console_all(self) -> None:
|
||
self._copy_text(self.console.get("1.0", "end-1c"))
|
||
|
||
def _show_console_menu(self, event) -> str:
|
||
try:
|
||
self.console_menu.tk_popup(event.x_root, event.y_root)
|
||
finally:
|
||
self.console_menu.grab_release()
|
||
return "break"
|
||
|
||
def _browse_target_file(self, variable: tk.Variable) -> None:
|
||
selected = filedialog.askopenfilename(initialdir=self.evaluation or self.repository)
|
||
if selected:
|
||
variable.set(selected)
|
||
|
||
def _browse_target_dir(self, variable: tk.Variable) -> None:
|
||
selected = filedialog.askdirectory(initialdir=self.evaluation or self.repository)
|
||
if selected:
|
||
variable.set(selected)
|
||
|
||
def _validate_arguments(self) -> bool:
|
||
if not self.current_step:
|
||
return False
|
||
variant = self._current_variant()
|
||
for spec in self.current_step.arguments:
|
||
if spec.variants and variant.id not in spec.variants:
|
||
continue
|
||
value = self.arg_vars.get(spec.name)
|
||
if spec.kind == "int" and value and str(value.get()).strip():
|
||
try:
|
||
int(str(value.get()))
|
||
except ValueError:
|
||
messagebox.showerror("Argument invalide", f"« {spec.label} » doit être un entier.")
|
||
return False
|
||
try:
|
||
self._make_command()
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
messagebox.showerror("Arguments invalides", str(exc))
|
||
return False
|
||
return True
|
||
|
||
def _run_current_step(self) -> None:
|
||
step = self.current_step
|
||
evaluation = self.evaluation
|
||
if not step or not evaluation or not self.state_store.evaluation:
|
||
messagebox.showerror("Évaluation absente", "Chargez d’abord un dossier d’évaluation.")
|
||
return
|
||
if step.id == "batch_status" and self.batch_monitor.active:
|
||
return
|
||
if self.active_step_id or self.runner.running:
|
||
messagebox.showwarning("Traitement en cours", "Interrompez le traitement actuel avant d’en lancer un autre.")
|
||
return
|
||
if step.id == "refaire_selection":
|
||
self._save_refaire_selection()
|
||
return
|
||
if step.section == REFAIRE_SECTION:
|
||
try:
|
||
self._refaire_selection()
|
||
except (OSError, ValueError, TypeError) as exc:
|
||
messagebox.showerror("Sélection à vérifier", str(exc))
|
||
return
|
||
if step.is_manual:
|
||
self._mark_step("success")
|
||
if step.section == REFAIRE_SECTION:
|
||
self._move_selection_from(step.id, 1)
|
||
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)
|
||
if missing and not messagebox.askyesno(
|
||
"Prérequis non détectés",
|
||
"Les éléments suivants n’ont pas été trouvés :\n\n"
|
||
+ "\n".join(missing)
|
||
+ "\n\nLancer tout de même la commande ?",
|
||
):
|
||
return
|
||
variant = self._current_variant()
|
||
command = self._make_command()
|
||
if variant.dangerous and not messagebox.askyesno(
|
||
"Nettoyage irréversible" if step.id == "clean" else "Confirmation requise",
|
||
variant.danger_warning
|
||
or "Cette commande réinitialise ou supprime des résultats de correction. Continuer ?",
|
||
icon="warning",
|
||
):
|
||
return
|
||
|
||
self._save_current_form()
|
||
run_values = self._values()
|
||
ordered_ids = self._progression_ids(step.id)
|
||
self.state_store.invalidate_after(ordered_ids, step.id)
|
||
self.state_store.update_step(
|
||
step.id,
|
||
status="running",
|
||
command=command_display(command),
|
||
last_run_variant=variant.id,
|
||
last_run_values=run_values,
|
||
)
|
||
self.active_step_id = step.id
|
||
workspace = self.state_store.workspace
|
||
assert workspace is not None
|
||
log_path = workspace.log_path(step.id)
|
||
|
||
environment = build_runner_environment(
|
||
os.environ,
|
||
self.api_key_var.get(),
|
||
self.proxy_var.get(),
|
||
self.use_proxy_var.get(),
|
||
)
|
||
|
||
self.pending_refaire_commands = self._refaire_commands()[1:] if step.section == REFAIRE_SECTION else []
|
||
self.refaire_command_index = 1
|
||
self._append_console(f"\n$ {command_display(command)}\n")
|
||
try:
|
||
self.runner.start(command, self.repository, environment, log_path)
|
||
except (OSError, RuntimeError) as exc:
|
||
self.state_store.update_step(step.id, status="failed")
|
||
self.state_store.add_history(
|
||
{"step": step.id, "command": command_display(command), "status": "failed", "error": str(exc)}
|
||
)
|
||
self.active_step_id = None
|
||
self.pending_refaire_commands = []
|
||
self._append_console(f"Impossible de lancer la commande : {exc}\n")
|
||
messagebox.showerror("Échec du lancement", str(exc))
|
||
self._populate_tree()
|
||
self._update_controls()
|
||
|
||
def _mark_step(
|
||
self, status: str, *, automatic: bool = False, reason: str | None = None
|
||
) -> None:
|
||
if not self.current_step or not self.state_store.evaluation or self.active_step_id:
|
||
return
|
||
self._save_current_form()
|
||
self.state_store.invalidate_after(self._progression_ids(self.current_step.id), self.current_step.id)
|
||
self.state_store.update_step(self.current_step.id, status=status)
|
||
history: dict[str, object] = {
|
||
"step": self.current_step.id,
|
||
"status": status,
|
||
"manual": not automatic,
|
||
}
|
||
if reason:
|
||
history["reason"] = reason
|
||
self.state_store.add_history(history)
|
||
self._populate_tree()
|
||
detail = f" ({reason})" if reason else ""
|
||
self.info_var.set(
|
||
f"{self.current_step.title} : {STATUS_LABELS.get(status, status).lower()}{detail}."
|
||
)
|
||
|
||
def _validate_rename_step(self) -> None:
|
||
if (not self.current_step or self.current_step.id != "rename"
|
||
or not self.state_store.evaluation or self.active_step_id or self.runner.running):
|
||
return
|
||
self._save_current_form()
|
||
self.state_store.update_step("rename", status="success")
|
||
self.state_store.add_history({
|
||
"step": "rename", "status": "success", "manual": True,
|
||
"reason": "Noms des PDF validés sans renommer",
|
||
})
|
||
self._populate_tree()
|
||
self.info_var.set("Renommage validé — aucun fichier modifié.")
|
||
|
||
def _skip_step(self) -> None:
|
||
if self.current_step and self.current_step.optional:
|
||
step_id = self.current_step.id
|
||
self._mark_step("skipped")
|
||
if step_id.startswith("refaire_"):
|
||
self._move_selection_from(step_id, 1)
|
||
|
||
def _poll_runner(self) -> None:
|
||
while True:
|
||
try:
|
||
event, payload = self.runner.events.get_nowait()
|
||
except queue.Empty:
|
||
break
|
||
if event == "output":
|
||
self._append_console(str(payload))
|
||
elif event == "input_echo":
|
||
self._append_console(f"> {payload}")
|
||
elif event == "runner_error":
|
||
self._append_console(f"\nErreur du lanceur : {payload}\n")
|
||
elif event == "finished":
|
||
return_code, interrupted = payload
|
||
self._finish_process(int(return_code), bool(interrupted))
|
||
self.after(60, self._poll_runner)
|
||
|
||
def _batch_watch_status_changed(self, message: str) -> None:
|
||
self.batch_watch_status.set(message)
|
||
self._update_controls()
|
||
|
||
def _start_batch_watch(self) -> None:
|
||
if self.active_step_id or self.runner.running or not self.state_store.evaluation:
|
||
return
|
||
step = self.step_by_id["batch_status"]
|
||
command = build_command(self.repository, step, step.variants[0], {},
|
||
str(self.state_store.evaluation))
|
||
environment = build_runner_environment(
|
||
os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get()
|
||
)
|
||
self.batch_monitor.start(command, self.repository, environment,
|
||
self.state_store.workspace.log_path("batch_status_watch"))
|
||
|
||
def _batch_results_ready(self) -> None:
|
||
evaluation = self.state_store.evaluation
|
||
if evaluation is None:
|
||
return
|
||
self.state_store.update_step("batch_status", status="success", return_code=0)
|
||
self._populate_tree()
|
||
message = f"{evaluation.name} : tous les résultats batch sont prêts à récupérer."
|
||
self.info_var.set(message)
|
||
try:
|
||
notify_desktop("Copienator — batchs terminés", message)
|
||
except (OSError, RuntimeError) as exc:
|
||
self.bell()
|
||
self._append_console(f"Notification de bureau indisponible : {exc}\n{message}\n")
|
||
if (self.current_step and self.current_step.id == "batch_status"
|
||
and not self.active_step_id and not self.runner.running):
|
||
self._move_selection_from("batch_status", 1)
|
||
|
||
def _finish_process(self, return_code: int, interrupted: bool) -> None:
|
||
step_id = self.active_step_id
|
||
if not step_id:
|
||
return
|
||
status = process_status(return_code, interrupted)
|
||
if status == "success" and self.pending_refaire_commands:
|
||
command = self.pending_refaire_commands.pop(0)
|
||
self.refaire_command_index += 1
|
||
self._append_console(f"\n$ {command_display(command)}\n")
|
||
self.state_store.update_step(step_id, command=command_display(command))
|
||
try:
|
||
self.runner.start(command, self.repository,
|
||
build_runner_environment(os.environ, self.api_key_var.get(), self.proxy_var.get(), self.use_proxy_var.get()),
|
||
self.state_store.workspace.log_path(f"{step_id}_{self.refaire_command_index}"))
|
||
self._update_controls()
|
||
return
|
||
except (OSError, RuntimeError) as exc:
|
||
self._append_console(f"Impossible de lancer la copie suivante : {exc}\n")
|
||
status, return_code = "failed", 1
|
||
self.pending_refaire_commands = []
|
||
if step_id == "refaire_merge" and status == "success":
|
||
for downstream in ("giving_names", "update_ods", "final_score", "personal_deploy", "personal_sent"):
|
||
entry = self.state_store.step(downstream)
|
||
if entry.get("status") in {"success", "detected", "skipped"}:
|
||
self.state_store.update_step(downstream, status="stale")
|
||
if step_id == "clean" and status == "success":
|
||
self._append_console(
|
||
f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n"
|
||
)
|
||
self.info_var.set(
|
||
"Nettoyage terminé : la progression précédente a été supprimée."
|
||
)
|
||
self.active_step_id = None
|
||
# Do not recreate .copienator-gui.json after clean removed it.
|
||
self.state_store = StateStore()
|
||
self._populate_tree()
|
||
self._update_controls()
|
||
messagebox.showinfo(
|
||
"Nettoyage terminé",
|
||
"Les fichiers intermédiaires et la progression du GUI ont été "
|
||
"supprimés. Les copies traitées, les fichiers textuels de l’énoncé, "
|
||
"correction.json, les journaux et les livrables de A Rendre ont "
|
||
"été conservés.",
|
||
)
|
||
return
|
||
self.state_store.update_step(step_id, status=status, return_code=return_code)
|
||
self.state_store.add_history(
|
||
{
|
||
"step": step_id,
|
||
"command": self.state_store.step(step_id).get("command", ""),
|
||
"status": status,
|
||
"return_code": return_code,
|
||
}
|
||
)
|
||
title = self.step_by_id[step_id].title
|
||
self._append_console(f"\n[Terminé — code {return_code} — {STATUS_LABELS[status]}]\n")
|
||
self.info_var.set(f"{title} : {STATUS_LABELS[status].lower()} (code {return_code}).")
|
||
self.active_step_id = None
|
||
self._populate_tree()
|
||
self._update_controls()
|
||
if status == "success":
|
||
if step_id == "batch_status" and self.batch_monitor.active:
|
||
self.batch_monitor.stop()
|
||
self._batch_results_ready()
|
||
return
|
||
self.after_idle(lambda completed_id=step_id: self._move_selection_from(completed_id, 1))
|
||
|
||
def _send_input(self) -> None:
|
||
text = self.stdin_var.get()
|
||
try:
|
||
self.runner.send_input(text)
|
||
except RuntimeError as exc:
|
||
messagebox.showinfo("Aucune saisie attendue", str(exc))
|
||
return
|
||
self.stdin_var.set("")
|
||
|
||
def _interrupt(self) -> None:
|
||
if self.runner.running and messagebox.askyesno(
|
||
"Interrompre", "Envoyer une interruption au script en cours ?"
|
||
):
|
||
try:
|
||
self.runner.interrupt()
|
||
self.info_var.set("Interruption demandée. Utilisez « Forcer l’arrêt » si le script ne répond pas.")
|
||
except OSError as exc:
|
||
messagebox.showerror("Interruption impossible", str(exc))
|
||
|
||
def _force_stop(self) -> None:
|
||
if self.runner.running and messagebox.askyesno(
|
||
"Forcer l’arrêt", "Forcer l’arrêt du script et de ses processus enfants ?", icon="warning"
|
||
):
|
||
try:
|
||
self.runner.force_stop()
|
||
except OSError as exc:
|
||
messagebox.showerror("Arrêt impossible", str(exc))
|
||
|
||
def _append_console(self, text: str) -> None:
|
||
self.console.configure(state="normal")
|
||
self.console.insert("end", text)
|
||
self.console.see("end")
|
||
self.console.configure(state="disabled")
|
||
|
||
def _clear_console(self) -> None:
|
||
self.console.configure(state="normal")
|
||
self.console.delete("1.0", "end")
|
||
self.console.configure(state="disabled")
|
||
|
||
def _update_controls(self) -> None:
|
||
running = bool(self.active_step_id) or self.runner.running
|
||
if self.current_step and self.current_step.id == "batch_status":
|
||
self.watch_batches_button.configure(state="disabled" if running or self.batch_monitor.active or not self.state_store.evaluation else "normal")
|
||
self.stop_watch_batches_button.configure(state="normal" if self.batch_monitor.active else "disabled")
|
||
if running and self.current_step and self.current_step.id in {"page_splitter", "cutleft"}:
|
||
self.marked_copies_button.configure(state="disabled")
|
||
if self.current_step and self.current_step.id == "rename":
|
||
self.validate_rename_button.configure(
|
||
state="disabled" if running or not self.state_store.evaluation else "normal"
|
||
)
|
||
watching_current = bool(self.current_step and self.current_step.id == "batch_status"
|
||
and self.batch_monitor.active)
|
||
self.run_button.configure(state="disabled" if running or not self.current_step or watching_current else "normal")
|
||
self.skip_button.configure(state="normal" if not running and self.current_step and self.current_step.optional else "disabled")
|
||
self.interrupt_button.configure(state="normal" if running else "disabled")
|
||
self.force_button.configure(state="normal" if running else "disabled")
|
||
self.send_button.configure(state="normal" if running else "disabled")
|
||
self.stdin_entry.configure(state="normal" if running else "disabled")
|
||
|
||
def _move_selection(self, delta: int) -> None:
|
||
if not self.current_step:
|
||
return
|
||
self._move_selection_from(self.current_step.id, delta)
|
||
|
||
def _move_selection_from(self, step_id: str, delta: int) -> None:
|
||
ids = self._progression_ids(step_id)
|
||
try:
|
||
index = ids.index(step_id)
|
||
except ValueError:
|
||
return
|
||
target = max(0, min(len(ids) - 1, index + delta))
|
||
if target == index:
|
||
return
|
||
self.tree.selection_set(ids[target])
|
||
self.tree.see(ids[target])
|
||
|
||
def _on_close(self) -> None:
|
||
if self.runner.running:
|
||
if not messagebox.askyesno(
|
||
"Traitement en cours", "Un script est encore actif. Le forcer à s’arrêter et fermer l’application ?"
|
||
):
|
||
return
|
||
try:
|
||
self.runner.force_stop()
|
||
except OSError:
|
||
pass
|
||
self._save_current_form()
|
||
self.batch_monitor.stop()
|
||
self.destroy()
|