Merge pull request #1175 from Mbucari/master

Additional null safety
This commit is contained in:
rmcrackan 2025-03-04 21:32:35 -05:00 committed by GitHub
commit 85b6792468
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 491 additions and 421 deletions

View File

@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.0.2.1</Version>
<Version>12.0.2.2</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@ -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<int> ScanBegin;
public static event EventHandler<int> ScanEnd;
public static event EventHandler<int>? ScanBegin;
public static event EventHandler<int>? 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<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[]? accounts)
{
logRestart();
@ -236,7 +237,7 @@ namespace ApplicationServices
{
var tasks = new List<Task<List<ImportItem>>>();
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<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
private static async Task<List<ImportItem>> 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<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null)
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception>? exceptions = null)
{
if (archiver is not null)
{
@ -452,28 +453,28 @@ namespace ApplicationServices
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler<List<LibraryBook>> LibrarySizeChanged;
public static event EventHandler<List<LibraryBook>>? LibrarySizeChanged;
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
public static event EventHandler<IEnumerable<LibraryBook>>? 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<LibraryBook> 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<Rating> 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<LibraryBook> libraryBooks = null)
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();

View File

@ -17,6 +17,9 @@
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
<Setter Property="Background" Value="Transparent" />
</ControlTheme>
<ControlTheme x:Key="{x:Type DataGridCell}" TargetType="DataGridCell" BasedOn="{StaticResource {x:Type DataGridCell}}">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
</ControlTheme>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" />

View File

@ -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<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null)
public static Task<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window? owner = null)
=> dialogWindow.ShowDialog<DialogResult>(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

View File

@ -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;

View File

@ -42,6 +42,17 @@ namespace LibationAvalonia.Dialogs
public EditQuickFilters()
{
InitializeComponent();
if (Design.IsDesignMode)
{
Filters = new ObservableCollection<Filter>([
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

View File

@ -51,7 +51,7 @@
<DataGridTemplateColumn Width="Auto" Header="Tag">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -59,7 +59,7 @@
<DataGridTemplateColumn Width="Auto" Header="Description">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter
<TextBlock
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center" Text="{Binding Item2}" />

View File

@ -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<Templates.FileTemplate>.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<Templates.FileTemplate>.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<string, string, string>).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<Tuple<string, string, string>>(
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{t.TagName}>",
t.Description,
t.DefaultValue)
)
);
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).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<Tuple<string, string, string>>(
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<Tuple<string, string, string>> ListItems { get; set; }
public void ResetTextBox(string value) => UserTemplateText = value;
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
public async Task<bool> 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<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{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<Tuple<string, string, string>> 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<bool> 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}"));
}
}
}

View File

@ -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<FormSizeAndPosition>(defaultValue: null, form.GetType().Name);
var savedState = config.GetNonString<FormSizeAndPosition?>(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);

View File

@ -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<Configuration, string> selector)
static string getConfigValue(Func<Configuration, string?> selector)
{
try
{
return selector(Configuration.Instance);
return selector(Configuration.Instance) ?? "[null]";
}
catch (Exception ex)
{

View File

@ -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;
}
}

View File

@ -1,5 +1,6 @@
using ReactiveUI;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class LiberateStatusButtonViewModel : ViewModelBase

View File

@ -6,12 +6,13 @@ using ReactiveUI;
using System.Collections.Generic;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM
{
private Task<LibraryCommands.LibraryStats> updateCountsTask;
private LibraryCommands.LibraryStats _libraryStats;
private Task<LibraryCommands.LibraryStats>? updateCountsTask;
private LibraryCommands.LibraryStats? _libraryStats;
/// <summary> The "Begin Book and PDF Backup" menu item header text </summary>
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";
/// <summary> The user's library statistics </summary>
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<LibraryBook> libraryBooks)
public async Task SetBackupCountsAsync(IEnumerable<LibraryBook>? 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);

View File

@ -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();

View File

@ -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;
/// <summary> Library filterting query </summary>
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<Control> QuickFilterMenuItems { get; } = new();
/// <summary> Indicates if the first quick filter is the default filter </summary>
public bool FirstFilterIsDefault { get => _firstFilterIsDefault; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); }
@ -50,36 +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)
{
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);
@ -89,7 +98,7 @@ namespace LibationAvalonia.ViewModels
command.Dispose();
}
quickFilterNativeMenu.Menu.Items.RemoveAt(i);
quickFilterNativeMenu.Items.RemoveAt(i);
QuickFilterMenuItems.RemoveAt(i);
}
@ -116,7 +125,7 @@ namespace LibationAvalonia.ViewModels
}
QuickFilterMenuItems.Add(menuItem);
quickFilterNativeMenu.Menu.Items.Add(nativeMenuItem);
quickFilterNativeMenu.Items.Add(nativeMenuItem);
}
}
}

View File

@ -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) });
}
}
}

View File

@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using DataLayer;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@ -6,6 +6,7 @@ using Dinah.Core;
using LibationUiBase.GridView;
using ReactiveUI;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@ -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)

View File

@ -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);

View File

@ -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);

View File

@ -2,6 +2,7 @@
using LibationUiBase;
using System.IO;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@ -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<LibraryBook> fullLibrary)
private async void LibraryCommands_LibrarySizeChanged(object? sender, List<LibraryBook> 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}";

View File

@ -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
/// </summary>
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<Func<Processable>> 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())}";

View File

@ -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<LogEntry> LogEntries { get; } = new();
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
public TrackedQueue<ProcessBookViewModel> 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; }
}
}

View File

@ -104,6 +104,9 @@ namespace LibationAvalonia.ViewModels
internal async Task BindToGridAsync(List<LibraryBook> 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
/// </summary>
internal async Task UpdateGridAsync(List<LibraryBook> 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

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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<Configuration.KnownDirectories> 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));

View File

@ -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;

View File

@ -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
};
}
}
}

View File

@ -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 &#169; 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 : "";
}
}

View File

@ -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<string, bool> DefaultColumns = new(
new KeyValuePair<string, bool>[]
{
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); }

View File

@ -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;

View File

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.ComponentModel;
#nullable enable
namespace LibationUiBase.GridView
{
/// <summary>
@ -11,67 +12,10 @@ namespace LibationUiBase.GridView
/// </summary>
public abstract class RowComparerBase : IComparer, IComparer<IGridEntry>, IComparer<object>
{
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);
}
}
}
}