using System.IO; using System.Text.Json; namespace TeamsISO.App.Services; /// /// Persistent named snapshots of which participants should have ISOs enabled and /// what their custom output names are. Useful for recurring shows: an operator /// can save the assignment they spent 5 minutes setting up, and on the next /// meeting load the same preset and auto-enable everyone whose display name /// matches. /// /// Persisted as JSON at %LOCALAPPDATA%\TeamsISO\presets.json. We key by /// participant rather than Id /// because the Id is freshly generated for every meeting (Teams' NDI source /// identity isn't stable across sessions); display name is the operator's /// natural identifier and is what they see in the UI anyway. /// public static class OperatorPresetStore { /// /// Test-only override for the presets file path. Tests set this to a temp /// path so they don't pollute the operator's real %LOCALAPPDATA% store. /// Null in production. /// in the project file grants the test assembly access. /// internal static string? PathOverride { get; set; } private static string PresetsPath => PathOverride ?? Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "TeamsISO", "presets.json"); /// /// One operator preset: a name, when it was saved, and a list of /// per-participant assignments keyed by display name. /// public sealed record Preset( string Name, DateTimeOffset SavedAt, IReadOnlyList Assignments); /// /// Single participant's assignment within a preset. Both fields are stable /// across meetings; is the join key when applying. /// public sealed record Assignment( string DisplayName, string? CustomOutputName, bool Enabled); /// /// On-disk shape: a list of presets indexed by name. Wrapped in an object so /// we can grow the schema (versioning, defaults, last-used) without breaking /// existing files. + /// drive the "auto-apply on startup" feature; reading older files (which lack /// these fields) falls back to default values via the records' default ctor. /// private sealed record File( int Version, IReadOnlyList Presets, string? LastAppliedName = null, bool AutoApplyOnStartup = false); /// /// Operator-level preferences that travel inside the same JSON envelope as the /// presets themselves. Currently used for the "auto-apply last preset on launch" /// feature so the host can decide on startup whether to silently re-apply the /// most recent preset and which one to apply. /// public sealed record StartupPreference(string? LastAppliedName, bool AutoApplyOnStartup); /// Returns all stored presets, oldest first. Empty list if no file exists. public static IReadOnlyList LoadAll() => LoadFile().Presets ?? Array.Empty(); /// /// Returns the operator's startup preference (which preset, if any, should be /// auto-applied on launch). Defaults to (null, false) when no file exists /// or the file predates the field — older preset.json files deserialize cleanly /// because both fields are optional with default values. /// public static StartupPreference GetStartupPreference() { var file = LoadFile(); return new StartupPreference(file.LastAppliedName, file.AutoApplyOnStartup); } /// /// Records that was just successfully applied. Combined /// with , drives the auto-apply-on-launch flow. /// Preserves the rest of the file (presets, AutoApplyOnStartup flag) intact. /// public static void MarkApplied(string name) { var file = LoadFile(); WriteFile(file with { LastAppliedName = name }); } /// /// Toggles whether the host should auto-apply /// on next launch. Independent of so the operator can flip /// the toggle without losing the most-recent name. /// public static void SetAutoApplyOnStartup(bool enabled) { var file = LoadFile(); WriteFile(file with { AutoApplyOnStartup = enabled }); } /// /// Adds (or replaces) a preset by name. Atomic write: writes to a temp file /// then File.Replace so a crash mid-write doesn't corrupt the existing file. /// Preserves across writes. /// public static void Save(Preset preset) { var file = LoadFile(); var presets = (file.Presets ?? Array.Empty()) .Where(p => !string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase)) .Append(preset) .OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); WriteFile(file with { Presets = presets }); } /// /// Removes a preset by name. No-op if not present. If the deleted preset was /// the last-applied one, clears that field so we don't try to re-apply a missing /// preset on next launch. /// public static void Delete(string name) { var file = LoadFile(); var existing = file.Presets ?? Array.Empty(); var remaining = existing .Where(p => !string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)) .ToArray(); if (remaining.Length == existing.Count) return; // not present var clearedLastApplied = string.Equals(file.LastAppliedName, name, StringComparison.OrdinalIgnoreCase) ? null : file.LastAppliedName; WriteFile(file with { Presets = remaining, LastAppliedName = clearedLastApplied }); } /// Looks up a preset by name (case-insensitive). Null if not present. public static Preset? Find(string name) => LoadAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase)); /// /// Bundle format for the export/import surface. Wraps the preset list with /// a version stamp + an export timestamp so a future-format-aware importer /// can migrate the data. We deliberately export a flat preset list — not /// the full envelope — because StartupPreference is /// machine-local (operator A's "auto-apply Friday Show" shouldn't follow /// the bundle to operator B's machine). /// public sealed record Bundle( string Schema, DateTimeOffset ExportedAt, IReadOnlyList Presets) { public const string CurrentSchema = "teamsiso-presets-bundle/v1"; } /// /// Serialize every preset to a JSON string suitable for writing to disk. /// The shape is human-readable (WriteIndented) so an operator can diff /// two bundles in their editor. /// public static string ExportAllAsJson() { var bundle = new Bundle( Schema: Bundle.CurrentSchema, ExportedAt: DateTimeOffset.Now, Presets: LoadAll()); return JsonSerializer.Serialize(bundle, new JsonSerializerOptions { WriteIndented = true }); } /// /// Result of an import attempt — counts so the UI can toast a clear summary. /// public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error) { public static ImportResult Failed(string error) => new(0, 0, 0, error); } /// /// Import a bundle JSON. Per-preset name collision policy is determined by /// : when true, identically-named presets in the /// bundle replace local ones; when false they're skipped. Returns counts /// so the caller can toast a "added X, overwrote Y, skipped Z" summary. /// public static ImportResult ImportBundle(string json, bool overwrite) { Bundle? bundle; try { bundle = JsonSerializer.Deserialize(json); } catch (Exception ex) { return ImportResult.Failed("Could not parse bundle: " + ex.Message); } if (bundle is null || bundle.Presets is null) return ImportResult.Failed("Bundle was empty or malformed."); var existingNames = new HashSet( LoadAll().Select(p => p.Name), StringComparer.OrdinalIgnoreCase); var added = 0; var overwritten = 0; var skipped = 0; foreach (var p in bundle.Presets) { if (existingNames.Contains(p.Name)) { if (!overwrite) { skipped++; continue; } overwritten++; } else { added++; } try { Save(p); } catch { // One bad preset shouldn't abort the rest. Count as skipped so // the user knows their import wasn't 100% clean. if (overwrite && existingNames.Contains(p.Name)) overwritten--; else added--; skipped++; } } return new ImportResult(added, overwritten, skipped, null); } private static File LoadFile() { try { if (!System.IO.File.Exists(PresetsPath)) return new File(1, Array.Empty()); var json = System.IO.File.ReadAllText(PresetsPath); return JsonSerializer.Deserialize(json) ?? new File(1, Array.Empty()); } catch { return new File(1, Array.Empty()); } } private static void WriteFile(File file) { var dir = Path.GetDirectoryName(PresetsPath); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); var json = JsonSerializer.Serialize(file, new JsonSerializerOptions { WriteIndented = true }); var temp = PresetsPath + ".tmp"; System.IO.File.WriteAllText(temp, json); if (System.IO.File.Exists(PresetsPath)) System.IO.File.Replace(temp, PresetsPath, destinationBackupFileName: null); else System.IO.File.Move(temp, PresetsPath); } }