Files

274 lines
7.6 KiB
Python
Executable File
Raw Permalink 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 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 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 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("lidentifiant doit être écrit en minuscules")
if value.endswith("-") or "--" in value:
raise ValueError(
"lidentifiant 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(
"lidentifiant 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())