Le vocabulaire des deux générateurs sortait en français en dur. Il est externalisé dans un dict REF par générateur, EN ANGLAIS, un seul jeu servi identique dans les trois langues. La raison est une MESURE, pas un goût : nymea ne sert aucune traduction sur notre parc. Vérifié le 2026-10-12 sur le banc `hems` — Integrations.GetThingClasses rend la MÊME charge utile au sha256 près (243 340 octets) pour fr_FR, de_DE et en_US, et nymea-plugins-translations n'est pas installé. Les noms de modèles, de registres et d'unités sont donc déjà en anglais ; traduire les seuls en-têtes de colonnes autour aurait fabriqué des pages bilingues sans rien gagner. Côté API c'est plus direct encore : méthodes, champs, types et `description` viennent d'introspect.json. Ce qui se TRADUIT, en revanche, c'est la prose et la navigation. D'où deux ajouts côté fiches appareils : - SCAFFOLD : l'échafaudage de prose par langue (titre de catégorie, intertitre « Référence », mention de guide à venir). Sans lui, chaque nouvel appareil créerait sa fiche dans la seule langue par défaut, et fallback_to_default la servirait EN FRANÇAIS sous /de/ et /en/ — par le simple fait d'être créée. - CATEGORY_LABELS_I18N pour les titres de pages d'index de catégorie. `--lang` existait depuis la CI mais n'était PAS lu (« Conservé pour compatibilité CI »). Il désigne maintenant la langue par défaut — celle dont les fichiers n'ont pas de suffixe — et `--locales` dit lesquelles générer. ensure_device_pages() et ensure_category_indexes() bouclent dessus, et ne réécrivent JAMAIS une page existante : la prose traduite à la main survit aux régénérations. Vérifié : second passage, « Doc à jour », 0 page régénérée. Les messages de diagnostic (ERREUR, AVERTISSEMENT, BLOQUANT) restent en français : ils s'adressent au mainteneur, pas au site. Note de méthode : la validation par ast.parse() ne voit pas une erreur de table des symboles (`global` après usage). compile() la voit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
763 lines
31 KiB
Python
763 lines
31 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
gen_device_reference.py — génère la matrice de compatibilité, les sections de
|
||
référence des appareils et le fichier de nav literate-nav (SUMMARY.md) depuis
|
||
PORTING_STATUS.yaml + integrationplugin*.json + meta.json.
|
||
|
||
Deux modes de traitement des fiches :
|
||
- Injection : remplace le contenu entre marqueurs dans les fiches existantes.
|
||
- Création : crée entièrement les fiches et index inexistants.
|
||
|
||
Marqueurs gérés :
|
||
<!-- BEGIN GENERATED: integrationplugin<plugin>.json --> fiche appareil
|
||
<!-- BEGIN GENERATED: __matrix__ --> matrice compatibilité
|
||
<!-- BEGIN GENERATED: __index_<cat>__ --> index de catégorie
|
||
|
||
Usage :
|
||
python3 scripts/gen_device_reference.py --src .plugins-src --docs docs --lang fr
|
||
python3 scripts/gen_device_reference.py --src .plugins-src --docs docs --lang fr --check
|
||
"""
|
||
from __future__ import annotations
|
||
import argparse
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError:
|
||
sys.exit("PyYAML est requis : pip install PyYAML")
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# VOCABULAIRE DU BLOC DE RÉFÉRENCE — EN ANGLAIS, UNE SEULE FOIS
|
||
#
|
||
# Décision du 2026-10-12 (multi-langue FR/DE/EN) : le contenu entre les
|
||
# marqueurs BEGIN/END GENERATED est servi IDENTIQUE dans les trois langues, en
|
||
# anglais. Raison : son contenu vient de l'introspection nymea, et nymea ne
|
||
# sert AUCUNE traduction sur notre parc — vérifié le 2026-10-12 sur le banc
|
||
# `hems`, où GetThingClasses rend la MÊME charge utile (sha256 identique,
|
||
# 243 340 octets) pour fr_FR, de_DE et en_US. Les noms de modèles, de registres
|
||
# et d'unités sont donc déjà en anglais ; traduire seulement les en-têtes de
|
||
# colonnes autour aurait produit des pages bilingues sans rien gagner.
|
||
#
|
||
# Ce qui est traduit, lui, c'est la NAVIGATION et la PROSE — voir NAV_LABELS
|
||
# plus bas et les fichiers `*.de.md` / `*.en.md`.
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
INTERFACE_ROLE = {
|
||
"energymeter": "Energy meter",
|
||
"smartmeterconsumer": "Consumption meter",
|
||
"smartmeterproducer": "Production meter",
|
||
"evcharger": "EV charger",
|
||
"heatpump": "Heat pump",
|
||
"smartmeter": "Smart meter",
|
||
}
|
||
TECH_INTERFACES = {"connectable", "networkdevice", "wirelessconnectable"}
|
||
|
||
CREATE_METHOD = {
|
||
"discovery": "Auto-discovery",
|
||
"user": "Manual",
|
||
"auto": "Automatic",
|
||
}
|
||
|
||
# Libellés du bloc de référence. Un seul jeu, anglais (cf. en-tête ci-dessus).
|
||
REF = {
|
||
"manufacturer": "**Manufacturer:**",
|
||
"plugin": "**Plugin:**",
|
||
"models_h": "#### Supported models",
|
||
"models_cols": ["Model", "Role", "Transport", "Pairing", "Quantities"],
|
||
"detail_h": "#### Per-model detail",
|
||
"discovery_lbl": "_Discovery parameters:_",
|
||
"discovery_cols": ["Key", "Label", "Type", "Range", "Default"],
|
||
"settings_lbl": "_Settings:_",
|
||
"settings_cols": ["Key", "Label", "Type", "Range", "Default", "Read-only"],
|
||
"states_lbl": "_Measured quantities:_",
|
||
"states_cols": ["Key", "Quantity", "Type", "Unit"],
|
||
"yes": "yes",
|
||
"no": "no",
|
||
"cat_cols": ["Device", "Protocol", "Channel", "Origin", "Stability"],
|
||
"cat_empty": "_No device in this category._\n",
|
||
"matrix_cols": ["Brand / Model", "Protocol", "Channel", "Origin", "Stability"],
|
||
}
|
||
|
||
CATEGORY_LABELS = {
|
||
"compteur": "Compteurs",
|
||
"irve": "Bornes de recharge",
|
||
"smartdevice": "SmartDevices",
|
||
"hvac": "HVAC",
|
||
"onduleur": "Onduleurs / PV",
|
||
"batterie": "Batteries / ESS",
|
||
"tarif": "Tarifs & prévisions",
|
||
"generic": "Types génériques",
|
||
}
|
||
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
# PROSE PAR LANGUE — ce qui entoure le bloc de référence
|
||
#
|
||
# Le bloc de référence est en anglais une seule fois (REF ci-dessus) ; ce qui
|
||
# l'entoure — titre de catégorie, intertitre « Référence », mention de guide à
|
||
# venir — est de la PROSE et se traduit. Sans ça, chaque nouvel appareil ajouté
|
||
# réintroduirait du français dans /de/ et /en/ par le simple fait d'être créé.
|
||
# ═════════════════════════════════════════════════════════════════════════════
|
||
|
||
LOCALES = ["fr", "de", "en"] # fr = langue par défaut (fichier sans suffixe)
|
||
DEFAULT_LOCALE = "fr"
|
||
|
||
SCAFFOLD = {
|
||
"fr": {"ref_h": "## Référence", "guide_todo": "_Guide d'installation à venir._"},
|
||
"de": {"ref_h": "## Referenz", "guide_todo": "_Installationsanleitung folgt._"},
|
||
"en": {"ref_h": "## Reference", "guide_todo": "_Installation guide coming soon._"},
|
||
}
|
||
|
||
CATEGORY_LABELS_I18N = {
|
||
"fr": CATEGORY_LABELS,
|
||
"de": {
|
||
"compteur": "Zähler", "irve": "Wallboxen", "smartdevice": "SmartDevices",
|
||
"hvac": "HVAC", "onduleur": "Wechselrichter / PV", "batterie": "Speicher / ESS",
|
||
"tarif": "Tarife & Prognosen", "generic": "Generische Typen",
|
||
},
|
||
"en": {
|
||
"compteur": "Meters", "irve": "EV chargers", "smartdevice": "SmartDevices",
|
||
"hvac": "HVAC", "onduleur": "Inverters / PV", "batterie": "Batteries / ESS",
|
||
"tarif": "Tariffs & forecasts", "generic": "Generic types",
|
||
},
|
||
}
|
||
|
||
|
||
def loc_name(stem: str, locale: str) -> str:
|
||
"""Nom de fichier mkdocs-static-i18n en mode `suffix` : `x.md`, `x.de.md`, `x.en.md`."""
|
||
return f"{stem}.md" if locale == DEFAULT_LOCALE else f"{stem}.{locale}.md"
|
||
|
||
|
||
CATEGORY_FOLDER = {
|
||
"compteur": "compteurs",
|
||
"irve": "bornes",
|
||
"smartdevice": "smart",
|
||
"hvac": "hvac",
|
||
"onduleur": "onduleurs",
|
||
"batterie": "batteries",
|
||
"tarif": "tarifs",
|
||
"generic": "generic",
|
||
}
|
||
|
||
CATEGORY_ORDER = [
|
||
"compteur", "irve", "smartdevice", "hvac", "onduleur", "batterie", "tarif", "generic",
|
||
]
|
||
|
||
CHANNEL_BADGES = {
|
||
"stable": '<span class="badge stable">STABLE</span>',
|
||
"testing": '<span class="badge testing">TESTING</span>',
|
||
"nightly": '<span class="badge nightly">NIGHTLY</span>',
|
||
}
|
||
|
||
STABILITY_BADGES = {
|
||
"consumer": '<span class="badge consumer">CONSUMER</span>',
|
||
"community": '<span class="badge community">COMMUNITY</span>',
|
||
"experimental": '<span class="badge experimental">EXPERIMENTAL</span>',
|
||
}
|
||
|
||
ORIGIN_BADGES = {
|
||
"nymea": '<span class="badge origin-nymea">NYMEA</span>',
|
||
"etm": '<span class="badge origin-etm">ETM</span>',
|
||
"tiers": '<span class="badge origin-tiers">TIERS</span>',
|
||
}
|
||
|
||
|
||
def resolve_origin(e: dict) -> str:
|
||
"""Détermine l'origine depuis le champ explicite `origin:` ou depuis `repo:`."""
|
||
if e.get("origin"):
|
||
return e["origin"]
|
||
repo = e.get("repo", "")
|
||
if repo.startswith("nymea-"):
|
||
return "nymea"
|
||
if repo.startswith("etm-"):
|
||
return "etm"
|
||
return "tiers"
|
||
|
||
def render_header_badges(e: dict, meta: dict) -> str:
|
||
"""Rendu de la ligne de badges d'en-tête : canal, origine, stabilité."""
|
||
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
|
||
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "")
|
||
stability = meta.get("stability", "")
|
||
stability_badge = STABILITY_BADGES.get(stability, "") if stability else ""
|
||
parts = [b for b in [channel_badge, origin_badge, stability_badge] if b]
|
||
return " ".join(parts) + "\n"
|
||
|
||
|
||
MARKER_RE = re.compile(
|
||
r"(<!-- BEGIN GENERATED: (?P<key>[^\s]+) -->)(?P<body>.*?)(<!-- END GENERATED -->)",
|
||
re.DOTALL,
|
||
)
|
||
|
||
|
||
# ── Chargement ────────────────────────────────────────────────────────────────
|
||
|
||
def load_porting_status(root: Path) -> list:
|
||
path = root / "PORTING_STATUS.yaml"
|
||
if not path.exists():
|
||
sys.exit(f"ERREUR : PORTING_STATUS.yaml introuvable à {path}")
|
||
with open(path) as f:
|
||
return yaml.safe_load(f)
|
||
|
||
|
||
def load_plugins(src: Path) -> dict:
|
||
"""Charge tous les integrationplugin*.json trouvés récursivement.
|
||
|
||
Logue explicitement chaque fichier vide ou non parsable (ne plante pas).
|
||
"""
|
||
plugins: dict = {}
|
||
for f in sorted(src.rglob("integrationplugin*.json")):
|
||
if f.name in plugins:
|
||
continue
|
||
raw = f.read_text(encoding="utf-8").strip()
|
||
if not raw:
|
||
print(f" AVERTISSEMENT : {f} est vide — ignoré", file=sys.stderr)
|
||
continue
|
||
try:
|
||
plugins[f.name] = json.loads(raw)
|
||
except json.JSONDecodeError as exc:
|
||
print(f" AVERTISSEMENT : {f} non parsable ({exc}) — ignoré", file=sys.stderr)
|
||
return plugins
|
||
|
||
|
||
def load_meta(src: Path, plugin: str) -> dict:
|
||
"""Charge meta.json depuis le même dossier que integrationplugin<plugin>.json.
|
||
|
||
Logue explicitement si le fichier est vide ou non parsable.
|
||
"""
|
||
for f in sorted(src.rglob(f"integrationplugin{plugin}.json")):
|
||
meta_path = f.parent / "meta.json"
|
||
if not meta_path.exists():
|
||
return {}
|
||
raw = meta_path.read_text(encoding="utf-8").strip()
|
||
if not raw:
|
||
print(f" AVERTISSEMENT : {meta_path} est vide — meta ignoré", file=sys.stderr)
|
||
return {}
|
||
try:
|
||
return json.loads(raw)
|
||
except json.JSONDecodeError as exc:
|
||
print(f" AVERTISSEMENT : {meta_path} non parsable ({exc}) — meta ignoré",
|
||
file=sys.stderr)
|
||
return {}
|
||
return {}
|
||
|
||
|
||
# ── Libellé d'un appareil ────────────────────────────────────────────────────
|
||
|
||
def entry_name(e: dict, meta: dict) -> str:
|
||
"""Titre : PORTING_STATUS.name > meta.json.title > plugin (fallback dernier recours)."""
|
||
return e.get("name") or meta.get("title") or e.get("plugin", "?")
|
||
|
||
|
||
def render_manual_url(e: dict, meta: dict) -> str:
|
||
"""Retourne la section 'Documentation fabricant' si manual_url est présent."""
|
||
url = e.get("manual_url", "")
|
||
if not url:
|
||
return ""
|
||
name = entry_name(e, meta)
|
||
return f"\n**Documentation fabricant** : [Manuel {name}]({url})\n"
|
||
|
||
|
||
# ── Helpers JSON nymea ────────────────────────────────────────────────────────
|
||
|
||
def transport_of(tc: dict) -> str:
|
||
params = {p["name"] for p in tc.get("paramTypes", [])} | \
|
||
{p["name"] for p in tc.get("discoveryParamTypes", [])}
|
||
ifaces = set(tc.get("interfaces", []))
|
||
if "networkdevice" in ifaces or {"hostName", "address", "port", "macAddress"} & params:
|
||
return "Modbus TCP"
|
||
if {"modbusMasterUuid", "rtuMaster"} & params:
|
||
return "Modbus RTU"
|
||
return "—"
|
||
|
||
|
||
def roles(tc: dict) -> str:
|
||
r = [INTERFACE_ROLE.get(i, i) for i in tc.get("interfaces", []) if i not in TECH_INTERFACES]
|
||
return ", ".join(r) if r else "—"
|
||
|
||
|
||
def add_method(tc: dict) -> str:
|
||
return " / ".join(CREATE_METHOD.get(m, m) for m in tc.get("createMethods", [])) or "—"
|
||
|
||
|
||
def resolve_protocol(plugin_data: dict | None) -> str:
|
||
if not plugin_data:
|
||
return "—"
|
||
for v in plugin_data.get("vendors", []):
|
||
for tc in v.get("thingClasses", []):
|
||
t = transport_of(tc)
|
||
if t != "—":
|
||
return t
|
||
return "—"
|
||
|
||
|
||
# ── Rendu Markdown ────────────────────────────────────────────────────────────
|
||
|
||
def fmt_range(p: dict) -> str:
|
||
lo, hi = p.get("minValue"), p.get("maxValue")
|
||
if lo is not None and hi is not None:
|
||
return f"{lo}–{hi}"
|
||
return "—"
|
||
|
||
|
||
def fmt_default(p: dict) -> str:
|
||
d = p.get("defaultValue", "")
|
||
if d == "" or d is None:
|
||
return "—"
|
||
return f"`{d}`"
|
||
|
||
|
||
def md_table(headers: list, rows: list) -> str:
|
||
out = ["| " + " | ".join(headers) + " |",
|
||
"| " + " | ".join("---" for _ in headers) + " |"]
|
||
for r in rows:
|
||
out.append("| " + " | ".join(str(c) for c in r) + " |")
|
||
return "\n".join(out)
|
||
|
||
|
||
def render_params(tc: dict) -> list:
|
||
lines = []
|
||
disc = tc.get("discoveryParamTypes", [])
|
||
if disc:
|
||
rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""),
|
||
fmt_range(p), fmt_default(p)] for p in disc]
|
||
lines.append(REF["discovery_lbl"])
|
||
lines.append("")
|
||
lines.append(md_table(REF["discovery_cols"], rows))
|
||
lines.append("")
|
||
settings = tc.get("paramTypes", [])
|
||
if settings:
|
||
rows = [[f"`{p['name']}`", p.get("displayName", ""), p.get("type", ""),
|
||
fmt_range(p), fmt_default(p),
|
||
REF["yes"] if p.get("readOnly") else REF["no"]] for p in settings]
|
||
lines.append(REF["settings_lbl"])
|
||
lines.append("")
|
||
lines.append(md_table(REF["settings_cols"], rows))
|
||
lines.append("")
|
||
return lines
|
||
|
||
|
||
def render_states(tc: dict) -> list:
|
||
states = tc.get("stateTypes", [])
|
||
if not states:
|
||
return []
|
||
rows = [[f"`{s['name']}`", s.get("displayName", ""), s.get("type", ""),
|
||
s.get("unit") or "—"] for s in states]
|
||
return [md_table(REF["states_cols"], rows), ""]
|
||
|
||
|
||
def render_plugin(plugin: dict) -> str:
|
||
out = []
|
||
vendors = plugin.get("vendors", [])
|
||
vname = ", ".join(v.get("displayName", v.get("name", "")) for v in vendors)
|
||
out.append(f'{REF["manufacturer"]} {vname} ')
|
||
out.append(f'{REF["plugin"]} `{plugin.get("name", "")}`')
|
||
out.append("")
|
||
|
||
tcs = [(v, tc) for v in vendors for tc in v.get("thingClasses", [])]
|
||
rows = [[f"**{tc.get('displayName', tc.get('name'))}**",
|
||
roles(tc), transport_of(tc), add_method(tc),
|
||
str(len(tc.get("stateTypes", [])))]
|
||
for _, tc in tcs]
|
||
out.append(REF["models_h"])
|
||
out.append(md_table(REF["models_cols"], rows))
|
||
out.append("")
|
||
|
||
out.append(REF["detail_h"])
|
||
for _, tc in tcs:
|
||
title = tc.get("displayName", tc.get("name"))
|
||
out.append(f'??? abstract "{title} — `{tc.get("name")}`"')
|
||
body = []
|
||
body += render_params(tc)
|
||
if tc.get("stateTypes"):
|
||
body.append(REF["states_lbl"])
|
||
body.append("")
|
||
body += render_states(tc)
|
||
for ln in ("\n".join(body)).splitlines():
|
||
out.append(" " + ln if ln else "")
|
||
out.append("")
|
||
return "\n".join(out).rstrip() + "\n"
|
||
|
||
|
||
def render_nightly_stub(entry: dict, meta: dict) -> str:
|
||
"""Contenu généré pour une entrée nightly dont le JSON est absent."""
|
||
name = entry_name(entry, meta)
|
||
return (
|
||
f"_Support for **{name}** is being integrated — nightly channel._\n"
|
||
"\n"
|
||
"_Technical documentation will be available once the plugin reaches "
|
||
"the stable repository._\n"
|
||
)
|
||
|
||
|
||
def render_category_index(cat: str, entries: list, plugins: dict, src: Path) -> str:
|
||
"""Tableau listant les appareils d'une catégorie, pour l'index.md auto-généré."""
|
||
cat_entries = [e for e in entries if e.get("category") == cat and e.get("plugin")]
|
||
if not cat_entries:
|
||
return REF["cat_empty"]
|
||
|
||
rows = []
|
||
for e in cat_entries:
|
||
fname = f"integrationplugin{e['plugin']}.json"
|
||
plugin_data = plugins.get(fname)
|
||
meta = load_meta(src, e["plugin"])
|
||
name = entry_name(e, meta)
|
||
slug = e.get("slug") or e["plugin"]
|
||
protocol = resolve_protocol(plugin_data) if plugin_data else "—"
|
||
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
|
||
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "—")
|
||
stability = meta.get("stability", "")
|
||
stability_badge = STABILITY_BADGES.get(stability, "—") if stability else "—"
|
||
rows.append([f"[{name}]({slug}.md)", protocol, channel_badge, origin_badge,
|
||
stability_badge])
|
||
|
||
return md_table(REF["cat_cols"], rows) + "\n"
|
||
|
||
|
||
def render_matrix(entries: list, plugins: dict, src: Path) -> str:
|
||
by_cat: dict = {}
|
||
for e in entries:
|
||
by_cat.setdefault(e.get("category", "autre"), []).append(e)
|
||
|
||
lines = []
|
||
ordered_cats = [c for c in CATEGORY_ORDER if c in by_cat]
|
||
ordered_cats += [c for c in by_cat if c not in CATEGORY_ORDER]
|
||
|
||
for cat in ordered_cats:
|
||
label = CATEGORY_LABELS.get(cat, cat.capitalize())
|
||
cat_rows = []
|
||
for e in by_cat[cat]:
|
||
fname = f"integrationplugin{e['plugin']}.json" if e.get("plugin") else None
|
||
plugin_data = plugins.get(fname) if fname else None
|
||
if e["channel"] == "nightly" and (not fname or fname not in plugins):
|
||
print(f" INFO matrice : {e.get('plugin', '?')} nightly sans JSON "
|
||
f"→ ligne stub dans la matrice", file=sys.stderr)
|
||
meta = load_meta(src, e["plugin"]) if e.get("plugin") else {}
|
||
name = entry_name(e, meta)
|
||
protocol = resolve_protocol(plugin_data)
|
||
channel_badge = CHANNEL_BADGES.get(e["channel"], e["channel"])
|
||
origin_badge = ORIGIN_BADGES.get(resolve_origin(e), "—")
|
||
stability = meta.get("stability", "")
|
||
stability_badge = STABILITY_BADGES.get(stability, "—") if stability else "—"
|
||
cat_rows.append(
|
||
f"| {name} | {protocol} | {channel_badge} | {origin_badge} | {stability_badge} |"
|
||
)
|
||
if not cat_rows:
|
||
continue
|
||
lines.append(f"## {label}\n")
|
||
lines.append("| " + " | ".join(REF["matrix_cols"]) + " |")
|
||
lines.append("|---|---|---|---|---|")
|
||
lines.extend(cat_rows)
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
# ── SUMMARY.md pour mkdocs-literate-nav ──────────────────────────────────────
|
||
|
||
def generate_summary(entries: list, docs: Path, src: Path, plugins: dict,
|
||
check: bool) -> int:
|
||
"""Écrit docs/appareils/SUMMARY.md. Retourne 1 en mode --check si périmé."""
|
||
by_cat: dict = {}
|
||
for e in entries:
|
||
by_cat.setdefault(e.get("category", "autre"), []).append(e)
|
||
|
||
lines = ["* [Compatibilité](compatibilite.md)"]
|
||
|
||
ordered_cats = [c for c in CATEGORY_ORDER if c in by_cat]
|
||
ordered_cats += [c for c in by_cat if c not in CATEGORY_ORDER]
|
||
|
||
for cat in ordered_cats:
|
||
label = CATEGORY_LABELS.get(cat, cat.capitalize())
|
||
folder = CATEGORY_FOLDER.get(cat, cat)
|
||
cat_entries = []
|
||
for e in by_cat[cat]:
|
||
if not e.get("plugin"):
|
||
continue
|
||
meta = load_meta(src, e["plugin"])
|
||
name = entry_name(e, meta)
|
||
slug = e.get("slug") or e["plugin"]
|
||
cat_entries.append(f" * [{name}]({folder}/{slug}.md)")
|
||
if cat_entries:
|
||
lines.append(f"* [{label}]({folder}/index.md)")
|
||
lines.extend(cat_entries)
|
||
|
||
new_content = "\n".join(lines) + "\n"
|
||
summary_path = docs / "appareils" / "SUMMARY.md"
|
||
|
||
if check:
|
||
if not summary_path.exists() or summary_path.read_text(encoding="utf-8") != new_content:
|
||
print(f" SUMMARY.md périmé : {summary_path.relative_to(docs.parent)}")
|
||
return 1
|
||
return 0
|
||
|
||
summary_path.write_text(new_content, encoding="utf-8")
|
||
print(f"SUMMARY.md → {summary_path.relative_to(docs.parent)}")
|
||
return 0
|
||
|
||
|
||
# ── Validation ────────────────────────────────────────────────────────────────
|
||
|
||
def validate_entries(entries: list, plugins: dict, src: Path) -> None:
|
||
errors = []
|
||
for e in entries:
|
||
if not e.get("plugin"):
|
||
continue
|
||
fname = f"integrationplugin{e['plugin']}.json"
|
||
|
||
if e["channel"] != "nightly":
|
||
if fname not in plugins:
|
||
errors.append(
|
||
f" BLOQUANT : {fname} introuvable dans --src "
|
||
f"(channel={e['channel']}, plugin={e['plugin']})"
|
||
)
|
||
meta = load_meta(src, e["plugin"])
|
||
if not e.get("name") and not meta.get("title"):
|
||
errors.append(
|
||
f" BLOQUANT : libellé manquant pour plugin={e['plugin']} "
|
||
f"(ni PORTING_STATUS.name ni meta.json.title)"
|
||
)
|
||
|
||
if errors:
|
||
sys.exit("ERREURS BLOQUANTES :\n" + "\n".join(errors))
|
||
|
||
|
||
# ── Construction d'une page complète ─────────────────────────────────────────
|
||
|
||
def build_device_page(e: dict, plugin_data: dict | None, meta: dict,
|
||
locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Construit le contenu complet d'une fiche appareil générée.
|
||
|
||
`locale` ne change QUE la prose (SCAFFOLD) : le bloc de référence est
|
||
identique dans les trois langues, en anglais, par décision du 2026-10-12.
|
||
"""
|
||
sc = SCAFFOLD[locale]
|
||
title = entry_name(e, meta)
|
||
plugin = e["plugin"]
|
||
fname = f"integrationplugin{plugin}.json"
|
||
|
||
badges_gen = render_header_badges(e, meta)
|
||
badges_block = (
|
||
f"<!-- BEGIN GENERATED: __badges_{plugin}__ -->\n{badges_gen}\n<!-- END GENERATED -->"
|
||
)
|
||
tagline = meta.get("tagline", "")
|
||
|
||
if plugin_data:
|
||
gen = render_plugin(plugin_data) + render_manual_url(e, meta)
|
||
else:
|
||
print(f" INFO : {fname} absent (nightly) → page stub pour {title}",
|
||
file=sys.stderr)
|
||
gen = render_nightly_stub(e, meta)
|
||
|
||
block = f"<!-- BEGIN GENERATED: {fname} -->\n{gen}\n<!-- END GENERATED -->"
|
||
|
||
header_parts = [f"# {title}", "", badges_block]
|
||
if tagline:
|
||
header_parts += ["", tagline]
|
||
header_parts += ["", sc["guide_todo"], "", sc["ref_h"], ""]
|
||
header = "\n".join(header_parts)
|
||
|
||
return header + "\n" + block + "\n"
|
||
|
||
|
||
def build_category_index_page(cat: str, entries: list, plugins: dict, src: Path,
|
||
locale: str = DEFAULT_LOCALE) -> str:
|
||
"""Construit le contenu complet d'un index.md de catégorie."""
|
||
label = CATEGORY_LABELS_I18N[locale].get(cat, cat.capitalize())
|
||
gen = render_category_index(cat, entries, plugins, src)
|
||
# Même format que process() : f"{m.group(1)}\n{gen}\n{m.group(4)}"
|
||
block = f"<!-- BEGIN GENERATED: __index_{cat}__ -->\n{gen}\n<!-- END GENERATED -->"
|
||
return f"---\nhide:\n - toc\n---\n\n# {label}\n\n{block}\n"
|
||
|
||
|
||
# ── Création des fichiers manquants ───────────────────────────────────────────
|
||
|
||
def ensure_device_pages(entries: list, plugins: dict, src: Path,
|
||
docs: Path, check: bool, locales: list | None = None) -> int:
|
||
"""Crée les fiches appareils inexistantes, dans CHAQUE langue.
|
||
|
||
Une fiche créée dans la seule langue par défaut réapparaîtrait en français
|
||
sous /de/ et /en/ par le repli `fallback_to_default` — d'où la boucle.
|
||
Une fiche déjà présente n'est jamais réécrite : la prose traduite à la main
|
||
survit aux régénérations.
|
||
"""
|
||
locales = locales or LOCALES
|
||
rc = 0
|
||
for e in entries:
|
||
if not e.get("plugin"):
|
||
continue
|
||
slug = e.get("slug") or e["plugin"]
|
||
folder = CATEGORY_FOLDER.get(e.get("category", ""), e.get("category", "autre"))
|
||
base = docs / "appareils" / folder
|
||
|
||
fname = f"integrationplugin{e['plugin']}.json"
|
||
plugin_data = plugins.get(fname)
|
||
meta = None
|
||
|
||
for locale in locales:
|
||
md_path = base / loc_name(slug, locale)
|
||
if md_path.exists():
|
||
continue
|
||
if meta is None:
|
||
meta = load_meta(src, e["plugin"])
|
||
content = build_device_page(e, plugin_data, meta, locale)
|
||
|
||
if check:
|
||
print(f" MANQUANT : {md_path.relative_to(docs.parent)}")
|
||
rc = 1
|
||
else:
|
||
md_path.parent.mkdir(parents=True, exist_ok=True)
|
||
md_path.write_text(content, encoding="utf-8")
|
||
print(f" CRÉÉ : {md_path.relative_to(docs.parent)}")
|
||
|
||
return rc
|
||
|
||
|
||
def ensure_category_indexes(entries: list, plugins: dict, src: Path,
|
||
docs: Path, check: bool,
|
||
locales: list | None = None) -> int:
|
||
"""Crée les index.md de catégorie inexistants, dans CHAQUE langue."""
|
||
locales = locales or LOCALES
|
||
cats_present = {e.get("category") for e in entries if e.get("category")}
|
||
rc = 0
|
||
for cat in sorted(cats_present):
|
||
folder = CATEGORY_FOLDER.get(cat, cat)
|
||
base = docs / "appareils" / folder
|
||
|
||
for locale in locales:
|
||
index_path = base / loc_name("index", locale)
|
||
if index_path.exists():
|
||
continue
|
||
|
||
content = build_category_index_page(cat, entries, plugins, src, locale)
|
||
|
||
if check:
|
||
print(f" MANQUANT : {index_path.relative_to(docs.parent)}")
|
||
rc = 1
|
||
else:
|
||
index_path.parent.mkdir(parents=True, exist_ok=True)
|
||
index_path.write_text(content, encoding="utf-8")
|
||
print(f" CRÉÉ : {index_path.relative_to(docs.parent)}")
|
||
|
||
return rc
|
||
|
||
|
||
# ── Traitement des fichiers MD (injection dans les marqueurs) ─────────────────
|
||
|
||
def process(docs: Path, entries: list, plugins: dict, src: Path, check: bool) -> int:
|
||
entry_by_plugin = {e["plugin"]: e for e in entries if e.get("plugin")}
|
||
changed = []
|
||
|
||
for md in sorted(docs.rglob("*.md")):
|
||
text = md.read_text(encoding="utf-8")
|
||
if "<!-- BEGIN GENERATED:" not in text:
|
||
continue
|
||
|
||
def repl(m: re.Match) -> str:
|
||
key = m.group("key")
|
||
if key == "__matrix__":
|
||
gen = render_matrix(entries, plugins, src)
|
||
elif key.startswith("__badges_"):
|
||
plugin_name = key.removeprefix("__badges_").removesuffix("__")
|
||
entry = entry_by_plugin.get(plugin_name)
|
||
if not entry:
|
||
print(f" ! {md.name}: clé '{key}' plugin '{plugin_name}' inconnu",
|
||
file=sys.stderr)
|
||
return m.group(0)
|
||
gen = render_header_badges(entry, load_meta(src, plugin_name))
|
||
elif key.startswith("__index_"):
|
||
# key = "__index_<cat>__" → strip prefix "__index_" et suffix "__"
|
||
cat = key.removeprefix("__index_").removesuffix("__")
|
||
gen = render_category_index(cat, entries, plugins, src)
|
||
elif key in plugins:
|
||
plugin_name = key.removeprefix("integrationplugin").removesuffix(".json")
|
||
entry = entry_by_plugin.get(plugin_name, {})
|
||
meta_e = load_meta(src, plugin_name) if entry else {}
|
||
gen = render_plugin(plugins[key]) + render_manual_url(entry, meta_e)
|
||
else:
|
||
# Clé plugin absente : nightly sans JSON → stub ; sinon avertissement
|
||
plugin_name = key.removeprefix("integrationplugin").removesuffix(".json")
|
||
entry = entry_by_plugin.get(plugin_name)
|
||
if entry and entry.get("channel") == "nightly":
|
||
print(f" INFO : {key} absent (nightly) → stub dans {md.name}",
|
||
file=sys.stderr)
|
||
gen = render_nightly_stub(entry, load_meta(src, plugin_name))
|
||
else:
|
||
print(f" ! {md.name}: clé '{key}' absente de --src", file=sys.stderr)
|
||
return m.group(0)
|
||
return f"{m.group(1)}\n{gen}\n{m.group(4)}"
|
||
|
||
new = MARKER_RE.sub(repl, text)
|
||
if new != text:
|
||
changed.append(md)
|
||
if not check:
|
||
md.write_text(new, encoding="utf-8")
|
||
|
||
if check:
|
||
if changed:
|
||
print("Doc PAS à jour :", ", ".join(str(c) for c in changed))
|
||
return 1
|
||
print("Doc à jour.")
|
||
return 0
|
||
print(f"Régénéré : {len(changed)} page(s).")
|
||
for c in changed:
|
||
print(f" - {c.relative_to(docs)}")
|
||
return 0
|
||
|
||
|
||
# ── Point d'entrée ────────────────────────────────────────────────────────────
|
||
|
||
def main() -> int:
|
||
global DEFAULT_LOCALE # `--lang` peut la redéfinir (voir plus bas)
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--src", default=".plugins-src", type=Path,
|
||
help="Dossier source des JSON (plat ou avec sous-dossiers repos)")
|
||
ap.add_argument("--docs", default="docs", type=Path)
|
||
# `--lang` existait depuis la CI mais n'était PAS lu (« Conservé pour
|
||
# compatibilité CI »). Il est devenu réel au passage multi-langue : il
|
||
# désigne la langue par défaut, celle dont les fichiers n'ont pas de suffixe.
|
||
ap.add_argument("--lang", default=DEFAULT_LOCALE, choices=LOCALES,
|
||
help=f"Langue par défaut, fichiers sans suffixe (défaut : {DEFAULT_LOCALE})")
|
||
ap.add_argument("--locales", default=",".join(LOCALES),
|
||
help=f"Langues à générer, séparées par des virgules (défaut : {','.join(LOCALES)})")
|
||
ap.add_argument("--check", action="store_true",
|
||
help="CI : exit 1 si la doc n'est pas à jour")
|
||
a = ap.parse_args()
|
||
|
||
src = a.src
|
||
|
||
DEFAULT_LOCALE = a.lang
|
||
locales = [x.strip() for x in a.locales.split(",") if x.strip()]
|
||
unknown = [x for x in locales if x not in SCAFFOLD]
|
||
if unknown:
|
||
print(f"ERREUR : langue(s) sans échafaudage SCAFFOLD : {', '.join(unknown)}",
|
||
file=sys.stderr)
|
||
return 2
|
||
if DEFAULT_LOCALE not in locales:
|
||
print(f"ERREUR : --lang {DEFAULT_LOCALE} absent de --locales {','.join(locales)}",
|
||
file=sys.stderr)
|
||
return 2
|
||
print(f"langues : {', '.join(locales)} · défaut (sans suffixe) : {DEFAULT_LOCALE}")
|
||
|
||
entries = load_porting_status(Path.cwd())
|
||
plugins = load_plugins(src)
|
||
|
||
if not plugins:
|
||
print(f"Aucun integrationplugin*.json trouvé dans {src}", file=sys.stderr)
|
||
return 2
|
||
print(f"{len(plugins)} plugin(s) chargé(s) : {', '.join(plugins)}")
|
||
|
||
validate_entries(entries, plugins, src)
|
||
|
||
rc_pages = ensure_device_pages(entries, plugins, src, a.docs, a.check, locales)
|
||
rc_indexes = ensure_category_indexes(entries, plugins, src, a.docs, a.check, locales)
|
||
rc_summary = generate_summary(entries, a.docs, src, plugins, a.check)
|
||
rc_process = process(a.docs, entries, plugins, src, a.check)
|
||
|
||
return max(rc_pages, rc_indexes, rc_summary, rc_process)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|