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>
This commit is contained in:
+218
-63
@@ -19,12 +19,72 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent
|
||||
SCHEMA_SWIFT = ROOT / "RouterOSAssistant/Core/Models/RouterOSSchemaCatalog.swift"
|
||||
MANUAL_MD = ROOT / "Manual.md"
|
||||
MANUAL_PDF = ROOT / "Manual.pdf"
|
||||
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.)",
|
||||
@@ -268,20 +328,56 @@ def parse_schema(src):
|
||||
return ordered
|
||||
|
||||
|
||||
def kind_label(kind):
|
||||
t = kind.get("type")
|
||||
if t in ("text", "bool", "int", "duration", "interfacePick"):
|
||||
return {
|
||||
"text": "Text",
|
||||
"bool": "Ja/Nein",
|
||||
"int": "Zahl",
|
||||
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",
|
||||
}[t]
|
||||
"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 "Auswahl (fest): " + ", ".join(f"`{o}`" for o in kind.get("options", []))
|
||||
return labels["kind"]["enumPick"] + ", ".join(f"`{o}`" for o in kind.get("options", []))
|
||||
if t == "menuItemPick":
|
||||
return f"Verweis auf bestehenden Eintrag unter `{kind.get('menuPath')}`"
|
||||
return labels["kind"]["menuItemPick"].format(menu=kind.get("menuPath"))
|
||||
return "—"
|
||||
|
||||
|
||||
@@ -289,7 +385,19 @@ def esc(s):
|
||||
return "" if s is None else s.replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render_expert_reference(menus):
|
||||
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)
|
||||
@@ -298,50 +406,49 @@ def render_expert_reference(menus):
|
||||
for cat in CATEGORY_ORDER:
|
||||
if cat not in by_cat:
|
||||
continue
|
||||
lines.append(f"### {cat}\n")
|
||||
lines.append(f"### {translate(cat)}\n")
|
||||
for m in by_cat[cat]:
|
||||
note = " *(Einstellungsmenü — genau ein Eintrag, kein Anlegen/Löschen)*" if m["isSingleton"] else ""
|
||||
lines.append(f"#### {m['displayName']}{note}")
|
||||
lines.append(f"RouterOS-Menü: `{m['menuPath']}` · REST-Pfad: `{m['restPath']}`\n")
|
||||
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"{m['summary']}\n")
|
||||
lines.append(f"{translate(m['summary'])}\n")
|
||||
if m["explanation"] and m["explanation"] != m["summary"]:
|
||||
lines.append(f"{m['explanation']}\n")
|
||||
lines.append(f"{translate(m['explanation'])}\n")
|
||||
if m.get("warning"):
|
||||
lines.append(f"> ⚠️ **Achtung:** {m['warning']}\n")
|
||||
lines.append(labels["warning"].format(text=translate(m["warning"])))
|
||||
if m["generic"]:
|
||||
lines.append(
|
||||
"*Noch kein kuratiertes Formular — alle Felder erscheinen als freie "
|
||||
"Schlüssel/Wert-Paare (siehe „Eigener Menüpfad“).*\n"
|
||||
)
|
||||
lines.append(labels["generic_note"])
|
||||
elif m["fields"]:
|
||||
lines.append("| Feld | RouterOS-Parameter | Typ | Pflicht | Standard | Hilfetext |")
|
||||
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(f['label'])} | `{esc(f['key'])}` | {kind_label(f['kind'])} "
|
||||
f"| {'Ja' if f['required'] else 'Nein'} | {default} | {esc(f['help'])} |"
|
||||
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("*Keine kuratierten Felder — generischer Schlüssel/Wert-Zugriff.*\n")
|
||||
lines.append(labels["no_fields"])
|
||||
lines.append("")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
|
||||
def update_expert_reference_section():
|
||||
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)
|
||||
ref_md = render_expert_reference(menus, lang, translate)
|
||||
|
||||
manual = MANUAL_MD.read_text(encoding="utf-8")
|
||||
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:]
|
||||
MANUAL_MD.write_text(manual, encoding="utf-8")
|
||||
print(f"Expert reference updated: {len(menus)} menus, {sum(len(m['fields']) for m in menus)} fields")
|
||||
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 ---
|
||||
@@ -363,57 +470,105 @@ def render_diagrams():
|
||||
# --- Markdown -> HTML -> PDF ---
|
||||
|
||||
|
||||
def render_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 = MANUAL_MD.read_text(encoding="utf-8")
|
||||
md = MarkdownIt("gfm-like").enable("table")
|
||||
body_html = md.render(md_text)
|
||||
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(m):
|
||||
|
||||
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://", "/")):
|
||||
if path.startswith(("http://", "https://", "file://", "data:", "/")):
|
||||
return m.group(0)
|
||||
return f'{prefix}file://{(ROOT / path).resolve()}{suffix}'
|
||||
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}'
|
||||
|
||||
body_html = re.sub(r'(<img[^>]*src=")([^"]+)("[^>]*>)', fix_img, body_html)
|
||||
return re.sub(r'(<img[^>]*src=")([^"]+)("[^>]*>)', fix, body_html)
|
||||
|
||||
html = f"""<!DOCTYPE html><html lang="de"><head><meta charset="utf-8">
|
||||
<title>RouterOS Assistant — Bedienungsanleitung</title>
|
||||
|
||||
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; }}
|
||||
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; }}
|
||||
</style></head><body>{body_html}</body></html>"""
|
||||
{STYLE}</style></head><body>{body_html}</body></html>"""
|
||||
|
||||
html_path = ASSETS / "_manual_build.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={MANUAL_PDF}", f"file://{html_path}"],
|
||||
f"--print-to-pdf={pdf_path}", f"file://{html_path}"],
|
||||
check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
)
|
||||
html_path.unlink()
|
||||
print(f"PDF written: {MANUAL_PDF} ({MANUAL_PDF.stat().st_size // 1024} KB)")
|
||||
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():
|
||||
update_expert_reference_section()
|
||||
translations = load_l10n_translations()
|
||||
|
||||
def translate_en(german):
|
||||
return translations.get(german, german)
|
||||
|
||||
translators = {"de": lambda s: s, "en": translate_en}
|
||||
|
||||
render_diagrams()
|
||||
render_pdf()
|
||||
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__":
|
||||
|
||||
Reference in New Issue
Block a user