235 lines
8.5 KiB
Python
235 lines
8.5 KiB
Python
from __future__ import annotations
|
||
|
||
import os
|
||
import re
|
||
import tkinter as tk
|
||
from collections import Counter
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from tkinter import messagebox, ttk
|
||
from typing import Callable
|
||
|
||
from copienator import EvaluationWorkspace
|
||
from copienator.platform import open_path, safe_filename
|
||
|
||
|
||
RETURN_FOLDER_PATTERN = re.compile(r"^(?P<name>.+) \((?P<copy_id>\d+)\)$")
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class NameIssue:
|
||
folder: Path
|
||
name: str
|
||
copy_id: str
|
||
reasons: tuple[str, ...]
|
||
|
||
|
||
def find_name_issues(evaluation: Path) -> list[NameIssue]:
|
||
"""Return unknown and duplicated names currently present in A Rendre."""
|
||
return_dir = EvaluationWorkspace(evaluation).return_dir
|
||
parsed: list[tuple[Path, str, str]] = []
|
||
if return_dir.is_dir():
|
||
for folder in return_dir.iterdir():
|
||
if not folder.is_dir() or folder.is_symlink():
|
||
continue
|
||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||
if match:
|
||
parsed.append(
|
||
(folder, match.group("name"), match.group("copy_id"))
|
||
)
|
||
|
||
counts = Counter(name.casefold() for _folder, name, _copy_id in parsed)
|
||
issues = []
|
||
for folder, name, copy_id in parsed:
|
||
reasons = []
|
||
if name.casefold() == "unknown":
|
||
reasons.append("nom inconnu")
|
||
elif counts[name.casefold()] > 1:
|
||
reasons.append(f"nom attribué à {counts[name.casefold()]} copies")
|
||
if reasons:
|
||
issues.append(NameIssue(folder, name, copy_id, tuple(reasons)))
|
||
return sorted(issues, key=lambda issue: (int(issue.copy_id), issue.name.casefold()))
|
||
|
||
|
||
def original_copy_path(evaluation: Path, copy_id: str) -> Path | None:
|
||
workspace = EvaluationWorkspace(evaluation)
|
||
filename = f"Copie{copy_id}.pdf"
|
||
for candidate in (
|
||
workspace.original_copies_dir / filename,
|
||
workspace.copies_dir / filename,
|
||
workspace.root / filename,
|
||
):
|
||
if candidate.is_file():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def rename_return_copy(folder: Path, requested_name: str) -> Path:
|
||
"""Rename one return folder and its top-level JPG/PDF, preserving its ID."""
|
||
match = RETURN_FOLDER_PATTERN.fullmatch(folder.name)
|
||
if not folder.is_dir() or folder.is_symlink() or match is None:
|
||
raise ValueError(f"Dossier de copie invalide : {folder}")
|
||
if folder.parent.name != "A Rendre":
|
||
raise ValueError("Le dossier à renommer doit se trouver dans A Rendre.")
|
||
|
||
entered_name = requested_name.strip()
|
||
if not entered_name:
|
||
raise ValueError("Saisissez un nom.")
|
||
name = safe_filename(entered_name, fallback="")
|
||
if not name:
|
||
raise ValueError("Le nom ne contient aucun caractère utilisable.")
|
||
|
||
copy_id = match.group("copy_id")
|
||
destination = folder.with_name(f"{name} ({copy_id})")
|
||
if destination != folder and os.path.lexists(destination):
|
||
raise FileExistsError(f"Le dossier {destination.name!r} existe déjà.")
|
||
|
||
assets: dict[str, Path] = {}
|
||
for child in folder.iterdir():
|
||
suffix = child.suffix.casefold()
|
||
if suffix not in {".jpg", ".pdf"}:
|
||
continue
|
||
if suffix in assets:
|
||
raise ValueError(
|
||
f"Plusieurs fichiers {suffix} sont présents dans {folder.name}."
|
||
)
|
||
assets[suffix] = child
|
||
|
||
renames: list[tuple[Path, Path]] = []
|
||
for suffix, source in assets.items():
|
||
target = source.with_name(f"{name}{suffix}")
|
||
if target != source and os.path.lexists(target):
|
||
raise FileExistsError(f"Le fichier {target.name!r} existe déjà.")
|
||
if target != source:
|
||
renames.append((source, target))
|
||
|
||
completed: list[tuple[Path, Path]] = []
|
||
try:
|
||
for source, target in renames:
|
||
source.rename(target)
|
||
completed.append((source, target))
|
||
if destination != folder:
|
||
folder.rename(destination)
|
||
except OSError:
|
||
for source, target in reversed(completed):
|
||
if os.path.lexists(target) and not os.path.lexists(source):
|
||
target.rename(source)
|
||
raise
|
||
return destination
|
||
|
||
|
||
class GivingNamesPanel(ttk.LabelFrame):
|
||
def __init__(
|
||
self,
|
||
master: tk.Misc,
|
||
evaluation: Path,
|
||
*,
|
||
opener: Callable[[Path], None] = open_path,
|
||
) -> None:
|
||
super().__init__(master, text="Vérification des noms", padding=7)
|
||
self.evaluation = evaluation
|
||
self.opener = opener
|
||
self.columnconfigure(0, weight=1)
|
||
|
||
actions = ttk.Frame(self)
|
||
actions.grid(row=0, column=0, sticky="w", pady=(0, 7))
|
||
ttk.Button(
|
||
actions,
|
||
text="Ouvrir A Rendre",
|
||
command=self.open_return_folder,
|
||
).pack(side="left")
|
||
ttk.Button(actions, text="Recharger", command=self.reload).pack(
|
||
side="left", padx=6
|
||
)
|
||
|
||
self.issue_frame = ttk.Frame(self)
|
||
self.issue_frame.grid(row=1, column=0, sticky="ew")
|
||
self.issue_frame.columnconfigure(1, weight=1)
|
||
self.reload()
|
||
|
||
def open_return_folder(self) -> None:
|
||
path = EvaluationWorkspace(self.evaluation).return_dir
|
||
if not path.is_dir():
|
||
messagebox.showerror(
|
||
"Dossier introuvable",
|
||
"Exécutez d’abord l’attribution des noms pour créer A Rendre.",
|
||
)
|
||
return
|
||
try:
|
||
self.opener(path)
|
||
except (OSError, RuntimeError) as exc:
|
||
messagebox.showerror("Ouverture impossible", str(exc))
|
||
|
||
def open_original(self, copy_id: str) -> None:
|
||
path = original_copy_path(self.evaluation, copy_id)
|
||
if path is None:
|
||
messagebox.showerror(
|
||
"Copie introuvable", f"Copie{copy_id}.pdf est introuvable."
|
||
)
|
||
return
|
||
try:
|
||
self.opener(path)
|
||
except (OSError, RuntimeError) as exc:
|
||
messagebox.showerror("Ouverture impossible", str(exc))
|
||
|
||
def rename_issue(self, issue: NameIssue, variable: tk.StringVar) -> None:
|
||
try:
|
||
destination = rename_return_copy(issue.folder, variable.get())
|
||
except (OSError, ValueError) as exc:
|
||
messagebox.showerror("Renommage impossible", str(exc))
|
||
return
|
||
self.reload()
|
||
messagebox.showinfo("Copie renommée", f"Nouveau dossier : {destination.name}")
|
||
|
||
def reload(self) -> None:
|
||
for child in self.issue_frame.winfo_children():
|
||
child.destroy()
|
||
issues = find_name_issues(self.evaluation)
|
||
if not issues:
|
||
return_dir = EvaluationWorkspace(self.evaluation).return_dir
|
||
text = (
|
||
"Aucun nom inconnu ou dupliqué."
|
||
if return_dir.is_dir()
|
||
else "A Rendre n’a pas encore été généré."
|
||
)
|
||
ttk.Label(self.issue_frame, text=text).grid(
|
||
row=0, column=0, columnspan=5, sticky="w"
|
||
)
|
||
return
|
||
|
||
ttk.Label(self.issue_frame, text="Copie / problème").grid(
|
||
row=0, column=0, sticky="w", padx=(0, 6)
|
||
)
|
||
ttk.Label(self.issue_frame, text="Nouveau nom").grid(
|
||
row=0, column=1, sticky="w"
|
||
)
|
||
ttk.Label(self.issue_frame, text="Nom attribué").grid(
|
||
row=0, column=4, sticky="w", padx=(12, 0)
|
||
)
|
||
for row, issue in enumerate(issues, start=1):
|
||
ttk.Label(
|
||
self.issue_frame,
|
||
text=f"Copie{issue.copy_id} — {', '.join(issue.reasons)}",
|
||
).grid(row=row, column=0, sticky="w", padx=(0, 6), pady=2)
|
||
variable = tk.StringVar()
|
||
ttk.Entry(self.issue_frame, textvariable=variable).grid(
|
||
row=row, column=1, sticky="ew", pady=2
|
||
)
|
||
original = original_copy_path(self.evaluation, issue.copy_id)
|
||
ttk.Button(
|
||
self.issue_frame,
|
||
text="Ouvrir l’originale",
|
||
command=lambda ident=issue.copy_id: self.open_original(ident),
|
||
state="normal" if original else "disabled",
|
||
).grid(row=row, column=2, padx=6, pady=2)
|
||
ttk.Button(
|
||
self.issue_frame,
|
||
text="Renommer",
|
||
command=lambda item=issue, value=variable: self.rename_issue(
|
||
item, value
|
||
),
|
||
).grid(row=row, column=3, pady=2)
|
||
ttk.Label(self.issue_frame, text=issue.name).grid(
|
||
row=row, column=4, sticky="w", padx=(12, 0), pady=2
|
||
)
|