From c4827fc76165d24414c5dbdbdeaeb3b96ef9d220 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 4 Mar 2025 12:53:34 -0700 Subject: [PATCH 1/4] Add error logging --- Source/AppScaffolding/AppScaffolding.csproj | 2 +- Source/LibationAvalonia/ViewModels/MainVM.Filters.cs | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index 9f5ab14e..b89e90ee 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -2,7 +2,7 @@ net9.0 - 12.0.2.1 + 12.0.2.2 diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs index 4c9433bb..640ff4d8 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs @@ -66,6 +66,7 @@ namespace LibationAvalonia.ViewModels } catch (Exception ex) { + Serilog.Log.Logger.Error(ex, "Error performing filtering."); await MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); // re-apply last good filter From 7658f21d7ca990e5274caef9757370dbb9129b9b Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 4 Mar 2025 15:07:37 -0700 Subject: [PATCH 2/4] Fix tags font color in dark mode --- .../Dialogs/EditTemplateDialog.axaml | 4 +- .../Dialogs/EditTemplateDialog.axaml.cs | 288 +++++++++--------- 2 files changed, 146 insertions(+), 146 deletions(-) diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml index cd5a9ba6..9b29ec9e 100644 --- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml @@ -51,7 +51,7 @@ - + @@ -59,7 +59,7 @@ - diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs index 12d145a1..3a7010b4 100644 --- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs @@ -1,8 +1,8 @@ using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Controls.Documents; -using Avalonia.Markup.Xaml; using Avalonia.Media; +using Avalonia.Styling; using Dinah.Core; using LibationFileManager; using ReactiveUI; @@ -11,175 +11,175 @@ using System.IO; using System.Linq; using System.Threading.Tasks; -namespace LibationAvalonia.Dialogs +namespace LibationAvalonia.Dialogs; + +public partial class EditTemplateDialog : DialogWindow { - public partial class EditTemplateDialog : DialogWindow + private EditTemplateViewModel _viewModel; + + public EditTemplateDialog() { - private EditTemplateViewModel _viewModel; + InitializeComponent(); - public EditTemplateDialog() + if (Design.IsDesignMode) { - InitializeComponent(); - - if (Design.IsDesignMode) - { - _ = Configuration.Instance.LibationFiles; - var editor = TemplateEditor.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate); - _viewModel = new(Configuration.Instance, editor); - _viewModel.ResetTextBox(editor.EditingTemplate.TemplateText); - Title = $"Edit {editor.TemplateName}"; - DataContext = _viewModel; - } - } - - public EditTemplateDialog(ITemplateEditor templateEditor) : this() - { - ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor)); - - _viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor); - _viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText); - Title = $"Edit {templateEditor.TemplateName}"; + _ = Configuration.Instance.LibationFiles; + RequestedThemeVariant = ThemeVariant.Dark; + var editor = TemplateEditor.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate); + _viewModel = new(Configuration.Instance, editor); + _viewModel.ResetTextBox(editor.EditingTemplate.TemplateText); + Title = $"Edit {editor.TemplateName}"; DataContext = _viewModel; } + } + + public EditTemplateDialog(ITemplateEditor templateEditor) : this() + { + ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor)); + + _viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor); + _viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText); + Title = $"Edit {templateEditor.TemplateName}"; + DataContext = _viewModel; + } - public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e) + public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e) + { + var dataGrid = sender as DataGrid; + + var item = (dataGrid.SelectedItem as Tuple).Item3; + if (string.IsNullOrWhiteSpace(item)) return; + + var text = userEditTbox.Text; + + userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item); + userEditTbox.CaretIndex += item.Length; + } + + protected override async Task SaveAndCloseAsync() + { + if (!await _viewModel.Validate()) + return; + + await base.SaveAndCloseAsync(); + } + + public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => await SaveAndCloseAsync(); + + private class EditTemplateViewModel : ViewModels.ViewModelBase + { + private readonly Configuration config; + public InlineCollection Inlines { get; } = new(); + public ITemplateEditor TemplateEditor { get; } + public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates) { - var dataGrid = sender as DataGrid; + config = configuration; + TemplateEditor = templates; + Description = templates.TemplateDescription; + ListItems + = new AvaloniaList>( + TemplateEditor + .EditingTemplate + .TagsRegistered + .Cast() + .Select( + t => new Tuple( + $"<{t.TagName}>", + t.Description, + t.DefaultValue) + ) + ); - var item = (dataGrid.SelectedItem as Tuple).Item3; - if (string.IsNullOrWhiteSpace(item)) return; - - var text = userEditTbox.Text; - - userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item); - userEditTbox.CaretIndex += item.Length; } - protected override async Task SaveAndCloseAsync() + // hold the work-in-progress value. not guaranteed to be valid + private string _userTemplateText; + public string UserTemplateText { - if (!await _viewModel.Validate()) - return; - - await base.SaveAndCloseAsync(); - } - - public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) - => await SaveAndCloseAsync(); - - private class EditTemplateViewModel : ViewModels.ViewModelBase - { - private readonly Configuration config; - public InlineCollection Inlines { get; } = new(); - public ITemplateEditor TemplateEditor { get; } - public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates) + get => _userTemplateText; + set { - config = configuration; - TemplateEditor = templates; - Description = templates.TemplateDescription; - ListItems - = new AvaloniaList>( + this.RaiseAndSetIfChanged(ref _userTemplateText, value); + templateTb_TextChanged(); + } + } + + private string _warningText; + public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); } + + public string Description { get; } + + public AvaloniaList> ListItems { get; set; } + + public void ResetTextBox(string value) => UserTemplateText = value; + public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate); + + public async Task Validate() + { + if (TemplateEditor.EditingTemplate.IsValid) + return true; + + var errors + = TemplateEditor + .EditingTemplate + .Errors + .Select(err => $"- {err}") + .Aggregate((a, b) => $"{a}\r\n{b}"); + await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); + return false; + } + + private void templateTb_TextChanged() + { + TemplateEditor.SetTemplateText(UserTemplateText); + + const char ZERO_WIDTH_SPACE = '\u200B'; + var sing = $"{Path.DirectorySeparatorChar}"; + + // result: can wrap long paths. eg: + // |-- LINE WRAP BOUNDARIES --| + // \books\author with a very <= normal line break on space between words + // long name\narrator narrator + // \title <= line break on the zero-with space we added before slashes + string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); + + WarningText + = !TemplateEditor.EditingTemplate.HasWarnings + ? "" + : "Warning:\r\n" + TemplateEditor .EditingTemplate - .TagsRegistered - .Cast() - .Select( - t => new Tuple( - $"<{t.TagName}>", - t.Description, - t.DefaultValue) - ) - ); + .Warnings + .Select(err => $"- {err}") + .Aggregate((a, b) => $"{a}\r\n{b}"); - } + var bold = FontWeight.Bold; + var reg = FontWeight.Normal; - // hold the work-in-progress value. not guaranteed to be valid - private string _userTemplateText; - public string UserTemplateText + Inlines.Clear(); + + if (!TemplateEditor.IsFilePath) { - get => _userTemplateText; - set - { - this.RaiseAndSetIfChanged(ref _userTemplateText, value); - templateTb_TextChanged(); - } + Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold }); + return; } - private string _warningText; - public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); } + var folder = TemplateEditor.GetFolderName(); + var file = TemplateEditor.GetFileName(); + var ext = config.DecryptToLossy ? "mp3" : "m4b"; - public string Description { get; } + Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg }); + Inlines.Add(new Run(sing) { FontWeight = reg }); - public AvaloniaList> ListItems { get; set; } + Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg }); - public void ResetTextBox(string value) => UserTemplateText = value; - public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate); + Inlines.Add(new Run(sing)); - public async Task Validate() - { - if (TemplateEditor.EditingTemplate.IsValid) - return true; + Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold }); - var errors - = TemplateEditor - .EditingTemplate - .Errors - .Select(err => $"- {err}") - .Aggregate((a, b) => $"{a}\r\n{b}"); - await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error); - return false; - } - - private void templateTb_TextChanged() - { - TemplateEditor.SetTemplateText(UserTemplateText); - - const char ZERO_WIDTH_SPACE = '\u200B'; - var sing = $"{Path.DirectorySeparatorChar}"; - - // result: can wrap long paths. eg: - // |-- LINE WRAP BOUNDARIES --| - // \books\author with a very <= normal line break on space between words - // long name\narrator narrator - // \title <= line break on the zero-with space we added before slashes - string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}"); - - WarningText - = !TemplateEditor.EditingTemplate.HasWarnings - ? "" - : "Warning:\r\n" + - TemplateEditor - .EditingTemplate - .Warnings - .Select(err => $"- {err}") - .Aggregate((a, b) => $"{a}\r\n{b}"); - - var bold = FontWeight.Bold; - var reg = FontWeight.Normal; - - Inlines.Clear(); - - if (!TemplateEditor.IsFilePath) - { - Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold }); - return; - } - - var folder = TemplateEditor.GetFolderName(); - var file = TemplateEditor.GetFileName(); - var ext = config.DecryptToLossy ? "mp3" : "m4b"; - - Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg }); - Inlines.Add(new Run(sing) { FontWeight = reg }); - - Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg }); - - Inlines.Add(new Run(sing)); - - Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold }); - - Inlines.Add(new Run($".{ext}")); - } + Inlines.Add(new Run($".{ext}")); } } } From c3938c49a9690e75e69f0249e8539af43d8a8459 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 4 Mar 2025 15:11:05 -0700 Subject: [PATCH 3/4] Additional null safety --- Source/ApplicationServices/LibraryCommands.cs | 33 +++-- Source/LibationAvalonia/AvaloniaUtils.cs | 9 +- Source/LibationAvalonia/FormSaveExtension.cs | 26 ++-- Source/LibationAvalonia/Program.cs | 28 ++-- .../ViewModels/AvaloniaEntryStatus.cs | 3 +- .../LiberateStatusButtonViewModel.cs | 1 + .../ViewModels/MainVM.BackupCounts.cs | 15 +- .../ViewModels/MainVM.Export.cs | 5 +- .../ViewModels/MainVM.Filters.cs | 38 ++++-- .../ViewModels/MainVM.Import.cs | 44 +++--- .../ViewModels/MainVM.Liberate.cs | 1 + .../ViewModels/MainVM.ProcessQueue.cs | 1 + .../ViewModels/MainVM.ScanAuto.cs | 11 +- .../ViewModels/MainVM.Settings.cs | 5 +- .../ViewModels/MainVM.VisibleBooks.cs | 5 +- .../ViewModels/MainVM._NoUI.cs | 1 + Source/LibationAvalonia/ViewModels/MainVM.cs | 16 ++- .../ViewModels/ProcessBookViewModel.cs | 59 ++++---- .../ViewModels/ProcessQueueViewModel.cs | 30 ++-- .../ViewModels/ProductsDisplayViewModel.cs | 12 +- .../ViewModels/RowComparer.cs | 13 +- .../ViewModels/Settings/AudioSettingsVM.cs | 15 +- .../Settings/DownloadDecryptSettingsVM.cs | 34 ++--- .../ViewModels/Settings/ImportSettingsVM.cs | 6 +- .../Settings/ImportantSettingsVM.cs | 30 ++-- .../Configuration.HelpText.cs | 8 +- .../Configuration.PersistentSettings.cs | 21 +-- Source/LibationFileManager/QuickFilters.cs | 2 +- .../GridView/RowComparerBase.cs | 129 +++++++++--------- 29 files changed, 326 insertions(+), 275 deletions(-) diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index 45556dfb..d8a9d2d2 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -15,12 +15,13 @@ using Newtonsoft.Json.Linq; using Serilog; using static DtoImporterService.PerfLogger; +#nullable enable namespace ApplicationServices { public static class LibraryCommands { - public static event EventHandler ScanBegin; - public static event EventHandler ScanEnd; + public static event EventHandler? ScanBegin; + public static event EventHandler? ScanEnd; public static bool Scanning { get; private set; } private static object _lock { get; } = new(); @@ -100,7 +101,7 @@ namespace ApplicationServices } #region FULL LIBRARY scan and import - public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func> apiExtendedfunc, params Account[] accounts) + public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func> apiExtendedfunc, params Account[]? accounts) { logRestart(); @@ -236,7 +237,7 @@ namespace ApplicationServices { var tasks = new List>>(); - await using LogArchiver archiver + await using LogArchiver? archiver = Log.Logger.IsDebugEnabled() ? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip")) : default; @@ -266,13 +267,13 @@ namespace ApplicationServices return importItems; } - private static async Task> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver) + private static async Task> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver) { ArgumentValidator.EnsureNotNull(account, nameof(account)); Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new { - Account = account?.MaskedLogEntry ?? "[null]" + Account = account.MaskedLogEntry ?? "[null]" }); logTime($"pre scanAccountAsync {account.AccountName}"); @@ -294,7 +295,7 @@ namespace ApplicationServices throw new AggregateException(ex.InnerExceptions); } - async Task logDtoItemsAsync(IEnumerable dtoItems, IEnumerable exceptions = null) + async Task logDtoItemsAsync(IEnumerable dtoItems, IEnumerable? exceptions = null) { if (archiver is not null) { @@ -452,28 +453,28 @@ namespace ApplicationServices } /// Occurs when the size of the library changes. ie: books are added or removed - public static event EventHandler> LibrarySizeChanged; + public static event EventHandler>? LibrarySizeChanged; /// /// Occurs when the size of the library does not change but book(s) details do. Especially when , , or changed values are successfully persisted. /// - public static event EventHandler> BookUserDefinedItemCommitted; + public static event EventHandler>? BookUserDefinedItemCommitted; #region Update book details public static int UpdateUserDefinedItem( this LibraryBook lb, - string tags = null, + string? tags = null, LiberatedStatus? bookStatus = null, LiberatedStatus? pdfStatus = null, - Rating rating = null) + Rating? rating = null) => new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating); public static int UpdateUserDefinedItem( this IEnumerable lb, - string tags = null, + string? tags = null, LiberatedStatus? bookStatus = null, LiberatedStatus? pdfStatus = null, - Rating rating = null) + Rating? rating = null) => updateUserDefinedItem( lb, udi => { @@ -532,7 +533,8 @@ namespace ApplicationServices var udiEntity = context.Entry(book.Book.UserDefinedItem); udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified; - udiEntity.Reference(udi => udi.Rating).TargetEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified; + if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry ratingEntry) + ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified; } var qtyChanges = context.SaveChanges(); @@ -598,7 +600,8 @@ namespace ApplicationServices return sb.ToString(); } } - public static LibraryStats GetCounts(IEnumerable libraryBooks = null) + + public static LibraryStats GetCounts(IEnumerable? libraryBooks = null) { libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(); diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index a825eace..714491bf 100644 --- a/Source/LibationAvalonia/AvaloniaUtils.cs +++ b/Source/LibationAvalonia/AvaloniaUtils.cs @@ -6,6 +6,7 @@ using LibationAvalonia.Dialogs; using LibationFileManager; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia { internal static class AvaloniaUtils @@ -14,18 +15,18 @@ namespace LibationAvalonia => GetBrushFromResources(name, Brushes.Transparent); public static IBrush GetBrushFromResources(string name, IBrush defaultBrush) { - if (App.Current.TryGetResource(name, App.Current.ActualThemeVariant, out var value) && value is IBrush brush) + if ((App.Current?.TryGetResource(name, App.Current.ActualThemeVariant, out var value) ?? false) && value is IBrush brush) return brush; return defaultBrush; } - public static Task ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null) + public static Task ShowDialogAsync(this DialogWindow dialogWindow, Window? owner = null) => dialogWindow.ShowDialog(owner ?? App.MainWindow); - public static Window GetParentWindow(this Control control) => control.GetVisualRoot() as Window; + public static Window? GetParentWindow(this Control control) => control.GetVisualRoot() as Window; - private static Bitmap defaultImage; + private static Bitmap? defaultImage; public static Bitmap TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native) { try diff --git a/Source/LibationAvalonia/FormSaveExtension.cs b/Source/LibationAvalonia/FormSaveExtension.cs index f0e239f5..7683ceb1 100644 --- a/Source/LibationAvalonia/FormSaveExtension.cs +++ b/Source/LibationAvalonia/FormSaveExtension.cs @@ -6,17 +6,17 @@ using LibationFileManager; using System; using System.Linq; +#nullable enable namespace LibationAvalonia { public static class FormSaveExtension { - static readonly WindowIcon WindowIcon; + static readonly WindowIcon? WindowIcon; static FormSaveExtension() { - if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow is not null) - WindowIcon = desktop.MainWindow.Icon; - else - WindowIcon = null; + WindowIcon = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow?.Icon is WindowIcon icon + ? icon + : null; } public static void SetLibationIcon(this Window form) @@ -29,7 +29,7 @@ namespace LibationAvalonia if (Design.IsDesignMode) return; try { - var savedState = config.GetNonString(defaultValue: null, form.GetType().Name); + var savedState = config.GetNonString(defaultValue: null, form.GetType().Name); if (savedState is null) return; @@ -40,12 +40,14 @@ namespace LibationAvalonia 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; + if (form.Screens.Primary is Screen primaryScreen) + { + // Fit to the current screen size in case the screen resolution changed since the size was last persisted + if (savedState.Width > primaryScreen.WorkingArea.Width) + savedState.Width = primaryScreen.WorkingArea.Width; + if (savedState.Height > primaryScreen.WorkingArea.Height) + savedState.Height = primaryScreen.WorkingArea.Height; + } var rect = new PixelRect(savedState.X, savedState.Y, savedState.Width, savedState.Height); diff --git a/Source/LibationAvalonia/Program.cs b/Source/LibationAvalonia/Program.cs index 1516af80..ef5d6c61 100644 --- a/Source/LibationAvalonia/Program.cs +++ b/Source/LibationAvalonia/Program.cs @@ -5,10 +5,10 @@ using System.Threading.Tasks; using ApplicationServices; using AppScaffolding; using Avalonia; -using Avalonia.Controls.ApplicationLifetimes; using Avalonia.ReactiveUI; using LibationFileManager; +#nullable enable namespace LibationAvalonia { static class Program @@ -57,7 +57,7 @@ namespace LibationAvalonia App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); } - BuildAvaloniaApp().StartWithClassicDesktopLifetime(null); + BuildAvaloniaApp().StartWithClassicDesktopLifetime([]); } catch (Exception ex) { @@ -77,27 +77,27 @@ namespace LibationAvalonia private static void LogError(object exceptionObject) { var logError = $""" - {DateTime.Now} - Libation Crash - OS {Configuration.OS} - Version {LibationScaffolding.BuildVersion} - ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier} - InteropFunctionsType {InteropFactory.InteropFunctionsType} - LibationFiles {getConfigValue(c => c.LibationFiles)} - Books Folder {getConfigValue(c => c.Books)} - === EXCEPTION === - {exceptionObject} - """; + {DateTime.Now} - Libation Crash + OS {Configuration.OS} + Version {LibationScaffolding.BuildVersion} + ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier} + InteropFunctionsType {InteropFactory.InteropFunctionsType} + LibationFiles {getConfigValue(c => c.LibationFiles)} + Books Folder {getConfigValue(c => c.Books)} + === EXCEPTION === + {exceptionObject} + """; var crashLog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log"); using var sw = new StreamWriter(crashLog, true); sw.WriteLine(logError); - static string getConfigValue(Func selector) + static string getConfigValue(Func selector) { try { - return selector(Configuration.Instance); + return selector(Configuration.Instance) ?? "[null]"; } catch (Exception ex) { diff --git a/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs b/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs index d02429db..9045e783 100644 --- a/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs +++ b/Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs @@ -4,6 +4,7 @@ using DataLayer; using LibationUiBase.GridView; using System; +#nullable enable namespace LibationAvalonia.ViewModels { public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable @@ -17,6 +18,6 @@ namespace LibationAvalonia.ViewModels => AvaloniaUtils.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80); //Button icons are handled by LiberateStatusButton - protected override Bitmap GetResourceImage(string rescName) => null; + protected override Bitmap? GetResourceImage(string rescName) => null; } } diff --git a/Source/LibationAvalonia/ViewModels/LiberateStatusButtonViewModel.cs b/Source/LibationAvalonia/ViewModels/LiberateStatusButtonViewModel.cs index c5e8e148..f1342600 100644 --- a/Source/LibationAvalonia/ViewModels/LiberateStatusButtonViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/LiberateStatusButtonViewModel.cs @@ -1,5 +1,6 @@ using ReactiveUI; +#nullable enable namespace LibationAvalonia.ViewModels { public class LiberateStatusButtonViewModel : ViewModelBase diff --git a/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs b/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs index 7b598c5c..8060552a 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs @@ -6,12 +6,13 @@ using ReactiveUI; using System.Collections.Generic; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM { - private Task updateCountsTask; - private LibraryCommands.LibraryStats _libraryStats; + private Task? updateCountsTask; + private LibraryCommands.LibraryStats? _libraryStats; /// The "Begin Book and PDF Backup" menu item header text public string BookBackupsToolStripText { get; private set; } = "Begin Book and PDF Backups: 0"; @@ -19,7 +20,7 @@ namespace LibationAvalonia.ViewModels public string PdfBackupsToolStripText { get; private set; } = "Begin PDF Only Backups: 0"; /// The user's library statistics - public LibraryCommands.LibraryStats LibraryStats + public LibraryCommands.LibraryStats? LibraryStats { get => _libraryStats; set @@ -27,12 +28,12 @@ namespace LibationAvalonia.ViewModels this.RaiseAndSetIfChanged(ref _libraryStats, value); BookBackupsToolStripText - = LibraryStats.HasPendingBooks + = LibraryStats?.HasPendingBooks ?? false ? "Begin " + menufyText($"Book and PDF Backups: {LibraryStats.PendingBooks} remaining") : "All books have been liberated"; PdfBackupsToolStripText - = LibraryStats.pdfsNotDownloaded > 0 + = LibraryStats?.pdfsNotDownloaded > 0 ? "Begin " + menufyText($"PDF Only Backups: {LibraryStats.pdfsNotDownloaded} remaining") : "All PDFs have been downloaded"; @@ -48,14 +49,14 @@ namespace LibationAvalonia.ViewModels => await SetBackupCountsAsync(null); } - public async Task SetBackupCountsAsync(IEnumerable libraryBooks) + public async Task SetBackupCountsAsync(IEnumerable? libraryBooks) { if (updateCountsTask?.IsCompleted ?? true) { updateCountsTask = Task.Run(() => LibraryCommands.GetCounts(libraryBooks)); var stats = await updateCountsTask; await Dispatcher.UIThread.InvokeAsync(() => LibraryStats = stats); - + if (Configuration.Instance.AutoDownloadEpisodes && stats.booksNoProgress + stats.pdfsNotDownloaded > 0) await Dispatcher.UIThread.InvokeAsync(BackupAllBooks); diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Export.cs b/Source/LibationAvalonia/ViewModels/MainVM.Export.cs index c29f90ce..e7ec6830 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Export.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Export.cs @@ -5,6 +5,7 @@ using LibationFileManager; using System; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM @@ -18,7 +19,7 @@ namespace LibationAvalonia.ViewModels var options = new FilePickerSaveOptions { Title = "Where to export Library", - SuggestedStartLocation = await MainWindow.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix), + SuggestedStartLocation = await MainWindow.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? Configuration.DefaultBooksDirectory), SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}", DefaultExtension = "xlsx", ShowOverwritePrompt = true, @@ -41,7 +42,7 @@ namespace LibationAvalonia.ViewModels AppleUniformTypeIdentifiers = new[] { "public.json" } }, new("All files (*.*)") { Patterns = new[] { "*" } } - } + } }; var selectedFile = (await MainWindow.StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath(); diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs index 640ff4d8..7a7acf69 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Filters.cs @@ -9,16 +9,17 @@ using System; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM { - private QuickFilters.NamedFilter lastGoodFilter = new(string.Empty, null); - private QuickFilters.NamedFilter _selectedNamedFilter = new(string.Empty, null); + private QuickFilters.NamedFilter? lastGoodFilter = new(string.Empty, null); + private QuickFilters.NamedFilter? _selectedNamedFilter = new(string.Empty, null); private bool _firstFilterIsDefault = true; /// Library filterting query - public QuickFilters.NamedFilter SelectedNamedFilter { get => _selectedNamedFilter; set => this.RaiseAndSetIfChanged(ref _selectedNamedFilter, value); } + public QuickFilters.NamedFilter? SelectedNamedFilter { get => _selectedNamedFilter; set => this.RaiseAndSetIfChanged(ref _selectedNamedFilter, value); } public AvaloniaList QuickFilterMenuItems { get; } = new(); /// Indicates if the first quick filter is the default filter public bool FirstFilterIsDefault { get => _firstFilterIsDefault; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); } @@ -50,37 +51,44 @@ namespace LibationAvalonia.ViewModels QuickFilterMenuItems.Add(new Separator()); } - public void AddQuickFilterBtn() => QuickFilters.Add(SelectedNamedFilter); + public void AddQuickFilterBtn() { if (SelectedNamedFilter != null) QuickFilters.Add(SelectedNamedFilter); } public async Task FilterBtn() => await PerformFilter(SelectedNamedFilter); public async Task FilterHelpBtn() => await new LibationAvalonia.Dialogs.SearchSyntaxDialog().ShowDialog(MainWindow); public void ToggleFirstFilterIsDefault() => FirstFilterIsDefault = !FirstFilterIsDefault; public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow); - public async Task PerformFilter(QuickFilters.NamedFilter namedFilter) + public async Task PerformFilter(QuickFilters.NamedFilter? namedFilter) { SelectedNamedFilter = namedFilter; + var tryFilter = namedFilter?.Filter; try { - await ProductsDisplay.Filter(namedFilter.Filter); + await ProductsDisplay.Filter(tryFilter); lastGoodFilter = namedFilter; } catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "Error performing filtering."); - await MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); + Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter); + await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error); // re-apply last good filter await PerformFilter(lastGoodFilter); } } - private void updateFiltersMenu(object _ = null, object __ = null) + private void updateFiltersMenu(object? _ = null, object? __ = null) { - //Clear all filters - var quickFilterNativeMenu = (NativeMenuItem)NativeMenu.GetMenu(MainWindow).Items[3]; - for (int i = quickFilterNativeMenu.Menu.Items.Count - 1; i >= 3; i--) + if (NativeMenu.GetMenu(MainWindow)?.Items[3] is not NativeMenuItem ss || + ss.Menu is not NativeMenu quickFilterNativeMenu) { - var command = ((NativeMenuItem)quickFilterNativeMenu.Menu.Items[i]).Command as IDisposable; + Serilog.Log.Logger.Error($"Unable to find {nameof(quickFilterNativeMenu)}"); + return; + } + + //Clear all filters + for (int i = quickFilterNativeMenu.Items.Count - 1; i >= 3; i--) + { + var command = ((NativeMenuItem)quickFilterNativeMenu.Items[i]).Command as IDisposable; if (command != null) { var existingBinding = MainWindow.KeyBindings.FirstOrDefault(kb => kb.Command == command); @@ -90,7 +98,7 @@ namespace LibationAvalonia.ViewModels command.Dispose(); } - quickFilterNativeMenu.Menu.Items.RemoveAt(i); + quickFilterNativeMenu.Items.RemoveAt(i); QuickFilterMenuItems.RemoveAt(i); } @@ -117,7 +125,7 @@ namespace LibationAvalonia.ViewModels } QuickFilterMenuItems.Add(menuItem); - quickFilterNativeMenu.Menu.Items.Add(nativeMenuItem); + quickFilterNativeMenu.Items.Add(nativeMenuItem); } } } diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs index 1bae061f..2a39fee7 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Import.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Import.cs @@ -8,6 +8,7 @@ using System.Linq; using System.Threading.Tasks; using Avalonia.Input; +#nullable enable namespace LibationAvalonia.ViewModels { public partial class MainVM @@ -90,7 +91,9 @@ namespace LibationAvalonia.ViewModels public async Task ScanAccountAsync() { using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - await scanLibrariesAsync(persister.AccountsSettings.GetAll().FirstOrDefault()); + var firstAccount = persister.AccountsSettings.GetAll().FirstOrDefault(); + if (firstAccount != null) + await scanLibrariesAsync(firstAccount); } public async Task ScanAllAccountsAsync() @@ -194,7 +197,7 @@ namespace LibationAvalonia.ViewModels await ProductsDisplay.ScanAndRemoveBooksAsync(accounts); } - private async Task scanLibrariesAsync(params Account[] accounts) + private async Task scanLibrariesAsync(params Account[]? accounts) { try { @@ -218,37 +221,44 @@ namespace LibationAvalonia.ViewModels } } - private void refreshImportMenu(object _ = null, EventArgs __ = null) + private void refreshImportMenu(object? _ = null, EventArgs? __ = null) { using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); AccountsCount = persister.AccountsSettings.Accounts.Count; - var importMenuItem = (NativeMenuItem)NativeMenu.GetMenu(MainWindow).Items[0]; - for (int i = importMenuItem.Menu.Items.Count - 1; i >= 2; i--) - importMenuItem.Menu.Items.RemoveAt(i); + if (NativeMenu.GetMenu(MainWindow)?.Items[0] is not NativeMenuItem ss || + ss.Menu is not NativeMenu importMenuItem) + { + Serilog.Log.Logger.Error($"Unable to find {nameof(importMenuItem)}"); + return; + } + + + for (int i = importMenuItem.Items.Count - 1; i >= 2; i--) + importMenuItem.Items.RemoveAt(i); if (AccountsCount < 1) { - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "No accounts yet. Add Account...", Command = ReactiveCommand.Create(AddAccountsAsync) }); + importMenuItem.Items.Add(new NativeMenuItem { Header = "No accounts yet. Add Account...", Command = ReactiveCommand.Create(AddAccountsAsync) }); } else if (AccountsCount == 1) { - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Scan Library", Command = ReactiveCommand.Create(ScanAccountAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta)}); - importMenuItem.Menu.Items.Add(new NativeMenuItemSeparator()); - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Remove Library Books", Command = ReactiveCommand.Create(RemoveBooksAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta)}); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Scan Library", Command = ReactiveCommand.Create(ScanAccountAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta) }); + importMenuItem.Items.Add(new NativeMenuItemSeparator()); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Remove Library Books", Command = ReactiveCommand.Create(RemoveBooksAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta) }); } else { - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Scan Library of All Accounts", Command = ReactiveCommand.Create(ScanAllAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta)}); - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Scan Library of Some Accounts", Command = ReactiveCommand.Create(ScanSomeAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) }); - importMenuItem.Menu.Items.Add(new NativeMenuItemSeparator()); - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Remove Books from All Accounts", Command = ReactiveCommand.Create(RemoveBooksAllAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta)}); - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Remove Books from Some Accounts", Command = ReactiveCommand.Create(RemoveBooksSomeAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) }); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Scan Library of All Accounts", Command = ReactiveCommand.Create(ScanAllAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta) }); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Scan Library of Some Accounts", Command = ReactiveCommand.Create(ScanSomeAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) }); + importMenuItem.Items.Add(new NativeMenuItemSeparator()); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Remove Books from All Accounts", Command = ReactiveCommand.Create(RemoveBooksAllAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta) }); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Remove Books from Some Accounts", Command = ReactiveCommand.Create(RemoveBooksSomeAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) }); } - importMenuItem.Menu.Items.Add(new NativeMenuItemSeparator()); - importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Locate Audiobooks...", Command = ReactiveCommand.Create(LocateAudiobooksAsync) }); + importMenuItem.Items.Add(new NativeMenuItemSeparator()); + importMenuItem.Items.Add(new NativeMenuItem { Header = "Locate Audiobooks...", Command = ReactiveCommand.Create(LocateAudiobooksAsync) }); } } } diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs index f5487022..b69a8ef2 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Liberate.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using DataLayer; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs index 9c30a7cc..57ffa535 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ProcessQueue.cs @@ -6,6 +6,7 @@ using Dinah.Core; using LibationUiBase.GridView; using ReactiveUI; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM diff --git a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs index 2dd4829c..23e5b8bb 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.ScanAuto.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; using System.Linq; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM @@ -50,7 +51,7 @@ namespace LibationAvalonia.ViewModels } - private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts; + private List<(string AccountId, string LocaleName)>? preSaveDefaultAccounts; private List<(string AccountId, string LocaleName)> getDefaultAccounts() { using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); @@ -61,17 +62,17 @@ namespace LibationAvalonia.ViewModels .ToList(); } - private void accountsPreSave(object sender = null, EventArgs e = null) + private void accountsPreSave(object? sender = null, EventArgs? e = null) => preSaveDefaultAccounts = getDefaultAccounts(); - private void accountsPostSave(object sender = null, EventArgs e = null) + private void accountsPostSave(object? sender = null, EventArgs? e = null) { - if (getDefaultAccounts().Except(preSaveDefaultAccounts).Any()) + if (getDefaultAccounts().Except(preSaveDefaultAccounts ?? Enumerable.Empty<(string AccountId, string LocaleName)>()).Any()) startAutoScan(); } [PropertyChangeFilter(nameof(Configuration.AutoScan))] - private void startAutoScan(object sender = null, EventArgs e = null) + private void startAutoScan(object? sender = null, EventArgs? e = null) { AutoScanChecked = Configuration.Instance.AutoScan; if (AutoScanChecked) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.Settings.cs b/Source/LibationAvalonia/ViewModels/MainVM.Settings.cs index 715eabc8..72c25c27 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.Settings.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.Settings.cs @@ -4,6 +4,7 @@ using ReactiveUI; using System; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM @@ -12,7 +13,9 @@ namespace LibationAvalonia.ViewModels public bool MenuBarVisible { get => _menuBarVisible; set => this.RaiseAndSetIfChanged(ref _menuBarVisible, value); } private void Configure_Settings() { - ((NativeMenuItem)NativeMenu.GetMenu(App.Current).Items[0]).Command = ReactiveCommand.Create(ShowAboutAsync); + if (App.Current is Avalonia.Application app && + NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu) + aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync); } public Task ShowAboutAsync() => new LibationAvalonia.Dialogs.AboutDialog().ShowDialog(MainWindow); diff --git a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs index 782337bf..f9a2aeb7 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs @@ -6,6 +6,7 @@ using Avalonia.Threading; using LibationAvalonia.Dialogs; using ReactiveUI; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM @@ -56,13 +57,13 @@ namespace LibationAvalonia.ViewModels this.RaisePropertyChanged(nameof(LiberateVisibleToolStripText_2)); } - public async void ProductsDisplay_VisibleCountChanged(object sender, int qty) + public async void ProductsDisplay_VisibleCountChanged(object? sender, int qty) { setVisibleCount(qty); await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem); } - private async void setLiberatedVisibleMenuItemAsync(object _, object __) + private async void setLiberatedVisibleMenuItemAsync(object? _, object __) => await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem); diff --git a/Source/LibationAvalonia/ViewModels/MainVM._NoUI.cs b/Source/LibationAvalonia/ViewModels/MainVM._NoUI.cs index 9cfd5dd8..d57594e3 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM._NoUI.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM._NoUI.cs @@ -2,6 +2,7 @@ using LibationUiBase; using System.IO; +#nullable enable namespace LibationAvalonia.ViewModels { partial class MainVM diff --git a/Source/LibationAvalonia/ViewModels/MainVM.cs b/Source/LibationAvalonia/ViewModels/MainVM.cs index 9e6a602c..a57bf9c5 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.cs @@ -6,6 +6,7 @@ using ReactiveUI; using System.Collections.Generic; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { public partial class MainVM : ViewModelBase @@ -37,11 +38,18 @@ namespace LibationAvalonia.ViewModels Configure_VisibleBooks(); } - private async void LibraryCommands_LibrarySizeChanged(object sender, List fullLibrary) + private async void LibraryCommands_LibrarySizeChanged(object? sender, List fullLibrary) { - await Task.WhenAll( - SetBackupCountsAsync(fullLibrary), - Task.Run(() => ProductsDisplay.UpdateGridAsync(fullLibrary))); + try + { + await Task.WhenAll( + SetBackupCountsAsync(fullLibrary), + Task.Run(() => ProductsDisplay.UpdateGridAsync(fullLibrary))); + } + catch (System.Exception ex) + { + await MessageBox.ShowAdminAlert(MainWindow, "An error occurred while updating the library.", "Library Size Change Error", ex); + } } private static string menufyText(string header) => Configuration.IsMacOs ? header : $"_{header}"; diff --git a/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs index d0f405cb..c98239ea 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessBookViewModel.cs @@ -16,6 +16,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { public enum ProcessBookResult @@ -45,28 +46,28 @@ namespace LibationAvalonia.ViewModels /// public class ProcessBookViewModel : ViewModelBase { - public event EventHandler Completed; + 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 string? _narrator; + private string? _author; + private string? _title; private int _progress; - private string _eta; - private Bitmap _cover; + 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 => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); } - public string Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); } - public string Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); } + public string? Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); } + public string? Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); } + public string? Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); } public int Progress { get => _progress; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _progress, value)); } - public string ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); } - public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); } + public string? ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); } + public Bitmap? Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => 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; @@ -95,8 +96,8 @@ namespace LibationAvalonia.ViewModels 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 Processable? NextProcessable() => _currentProcessable = null; + private Processable? _currentProcessable; private readonly Queue> Processes = new(); private readonly LogMe Logger; @@ -118,7 +119,7 @@ namespace LibationAvalonia.ViewModels _cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80); } - private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e) + private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e) { if (e.Definition.PictureId == LibraryBook.Book.PictureId) { @@ -255,14 +256,14 @@ namespace LibationAvalonia.ViewModels #region AudioDecodable event handlers - private void AudioDecodable_TitleDiscovered(object sender, string title) => Title = title; + private void AudioDecodable_TitleDiscovered(object? sender, string title) => Title = title; - private void AudioDecodable_AuthorsDiscovered(object sender, string authors) => Author = authors; + private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors; - private void AudioDecodable_NarratorsDiscovered(object sender, string narrators) => Narrator = narrators; + private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators; - private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e) + private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e) { var quality = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null @@ -275,7 +276,7 @@ namespace LibationAvalonia.ViewModels return coverData; } - private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt) + private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt) { using var ms = new System.IO.MemoryStream(coverArt); Cover = new Avalonia.Media.Imaging.Bitmap(ms); @@ -284,10 +285,10 @@ namespace LibationAvalonia.ViewModels #endregion #region Streamable event handlers - private void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining; + private void Streamable_StreamingTimeRemaining(object? sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining; - private void Streamable_StreamingProgressChanged(object sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) + private void Streamable_StreamingProgressChanged(object? sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress) { if (!downloadProgress.ProgressPercentage.HasValue) return; @@ -302,21 +303,25 @@ namespace LibationAvalonia.ViewModels #region Processable event handlers - private async void Processable_Begin(object sender, LibraryBook libraryBook) + private async void Processable_Begin(object? sender, LibraryBook libraryBook) { await Dispatcher.UIThread.InvokeAsync(() => Status = ProcessBookStatus.Working); - Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}"); + if (sender is Processable processable) + Logger.Info($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}"); Title = libraryBook.Book.TitleWithSubtitle; Author = libraryBook.Book.AuthorNames(); Narrator = libraryBook.Book.NarratorNames(); } - private async void Processable_Completed(object sender, LibraryBook libraryBook) + private async void Processable_Completed(object? sender, LibraryBook libraryBook) { - Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}"); - UnlinkProcessable((Processable)sender); + if (sender is Processable processable) + { + Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}"); + UnlinkProcessable(processable); + } if (Processes.Count == 0) { @@ -375,7 +380,7 @@ namespace LibationAvalonia.ViewModels : str; details = -$@" Title: {libraryBook.Book.TitleWithSubtitle} + $@" Title: {libraryBook.Book.TitleWithSubtitle} ID: {libraryBook.Book.AudibleProductId} Author: {trunc(libraryBook.Book.AuthorNames())} Narr: {trunc(libraryBook.Book.NarratorNames())}"; diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index 5ed38d96..fcc13a9e 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -12,15 +12,17 @@ using System.Collections.ObjectModel; using System.Linq; using System.Threading.Tasks; +#nullable enable namespace LibationAvalonia.ViewModels { + public class ProcessQueueViewModel : ViewModelBase, ILogForm { public ObservableCollection LogEntries { get; } = new(); public AvaloniaList Items { get; } = new(); public TrackedQueue Queue { get; } - public ProcessBookViewModel SelectedItem { get; set; } - public Task QueueRunner { get; private set; } + public ProcessBookViewModel? SelectedItem { get; set; } + public Task? QueueRunner { get; private set; } public bool Running => !QueueRunner?.IsCompleted ?? false; private readonly LogMe Logger; @@ -41,14 +43,14 @@ namespace LibationAvalonia.ViewModels private int _completedCount; private int _errorCount; private int _queuedCount; - private string _runningTime; + private string? _runningTime; private bool _progressBarVisible; private decimal _speedLimit; public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); } public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); } public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); } - public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); } + public string? RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); } public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); } public bool AnyCompleted => CompletedCount > 0; public bool AnyQueued => QueuedCount > 0; @@ -89,7 +91,7 @@ namespace LibationAvalonia.ViewModels public decimal SpeedLimitIncrement { get; private set; } - private async void Queue_CompletedCountChanged(object sender, int e) + private async 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); @@ -98,7 +100,7 @@ namespace LibationAvalonia.ViewModels CompletedCount = completeCount; await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress))); } - private async void Queue_QueuededCountChanged(object sender, int cueCount) + private async void Queue_QueuededCountChanged(object? sender, int cueCount) { QueuedCount = cueCount; await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress))); @@ -120,7 +122,7 @@ namespace LibationAvalonia.ViewModels private bool isBookInQueue(LibraryBook libraryBook) { var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); - if (entry == null) + if (entry == null) return false; else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed) return !Queue.RemoveCompleted(entry); @@ -218,13 +220,17 @@ namespace LibationAvalonia.ViewModels while (Queue.MoveNext()) { - var nextBook = Queue.Current; + if (Queue.Current is not ProcessBookViewModel nextBook) + { + Serilog.Log.Logger.Information("Current queue item is empty."); + continue; + } - Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook?.LibraryBook); + 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); + Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook.LibraryBook, result); if (result == ProcessBookResult.ValidationFail) Queue.ClearCurrent(); @@ -256,7 +262,7 @@ This error appears to be caused by a temporary interruption of service that some } } - private void CounterTimer_Tick(object state) + private void CounterTimer_Tick(object? state) { string timeToStr(TimeSpan time) { @@ -273,6 +279,6 @@ This error appears to be caused by a temporary interruption of service that some { public DateTime LogDate { get; init; } public string LogDateString => LogDate.ToShortTimeString(); - public string LogMessage { get; init; } + public string? LogMessage { get; init; } } } diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index 406e6478..76193ae1 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -104,6 +104,9 @@ namespace LibationAvalonia.ViewModels internal async Task BindToGridAsync(List dbBooks) { + if (dbBooks == null) + throw new ArgumentNullException(nameof(dbBooks)); + //Get the UI thread's synchronization context and set it on the current thread to ensure //it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current); @@ -155,12 +158,11 @@ namespace LibationAvalonia.ViewModels /// internal async Task UpdateGridAsync(List dbBooks) { + if (dbBooks == null) + throw new ArgumentNullException(nameof(dbBooks)); + if (GridEntries == null) - { - //always bind before updating. Binding creates GridEntries. - await BindToGridAsync(dbBooks); - return; - } + throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGridAsync)}"); #region Add new or update existing grid entries diff --git a/Source/LibationAvalonia/ViewModels/RowComparer.cs b/Source/LibationAvalonia/ViewModels/RowComparer.cs index a9d40378..071bedc6 100644 --- a/Source/LibationAvalonia/ViewModels/RowComparer.cs +++ b/Source/LibationAvalonia/ViewModels/RowComparer.cs @@ -3,17 +3,18 @@ using LibationUiBase.GridView; using System.ComponentModel; using System.Reflection; +#nullable enable namespace LibationAvalonia.ViewModels { internal class RowComparer : RowComparerBase { - 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); + 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); - private DataGridColumn Column { get; init; } - public override string PropertyName { get; set; } + private DataGridColumn? Column { get; } + public override string? PropertyName { get; set; } - public RowComparer(DataGridColumn column) + public RowComparer(DataGridColumn? column) { Column = column; PropertyName = Column?.SortMemberPath ?? nameof(IGridEntry.DateAdded); @@ -22,7 +23,7 @@ namespace LibationAvalonia.ViewModels //Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection protected override ListSortDirection GetSortOrder() => Column is null ? ListSortDirection.Descending - : CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) is ListSortDirection lsd ? lsd + : CurrentSortingStatePi?.GetValue(HeaderCellPi?.GetValue(Column)) is ListSortDirection lsd ? lsd : ListSortDirection.Descending; } } diff --git a/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs index 148ca1da..02c87a58 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/AudioSettingsVM.cs @@ -8,6 +8,7 @@ using ReactiveUI; using System; using System.Linq; +#nullable enable namespace LibationAvalonia.ViewModels.Settings { public class AudioSettingsVM : ViewModelBase @@ -33,17 +34,13 @@ namespace LibationAvalonia.ViewModels.Settings = new( new[] { - NAudio.Lame.EncoderQuality.High, - NAudio.Lame.EncoderQuality.Standard, - NAudio.Lame.EncoderQuality.Fast, + NAudio.Lame.EncoderQuality.High, + NAudio.Lame.EncoderQuality.Standard, + NAudio.Lame.EncoderQuality.Fast, }); public AudioSettingsVM(Configuration config) - { - LoadSettings(config); - } - public void LoadSettings(Configuration config) { CreateCueSheet = config.CreateCueSheet; CombineNestedChapterTitles = config.CombineNestedChapterTitles; @@ -57,7 +54,7 @@ namespace LibationAvalonia.ViewModels.Settings MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits; StripAudibleBrandAudio = config.StripAudibleBrandAudio; StripUnabridged = config.StripUnabridged; - ChapterTitleTemplate = config.ChapterTitleTemplate; + _chapterTitleTemplate = config.ChapterTitleTemplate; DecryptToLossy = config.DecryptToLossy; MoveMoovToBeginning = config.MoveMoovToBeginning; LameTargetBitrate = config.LameTargetBitrate; @@ -67,7 +64,7 @@ namespace LibationAvalonia.ViewModels.Settings LameBitrate = config.LameBitrate; LameVBRQuality = config.LameVBRQuality; - SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate); + SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate) ?? SampleRates[0]; SelectedEncoderQuality = config.LameEncoderQuality; } diff --git a/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs index 84032983..cc8dcbef 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs @@ -3,6 +3,7 @@ using LibationFileManager; using ReactiveUI; using System.Collections.Generic; +#nullable enable namespace LibationAvalonia.ViewModels.Settings { public class DownloadDecryptSettingsVM : ViewModelBase @@ -15,7 +16,16 @@ namespace LibationAvalonia.ViewModels.Settings public DownloadDecryptSettingsVM(Configuration config) { Config = config; - LoadSettings(config); + BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask; + BadBookAbort = config.BadBook is Configuration.BadBookAction.Abort; + BadBookRetry = config.BadBook is Configuration.BadBookAction.Retry; + BadBookIgnore = config.BadBook is Configuration.BadBookAction.Ignore; + _folderTemplate = config.FolderTemplate; + _fileTemplate = config.FileTemplate; + _chapterFileTemplate = config.ChapterFileTemplate; + InProgressDirectory = config.InProgress; + UseCoverAsFolderIcon = config.UseCoverAsFolderIcon; + SaveMetadataToFile = config.SaveMetadataToFile; } public List KnownDirectories { get; } = new() @@ -28,20 +38,6 @@ namespace LibationAvalonia.ViewModels.Settings Configuration.KnownDirectories.LibationFiles }; - public void LoadSettings(Configuration config) - { - BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask; - BadBookAbort = config.BadBook is Configuration.BadBookAction.Abort; - BadBookRetry = config.BadBook is Configuration.BadBookAction.Retry; - BadBookIgnore = config.BadBook is Configuration.BadBookAction.Ignore; - FolderTemplate = config.FolderTemplate; - FileTemplate = config.FileTemplate; - ChapterFileTemplate = config.ChapterFileTemplate; - InProgressDirectory = config.InProgress; - UseCoverAsFolderIcon = config.UseCoverAsFolderIcon; - SaveMetadataToFile = config.SaveMetadataToFile; - } - public void SaveSettings(Configuration config) { config.BadBook @@ -62,10 +58,10 @@ namespace LibationAvalonia.ViewModels.Settings public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon)); public string SaveMetadataToFileText { get; } = Configuration.GetDescription(nameof(Configuration.SaveMetadataToFile)); public string BadBookGroupboxText { get; } = Configuration.GetDescription(nameof(Configuration.BadBook)); - public string BadBookAskText { get; } = Configuration.BadBookAction.Ask.GetDescription(); - public string BadBookAbortText { get; } = Configuration.BadBookAction.Abort.GetDescription(); - public string BadBookRetryText { get; } = Configuration.BadBookAction.Retry.GetDescription(); - public string BadBookIgnoreText { get; } = Configuration.BadBookAction.Ignore.GetDescription(); + public string BadBookAskText { get; } = Configuration.BadBookAction.Ask.GetDescription() ?? nameof(Configuration.BadBookAction.Ask); + public string BadBookAbortText { get; } = Configuration.BadBookAction.Abort.GetDescription() ?? nameof(Configuration.BadBookAction.Abort); + public string BadBookRetryText { get; } = Configuration.BadBookAction.Retry.GetDescription() ?? nameof(Configuration.BadBookAction.Retry); + public string BadBookIgnoreText { get; } = Configuration.BadBookAction.Ignore.GetDescription() ?? nameof(Configuration.BadBookAction.Ignore); public string FolderTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)); public string FileTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)); public string ChapterFileTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)); diff --git a/Source/LibationAvalonia/ViewModels/Settings/ImportSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/ImportSettingsVM.cs index 5eeea63a..0df3dc6a 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/ImportSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/ImportSettingsVM.cs @@ -1,15 +1,11 @@ using LibationFileManager; +#nullable enable namespace LibationAvalonia.ViewModels.Settings { public class ImportSettingsVM { public ImportSettingsVM(Configuration config) - { - LoadSettings(config); - } - - public void LoadSettings(Configuration config) { AutoScan = config.AutoScan; ShowImportedStats = config.ShowImportedStats; diff --git a/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs index 648f7cd0..da6ebada 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs @@ -7,6 +7,7 @@ using System; using System.Collections.Generic; using System.Linq; +#nullable enable namespace LibationAvalonia.ViewModels.Settings { public class ImportantSettingsVM : ViewModelBase @@ -18,12 +19,8 @@ namespace LibationAvalonia.ViewModels.Settings public ImportantSettingsVM(Configuration config) { this.config = config; - LoadSettings(config); - } - public void LoadSettings(Configuration config) - { - BooksDirectory = config.Books.PathWithoutPrefix; + BooksDirectory = config.Books?.PathWithoutPrefix ?? Configuration.DefaultBooksDirectory; SavePodcastsToParentFolder = config.SavePodcastsToParentFolder; OverwriteExisting = config.OverwriteExisting; CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0]; @@ -32,9 +29,9 @@ namespace LibationAvalonia.ViewModels.Settings GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor); GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor); - ThemeVariant = initialThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)); - if (string.IsNullOrWhiteSpace(initialThemeVariant)) - ThemeVariant = initialThemeVariant = "System"; + themeVariant = initialThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) ?? ""; + if (string.IsNullOrWhiteSpace(initialThemeVariant)) + themeVariant = initialThemeVariant = "System"; } public void SaveSettings(Configuration config) @@ -100,14 +97,17 @@ namespace LibationAvalonia.ViewModels.Settings get => themeVariant; set { + var changed = !value.Equals(themeVariant); this.RaiseAndSetIfChanged(ref themeVariant, value); - App.Current.RequestedThemeVariant = themeVariant switch - { - nameof(Avalonia.Styling.ThemeVariant.Dark) => Avalonia.Styling.ThemeVariant.Dark, - nameof(Avalonia.Styling.ThemeVariant.Light) => Avalonia.Styling.ThemeVariant.Light, - // "System" - _ => Avalonia.Styling.ThemeVariant.Default - }; + + if (changed && App.Current is Avalonia.Application app) + app.RequestedThemeVariant = themeVariant switch + { + nameof(Avalonia.Styling.ThemeVariant.Dark) => Avalonia.Styling.ThemeVariant.Dark, + nameof(Avalonia.Styling.ThemeVariant.Light) => Avalonia.Styling.ThemeVariant.Light, + // "System" + _ => Avalonia.Styling.ThemeVariant.Default + }; } } } diff --git a/Source/LibationFileManager/Configuration.HelpText.cs b/Source/LibationFileManager/Configuration.HelpText.cs index 8eb7679c..8b0c0f07 100644 --- a/Source/LibationFileManager/Configuration.HelpText.cs +++ b/Source/LibationFileManager/Configuration.HelpText.cs @@ -19,7 +19,7 @@ namespace LibationFileManager {nameof(AllowLibationFixup), """ In addition to the options that are enabled if you allow "fixing up" the audiobook, it does the following: - + * Sets the ©gen metadata tag for the genres. * Adds the TCOM (@wrt in M4B files) metadata tag for the narrators. * Unescapes the copyright symbol (replace © with ©) @@ -30,7 +30,7 @@ namespace LibationFileManager } .AsReadOnly(); - public static string? GetHelpText(string settingName) - => HelpText.TryGetValue(settingName, out var value) ? value : null; - } + public static string GetHelpText(string settingName) + => HelpText.TryGetValue(settingName, out var value) ? value : ""; + } } diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 94445347..33a61e76 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -82,7 +82,7 @@ namespace LibationFileManager public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json"); - public static string? GetDescription(string propertyName) + public static string GetDescription(string propertyName) { var attribute = typeof(Configuration) .GetProperty(propertyName) @@ -90,7 +90,7 @@ namespace LibationFileManager .SingleOrDefault() as DescriptionAttribute; - return attribute?.Description; + return attribute?.Description ?? $"[{propertyName}]"; } public bool Exists(string propertyName) => Settings.Exists(propertyName); @@ -118,12 +118,15 @@ namespace LibationFileManager // temp/working dir(s) should be outside of dropbox [Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")] - public string InProgress { get + public string InProgress + { + get { var tempDir = GetString(); return string.IsNullOrWhiteSpace(tempDir) ? WinTemp : tempDir; } - set => SetString(value); } + set => SetString(value); + } [Description("Allow Libation to fix up audiobook metadata")] public bool AllowLibationFixup { get => GetNonString(defaultValue: true); set => SetNonString(value); } @@ -162,10 +165,10 @@ namespace LibationFileManager public NAudio.Lame.EncoderQuality LameEncoderQuality { get => GetNonString(defaultValue: NAudio.Lame.EncoderQuality.High); set => SetNonString(value); } [Description("Lame encoder downsamples to mono")] - public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); } + public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); } [Description("Lame target bitrate [16,320]")] - public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); } + public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); } [Description("Restrict encoder to constant bitrate?")] public bool LameConstantBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); } @@ -179,8 +182,8 @@ namespace LibationFileManager private static readonly EquatableDictionary DefaultColumns = new( new KeyValuePair[] { - new ("SeriesOrder", false), - new ("LastDownload", false) + new ("SeriesOrder", false), + new ("LastDownload", false) }); [Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")] @@ -200,7 +203,7 @@ namespace LibationFileManager [Description("Download clips and bookmarks?")] public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); } - + [Description("File format to save clips and bookmarks")] public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString(defaultValue: ClipBookmarkFormat.CSV); set => SetNonString(value); } diff --git a/Source/LibationFileManager/QuickFilters.cs b/Source/LibationFileManager/QuickFilters.cs index d8f40181..2f61082c 100644 --- a/Source/LibationFileManager/QuickFilters.cs +++ b/Source/LibationFileManager/QuickFilters.cs @@ -46,7 +46,7 @@ namespace LibationFileManager // Note that records overload equality automagically, so should be able to // compare these the same way as comparing simple strings. - public record NamedFilter(string Filter, string Name) + public record NamedFilter(string Filter, string? Name) { public string Filter { get; set; } = Filter; public string? Name { get; set; } = Name; diff --git a/Source/LibationUiBase/GridView/RowComparerBase.cs b/Source/LibationUiBase/GridView/RowComparerBase.cs index eefaf34b..c2d96b24 100644 --- a/Source/LibationUiBase/GridView/RowComparerBase.cs +++ b/Source/LibationUiBase/GridView/RowComparerBase.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.ComponentModel; +#nullable enable namespace LibationUiBase.GridView { /// @@ -11,67 +12,10 @@ namespace LibationUiBase.GridView /// public abstract class RowComparerBase : IComparer, IComparer, IComparer { - public abstract string PropertyName { get; set; } + public abstract string? PropertyName { get; set; } - 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 = (IGridEntry)x; - var geB = (IGridEntry)y; - - var sortDirection = GetSortOrder(); - - ISeriesEntry parentA = null; - ISeriesEntry parentB = null; - - if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA) - parentA = seA; - if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry 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 - if (parentA == parentB) - { - //Podcast episodes usually all have the same PurchaseDate and DateAdded property: - //the date that the series was added to the library. So when sorting by PurchaseDate - //and DateAdded, compare SeriesOrder instead.. - return PropertyName switch - { - nameof(IGridEntry.DateAdded) or nameof (IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder), - _ => InternalCompare(geA, geB), - }; - } - - //a and b are children of different series. - return InternalCompare(parentA, parentB); - } + public int Compare(object? x, object? y) + => Compare(x as IGridEntry, y as IGridEntry); protected abstract ListSortDirection GetSortOrder(); @@ -80,17 +24,74 @@ namespace LibationUiBase.GridView var val1 = x.GetMemberValue(PropertyName); var val2 = y.GetMemberValue(PropertyName); - var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); - + var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2); + return compare == 0 && x.Liberate.IsSeries && y.Liberate.IsSeries //Both a and b are series parents and compare as equal, so break the tie. ? x.AudibleProductId.CompareTo(y.AudibleProductId) : compare; } - public int Compare(IGridEntry x, IGridEntry y) + public int Compare(IGridEntry? geA, IGridEntry? geB) { - return Compare((object)x, y); + if (geA is null && geB is not null) return -1; + if (geA is not null && geB is null) return 1; + if (geA is null || geB is null) return 0; + + var sortDirection = GetSortOrder(); + + ISeriesEntry? parentA = null; + ISeriesEntry? parentB = null; + + if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA) + parentA = seA; + if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB) + parentB = seB; + + //both entries are children + if (parentA != null && parentB != null) + { + //both are children of the same series + if (parentA == parentB) + { + //Podcast episodes usually all have the same PurchaseDate and DateAdded property: + //the date that the series was added to the library. So when sorting by PurchaseDate + //and DateAdded, compare SeriesOrder instead.. + return PropertyName switch + { + nameof(IGridEntry.DateAdded) or nameof(IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder), + _ => InternalCompare(geA, geB), + }; + } + else + //a and b are children of different series. + return InternalCompare(parentA, parentB); + } + + //a is top-level, b is a child + else 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 + else 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); + } + //parentA and parentB are null + else + { + //both a and b are top-level grid entries + return InternalCompare(geA, geB); + } } } } From e37abbf2760a929f81a942437b4f147bf56d3ae0 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Tue, 4 Mar 2025 16:18:06 -0700 Subject: [PATCH 4/4] Fix dark theme text color in DataGridTextColumn --- Source/LibationAvalonia/App.axaml | 3 +++ Source/LibationAvalonia/Dialogs/DialogWindow.cs | 4 ++++ .../Dialogs/EditQuickFilters.axaml.cs | 11 +++++++++++ 3 files changed, 18 insertions(+) diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index e8f01956..13a64b07 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -17,6 +17,9 @@ + + + diff --git a/Source/LibationAvalonia/Dialogs/DialogWindow.cs b/Source/LibationAvalonia/Dialogs/DialogWindow.cs index 38dcd109..a5f7c15e 100644 --- a/Source/LibationAvalonia/Dialogs/DialogWindow.cs +++ b/Source/LibationAvalonia/Dialogs/DialogWindow.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Avalonia.Controls.Primitives; +using Avalonia.Styling; using LibationFileManager; using System; using System.Threading.Tasks; @@ -30,6 +31,9 @@ namespace LibationAvalonia.Dialogs Closing += DialogWindow_Closing; UseCustomTitleBar = Configuration.IsWindows; + + if (Design.IsDesignMode) + RequestedThemeVariant = ThemeVariant.Dark; } private bool fixedMinHeight = false; diff --git a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs index 3a506658..48b4a14d 100644 --- a/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditQuickFilters.axaml.cs @@ -42,6 +42,17 @@ namespace LibationAvalonia.Dialogs public EditQuickFilters() { InitializeComponent(); + if (Design.IsDesignMode) + { + Filters = new ObservableCollection([ + new Filter { Name = "Filter 1", FilterString = "[filter1 string]" }, + new Filter { Name = "Filter 2", FilterString = "[filter2 string]" }, + new Filter { Name = "Filter 3", FilterString = "[filter3 string]" }, + new Filter { Name = "Filter 4", FilterString = "[filter4 string]" } + ]); + DataContext = this; + return; + } // WARNING: accounts persister will write ANY EDIT to object immediately to file // here: copy strings and dispose of persister