dragon-iso/src/TeamsISO.App/Services/OperatorPresetStore.cs

273 lines
11 KiB
C#

using System.IO;
using System.Text.Json;
namespace TeamsISO.App.Services;
/// <summary>
/// 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 <c>%LOCALAPPDATA%\TeamsISO\presets.json</c>. We key by
/// participant <see cref="DisplayName"/> rather than <see cref="Guid"/> 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.
/// </summary>
public static class OperatorPresetStore
{
/// <summary>
/// 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. <see cref="System.Runtime.CompilerServices.InternalsVisibleToAttribute"/>
/// in the project file grants the test assembly access.
/// </summary>
internal static string? PathOverride { get; set; }
private static string PresetsPath =>
PathOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"TeamsISO",
"presets.json");
/// <summary>
/// One operator preset: a name, when it was saved, and a list of
/// per-participant assignments keyed by display name.
/// </summary>
public sealed record Preset(
string Name,
DateTimeOffset SavedAt,
IReadOnlyList<Assignment> Assignments);
/// <summary>
/// Single participant's assignment within a preset. Both fields are stable
/// across meetings; <see cref="DisplayName"/> is the join key when applying.
/// </summary>
public sealed record Assignment(
string DisplayName,
string? CustomOutputName,
bool Enabled);
/// <summary>
/// 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. <see cref="LastAppliedName"/> + <see cref="AutoApplyOnStartup"/>
/// drive the "auto-apply on startup" feature; reading older files (which lack
/// these fields) falls back to default values via the records' default ctor.
/// </summary>
private sealed record File(
int Version,
IReadOnlyList<Preset> Presets,
string? LastAppliedName = null,
bool AutoApplyOnStartup = false);
/// <summary>
/// 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.
/// </summary>
public sealed record StartupPreference(string? LastAppliedName, bool AutoApplyOnStartup);
/// <summary>Returns all stored presets, oldest first. Empty list if no file exists.</summary>
public static IReadOnlyList<Preset> LoadAll() => LoadFile().Presets ?? Array.Empty<Preset>();
/// <summary>
/// Returns the operator's startup preference (which preset, if any, should be
/// auto-applied on launch). Defaults to <c>(null, false)</c> when no file exists
/// or the file predates the field — older preset.json files deserialize cleanly
/// because both fields are optional with default values.
/// </summary>
public static StartupPreference GetStartupPreference()
{
var file = LoadFile();
return new StartupPreference(file.LastAppliedName, file.AutoApplyOnStartup);
}
/// <summary>
/// Records that <paramref name="name"/> was just successfully applied. Combined
/// with <see cref="SetAutoApplyOnStartup"/>, drives the auto-apply-on-launch flow.
/// Preserves the rest of the file (presets, AutoApplyOnStartup flag) intact.
/// </summary>
public static void MarkApplied(string name)
{
var file = LoadFile();
WriteFile(file with { LastAppliedName = name });
}
/// <summary>
/// Toggles whether the host should auto-apply <see cref="StartupPreference.LastAppliedName"/>
/// on next launch. Independent of <see cref="MarkApplied"/> so the operator can flip
/// the toggle without losing the most-recent name.
/// </summary>
public static void SetAutoApplyOnStartup(bool enabled)
{
var file = LoadFile();
WriteFile(file with { AutoApplyOnStartup = enabled });
}
/// <summary>
/// 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 <see cref="StartupPreference"/> across writes.
/// </summary>
public static void Save(Preset preset)
{
var file = LoadFile();
var presets = (file.Presets ?? Array.Empty<Preset>())
.Where(p => !string.Equals(p.Name, preset.Name, StringComparison.OrdinalIgnoreCase))
.Append(preset)
.OrderBy(p => p.Name, StringComparer.OrdinalIgnoreCase)
.ToArray();
WriteFile(file with { Presets = presets });
}
/// <summary>
/// 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.
/// </summary>
public static void Delete(string name)
{
var file = LoadFile();
var existing = file.Presets ?? Array.Empty<Preset>();
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 });
}
/// <summary>Looks up a preset by name (case-insensitive). Null if not present.</summary>
public static Preset? Find(string name) =>
LoadAll().FirstOrDefault(p => string.Equals(p.Name, name, StringComparison.OrdinalIgnoreCase));
/// <summary>
/// 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 <see cref="File"/> envelope — because StartupPreference is
/// machine-local (operator A's "auto-apply Friday Show" shouldn't follow
/// the bundle to operator B's machine).
/// </summary>
public sealed record Bundle(
string Schema,
DateTimeOffset ExportedAt,
IReadOnlyList<Preset> Presets)
{
public const string CurrentSchema = "teamsiso-presets-bundle/v1";
}
/// <summary>
/// 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.
/// </summary>
public static string ExportAllAsJson()
{
var bundle = new Bundle(
Schema: Bundle.CurrentSchema,
ExportedAt: DateTimeOffset.Now,
Presets: LoadAll());
return JsonSerializer.Serialize(bundle, new JsonSerializerOptions { WriteIndented = true });
}
/// <summary>
/// Result of an import attempt — counts so the UI can toast a clear summary.
/// </summary>
public sealed record ImportResult(int Added, int Overwritten, int Skipped, string? Error)
{
public static ImportResult Failed(string error) => new(0, 0, 0, error);
}
/// <summary>
/// Import a bundle JSON. Per-preset name collision policy is determined by
/// <paramref name="overwrite"/>: 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.
/// </summary>
public static ImportResult ImportBundle(string json, bool overwrite)
{
Bundle? bundle;
try
{
bundle = JsonSerializer.Deserialize<Bundle>(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<string>(
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<Preset>());
var json = System.IO.File.ReadAllText(PresetsPath);
return JsonSerializer.Deserialize<File>(json) ?? new File(1, Array.Empty<Preset>());
}
catch
{
return new File(1, Array.Empty<Preset>());
}
}
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);
}
}