220 lines
6.4 KiB
Python
Executable File
220 lines
6.4 KiB
Python
Executable File
#!/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())
|