Files
lescarnetsdakkadien.netig.net/scripts/new-content.py
T
2026-08-06 16:42:42 +02:00

220 lines
6.4 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 lidentifiant par défaut",
)
parser.add_argument(
"--nature",
choices=tuple(VOCABULARY_FIELDS),
help="nature grammaticale dune 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())