#!/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"(?= 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"(? 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'') 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 = "", "" 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 `` 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'(]*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""" RouterOS Assistant — Manual {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""" RouterOS Assistant — Manual {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()