using MikrotikInstaller.Core.Localization; using System.Collections.ObjectModel; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MikrotikInstaller.Core.Connectivity; using MikrotikInstaller.Core.Expert; namespace MikrotikInstaller.App.ViewModels.Steps; /// Ein Eintrag in der Menüliste links: ein kuratiertes Menü oder ein Werkzeug. public sealed record ExpertMenuItem(string Title, string Group, string? Path, MenuSchema? Schema, string? Tool = null); /// Ein Listeneintrag eines Menüs. public sealed class ExpertEntryViewModel(IReadOnlyDictionary row, MenuSchema? schema) { public IReadOnlyDictionary Row { get; } = row; public string? Id => Row.GetValueOrDefault(".id"); /// Die wichtigsten Spalten des Menüs, sonst die ersten sinnvollen Felder. public string Summary { get { var keys = schema?.Columns is { Count: > 0 } columns ? columns : Row.Keys.Where(k => !k.StartsWith('.') && k is not ("disabled" or "dynamic" or "invalid")).Take(4).ToList(); var parts = keys.Select(k => Row.GetValueOrDefault(k)).Where(v => !string.IsNullOrEmpty(v)); var text = string.Join(" · ", parts); return text.Length > 0 ? text : Id ?? "(Eintrag)"; } } public bool IsDisabled => ExpertMenus.IsDisabled(Row); public bool IsDynamic => ExpertMenus.IsTrue(Row, "dynamic"); public bool CanDelete => ExpertMenus.CanDelete(schema, Row); } /// Ein Feld im Bearbeiten-Bereich, mit Hilfe aus dem Katalog. public partial class ExpertFieldViewModel : ObservableObject { public ExpertFieldViewModel(string key, string label, string help, string kind, IReadOnlyList options, string original) { Key = key; Label = label; Help = help; Kind = kind; Options = options; Original = kind == "bool" ? NormalizeBool(original) : original; _value = Original; } public string Key { get; } public string Label { get; } public string Help { get; } public string Kind { get; } public IReadOnlyList Options { get; } public string Original { get; } public bool IsBool => Kind == "bool"; public bool HasOptions => !IsBool && Options.Count > 0; public bool IsText => !IsBool && Options.Count == 0; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsChecked))] private string _value; public bool IsChecked { get => Value == "true"; set => Value = value ? "true" : "false"; } /// RouterOS liefert "true"/"false", akzeptiert aber auch "yes"/"no" — einheitlich vergleichen. private static string NormalizeBool(string value) => value switch { "yes" or "true" => "true", "no" or "false" => "false", _ => value, }; } /// /// Experte-Bereich (Issue #21, Mac „Experte“-Tab): kuratierte RouterOS-Menüs mit Feldhilfen und ein /// eigener Menüpfad für alles andere. Auflisten, bearbeiten, anlegen, löschen — jede Änderung zeigt vorher /// den genauen Befehl, vor der ersten Änderung entsteht eine Sicherung. Nur mit „Professioneller Netzwerker“. /// public partial class ExpertStepViewModel : WizardStepViewModelBase { private readonly WizardSession _session; public ExpertStepViewModel(WizardSession session) : base( title: L.T("Experte"), description: L.T("Direkter Zugriff auf RouterOS-Menüs. Jede Änderung zeigt vorher den genauen Befehl; vor der ersten Änderung wird eine Sicherung erstellt.")) { _session = session; Menus = [.. ExpertTools.Items, .. MenuCatalog.All.Select(m => new ExpertMenuItem(m.Title, m.Group, m.Path, m))]; Tools = new ExpertTools(session); } public IReadOnlyList Menus { get; } /// Werkzeuge (Netzwerke isolieren, Neues LAN, Port in Bridge, Pakete). public ExpertTools Tools { get; } public ObservableCollection Entries { get; } = []; [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsMenuSelected))] [NotifyPropertyChangedFor(nameof(IsToolSelected))] [NotifyPropertyChangedFor(nameof(CanAdd))] private ExpertMenuItem? _selectedMenu; public bool IsMenuSelected => SelectedMenu is { Tool: null }; public bool IsToolSelected => SelectedMenu is { Tool: not null }; public bool CanAdd => SelectedMenu?.Schema is not { Singleton: true } and not { FixedList: true } && IsMenuSelected; [ObservableProperty] private string _customPath = string.Empty; [ObservableProperty] private bool _isLoading; [ObservableProperty] private string? _errorMessage; [ObservableProperty] private string? _statusMessage; /// Der geöffnete Bearbeiten-Bereich, null wenn geschlossen (gemeinsam mit der Übersicht, Issue #24). [ObservableProperty] private ExpertEditorViewModel? _editor; public override async Task OnActivatedAsync() { await Tools.ActivateAsync(); if (SelectedMenu is { Path: not null }) { await LoadEntriesAsync(); } } partial void OnSelectedMenuChanged(ExpertMenuItem? value) { Tools.ActiveTool = value?.Tool; Editor = null; Entries.Clear(); ErrorMessage = null; StatusMessage = null; if (value is { Path: not null }) { _ = LoadEntriesAsync(); } } [RelayCommand] private void OpenCustomPath() { if (string.IsNullOrWhiteSpace(CustomPath)) { return; } var path = MenuCatalog.NormalizePath(CustomPath); SelectedMenu = Menus.FirstOrDefault(m => m.Path == path) ?? new ExpertMenuItem(path, L.T("Eigener Menüpfad"), path, MenuCatalog.Find(path)); } [RelayCommand] private Task Refresh() => LoadEntriesAsync(); private async Task LoadEntriesAsync() { if (_session.Client is not { } client || SelectedMenu?.Path is not { } path) { return; } IsLoading = true; ErrorMessage = null; Entries.Clear(); try { foreach (var row in await client.GetAsync(path)) { Entries.Add(new ExpertEntryViewModel(row, SelectedMenu.Schema)); } if (SelectedMenu.Schema is { Singleton: true } && Entries.Count > 0) { EditEntry(Entries[0]); } } catch (RouterOsException ex) { ErrorMessage = DescribeLoadError(path, ex); } finally { IsLoading = false; } } /// Fehlt ein Bereich, weil er ein eigenes Paket ist (z. B. Hotspot auf dem hAP lite), verständlich erklären (Mac v1.11.0). private static string DescribeLoadError(string path, RouterOsException ex) => path.StartsWith("/ip/hotspot", StringComparison.OrdinalIgnoreCase) ? L.T("Hotspot ist auf diesem Router ein eigenes Zusatzpaket und noch nicht installiert. Unter „Werkzeuge → Pakete“ lässt sich das Paket „hotspot“ installieren.") : ex.Message == L.T("Diese Einstellung wird von deinem Gerät nicht unterstützt.") ? L.F("Den Menüpfad „{0}“ gibt es auf diesem Router nicht (RouterOS-Version oder fehlendes Paket).", path) : ex.Message; [RelayCommand] private void EditEntry(ExpertEntryViewModel entry) => OpenEditor(entry.Row); [RelayCommand] private void NewEntry() => OpenEditor(null); private void OpenEditor(IReadOnlyDictionary? row) { if (SelectedMenu?.Path is not { } path) { return; } ExpertEditorViewModel? editor = null; editor = new ExpertEditorViewModel(_session, path, SelectedMenu.Schema, row, Tools.InterfaceNames, async () => { StatusMessage = L.F("Gespeichert: {0}", editor!.LastSavedCommand); await LoadEntriesAsync(); }); editor.Closed += (_, _) => { if (Editor == editor) { Editor = null; } }; Editor = editor; } [RelayCommand] private async Task DeleteEntryAsync(ExpertEntryViewModel entry) { if (_session.Client is not { } client || SelectedMenu?.Path is not { } path || entry.Id is not { } id || !entry.CanDelete) { return; } var preview = ExpertMenus.Preview(path, "remove", id, new Dictionary()); if (System.Windows.MessageBox.Show(L.F("Diesen Eintrag löschen?\n\n{0}\n\nBefehl: {1}", entry.Summary, preview), L.T("Eintrag löschen"), System.Windows.MessageBoxButton.YesNo, System.Windows.MessageBoxImage.Warning) != System.Windows.MessageBoxResult.Yes) { return; } IsLoading = true; ErrorMessage = null; try { if (!_session.ExpertBackupDone) { StatusMessage = L.T("Sicherung vor der ersten Änderung wird erstellt …"); await _session.CreateSafetyBackupAsync(client); _session.ExpertBackupDone = true; } await client.RemoveAsync(path, id); StatusMessage = L.F("Gelöscht: {0}", preview); } catch (RouterOsException ex) { ErrorMessage = ex.Message; } finally { IsLoading = false; } await LoadEntriesAsync(); } }