#!/usr/bin/env python3 """Horodate la dernière modification substantielle d’une fiche.""" import argparse import re import sys import tomllib from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from _schema import KNOWN_SECTIONS ROOT_DIR = Path(__file__).resolve().parent.parent CONTENT_DIR = ROOT_DIR / "src" / "content" TIMEZONE_NAME = "Europe/Paris" UPDATED_RE = re.compile(r"\s*(?:#\s*)?updated\s*=.*") DATE_RE = re.compile(r"\s*date\s*=.*") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Horodater la dernière modification substantielle d’une fiche." ) parser.add_argument( "fiche", help=( "chemin depuis la racine du dépôt ou depuis src/content/ " "(par exemple vocabulaire/bītum.md)" ), ) return parser.parse_args() def current_timestamp() -> str: try: timezone = ZoneInfo(TIMEZONE_NAME) except ZoneInfoNotFoundError as exc: raise RuntimeError( f"fuseau {TIMEZONE_NAME} indisponible ; installer les données de fuseaux horaires" ) from exc return datetime.now(timezone).isoformat(timespec="seconds") def resolve_content_path(value: str) -> Path: supplied = Path(value).expanduser() if supplied.is_absolute(): path = supplied elif supplied.parts[:2] == ("src", "content"): path = ROOT_DIR / supplied else: path = CONTENT_DIR / supplied path = path.resolve() try: relative = path.relative_to(CONTENT_DIR.resolve()) except ValueError as exc: raise ValueError("la fiche doit se trouver dans src/content/") from exc if not path.is_file(): raise ValueError(f"fichier introuvable : {path}") if path.suffix != ".md" or path.name == "_index.md": raise ValueError("le chemin doit désigner une fiche Markdown, pas un index") if len(relative.parts) < 2 or relative.parts[0] not in KNOWN_SECTIONS: raise ValueError("la fiche doit appartenir à une section éditoriale connue") return path def update_front_matter(path: Path, timestamp: str) -> None: try: text = path.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError) as exc: raise ValueError(f"lecture impossible ({exc})") from exc lines = text.splitlines(keepends=True) if not lines or lines[0].rstrip("\r\n") != "+++": raise ValueError("front matter TOML initial absent") try: closing_index = next( index for index, line in enumerate(lines[1:], start=1) if line.rstrip("\r\n") == "+++" ) except StopIteration as exc: raise ValueError("délimiteur final +++ absent") from exc try: tomllib.loads("".join(lines[1:closing_index])) except tomllib.TOMLDecodeError as exc: raise ValueError(f"front matter TOML invalide ({exc})") from exc updated_indexes = [ index for index in range(1, closing_index) if UPDATED_RE.fullmatch(lines[index].rstrip("\r\n")) is not None ] if len(updated_indexes) > 1: raise ValueError("plusieurs champs updated sont présents dans le front matter") newline = "\r\n" if lines[0].endswith("\r\n") else "\n" updated_line = f"updated = {timestamp}{newline}" if updated_indexes: lines[updated_indexes[0]] = updated_line else: try: date_index = next( index for index in range(1, closing_index) if DATE_RE.fullmatch(lines[index].rstrip("\r\n")) is not None ) except StopIteration as exc: raise ValueError("champ date absent du front matter") from exc lines.insert(date_index + 1, updated_line) try: path.write_text("".join(lines), encoding="utf-8") except OSError as exc: raise ValueError(f"écriture impossible ({exc})") from exc def main() -> int: args = parse_args() try: path = resolve_content_path(args.fiche) timestamp = current_timestamp() update_front_matter(path, timestamp) except (ValueError, RuntimeError) as exc: print(f"Erreur : {exc}", file=sys.stderr) return 1 print(f"Fiche mise à jour : {path.relative_to(ROOT_DIR)}") print(f"updated = {timestamp}") return 0 if __name__ == "__main__": raise SystemExit(main())