first commit
This commit is contained in:
Executable
+268
@@ -0,0 +1,268 @@
|
||||
#!/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}
|
||||
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}
|
||||
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}
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[extra]
|
||||
banner = ""
|
||||
banner_alt = ""
|
||||
banner_credit = ""
|
||||
+++
|
||||
"""
|
||||
|
||||
if section == "grammaire":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
description = ""
|
||||
draft = true
|
||||
|
||||
[taxonomies]
|
||||
themes = []
|
||||
+++
|
||||
"""
|
||||
|
||||
if section == "textes":
|
||||
return f"""+++
|
||||
title = {toml_string(title)}
|
||||
date = {timestamp}
|
||||
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())
|
||||
Reference in New Issue
Block a user