Files
RouterOS/build-manual.py
T
KayandClaude Sonnet 5 3a26f50800 M31: Handbuch in der App (Textanker, DE+EN)
"?"-Hilfe-Buttons in allen 6 Haupt-Tabs, allen 6 Wizard-Schritten und
allen 45 Experte-Menüs öffnen ein Handbuch-Fenster (WKWebView) und
springen per Textanker direkt zur passenden Manual-Stelle.

build-manual.py generalisiert auf beliebig viele Sprachen (LANGUAGES-
Dict) statt hart DE/EN. Manual.en.md: komplette Handübersetzung aller
Fließtext-Kapitel. Kapitel 5 (Experte-Referenz) wird pro Sprache
automatisch übersetzt, indem L10n.swifts eigenes App-Übersetzungs-
Dictionary wiederverwendet wird (714 Einträge geparst) statt einer
zweiten, separat gepflegten Übersetzung.

Live bestätigt (DE und EN).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-17 17:15:29 +02:00

576 lines
22 KiB
Python

#!/usr/bin/env python3
"""Regenerates Manual.md's Experte-reference table + all Mermaid diagrams + Manual.pdf.
Run after any change to RouterOSSchemaCatalog.swift, to a Manual-assets/diagrams/*.mmd
file, or to the hand-written prose in Manual.md itself — same "update docs after every
live-tested milestone" habit as README.md, just automated for the parts that would
otherwise drift from the app's actual source text.
Requires:
- `pip install markdown-it-py linkify-it-py mdit-py-plugins` (HTML step)
- `npx @mermaid-js/mermaid-cli` reachable (diagram rendering — auto-downloads on first use)
- Google Chrome installed at the default macOS path (HTML -> PDF via headless print)
Usage: python3 build-manual.py
"""
import re
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SCHEMA_SWIFT = ROOT / "RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift"
L10N_SWIFT = ROOT / "RouterOSAssistant/Core/Localization/L10n.swift"
ASSETS = ROOT / "Manual-assets"
DIAGRAMS = ASSETS / "diagrams"
CHROME = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
# One entry per manual language. "de" has no translation dict (it *is* the source
# language everything else is written against); every other language reuses the same
# DE->lang dictionary the app itself ships in L10n.swift for its own UI — Chapter 5
# (Expert reference) is generated per language straight from RouterOSSchemaCatalog.swift's
# German strings run through that dictionary, so a new language needs nothing here beyond
# adding its md_source and appending translations to L10n.swift; no new Python code.
LANGUAGES = {
"de": {"md_source": ROOT / "Manual.md", "pdf": ROOT / "Manual.pdf"},
"en": {"md_source": ROOT / "Manual.en.md", "pdf": ROOT / "Manual_en.pdf"},
}
def load_l10n_translations():
"""Parses `private static let translations: [String: String] = [...]` out of
L10n.swift — the exact same DE->EN dictionary the running app uses for its own UI
strings — so Chapter 5's field labels/help texts translate identically to what the
app itself shows, instead of a second, separately-maintained translation."""
src = L10N_SWIFT.read_text(encoding="utf-8")
marker = "translations: [String: String] = ["
start = src.index(marker) + len(marker)
end = find_matching_bracket(src, start - 1)
body = src[start:end]
translations = {}
i = 0
while i < len(body):
if body[i] == '"':
key, i = read_string_literal(body, i)
j = body.index(":", i) + 1
while body[j] in " \t\n":
j += 1
value, i = read_string_literal(body, j)
translations[key] = value
else:
i += 1
return translations
def find_matching_bracket(s, start):
depth, i, in_string, escape = 0, start, False, False
while i < len(s):
c = s[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "[":
depth += 1
elif c == "]":
depth -= 1
if depth == 0:
return i
i += 1
raise ValueError("unbalanced brackets starting at %d" % start)
CATEGORY_NAMES = {
"firewallFilter": "Firewall: Filter-Regeln",
"firewallNat": "Firewall: NAT (Portweiterleitung etc.)",
"firewallMangle": "Firewall: Mangle (Markierung/QoS-Vorbereitung)",
"firewallRaw": "Firewall: Raw (vor Connection-Tracking)",
"firewallAddressLists": "Firewall: Adress-Listen",
"interfaces": "Interfaces (Bridge, VLAN, VPN-Tunnel...)",
"ipAddressing": "IP-Adressierung & Dienste",
"routing": "Routing",
"vpn": "VPN-Server/Clients",
"wireless": "WLAN / CAPsMAN",
"queues": "Queues / Bandbreiten-Steuerung",
"system": "System",
"tools": "Werkzeuge & Überwachung",
}
CATEGORY_ORDER = [
"System",
"Interfaces (Bridge, VLAN, VPN-Tunnel...)",
"IP-Adressierung & Dienste",
"Routing",
"VPN-Server/Clients",
"WLAN / CAPsMAN",
"Firewall: Filter-Regeln",
"Firewall: NAT (Portweiterleitung etc.)",
"Firewall: Mangle (Markierung/QoS-Vorbereitung)",
"Firewall: Raw (vor Connection-Tracking)",
"Firewall: Adress-Listen",
"Queues / Bandbreiten-Steuerung",
"Werkzeuge & Überwachung",
]
# --- Swift schema parsing (RouterOSSchemaCatalog.swift -> structured menu list) ---
def find_matching_paren(s, start):
depth, i, in_string, escape = 0, start, False, False
while i < len(s):
c = s[i]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c == "(":
depth += 1
elif c == ")":
depth -= 1
if depth == 0:
return i
i += 1
raise ValueError("unbalanced parens starting at %d" % start)
def read_string_literal(s, i):
assert s[i] == '"'
j, escape, out = i + 1, False, []
while j < len(s):
c = s[j]
if escape:
out.append({"\\": "\\", '"': '"', "n": "\n"}.get(c, c))
escape = False
elif c == "\\":
escape = True
elif c == '"':
return "".join(out), j + 1
else:
out.append(c)
j += 1
raise ValueError("unterminated string at %d" % i)
def extract_kwarg_string(call, key):
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*", call)
if not m or m.end() >= len(call) or call[m.end()] != '"':
return None
val, _ = read_string_literal(call, m.end())
return val
def extract_kwarg_bool(call, key):
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*(true|false)", call)
return m.group(1) == "true" if m else None
def find_call_args_span(call, key):
m = re.search(r"(?<![A-Za-z0-9_])" + re.escape(key) + r"\s*:\s*", call)
if not m:
return None
i = m.end()
depth, j, in_string, escape = 0, i, False, False
while j < len(call):
c = call[j]
if in_string:
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string = True
elif c in "([":
depth += 1
elif c in ")]":
if depth == 0:
break
depth -= 1
elif c == "," and depth == 0:
break
j += 1
return call[i:j].strip()
def parse_kind(expr):
expr = expr.strip()
if expr in (".text", ".bool", ".int", ".duration", ".interfacePick"):
return {"type": expr[1:]}
if expr.startswith(".enumPick"):
opts_span = find_call_args_span(expr, "options")
opts = re.findall(r'"((?:[^"\\]|\\.)*)"', opts_span or "")
return {"type": "enumPick", "options": [o.replace('\\"', '"') for o in opts]}
if expr.startswith(".menuItemPick"):
return {"type": "menuItemPick", "menuPath": extract_kwarg_string(expr, "menuPath")}
return {"type": "raw"}
def parse_fields(fields_text):
fields = []
for m in re.finditer(r"RouterOSFieldSchema\(", fields_text):
start = m.end() - 1
end = find_matching_paren(fields_text, start)
call = fields_text[start + 1 : end]
kind_raw = find_call_args_span(call, "kind")
fields.append(
{
"key": extract_kwarg_string(call, "key"),
"label": extract_kwarg_string(call, "label"),
"kind": parse_kind(kind_raw) if kind_raw else {"type": "unknown"},
"help": extract_kwarg_string(call, "help") or "",
"defaultValue": extract_kwarg_string(call, "defaultValue"),
"required": bool(extract_kwarg_bool(call, "required")),
}
)
return fields
def parse_schema(src):
menus = []
for m in re.finditer(r"RouterOSMenuSchema\(", src):
start = m.end() - 1
end = find_matching_paren(src, start)
call = src[start + 1 : end]
cat_match = re.search(r"category\s*:\s*\.(\w+)", call)
fields_span = find_call_args_span(call, "fields")
menus.append(
{
"menuPath": extract_kwarg_string(call, "menuPath"),
"restPath": extract_kwarg_string(call, "restPath"),
"category": CATEGORY_NAMES.get(cat_match.group(1)) if cat_match else None,
"displayName": extract_kwarg_string(call, "displayName"),
"summary": extract_kwarg_string(call, "summary") or "",
"explanation": extract_kwarg_string(call, "explanation") or "",
"warning": extract_kwarg_string(call, "warning"),
"isSingleton": extract_kwarg_bool(call, "isSingleton") or False,
"fields": parse_fields(fields_span) if fields_span else [],
"generic": False,
}
)
for m in re.finditer(r"(?<![A-Za-z0-9_])generic\(", src):
start = m.end() - 1
end = find_matching_paren(src, start)
call = src[start + 1 : end]
args, depth, in_string, escape, cur = [], 0, False, False, ""
for c in call:
if in_string:
cur += c
if escape:
escape = False
elif c == "\\":
escape = True
elif c == '"':
in_string = False
else:
if c == '"':
in_string, cur = True, cur + c
elif c in "([":
depth, cur = depth + 1, cur + c
elif c in ")]":
depth, cur = depth - 1, cur + c
elif c == "," and depth == 0:
args.append(cur.strip())
cur = ""
else:
cur += c
if cur.strip():
args.append(cur.strip())
def unquote(a):
a = a.strip()
return a[1:-1].replace('\\"', '"') if a.startswith('"') else a
positional = [a for a in args if not re.match(r"^\w+\s*:", a)]
cat_match = re.match(r"\.(\w+)", positional[2]) if len(positional) > 2 else None
warning = None
for a in args:
wm = re.match(r"warning\s*:\s*(.*)$", a, re.S)
if wm:
warning = unquote(wm.group(1))
summary = unquote(positional[4]) if len(positional) > 4 else ""
explanation = unquote(positional[5]) if len(positional) > 5 else ""
menus.append(
{
"menuPath": unquote(positional[0]) if positional else None,
"restPath": unquote(positional[1]) if len(positional) > 1 else None,
"category": CATEGORY_NAMES.get(cat_match.group(1)) if cat_match else None,
"displayName": unquote(positional[3]) if len(positional) > 3 else None,
"summary": summary,
"explanation": explanation or summary,
"warning": warning,
"isSingleton": False,
"fields": [],
"generic": True,
}
)
seen, ordered = set(), []
for menu in menus:
if not menu["menuPath"] or menu["menuPath"] in seen or not menu["category"]:
continue
seen.add(menu["menuPath"])
ordered.append(menu)
return ordered
STATIC_LABELS = {
"de": {
"singleton_note": " *(Einstellungsmenü — genau ein Eintrag, kein Anlegen/Löschen)*",
"menu_line": "RouterOS-Menü: `{menu}` · REST-Pfad: `{rest}`\n",
"warning": "> ⚠️ **Achtung:** {text}\n",
"generic_note": (
"*Noch kein kuratiertes Formular — alle Felder erscheinen als freie "
"Schlüssel/Wert-Paare (siehe „Eigener Menüpfad“).*\n"
),
"table_header": "| Feld | RouterOS-Parameter | Typ | Pflicht | Standard | Hilfetext |",
"yes": "Ja", "no": "Nein",
"no_fields": "*Keine kuratierten Felder — generischer Schlüssel/Wert-Zugriff.*\n",
"kind": {
"text": "Text", "bool": "Ja/Nein", "int": "Zahl",
"duration": "Zeitdauer (Tage/Std/Min/Sek, per Stepper)",
"interfacePick": "Auswahl aus Live-Interface-Liste des Routers",
"enumPick": "Auswahl (fest): ",
"menuItemPick": "Verweis auf bestehenden Eintrag unter `{menu}`",
},
},
"en": {
"singleton_note": " *(settings menu — exactly one entry, no add/remove)*",
"menu_line": "RouterOS menu: `{menu}` · REST path: `{rest}`\n",
"warning": "> ⚠️ **Warning:** {text}\n",
"generic_note": (
"*No curated form yet — every field appears as a free-form "
"key/value pair (see \"Custom Menu Path\").*\n"
),
"table_header": "| Field | RouterOS Parameter | Type | Required | Default | Help Text |",
"yes": "Yes", "no": "No",
"no_fields": "*No curated fields — generic key/value access.*\n",
"kind": {
"text": "Text", "bool": "Yes/No", "int": "Number",
"duration": "Time duration (days/hrs/min/sec, via stepper)",
"interfacePick": "Picker from the router's live interface list",
"enumPick": "Fixed choice: ",
"menuItemPick": "Reference to an existing entry under `{menu}`",
},
},
}
def kind_label(kind, labels):
t = kind.get("type")
if t in labels["kind"] and t != "enumPick" and t != "menuItemPick":
return labels["kind"][t]
if t == "enumPick":
return labels["kind"]["enumPick"] + ", ".join(f"`{o}`" for o in kind.get("options", []))
if t == "menuItemPick":
return labels["kind"]["menuItemPick"].format(menu=kind.get("menuPath"))
return "—"
def esc(s):
return "" if s is None else s.replace("|", "\\|").replace("\n", " ")
def schema_anchor(menu_path):
"""Deterministic HTML anchor id for one Experte-menu's Manual heading, from its
RouterOS menu path — the same transform is duplicated in Swift (ManualAnchors.swift)
so the app can compute a schema's anchor from `RouterOSMenuSchema.menuPath` alone,
with no generated mapping file to keep in sync."""
return "schema-" + menu_path.strip("/").replace(" ", "-")
def render_expert_reference(menus, lang, translate):
"""`translate(s)` maps a German source string (displayName/summary/explanation/
warning/field label/help/category name) to this language's text — identity for
"de", the app's own L10n.swift dictionary for every other language."""
labels = STATIC_LABELS[lang]
by_cat = {}
for m in menus:
by_cat.setdefault(m["category"], []).append(m)
lines = []
for cat in CATEGORY_ORDER:
if cat not in by_cat:
continue
lines.append(f"### {translate(cat)}\n")
for m in by_cat[cat]:
note = labels["singleton_note"] if m["isSingleton"] else ""
lines.append(f'<a id="{schema_anchor(m["menuPath"])}"></a>')
lines.append(f"#### {translate(m['displayName'])}{note}")
lines.append(labels["menu_line"].format(menu=m["menuPath"], rest=m["restPath"]))
if m["summary"]:
lines.append(f"{translate(m['summary'])}\n")
if m["explanation"] and m["explanation"] != m["summary"]:
lines.append(f"{translate(m['explanation'])}\n")
if m.get("warning"):
lines.append(labels["warning"].format(text=translate(m["warning"])))
if m["generic"]:
lines.append(labels["generic_note"])
elif m["fields"]:
lines.append(labels["table_header"])
lines.append("|---|---|---|---|---|---|")
for f in m["fields"]:
default = f"`{esc(f['defaultValue'])}`" if f["defaultValue"] else "—"
required = labels["yes"] if f["required"] else labels["no"]
lines.append(
f"| {esc(translate(f['label']))} | `{esc(f['key'])}` | {kind_label(f['kind'], labels)} "
f"| {required} | {default} | {esc(translate(f['help']))} |"
)
lines.append("")
else:
lines.append(labels["no_fields"])
lines.append("")
return "\n".join(lines).strip()
def update_expert_reference_section(lang, md_path, translate):
src = SCHEMA_SWIFT.read_text(encoding="utf-8")
menus = parse_schema(src)
ref_md = render_expert_reference(menus, lang, translate)
manual = md_path.read_text(encoding="utf-8")
start_marker, end_marker = "<!-- EXPERT_REFERENCE_START -->", "<!-- EXPERT_REFERENCE_END -->"
start = manual.index(start_marker) + len(start_marker)
end = manual.index(end_marker)
manual = manual[:start] + "\n\n" + ref_md + "\n\n" + manual[end:]
md_path.write_text(manual, encoding="utf-8")
print(f"[{lang}] Expert reference updated: {len(menus)} menus, {sum(len(m['fields']) for m in menus)} fields")
# --- Diagram rendering ---
def render_diagrams():
DIAGRAMS.mkdir(parents=True, exist_ok=True)
for mmd in sorted(DIAGRAMS.glob("*.mmd")):
png = ASSETS / f"{mmd.stem}.png"
if png.exists() and png.stat().st_mtime > mmd.stat().st_mtime:
continue
subprocess.run(
["npx", "-y", "@mermaid-js/mermaid-cli", "-i", str(mmd), "-o", str(png), "-b", "white", "-s", "2"],
check=True,
)
print(f"rendered {png.name}")
# --- Markdown -> HTML -> PDF ---
STYLE = """
body { font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; font-size: 10.5pt; line-height: 1.5; color: #1a1a1a; }
h1 { font-size: 20pt; border-bottom: 2px solid #2f6fb0; padding-bottom: 6px; }
h2 { font-size: 15pt; color: #2f6fb0; margin-top: 28px; border-bottom: 1px solid #ccc; padding-bottom: 3px; }
h3 { font-size: 12.5pt; color: #1a4a75; margin-top: 20px; }
h4 { font-size: 11pt; margin-top: 14px; margin-bottom: 4px; }
code { background: #f2f2f2; padding: 1px 4px; border-radius: 3px; font-size: 92%; }
table { border-collapse: collapse; width: 100%; margin: 8px 0 16px 0; font-size: 9pt; }
th, td { border: 1px solid #ccc; padding: 4px 6px; text-align: left; vertical-align: top; }
th { background: #eef4fb; }
tr { page-break-inside: avoid; }
blockquote { border-left: 4px solid #d9a441; background: #fff8ea; margin: 10px 0; padding: 6px 12px; }
img { max-width: 90%; display: block; margin: 14px auto; }
a { color: #2f6fb0; }
hr { border: none; border-top: 1px solid #ddd; margin: 24px 0; }
"""
def render_body_html(md_path):
from markdown_it import MarkdownIt
md_text = md_path.read_text(encoding="utf-8")
# html=True: lets the `<a id="...">` anchors inserted before each heading (see
# schema_anchor() and Manual.md's hand-placed tab-/step-anchors) pass through as
# real DOM ids instead of being escaped as literal text — CommonMark disables raw
# HTML by default, this is the one option that matters for that here.
md = MarkdownIt("gfm-like", {"html": True}).enable("table")
return md.render(md_text)
def fix_img_srcs(body_html, embed_as_data_uri):
import base64
import mimetypes
def fix(m):
prefix, path, suffix = m.group(1), m.group(2), m.group(3)
if path.startswith(("http://", "https://", "file://", "data:", "/")):
return m.group(0)
full = (ROOT / path).resolve()
if embed_as_data_uri:
mime = mimetypes.guess_type(str(full))[0] or "image/png"
data = base64.b64encode(full.read_bytes()).decode("ascii")
return f'{prefix}data:{mime};base64,{data}{suffix}'
return f'{prefix}file://{full}{suffix}'
return re.sub(r'(<img[^>]*src=")([^"]+)("[^>]*>)', fix, body_html)
def render_pdf(lang, md_path, pdf_path):
body_html = fix_img_srcs(render_body_html(md_path), embed_as_data_uri=False)
html = f"""<!DOCTYPE html><html lang="{lang}"><head><meta charset="utf-8">
<title>RouterOS Assistant — Manual</title>
<style>
@page {{ size: A4; margin: 18mm 16mm; }}
{STYLE}</style></head><body>{body_html}</body></html>"""
html_path = ASSETS / f"_manual_build_{lang}.html"
html_path.write_text(html, encoding="utf-8")
subprocess.run(
[CHROME, "--headless", "--disable-gpu", "--no-pdf-header-footer",
f"--print-to-pdf={pdf_path}", f"file://{html_path}"],
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
html_path.unlink()
print(f"[{lang}] PDF written: {pdf_path} ({pdf_path.stat().st_size // 1024} KB)")
def render_app_html(lang, md_path):
"""Self-contained Manual[_lang].html (images embedded as base64 data URIs, no
external file references) bundled straight into the app as a resource — see
ManualView.swift. Self-contained because once inside the .app bundle there's no
Manual-assets/ directory next to it to resolve relative image paths against."""
body_html = fix_img_srcs(render_body_html(md_path), embed_as_data_uri=True)
html = f"""<!DOCTYPE html><html lang="{lang}"><head><meta charset="utf-8">
<title>RouterOS Assistant — Manual</title>
<style>
body {{ margin: 0; padding: 24px 32px; }}
{STYLE}</style></head><body>{body_html}</body></html>"""
suffix = "" if lang == "de" else f"_{lang}"
out = ROOT / f"RouterOSAssistant/Resources/Manual{suffix}.html"
out.write_text(html, encoding="utf-8")
print(f"[{lang}] App HTML written: {out} ({out.stat().st_size // 1024} KB)")
def main():
translations = load_l10n_translations()
def translate_en(german):
return translations.get(german, german)
translators = {"de": lambda s: s, "en": translate_en}
render_diagrams()
for lang, cfg in LANGUAGES.items():
update_expert_reference_section(lang, cfg["md_source"], translators[lang])
render_pdf(lang, cfg["md_source"], cfg["pdf"])
render_app_html(lang, cfg["md_source"])
if __name__ == "__main__":
main()