feat: preset import / export bundles

This commit is contained in:
Zac Gaetano 2026-05-10 09:41:30 -04:00
parent b8fe344c58
commit f73552a6b9
2 changed files with 482 additions and 0 deletions

View file

@ -0,0 +1,209 @@
<Window x:Class="TeamsISO.App.PresetsDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:shell="clr-namespace:System.Windows.Shell;assembly=PresentationFramework"
Title="Operator presets"
Icon="/Assets/teamsiso.ico"
Width="460" Height="520"
WindowStartupLocation="CenterOwner"
WindowStyle="None"
ResizeMode="NoResize"
Background="{DynamicResource Wd.Canvas}"
UseLayoutRounding="True"
TextOptions.TextFormattingMode="Ideal"
TextOptions.TextRenderingMode="ClearType">
<shell:WindowChrome.WindowChrome>
<shell:WindowChrome
CaptionHeight="32"
ResizeBorderThickness="0"
CornerRadius="0"
GlassFrameThickness="0"
UseAeroCaptionButtons="False"/>
</shell:WindowChrome.WindowChrome>
<Border BorderBrush="{DynamicResource Wd.Border}" BorderThickness="1">
<Grid Margin="24,16,24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<!-- Caption -->
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Text="OPERATOR PRESETS"
Style="{StaticResource Wd.Text.Caption}"
VerticalAlignment="Center"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.CaptionClose}"
Click="OnCancel"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<Path Data="M 0,0 L 10,10 M 10,0 L 0,10"
Stroke="{DynamicResource Wd.Text.Primary}"
StrokeThickness="1.2"
Width="10" Height="10"
Stretch="None"/>
</Button>
</Grid>
<TextBlock Grid.Row="1"
Text="Save the current ISO assignments as a named preset, or load an existing preset to restore them."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontSize="12"
TextWrapping="Wrap"
Margin="0,12,0,16"/>
<!-- Save row: name textbox + Save button -->
<Grid Grid.Row="2">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBox x:Name="NameBox"
ToolTip="Name for the new preset (or pick an existing one to overwrite)"
VerticalContentAlignment="Center"
Margin="0,0,12,0"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Primary}"
Content="Save"
Click="OnSave"
Padding="20,8"/>
</Grid>
<!-- Existing presets list -->
<Border Grid.Row="3"
Style="{StaticResource Wd.Card}"
Padding="0"
Margin="0,16,0,0">
<Grid>
<ListBox x:Name="PresetsList"
Background="Transparent"
BorderThickness="0"
SelectionMode="Single"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
SelectionChanged="OnSelectionChanged">
<ListBox.ItemContainerStyle>
<Style TargetType="ListBoxItem">
<Setter Property="Padding" Value="12,8"/>
<Setter Property="Foreground" Value="{DynamicResource Wd.Text.Primary}"/>
<Setter Property="Background" Value="Transparent"/>
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="ListBoxItem">
<Border x:Name="Bd"
Background="{TemplateBinding Background}"
Padding="{TemplateBinding Padding}"
CornerRadius="4"
Margin="4,2">
<ContentPresenter/>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceHover}"/>
</Trigger>
<Trigger Property="IsSelected" Value="True">
<Setter TargetName="Bd" Property="Background" Value="{DynamicResource Wd.SurfaceElevated}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ListBox.ItemContainerStyle>
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel>
<TextBlock Text="{Binding Name}"
Style="{StaticResource Wd.Text.Body}"
FontWeight="Medium"/>
<TextBlock Style="{StaticResource Wd.Text.Mono}"
FontSize="10"
Foreground="{DynamicResource Wd.Text.Tertiary}">
<Run Text="{Binding SavedAtDisplay, Mode=OneWay}"/>
<Run Text=" · "/>
<Run Text="{Binding AssignmentCount, Mode=OneWay}"/>
<Run Text=" assignments"/>
</TextBlock>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<!-- Empty-state inside the card -->
<TextBlock x:Name="EmptyState"
Text="No presets yet. Type a name above and click Save."
Style="{StaticResource Wd.Text.Subtle}"
Foreground="{DynamicResource Wd.Text.Tertiary}"
FontSize="12"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Visibility="Collapsed"/>
</Grid>
</Border>
<!-- Footer buttons -->
<Grid Grid.Row="4" Margin="0,16,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Grid.Column="0"
Style="{StaticResource Wd.Button.Ghost}"
Content="Delete"
Click="OnDelete"
IsEnabled="False"
x:Name="DeleteButton"
Padding="14,8"/>
<Button Grid.Column="1"
Style="{StaticResource Wd.Button.Ghost}"
Content="Duplicate"
Click="OnDuplicate"
IsEnabled="False"
x:Name="DuplicateButton"
Margin="8,0,0,0"
Padding="14,8"
ToolTip="Copy the selected preset to a new name. Useful when iterating on variants of a recurring show."/>
<Button Grid.Column="2"
Style="{StaticResource Wd.Button.Ghost}"
Content="Export…"
Click="OnExport"
Margin="8,0,0,0"
Padding="14,8"
ToolTip="Save every preset as a single .json bundle. Useful for moving a curated library between machines, or sharing with a colleague."/>
<Button Grid.Column="3"
Style="{StaticResource Wd.Button.Ghost}"
Content="Import…"
Click="OnImport"
Margin="8,0,0,0"
Padding="14,8"
ToolTip="Load presets from a .json bundle. Existing presets with the same name are skipped unless you confirm overwrite."/>
<Button Grid.Column="5"
Style="{StaticResource Wd.Button.Ghost}"
Content="Cancel"
Click="OnCancel"
Margin="0,0,8,0"
Padding="14,8"/>
<Button Grid.Column="6"
Style="{StaticResource Wd.Button.Primary}"
Content="Apply"
Click="OnApply"
IsEnabled="False"
x:Name="ApplyButton"
Padding="20,8"/>
</Grid>
</Grid>
</Border>
</Window>

View file

@ -0,0 +1,273 @@
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);
}
}