#!/usr/bin/env python3 """Crée un brouillon Markdown adapté à une section du site.""" import argparse import json import re import sys import unicodedata from datetime import datetime from pathlib import Path from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from _schema import SECTION_ALIASES, VOCABULARY_FIELDS ROOT_DIR = Path(__file__).resolve().parent.parent CONTENT_DIR = ROOT_DIR / "src" / "content" TIMEZONE_NAME = "Europe/Paris" MZL_RE = re.compile(r"(?:mzl-)?([0-9]{1,3})") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Créer un brouillon Markdown horodaté et prêt à compléter." ) parser.add_argument( "section", choices=sorted(SECTION_ALIASES), help="section de destination", ) parser.add_argument( "identifiant", help="nom du fichier sans .md ; pour un signe, numéro MZL ou mzl-XXX", ) parser.add_argument( "--title", help="titre initial ; déduit de l’identifiant par défaut", ) parser.add_argument( "--nature", choices=tuple(VOCABULARY_FIELDS), help="nature grammaticale d’une fiche de vocabulaire", ) return parser.parse_args() def normalize(value: str) -> str: return unicodedata.normalize("NFC", value.strip()) def toml_string(value: str) -> str: return json.dumps(value, ensure_ascii=False) 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 validate_identifier(identifier: str, *, allow_leading_hyphen: bool = False) -> str: """Valide un identifiant déjà écrit sous sa forme de nom de fichier.""" value = normalize(identifier) if value.startswith("-") and not allow_leading_hyphen: raise ValueError("seul le vocabulaire accepte un identifiant commençant par un tiret") body = value[1:] if value.startswith("-") else value if not body or value in {".", "..", "_index"}: raise ValueError("identifiant de fichier invalide") if value != value.lower(): raise ValueError("l’identifiant doit être écrit en minuscules") if value.endswith("-") or "--" in value: raise ValueError( "l’identifiant ne doit pas finir par un tiret ni en contenir deux de suite" ) if not all(character.isalnum() or character == "-" for character in body): raise ValueError( "l’identifiant ne doit contenir que des lettres, des chiffres et des tirets" ) return value def parse_mzl(identifier: str) -> tuple[str, str]: value = normalize(identifier) match = MZL_RE.fullmatch(value) if match is None: raise ValueError( "un signe doit être identifié par un numéro MZL de un à trois chiffres ASCII" ) number = int(match.group(1)) if not 1 <= number <= 999: raise ValueError("le numéro MZL doit être compris entre 1 et 999") mzl = f"{number:03d}" return f"mzl-{mzl}", mzl def choose_nature(nature: str | None) -> str: if nature: return nature if not sys.stdin.isatty(): raise ValueError("--nature est requis pour une fiche de vocabulaire") choices = tuple(VOCABULARY_FIELDS) print("Nature grammaticale :") for index, choice in enumerate(choices, start=1): print(f" {index}. {choice}") try: answer = input("Choix : ").strip() except (EOFError, KeyboardInterrupt) as exc: raise ValueError("sélection interrompue") from exc if not answer.isdigit() or not 1 <= int(answer) <= len(choices): raise ValueError("nature grammaticale invalide") return choices[int(answer) - 1] def title_from_identifier(identifier: str) -> str: title = identifier.replace("-", " ") return title[:1].upper() + title[1:] def build_vocabulary(title: str, timestamp: str, nature: str) -> str: fields = "\n".join( f"{field} = []" if field == "logograms" else f'{field} = ""' for field in VOCABULARY_FIELDS[nature] ) return f"""+++ title = {toml_string(title)} date = {timestamp} # updated = YYYY-MM-DDTHH:MM:SS+HH:MM description = "" draft = true [extra] {fields} [taxonomies] natures = [{toml_string(nature)}] +++ """ def build_sign(title: str, timestamp: str, mzl: str) -> str: return f"""+++ title = {toml_string(title)} date = {timestamp} # updated = YYYY-MM-DDTHH:MM:SS+HH:MM description = "" draft = true [extra] sign = "" mzl = "{mzl}" [taxonomies] lectures = [] +++ """ def build_page(section: str, title: str, timestamp: str) -> str: if section == "articles": return f"""+++ title = {toml_string(title)} date = {timestamp} # updated = YYYY-MM-DDTHH:MM:SS+HH:MM description = "" draft = true [extra] banner = "" banner_alt = "" banner_credit = "" +++ """ if section == "grammaire": return f"""+++ title = {toml_string(title)} date = {timestamp} # updated = YYYY-MM-DDTHH:MM:SS+HH:MM description = "" draft = true [taxonomies] themes = [] +++ """ if section == "textes": return f"""+++ title = {toml_string(title)} date = {timestamp} # updated = YYYY-MM-DDTHH:MM:SS+HH:MM description = "" draft = true [taxonomies] genres = [] +++ """ raise ValueError(f"section non prise en charge : {section}") def main() -> int: args = parse_args() section = SECTION_ALIASES[args.section] try: if section != "vocabulaire" and args.nature is not None: raise ValueError("--nature est réservé aux fiches de vocabulaire") timestamp = current_timestamp() custom_title = normalize(args.title) if args.title is not None else None if section == "signes": filename, mzl = parse_mzl(args.identifiant) title = custom_title if custom_title is not None else "" content = build_sign(title, timestamp, mzl) else: filename = validate_identifier( args.identifiant, allow_leading_hyphen=section == "vocabulaire", ) if section == "vocabulaire": nature = choose_nature(args.nature) title = custom_title if custom_title is not None else filename content = build_vocabulary(title, timestamp, nature) else: title = ( custom_title if custom_title is not None else title_from_identifier(filename) ) content = build_page(section, title, timestamp) except (ValueError, RuntimeError) as exc: print(f"Erreur : {exc}", file=sys.stderr) return 2 destination = CONTENT_DIR / section / f"{filename}.md" try: destination.parent.mkdir(parents=True, exist_ok=True) with destination.open("x", encoding="utf-8") as stream: stream.write(content) except FileExistsError: print( f"Erreur : le fichier existe déjà : {destination.relative_to(ROOT_DIR)}", file=sys.stderr, ) return 1 except OSError as exc: print( f"Erreur : impossible de créer {destination.relative_to(ROOT_DIR)} ({exc})", file=sys.stderr, ) return 1 print(f"Brouillon créé : {destination.relative_to(ROOT_DIR)}") print("Compléter la fiche, passer draft à false, puis lancer :") print(" ./scripts/check-content.py") return 0 if __name__ == "__main__": raise SystemExit(main())