#!/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 LIST_FIELDS, SECTION_ALIASES, SECTION_FIELDS, VOCABULARY_FIELDS ROOT_DIR = Path(__file__).resolve().parent.parent CONTENT_DIR = ROOT_DIR / "src" / "content" MZL_RE = re.compile(r"(?:mzl-)?([0-9]{1,3})") FieldValue = str | list[str] 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 validate_identifier(identifier: str, allow_leading_hyphen: bool) -> str: value = normalize(identifier) body = value[1:] if value.startswith("-") else value if value.startswith("-") and not allow_leading_hyphen: raise ValueError( "seul le vocabulaire accepte un identifiant commençant par un tiret" ) if ( not body or value in {".", "..", "_index"} or value != value.lower() or value.endswith("-") or "--" in value or not all(character.isalnum() or character == "-" for character in body) ): raise ValueError("identifiant de fichier invalide") return value def parse_mzl(identifier: str) -> tuple[str, str]: match = MZL_RE.fullmatch(normalize(identifier)) if match is None or not 1 <= int(match.group(1)) <= 999: raise ValueError("le numéro MZL doit être compris entre 1 et 999") mzl = f"{int(match.group(1)):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 empty_fields(fields: tuple[str, ...]) -> dict[str, FieldValue]: return {field: [] if field in LIST_FIELDS else "" for field in fields} def content_tables( section: str, nature: str | None = None, mzl: str | None = None, ) -> dict[str, dict[str, FieldValue]]: if section == "vocabulaire": assert nature is not None return { "extra": empty_fields(VOCABULARY_FIELDS[nature]), "taxonomies": {"natures": [nature]}, } tables = { table: empty_fields(fields) for table, fields in SECTION_FIELDS[section].items() } if section == "signes": assert mzl is not None tables["extra"]["mzl"] = mzl return tables def build_content( title: str, timestamp: str, tables: dict[str, dict[str, FieldValue]], ) -> str: lines = [ "+++", f"title = {json.dumps(title, ensure_ascii=False)}", f"date = {timestamp}", "# updated = YYYY-MM-DDTHH:MM:SS+HH:MM", 'description = ""', "draft = true", ] for table, fields in tables.items(): lines.extend(("", f"[{table}]")) lines.extend( f"{field} = {json.dumps(value, ensure_ascii=False)}" for field, value in fields.items() ) lines.extend(("+++", "")) return "\n".join(lines) 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") custom_title = normalize(args.title) if args.title is not None else None nature = None mzl = None if section == "signes": filename, mzl = parse_mzl(args.identifiant) title = custom_title if custom_title is not None else "" 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 else: title = ( custom_title if custom_title is not None else title_from_identifier(filename) ) timestamp = datetime.now(ZoneInfo("Europe/Paris")).isoformat( timespec="seconds" ) content = build_content( title, timestamp, content_tables(section, nature, mzl), ) except (ValueError, ZoneInfoNotFoundError) as exc: print(f"Erreur : {exc}", file=sys.stderr) return 2 destination = CONTENT_DIR / section / f"{filename}.md" try: 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 la fiche ({exc})", file=sys.stderr) return 1 print(f"Brouillon créé : {destination.relative_to(ROOT_DIR)}") return 0 if __name__ == "__main__": raise SystemExit(main())