from __future__ import annotations import os import re import shutil import subprocess import sys from pathlib import Path from typing import Literal WINDOWS_RESERVED_NAMES = { "CON", "PRN", "AUX", "NUL", *(f"COM{number}" for number in range(1, 10)), *(f"LPT{number}" for number in range(1, 10)), } class WindowsLabelError(ValueError): pass def windows_filename_problems(value: str) -> list[str]: problems = [] forbidden = sorted({character for character in value if character in '<>:"/\\|?*'}) if forbidden: problems.append("caractères interdits " + " ".join(repr(char) for char in forbidden)) if any(ord(character) < 32 for character in value): problems.append("caractère de contrôle") if value.endswith((" ", ".")): problems.append("espace ou point final") if not value or value in {".", ".."}: problems.append("nom vide ou réservé") elif value.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES: problems.append("nom système réservé") return problems def validate_windows_labels( labels: list[str] | tuple[str, ...], platform_name: str | None = None ) -> None: """Reject labels that cannot be used as filenames on native Windows.""" if (platform_name or os.name) != "nt": return invalid = [] for label in labels: problems = windows_filename_problems(label) if problems: invalid.append((label, problems)) if not invalid: return details = "\n".join( f" - {label!r}: {', '.join(problems)}" for label, problems in invalid ) raise WindowsLabelError( "Labels incompatibles avec Windows :\n" f"{details}\n" "Modifiez ces labels avant de poursuivre (par exemple, remplacez ':' par ' - ')." ) def safe_filename(value: str, fallback: str = "Unknown") -> str: """Return a filename component accepted by both Linux and Windows.""" cleaned = re.sub(r'[<>:"/\\|?*\x00-\x1f]', "", value).strip().rstrip(". ") if not cleaned: cleaned = fallback if cleaned.split(".", 1)[0].upper() in WINDOWS_RESERVED_NAMES: cleaned = f"_{cleaned}" return cleaned def open_path(path: str | Path) -> None: """Open a file with the desktop's default application.""" target = str(Path(path).expanduser().resolve()) if os.name == "nt": os.startfile(target) # type: ignore[attr-defined] elif sys.platform.startswith("linux"): subprocess.Popen(["xdg-open", target]) else: raise RuntimeError(f"Unsupported platform: {sys.platform}") def launch_pdf_arranger(path: str | Path) -> None: executable = shutil.which("pdf-arranger") or shutil.which("pdfarranger") if not executable: raise FileNotFoundError( "PDF Arranger is not installed or is not available in PATH." ) subprocess.Popen([executable, str(Path(path).expanduser().resolve())]) def replace_with_link_or_copy( source: str | Path, destination: str | Path, *, prefer: Literal["hardlink", "symlink"] = "hardlink", ) -> str: """Replace destination with a link, falling back to a regular copy.""" source_path = Path(source).expanduser().resolve() destination_path = Path(destination).expanduser() destination_path.parent.mkdir(parents=True, exist_ok=True) if os.path.lexists(destination_path): destination_path.unlink() strategies = (prefer, "symlink" if prefer == "hardlink" else "hardlink") for strategy in strategies: try: if strategy == "hardlink": os.link(source_path, destination_path) else: os.symlink(source_path, destination_path) return strategy except (NotImplementedError, OSError): continue shutil.copy2(source_path, destination_path) return "copy"