using System.ComponentModel; using System.Globalization; using System.Reflection; namespace IBKRTrader.Core.Settings; /// /// Beschreibung eines einzelnen bearbeitbaren Einstellungsfelds für Oberflächen (Desktop & Web). /// public sealed class SettingsFieldInfo { public required string Path { get; init; } public required string DisplayName { get; init; } public required string Description { get; init; } public required Type ValueType { get; init; } public required string TypeName { get; init; } public required bool IsPassword { get; init; } public string[]? EnumValues { get; init; } public required Func Get { get; init; } public required Action Set { get; init; } } /// /// Ein logischer Abschnitt von Einstellungen (z. B. "Trading", "Webserver", "Datenbank"). /// public sealed record SettingsSectionInfo(string Title, string Key, IReadOnlyList Fields); /// /// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Felds. /// Geheimnisse (Passwörter/Tokens) werden niemals lesend herausgegeben. /// public sealed record SettingsFieldDto( string Path, string DisplayName, string Description, string TypeName, bool IsPassword, bool HasValue, object? Value, string[]? EnumValues); /// /// DTO-Struktur für die Web-API: Serialisierbare Repräsentation eines Abschnitts. /// public sealed record SettingsSectionDto( string Title, string Key, IReadOnlyList Fields); /// /// Erstellt strukturierte Einstellungsmetadaten aus den Attributen der . /// Geteilt zwischen Desktop (Avalonia) und Web (Minimal API), damit beide exakt dieselbe /// Struktur und dieselben Validierungsregeln verwenden. /// public static class SettingsSchema { private static bool IsLeaf(Type t) => t == typeof(string) || t.IsEnum || t == typeof(int) || t == typeof(long) || t == typeof(double) || t == typeof(decimal) || t == typeof(bool); private static string TypeToName(Type t) { if (t == typeof(bool)) return "bool"; if (t.IsEnum) return "enum"; if (t == typeof(int) || t == typeof(long)) return "int"; if (t == typeof(double) || t == typeof(decimal)) return "decimal"; return "string"; } /// /// Analysiert das Konfigurationsobjekt per Reflection und erzeugt die Abschnittsliste. /// public static IReadOnlyList Build(object root) { var sections = new List(); Walk(root, prefixTitle: null, prefixKey: null, sections); return sections; } /// /// Erzeugt ein sicheres DTO für die Web-API. Passwörter und Tokens werden maskiert. /// public static IReadOnlyList ToDto(object root) { var sections = Build(root); return sections.Select(s => new SettingsSectionDto( Title: s.Title, Key: s.Key, Fields: s.Fields.Select(f => { var val = f.Get(); var hasVal = val != null && (!(val is string str) || !string.IsNullOrWhiteSpace(str)); return new SettingsFieldDto( Path: f.Path, DisplayName: f.DisplayName, Description: f.Description, TypeName: f.TypeName, IsPassword: f.IsPassword, HasValue: hasVal, // Wenn IsPassword true ist, wird der echte Wert niemals herausgegeben! Value: f.IsPassword ? null : val, EnumValues: f.EnumValues); }).ToList())).ToList(); } /// /// Wendet einen vom Web-Client übergebenen Wert auf das passende Feld an. /// public static bool TrySetValue(object root, string path, object? rawValue) { var sections = Build(root); var field = sections.SelectMany(s => s.Fields) .FirstOrDefault(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase)); if (field == null) return false; // Wenn ein Passwortfeld leer übergeben wird, bleibt das alte Kennwort unberührt if (field.IsPassword && (rawValue == null || (rawValue is string s && string.IsNullOrWhiteSpace(s)))) return true; try { if (field.ValueType == typeof(bool)) { var b = rawValue switch { bool boolVal => boolVal, string strVal => bool.TryParse(strVal, out var parsed) && parsed, _ => false }; field.Set(b); return true; } if (field.ValueType.IsEnum) { var str = rawValue?.ToString(); if (str != null && Enum.TryParse(field.ValueType, str, true, out var enumVal)) { field.Set(enumVal); return true; } return false; } if (field.ValueType == typeof(int)) { var num = Convert.ToInt32(rawValue, CultureInfo.InvariantCulture); field.Set(num); return true; } if (field.ValueType == typeof(decimal)) { var dec = Convert.ToDecimal(rawValue, CultureInfo.InvariantCulture); field.Set(dec); return true; } if (field.ValueType == typeof(double)) { var dbl = Convert.ToDouble(rawValue, CultureInfo.InvariantCulture); field.Set(dbl); return true; } field.Set(rawValue?.ToString() ?? ""); return true; } catch { return false; } } private static void Walk(object owner, string? prefixTitle, string? prefixKey, List sections) { var fields = new List(); foreach (var prop in owner.GetType().GetProperties(BindingFlags.Public | BindingFlags.Instance)) { if (!prop.CanRead || prop.GetIndexParameters().Length > 0) continue; var title = prop.GetCustomAttribute()?.DisplayName ?? prop.Name; var key = prefixKey == null ? prop.Name : $"{prefixKey}.{prop.Name}"; if (IsLeaf(prop.PropertyType)) { if (!prop.CanWrite) continue; var target = owner; var isPassword = prop.GetCustomAttribute()?.Password == true || prop.Name.Contains("Password", StringComparison.OrdinalIgnoreCase) || prop.Name.Contains("ApiToken", StringComparison.OrdinalIgnoreCase) || prop.Name.Contains("AuthToken", StringComparison.OrdinalIgnoreCase); fields.Add(new SettingsFieldInfo { Path = key, DisplayName = title, Description = prop.GetCustomAttribute()?.Description ?? "", ValueType = prop.PropertyType, TypeName = TypeToName(prop.PropertyType), IsPassword = isPassword, EnumValues = prop.PropertyType.IsEnum ? Enum.GetNames(prop.PropertyType) : null, Get = () => prop.GetValue(target), Set = v => prop.SetValue(target, v) }); continue; } if (prop.PropertyType.IsClass && prop.PropertyType.Namespace?.StartsWith("IBKRTrader", StringComparison.Ordinal) == true) { var child = prop.GetValue(owner); if (child != null) { Walk(child, prefixTitle == null ? title : $"{prefixTitle} · {title}", key, sections); } } } if (fields.Count > 0) { sections.Add(new SettingsSectionInfo( Title: prefixTitle ?? "Allgemein", Key: prefixKey ?? "Root", Fields: fields)); } } }