diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 9af8b75f..4a83cd38 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -66,6 +66,9 @@ namespace AppScaffolding { config.InProgress ??= Configuration.WinTemp; + if (!config.Exists(nameof(config.BetaOptIn))) + config.BetaOptIn = false; + if (!config.Exists(nameof(config.AllowLibationFixup))) config.AllowLibationFixup = true; @@ -411,9 +414,9 @@ namespace AppScaffolding public static void migrate_from_7_10_1(Configuration config) { - var lastNigrationThres = config.GetNonString($"{nameof(migrate_from_7_10_1)}_ThrewError"); + var lastMigrationThrew = config.GetNonString($"{nameof(migrate_from_7_10_1)}_ThrewError"); - if (lastNigrationThres) return; + if (lastMigrationThrew) return; try { diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index f7000e07..cf4d0a3b 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -74,6 +74,13 @@ namespace LibationFileManager public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName); + [Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")] + public bool BetaOptIn + { + get => persistentDictionary.GetNonString(nameof(BetaOptIn)); + set => persistentDictionary.SetNonString(nameof(BetaOptIn), value); + } + [Description("Location for book storage. Includes destination of newly liberated books")] public string Books { diff --git a/Source/LibationWinForms/AvaloniaUI/App.axaml b/Source/LibationWinForms/AvaloniaUI/App.axaml new file mode 100644 index 00000000..f8ef3650 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/App.axaml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/LibationWinForms/AvaloniaUI/App.axaml.cs b/Source/LibationWinForms/AvaloniaUI/App.axaml.cs new file mode 100644 index 00000000..1f0642bc --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/App.axaml.cs @@ -0,0 +1,47 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using Avalonia.Media; +using LibationFileManager; +using LibationWinForms.AvaloniaUI.Views; + +namespace LibationWinForms.AvaloniaUI +{ + public class App : Application + { + public static IBrush ProcessQueueBookFailedBrush { get; private set; } + public static IBrush ProcessQueueBookCompletedBrush { get; private set; } + public static IBrush ProcessQueueBookCancelledBrush { get; private set; } + public static IBrush ProcessQueueBookDefaultBrush { get; private set; } + public static IBrush SeriesEntryGridBackgroundBrush { get; private set; } + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + LoadStyles(); + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var mainWindow = new MainWindow(); + desktop.MainWindow = mainWindow; + mainWindow.RestoreSizeAndLocation(Configuration.Instance); + mainWindow.OnLoad(); + } + + base.OnFrameworkInitializationCompleted(); + } + + private void LoadStyles() + { + ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookFailedBrush"); + ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookCompletedBrush"); + ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookCancelledBrush"); + ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources("ProcessQueueBookDefaultBrush"); + SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources("SeriesEntryGridBackgroundBrush"); + } + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/1x1.png b/Source/LibationWinForms/AvaloniaUI/Assets/1x1.png new file mode 100644 index 00000000..1914264c Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/1x1.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/DataGridTheme.xaml b/Source/LibationWinForms/AvaloniaUI/Assets/DataGridTheme.xaml new file mode 100644 index 00000000..904b6a2b --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Assets/DataGridTheme.xaml @@ -0,0 +1,658 @@ + + + 0.6 + 0.8 + 12,0,12,0 + + M1875 1011l-787 787v-1798h-128v1798l-787 -787l-90 90l941 941l941 -941z + M515 93l930 931l-930 931l90 90l1022 -1021l-1022 -1021z + M1939 1581l90 -90l-1005 -1005l-1005 1005l90 90l915 -915z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/LibationStyles.xaml b/Source/LibationWinForms/AvaloniaUI/Assets/LibationStyles.xaml new file mode 100644 index 00000000..e1e3f5ce --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Assets/LibationStyles.xaml @@ -0,0 +1,12 @@ + + + #FFE6FFE6 + + + + + + + + + \ No newline at end of file diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Asterisk.png b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Asterisk.png new file mode 100644 index 00000000..c345a8f9 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Asterisk.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Exclamation.png b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Exclamation.png new file mode 100644 index 00000000..cc884984 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Exclamation.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Question.png b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Question.png new file mode 100644 index 00000000..3aeb017c Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/Question.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/error.png b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/error.png new file mode 100644 index 00000000..916e14f1 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/MBIcons/error.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png b/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png new file mode 100644 index 00000000..fa34f935 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/cancel.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/completed.png b/Source/LibationWinForms/AvaloniaUI/Assets/completed.png new file mode 100644 index 00000000..3cd61981 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/completed.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/down.png b/Source/LibationWinForms/AvaloniaUI/Assets/down.png new file mode 100644 index 00000000..2536c961 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/down.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/edit_25x25.png b/Source/LibationWinForms/AvaloniaUI/Assets/edit_25x25.png new file mode 100644 index 00000000..12e70d0f Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/edit_25x25.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/errored.png b/Source/LibationWinForms/AvaloniaUI/Assets/errored.png new file mode 100644 index 00000000..bb8ba7ef Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/errored.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/first.png b/Source/LibationWinForms/AvaloniaUI/Assets/first.png new file mode 100644 index 00000000..e470c697 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/first.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/glass-with-glow_16.png b/Source/LibationWinForms/AvaloniaUI/Assets/glass-with-glow_16.png new file mode 100644 index 00000000..05e40bec Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/glass-with-glow_16.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png b/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png new file mode 100644 index 00000000..40b582b1 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/import_16x16.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/last.png b/Source/LibationWinForms/AvaloniaUI/Assets/last.png new file mode 100644 index 00000000..3c3ea886 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/last.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico b/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico new file mode 100644 index 00000000..d3e00443 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/libation.ico differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/queued.png b/Source/LibationWinForms/AvaloniaUI/Assets/queued.png new file mode 100644 index 00000000..f30221c3 Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/queued.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/Assets/up.png b/Source/LibationWinForms/AvaloniaUI/Assets/up.png new file mode 100644 index 00000000..7c00155a Binary files /dev/null and b/Source/LibationWinForms/AvaloniaUI/Assets/up.png differ diff --git a/Source/LibationWinForms/AvaloniaUI/AvaloniaUtils.cs b/Source/LibationWinForms/AvaloniaUI/AvaloniaUtils.cs new file mode 100644 index 00000000..30ef7dd2 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/AvaloniaUtils.cs @@ -0,0 +1,17 @@ +using Avalonia.Media; +using System; + +namespace LibationWinForms.AvaloniaUI +{ + internal static class AvaloniaUtils + { + public static IBrush GetBrushFromResources(string name) + => GetBrushFromResources(name, Brushes.Transparent); + public static IBrush GetBrushFromResources(string name, IBrush defaultBrush) + { + if (App.Current.Styles.TryGetResource(name, out var value) && value is IBrush brush) + return brush; + return defaultBrush; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml new file mode 100644 index 00000000..d67603fe --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml @@ -0,0 +1,5 @@ + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs new file mode 100644 index 00000000..2c7ca7be --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DataGridCheckBoxColumnExt.axaml.cs @@ -0,0 +1,17 @@ +using Avalonia.Controls; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn + { + protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem) + { + //Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary. + var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox; + ele.IsThreeState = dataItem is SeriesEntry; + return ele; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DirectorySelectControl.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/DirectorySelectControl.axaml new file mode 100644 index 00000000..cc93bafa --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DirectorySelectControl.axaml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/DirectorySelectControl.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/DirectorySelectControl.axaml.cs new file mode 100644 index 00000000..ded5f227 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/DirectorySelectControl.axaml.cs @@ -0,0 +1,146 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Dinah.Core; +using LibationFileManager; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using ReactiveUI; +using Avalonia.Controls.Primitives; +using System.Collections; +using Avalonia.Data.Converters; +using System; +using System.Globalization; +using Avalonia.Data; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public class TextCaseConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + if (value is Configuration.KnownDirectories dir) + { + + } + return new BindingNotification(new InvalidCastException(), BindingErrorType.Error); + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotImplementedException(); + } + } + public partial class DirectorySelectControl : TemplatedControl + { + private static readonly List defaultList = new List() + { + Configuration.KnownDirectories.WinTemp, + Configuration.KnownDirectories.UserProfile, + Configuration.KnownDirectories.AppDir, + Configuration.KnownDirectories.MyDocs, + Configuration.KnownDirectories.LibationFiles + }; + public static readonly StyledProperty SelectedirectoryProperty = + AvaloniaProperty.Register(nameof(Selectedirectory), defaultList[0]); + + public static readonly StyledProperty> KnownDirectoriesProperty = + AvaloniaProperty.Register>(nameof(KnownDirectories), defaultList); + + public static readonly StyledProperty SubdirectoryProperty = + AvaloniaProperty.Register(nameof(Subdirectory), "subdir"); + + DirectorySelectViewModel DirectorySelect { get; } = new(); + public DirectorySelectControl() + { + InitializeComponent(); + } + + protected override void OnInitialized() + { + DirectorySelect.Directories.Clear(); + + int insertIndex = 0; + foreach (var kd in KnownDirectories.Distinct()) + DirectorySelect.Directories.Insert(insertIndex++, new(this, kd)); + + DataContext = DirectorySelect; + base.OnInitialized(); + } + + public List KnownDirectories + { + get { return GetValue(KnownDirectoriesProperty); } + set + { + SetValue(KnownDirectoriesProperty, value); + //SetDirectoryItems(KnownDirectories); + } + } + + + public Configuration.KnownDirectories? Selectedirectory + { + get { return GetValue(SelectedirectoryProperty); } + set + { + SetValue(SelectedirectoryProperty, value); + + if (value is null or Configuration.KnownDirectories.None) + return; + + // set default + var item = DirectorySelect.Directories.SingleOrDefault(item => item.Value == value.Value); + if (item is null) + return; + + DirectorySelect.SelectedDirectory = item; + } + } + + + public string? Subdirectory + { + get { return GetValue(SubdirectoryProperty); } + set + { + SetValue(SubdirectoryProperty, value); + } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } + + public class DirectorySelectViewModel : ViewModels.ViewModelBase + { + public class DirectoryComboBoxItem + { + private readonly DirectorySelectControl _parentControl; + public string Description { get; } + public Configuration.KnownDirectories Value { get; } + + public string FullPath => AddSubDirectoryToPath(Configuration.GetKnownDirectoryPath(Value)); + + /// Displaying relative paths is confusing. UI should display absolute equivalent + public string UiDisplayPath => Value == Configuration.KnownDirectories.AppDir ? AddSubDirectoryToPath(Configuration.AppDir_Absolute) : FullPath; + + public DirectoryComboBoxItem(DirectorySelectControl parentControl, Configuration.KnownDirectories knownDirectory) + { + _parentControl = parentControl; + Value = knownDirectory; + Description = Value.GetDescription(); + } + + internal string AddSubDirectoryToPath(string path) => string.IsNullOrWhiteSpace(_parentControl.Subdirectory) ? path : System.IO.Path.Combine(path, _parentControl.Subdirectory); + + public override string ToString() => Description; + } + public ObservableCollection Directories { get; } = new(new()); + private DirectoryComboBoxItem _selectedDirectory; + public DirectoryComboBoxItem SelectedDirectory { get => _selectedDirectory; set => this.RaiseAndSetIfChanged(ref _selectedDirectory, value); } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/GroupBox.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/GroupBox.axaml new file mode 100644 index 00000000..f6395f35 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/GroupBox.axaml @@ -0,0 +1,55 @@ + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/GroupBox.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/GroupBox.axaml.cs new file mode 100644 index 00000000..094e8a3e --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/GroupBox.axaml.cs @@ -0,0 +1,38 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public partial class GroupBox : ContentControl + { + + public static readonly StyledProperty BorderWidthProperty = + AvaloniaProperty.Register(nameof(BorderWidth)); + + public static readonly StyledProperty LabelProperty = + AvaloniaProperty.Register(nameof(Label)); + public GroupBox() + { + InitializeComponent(); + BorderWidth = new Thickness(3); + Label = "This is a groupbox label"; + } + public Thickness BorderWidth + { + get { return GetValue(BorderWidthProperty); } + set { SetValue(BorderWidthProperty, value); } + } + + public string Label + { + get { return GetValue(LabelProperty); } + set { SetValue(LabelProperty, value); } + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/WheelComboBox.axaml b/Source/LibationWinForms/AvaloniaUI/Controls/WheelComboBox.axaml new file mode 100644 index 00000000..635aa2a8 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/WheelComboBox.axaml @@ -0,0 +1,5 @@ + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Controls/WheelComboBox.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Controls/WheelComboBox.axaml.cs new file mode 100644 index 00000000..4458e288 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Controls/WheelComboBox.axaml.cs @@ -0,0 +1,35 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.Styling; +using System; +using System.Collections; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.Controls +{ + public partial class WheelComboBox : ComboBox, IStyleable + { + Type IStyleable.StyleKey => typeof(ComboBox); + public WheelComboBox() + { + InitializeComponent(); + } + protected override void OnPointerWheelChanged(PointerWheelEventArgs e) + { + var dir = Math.Sign(e.Delta.Y); + if (dir == 1 && SelectedIndex > 0) + SelectedIndex--; + else if (dir == -1 && SelectedIndex < ItemCount - 1) + SelectedIndex++; + + base.OnPointerWheelChanged(e); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/FormSaveExtension2.cs b/Source/LibationWinForms/AvaloniaUI/FormSaveExtension2.cs new file mode 100644 index 00000000..1edcf955 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/FormSaveExtension2.cs @@ -0,0 +1,123 @@ +using System; +using System.Linq; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using LibationFileManager; + +namespace LibationWinForms.AvaloniaUI +{ + public static class FormSaveExtension2 + { + static readonly WindowIcon WindowIcon; + static FormSaveExtension2() + { + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + WindowIcon = desktop.MainWindow.Icon; + else + WindowIcon = null; + } + + public static void SetLibationIcon(this Window form) + { + form.Icon = WindowIcon; + } + + public static void RestoreSizeAndLocation(this Window form, Configuration config) + { + if (Design.IsDesignMode) return; + + FormSizeAndPosition savedState = config.GetNonString(form.GetType().Name); + + if (savedState is null) + return; + + // too small -- something must have gone wrong. use defaults + if (savedState.Width < form.MinWidth || savedState.Height < form.MinHeight) + { + savedState.Width = (int)form.Width; + savedState.Height = (int)form.Height; + } + + // Fit to the current screen size in case the screen resolution changed since the size was last persisted + if (savedState.Width > form.Screens.Primary.WorkingArea.Width) + savedState.Width = form.Screens.Primary.WorkingArea.Width; + if (savedState.Height > form.Screens.Primary.WorkingArea.Height) + savedState.Height = form.Screens.Primary.WorkingArea.Height; + + var rect = new PixelRect(savedState.X, savedState.Y, savedState.Width, savedState.Height); + + form.Width = savedState.Width; + form.Height = savedState.Height; + + // is proposed rect on a screen? + if (form.Screens.All.Any(screen => screen.WorkingArea.Contains(rect))) + { + form.WindowStartupLocation = WindowStartupLocation.Manual; + form.Position = new PixelPoint(savedState.X, savedState.Y); + } + else + { + form.WindowStartupLocation = WindowStartupLocation.CenterScreen; + } + + // FINAL: for Maximized: start normal state, set size and location, THEN set max state + form.WindowState = savedState.IsMaximized ? WindowState.Maximized : WindowState.Normal; + } + public static void SaveSizeAndLocation(this Window form, Configuration config) + { + if (Design.IsDesignMode) return; + + var saveState = new FormSizeAndPosition(); + + saveState.IsMaximized = form.WindowState == WindowState.Maximized; + + // restore normal state to get real window size. + if (form.WindowState != WindowState.Normal) + { + form.WindowState = WindowState.Normal; + } + + saveState.X = form.Position.X; + saveState.Y = form.Position.Y; + + saveState.Width = (int)form.Bounds.Size.Width; + saveState.Height = (int)form.Bounds.Size.Height; + + config.SetObject(form.GetType().Name, saveState); + } + + class FormSizeAndPosition + { + public int X; + public int Y; + public int Height; + public int Width; + public bool IsMaximized; + } + + + public static void HideMinMaxBtns(this Window form) + { + + if (Design.IsDesignMode) + return; +#if WINDOWS7_0_OR_GREATER + var handle = form.PlatformImpl.Handle.Handle; + var currentStyle = GetWindowLong(handle, GWL_STYLE); + + SetWindowLong(handle, GWL_STYLE, currentStyle & ~WS_MAXIMIZEBOX & ~WS_MINIMIZEBOX); +#endif + } + +#if WINDOWS7_0_OR_GREATER + const long WS_MINIMIZEBOX = 0x00020000L; + const long WS_MAXIMIZEBOX = 0x10000L; + const int GWL_STYLE = -16; + [System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLong")] + static extern long GetWindowLong(IntPtr hWnd, int nIndex); + [System.Runtime.InteropServices.DllImport("user32.dll")] + static extern int SetWindowLong(IntPtr hWnd, int nIndex, long dwNewLong); +#endif + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/MessageBox.cs b/Source/LibationWinForms/AvaloniaUI/MessageBox.cs new file mode 100644 index 00000000..97020c89 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/MessageBox.cs @@ -0,0 +1,327 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.ApplicationLifetimes; +using DataLayer; +using LibationWinForms.AvaloniaUI.ViewModels.Dialogs; +using LibationWinForms.AvaloniaUI.Views.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI +{ + public enum DialogResult + { + None = 0, + OK = 1, + Cancel = 2, + Abort = 3, + Retry = 4, + Ignore = 5, + Yes = 6, + No = 7, + TryAgain = 10, + Continue = 11 + } + + + public enum MessageBoxIcon + { + None = 0, + Error = 16, + Hand = 16, + Stop = 16, + Question = 32, + Exclamation = 48, + Warning = 48, + Asterisk = 64, + Information = 64 + } + public enum MessageBoxButtons + { + OK, + OKCancel, + AbortRetryIgnore, + YesNoCancel, + YesNo, + RetryCancel, + CancelTryContinue + } + + public enum MessageBoxDefaultButton + { + Button1, + Button2 = 256, + Button3 = 512, + } + + public class MessageBox + { + + /// Displays a message box with the specified text, caption, buttons, icon, and default button. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values that specifies which buttons to display in the message box. + /// One of the values that specifies which icon to display in the message box. + /// One of the values that specifies the default button for the message box. + /// One of the values. + /// + /// is not a member of . + /// -or- + /// is not a member of . + /// -or- + /// is not a member of . + /// An attempt was made to display the in a process that is not running in User Interactive mode. This is specified by the property. + public static async Task Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) + { + return await ShowCore(null, text, caption, buttons, icon, defaultButton); + } + + + /// Displays a message box with specified text, caption, buttons, and icon. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values that specifies which buttons to display in the message box. + /// One of the values that specifies which icon to display in the message box. + /// One of the values. + /// The parameter specified is not a member of . + /// -or- + /// The parameter specified is not a member of . + /// An attempt was made to display the in a process that is not running in User Interactive mode. This is specified by the property. + public static async Task Show(string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon) + { + return await ShowCore(null, text, caption, buttons, icon, MessageBoxDefaultButton.Button1); + } + + + /// Displays a message box with specified text, caption, and buttons. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values that specifies which buttons to display in the message box. + /// One of the values. + /// The parameter specified is not a member of . + /// An attempt was made to display the in a process that is not running in User Interactive mode. This is specified by the property. + public static async Task Show(string text, string caption, MessageBoxButtons buttons) + { + return await ShowCore(null, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + } + + + /// Displays a message box with specified text and caption. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values. + public static async Task Show(string text, string caption) + { + return await ShowCore(null, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + } + + /// Displays a message box with specified text. + /// The text to display in the message box. + /// One of the values. + public static async Task Show(string text) + { + return await ShowCore(null, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + } + + + /// Displays a message box in front of the specified object and with the specified text, caption, buttons, icon, default button, and options. + /// An implementation of that will own the modal dialog box. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values that specifies which buttons to display in the message box. + /// One of the values that specifies which icon to display in the message box. + /// One of the values the specifies the default button for the message box. + /// One of the values that specifies which display and association options will be used for the message box. You may pass in 0 if you wish to use the defaults. + /// One of the values. + /// + /// is not a member of . + /// -or- + /// is not a member of . + /// -or- + /// is not a member of . + /// An attempt was made to display the in a process that is not running in User Interactive mode. This is specified by the property. + /// + /// -or- + /// specified an invalid combination of . + public static async Task Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) + { + return await ShowCore(owner, text, caption, buttons, icon, defaultButton); + } + + + /// Displays a message box in front of the specified object and with the specified text, caption, buttons, and icon. + /// An implementation of that will own the modal dialog box. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values that specifies which buttons to display in the message box. + /// One of the values that specifies which icon to display in the message box. + /// One of the values. + /// + /// is not a member of . + /// -or- + /// is not a member of . + /// An attempt was made to display the in a process that is not running in User Interactive mode. This is specified by the property. + public static async Task Show(Window owner, string text, string caption, MessageBoxButtons buttons, MessageBoxIcon icon) + { + return await ShowCore(owner, text, caption, buttons, icon, MessageBoxDefaultButton.Button1); + } + + /// Displays a message box in front of the specified object and with the specified text, caption, and buttons. + /// An implementation of that will own the modal dialog box. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values that specifies which buttons to display in the message box. + /// One of the values. + /// + /// is not a member of . + /// An attempt was made to display the in a process that is not running in User Interactive mode. This is specified by the property. + public static async Task Show(Window owner, string text, string caption, MessageBoxButtons buttons) + { + return await ShowCore(owner, text, caption, buttons, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + } + + /// Displays a message box in front of the specified object and with the specified text and caption. + /// An implementation of that will own the modal dialog box. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// One of the values. + public static async Task Show(Window owner, string text, string caption) + { + return await ShowCore(owner, text, caption, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + } + + /// Displays a message box in front of the specified object and with the specified text. + /// An implementation of that will own the modal dialog box. + /// The text to display in the message box. + /// One of the values. + public static async Task Show(Window owner, string text) + { + return await ShowCore(owner, text, string.Empty, MessageBoxButtons.OK, MessageBoxIcon.None, MessageBoxDefaultButton.Button1); + } + + public static async Task ShowConfirmationDialog(Window owner, IEnumerable libraryBooks, string format, string title, MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1) + { + if (libraryBooks is null || !libraryBooks.Any()) + return DialogResult.Cancel; + + var count = libraryBooks.Count(); + + string thisThese = count > 1 ? "these" : "this"; + string bookBooks = count > 1 ? "books" : "book"; + string titlesAgg = libraryBooks.AggregateTitles(); + + var message + = string.Format(format, $"{thisThese} {count} {bookBooks}") + + $"\r\n\r\n{titlesAgg}"; + + return await ShowCore(owner, + message, + title, + MessageBoxButtons.YesNo, + MessageBoxIcon.Question, + defaultButton); + } + + /// + /// Logs error. Displays a message box dialog with specified text and caption. + /// + /// Form calling this method. + /// The text to display in the message box. + /// The text to display in the title bar of the message box. + /// Exception to log. + public static async Task ShowAdminAlert(Window owner, string text, string caption, Exception exception) + { + // for development and debugging, show me what broke! + if (System.Diagnostics.Debugger.IsAttached) + throw exception; + + try + { + Serilog.Log.Logger.Error(exception, "Alert admin error: {@DebugText}", new { text, caption }); + } + catch { } + + var form = new MessageBoxAlertAdminDialog(text, caption, exception); + + await DisplayWindow(form, owner); + } + + + private static async Task ShowCore(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) + { + if (Avalonia.Threading.Dispatcher.UIThread.CheckAccess()) + return await ShowCore2(owner, message, caption, buttons, icon, defaultButton); + else + return await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => ShowCore2(owner, message, caption, buttons, icon, defaultButton)); + } + private static async Task ShowCore2(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton) + { + var dialog = new MessageBoxWindow(); + + dialog.HideMinMaxBtns(); + + var vm = new MessageBoxViewModel(message, caption, buttons, icon, defaultButton); + dialog.DataContext = vm; + dialog.ControlToFocusOnShow = dialog.FindControl(defaultButton.ToString()); + dialog.CanResize = false; + dialog.WindowStartupLocation = WindowStartupLocation.CenterOwner; + var tbx = dialog.FindControl("messageTextBlock"); + + tbx.MinWidth = vm.TextBlockMinWidth; + tbx.Text = message; + + var thisScreen = (owner ?? dialog).Screens.ScreenFromVisual(owner ?? dialog); + + var maxSize = new Size(0.20 * thisScreen.Bounds.Width, 0.9 * thisScreen.Bounds.Height - 55); + + var desiredMax = new Size(maxSize.Width, maxSize.Height); + + tbx.Measure(desiredMax); + + tbx.Height = tbx.DesiredSize.Height; + tbx.Width = tbx.DesiredSize.Width; + dialog.MinHeight = vm.FormHeightFromTboxHeight((int)tbx.DesiredSize.Height); + dialog.MinWidth = vm.FormWidthFromTboxWidth((int)tbx.DesiredSize.Width); + dialog.MaxHeight = dialog.MinHeight; + dialog.MaxWidth = dialog.MinWidth; + dialog.Height = dialog.MinHeight; + dialog.Width = dialog.MinWidth; + + return await DisplayWindow(dialog, owner); + } + private static async Task DisplayWindow(Window toDisplay, Window owner) + { + if (owner is null) + { + if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + return await toDisplay.ShowDialog(desktop.MainWindow); + } + else + { + var window = new Window + { + IsVisible = false, + Height = 1, + Width = 1, + SystemDecorations = SystemDecorations.None, + ShowInTaskbar = false + }; + + window.Show(); + var result = await toDisplay.ShowDialog(window); + window.Close(); + return result; + } + + } + else + { + return await toDisplay.ShowDialog(owner); + } + } + + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs b/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs new file mode 100644 index 00000000..a17b206c --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewLocator.cs @@ -0,0 +1,30 @@ +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; + +namespace LibationWinForms.AvaloniaUI +{ + public class ViewLocator : IDataTemplate + { + public IControl Build(object data) + { + var name = data.GetType().FullName!.Replace("ViewModel", "View"); + var type = Type.GetType(name); + + if (type != null) + { + return (Control)Activator.CreateInstance(type)!; + } + else + { + return new TextBlock { Text = "Not Found: " + name }; + } + } + + public bool Match(object data) + { + return data is ViewModelBase; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs new file mode 100644 index 00000000..cf2c7664 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/BookTags.cs @@ -0,0 +1,9 @@ +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class BookTags + { + private string _tags; + public string Tags { get => _tags; init { _tags = value; HasTags = !string.IsNullOrEmpty(_tags); } } + public bool HasTags { get; init; } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/Dialogs/MessageBoxViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/Dialogs/MessageBoxViewModel.cs new file mode 100644 index 00000000..bb3a2b45 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/Dialogs/MessageBoxViewModel.cs @@ -0,0 +1,84 @@ +using System; + +namespace LibationWinForms.AvaloniaUI.ViewModels.Dialogs +{ + public class MessageBoxViewModel + { + private string _message; + public string Message { get { return _message; } set { _message = value; } } + public string Caption { get; } = "Message Box"; + private MessageBoxButtons _button; + private MessageBoxIcon _icon; + private MessageBoxDefaultButton _defaultButton; + + public MessageBoxDefaultButton DefaultButton => _defaultButton; + public MessageBoxButtons Buttons => _button; + + public bool IsAsterisk => _icon == MessageBoxIcon.Asterisk; + public bool IsError => _icon == MessageBoxIcon.Error; + public bool IsQuestion => _icon == MessageBoxIcon.Question; + public bool IsExclamation => _icon == MessageBoxIcon.Exclamation; + + public bool HasButton3 => !string.IsNullOrEmpty(Button3Text); + public bool HasButton2 => !string.IsNullOrEmpty(Button2Text); + + public int WindowHeight { get;private set; } + public int WindowWidth { get;private set; } + + public string Button1Text => _button switch + { + MessageBoxButtons.OK => "OK", + MessageBoxButtons.OKCancel => "OK", + MessageBoxButtons.AbortRetryIgnore => "Abort", + MessageBoxButtons.YesNoCancel => "Yes", + MessageBoxButtons.YesNo => "Yes", + MessageBoxButtons.RetryCancel => "Retry", + MessageBoxButtons.CancelTryContinue => "Cancel", + _ => string.Empty, + }; + + public string Button2Text => _button switch + { + MessageBoxButtons.OKCancel => "Cancel", + MessageBoxButtons.AbortRetryIgnore => "Retry", + MessageBoxButtons.YesNoCancel => "No", + MessageBoxButtons.YesNo => "No", + MessageBoxButtons.RetryCancel => "Cancel", + MessageBoxButtons.CancelTryContinue => "Try", + _ => string.Empty, + }; + + public string Button3Text => _button switch + { + MessageBoxButtons.AbortRetryIgnore => "Ignore", + MessageBoxButtons.YesNoCancel => "Cancel", + MessageBoxButtons.CancelTryContinue => "Continue", + _ => string.Empty, + }; + + public int TextBlockMinWidth { get; } + + public double FormHeightFromTboxHeight(double tboxHeight) => tboxHeight + 65; + public double FormWidthFromTboxWidth(double tboxWidth) + { + int iconWidth = _icon is MessageBoxIcon.None ? 0 : 42; + return tboxWidth + 30 + iconWidth; + } + + public MessageBoxViewModel() { } + public MessageBoxViewModel(string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultBtn) + { + + Message = message; + Caption = caption; + _button = buttons; + _icon = icon; + _defaultButton = defaultBtn; + + int numBtns = HasButton3 ? 3 : HasButton2 ? 2 : 1; + int iconWidth = icon is MessageBoxIcon.None ? 0 : 42; + int formMinWidth = Math.Max(85 * numBtns + 10, 71 + iconWidth + 20); + TextBlockMinWidth = formMinWidth - 30 - iconWidth; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry.cs new file mode 100644 index 00000000..991ca7ee --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntry.cs @@ -0,0 +1,169 @@ +using Avalonia.Media; +using DataLayer; +using Dinah.Core; +using Dinah.Core.DataBinding; +using Dinah.Core.Drawing; +using LibationFileManager; +using LibationWinForms.GridView; +using ReactiveUI; +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Drawing; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public enum RemoveStatus + { + NotRemoved, + Removed, + SomeRemoved + } + /// The View Model base for the DataGridView + public abstract class GridEntry : ViewModelBase + { + [Browsable(false)] public string AudibleProductId => Book.AudibleProductId; + [Browsable(false)] public LibraryBook LibraryBook { get; protected set; } + [Browsable(false)] public float SeriesIndex { get; protected set; } + [Browsable(false)] public string LongDescription { get; protected set; } + [Browsable(false)] public abstract DateTime DateAdded { get; } + [Browsable(false)] public int ListIndex { get; set; } + [Browsable(false)] protected Book Book => LibraryBook.Book; + + #region Model properties exposed to the view + + private Avalonia.Media.Imaging.Bitmap _cover; + public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } } + public string PurchaseDate { get; protected set; } + public string Series { get; protected set; } + public string Title { get; protected set; } + public string Length { get; protected set; } + public string Authors { get; protected set; } + public string Narrators { get; protected set; } + public string Category { get; protected set; } + public string Misc { get; protected set; } + public string Description { get; protected set; } + public string ProductRating { get; protected set; } + public string MyRating { get; protected set; } + + protected bool? _remove = false; + public abstract bool? Remove { get; set; } + public abstract LiberateButtonStatus Liberate { get; } + public abstract BookTags BookTags { get; } + public abstract bool IsSeries { get; } + public abstract bool IsEpisode { get; } + public abstract bool IsBook { get; } + public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : null; + + #endregion + + #region Sorting + + public GridEntry() => _memberValues = CreateMemberValueDictionary(); + + // These methods are implementation of Dinah.Core.DataBinding.IMemberComparable + // Used by GridEntryBindingList for all sorting + public virtual object GetMemberValue(string memberName) => _memberValues[memberName](); + public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType]; + protected abstract Dictionary> CreateMemberValueDictionary(); + private Dictionary> _memberValues { get; set; } + + // Instantiate comparers for every exposed member object type. + private static readonly Dictionary _memberTypeComparers = new() + { + { typeof(RemoveStatus), new ObjectComparer() }, + { typeof(string), new ObjectComparer() }, + { typeof(int), new ObjectComparer() }, + { typeof(float), new ObjectComparer() }, + { typeof(bool), new ObjectComparer() }, + { typeof(DateTime), new ObjectComparer() }, + { typeof(LiberateButtonStatus), new ObjectComparer() }, + }; + + #endregion + + #region Cover Art + + protected void LoadCover() + { + // Get cover art. If it's default, subscribe to PictureCached + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + using var ms = new System.IO.MemoryStream(picture); + _cover = new Avalonia.Media.Imaging.Bitmap(ms); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == Book.PictureId) + { + using var ms = new System.IO.MemoryStream(e.Picture); + Cover = new Avalonia.Media.Imaging.Bitmap(ms); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + #endregion + + #region Static library display functions + + /// This information should not change during lifetime, so call only once. + protected static string GetDescriptionDisplay(Book book) + { + var doc = new HtmlAgilityPack.HtmlDocument(); + doc.LoadHtml(book?.Description?.Replace("

", "\r\n\r\n

") ?? ""); + return doc.DocumentNode.InnerText.Trim(); + } + + protected static string TrimTextToWord(string text, int maxLength) + { + return + text.Length <= maxLength ? + text : + text.Substring(0, maxLength - 3) + "..."; + } + + + /// + /// This information should not change during lifetime, so call only once. + /// Maximum of 5 text rows will fit in 80-pixel row height. + /// + protected static string GetMiscDisplay(LibraryBook libraryBook) + { + var details = new List(); + + var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]"); + var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]"); + + details.Add($"Account: {locale} - {acct}"); + + if (libraryBook.Book.HasPdf()) + details.Add("Has PDF"); + if (libraryBook.Book.IsAbridged) + details.Add("Abridged"); + if (libraryBook.Book.DatePublished.HasValue) + details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}"); + // this goes last since it's most likely to have a line-break + if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher)) + details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}"); + + if (!details.Any()) + return "[details not imported]"; + + return string.Join("\r\n", details); + } + + #endregion + + ~GridEntry() + { + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryCollection.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryCollection.cs new file mode 100644 index 00000000..daa31e6e --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/GridEntryCollection.cs @@ -0,0 +1,173 @@ +using ApplicationServices; +using LibationSearchEngine; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /* + * Allows filtering of the underlying ObservableCollection + * + * When filtering is applied, the filtered-out items are removed + * from the base list and added to the private FilterRemoved list. + * When filtering is removed, items in the FilterRemoved list are + * added back to the base list. + * + * Items are added and removed to/from the ObservableCollection's + * internal list instead of the ObservableCollection itself to + * avoid ObservableCollection firing CollectionChanged for every + * item. Editing the list this way improve's display performance, + * but requires ResetCollection() to be called after all changes + * have been made. + */ + public class GridEntryCollection : ObservableCollection + { + public GridEntryCollection(IEnumerable enumeration) + : base(new List(enumeration)) { } + public GridEntryCollection(List list) + : base(list) { } + + public List InternalList => Items as List; + /// All items in the list, including those filtered out. + public List AllItems() => Items.Concat(FilterRemoved).ToList(); + + /// When true, itms will not be checked filtered by search criteria on item changed + public bool SuspendFilteringOnUpdate { get; set; } + public string Filter { get => FilterString; set => ApplyFilter(value); } + + /// Items that were removed from the base list due to filtering + private readonly List FilterRemoved = new(); + private string FilterString; + private SearchResultSet SearchResults; + + #region Items Management + + public void ReplaceList(IEnumerable newItems) + { + Items.Clear(); + ((List)Items).AddRange(newItems); + ResetCollection(); + } + public void ResetCollection() + => OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + + #endregion + + #region Filtering + + + private void ApplyFilter(string filterString) + { + if (filterString != FilterString) + RemoveFilter(); + + FilterString = filterString; + SearchResults = SearchEngineCommands.Search(filterString); + + var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe); + + //Find all series containing children that match the search criteria + var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any()); + + var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList(); + + foreach (var item in filteredOut) + { + FilterRemoved.Add(item); + Items.Remove(item); + } + ResetCollection(); + } + + public void RemoveFilter() + { + if (FilterString is null) return; + + int visibleCount = Items.Count; + + foreach (var item in FilterRemoved.ToList()) + { + if (item is SeriesEntry || item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)) + { + + FilterRemoved.Remove(item); + Items.Insert(visibleCount++, item); + } + } + + FilterString = null; + SearchResults = null; + ResetCollection(); + } + + #endregion + + #region Expand/Collapse + + public void CollapseAll() + { + foreach (var series in Items.SeriesEntries().ToList()) + CollapseItem(series); + } + + public void ExpandAll() + { + foreach (var series in Items.SeriesEntries().ToList()) + ExpandItem(series); + } + + public void CollapseItem(SeriesEntry sEntry) + { + foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) + { + /* + * Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't + * fired. When adding or removing many items at once, Avalonia's CollectionChanged + * event handler causes serious performance problems. And unfotrunately, Avalonia + * doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * overload that would fire only once for all changed items. + * + * Doing this requires resetting the list so the view knows it needs to rebuild its display. + */ + + FilterRemoved.Add(episode); + Items.Remove(episode); + } + + sEntry.Liberate.Expanded = false; + ResetCollection(); + } + + public void ExpandItem(SeriesEntry sEntry) + { + var sindex = Items.IndexOf(sEntry); + + foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList()) + { + if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId)) + { + /* + * Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't + * fired. When adding or removing many items at once, Avalonia's CollectionChanged + * event handler causes serious performance problems. And unfotrunately, Avalonia + * doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems) + * overload that would fire only once for all changed items. + * + * Doing this requires resetting the list so the view knows it needs to rebuild its display. + */ + + FilterRemoved.Remove(episode); + Items.Insert(++sindex, episode); + } + } + + sEntry.Liberate.Expanded = true; + ResetCollection(); + } + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus.cs new file mode 100644 index 00000000..cd82e706 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LiberateButtonStatus.cs @@ -0,0 +1,123 @@ +using Avalonia.Media.Imaging; +using DataLayer; +using ReactiveUI; +using System; +using System.Collections.Generic; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class LiberateButtonStatus : ViewModelBase, IComparable + { + public LiberateButtonStatus(bool isSeries) + { + IsSeries = isSeries; + } + public LiberatedStatus BookStatus { get; set; } + public LiberatedStatus? PdfStatus { get; set; } + + private bool _expanded; + public bool Expanded + { + get => _expanded; + set + { + this.RaiseAndSetIfChanged(ref _expanded, value); + this.RaisePropertyChanged(nameof(Image)); + this.RaisePropertyChanged(nameof(ToolTip)); + } + } + private bool IsSeries { get; } + public Bitmap Image => GetLiberateIcon(); + public string ToolTip => GetTooltip(); + + static Dictionary iconCache = new(); + + /// Defines the Liberate column's sorting behavior + public int CompareTo(object obj) + { + if (obj is not LiberateButtonStatus second) return -1; + + if (IsSeries && !second.IsSeries) return -1; + else if (!IsSeries && second.IsSeries) return 1; + else if (IsSeries && second.IsSeries) return 0; + else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1; + else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1; + else return BookStatus.CompareTo(second.BookStatus); + } + + private Bitmap GetLiberateIcon() + { + if (IsSeries) + return Expanded ? GetFromResources("minus") : GetFromResources("plus"); + + if (BookStatus == LiberatedStatus.Error) + return GetFromResources("error"); + + string image_lib = BookStatus switch + { + LiberatedStatus.Liberated => "green", + LiberatedStatus.PartialDownload => "yellow", + LiberatedStatus.NotLiberated => "red", + _ => throw new Exception("Unexpected liberation state") + }; + + string image_pdf = PdfStatus switch + { + LiberatedStatus.Liberated => "_pdf_yes", + LiberatedStatus.NotLiberated => "_pdf_no", + LiberatedStatus.Error => "_pdf_no", + null => "", + _ => throw new Exception("Unexpected PDF state") + }; + + return GetFromResources($"liberate_{image_lib}{image_pdf}"); + } + private string GetTooltip() + { + if (IsSeries) + return Expanded ? "Click to Collpase" : "Click to Expand"; + + if (BookStatus == LiberatedStatus.Error) + return "Book downloaded ERROR"; + + string libState = BookStatus switch + { + LiberatedStatus.Liberated => "Liberated", + LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded", + LiberatedStatus.NotLiberated => "Book NOT downloaded", + _ => throw new Exception("Unexpected liberation state") + }; + + string pdfState = PdfStatus switch + { + LiberatedStatus.Liberated => "\r\nPDF downloaded", + LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded", + LiberatedStatus.Error => "\r\nPDF downloaded ERROR", + null => "", + _ => throw new Exception("Unexpected PDF state") + }; + + + var mouseoverText = libState + pdfState; + + if (BookStatus == LiberatedStatus.NotLiberated || + BookStatus == LiberatedStatus.PartialDownload || + PdfStatus == LiberatedStatus.NotLiberated) + mouseoverText += "\r\nClick to complete"; + + return mouseoverText; + } + + private static Bitmap GetFromResources(string rescName) + { + if (iconCache.ContainsKey(rescName)) return iconCache[rescName]; + + var memoryStream = new System.IO.MemoryStream(); + + ((System.Drawing.Bitmap)Properties.Resources.ResourceManager.GetObject(rescName)).Save(memoryStream, System.Drawing.Imaging.ImageFormat.Png); + memoryStream.Position = 0; + iconCache[rescName] = new Bitmap(memoryStream); + return iconCache[rescName]; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry.cs new file mode 100644 index 00000000..ac8140d1 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/LibraryBookEntry.cs @@ -0,0 +1,147 @@ +using ApplicationServices; +using DataLayer; +using Dinah.Core; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /// The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode + public class LibraryBookEntry : GridEntry + { + [Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded; + [Browsable(false)] public SeriesEntry Parent { get; init; } + + #region Model properties exposed to the view + + private DateTime lastStatusUpdate = default; + private LiberatedStatus _bookStatus; + private LiberatedStatus? _pdfStatus; + + public override bool? Remove + { + get => _remove; + set + { + _remove = value ?? false; + + Parent?.ChildRemoveUpdate(); + this.RaisePropertyChanged(nameof(Remove)); + } + } + + public override LiberateButtonStatus Liberate + { + get + { + //Cache these statuses for faster sorting. + if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2) + { + _bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book); + _pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book); + lastStatusUpdate = DateTime.Now; + } + return new LiberateButtonStatus(IsSeries) { BookStatus = _bookStatus, PdfStatus = _pdfStatus }; + } + } + + public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) }; + + public override bool IsSeries => false; + public override bool IsEpisode => Parent is not null; + public override bool IsBook => Parent is null; + + #endregion + + public LibraryBookEntry(LibraryBook libraryBook) + { + LibraryBook = libraryBook; + LoadCover(); + + Title = Book.Title; + Series = Book.SeriesNames(); + Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min"; + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + PurchaseDate = libraryBook.DateAdded.ToString("d"); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(libraryBook); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; + + UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; + } + + #region detect changes to the model, update the view. + + /// + /// This event handler receives notifications from the model that it has changed. + /// Notify the view that it's changed. + /// + private void UserDefinedItem_ItemChanged(object sender, string itemName) + { + var udi = sender as UserDefinedItem; + + if (udi.Book.AudibleProductId != Book.AudibleProductId) + return; + + // UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view. + // - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs + // - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view. + switch (itemName) + { + case nameof(udi.Tags): + Book.UserDefinedItem.Tags = udi.Tags; + this.RaisePropertyChanged(nameof(BookTags)); + break; + case nameof(udi.BookStatus): + Book.UserDefinedItem.BookStatus = udi.BookStatus; + _bookStatus = udi.BookStatus; + this.RaisePropertyChanged(nameof(Liberate)); + break; + case nameof(udi.PdfStatus): + Book.UserDefinedItem.PdfStatus = udi.PdfStatus; + _pdfStatus = udi.PdfStatus; + this.RaisePropertyChanged(nameof(Liberate)); + break; + } + } + + #endregion + + #region Data Sorting + + /// Create getters for all member object values by name + protected override Dictionary> CreateMemberValueDictionary() => new() + { + { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, + { nameof(Title), () => Book.TitleSortable() }, + { nameof(Series), () => Book.SeriesSortable() }, + { nameof(Length), () => Book.LengthInMinutes }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, + { nameof(PurchaseDate), () => LibraryBook.DateAdded }, + { nameof(ProductRating), () => Book.Rating.FirstScore() }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, + { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, + { nameof(Liberate), () => Liberate }, + { nameof(DateAdded), () => DateAdded }, + }; + + #endregion + + ~LibraryBookEntry() + { + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs new file mode 100644 index 00000000..9e688a2f --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,239 @@ +using ApplicationServices; +using Dinah.Core; +using LibationFileManager; +using ReactiveUI; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class MainWindowViewModel : ViewModelBase + { + private string _filterString; + private string _removeBooksButtonText = "Remove # Books from Libation"; + private bool _removeBooksButtonEnabled = true; + private bool _autoScanChecked = true; + private bool _firstFilterIsDefault = true; + private bool _removeButtonsVisible = true; + private int _numAccountsScanning = 2; + private int _accountsCount = 0; + private bool _queueOpen = true; + private int _visibleCount = 1; + private LibraryCommands.LibraryStats _libraryStats; + private int _visibleNotLiberated = 1; + + /// The Process Queue's viewmodel + public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel(); + public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel(); + + + /// Library filterting query + public string FilterString { get => _filterString; set => this.RaiseAndSetIfChanged(ref _filterString, value); } + + + /// Display text for the "Remove # Books from Libation" button + public string RemoveBooksButtonText { get => _removeBooksButtonText; set => this.RaiseAndSetIfChanged(ref _removeBooksButtonText, value); } + + + /// Indicates if the "Remove # Books from Libation" button is enabled + public bool RemoveBooksButtonEnabled { get => _removeBooksButtonEnabled; set { this.RaiseAndSetIfChanged(ref _removeBooksButtonEnabled, value); } } + + + /// Auto scanning accounts is enables + public bool AutoScanChecked + { + get => _autoScanChecked; + set + { + if (value != _autoScanChecked) + Configuration.Instance.AutoScan = value; + this.RaiseAndSetIfChanged(ref _autoScanChecked, value); + } + } + + + /// Indicates if the first quick filter is the default filter + public bool FirstFilterIsDefault + { + get => _firstFilterIsDefault; + set + { + if (value != _firstFilterIsDefault) + QuickFilters.UseDefault = value; + this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); + } + } + + + /// Indicates if the "Remove # Books from Libation" and "Done Removing" buttons should be visible + public bool RemoveButtonsVisible + { + get => _removeButtonsVisible; + set + { + this.RaiseAndSetIfChanged(ref _removeButtonsVisible, value); + this.RaisePropertyChanged(nameof(RemoveMenuItemsEnabled)); + } + } + + + + + /// The number of accounts currently being scanned + public int NumAccountsScanning + { + get => _numAccountsScanning; + set + { + this.RaiseAndSetIfChanged(ref _numAccountsScanning, value); + this.RaisePropertyChanged(nameof(ActivelyScanning)); + this.RaisePropertyChanged(nameof(RemoveMenuItemsEnabled)); + this.RaisePropertyChanged(nameof(ScanningText)); + } + } + + /// Indicates if Libation is currently scanning account(s) + public bool ActivelyScanning => _numAccountsScanning > 0; + /// Indicates if the "Remove Books" menu items are enabled + public bool RemoveMenuItemsEnabled => !RemoveButtonsVisible && !ActivelyScanning; + /// The library scanning status text + public string ScanningText => _numAccountsScanning == 1 ? "Scanning..." : $"Scanning {_numAccountsScanning} accounts..."; + + + + /// The number of accounts added to Libation + public int AccountsCount + { + get => _accountsCount; + set + { + this.RaiseAndSetIfChanged(ref _accountsCount, value); + this.RaisePropertyChanged(nameof(ZeroAccounts)); + this.RaisePropertyChanged(nameof(AnyAccounts)); + this.RaisePropertyChanged(nameof(OneAccount)); + this.RaisePropertyChanged(nameof(MultipleAccounts)); + } + } + + /// There are no Audible accounts + public bool ZeroAccounts => _accountsCount == 0; + /// There is at least one Audible account + public bool AnyAccounts => _accountsCount > 0; + /// There is exactly one Audible account + public bool OneAccount => _accountsCount == 1; + /// There are more than 1 Audible accounts + public bool MultipleAccounts => _accountsCount > 1; + + + + /// The Process Queue panel is open + public bool QueueOpen + { + get => _queueOpen; + set + { + this.RaiseAndSetIfChanged(ref _queueOpen, value); + QueueHideButtonText = _queueOpen? "❱❱❱" : "❰❰❰"; + this.RaisePropertyChanged(nameof(QueueHideButtonText)); + } + } + + /// The Process Queue's Expand/Collapse button display text + public string QueueHideButtonText { get; private set; } + + + + /// The number of books visible in the Product Display + public int VisibleCount + { + get => _visibleCount; + set + { + this.RaiseAndSetIfChanged(ref _visibleCount, value); + this.RaisePropertyChanged(nameof(VisibleCountText)); + this.RaisePropertyChanged(nameof(VisibleCountMenuItemText)); + } + } + + /// The Bottom-right visible book count status text + public string VisibleCountText => $"Visible: {VisibleCount}"; + /// The Visible Books menu item header text + public string VisibleCountMenuItemText => $"_Visible Books {VisibleCount}"; + + + + /// The user's library statistics + public LibraryCommands.LibraryStats LibraryStats + { + get => _libraryStats; + set + { + this.RaiseAndSetIfChanged(ref _libraryStats, value); + + var backupsCountText + = !LibraryStats.HasBookResults ? "No books. Begin by importing your library" + : !LibraryStats.HasPendingBooks ? $"All {"book".PluralizeWithCount(LibraryStats.booksFullyBackedUp)} backed up" + : $"BACKUPS: No progress: {LibraryStats.booksNoProgress} In process: {LibraryStats.booksDownloadedOnly} Fully backed up: {LibraryStats.booksFullyBackedUp} {(LibraryStats.booksError > 0 ? $" Errors : {LibraryStats.booksError}" : "")}"; + + var pdfCountText + = !LibraryStats.HasPdfResults ? "" + : LibraryStats.pdfsNotDownloaded == 0 ? $" | All {LibraryStats.pdfsDownloaded} PDFs downloaded" + : $" | PDFs: NOT d/l'ed: {LibraryStats.pdfsNotDownloaded} Downloaded: {LibraryStats.pdfsDownloaded}"; + + StatusCountText = backupsCountText + pdfCountText; + + BookBackupsToolStripText + = LibraryStats.HasPendingBooks + ? $"Begin _Book and PDF Backups: {LibraryStats.PendingBooks} remaining" + : "All books have been liberated"; + + PdfBackupsToolStripText + = LibraryStats.pdfsNotDownloaded > 0 + ? $"Begin _PDF Only Backups: {LibraryStats.pdfsNotDownloaded} remaining" + : "All PDFs have been downloaded"; + + this.RaisePropertyChanged(nameof(StatusCountText)); + this.RaisePropertyChanged(nameof(BookBackupsToolStripText)); + this.RaisePropertyChanged(nameof(PdfBackupsToolStripText)); + } + } + + /// Bottom-left library statistics display text + public string StatusCountText { get; private set; } = "[Calculating backed up book quantities] | [Calculating backed up PDFs]"; + /// The "Begin Book and PDF Backup" menu item header text + public string BookBackupsToolStripText { get; private set; } = "Begin _Book and PDF Backups: 0"; + /// The "Begin PDF Only Backup" menu item header text + public string PdfBackupsToolStripText { get; private set; } = "Begin _PDF Only Backups: 0"; + + + + /// The number of books visible in the Products Display that have not yet been liberated + public int VisibleNotLiberated + { + get => _visibleNotLiberated; + set + { + this.RaiseAndSetIfChanged(ref _visibleNotLiberated, value); + + LiberateVisibleToolStripText + = AnyVisibleNotLiberated + ? $"Liberate _Visible Books: {VisibleNotLiberated}" + : "All visible books are liberated"; + + LiberateVisibleToolStripText_2 + = AnyVisibleNotLiberated + ? $"_Liberate: {VisibleNotLiberated}" + : "All visible books are liberated"; + + this.RaisePropertyChanged(nameof(AnyVisibleNotLiberated)); + this.RaisePropertyChanged(nameof(LiberateVisibleToolStripText)); + this.RaisePropertyChanged(nameof(LiberateVisibleToolStripText_2)); + } + } + + /// Indicates if any of the books visible in the Products Display haven't been liberated + public bool AnyVisibleNotLiberated => VisibleNotLiberated > 0; + /// The "Liberate Visible Books" menu item header text (submenu item of the "Liberate Menu" menu item) + public string LiberateVisibleToolStripText { get; private set; } = "Liberate _Visible Books: 0"; + /// The "Liberate" menu item header text (submenu item of the "Visible Books" menu item) + public string LiberateVisibleToolStripText_2 { get; private set; } = "_Liberate: 0"; + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBookViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBookViewModel.cs new file mode 100644 index 00000000..2e785533 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessBookViewModel.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using ApplicationServices; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using DataLayer; +using Dinah.Core; +using FileLiberator; +using LibationFileManager; +using ReactiveUI; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public enum ProcessBookResult + { + None, + Success, + Cancelled, + ValidationFail, + FailedRetry, + FailedSkip, + FailedAbort + } + + public enum ProcessBookStatus + { + Queued, + Cancelled, + Working, + Completed, + Failed + } + + /// + /// This is the viewmodel for queued processables + /// + public class ProcessBookViewModel : ViewModelBase + { + public event EventHandler Completed; + + public LibraryBook LibraryBook { get; private set; } + + private ProcessBookResult _result = ProcessBookResult.None; + private ProcessBookStatus _status = ProcessBookStatus.Queued; + private string _narrator; + private string _author; + private string _title; + private int _progress; + private string _eta; + private Bitmap _cover; + + #region Properties exposed to the view + public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } } + public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(BackgroundColor)); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } } + public string Narrator { get => _narrator; set { this.RaiseAndSetIfChanged(ref _narrator, value); } } + public string Author { get => _author; set { this.RaiseAndSetIfChanged(ref _author, value); } } + public string Title { get => _title; set { this.RaiseAndSetIfChanged(ref _title, value); } } + public int Progress { get => _progress; private set { this.RaiseAndSetIfChanged(ref _progress, value); } } + public string ETA { get => _eta; private set { this.RaiseAndSetIfChanged(ref _eta, value); } } + public Bitmap Cover { get => _cover; private set { this.RaiseAndSetIfChanged(ref _cover, value); } } + public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working; + public bool IsDownloading => Status is ProcessBookStatus.Working; + public bool Queued => Status is ProcessBookStatus.Queued; + + public IBrush BackgroundColor => Status switch + { + ProcessBookStatus.Cancelled => App.ProcessQueueBookCancelledBrush, + ProcessBookStatus.Completed => App.ProcessQueueBookCompletedBrush, + ProcessBookStatus.Failed => App.ProcessQueueBookFailedBrush, + _ => App.ProcessQueueBookDefaultBrush, + }; + public string StatusText => Result switch + { + ProcessBookResult.Success => "Finished", + ProcessBookResult.Cancelled => "Cancelled", + ProcessBookResult.ValidationFail => "Validion fail", + ProcessBookResult.FailedRetry => "Error, will retry later", + ProcessBookResult.FailedSkip => "Error, Skippping", + ProcessBookResult.FailedAbort => "Error, Abort", + _ => Status.ToString(), + }; + + #endregion + + private TimeSpan TimeRemaining { set { ETA = $"ETA: {value:mm\\:ss}"; } } + private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke(); + private Processable NextProcessable() => _currentProcessable = null; + private Processable _currentProcessable; + private readonly Queue> Processes = new(); + private readonly ProcessQueue.LogMe Logger; + + public ProcessBookViewModel(LibraryBook libraryBook, ProcessQueue.LogMe logme) + { + LibraryBook = libraryBook; + Logger = logme; + + _title = LibraryBook.Book.Title; + _author = LibraryBook.Book.AuthorNames(); + _narrator = LibraryBook.Book.NarratorNames(); + + (bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._80x80)); + + if (isDefault) + PictureStorage.PictureCached += PictureStorage_PictureCached; + + // Mutable property. Set the field so PropertyChanged isn't fired. + using var ms = new System.IO.MemoryStream(picture); + _cover = new Bitmap(ms); + } + + private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == LibraryBook.Book.PictureId) + { + using var ms = new System.IO.MemoryStream(e.Picture); + Cover = new Bitmap(ms); + PictureStorage.PictureCached -= PictureStorage_PictureCached; + } + } + + public async Task ProcessOneAsync() + { + string procName = CurrentProcessable.Name; + try + { + LinkProcessable(CurrentProcessable); + + var statusHandler = await CurrentProcessable.ProcessSingleAsync(LibraryBook, validate: true); + + if (statusHandler.IsSuccess) + return Result = ProcessBookResult.Success; + else if (statusHandler.Errors.Contains("Cancelled")) + { + Logger.Info($"{procName}: Process was cancelled {LibraryBook.Book}"); + return Result = ProcessBookResult.Cancelled; + } + else if (statusHandler.Errors.Contains("Validation failed")) + { + Logger.Info($"{procName}: Validation failed {LibraryBook.Book}"); + return Result = ProcessBookResult.ValidationFail; + } + + foreach (var errorMessage in statusHandler.Errors) + Logger.Error($"{procName}: {errorMessage}"); + } + catch (Exception ex) + { + Logger.Error(ex, procName); + } + finally + { + if (Result == ProcessBookResult.None) + Result = await showRetry(LibraryBook); + + Status = Result switch + { + ProcessBookResult.Success => ProcessBookStatus.Completed, + ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled, + _ => ProcessBookStatus.Failed, + }; + } + + return Result; + } + + public async Task CancelAsync() + { + try + { + if (CurrentProcessable is AudioDecodable audioDecodable) + await audioDecodable.CancelAsync(); + } + catch (Exception ex) + { + Logger.Error(ex, $"{CurrentProcessable.Name}: Error while cancelling"); + } + } + + public void AddDownloadPdf() => AddProcessable(); + public void AddDownloadDecryptBook() => AddProcessable(); + public void AddConvertToMp3() => AddProcessable(); + + private void AddProcessable() where T : Processable, new() + { + Processes.Enqueue(() => new T()); + } + + public override string ToString() => LibraryBook.ToString(); + + #region Subscribers and Unsubscribers + + private void LinkProcessable(Processable processable) + { + processable.Begin += Processable_Begin; + processable.Completed += Processable_Completed; + processable.StreamingProgressChanged += Streamable_StreamingProgressChanged; + processable.StreamingTimeRemaining += Streamable_StreamingTimeRemaining; + + if (processable is AudioDecodable audioDecodable) + { + audioDecodable.RequestCoverArt += AudioDecodable_RequestCoverArt; + audioDecodable.TitleDiscovered += AudioDecodable_TitleDiscovered; + audioDecodable.AuthorsDiscovered += AudioDecodable_AuthorsDiscovered; + audioDecodable.NarratorsDiscovered += AudioDecodable_NarratorsDiscovered; + audioDecodable.CoverImageDiscovered += AudioDecodable_CoverImageDiscovered; + } + } + + private void UnlinkProcessable(Processable processable) + { + processable.Begin -= Processable_Begin; + processable.Completed -= Processable_Completed; + processable.StreamingProgressChanged -= Streamable_StreamingProgressChanged; + processable.StreamingTimeRemaining -= Streamable_StreamingTimeRemaining; + + if (processable is AudioDecodable audioDecodable) + { + audioDecodable.RequestCoverArt -= AudioDecodable_RequestCoverArt; + audioDecodable.TitleDiscovered -= AudioDecodable_TitleDiscovered; + audioDecodable.AuthorsDiscovered -= AudioDecodable_AuthorsDiscovered; + audioDecodable.NarratorsDiscovered -= AudioDecodable_NarratorsDiscovered; + audioDecodable.CoverImageDiscovered -= AudioDecodable_CoverImageDiscovered; + } + } + + #endregion + + #region AudioDecodable event handlers + + private void AudioDecodable_TitleDiscovered(object sender, string title) => Title = title; + + private void AudioDecodable_AuthorsDiscovered(object sender, string authors) => Author = authors; + + private void AudioDecodable_NarratorsDiscovered(object sender, string narrators) => Narrator = narrators; + + + private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e) + { + byte[] coverData = PictureStorage + .GetPictureSynchronously( + new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500)); + + AudioDecodable_CoverImageDiscovered(this, coverData); + return coverData; + } + + private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt) + { + using var ms = new System.IO.MemoryStream(coverArt); + Cover = new Avalonia.Media.Imaging.Bitmap(ms); + } + + #endregion + + #region Streamable event handlers + private void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining; + + + private void Streamable_StreamingProgressChanged(object sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) + { + if (!downloadProgress.ProgressPercentage.HasValue) + return; + + if (downloadProgress.ProgressPercentage == 0) + TimeRemaining = TimeSpan.Zero; + else + Progress = (int)downloadProgress.ProgressPercentage; + } + + #endregion + + #region Processable event handlers + + private void Processable_Begin(object sender, LibraryBook libraryBook) + { + Status = ProcessBookStatus.Working; + + Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}"); + + Title = libraryBook.Book.Title; + Author = libraryBook.Book.AuthorNames(); + Narrator = libraryBook.Book.NarratorNames(); + } + + private async void Processable_Completed(object sender, LibraryBook libraryBook) + { + Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}"); + UnlinkProcessable((Processable)sender); + + if (Processes.Count > 0) + { + NextProcessable(); + LinkProcessable(CurrentProcessable); + var result = await CurrentProcessable.ProcessSingleAsync(libraryBook, validate: true); + + if (result.HasErrors) + { + foreach (var errorMessage in result.Errors.Where(e => e != "Validation failed")) + Logger.Error(errorMessage); + + Completed?.Invoke(this, EventArgs.Empty); + } + } + else + { + Completed?.Invoke(this, EventArgs.Empty); + } + } + + #endregion + + #region Failure Handler + + private async Task showRetry(LibraryBook libraryBook) + { + Logger.Error("ERROR. All books have not been processed. Most recent book: processing failed"); + + DialogResult? dialogResult = Configuration.Instance.BadBook switch + { + Configuration.BadBookAction.Abort => DialogResult.Abort, + Configuration.BadBookAction.Retry => DialogResult.Retry, + Configuration.BadBookAction.Ignore => DialogResult.Ignore, + Configuration.BadBookAction.Ask => null, + _ => null + }; + + string details; + try + { + static string trunc(string str) + => string.IsNullOrWhiteSpace(str) ? "[empty]" + : (str.Length > 50) ? $"{str.Truncate(47)}..." + : str; + + details = +$@" Title: {libraryBook.Book.Title} + ID: {libraryBook.Book.AudibleProductId} + Author: {trunc(libraryBook.Book.AuthorNames())} + Narr: {trunc(libraryBook.Book.NarratorNames())}"; + } + catch + { + details = "[Error retrieving details]"; + } + + // if null then ask user + dialogResult ??= await MessageBox.Show(string.Format(SkipDialogText + "\r\n\r\nSee Settings to avoid this box in the future.", details), "Skip importing this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton); + + if (dialogResult == DialogResult.Abort) + return ProcessBookResult.FailedAbort; + + if (dialogResult == SkipResult) + { + libraryBook.Book.UpdateBookStatus(LiberatedStatus.Error); + + Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}"); + + return ProcessBookResult.FailedSkip; + } + + return ProcessBookResult.FailedRetry; + } + + private string SkipDialogText => @" +An error occurred while trying to process this book. +{0} + +- ABORT: Stop processing books. + +- RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.) + +- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.) +".Trim(); + private MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore; + private MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1; + private DialogResult SkipResult => DialogResult.Ignore; + } + + #endregion +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs new file mode 100644 index 00000000..15521a96 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProcessQueueViewModel.cs @@ -0,0 +1,213 @@ +using ApplicationServices; +using Avalonia.Controls; +using Avalonia.Threading; +using DataLayer; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ProcessQueueViewModel : ViewModelBase, ProcessQueue.ILogForm + { + public ObservableCollection LogEntries { get; } = new(); + public TrackedQueue Items { get; } = new(); + + private TrackedQueue Queue => Items; + public ProcessBookViewModel SelectedItem { get; set; } + public Task QueueRunner { get; private set; } + public bool Running => !QueueRunner?.IsCompleted ?? false; + + private readonly ProcessQueue.LogMe Logger; + + public ProcessQueueViewModel() + { + Queue.QueuededCountChanged += Queue_QueuededCountChanged; + Queue.CompletedCountChanged += Queue_CompletedCountChanged; + Logger = ProcessQueue.LogMe.RegisterForm(this); + } + + private int _completedCount; + private int _errorCount; + private int _queuedCount; + private string _runningTime; + private bool _progressBarVisible; + + public int CompletedCount { get => _completedCount; private set { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); } } + public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); } } + public int ErrorCount { get => _errorCount; private set { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); } } + public string RunningTime { get => _runningTime; set { this.RaiseAndSetIfChanged(ref _runningTime, value); } } + public bool ProgressBarVisible { get => _progressBarVisible; set { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); } } + public bool AnyCompleted => CompletedCount > 0; + public bool AnyQueued => QueuedCount > 0; + public bool AnyErrors => ErrorCount > 0; + public double Progress => 100d * Queue.Completed.Count / Queue.Count; + + private void Queue_CompletedCountChanged(object sender, int e) + { + int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail); + int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success); + + ErrorCount = errCount; + CompletedCount = completeCount; + this.RaisePropertyChanged(nameof(Progress)); + } + private void Queue_QueuededCountChanged(object sender, int cueCount) + { + QueuedCount = cueCount; + this.RaisePropertyChanged(nameof(Progress)); + } + + public void WriteLine(string text) + { + Dispatcher.UIThread.Post(() => + LogEntries.Add(new() + { + LogDate = DateTime.Now, + LogMessage = text.Trim() + })); + } + + + #region Add Books to Queue + + private bool isBookInQueue(LibraryBook libraryBook) + => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + + public void AddDownloadPdf(LibraryBook libraryBook) + => AddDownloadPdf(new List() { libraryBook }); + + public void AddDownloadDecrypt(LibraryBook libraryBook) + => AddDownloadDecrypt(new List() { libraryBook }); + + public void AddConvertMp3(LibraryBook libraryBook) + => AddConvertMp3(new List() { libraryBook }); + + public void AddDownloadPdf(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBookViewModel pbook = new(entry, Logger); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddDownloadDecrypt(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBookViewModel pbook = new(entry, Logger); + pbook.AddDownloadDecryptBook(); + pbook.AddDownloadPdf(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddConvertMp3(IEnumerable entries) + { + List procs = new(); + foreach (var entry in entries) + { + if (isBookInQueue(entry)) + continue; + + ProcessBookViewModel pbook = new(entry, Logger); + pbook.AddConvertToMp3(); + procs.Add(pbook); + } + + Serilog.Log.Logger.Information("Queueing {count} books", procs.Count); + AddToQueue(procs); + } + + public void AddToQueue(IEnumerable pbook) + { + Dispatcher.UIThread.Post(() => + { + Queue.Enqueue(pbook); + if (!Running) + QueueRunner = QueueLoop(); + }); + } + + #endregion + + DateTime StartingTime; + private async Task QueueLoop() + { + try + { + Serilog.Log.Logger.Information("Begin processing queue"); + + RunningTime = string.Empty; + ProgressBarVisible = true; + StartingTime = DateTime.Now; + + using var counterTimer = new System.Threading.Timer(CounterTimer_Tick, null, 0, 500); + + while (Queue.MoveNext()) + { + var nextBook = Queue.Current; + + Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook?.LibraryBook); + + var result = await nextBook.ProcessOneAsync(); + + Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook?.LibraryBook, result); + + if (result == ProcessBookResult.ValidationFail) + Queue.ClearCurrent(); + else if (result == ProcessBookResult.FailedAbort) + Queue.ClearQueue(); + else if (result == ProcessBookResult.FailedSkip) + nextBook.LibraryBook.Book.UpdateBookStatus(DataLayer.LiberatedStatus.Error); + } + Serilog.Log.Logger.Information("Completed processing queue"); + + Queue_CompletedCountChanged(this, 0); + ProgressBarVisible = false; + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "An error was encountered while processing queued items"); + } + } + + private void CounterTimer_Tick(object? state) + { + string timeToStr(TimeSpan time) + { + string minsSecs = $"{time:mm\\:ss}"; + if (time.TotalHours >= 1) + return $"{time.TotalHours:F0}:{minsSecs}"; + return minsSecs; + } + RunningTime = timeToStr(DateTime.Now - StartingTime); + } + } + + public class LogEntry + { + public DateTime LogDate { get; init; } + public string LogDateString => LogDate.ToShortTimeString(); + public string LogMessage { get; init; } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs new file mode 100644 index 00000000..f8b9fafd --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ProductsDisplayViewModel.cs @@ -0,0 +1,338 @@ +using Avalonia.Controls; +using DataLayer; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; +using ReactiveUI; +using System.Reflection; +using System.Collections; +using Avalonia.Threading; +using ApplicationServices; +using AudibleUtilities; +using LibationWinForms.AvaloniaUI.Views; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ProductsDisplayViewModel : ViewModelBase + { + /// Number of visible rows has changed + public event EventHandler VisibleCountChanged; + public event EventHandler RemovableCountChanged; + public event EventHandler InitialLoaded; + + private DataGridColumn _currentSortColumn; + private DataGrid productsDataGrid; + + private GridEntryCollection _gridEntries; + private bool _removeColumnVisivle; + public GridEntryCollection GridEntries { get => _gridEntries; private set => this.RaiseAndSetIfChanged(ref _gridEntries, value); } + public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } + + public List GetVisibleBookEntries() + => GridEntries.InternalList + .BookEntries() + .Select(lbe => lbe.LibraryBook) + .ToList(); + public IEnumerable GetAllBookEntries() + => GridEntries + .AllItems() + .BookEntries(); + public ProductsDisplayViewModel() { } + public ProductsDisplayViewModel(List items) + { + GridEntries = new GridEntryCollection(items); + } + + #region Display Functions + + /// + /// Call once on load so we can modify access a private member with reflection + /// + public void RegisterCollectionChanged(ProductsDisplay productsDisplay = null) + { + productsDataGrid ??= productsDisplay?.productsGrid; + + if (GridEntries is null) + return; + + //Avalonia displays items in the DataConncetion from an internal copy of + //the bound list, not the actual bound list. So we need to reflect to get + //the current display order and set each GridEntry.ListIndex correctly. + var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance); + var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance); + + GridEntries.CollectionChanged += (s, e) => + { + if (s != GridEntries) return; + + var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsDataGrid))).Cast(); + int index = 0; + foreach (var di in displayListGE) + { + di.ListIndex = index++; + } + }; + } + + /// + /// Only call once per lifetime + /// + public void InitialDisplay(List dbBooks) + { + try + { + GridEntries = new GridEntryCollection(CreateGridEntries(dbBooks)); + GridEntries.CollapseAll(); + + InitialLoaded?.Invoke(this, EventArgs.Empty); + VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + + RegisterCollectionChanged(); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); + } + } + + /// + /// Call when there's been a change to the library + /// + public async Task DisplayBooks(List dbBooks) + { + try + { + //List is already displayed. Replace all items with new ones, refilter, and re-sort + string existingFilter = GridEntries?.Filter; + var newEntries = CreateGridEntries(dbBooks); + + var existingSeriesEntries = GridEntries.AllItems().SeriesEntries().ToList(); + + await Dispatcher.UIThread.InvokeAsync(() => + { + GridEntries.ReplaceList(newEntries); + GridEntries.Filter = existingFilter; + ReSort(); + }); + + //We're replacing the list, so preserve usere's existing collapse/expand + //state. When resetting a list, default state is open. + foreach (var series in existingSeriesEntries) + { + var sEntry = GridEntries.InternalList.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); + if (sEntry is SeriesEntry se && !series.Liberate.Expanded) + await Dispatcher.UIThread.InvokeAsync(() => GridEntries.CollapseItem(se)); + } + + await Dispatcher.UIThread.InvokeAsync(() => VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count())); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); + } + } + + private static IEnumerable CreateGridEntries(IEnumerable dbBooks) + { + var geList = dbBooks + .Where(lb => lb.Book.IsProduct()) + .Select(b => new LibraryBookEntry(b)) + .Cast() + .ToList(); + + var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); + + var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); + + foreach (var parent in seriesBooks) + { + var seriesEpisodes = episodes.FindChildren(parent); + + if (!seriesEpisodes.Any()) continue; + + var seriesEntry = new SeriesEntry(parent, seriesEpisodes); + + geList.Add(seriesEntry); + geList.AddRange(seriesEntry.Children); + } + return geList.OrderByDescending(e => e.DateAdded); + } + + public void ToggleSeriesExpanded(SeriesEntry seriesEntry) + { + if (seriesEntry.Liberate.Expanded) + GridEntries.CollapseItem(seriesEntry); + else + GridEntries.ExpandItem(seriesEntry); + + VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + } + + #endregion + + #region Filtering + public async Task Filter(string searchString) + { + await Dispatcher.UIThread.InvokeAsync(() => + { + int visibleCount = GridEntries.Count; + + if (string.IsNullOrEmpty(searchString)) + GridEntries.RemoveFilter(); + else + GridEntries.Filter = searchString; + + if (visibleCount != GridEntries.Count) + VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count()); + + //Re-sort after filtering + ReSort(); + }); + } + + #endregion + + #region Sorting + + public void Sort(DataGridColumn sortColumn) + { + //Force the comparer to get the current sort order. We can't + //retrieve it from inside this event handler because Avalonia + //doesn't set the property until after this event. + var comparer = sortColumn.CustomSortComparer as RowComparer; + comparer.SortDirection = null; + + _currentSortColumn = sortColumn; + } + + //Must be invoked on UI thread + private void ReSort() + { + if (_currentSortColumn is null) + { + //Sort ascending and reverse. That's how the comparer is designed to work to be compatible with Avalonia. + var defaultComparer = new RowComparer(ListSortDirection.Descending, nameof(GridEntry.DateAdded)); + GridEntries.InternalList.Sort(defaultComparer); + GridEntries.InternalList.Reverse(); + GridEntries.ResetCollection(); + } + else + { + _currentSortColumn.Sort(((RowComparer)_currentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending); + } + } + + #endregion + + #region Scan and Remove Books + + public void DoneRemovingBooks() + { + foreach (var item in GridEntries.AllItems()) + item.PropertyChanged -= Item_PropertyChanged; + RemoveColumnVisivle = false; + } + + public async Task RemoveCheckedBooksAsync() + { + var selectedBooks = GetAllBookEntries().Where(lbe => lbe.Remove == true).ToList(); + + if (selectedBooks.Count == 0) + return; + + var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var result = await MessageBox.ShowConfirmationDialog( + null, + libraryBooks, + $"Are you sure you want to remove {selectedBooks.Count} books from Libation's library?", + "Remove books from Libation?"); + + if (result != DialogResult.Yes) + return; + + foreach (var book in selectedBooks) + book.PropertyChanged -= Item_PropertyChanged; + + var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); + GridEntries.CollectionChanged += BindingList_CollectionChanged; + + //The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(), + //so there's no need to remove books from the grid display here. + var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + + foreach (var b in GetAllBookEntries()) + b.Remove = false; + + RemovableCountChanged?.Invoke(this, 0); + } + + void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) + { + if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset) + return; + + //After ProductsDisplay2.Display() re-creates the list, + //re-subscribe to all items' PropertyChanged events. + + foreach (var b in GetAllBookEntries()) + b.PropertyChanged += Item_PropertyChanged; + + GridEntries.CollectionChanged -= BindingList_CollectionChanged; + } + + public async Task ScanAndRemoveBooksAsync(params Account[] accounts) + { + foreach (var item in GridEntries.AllItems()) + { + item.Remove = false; + item.PropertyChanged += Item_PropertyChanged; + } + + RemoveColumnVisivle = true; + RemovableCountChanged?.Invoke(this, 0); + + try + { + if (accounts is null || accounts.Length == 0) + return; + + var allBooks = GetAllBookEntries(); + + foreach (var b in allBooks) + b.Remove = false; + + var lib = allBooks + .Select(lbe => lbe.LibraryBook) + .Where(lb => !lb.Book.HasLiberated()); + + var removedBooks = await LibraryCommands.FindInactiveBooks(Login.WinformLoginChoiceEager.ApiExtendedFunc, lib, accounts); + + var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList(); + + foreach (var r in removable) + r.Remove = true; + } + catch (Exception ex) + { + await MessageBox.ShowAdminAlert( + null, + "Error scanning library. You may still manually select books to remove from Libation's library.", + "Error scanning library", + ex); + } + } + + private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e) + { + if (e.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry lbEntry) + { + int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true); + RemovableCountChanged?.Invoke(this, removeCount); + } + } + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs new file mode 100644 index 00000000..073bc91f --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/QueryExtensions.cs @@ -0,0 +1,44 @@ +using DataLayer; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ +#nullable enable + internal static class QueryExtensions + { + public static IEnumerable BookEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); + + public static IEnumerable SeriesEntries(this IEnumerable gridEntries) + => gridEntries.OfType(); + + public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry + => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); + + public static IEnumerable EmptySeries(this IEnumerable gridEntries) + => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); + + public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) + { + if (seriesEpisode.Book.SeriesLink is null) return null; + + try + { + //Parent books will always have exactly 1 SeriesBook due to how + //they are imported in ApiExtended.getChildEpisodesAsync() + return gridEntries.SeriesEntries().FirstOrDefault( + lb => + seriesEpisode.Book.SeriesLink.Any( + s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId)); + } + catch (Exception ex) + { + Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent)); + return null; + } + } + } +#nullable disable +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs new file mode 100644 index 00000000..2551d04e --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/RowComparer.cs @@ -0,0 +1,111 @@ +using Avalonia.Controls; +using System; +using System.Collections; +using System.Collections.Generic; +using System.ComponentModel; +using System.Reflection; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /// + /// This compare class ensures that all top-level grid entries (standalone books or series parents) + /// are sorted by PropertyName while all episodes remain immediately beneath their parents and remain + /// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex + /// properties when 2 items compare equal. + /// + internal class RowComparer : IComparer, IComparer + { + private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance); + private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance); + + public DataGridColumn Column { get; init; } + public string PropertyName { get; private set; } + public ListSortDirection? SortDirection { get; set; } + + public RowComparer(DataGridColumn column) + { + Column = column; + PropertyName = Column.SortMemberPath; + } + public RowComparer(ListSortDirection direction, string propertyName) + { + SortDirection = direction; + PropertyName = propertyName; + } + + public int Compare(object x, object y) + { + if (x is null && y is not null) return -1; + if (x is not null && y is null) return 1; + if (x is null && y is null) return 0; + + var geA = (GridEntry)x; + var geB = (GridEntry)y; + + SortDirection ??= GetSortOrder(); + + SeriesEntry parentA = null; + SeriesEntry parentB = null; + + if (geA is LibraryBookEntry lbA && lbA.Parent is SeriesEntry seA) + parentA = seA; + if (geB is LibraryBookEntry lbB && lbB.Parent is SeriesEntry seB) + parentB = seB; + + //both a and b are top-level grid entries + if (parentA is null && parentB is null) + return InternalCompare(geA, geB); + + //a is top-level, b is a child + if (parentA is null && parentB is not null) + { + // b is a child of a, parent is always first + if (parentB == geA) + return SortDirection is ListSortDirection.Ascending ? -1 : 1; + else + return InternalCompare(geA, parentB); + } + + //a is a child, b is a top-level + if (parentA is not null && parentB is null) + { + // a is a child of b, parent is always first + if (parentA == geB) + return SortDirection is ListSortDirection.Ascending ? 1 : -1; + else + return InternalCompare(parentA, geB); + } + + //both are children of the same series, always present in order of series index, ascending + if (parentA == parentB) + return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1); + + //a and b are children of different series. + return InternalCompare(parentA, parentB); + } + + //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection + private ListSortDirection? GetSortOrder() + => CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?; + + private int InternalCompare(GridEntry x, GridEntry y) + { + var val1 = x.GetMemberValue(PropertyName); + var val2 = y.GetMemberValue(PropertyName); + + var compareResult = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); + + //If items compare equal, compare them by their positions in the the list. + //This is how you achieve a stable sort. + if (compareResult == 0) + return x.ListIndex.CompareTo(y.ListIndex); + else + return compareResult; + } + + public int Compare(GridEntry x, GridEntry y) + { + return Compare((object)x, y); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntry.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntry.cs new file mode 100644 index 00000000..c2ec3177 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/SeriesEntry.cs @@ -0,0 +1,110 @@ +using Avalonia.Media; +using DataLayer; +using Dinah.Core; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + /// The View Model for a LibraryBook that is ContentType.Parent + public class SeriesEntry : GridEntry + { + [Browsable(false)] public List Children { get; } + [Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded); + + private bool suspendCounting = false; + public void ChildRemoveUpdate() + { + if (suspendCounting) return; + + var removeCount = Children.Count(c => c.Remove == true); + + _remove = removeCount == 0 ? false : (removeCount == Children.Count ? true : null); + this.RaisePropertyChanged(nameof(Remove)); + } + + #region Model properties exposed to the view + public override bool? Remove + { + get => _remove; + set + { + _remove = value ?? false; + + suspendCounting = true; + + foreach (var item in Children) + item.Remove = value; + + suspendCounting = false; + this.RaisePropertyChanged(nameof(Remove)); + } + } + + public override LiberateButtonStatus Liberate { get; } + public override BookTags BookTags { get; } = new(); + + public override bool IsSeries => true; + public override bool IsEpisode => false; + public override bool IsBook => false; + + #endregion + + public SeriesEntry(LibraryBook parent, IEnumerable children) + { + Liberate = new LiberateButtonStatus(IsSeries) { Expanded = true }; + SeriesIndex = -1; + LibraryBook = parent; + + LoadCover(); + + Children = children + .Select(c => new LibraryBookEntry(c) { Parent = this }) + .OrderBy(c => c.SeriesIndex) + .ToList(); + + Title = Book.Title; + Series = Book.SeriesNames(); + MyRating = Book.UserDefinedItem.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace(""); + Authors = Book.AuthorNames(); + Narrators = Book.NarratorNames(); + Category = string.Join(" > ", Book.CategoriesNames()); + Misc = GetMiscDisplay(LibraryBook); + LongDescription = GetDescriptionDisplay(Book); + Description = TrimTextToWord(LongDescription, 62); + + PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); + int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + } + + + #region Data Sorting + + /// Create getters for all member object values by name + protected override Dictionary> CreateMemberValueDictionary() => new() + { + { nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved }, + { nameof(Title), () => Book.TitleSortable() }, + { nameof(Series), () => Book.SeriesSortable() }, + { nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) }, + { nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() }, + { nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) }, + { nameof(ProductRating), () => Book.Rating.FirstScore() }, + { nameof(Authors), () => Authors }, + { nameof(Narrators), () => Narrators }, + { nameof(Description), () => Description }, + { nameof(Category), () => Category }, + { nameof(Misc), () => Misc }, + { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, + { nameof(Liberate), () => Liberate }, + { nameof(DateAdded), () => DateAdded }, + }; + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue[T].cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue[T].cs new file mode 100644 index 00000000..dd2ff3a7 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/TrackedQueue[T].cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public enum QueuePosition + { + Fisrt, + OneUp, + OneDown, + Last, + } + + /* + * This data structure is like lifting a metal chain one link at a time. + * Each time you grab and lift a new link (MoveNext call): + * + * 1) you're holding a new link in your hand (Current) + * 2) the remaining chain to be lifted shortens by 1 link (Queued) + * 3) the pile of chain at your feet grows by 1 link (Completed) + * + * The index is the link position from the first link you lifted to the + * last one in the chain. + * + * + * For this to work with Avalonia's ItemsRepeater, it must be an ObservableCollection + * (not merely a Collection with INotifyCollectionChanged, INotifyPropertyChanged). + * So TrackedQueue maintains 2 copies of the list. The primary copy of the list is + * split into Completed, Current and Queued and is used by ProcessQueue to keep track + * of what's what. The secondary copy is a concatenation of primary's three sources + * and is stored in ObservableCollection.Items. When the primary list changes, the + * secondary list is cleared and reset to match the primary. + */ + public class TrackedQueue : ObservableCollection where T : class + { + public event EventHandler CompletedCountChanged; + public event EventHandler QueuededCountChanged; + + public T Current { get; private set; } + + public IReadOnlyList Queued => _queued; + public IReadOnlyList Completed => _completed; + + private readonly List _queued = new(); + private readonly List _completed = new(); + private readonly object lockObject = new(); + + public bool RemoveQueued(T item) + { + bool itemsRemoved; + int queuedCount; + + lock (lockObject) + { + itemsRemoved = _queued.Remove(item); + queuedCount = _queued.Count; + } + + if (itemsRemoved) + { + QueuededCountChanged?.Invoke(this, queuedCount); + RebuildSecondary(); + } + return itemsRemoved; + } + + public void ClearCurrent() + { + lock(lockObject) + Current = null; + RebuildSecondary(); + } + + public bool RemoveCompleted(T item) + { + bool itemsRemoved; + int completedCount; + + lock (lockObject) + { + itemsRemoved = _completed.Remove(item); + completedCount = _completed.Count; + } + + if (itemsRemoved) + { + CompletedCountChanged?.Invoke(this, completedCount); + RebuildSecondary(); + } + return itemsRemoved; + } + + public void ClearQueue() + { + lock (lockObject) + _queued.Clear(); + QueuededCountChanged?.Invoke(this, 0); + RebuildSecondary(); + } + + public void ClearCompleted() + { + lock (lockObject) + _completed.Clear(); + CompletedCountChanged?.Invoke(this, 0); + RebuildSecondary(); + } + + public bool Any(Func predicate) + { + lock (lockObject) + { + return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate); + } + } + + public void MoveQueuePosition(T item, QueuePosition requestedPosition) + { + lock (lockObject) + { + if (_queued.Count == 0 || !_queued.Contains(item)) return; + + if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item) + return; + if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item) + return; + + int queueIndex = _queued.IndexOf(item); + + if (requestedPosition == QueuePosition.OneUp) + { + _queued.RemoveAt(queueIndex); + _queued.Insert(queueIndex - 1, item); + } + else if (requestedPosition == QueuePosition.OneDown) + { + _queued.RemoveAt(queueIndex); + _queued.Insert(queueIndex + 1, item); + } + else if (requestedPosition == QueuePosition.Fisrt) + { + _queued.RemoveAt(queueIndex); + _queued.Insert(0, item); + } + else + { + _queued.RemoveAt(queueIndex); + _queued.Insert(_queued.Count, item); + } + } + RebuildSecondary(); + } + + public bool MoveNext() + { + int completedCount = 0, queuedCount = 0; + bool completedChanged = false; + try + { + lock (lockObject) + { + if (Current != null) + { + _completed.Add(Current); + completedCount = _completed.Count; + completedChanged = true; + } + if (_queued.Count == 0) + { + Current = null; + return false; + } + Current = _queued[0]; + _queued.RemoveAt(0); + + queuedCount = _queued.Count; + return true; + } + } + finally + { + if (completedChanged) + CompletedCountChanged?.Invoke(this, completedCount); + QueuededCountChanged?.Invoke(this, queuedCount); + RebuildSecondary(); + } + } + + public bool TryPeek(out T item) + { + lock (lockObject) + { + if (_queued.Count == 0) + { + item = null; + return false; + } + item = _queued[0]; + return true; + } + } + + public T Peek() + { + lock (lockObject) + { + if (_queued.Count == 0) throw new InvalidOperationException("Queue empty"); + return _queued.Count > 0 ? _queued[0] : default; + } + } + + public void Enqueue(IEnumerable item) + { + int queueCount; + lock (lockObject) + { + _queued.AddRange(item); + queueCount = _queued.Count; + } + foreach (var i in item) + base.Add(i); + QueuededCountChanged?.Invoke(this, queueCount); + } + + private void RebuildSecondary() + { + base.ClearItems(); + foreach (var item in GetAllItems()) + base.Add(item); + } + + public IEnumerable GetAllItems() + { + if (Current is null) return Completed.Concat(Queued); + return Completed.Concat(new List { Current }).Concat(Queued); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs new file mode 100644 index 00000000..060934f0 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/ViewModels/ViewModelBase.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Text; + +namespace LibationWinForms.AvaloniaUI.ViewModels +{ + public class ViewModelBase : ReactiveObject + { + + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/AccountsDialog.axaml b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/AccountsDialog.axaml new file mode 100644 index 00000000..0942c6f6 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/AccountsDialog.axaml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/MessageBoxWindow.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/MessageBoxWindow.axaml.cs new file mode 100644 index 00000000..f28c3f56 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/MessageBoxWindow.axaml.cs @@ -0,0 +1,71 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibationWinForms.AvaloniaUI.ViewModels.Dialogs; + +namespace LibationWinForms.AvaloniaUI.Views.Dialogs +{ + + public partial class MessageBoxWindow : DialogWindow + { + public MessageBoxWindow() + { + InitializeComponent(); + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + protected override void CancelAndClose() => Close(DialogResult.None); + + protected override void SaveAndClose() { } + + public DialogResult DialogResult { get; private set; } + + public void Button1_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var vm = DataContext as MessageBoxViewModel; + DialogResult = vm.Buttons switch + { + MessageBoxButtons.OK => DialogResult.OK, + MessageBoxButtons.OKCancel => DialogResult.OK, + MessageBoxButtons.AbortRetryIgnore => DialogResult.Abort, + MessageBoxButtons.YesNoCancel => DialogResult.Yes, + MessageBoxButtons.YesNo => DialogResult.Yes, + MessageBoxButtons.RetryCancel => DialogResult.Retry, + MessageBoxButtons.CancelTryContinue => DialogResult.Cancel, + _ => DialogResult.None + }; + Close(DialogResult); + } + public void Button2_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var vm = DataContext as MessageBoxViewModel; + DialogResult = vm.Buttons switch + { + MessageBoxButtons.OKCancel => DialogResult.Cancel, + MessageBoxButtons.AbortRetryIgnore => DialogResult.Retry, + MessageBoxButtons.YesNoCancel => DialogResult.No, + MessageBoxButtons.YesNo => DialogResult.No, + MessageBoxButtons.RetryCancel => DialogResult.Cancel, + MessageBoxButtons.CancelTryContinue => DialogResult.TryAgain, + _ => DialogResult.None + }; + Close(DialogResult); + } + public void Button3_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var vm = DataContext as MessageBoxViewModel; + DialogResult = vm.Buttons switch + { + MessageBoxButtons.AbortRetryIgnore => DialogResult.Ignore, + MessageBoxButtons.YesNoCancel => DialogResult.Cancel, + MessageBoxButtons.CancelTryContinue => DialogResult.Continue, + _ => DialogResult.None + }; + Close(DialogResult); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/ScanAccountsDialog.axaml b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/ScanAccountsDialog.axaml new file mode 100644 index 00000000..83617c20 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/Dialogs/ScanAccountsDialog.axaml @@ -0,0 +1,82 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl.axaml.cs new file mode 100644 index 00000000..01376344 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessBookControl.axaml.cs @@ -0,0 +1,50 @@ +using Avalonia; +using System; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using LibationWinForms.AvaloniaUI.ViewModels; +using ApplicationServices; +using DataLayer; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public delegate void QueueItemPositionButtonClicked(ProcessBookViewModel item, QueuePosition queueButton); + public delegate void QueueItemCancelButtonClicked(ProcessBookViewModel item); + public partial class ProcessBookControl : UserControl + { + public static event QueueItemPositionButtonClicked PositionButtonClicked; + public static event QueueItemCancelButtonClicked CancelButtonClicked; + public ProcessBookControl() + { + InitializeComponent(); + + if (Design.IsDesignMode) + { + using var context = DbContexts.GetContext(); + DataContext = new ProcessBookViewModel( + context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), + ProcessQueue.LogMe.RegisterForm(default(ProcessQueue.ILogForm)) + ); + return; + } + } + + private ProcessBookViewModel DataItem => DataContext is null ? null : DataContext as ProcessBookViewModel; + + public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => CancelButtonClicked?.Invoke(DataItem); + public void MoveFirst_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => PositionButtonClicked?.Invoke(DataItem, QueuePosition.Fisrt); + public void MoveUp_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => PositionButtonClicked?.Invoke(DataItem, QueuePosition.OneUp); + public void MoveDown_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => PositionButtonClicked?.Invoke(DataItem, QueuePosition.OneDown); + public void MoveLast_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => PositionButtonClicked?.Invoke(DataItem, QueuePosition.Last); + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl.axaml new file mode 100644 index 00000000..d57b3002 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl.axaml @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + + + Process Queue + + + + + + + + + + + + + + + + + Queue Log + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl.axaml.cs new file mode 100644 index 00000000..0df50ef4 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProcessQueueControl.axaml.cs @@ -0,0 +1,151 @@ +using ApplicationServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using DataLayer; +using LibationWinForms.AvaloniaUI.ViewModels; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class ProcessQueueControl : UserControl + { + private TrackedQueue Queue => _viewModel.Items; + private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel; + + public ProcessQueueControl() + { + InitializeComponent(); + + ProcessBookControl.PositionButtonClicked += ProcessBookControl2_ButtonClicked; + ProcessBookControl.CancelButtonClicked += ProcessBookControl2_CancelButtonClicked; + + #region Design Mode Testing + if (Design.IsDesignMode) + { + var vm = new ProcessQueueViewModel(); + var Logger = ProcessQueue.LogMe.RegisterForm(vm); + DataContext = vm; + using var context = DbContexts.GetContext(); + List testList = new() + { + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) + { + Result = ProcessBookResult.FailedAbort, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"), Logger) + { + Result = ProcessBookResult.FailedSkip, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"), Logger) + { + Result = ProcessBookResult.FailedRetry, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"), Logger) + { + Result = ProcessBookResult.ValidationFail, + Status = ProcessBookStatus.Failed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"), Logger) + { + Result = ProcessBookResult.Cancelled, + Status = ProcessBookStatus.Cancelled, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"), Logger) + { + Result = ProcessBookResult.Success, + Status = ProcessBookStatus.Completed, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"), Logger) + { + Result = ProcessBookResult.None, + Status = ProcessBookStatus.Working, + }, + new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"), Logger) + { + Result = ProcessBookResult.None, + Status = ProcessBookStatus.Queued, + }, + }; + + vm.Items.Enqueue(testList); + vm.Items.MoveNext(); + vm.Items.MoveNext(); + vm.Items.MoveNext(); + vm.Items.MoveNext(); + vm.Items.MoveNext(); + vm.Items.MoveNext(); + vm.Items.MoveNext(); + return; + } + #endregion + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + } + + #region Control event handlers + + private async void ProcessBookControl2_CancelButtonClicked(ProcessBookViewModel item) + { + if (item is not null) + await item.CancelAsync(); + Queue.RemoveQueued(item); + } + + private void ProcessBookControl2_ButtonClicked(ProcessBookViewModel item, QueuePosition queueButton) + { + Queue.MoveQueuePosition(item, queueButton); + } + + public async void CancelAllBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + public void ClearFinishedBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + Queue.ClearCompleted(); + + if (!_viewModel.Running) + _viewModel.RunningTime = string.Empty; + } + + public void ClearLogBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + _viewModel.LogEntries.Clear(); + } + + private async void LogCopyBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + string logText = string.Join("\r\n", _viewModel.LogEntries.Select(r => $"{r.LogDate.ToShortDateString()} {r.LogDate.ToShortTimeString()}\t{r.LogMessage}")); + await Application.Current.Clipboard.SetTextAsync(logText); + } + + private async void cancelAllBtn_Click(object sender, EventArgs e) + { + Queue.ClearQueue(); + if (Queue.Current is not null) + await Queue.Current.CancelAsync(); + } + + private void btnClearFinished_Click(object sender, EventArgs e) + { + Queue.ClearCompleted(); + + if (!_viewModel.Running) + _viewModel.RunningTime = string.Empty; + } + + #endregion + } +} diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay.axaml b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay.axaml new file mode 100644 index 00000000..99f2dd8f --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay.axaml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay.axaml.cs b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay.axaml.cs new file mode 100644 index 00000000..33426bd3 --- /dev/null +++ b/Source/LibationWinForms/AvaloniaUI/Views/ProductsDisplay.axaml.cs @@ -0,0 +1,300 @@ +using ApplicationServices; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using DataLayer; +using FileLiberator; +using LibationFileManager; +using LibationWinForms.AvaloniaUI.ViewModels; +using LibationWinForms.AvaloniaUI.Views.Dialogs; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationWinForms.AvaloniaUI.Views +{ + public partial class ProductsDisplay : UserControl + { + public event EventHandler LiberateClicked; + + private ProductsDisplayViewModel _viewModel => DataContext as ProductsDisplayViewModel; + ImageDisplayDialog imageDisplayDialog; + + public ProductsDisplay() + { + InitializeComponent(); + + if (Design.IsDesignMode) + { + using var context = DbContexts.GetContext(); + List sampleEntries = new() + { + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G")), + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG")), + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q")), + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO")), + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4")), + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0")), + new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")), + }; + DataContext = new ProductsDisplayViewModel(sampleEntries); + return; + } + + Configure_ColumnCustomization(); + foreach (var column in productsGrid.Columns) + { + column.CustomSortComparer = new RowComparer(column); + } + } + + private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e) + { + _viewModel.Sort(e.Column); + } + + private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e) + { + if (sender is DataGridColumn col && e.Property.Name == nameof(DataGridColumn.IsVisible)) + { + col.DisplayIndex = 0; + col.CanUserReorder = false; + } + } + + public void DataGrid_CopyToClipboard(object sender, DataGridRowClipboardEventArgs e) + { + + } + + private void InitializeComponent() + { + AvaloniaXamlLoader.Load(this); + + productsGrid = this.FindControl(nameof(productsGrid)); + } + + #region Column Customizations + + private void Configure_ColumnCustomization() + { + if (Design.IsDesignMode) return; + + productsGrid.ColumnDisplayIndexChanged += ProductsGrid_ColumnDisplayIndexChanged; + + var config = Configuration.Instance; + var gridColumnsVisibilities = config.GridColumnsVisibilities; + var displayIndices = config.GridColumnsDisplayIndices; + + var contextMenu = new ContextMenu(); + contextMenu.MenuClosed += ContextMenu_MenuClosed; + contextMenu.ContextMenuOpening += ContextMenu_ContextMenuOpening; + List menuItems = new(); + contextMenu.Items = menuItems; + + menuItems.Add(new MenuItem { Header = "Show / Hide Columns" }); + menuItems.Add(new MenuItem { Header = "-" }); + + var HeaderCell_PI = typeof(DataGridColumn).GetProperty("HeaderCell", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + foreach (var column in productsGrid.Columns) + { + var itemName = column.SortMemberPath; + + if (itemName == nameof(GridEntry.Remove)) + continue; + + menuItems.Add + ( + new MenuItem + { + Header = ((string)column.Header).Replace((char)0xa, ' '), + Tag = column, + Margin = new Thickness(6, 0), + Icon = new CheckBox + { + Width = 50, + } + } + ); + + var headercell = HeaderCell_PI.GetValue(column) as DataGridColumnHeader; + headercell.ContextMenu = contextMenu; + + column.IsVisible = gridColumnsVisibilities.GetValueOrDefault(itemName, true); + } + + //We must set DisplayIndex properties in ascending order + foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key)) + { + if (!productsGrid.Columns.Any(c => c.SortMemberPath == itemName)) + continue; + + var column = productsGrid.Columns + .Single(c => c.SortMemberPath == itemName); + + column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, productsGrid.Columns.IndexOf(column)); + } + } + + private void ContextMenu_ContextMenuOpening(object sender, System.ComponentModel.CancelEventArgs e) + { + var contextMenu = sender as ContextMenu; + foreach (var mi in contextMenu.Items.OfType()) + { + if (mi.Tag is DataGridColumn column) + { + var cbox = mi.Icon as CheckBox; + cbox.IsChecked = column.IsVisible; + } + } + } + + private void ContextMenu_MenuClosed(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var contextMenu = sender as ContextMenu; + var config = Configuration.Instance; + var dictionary = config.GridColumnsVisibilities; + + foreach (var mi in contextMenu.Items.OfType()) + { + if (mi.Tag is DataGridColumn column) + { + var cbox = mi.Icon as CheckBox; + column.IsVisible = cbox.IsChecked == true; + dictionary[column.SortMemberPath] = cbox.IsChecked == true; + } + } + + //If all columns are hidden, register the context menu on the grid so users can unhide. + if (!productsGrid.Columns.Any(c => c.IsVisible)) + productsGrid.ContextMenu = contextMenu; + else + productsGrid.ContextMenu = null; + + config.GridColumnsVisibilities = dictionary; + } + + private void ProductsGrid_ColumnDisplayIndexChanged(object sender, DataGridColumnEventArgs e) + { + var config = Configuration.Instance; + + var dictionary = config.GridColumnsDisplayIndices; + dictionary[e.Column.SortMemberPath] = e.Column.DisplayIndex; + config.GridColumnsDisplayIndices = dictionary; + } + + #endregion + + #region Button Click Handlers + + public void LiberateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is SeriesEntry sEntry) + { + _viewModel.ToggleSeriesExpanded(sEntry); + + //Expanding and collapsing reset the list, which will cause focus to shift + //to the topright cell. Reset focus onto the clicked button's cell. + ((sender as Control).Parent.Parent as DataGridCell)?.Focus(); + } + else if (button.DataContext is LibraryBookEntry lbEntry) + { + LiberateClicked?.Invoke(this, lbEntry.LibraryBook); + } + } + + public void CloseImageDisplay() + { + if (imageDisplayDialog is not null && imageDisplayDialog.IsVisible) + imageDisplayDialog.Close(); + } + + public void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry) + return; + + + if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible) + { + imageDisplayDialog = new ImageDisplayDialog(); + } + + var picDef = new PictureDefinition(gEntry.LibraryBook.Book.PictureLarge ?? gEntry.LibraryBook.Book.PictureId, PictureSize.Native); + + void PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplayDialog.CoverBytes = e.Picture; + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); + + + var windowTitle = $"{gEntry.Title} - Cover"; + + + imageDisplayDialog.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook); + imageDisplayDialog.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg")); + imageDisplayDialog.Title = windowTitle; + imageDisplayDialog.CoverBytes = initialImageBts; + + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplayDialog.IsVisible) + imageDisplayDialog.Show(); + } + + public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + if (sender is TextBlock tblock && tblock.DataContext is GridEntry gEntry) + { + var pt = tblock.Parent.PointToScreen(tblock.Parent.Bounds.TopRight); + var displayWindow = new DescriptionDisplayDialog + { + SpawnLocation = new Point(pt.X, pt.Y), + DescriptionText = gEntry.LongDescription, + }; + + void CloseWindow(object o, DataGridRowEventArgs e) + { + displayWindow.Close(); + } + productsGrid.LoadingRow += CloseWindow; + displayWindow.Closing += (_, _) => + { + productsGrid.LoadingRow -= CloseWindow; + }; + + displayWindow.Show(); + } + } + + BookDetailsDialog bookDetailsForm; + + public void OnTagsButtonClick(object sender, Avalonia.Interactivity.RoutedEventArgs args) + { + var button = args.Source as Button; + + if (button.DataContext is LibraryBookEntry lbEntry && VisualRoot is Window window) + { + if (bookDetailsForm is null || !bookDetailsForm.IsVisible) + { + bookDetailsForm = new BookDetailsDialog(lbEntry.LibraryBook); + bookDetailsForm.Show(window); + } + else + bookDetailsForm.LibraryBook = lbEntry.LibraryBook; + } + } + + #endregion + } +} diff --git a/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs index 4cfa4680..61efc47b 100644 --- a/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/AccountsDialog.Designer.cs @@ -31,13 +31,13 @@ this.cancelBtn = new System.Windows.Forms.Button(); this.saveBtn = new System.Windows.Forms.Button(); this.dataGridView1 = new System.Windows.Forms.DataGridView(); - this.importBtn = new System.Windows.Forms.Button(); this.DeleteAccount = new System.Windows.Forms.DataGridViewButtonColumn(); this.ExportAccount = new System.Windows.Forms.DataGridViewButtonColumn(); this.LibraryScan = new System.Windows.Forms.DataGridViewCheckBoxColumn(); this.AccountId = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.Locale = new System.Windows.Forms.DataGridViewComboBoxColumn(); this.AccountName = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.importBtn = new System.Windows.Forms.Button(); ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); this.SuspendLayout(); // @@ -62,7 +62,7 @@ this.saveBtn.Name = "saveBtn"; this.saveBtn.Size = new System.Drawing.Size(88, 27); this.saveBtn.TabIndex = 1; - this.saveBtn.Text = "Save"; + this.saveBtn.Text = "pub "; this.saveBtn.UseVisualStyleBackColor = true; this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click); // @@ -89,18 +89,6 @@ this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick); this.dataGridView1.DefaultValuesNeeded += new System.Windows.Forms.DataGridViewRowEventHandler(this.dataGridView1_DefaultValuesNeeded); // - // importBtn - // - this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); - this.importBtn.Location = new System.Drawing.Point(14, 480); - this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); - this.importBtn.Name = "importBtn"; - this.importBtn.Size = new System.Drawing.Size(156, 27); - this.importBtn.TabIndex = 1; - this.importBtn.Text = "Import from audible-cli"; - this.importBtn.UseVisualStyleBackColor = true; - this.importBtn.Click += new System.EventHandler(this.importBtn_Click); - // // DeleteAccount // this.DeleteAccount.HeaderText = "Delete"; @@ -140,6 +128,18 @@ this.AccountName.Name = "AccountName"; this.AccountName.Width = 170; // + // importBtn + // + this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left))); + this.importBtn.Location = new System.Drawing.Point(14, 480); + this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3); + this.importBtn.Name = "importBtn"; + this.importBtn.Size = new System.Drawing.Size(156, 27); + this.importBtn.TabIndex = 1; + this.importBtn.Text = "Import from audible-cli"; + this.importBtn.UseVisualStyleBackColor = true; + this.importBtn.Click += new System.EventHandler(this.importBtn_Click); + // // AccountsDialog // this.AcceptButton = this.saveBtn; diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs index 5932dcd7..1778994a 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs @@ -51,6 +51,7 @@ this.loggingLevelCb = new System.Windows.Forms.ComboBox(); this.tabControl = new System.Windows.Forms.TabControl(); this.tab1ImportantSettings = new System.Windows.Forms.TabPage(); + this.betaOptInCbox = new System.Windows.Forms.CheckBox(); this.booksGb = new System.Windows.Forms.GroupBox(); this.saveEpisodesToSeriesFolderCbox = new System.Windows.Forms.CheckBox(); this.tab2ImportLibrary = new System.Windows.Forms.TabPage(); @@ -71,6 +72,8 @@ this.folderTemplateTb = new System.Windows.Forms.TextBox(); this.folderTemplateLbl = new System.Windows.Forms.Label(); this.tab4AudioFileOptions = new System.Windows.Forms.TabPage(); + this.audiobookFixupsGb = new System.Windows.Forms.GroupBox(); + this.stripUnabridgedCbox = new System.Windows.Forms.CheckBox(); this.chapterTitleTemplateGb = new System.Windows.Forms.GroupBox(); this.chapterTitleTemplateBtn = new System.Windows.Forms.Button(); this.chapterTitleTemplateTb = new System.Windows.Forms.TextBox(); @@ -104,12 +107,10 @@ this.groupBox2 = new System.Windows.Forms.GroupBox(); this.lameTargetQualityRb = new System.Windows.Forms.RadioButton(); this.lameTargetBitrateRb = new System.Windows.Forms.RadioButton(); - this.stripUnabridgedCbox = new System.Windows.Forms.CheckBox(); this.mergeOpeningEndCreditsCbox = new System.Windows.Forms.CheckBox(); this.retainAaxFileCbox = new System.Windows.Forms.CheckBox(); this.downloadCoverArtCbox = new System.Windows.Forms.CheckBox(); this.createCueSheetCbox = new System.Windows.Forms.CheckBox(); - this.audiobookFixupsGb = new System.Windows.Forms.GroupBox(); this.badBookGb.SuspendLayout(); this.tabControl.SuspendLayout(); this.tab1ImportantSettings.SuspendLayout(); @@ -119,6 +120,7 @@ this.inProgressFilesGb.SuspendLayout(); this.customFileNamingGb.SuspendLayout(); this.tab4AudioFileOptions.SuspendLayout(); + this.audiobookFixupsGb.SuspendLayout(); this.chapterTitleTemplateGb.SuspendLayout(); this.lameOptionsGb.SuspendLayout(); this.lameBitrateGb.SuspendLayout(); @@ -126,7 +128,6 @@ this.lameQualityGb.SuspendLayout(); ((System.ComponentModel.ISupportInitialize)(this.lameVBRQualityTb)).BeginInit(); this.groupBox2.SuspendLayout(); - this.audiobookFixupsGb.SuspendLayout(); this.SuspendLayout(); // // booksLocationDescLbl @@ -374,6 +375,7 @@ // // tab1ImportantSettings // + this.tab1ImportantSettings.Controls.Add(this.betaOptInCbox); this.tab1ImportantSettings.Controls.Add(this.booksGb); this.tab1ImportantSettings.Controls.Add(this.logsBtn); this.tab1ImportantSettings.Controls.Add(this.loggingLevelCb); @@ -386,6 +388,16 @@ this.tab1ImportantSettings.Text = "Important settings"; this.tab1ImportantSettings.UseVisualStyleBackColor = true; // + // betaOptInCbox + // + this.betaOptInCbox.AutoSize = true; + this.betaOptInCbox.Location = new System.Drawing.Point(13, 358); + this.betaOptInCbox.Name = "betaOptInCbox"; + this.betaOptInCbox.Size = new System.Drawing.Size(107, 19); + this.betaOptInCbox.TabIndex = 6; + this.betaOptInCbox.Text = "[Opt in to Beta]"; + this.betaOptInCbox.UseVisualStyleBackColor = true; + // // booksGb // this.booksGb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left) @@ -621,6 +633,30 @@ this.tab4AudioFileOptions.Text = "Audio File Options"; this.tab4AudioFileOptions.UseVisualStyleBackColor = true; // + // audiobookFixupsGb + // + this.audiobookFixupsGb.Controls.Add(this.splitFilesByChapterCbox); + this.audiobookFixupsGb.Controls.Add(this.stripUnabridgedCbox); + this.audiobookFixupsGb.Controls.Add(this.convertLosslessRb); + this.audiobookFixupsGb.Controls.Add(this.convertLossyRb); + this.audiobookFixupsGb.Controls.Add(this.stripAudibleBrandingCbox); + this.audiobookFixupsGb.Location = new System.Drawing.Point(6, 143); + this.audiobookFixupsGb.Name = "audiobookFixupsGb"; + this.audiobookFixupsGb.Size = new System.Drawing.Size(403, 160); + this.audiobookFixupsGb.TabIndex = 19; + this.audiobookFixupsGb.TabStop = false; + this.audiobookFixupsGb.Text = "Audiobook Fix-ups"; + // + // stripUnabridgedCbox + // + this.stripUnabridgedCbox.AutoSize = true; + this.stripUnabridgedCbox.Location = new System.Drawing.Point(13, 47); + this.stripUnabridgedCbox.Name = "stripUnabridgedCbox"; + this.stripUnabridgedCbox.Size = new System.Drawing.Size(147, 19); + this.stripUnabridgedCbox.TabIndex = 13; + this.stripUnabridgedCbox.Text = "[StripUnabridged desc]"; + this.stripUnabridgedCbox.UseVisualStyleBackColor = true; + // // chapterTitleTemplateGb // this.chapterTitleTemplateGb.Controls.Add(this.chapterTitleTemplateBtn); @@ -977,16 +1013,6 @@ this.lameTargetBitrateRb.UseVisualStyleBackColor = true; this.lameTargetBitrateRb.CheckedChanged += new System.EventHandler(this.lameTargetRb_CheckedChanged); // - // stripUnabridgedCbox - // - this.stripUnabridgedCbox.AutoSize = true; - this.stripUnabridgedCbox.Location = new System.Drawing.Point(13, 47); - this.stripUnabridgedCbox.Name = "stripUnabridgedCbox"; - this.stripUnabridgedCbox.Size = new System.Drawing.Size(147, 19); - this.stripUnabridgedCbox.TabIndex = 13; - this.stripUnabridgedCbox.Text = "[StripUnabridged desc]"; - this.stripUnabridgedCbox.UseVisualStyleBackColor = true; - // // mergeOpeningEndCreditsCbox // this.mergeOpeningEndCreditsCbox.AutoSize = true; @@ -1034,20 +1060,6 @@ this.createCueSheetCbox.UseVisualStyleBackColor = true; this.createCueSheetCbox.CheckedChanged += new System.EventHandler(this.allowLibationFixupCbox_CheckedChanged); // - // audiobookFixupsGb - // - this.audiobookFixupsGb.Controls.Add(this.splitFilesByChapterCbox); - this.audiobookFixupsGb.Controls.Add(this.stripUnabridgedCbox); - this.audiobookFixupsGb.Controls.Add(this.convertLosslessRb); - this.audiobookFixupsGb.Controls.Add(this.convertLossyRb); - this.audiobookFixupsGb.Controls.Add(this.stripAudibleBrandingCbox); - this.audiobookFixupsGb.Location = new System.Drawing.Point(6, 143); - this.audiobookFixupsGb.Name = "audiobookFixupsGb"; - this.audiobookFixupsGb.Size = new System.Drawing.Size(403, 160); - this.audiobookFixupsGb.TabIndex = 19; - this.audiobookFixupsGb.TabStop = false; - this.audiobookFixupsGb.Text = "Audiobook Fix-ups"; - // // SettingsDialog // this.AcceptButton = this.saveBtn; @@ -1082,6 +1094,8 @@ this.customFileNamingGb.PerformLayout(); this.tab4AudioFileOptions.ResumeLayout(false); this.tab4AudioFileOptions.PerformLayout(); + this.audiobookFixupsGb.ResumeLayout(false); + this.audiobookFixupsGb.PerformLayout(); this.chapterTitleTemplateGb.ResumeLayout(false); this.chapterTitleTemplateGb.PerformLayout(); this.lameOptionsGb.ResumeLayout(false); @@ -1094,8 +1108,6 @@ ((System.ComponentModel.ISupportInitialize)(this.lameVBRQualityTb)).EndInit(); this.groupBox2.ResumeLayout(false); this.groupBox2.PerformLayout(); - this.audiobookFixupsGb.ResumeLayout(false); - this.audiobookFixupsGb.PerformLayout(); this.ResumeLayout(false); } @@ -1183,5 +1195,6 @@ private System.Windows.Forms.Button editCharreplacementBtn; private System.Windows.Forms.CheckBox mergeOpeningEndCreditsCbox; private System.Windows.Forms.GroupBox audiobookFixupsGb; + private System.Windows.Forms.CheckBox betaOptInCbox; } } \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs index 505edb43..a2379c70 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs @@ -22,6 +22,7 @@ namespace LibationWinForms.Dialogs } booksLocationDescLbl.Text = desc(nameof(config.Books)); + betaOptInCbox.Text = desc(nameof(config.BetaOptIn)); this.saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder)); booksSelectControl.SetSearchTitle("books location"); @@ -37,6 +38,10 @@ namespace LibationWinForms.Dialogs booksSelectControl.SelectDirectory(config.Books); saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder; + betaOptInCbox.Checked = config.BetaOptIn; + + if (!betaOptInCbox.Checked) + betaOptInCbox.CheckedChanged += betaOptInCbox_CheckedChanged; } private void Save_Important(Configuration config) @@ -88,6 +93,35 @@ namespace LibationWinForms.Dialogs } config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked; + + config.BetaOptIn = betaOptInCbox.Checked; + } + + + private void betaOptInCbox_CheckedChanged(object sender, EventArgs e) + { + if (!betaOptInCbox.Checked) + return; + + var result = MessageBox.Show(this, @" + + +You've chosen to opt-in to Libation's beta releases. Thank you! We need all the testers we can get. + +These features are works in progress and potentially very buggy. Libation may crash unexpectedly, and your library database may even be corruted. We suggest you back up your LibationContext.db file before proceding. + +If bad/weird things happen, please report them at getlibation.com. + +".Trim(), "A word of warning...", MessageBoxButtons.YesNo, MessageBoxIcon.Warning, MessageBoxDefaultButton.Button2); + + if (result == DialogResult.Yes) + { + betaOptInCbox.CheckedChanged -= betaOptInCbox_CheckedChanged; + } + else + { + betaOptInCbox.Checked = false; + } } } } diff --git a/Source/LibationWinForms/Form1.BackupCounts.cs b/Source/LibationWinForms/Form1.BackupCounts.cs index 0046cc61..f12c58ed 100644 --- a/Source/LibationWinForms/Form1.BackupCounts.cs +++ b/Source/LibationWinForms/Form1.BackupCounts.cs @@ -49,7 +49,7 @@ namespace LibationWinForms private void exportMenuEnable(object _, System.ComponentModel.RunWorkerCompletedEventArgs e) { var libraryStats = e.Result as LibraryCommands.LibraryStats; - exportLibraryToolStripMenuItem.Enabled = libraryStats.HasBookResults; + Invoke(() => exportLibraryToolStripMenuItem.Enabled = libraryStats.HasBookResults); } // this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index ae4628a4..8b7c6767 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -64,7 +64,6 @@ namespace LibationWinForms { if (this.DesignMode) return; - // I'm leaving this empty call here as a reminder that if we use this, it should probably be after DesignMode check } } diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index e7210ad0..0f30b8f5 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -31,27 +31,39 @@ namespace LibationWinForms.GridView #region Button controls private ImageDisplay imageDisplay; - private async void productsGrid_CoverClicked(GridEntry liveGridEntry) + private void productsGrid_CoverClicked(GridEntry liveGridEntry) { - var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); - var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition)); + var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native); + + void PictureCached(object sender, PictureCachedEventArgs e) + { + if (e.Definition.PictureId == picDef.PictureId) + imageDisplay.CoverPicture = e.Picture; + + PictureStorage.PictureCached -= PictureCached; + } + + PictureStorage.PictureCached += PictureCached; + (bool isDefault, byte[] initialImageBts) = PictureStorage.GetPicture(picDef); - (_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureId, PictureSize._80x80)); var windowTitle = $"{liveGridEntry.Title} - Cover"; if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible) { - imageDisplay = new ImageDisplay(); + imageDisplay = new GridView.ImageDisplay(); imageDisplay.RestoreSizeAndLocation(Configuration.Instance); imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance); - imageDisplay.Show(this); } imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook); imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg")); imageDisplay.Text = windowTitle; imageDisplay.CoverPicture = initialImageBts; - imageDisplay.CoverPicture = await picDlTask; + if (!isDefault) + PictureStorage.PictureCached -= PictureCached; + + if (!imageDisplay.Visible) + imageDisplay.Show(null); } private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle) diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 35996f27..f7905a4e 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -38,6 +38,7 @@ namespace LibationWinForms.GridView InitializeComponent(); EnableDoubleBuffering(); gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s); + removeGVColumn.Frozen = false; } private void EnableDoubleBuffering() @@ -115,6 +116,9 @@ namespace LibationWinForms.GridView foreach (var book in bindingList.AllItems()) book.Remove = RemoveStatus.NotRemoved; } + + removeGVColumn.DisplayIndex = 0; + removeGVColumn.Frozen = value; removeGVColumn.Visible = value; } } @@ -353,7 +357,9 @@ namespace LibationWinForms.GridView { var column = gridEntryDataGridView.Columns .Cast() - .Single(c => c.DataPropertyName == itemName); + .SingleOrDefault(c => c.DataPropertyName == itemName); + + if (column is null) continue; column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index); } diff --git a/Source/LibationWinForms/LibationWinForms.csproj b/Source/LibationWinForms/LibationWinForms.csproj index b15b714c..ed1695b9 100644 --- a/Source/LibationWinForms/LibationWinForms.csproj +++ b/Source/LibationWinForms/LibationWinForms.csproj @@ -36,10 +36,43 @@ ..\bin\Release embedded + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -51,15 +84,55 @@ Form1.cs - + SettingsDialog.cs + + + Form1.cs + + + + Form1.cs + + + + + + + MSBuild:Compile + + + + DescriptionDisplayDialog.axaml + + + EditQuickFilters.axaml + + + BookDetailsDialog.axaml + + + SearchSyntaxDialog.axaml + + + ImageDisplayDialog.axaml + + + ProcessBookControl.axaml + + + ProcessQueueControl.axaml + + + ProductsDisplay.axaml + True True @@ -74,4 +147,8 @@ + + + + \ No newline at end of file diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index fd933c32..2af09639 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -1,12 +1,14 @@ using System; -using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading.Tasks; using System.Windows.Forms; -using Dinah.Core; +using ApplicationServices; +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.ReactiveUI; using LibationFileManager; using LibationWinForms.Dialogs; -using Serilog; namespace LibationWinForms { @@ -18,6 +20,98 @@ namespace LibationWinForms [STAThread] static void Main() + { + var sw = System.Diagnostics.Stopwatch.StartNew(); + var config = LoadLibationConfig(); + + if (config is null) return; + + + var bmp = System.Drawing.SystemIcons.Error.ToBitmap(); + + /* + Results below compare startup times when parallelizing startup tasks vs when + running everything sequentially, from the entry point until after the call to + OnLoadedLibrary() returns. Tests were run on a ReadyToRun enabled release build. + + The first run is substantially slower than all subsequent runs for both serial + and parallel. This is most likely due to file system caching speeding up + subsequent runs, and it's significant because in the wild, most runs are "cold" + and will not benefit from caching. + + All times are in milliseconds. + + Run Parallel Serial + 1 2837 5835 + 2 1566 2774 + 3 1562 2316 + 4 1642 2388 + 5 1596 2391 + 6 1591 2358 + 7 1492 2363 + 8 1542 2335 + 9 1600 2418 + 10 1564 2359 + 11 1567 2379 + + Min 1492 2316 + Q1 1562 2358 + Med 1567 2379 + Q3 1600 2418 + Max 2837 5835 + */ + + void Form1_Load(object sender, EventArgs e) + { + sw.Stop(); + //MessageBox.Show(sw.ElapsedMilliseconds.ToString()); + } + + //For debug purposes, always run AvaloniaUI. + if (config.GetNonString("BetaOptIn")) + { + //Start as much work in parallel as possible. + var runDbMigrationsTask = Task.Run(() => RunDbMigrations(config)); + var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime()); + var appBuilderTask = Task.Run(BuildAvaloniaApp); + + if (!runDbMigrationsTask.GetAwaiter().GetResult()) + return; + + var runOtherMigrationsTask = Task.Run(() => RunOtherMigrations(config)); + var dbLibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + + appBuilderTask.GetAwaiter().GetResult().SetupWithLifetime(classicLifetimeTask.GetAwaiter().GetResult()); + + if (!runOtherMigrationsTask.GetAwaiter().GetResult()) + return; + + var form1 = (AvaloniaUI.Views.MainWindow)classicLifetimeTask.Result.MainWindow; + form1.Opened += Form1_Load; + + form1.OnLibraryLoaded(dbLibraryTask.GetAwaiter().GetResult()); + + classicLifetimeTask.Result.Start(null); + } + else + { + if (!RunDbMigrations(config) || !RunOtherMigrations(config)) + return; + + var form1 = new Form1(); + form1.Shown += Form1_Load; + + System.Windows.Forms.Application.Run(form1); + } + } + + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .LogToTrace() + .UseReactiveUI(); + + private static Configuration LoadLibationConfig() { try { @@ -26,7 +120,7 @@ namespace LibationWinForms //AllocConsole(); // run as early as possible. see notes in postLoggingGlobalExceptionHandling - Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); + System.Windows.Forms.Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); ApplicationConfiguration.Initialize(); @@ -37,13 +131,40 @@ namespace LibationWinForms //***********************************************// // Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration var config = AppScaffolding.LibationScaffolding.RunPreConfigMigrations(); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + return config; + } + catch (Exception ex) + { + DisplayStartupErrorMessage(ex); + return null; + } + } + + private static bool RunDbMigrations(Configuration config) + { + try + { // do this as soon as possible (post-config) RunInstaller(config); // most migrations go in here - AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); + AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); + return true; + } + catch (Exception ex) + { + DisplayStartupErrorMessage(ex); + return false; + } + } + + private static bool RunOtherMigrations(Configuration config) + { + try + { // migrations which require Forms or are long-running RunWindowsOnlyMigrations(config); @@ -54,26 +175,36 @@ namespace LibationWinForms #endif // logging is init'd here AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(config); + + // global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd +#if WINDOWS7_0_OR_GREATER + postLoggingGlobalExceptionHandling(); +#endif + + return true; } catch (Exception ex) { - var title = "Fatal error, pre-logging"; - var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; - try - { - MessageBoxLib.ShowAdminAlert(null, body, title, ex); - } - catch - { - MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - return; - } + DisplayStartupErrorMessage(ex); + return false; + } + } - // global exception handling (ShowAdminAlert) attempts to use logging. only call it after logging has been init'd - postLoggingGlobalExceptionHandling(); - Application.Run(new Form1()); + private static void DisplayStartupErrorMessage(Exception ex) + { + var title = "Fatal error, pre-logging"; + var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; +#if WINDOWS7_0_OR_GREATER + try + { + MessageBoxLib.ShowAdminAlert(null, body, title, ex); + } + catch + { + MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); + } +#endif } private static void RunInstaller(Configuration config) @@ -98,7 +229,7 @@ namespace LibationWinForms static void CancelInstallation() { MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Application.Exit(); + System.Windows.Forms.Application.Exit(); Environment.Exit(0); } @@ -176,13 +307,17 @@ namespace LibationWinForms } catch (Exception ex) { +#if WINDOWS7_0_OR_GREATER MessageBoxLib.ShowAdminAlert(null, "Error checking for update", "Error checking for update", ex); +#endif return; } if (upgradeProperties.ZipUrl is null) { +#if WINDOWS7_0_OR_GREATER MessageBox.Show(upgradeProperties.HtmlUrl, "New version available"); +#endif return; } @@ -195,7 +330,7 @@ namespace LibationWinForms AppDomain.CurrentDomain.UnhandledException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has crashed due to an unhandled error.", "Application crash!", (Exception)e.ExceptionObject); // these 2 lines makes it graceful. sync (eg in main form's ctor) and thread exceptions will still crash us, but event (sync, void async, Task async) will not - Application.ThreadException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has encountered an unexpected error.", "Unexpected error", e.Exception); + System.Windows.Forms.Application.ThreadException += (_, e) => MessageBoxLib.ShowAdminAlert(null, "Libation has encountered an unexpected error.", "Unexpected error", e.Exception); // move to beginning of execution. crashes app if this is called post-RunInstaller: System.InvalidOperationException: 'Thread exception mode cannot be changed once any Controls are created on the thread.' //// I never found a case where including made a difference. I think this enum is default and including it will override app user config file //Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException); diff --git a/Source/LibationWinForms/Properties/DataSources/LibationWinForm.RemovableGridEntry.datasource b/Source/LibationWinForms/Properties/DataSources/LibationWinForm.RemovableGridEntry.datasource deleted file mode 100644 index da01a0bf..00000000 --- a/Source/LibationWinForms/Properties/DataSources/LibationWinForm.RemovableGridEntry.datasource +++ /dev/null @@ -1,10 +0,0 @@ - - - - LibationWinForms.Dialogs.RemovableGridEntry, LibationWinForms, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null - \ No newline at end of file