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:
Kay
2026-09-17 17:15:29 +02:00
co-authored by Claude Sonnet 5
parent e582728040
commit 3a26f50800
25 changed files with 7247 additions and 128 deletions
@@ -3,6 +3,7 @@ import SwiftUI
@main
struct RouterOSAssistantApp: App {
@StateObject private var connectionService = ConnectionService()
@StateObject private var manualNavigator = ManualNavigator()
/// Manual language override independent of the system locale, per the user's explicit
/// request for an in-app toggle button. `.environment(\.locale, ...)` does NOT make
/// `Text(LocalizedStringKey)` re-resolve against Localizable.xcstrings at runtime (confirmed
@@ -53,11 +54,18 @@ struct RouterOSAssistantApp: App {
// (\.font, ...)` only ever supplies a *default*: anywhere `.appFont(...)` already sets
// an explicit font, that still wins.
.environment(\.font, .system(size: 13 * ((AppTextSize(rawValue: textSizeRaw) ?? .standard).scale)))
.environmentObject(manualNavigator)
}
.environment(\.locale, Locale(identifier: appLanguage))
Settings {
SettingsView()
.environmentObject(manualNavigator)
}
Window(L10n.t("Handbuch", appLanguage), id: "manual") {
ManualView()
.environmentObject(manualNavigator)
}
}
}
@@ -16,6 +16,8 @@ enum L10n {
private static let translations: [String: String] = [
"Verbinden": "Connect",
"Handbuch": "Manual",
"Hilfe zu diesem Bereich im Handbuch öffnen": "Open help for this area in the manual",
"Einrichten": "Setup",
"Übersicht": "Topology",
"LAN-Scanner": "LAN Scanner",
@@ -212,6 +212,7 @@ struct BackupListView: View {
}
}
.navigationTitle(LocalizedStringKey(L10n.t("Sicherungen", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabBackup) } }
.toolbar {
ToolbarItem {
Button {
@@ -120,6 +120,7 @@ struct DevicesView: View {
}
}
.navigationTitle(LocalizedStringKey(L10n.t("LAN-Scanner", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabDevices) } }
.toolbar {
ToolbarItem {
Button {
@@ -79,6 +79,7 @@ struct ExpertMenuDetailView: View {
// See DevicesView's identical fix: `.formStyle(.grouped)` paints an opaque background
// over each Section's rows, hiding `.listRowBackground` (Zebra-Streifen) underneath it.
.scrollContentBackground(.hidden)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.schema(schema.menuPath)) } }
.navigationTitle(LocalizedStringKey(L10n.t(schema.displayName, appLanguage)))
.task(id: schema.id) { await viewModel.reloadItems() }
.sheet(item: $viewModel.editingItem) { item in
@@ -84,6 +84,7 @@ struct ExpertView: View {
}
.listStyle(.sidebar)
.navigationTitle(LocalizedStringKey(L10n.t("Experte", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabExpert) } }
} detail: {
if let schema = viewModel.selectedSchema {
ExpertMenuDetailView(
@@ -0,0 +1,118 @@
import SwiftUI
import WebKit
/// Static/stable anchor ids matching the `<a id="...">` markers hand-placed in `Manual.md`
/// (tab-/step-anchors) and auto-generated by `build-manual.py`'s `schema_anchor()` (Experte
/// menus) the two sides are kept in sync by construction: this is the exact same string
/// transform as the Python function, not a generated file, so there's nothing to regenerate
/// when a schema's `menuPath` doesn't change.
enum ManualAnchor {
static let tabConnect = "tab-connect"
static let stepWAN = "step-wan"
static let stepLAN = "step-lan"
static let stepVLAN = "step-vlan"
static let stepWifi = "step-wifi"
static let stepFirewall = "step-firewall"
static let stepReview = "step-review"
static let tabOverview = "tab-overview"
static let tabDevices = "tab-devices"
static let tabExpert = "tab-expert"
static let tabBackup = "tab-backup"
static let tabSettings = "tab-settings"
/// Mirrors `build-manual.py`'s `schema_anchor(menu_path)` exactly.
static func schema(_ menuPath: String) -> String {
let trimmed = menuPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
return "schema-" + trimmed.replacingOccurrences(of: " ", with: "-")
}
}
/// Shared across the app (one instance, injected as an `@StateObject` in
/// `RouterOSAssistantApp`) so any "?"-help button anywhere can request the Handbuch window
/// jump to a specific section, whether or not that window is already open.
@MainActor
final class ManualNavigator: ObservableObject {
@Published var pendingAnchor: String?
func go(to anchor: String) {
// Reassigning the same value wouldn't trigger `didSet`/a fresh `onChange` in
// `ManualView` if the user clicks the same help button twice in a row nil the
// slot first so every request, including a repeat, actually re-scrolls.
pendingAnchor = nil
DispatchQueue.main.async { self.pendingAnchor = anchor }
}
}
private struct ManualWebView: NSViewRepresentable {
@ObservedObject var navigator: ManualNavigator
let language: String
/// One HTML file per language (`Manual.html` for "de", `Manual_en.html`, later
/// `Manual_es.html`, ...) `build-manual.py` names them the same way (`suffix = ""
/// if lang == "de" else f"_{lang}"`), so a new language needs no Swift change here,
/// only a new `Manual.<lang>.md` + an entry in `LANGUAGES` in that script.
final class Coordinator {
var loadedLanguage: String?
}
func makeCoordinator() -> Coordinator { Coordinator() }
func makeNSView(context: Context) -> WKWebView {
let webView = WKWebView()
load(language, into: webView, coordinator: context.coordinator)
return webView
}
func updateNSView(_ webView: WKWebView, context: Context) {
if context.coordinator.loadedLanguage != language {
load(language, into: webView, coordinator: context.coordinator)
}
guard let anchor = navigator.pendingAnchor else { return }
// The page is already loaded scrolling via JS instead of re-navigating to
// "Manual.html#anchor" avoids a full page reload/flash on every help-button click.
webView.evaluateJavaScript(
"document.getElementById(\(String(reflecting: anchor)))?.scrollIntoView({behavior: 'smooth', block: 'start'});"
)
}
private func load(_ language: String, into webView: WKWebView, coordinator: Coordinator) {
let resourceName = language == "de" ? "Manual" : "Manual_\(language)"
// Falls back to the German file if this language has no translated manual yet
// (e.g. a language the app UI supports but the manual hasn't been translated
// into) rather than showing a blank window.
guard let url = Bundle.main.url(forResource: resourceName, withExtension: "html")
?? Bundle.main.url(forResource: "Manual", withExtension: "html") else { return }
webView.loadFileURL(url, allowingReadAccessTo: url)
coordinator.loadedLanguage = language
}
}
struct ManualView: View {
@EnvironmentObject private var navigator: ManualNavigator
@AppStorage("appLanguage") private var appLanguage: String = "de"
var body: some View {
ManualWebView(navigator: navigator, language: appLanguage)
.frame(minWidth: 640, minHeight: 480)
}
}
/// Drop-in "?" toolbar button for any screen opens (or focuses, if already open) the
/// Handbuch window scrolled straight to `anchor`. One shared implementation so every tab/
/// wizard-step/Experte-schema screen wires help the same way.
struct ManualHelpButton: View {
let anchor: String
@AppStorage("appLanguage") private var appLanguage: String = "de"
@EnvironmentObject private var navigator: ManualNavigator
@Environment(\.openWindow) private var openWindow
var body: some View {
Button {
navigator.go(to: anchor)
openWindow(id: "manual")
} label: {
Image(systemName: "questionmark.circle")
}
.help(L10n.t("Hilfe zu diesem Bereich im Handbuch öffnen", appLanguage))
}
}
@@ -166,6 +166,7 @@ struct OverviewView: View {
}
}
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabOverview) } }
.toolbar {
ToolbarItemGroup {
if viewModel.isLoading {
@@ -107,6 +107,7 @@ struct ConnectView: View {
statusSection
}
.formStyle(.grouped)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabConnect) } }
.frame(minWidth: 320)
} detail: {
deviceDetail
@@ -368,6 +369,7 @@ struct ConnectView: View {
}
}
.formStyle(.grouped)
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.tabConnect) } }
.scrollContentBackground(.hidden)
} else {
ContentUnavailableView(
@@ -61,6 +61,7 @@ struct FirewallStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Firewall (optional)", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepFirewall) } }
.onAppear {
if viewModel.firewallSectionEnabled {
viewModel.loadExistingFirewallRuleCounts()
@@ -85,6 +85,7 @@ struct LanStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Heimnetzwerk einrichten", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepLAN) } }
}
private var isStepValid: Bool {
@@ -74,6 +74,7 @@ struct ReviewApplyView: View {
.formStyle(.grouped)
.scrollContentBackground(.hidden)
.navigationTitle(LocalizedStringKey(L10n.t("Übersicht & Anwenden", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepReview) } }
.alert(
L10n.t("Anwenden fehlgeschlagen", appLanguage),
isPresented: Binding(
@@ -68,6 +68,7 @@ struct VlanStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Zusätzliche Netzwerke (VLAN)", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepVLAN) } }
}
/// Only gates on filled-in fields while the VLAN section is actually enabled with it off,
@@ -60,6 +60,7 @@ struct WanStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("Internet einrichten", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepWAN) } }
}
private var isStepValid: Bool {
@@ -49,6 +49,7 @@ struct WifiStepView: View {
}
.formStyle(.grouped)
.navigationTitle(LocalizedStringKey(L10n.t("WLAN (falls vorhanden)", appLanguage)))
.toolbar { ToolbarItem { ManualHelpButton(anchor: ManualAnchor.stepWifi) } }
}
private var isStepValid: Bool {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long