426 lines
16 KiB
C#
426 lines
16 KiB
C#
using System.Collections.ObjectModel;
|
|
using System.Windows;
|
|
using System.Windows.Controls;
|
|
using TeamsISO.App.Services;
|
|
using TeamsISO.App.ViewModels;
|
|
using TeamsISO.Engine.Controller;
|
|
|
|
namespace TeamsISO.App;
|
|
|
|
/// <summary>
|
|
/// Modal dialog for saving and loading operator presets. Owned by
|
|
/// <see cref="MainWindow"/>; given a snapshot of the current
|
|
/// <see cref="ParticipantViewModel"/> list and the
|
|
/// <see cref="IIsoController"/> so it can re-apply assignments
|
|
/// (which requires calling EnableIsoAsync/DisableIsoAsync on the engine).
|
|
/// </summary>
|
|
public partial class PresetsDialog : Window
|
|
{
|
|
private readonly IIsoController _controller;
|
|
private readonly IReadOnlyList<ParticipantViewModel> _participants;
|
|
private readonly ToastViewModel? _toast;
|
|
|
|
/// <summary>
|
|
/// Display-side wrapper for an <see cref="OperatorPresetStore.Preset"/>.
|
|
/// Adds derived presentation-only properties so the ListBox template can
|
|
/// render without inline converters or value-conversion logic.
|
|
/// </summary>
|
|
public sealed class PresetRow
|
|
{
|
|
public OperatorPresetStore.Preset Preset { get; }
|
|
public string Name => Preset.Name;
|
|
public string SavedAtDisplay => Preset.SavedAt.LocalDateTime.ToString("yyyy-MM-dd HH:mm");
|
|
public int AssignmentCount => Preset.Assignments.Count(a => a.Enabled);
|
|
public PresetRow(OperatorPresetStore.Preset preset) => Preset = preset;
|
|
}
|
|
|
|
public ObservableCollection<PresetRow> Rows { get; } = new();
|
|
|
|
public PresetsDialog(
|
|
IIsoController controller,
|
|
IReadOnlyList<ParticipantViewModel> participants,
|
|
ToastViewModel? toast = null)
|
|
{
|
|
InitializeComponent();
|
|
_controller = controller;
|
|
_participants = participants;
|
|
_toast = toast;
|
|
PresetsList.ItemsSource = Rows;
|
|
ReloadPresets();
|
|
}
|
|
|
|
/// <summary>Refresh the ListBox from disk and reflect emptiness in the empty-state TextBlock.</summary>
|
|
private void ReloadPresets()
|
|
{
|
|
Rows.Clear();
|
|
foreach (var p in OperatorPresetStore.LoadAll().OrderByDescending(p => p.SavedAt))
|
|
Rows.Add(new PresetRow(p));
|
|
EmptyState.Visibility = Rows.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
|
|
UpdateButtonStates();
|
|
}
|
|
|
|
private void UpdateButtonStates()
|
|
{
|
|
var hasSelection = PresetsList.SelectedItem is PresetRow;
|
|
ApplyButton.IsEnabled = hasSelection;
|
|
DeleteButton.IsEnabled = hasSelection;
|
|
DuplicateButton.IsEnabled = hasSelection;
|
|
}
|
|
|
|
private void OnSelectionChanged(object sender, SelectionChangedEventArgs e)
|
|
{
|
|
if (PresetsList.SelectedItem is PresetRow row)
|
|
{
|
|
// Mirror the selected name into the textbox so a re-save overwrites
|
|
// by default; operator can still type a new name to fork.
|
|
NameBox.Text = row.Name;
|
|
}
|
|
UpdateButtonStates();
|
|
}
|
|
|
|
private void OnSave(object sender, RoutedEventArgs e)
|
|
{
|
|
var name = NameBox.Text?.Trim();
|
|
if (string.IsNullOrWhiteSpace(name))
|
|
{
|
|
_toast?.Warn("Enter a name for the preset");
|
|
NameBox.Focus();
|
|
return;
|
|
}
|
|
|
|
var assignments = _participants
|
|
.Select(p => new OperatorPresetStore.Assignment(
|
|
DisplayName: p.DisplayName,
|
|
CustomOutputName: string.IsNullOrWhiteSpace(p.CustomName) ? null : p.CustomName,
|
|
Enabled: p.IsEnabled))
|
|
.ToList();
|
|
|
|
var existing = OperatorPresetStore.Find(name);
|
|
if (existing is not null)
|
|
{
|
|
var confirm = MessageBox.Show(
|
|
this,
|
|
$"A preset named \"{name}\" already exists. Overwrite it?",
|
|
"TeamsISO — Overwrite preset",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Question,
|
|
MessageBoxResult.No);
|
|
if (confirm != MessageBoxResult.Yes) return;
|
|
}
|
|
|
|
try
|
|
{
|
|
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
|
Name: name,
|
|
SavedAt: DateTimeOffset.Now,
|
|
Assignments: assignments));
|
|
_toast?.Show($"Saved preset \"{name}\"");
|
|
ReloadPresets();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(
|
|
this,
|
|
$"Could not save preset.\n\n{ex.Message}",
|
|
"TeamsISO — Save preset",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Apply the selected preset: walks the current participants list, matching
|
|
/// by display name (the only stable join key across meetings — Ids are
|
|
/// regenerated each meeting). For each match, set the custom output name and
|
|
/// reconcile its enabled state with the preset by calling EnableIsoAsync /
|
|
/// DisableIsoAsync as needed. Participants in the preset who aren't in the
|
|
/// current meeting are silently skipped (and reported in the toast).
|
|
/// </summary>
|
|
private async void OnApply(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PresetsList.SelectedItem is not PresetRow row) return;
|
|
|
|
ApplyButton.IsEnabled = false;
|
|
try
|
|
{
|
|
// PresetApplier owns the apply loop — same code path the REST control
|
|
// surface and auto-apply-on-launch use. Dialog passes null dispatcher
|
|
// since OnApply already runs on the UI thread.
|
|
var result = await PresetApplier.ApplyAsync(
|
|
row.Preset, _participants, _controller, dispatcher: null);
|
|
|
|
var summary = result.Skipped > 0
|
|
? $"Applied \"{row.Name}\" — {result.Changed} change(s); {result.Skipped} not in meeting"
|
|
: $"Applied \"{row.Name}\" — {result.Changed} change(s)";
|
|
_toast?.Show(summary);
|
|
DialogResult = true;
|
|
Close();
|
|
}
|
|
finally
|
|
{
|
|
ApplyButton.IsEnabled = true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Duplicate the selected preset under a new name. We auto-suggest
|
|
/// "<original> (copy)" but pop a tiny input dialog so the operator
|
|
/// can pick something meaningful. WPF doesn't ship an InputBox; we
|
|
/// use a quick custom prompt below.
|
|
/// </summary>
|
|
private void OnDuplicate(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PresetsList.SelectedItem is not PresetRow row) return;
|
|
|
|
var defaultName = SuggestCopyName(row.Name);
|
|
var newName = PromptForName("Duplicate preset", "New name:", defaultName);
|
|
if (string.IsNullOrWhiteSpace(newName)) return;
|
|
|
|
try
|
|
{
|
|
var existing = OperatorPresetStore.Find(newName);
|
|
if (existing is not null)
|
|
{
|
|
var confirm = MessageBox.Show(
|
|
this,
|
|
$"A preset named \"{newName}\" already exists. Overwrite it?",
|
|
"TeamsISO — Duplicate preset",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Question,
|
|
MessageBoxResult.No);
|
|
if (confirm != MessageBoxResult.Yes) return;
|
|
}
|
|
|
|
// Re-using Save() with a fresh SavedAt timestamp — Save's overwrite
|
|
// semantics handle the name-collision case cleanly.
|
|
OperatorPresetStore.Save(new OperatorPresetStore.Preset(
|
|
Name: newName,
|
|
SavedAt: DateTimeOffset.Now,
|
|
Assignments: row.Preset.Assignments));
|
|
_toast?.Show($"Duplicated to \"{newName}\"");
|
|
ReloadPresets();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(this,
|
|
$"Could not duplicate preset.\n\n{ex.Message}",
|
|
"TeamsISO — Duplicate preset",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Suggest a name for a copy: "Foo" → "Foo (copy)", "Foo (copy)" → "Foo (copy 2)".
|
|
/// Bumps the digit if the operator iterates from a copy.
|
|
/// </summary>
|
|
private static string SuggestCopyName(string original)
|
|
{
|
|
if (!original.EndsWith(")", StringComparison.Ordinal))
|
|
return original + " (copy)";
|
|
var match = System.Text.RegularExpressions.Regex.Match(original, @" \(copy(?: (\d+))?\)$");
|
|
if (!match.Success) return original + " (copy)";
|
|
var n = match.Groups[1].Success && int.TryParse(match.Groups[1].Value, out var parsed) ? parsed + 1 : 2;
|
|
return original[..(original.Length - match.Length)] + $" (copy {n})";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Quick input dialog for a single string. WPF doesn't ship one, so we
|
|
/// build a minimal modal here. Keeps the dialog dependency-free.
|
|
/// </summary>
|
|
private string? PromptForName(string title, string prompt, string defaultValue)
|
|
{
|
|
var dlg = new System.Windows.Window
|
|
{
|
|
Title = title,
|
|
Owner = this,
|
|
Width = 400,
|
|
Height = 170,
|
|
WindowStartupLocation = System.Windows.WindowStartupLocation.CenterOwner,
|
|
ResizeMode = System.Windows.ResizeMode.NoResize,
|
|
Background = (System.Windows.Media.Brush)FindResource("Wd.Canvas"),
|
|
};
|
|
var stack = new System.Windows.Controls.StackPanel { Margin = new System.Windows.Thickness(20) };
|
|
stack.Children.Add(new System.Windows.Controls.TextBlock
|
|
{
|
|
Text = prompt,
|
|
Margin = new System.Windows.Thickness(0, 0, 0, 8),
|
|
Foreground = (System.Windows.Media.Brush)FindResource("Wd.Text.Primary"),
|
|
});
|
|
var tb = new System.Windows.Controls.TextBox { Text = defaultValue, Padding = new System.Windows.Thickness(8, 6, 8, 6) };
|
|
stack.Children.Add(tb);
|
|
var buttons = new System.Windows.Controls.StackPanel
|
|
{
|
|
Orientation = System.Windows.Controls.Orientation.Horizontal,
|
|
HorizontalAlignment = System.Windows.HorizontalAlignment.Right,
|
|
Margin = new System.Windows.Thickness(0, 16, 0, 0),
|
|
};
|
|
var ok = new System.Windows.Controls.Button { Content = "OK", IsDefault = true, Padding = new System.Windows.Thickness(20, 6, 20, 6), Style = (System.Windows.Style)FindResource("Wd.Button.Primary") };
|
|
var cancel = new System.Windows.Controls.Button { Content = "Cancel", IsCancel = true, Padding = new System.Windows.Thickness(14, 6, 14, 6), Margin = new System.Windows.Thickness(0, 0, 8, 0), Style = (System.Windows.Style)FindResource("Wd.Button.Ghost") };
|
|
ok.Click += (_, _) => { dlg.DialogResult = true; dlg.Close(); };
|
|
buttons.Children.Add(cancel);
|
|
buttons.Children.Add(ok);
|
|
stack.Children.Add(buttons);
|
|
dlg.Content = stack;
|
|
tb.Focus();
|
|
tb.SelectAll();
|
|
var result = dlg.ShowDialog();
|
|
return result == true ? tb.Text.Trim() : null;
|
|
}
|
|
|
|
private void OnDelete(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PresetsList.SelectedItem is not PresetRow row) return;
|
|
|
|
var confirm = MessageBox.Show(
|
|
this,
|
|
$"Delete preset \"{row.Name}\"? This cannot be undone.",
|
|
"TeamsISO — Delete preset",
|
|
MessageBoxButton.YesNo,
|
|
MessageBoxImage.Warning,
|
|
MessageBoxResult.No);
|
|
if (confirm != MessageBoxResult.Yes) return;
|
|
|
|
try
|
|
{
|
|
OperatorPresetStore.Delete(row.Name);
|
|
_toast?.Show($"Deleted preset \"{row.Name}\"");
|
|
ReloadPresets();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(
|
|
this,
|
|
$"Could not delete preset.\n\n{ex.Message}",
|
|
"TeamsISO — Delete preset",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
|
|
private void OnCancel(object sender, RoutedEventArgs e)
|
|
{
|
|
DialogResult = false;
|
|
Close();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Save every preset as a single .json bundle to a path the user picks via
|
|
/// SaveFileDialog. We use Microsoft.Win32.SaveFileDialog because it doesn't
|
|
/// drag in WinForms; the WPF host doesn't ship a built-in alternative.
|
|
/// </summary>
|
|
private void OnExport(object sender, RoutedEventArgs e)
|
|
{
|
|
var dlg = new Microsoft.Win32.SaveFileDialog
|
|
{
|
|
Title = "Export TeamsISO presets",
|
|
FileName = $"teamsiso-presets-{DateTimeOffset.Now:yyyy-MM-dd}.json",
|
|
Filter = "TeamsISO preset bundle (*.json)|*.json",
|
|
DefaultExt = "json",
|
|
};
|
|
if (dlg.ShowDialog(this) != true) return;
|
|
|
|
try
|
|
{
|
|
var json = OperatorPresetStore.ExportAllAsJson();
|
|
System.IO.File.WriteAllText(dlg.FileName, json);
|
|
_toast?.Show($"Exported {Rows.Count} preset(s)");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(this,
|
|
$"Could not export presets.\n\n{ex.Message}",
|
|
"TeamsISO — Export presets",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Load a bundle from a path the user picks. On name collision we ask once
|
|
/// (covering all collisions) whether to overwrite — a per-preset prompt would
|
|
/// be exhausting for a 20-preset bundle. The Y/N here drives the overwrite
|
|
/// flag passed to <see cref="OperatorPresetStore.ImportBundle"/>.
|
|
/// </summary>
|
|
private void OnImport(object sender, RoutedEventArgs e)
|
|
{
|
|
var dlg = new Microsoft.Win32.OpenFileDialog
|
|
{
|
|
Title = "Import TeamsISO presets",
|
|
Filter = "TeamsISO preset bundle (*.json)|*.json|All files (*.*)|*.*",
|
|
};
|
|
if (dlg.ShowDialog(this) != true) return;
|
|
|
|
string json;
|
|
try { json = System.IO.File.ReadAllText(dlg.FileName); }
|
|
catch (Exception ex)
|
|
{
|
|
MessageBox.Show(this,
|
|
$"Could not read the file.\n\n{ex.Message}",
|
|
"TeamsISO — Import presets",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
return;
|
|
}
|
|
|
|
// Quick parse to sniff for collisions before asking the operator anything.
|
|
OperatorPresetStore.Bundle? bundle;
|
|
try { bundle = System.Text.Json.JsonSerializer.Deserialize<OperatorPresetStore.Bundle>(json); }
|
|
catch
|
|
{
|
|
MessageBox.Show(this,
|
|
"That file isn't a valid TeamsISO preset bundle.",
|
|
"TeamsISO — Import presets",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
return;
|
|
}
|
|
if (bundle is null || bundle.Presets is null || bundle.Presets.Count == 0)
|
|
{
|
|
MessageBox.Show(this,
|
|
"The bundle is empty.",
|
|
"TeamsISO — Import presets",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Information);
|
|
return;
|
|
}
|
|
|
|
var existingNames = OperatorPresetStore.LoadAll()
|
|
.Select(p => p.Name)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var collisions = bundle.Presets.Count(p => existingNames.Contains(p.Name));
|
|
|
|
var overwrite = false;
|
|
if (collisions > 0)
|
|
{
|
|
var choice = MessageBox.Show(
|
|
this,
|
|
$"{collisions} preset name(s) in this bundle already exist on this machine.\n\n" +
|
|
"Yes = overwrite local copies with the bundle's versions.\n" +
|
|
"No = keep local copies; only import new presets.",
|
|
"TeamsISO — Import presets",
|
|
MessageBoxButton.YesNoCancel,
|
|
MessageBoxImage.Question,
|
|
MessageBoxResult.No);
|
|
if (choice == MessageBoxResult.Cancel) return;
|
|
overwrite = choice == MessageBoxResult.Yes;
|
|
}
|
|
|
|
var result = OperatorPresetStore.ImportBundle(json, overwrite);
|
|
if (result.Error is not null)
|
|
{
|
|
MessageBox.Show(this,
|
|
$"Import failed.\n\n{result.Error}",
|
|
"TeamsISO — Import presets",
|
|
MessageBoxButton.OK,
|
|
MessageBoxImage.Warning);
|
|
return;
|
|
}
|
|
|
|
var summary = $"Imported — {result.Added} new";
|
|
if (result.Overwritten > 0) summary += $", {result.Overwritten} overwritten";
|
|
if (result.Skipped > 0) summary += $", {result.Skipped} skipped";
|
|
_toast?.Show(summary);
|
|
ReloadPresets();
|
|
}
|
|
}
|