feat: preset import / export bundles
This commit is contained in:
parent
b8fe344c58
commit
f73552a6b9
2 changed files with 482 additions and 0 deletions
209
src/TeamsISO.App/PresetsDialog.xaml
Normal file
209
src/TeamsISO.App/PresetsDialog.xaml
Normal 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>
|
||||
273
src/TeamsISO.App/Services/OperatorPresetStore.cs
Normal file
273
src/TeamsISO.App/Services/OperatorPresetStore.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue