miscs (Interro02) : horizontal cutting resolution
This commit is contained in:
+84
-2
@@ -21,6 +21,9 @@ from copienator.platform import (
|
||||
)
|
||||
|
||||
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,
|
||||
@@ -274,6 +277,10 @@ class CopienatorApp(tk.Tk):
|
||||
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())
|
||||
@@ -543,6 +550,7 @@ class CopienatorApp(tk.Tk):
|
||||
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:
|
||||
@@ -744,7 +752,7 @@ class CopienatorApp(tk.Tk):
|
||||
return
|
||||
self._rendering = True
|
||||
redo = step.section == REFAIRE_SECTION
|
||||
expanded_form = redo or step.id in {"page_splitter", "crop_blank_margins", "cutleft", "labels"}
|
||||
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)
|
||||
@@ -830,6 +838,18 @@ class CopienatorApp(tk.Tk):
|
||||
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
|
||||
@@ -852,6 +872,23 @@ class CopienatorApp(tk.Tk):
|
||||
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
|
||||
@@ -1299,6 +1336,8 @@ class CopienatorApp(tk.Tk):
|
||||
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
|
||||
@@ -1448,6 +1487,39 @@ class CopienatorApp(tk.Tk):
|
||||
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:
|
||||
@@ -1509,6 +1581,10 @@ class CopienatorApp(tk.Tk):
|
||||
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:
|
||||
@@ -1552,13 +1628,18 @@ class CopienatorApp(tk.Tk):
|
||||
|
||||
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"
|
||||
)
|
||||
self.run_button.configure(state="disabled" if running or not self.current_step 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")
|
||||
@@ -1593,4 +1674,5 @@ class CopienatorApp(tk.Tk):
|
||||
except OSError:
|
||||
pass
|
||||
self._save_current_form()
|
||||
self.batch_monitor.stop()
|
||||
self.destroy()
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Non-blocking, cancellable batch checks while the desktop GUI is open."""
|
||||
|
||||
import queue
|
||||
|
||||
from .runner import ProcessRunner
|
||||
|
||||
CHECK_INTERVAL_MS = 5 * 60 * 1000
|
||||
|
||||
|
||||
class BatchMonitor:
|
||||
def __init__(self, scheduler, on_ready, on_status, on_output):
|
||||
self.scheduler = scheduler
|
||||
self.on_ready = on_ready
|
||||
self.on_status = on_status
|
||||
self.on_output = on_output
|
||||
self.active = False
|
||||
self.timer = None
|
||||
self.runner = None
|
||||
|
||||
def start(self, command, cwd, environment, log_path):
|
||||
if self.active:
|
||||
return
|
||||
self.arguments = (command, cwd, environment, log_path)
|
||||
self.active = True
|
||||
self._check()
|
||||
|
||||
def stop(self):
|
||||
self.active = False
|
||||
if self.timer is not None:
|
||||
self.scheduler.after_cancel(self.timer)
|
||||
self.timer = None
|
||||
if self.runner is not None:
|
||||
try:
|
||||
self.runner.force_stop()
|
||||
except OSError as exc:
|
||||
self.on_output(f"Arrêt de la vérification : {exc}\n")
|
||||
self.runner = None
|
||||
self.on_status("Vérification automatique arrêtée.")
|
||||
|
||||
def _later(self):
|
||||
self.timer = self.scheduler.after(CHECK_INTERVAL_MS, self._check)
|
||||
|
||||
def _check(self):
|
||||
self.timer = None
|
||||
if not self.active:
|
||||
return
|
||||
self.runner = ProcessRunner()
|
||||
self.on_status("Vérification des batchs en cours…")
|
||||
try:
|
||||
self.runner.start(*self.arguments)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
self.on_output(f"Vérification impossible : {exc}\n")
|
||||
self.runner = None
|
||||
self.on_status("Échec de la vérification. Nouvel essai dans 5 minutes.")
|
||||
self._later()
|
||||
return
|
||||
self.timer = self.scheduler.after(100, self._poll)
|
||||
|
||||
def _poll(self):
|
||||
self.timer = None
|
||||
if not self.active or self.runner is None:
|
||||
return
|
||||
while True:
|
||||
try:
|
||||
event, payload = self.runner.events.get_nowait()
|
||||
except queue.Empty:
|
||||
break
|
||||
if event in {"output", "runner_error"}:
|
||||
self.on_output(str(payload))
|
||||
elif event == "finished":
|
||||
code, interrupted = payload
|
||||
self.runner = None
|
||||
if code == 0 and not interrupted:
|
||||
self.active = False
|
||||
self.on_status("Tous les résultats batch sont prêts.")
|
||||
self.on_ready()
|
||||
else:
|
||||
self.on_status("Résultats pas encore prêts. Nouvelle vérification dans 5 minutes.")
|
||||
self._later()
|
||||
return
|
||||
self.timer = self.scheduler.after(100, self._poll)
|
||||
@@ -0,0 +1,131 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
import pymupdf
|
||||
from PIL import Image, ImageTk
|
||||
|
||||
from copienator.pdf_cut import cut_position
|
||||
|
||||
PAGE_GAP = 28
|
||||
SNAP_PIXELS = 12
|
||||
|
||||
|
||||
def percentage_at_y(y: float, heights: list[float], scale: float) -> float:
|
||||
"""Convert canvas y to document height, snapping across inter-page gaps."""
|
||||
top = 0.0
|
||||
cumulative = 0.0
|
||||
total = sum(heights)
|
||||
for index, height in enumerate(heights):
|
||||
bottom = top + height * scale
|
||||
if index + 1 < len(heights) and bottom - SNAP_PIXELS <= y <= bottom + PAGE_GAP + SNAP_PIXELS:
|
||||
return (cumulative + height) / total * 100
|
||||
if y <= bottom:
|
||||
return max(0.0, min(100.0, (cumulative + (y - top) / scale) / total * 100))
|
||||
cumulative += height
|
||||
top = bottom + PAGE_GAP
|
||||
return 100.0
|
||||
|
||||
|
||||
def y_at_percentage(percent: float, heights: list[float], scale: float) -> float:
|
||||
if percent <= 0:
|
||||
return 0.0
|
||||
if percent >= 100:
|
||||
return sum(heights) * scale + (len(heights) - 1) * PAGE_GAP
|
||||
index, offset = cut_position(heights, percent)
|
||||
top = sum(heights[:index]) * scale + index * PAGE_GAP
|
||||
return top - PAGE_GAP / 2 if index and offset == 0 else top + offset * scale
|
||||
|
||||
|
||||
def cut_operator(percent: float, keep: int, mode: str) -> str:
|
||||
value = f"{percent:.6f}".rstrip("0").rstrip(".")
|
||||
return f"c{{{value}}}{keep}{mode}"
|
||||
|
||||
|
||||
class CutHelper(tk.Toplevel):
|
||||
def __init__(self, parent, path: Path, on_accept, initial=None):
|
||||
# Load the source before creating a window so invalid PDFs leave no dialog.
|
||||
with pymupdf.open(path) as document:
|
||||
if not len(document):
|
||||
raise ValueError("Le PDF est vide.")
|
||||
self.heights = [page.rect.height for page in document]
|
||||
width = min(850, parent.winfo_screenwidth() - 100)
|
||||
self.scale = min(1.5, width / max(page.rect.width for page in document))
|
||||
rendered = []
|
||||
for page in document:
|
||||
pix = page.get_pixmap(matrix=pymupdf.Matrix(self.scale, self.scale), alpha=False,
|
||||
colorspace=pymupdf.csRGB)
|
||||
rendered.append(Image.frombytes("RGB", (pix.width, pix.height), pix.samples))
|
||||
super().__init__(parent)
|
||||
self.title(f"Cut — {path.name}")
|
||||
self.geometry(f"{width + 45}x{min(850, parent.winfo_screenheight() - 100)}")
|
||||
self.transient(parent.winfo_toplevel())
|
||||
self.on_accept = on_accept
|
||||
self.percent = initial[0] if initial else 50.0
|
||||
self.keep = tk.IntVar(value=initial[1] if initial else 1)
|
||||
self.mode = tk.StringVar(value=initial[2] if initial else ">")
|
||||
self.caption = tk.StringVar()
|
||||
ttk.Label(self, text="Déplacez la barre rouge. Entrée : afficher la commande ; Échap : annuler.",
|
||||
wraplength=width).pack(anchor="w", padx=8, pady=5)
|
||||
controls = ttk.Frame(self)
|
||||
controls.pack(fill="x", padx=8)
|
||||
ttk.Label(controls, text="Conserver à la source :").pack(side="left")
|
||||
ttk.Radiobutton(controls, text="1 — début", variable=self.keep, value=1).pack(side="left")
|
||||
ttk.Radiobutton(controls, text="2 — fin", variable=self.keep, value=2).pack(side="left")
|
||||
ttk.Radiobutton(controls, text="Ajouter à la cible", variable=self.mode, value=">").pack(side="left")
|
||||
ttk.Radiobutton(controls, text="Remplacer", variable=self.mode, value="x").pack(side="left")
|
||||
ttk.Label(self, textvariable=self.caption).pack(anchor="w", padx=8, pady=5)
|
||||
viewport = ttk.Frame(self)
|
||||
viewport.pack(fill="both", expand=True)
|
||||
self.canvas = tk.Canvas(viewport, background="#555555", highlightthickness=0)
|
||||
scrollbar = ttk.Scrollbar(viewport, command=self.canvas.yview)
|
||||
self.canvas.configure(yscrollcommand=scrollbar.set)
|
||||
scrollbar.pack(side="right", fill="y")
|
||||
self.canvas.pack(fill="both", expand=True)
|
||||
self.images = [ImageTk.PhotoImage(image, master=self) for image in rendered]
|
||||
top = 0.0
|
||||
self.width = width
|
||||
for index, image in enumerate(self.images):
|
||||
self.canvas.create_image(0, top, anchor="nw", image=image)
|
||||
top += self.heights[index] * self.scale
|
||||
if index + 1 < len(self.images):
|
||||
top += PAGE_GAP
|
||||
self.canvas.configure(scrollregion=(0, 0, width, top))
|
||||
self.bar = self.canvas.create_line(0, 0, width, 0, fill="#ff3030", width=4)
|
||||
self.canvas.bind("<Button-1>", self.move_bar)
|
||||
self.canvas.bind("<B1-Motion>", self.move_bar)
|
||||
self.canvas.bind("<Button-4>", lambda event: self.canvas.yview_scroll(-3, "units"))
|
||||
self.canvas.bind("<Button-5>", lambda event: self.canvas.yview_scroll(3, "units"))
|
||||
self.canvas.bind("<MouseWheel>", lambda event: self.canvas.yview_scroll(-1 if event.delta > 0 else 1, "units"))
|
||||
self.bind("<Return>", self.accept)
|
||||
self.bind("<Escape>", lambda event: self.destroy())
|
||||
self.keep.trace_add("write", lambda *_: self.draw_bar())
|
||||
self.mode.trace_add("write", lambda *_: self.draw_bar())
|
||||
self.draw_bar()
|
||||
self.canvas.yview_moveto(max(0, (y_at_percentage(self.percent, self.heights, self.scale) - 200) / top))
|
||||
self.focus_set()
|
||||
self.grab_set()
|
||||
|
||||
def move_bar(self, event):
|
||||
self.percent = percentage_at_y(self.canvas.canvasy(event.y), self.heights, self.scale)
|
||||
self.draw_bar()
|
||||
|
||||
def draw_bar(self):
|
||||
y = y_at_percentage(self.percent, self.heights, self.scale)
|
||||
self.canvas.coords(self.bar, 0, y, self.width, y)
|
||||
text = cut_operator(self.percent, self.keep.get(), self.mode.get())
|
||||
if 0 < self.percent < 100:
|
||||
index, offset = cut_position(self.heights, self.percent)
|
||||
text += f" — entre les pages {index} et {index + 1}" if offset == 0 else f" — page {index + 1}"
|
||||
self.caption.set(text)
|
||||
|
||||
def accept(self, event=None):
|
||||
operator = cut_operator(self.percent, self.keep.get(), self.mode.get())
|
||||
rounded = float(operator.split("{")[1].split("}")[0])
|
||||
if not 0 < rounded < 100:
|
||||
messagebox.showerror("Coupe invalide", "Chaque partie doit contenir une portion du PDF.", parent=self)
|
||||
return
|
||||
self.destroy()
|
||||
self.on_accept(operator)
|
||||
@@ -0,0 +1,154 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import tkinter as tk
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
|
||||
from copienator import CliError
|
||||
from copienator.commands.resolve_manual import get_actual_pdf, parse_instruction_text
|
||||
from copienator.platform import open_path
|
||||
from .cut_helper import CutHelper
|
||||
|
||||
|
||||
HELP = """La correction propose x> pour un mauvais label et -> pour une réponse supplémentaire lorsque le PDF cible existe déjà. Vérifiez les deux PDF avant de choisir.
|
||||
|
||||
Format : Copie01 Label source OP Label cible|
|
||||
Conservez Copie suivi du numéro et les labels exacts (sans .pdf). Les espaces dans les labels sont acceptés ; entourez l’opérateur d’espaces.
|
||||
|
||||
-> : fusionner dans la cible, conserver la source.
|
||||
x> : fusionner dans la cible, archiver la source.
|
||||
-x : remplacer la cible par une copie de la source, conserver la source.
|
||||
xx : remplacer la cible par une copie de la source, archiver la source.
|
||||
ss : conserver les deux PDF sans fusion.
|
||||
sx : conserver la source, archiver la cible, sans fusion.
|
||||
xs : archiver la source, conserver la cible, sans fusion.
|
||||
|
||||
c{43}1> : couper la source à 43 %, conserver le début et ajouter la fin à la cible.
|
||||
c{43}2x : couper la source à 43 %, conserver la fin et remplacer la cible par le début.
|
||||
1 conserve le début, 2 conserve la fin ; > fusionne l’autre partie avec la cible, x la remplace. Le pourcentage porte sur la hauteur cumulée des pages visibles. Les décimales sont acceptées. Une seule coupe par label source est autorisée.
|
||||
Cut permet de placer la coupe, avec accrochage entre les pages. Entrée ferme l’aperçu et affiche la commande à recopier ci-dessus : aucun fichier n’est modifié. Une séparation entre pages conserve les pages entières. La source complète est archivée en _old ; les deux labels modifiés sont générés en _new et ajoutés à refaire.json.
|
||||
|
||||
Pour les fusions : « Source x> Cible| » place la cible avant la source ; « Source x> |Cible » place la source avant la cible. Même règle avec -> et c{…}1>/c{…}2> (pour la partie transférée) ; sans |, la cible vient en premier.
|
||||
Vous pouvez changer l’opérateur, déplacer |, corriger les labels ou retirer une instruction. Les lignes vides et celles commençant par ### sont ignorées. Retirer/commenter une ligne ne résout pas son conflit.
|
||||
|
||||
Enregistrez dans l’éditeur, puis cliquez sur Recharger et enfin sur Exécuter. Seul le fichier enregistré est appliqué. L’archivage utilise le suffixe _old ; les PDF créés utilisent _new. Après succès, manual_resolutions.txt est supprimé et correction.json est mis à jour. Si des PDF sont créés, refaire.json est généré : relancez la correction avec --refaire."""
|
||||
|
||||
|
||||
class ManualResolutionPanel(ttk.Frame):
|
||||
def __init__(self, parent, get_evaluation):
|
||||
super().__init__(parent)
|
||||
self.get_evaluation = get_evaluation
|
||||
self.pdf_buttons = []
|
||||
self.cut_buttons = []
|
||||
actions = ttk.Frame(self)
|
||||
actions.pack(fill="x")
|
||||
self.editor_button = ttk.Button(
|
||||
actions, text="Ouvrir dans un éditeur de texte", command=self.open_editor
|
||||
)
|
||||
self.editor_button.pack(side="left")
|
||||
ttk.Button(actions, text="Recharger", command=self.reload).pack(side="left", padx=6)
|
||||
self.cut_result = tk.StringVar()
|
||||
result = ttk.Frame(self)
|
||||
result.pack(fill="x", pady=4)
|
||||
ttk.Entry(result, textvariable=self.cut_result, state="readonly").pack(side="left", fill="x", expand=True)
|
||||
ttk.Button(result, text="Copier la commande Cut", command=self.copy_cut_command).pack(side="left", padx=6)
|
||||
self.status = ttk.Label(self, wraplength=650)
|
||||
self.status.pack(fill="x", pady=4)
|
||||
preview = ttk.Frame(self)
|
||||
preview.pack(fill="both", expand=True)
|
||||
self.text = tk.Text(preview, height=10, width=50, wrap="word", state="disabled")
|
||||
scroll = ttk.Scrollbar(preview, command=self.text.yview)
|
||||
self.text.configure(yscrollcommand=scroll.set)
|
||||
scroll.pack(side="right", fill="y")
|
||||
self.text.pack(fill="both", expand=True)
|
||||
ttk.Label(self, text=HELP, wraplength=650, justify="left").pack(fill="x", pady=8)
|
||||
self.reload()
|
||||
|
||||
def manual_path(self) -> Path | None:
|
||||
evaluation = self.get_evaluation()
|
||||
return evaluation / "manual_resolutions.txt" if evaluation else None
|
||||
|
||||
def open_editor(self):
|
||||
path = self.manual_path()
|
||||
if path is None or not path.is_file():
|
||||
self.reload()
|
||||
return
|
||||
try:
|
||||
# .txt is opened in the desktop's associated text editor.
|
||||
open_path(path)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
messagebox.showerror("Ouverture impossible", str(exc))
|
||||
|
||||
def open_pdf(self, evaluation, copy_id, label):
|
||||
path = get_actual_pdf(evaluation / "Copies", copy_id, label)
|
||||
try:
|
||||
if not path.is_file():
|
||||
raise FileNotFoundError(f"PDF introuvable : {path}")
|
||||
open_path(path)
|
||||
except (OSError, RuntimeError) as exc:
|
||||
messagebox.showerror("Ouverture du PDF", str(exc))
|
||||
|
||||
def reload(self):
|
||||
for button in self.pdf_buttons + self.cut_buttons:
|
||||
button.destroy()
|
||||
self.pdf_buttons.clear()
|
||||
self.cut_buttons.clear()
|
||||
self.cut_result.set("")
|
||||
self.text.configure(state="normal")
|
||||
self.text.delete("1.0", "end")
|
||||
path = self.manual_path()
|
||||
self.editor_button.configure(state="disabled")
|
||||
try:
|
||||
if path is None:
|
||||
raise FileNotFoundError("Chargez une évaluation.")
|
||||
content = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
self.status.configure(text=f"manual_resolutions.txt indisponible : {exc}")
|
||||
else:
|
||||
self.editor_button.configure(state="normal")
|
||||
malformed = []
|
||||
for number, raw in enumerate(content.splitlines(keepends=True), 1):
|
||||
self.text.insert("end", raw.rstrip("\r\n"))
|
||||
try:
|
||||
instructions = parse_instruction_text(raw)
|
||||
except CliError:
|
||||
malformed.append(str(number))
|
||||
else:
|
||||
if instructions:
|
||||
instruction = instructions[0]
|
||||
for title, label in (("PDF source", instruction.old_label),
|
||||
("PDF cible", instruction.new_label)):
|
||||
button = ttk.Button(
|
||||
self.text, text=title,
|
||||
command=lambda label=label, copy_id=instruction.copy_id, root=path.parent: self.open_pdf(root, copy_id, label),
|
||||
)
|
||||
self.pdf_buttons.append(button)
|
||||
self.text.window_create("end", window=button, padx=8)
|
||||
button = ttk.Button(self.text, text="Cut",
|
||||
command=lambda item=instruction, root=path.parent: self.open_cut(root, item))
|
||||
self.cut_buttons.append(button)
|
||||
self.text.window_create("end", window=button, padx=8)
|
||||
if raw.endswith("\n"):
|
||||
self.text.insert("end", "\n")
|
||||
detail = (" — lignes invalides : " + ", ".join(malformed)) if malformed else (
|
||||
f" — {len(self.pdf_buttons) // 2} instruction(s)"
|
||||
)
|
||||
self.status.configure(text=str(path) + detail)
|
||||
finally:
|
||||
self.text.configure(state="disabled")
|
||||
|
||||
def copy_cut_command(self):
|
||||
if self.cut_result.get():
|
||||
self.clipboard_clear()
|
||||
self.clipboard_append(self.cut_result.get())
|
||||
|
||||
def open_cut(self, evaluation, instruction):
|
||||
source = get_actual_pdf(evaluation / "Copies", instruction.copy_id, instruction.old_label)
|
||||
def accepted(operator):
|
||||
target = ("|" + instruction.new_label) if instruction.pipe_first else (instruction.new_label + "|")
|
||||
self.cut_result.set(f"Copie{instruction.copy_id} {instruction.old_label} {operator} {target}")
|
||||
try:
|
||||
initial = (*instruction.cut, instruction.operator[-1]) if instruction.cut else None
|
||||
CutHelper(self, source, accepted, initial)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
messagebox.showerror("Ouverture du PDF", str(exc))
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Launch native desktop notifications without blocking Tk."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from copienator.platform import find_executable
|
||||
|
||||
|
||||
def notify_desktop(title: str, message: str) -> None:
|
||||
if sys.platform.startswith("linux"):
|
||||
executable = find_executable("notify-send")
|
||||
if not executable:
|
||||
raise RuntimeError("Installez notify-send (libnotify) pour les notifications de bureau.")
|
||||
command = [executable, "--app-name=Copienator", "--", title, message]
|
||||
elif sys.platform == "darwin":
|
||||
command = ["/usr/bin/osascript", "-e",
|
||||
f"display notification {json.dumps(message, ensure_ascii=False)} with title {json.dumps(title, ensure_ascii=False)}"]
|
||||
elif os.name == "nt":
|
||||
# Encode the script and quote strings as literals; no shell interpolation.
|
||||
quote = lambda value: "'" + value.replace("'", "''") + "'"
|
||||
script = (
|
||||
"Add-Type -AssemblyName System.Windows.Forms;"
|
||||
"$notice = New-Object System.Windows.Forms.NotifyIcon;"
|
||||
"$notice.Icon = [System.Drawing.SystemIcons]::Information;"
|
||||
"$notice.Visible = $true;"
|
||||
f"$notice.ShowBalloonTip(10000, {quote(title)}, {quote(message)}, "
|
||||
"[System.Windows.Forms.ToolTipIcon]::Info);"
|
||||
"Start-Sleep -Seconds 12; $notice.Dispose()"
|
||||
)
|
||||
command = ["powershell.exe", "-NoProfile", "-NonInteractive", "-WindowStyle", "Hidden",
|
||||
"-EncodedCommand", base64.b64encode(script.encode("utf-16-le")).decode("ascii")]
|
||||
else:
|
||||
raise RuntimeError("Notifications de bureau indisponibles sur ce système.")
|
||||
subprocess.Popen(command, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
@@ -352,7 +352,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"bool",
|
||||
"--overwrite",
|
||||
help="Relance les corrections demandées même lorsqu’un résultat existe déjà.",
|
||||
variants=("live", "batch", "hybrid", "refaire"),
|
||||
variants=("live", "batch", "hybrid", "refaire", "integrate"),
|
||||
),
|
||||
ArgumentSpec(
|
||||
"limit",
|
||||
@@ -394,24 +394,8 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"batch_status",
|
||||
"Correction",
|
||||
"Consulter l’état des batchs",
|
||||
"Affiche les jobs Gemini en cours. L’identifiant de téléchargement est facultatif.",
|
||||
"Vérifie les jobs enregistrés pour l’évaluation. Passe à la récupération uniquement lorsque tous ont réussi et que leurs résultats sont disponibles ; sinon, reste sur cette étape.",
|
||||
(python("default", "État des batchs", "batch-status"),),
|
||||
arguments=(
|
||||
ArgumentSpec(
|
||||
"download",
|
||||
"Télécharger le job",
|
||||
"text",
|
||||
"--download",
|
||||
help="Saisissez l’identifiant complet d’un job Gemini terminé pour télécharger son fichier de résultats.",
|
||||
),
|
||||
ArgumentSpec(
|
||||
"output",
|
||||
"Fichier JSONL de destination",
|
||||
"path",
|
||||
"--output",
|
||||
help="Choisissez le fichier JSONL dans lequel enregistrer le job téléchargé. Ce champ n’est utilisé que si un identifiant de job est fourni.",
|
||||
),
|
||||
),
|
||||
optional=True,
|
||||
skip_for_live_correction=True,
|
||||
),
|
||||
@@ -429,7 +413,7 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
||||
"post_correction",
|
||||
"Correction",
|
||||
"Nettoyer la correction",
|
||||
"Corrige certains problèmes d’encodage et prépare le texte pour LaTeX.",
|
||||
"Sauvegarde correction.json dans correction_precleanup.json, puis corrige certains problèmes d’encodage et prépare le texte pour LaTeX. La sauvegarde est remplacée à chaque nettoyage.",
|
||||
(python("default", "Post-correction", "post-correction"),),
|
||||
arguments=(arg_target("le dossier de l’évaluation"),),
|
||||
requires=("correction.json",),
|
||||
@@ -788,6 +772,9 @@ def build_command(
|
||||
|
||||
positionals: list[str] = []
|
||||
options: list[str] = []
|
||||
if step.id == "batch_status":
|
||||
# Use the loaded evaluation without introducing another input field.
|
||||
options.extend(("--evaluation", evaluation_arg))
|
||||
for spec in step.arguments:
|
||||
if spec.variants and variant.id not in spec.variants:
|
||||
continue
|
||||
|
||||
Reference in New Issue
Block a user