122 lines
3.8 KiB
Python
122 lines
3.8 KiB
Python
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"
|
|
|