From 47c9fcb88353483f6e6def77005a631f5017e1a3 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Thu, 27 Feb 2025 22:56:02 -0700 Subject: [PATCH 1/7] Improve LibrarySizeChanged performance --- Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs | 4 ---- Source/LibationAvalonia/ViewModels/MainVM.cs | 5 ++++- Source/LibationAvalonia/Views/MainWindow.axaml.cs | 8 +++++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs b/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs index 658ef683..7b598c5c 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.BackupCounts.cs @@ -4,7 +4,6 @@ using DataLayer; using LibationFileManager; using ReactiveUI; using System.Collections.Generic; -using System.Linq; using System.Threading.Tasks; namespace LibationAvalonia.ViewModels @@ -44,9 +43,6 @@ namespace LibationAvalonia.ViewModels private void Configure_BackupCounts() { - LibraryCommands.LibrarySizeChanged += async (object _, List libraryBooks) - => await SetBackupCountsAsync(libraryBooks); - //Pass null to the setup count to get the whole library. LibraryCommands.BookUserDefinedItemCommitted += async (_, _) => await SetBackupCountsAsync(null); diff --git a/Source/LibationAvalonia/ViewModels/MainVM.cs b/Source/LibationAvalonia/ViewModels/MainVM.cs index 027f13d9..35089bb0 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.cs @@ -4,6 +4,7 @@ using LibationAvalonia.Views; using LibationFileManager; using ReactiveUI; using System.Collections.Generic; +using System.Threading.Tasks; namespace LibationAvalonia.ViewModels { @@ -38,7 +39,9 @@ namespace LibationAvalonia.ViewModels private async void LibraryCommands_LibrarySizeChanged(object sender, List fullLibrary) { - await ProductsDisplay.UpdateGridAsync(fullLibrary); + await Task.WhenAll( + SetBackupCountsAsync(fullLibrary), + ProductsDisplay.UpdateGridAsync(fullLibrary)); } private static string menufyText(string header) => Configuration.IsMacOs ? header : $"_{header}"; diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index f9687d2a..005f3654 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -8,6 +8,7 @@ using ReactiveUI; using System; using System.Collections.Generic; using System.Linq; +using System.Threading.Tasks; namespace LibationAvalonia.Views { @@ -60,13 +61,14 @@ namespace LibationAvalonia.Views filterSearchTb.Focus(); } - public async System.Threading.Tasks.Task OnLibraryLoadedAsync(List initialLibrary) + public async Task OnLibraryLoadedAsync(List initialLibrary) { if (QuickFilters.UseDefault) await ViewModel.PerformFilter(QuickFilters.Filters.FirstOrDefault()); - await ViewModel.SetBackupCountsAsync(initialLibrary); - await ViewModel.ProductsDisplay.BindToGridAsync(initialLibrary); + await Task.WhenAll( + ViewModel.SetBackupCountsAsync(initialLibrary), + ViewModel.ProductsDisplay.BindToGridAsync(initialLibrary)); } public void ProductsDisplay_LiberateClicked(object _, LibraryBook libraryBook) => ViewModel.LiberateClicked(libraryBook); From a9375f15204131abbea7a306d0525df1e80dacc3 Mon Sep 17 00:00:00 2001 From: MBucari Date: Fri, 28 Feb 2025 01:06:54 -0700 Subject: [PATCH 2/7] Improve file cache performance and add migration LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size. The new cache uses a dictionary with the ID as its key, and a CacheEntry list as its value. --- Source/AppScaffolding/LibationScaffolding.cs | 77 ++++++++ Source/LibationFileManager/FilePathCache.cs | 178 ++++++++++++------- Source/LibationFileManager/FileTypes.cs | 1 + 3 files changed, 191 insertions(+), 65 deletions(-) diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 9a0b385d..57b86ec8 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -90,6 +90,7 @@ namespace AppScaffolding Migrations.migrate_to_v6_6_9(config); Migrations.migrate_to_v11_5_0(config); Migrations.migrate_to_v11_6_5(config); + Migrations.migrate_to_v12_0_1(config); } /// Initialize logging. Wire-up events. Run after migration @@ -417,6 +418,82 @@ namespace AppScaffolding public List Filters { get; set; } = new(); } + + public static void migrate_to_v12_0_1(Configuration config) + { +#nullable enable + //Migrate from version 1 file cache to the dictionary-based version 2 cache + const string FILENAME_V1 = "FileLocations.json"; + const string FILENAME_V2 = "FileLocationsV2.json"; + + var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1); + var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2); + + if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1)) + { + try + { + //FilePathCache loads the cache in its static constructor, + //so perform migration without using FilePathCache.CacheEntry + if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0) + return; + + Dictionary cache = new(); + + //Convert to c# objects to speed up searching by ID inside the iterator + var allItems + = v1Cache + .Select(i => new + { + Id = i["Id"]?.Value(), + Path = i["Path"]?["Path"]?.Value() + }).Where(i => i.Id != null) + .ToArray(); + + foreach (var id in allItems.Select(i => i.Id).OfType().Distinct()) + { + //Use this opportunity to purge non-existent files and re-classify file types + //(due to *.aax files previously not being classified as FileType.AAXC) + var items = allItems + .Where(i => i.Id == id && File.Exists(i.Path)) + .Select(i => new JObject + { + { "Id", i.Id }, + { "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) }, + { "Path", new JObject{ { "Path", i.Path } } } + }) + .ToArray(); + + if (items.Length == 0) + continue; + + cache[id] = new JArray(items); + } + + var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } }; + var cacheFileText = cacheJson.ToString(Formatting.Indented); + + void migrate() + { + File.WriteAllText(jsonFileV2, cacheFileText); + File.Delete(jsonFileV1); + } + + try { migrate(); } + catch (IOException) + { + try { migrate(); } + catch (IOException) + { + migrate(); + } + } + } + catch { /* eat */ } + } +#nullable restore + } + public static void migrate_to_v11_6_5(Configuration config) { //Settings migration for unsupported sample rates (#1116) diff --git a/Source/LibationFileManager/FilePathCache.cs b/Source/LibationFileManager/FilePathCache.cs index 21aee8f6..96724d4a 100644 --- a/Source/LibationFileManager/FilePathCache.cs +++ b/Source/LibationFileManager/FilePathCache.cs @@ -1,8 +1,8 @@ using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; -using Dinah.Core.Collections.Immutable; using FileManager; using Newtonsoft.Json; @@ -13,78 +13,96 @@ namespace LibationFileManager { public record CacheEntry(string Id, FileType FileType, LongPath Path); - private const string FILENAME = "FileLocations.json"; + private const string FILENAME_V2 = "FileLocationsV2.json"; public static event EventHandler? Inserted; public static event EventHandler? Removed; - private static Cache cache { get; } = new Cache(); + private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2); - private static LongPath jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME); + private static readonly FileCacheV2 Cache = new(); - static FilePathCache() - { + static FilePathCache() + { // load json into memory. if file doesn't exist, nothing to do. save() will create if needed - if (!File.Exists(jsonFile)) + if (!File.Exists(jsonFileV2)) return; - try - { - var list = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFile)); - if (list is null) - throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); - - cache = new Cache(list); - } - catch (Exception ex) - { - Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFile }); - lock (locker) - File.Delete(jsonFile); - return; - } - } - - public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; - - public static List<(FileType fileType, LongPath path)> GetFiles(string id) - => getEntries(entry => entry.Id == id) - .Select(entry => (entry.FileType, entry.Path)) - .ToList(); - - public static LongPath? GetFirstPath(string id, FileType type) - => getEntries(entry => entry.Id == id && entry.FileType == type) - ?.FirstOrDefault() - ?.Path; - - private static IEnumerable getEntries(Func predicate) - { - var entries = cache.Where(predicate).ToList(); - if (entries is null || !entries.Any()) - return Enumerable.Empty(); - - remove(entries.Where(e => !File.Exists(e.Path)).ToList()); - - return cache.Where(predicate).ToList(); - } - - private static void remove(List entries) - { - if (entries is null) - return; - - lock (locker) + try { - foreach (var entry in entries) - { - cache.Remove(entry); - Removed?.Invoke(null, entry); - } - save(); + Cache = JsonConvert.DeserializeObject>(File.ReadAllText(jsonFileV2)) + ?? throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy."); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFileV2 }); + lock (locker) + File.Delete(jsonFileV2); + return; } } - public static void Insert(string id, string path) + public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null; + + public static List<(FileType fileType, LongPath path)> GetFiles(string id) + { + var matchingFiles = Cache.GetIdEntries(id); + + bool cacheChanged = false; + + //Verify all entries exist + for (int i = 0; i < matchingFiles.Count; i++) + { + if (!File.Exists(matchingFiles[i].Path)) + { + matchingFiles.RemoveAt(i); + cacheChanged |= Remove(matchingFiles[i]); + } + } + if (cacheChanged) + save(); + + return matchingFiles.Select(e => (e.FileType, e.Path)).ToList(); + } + + public static LongPath? GetFirstPath(string id, FileType type) + { + var matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList(); + + bool cacheChanged = false; + try + { + //Verify entries exist, but return first matching 'type' + for (int i = 0; i < matchingFiles.Count; i++) + { + if (File.Exists(matchingFiles[i].Path)) + return matchingFiles[i].Path; + else + { + matchingFiles.RemoveAt(i); + cacheChanged |= Remove(matchingFiles[i]); + } + } + return null; + } + finally + { + if (cacheChanged) + save(); + } + } + + private static bool Remove(CacheEntry entry) + { + if (Cache.Remove(entry.Id, entry)) + { + Removed?.Invoke(null, entry); + return true; + } + return false; + } + + public static void Insert(string id, string path) { var type = FileTypes.GetFileTypeFromPath(path); Insert(new CacheEntry(id, type, path)); @@ -92,7 +110,7 @@ namespace LibationFileManager public static void Insert(CacheEntry entry) { - cache.Add(entry); + Cache.Add(entry.Id, entry); Inserted?.Invoke(null, entry); save(); } @@ -102,7 +120,7 @@ namespace LibationFileManager private static void save() { // create json if not exists - static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented)); + static void resave() => File.WriteAllText(jsonFileV2, JsonConvert.SerializeObject(Cache, Formatting.Indented)); lock (locker) { @@ -112,11 +130,41 @@ namespace LibationFileManager try { resave(); } catch (IOException ex) { - Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME}"); + Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME_V2}"); throw; } } } - } - } + } + + private class FileCacheV2 + { + [JsonProperty] + private readonly ConcurrentDictionary> Dictionary = new(); + + public List GetIdEntries(string id) + { + static List empty() => new(); + + return Dictionary.TryGetValue(id, out var entries) ? entries.ToList() : empty(); + } + + public void Add(string id, TEntry entry) + { + Dictionary.AddOrUpdate(id, [entry], (id, entries) => { entries.Add(entry); return entries; }); + } + + public void AddRange(string id, IEnumerable entries) + { + Dictionary.AddOrUpdate(id, entries.ToList(), (id, entries) => + { + entries.AddRange(entries); + return entries; + }); + } + + public bool Remove(string id, TEntry entry) + => Dictionary.TryGetValue(id, out List? entries) && entries.Remove(entry); + } + } } diff --git a/Source/LibationFileManager/FileTypes.cs b/Source/LibationFileManager/FileTypes.cs index 91c2fd25..591525b9 100644 --- a/Source/LibationFileManager/FileTypes.cs +++ b/Source/LibationFileManager/FileTypes.cs @@ -11,6 +11,7 @@ namespace LibationFileManager { private static Dictionary dic => new() { + ["aax"] = FileType.AAXC, ["aaxc"] = FileType.AAXC, ["cue"] = FileType.Cue, ["pdf"] = FileType.PDF, From 3b7d5a354f6c30a90766d81ccd4dce2bbcf94c2f Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 28 Feb 2025 10:42:14 -0700 Subject: [PATCH 3/7] Re-add books to queue that failed or were cancelled. --- .../ViewModels/ProcessQueueViewModel.cs | 10 +++++++++- Source/LibationUiBase/TrackedQueue[T].cs | 10 ++++++++++ .../ProcessQueue/ProcessQueueControl.cs | 10 +++++++++- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index d1005185..5ed38d96 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -118,7 +118,15 @@ namespace LibationAvalonia.ViewModels #region Add Books to Queue private bool isBookInQueue(LibraryBook libraryBook) - => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + { + var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + if (entry == null) + return false; + else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed) + return !Queue.RemoveCompleted(entry); + else + return true; + } public void AddDownloadPdf(LibraryBook libraryBook) => AddDownloadPdf(new List() { libraryBook }); diff --git a/Source/LibationUiBase/TrackedQueue[T].cs b/Source/LibationUiBase/TrackedQueue[T].cs index 2df99d7e..51f36f0b 100644 --- a/Source/LibationUiBase/TrackedQueue[T].cs +++ b/Source/LibationUiBase/TrackedQueue[T].cs @@ -169,6 +169,16 @@ namespace LibationUiBase } } + public T FirstOrDefault(Func predicate) + { + lock (lockObject) + { + return Current != null && predicate(Current) ? Current + : _completed.FirstOrDefault(predicate) is T completed ? completed + : _queued.FirstOrDefault(predicate); + } + } + public void MoveQueuePosition(T item, QueuePosition requestedPosition) { lock (lockObject) diff --git a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs index da7bebbc..c993c8b6 100644 --- a/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs +++ b/Source/LibationWinForms/ProcessQueue/ProcessQueueControl.cs @@ -82,7 +82,15 @@ namespace LibationWinForms.ProcessQueue } private bool isBookInQueue(DataLayer.LibraryBook libraryBook) - => Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + { + var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId); + if (entry == null) + return false; + else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed) + return !Queue.RemoveCompleted(entry); + else + return true; + } public void AddDownloadPdf(DataLayer.LibraryBook libraryBook) => AddDownloadPdf(new List() { libraryBook }); From a790c7535ce2b8f222f433c7ed5e4bb14885e6ec Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 28 Feb 2025 11:13:09 -0700 Subject: [PATCH 4/7] Changes to default directories for file storage (#1112) - Add My Music and Local Application Data to known directories - Make %localappdata%\Libation the default settings folder on *nix machines - Make %MyMusic%\Libation\Books the default books folder on *nix machines --- Source/LibationAvalonia/App.axaml.cs | 8 +++--- .../Controls/DirectorySelectControl.axaml.cs | 2 ++ .../ViewModels/MainVM.VisibleBooks.cs | 4 +-- .../Settings/DownloadDecryptSettingsVM.cs | 1 + .../Settings/ImportantSettingsVM.cs | 3 ++- .../LibationFileManager/AudibleFileStorage.cs | 2 +- .../Configuration.KnownDirectories.cs | 22 ++++++++++++---- .../Configuration.LibationFiles.cs | 25 +++++++++++++------ Source/LibationFileManager/Configuration.cs | 23 +++++++++-------- .../Dialogs/SettingsDialog.DownloadDecrypt.cs | 1 + .../Dialogs/SettingsDialog.Important.cs | 3 ++- Source/LibationWinForms/Program.cs | 4 +-- 12 files changed, 64 insertions(+), 34 deletions(-) diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index 70b091d6..55e2a137 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -44,7 +44,7 @@ namespace LibationAvalonia if (!config.LibationSettingsAreValid) { - var defaultLibationFilesDir = Configuration.UserProfile; + var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory; // check for existing settings in default location var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); @@ -82,8 +82,8 @@ namespace LibationAvalonia // - error message, Exit() if (setupDialog.IsNewUser) { - Configuration.SetLibationFiles(Configuration.UserProfile); - setupDialog.Config.Books = Path.Combine(Configuration.UserProfile, nameof(Configuration.Books)); + Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory); + setupDialog.Config.Books = Configuration.DefaultBooksDirectory; if (setupDialog.Config.LibationSettingsAreValid) { @@ -174,7 +174,7 @@ namespace LibationAvalonia if (continueResult == DialogResult.Yes) { - config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books)); + config.Books = Configuration.DefaultBooksDirectory; if (config.LibationSettingsAreValid) { diff --git a/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs b/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs index 1f8b02ab..65933290 100644 --- a/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/DirectorySelectControl.axaml.cs @@ -51,7 +51,9 @@ namespace LibationAvalonia.Controls { Configuration.KnownDirectories.WinTemp, Configuration.KnownDirectories.UserProfile, + Configuration.KnownDirectories.ApplicationData, Configuration.KnownDirectories.AppDir, + Configuration.KnownDirectories.MyMusic, Configuration.KnownDirectories.MyDocs, Configuration.KnownDirectories.LibationFiles }; diff --git a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs index db6fe17b..782337bf 100644 --- a/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs +++ b/Source/LibationAvalonia/ViewModels/MainVM.VisibleBooks.cs @@ -10,8 +10,8 @@ namespace LibationAvalonia.ViewModels { partial class MainVM { - private int _visibleNotLiberated = 1; - private int _visibleCount = 1; + private int _visibleNotLiberated = 0; + private int _visibleCount = 0; /// The Bottom-right visible book count status text public string VisibleCountText => $"Visible: {_visibleCount}"; diff --git a/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs index 67ec2567..84032983 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/DownloadDecryptSettingsVM.cs @@ -21,6 +21,7 @@ namespace LibationAvalonia.ViewModels.Settings public List KnownDirectories { get; } = new() { Configuration.KnownDirectories.WinTemp, + Configuration.KnownDirectories.ApplicationData, Configuration.KnownDirectories.UserProfile, Configuration.KnownDirectories.AppDir, Configuration.KnownDirectories.MyDocs, diff --git a/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs b/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs index c164b449..6b03c69f 100644 --- a/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs +++ b/Source/LibationAvalonia/ViewModels/Settings/ImportantSettingsVM.cs @@ -67,7 +67,8 @@ namespace LibationAvalonia.ViewModels.Settings { Configuration.KnownDirectories.UserProfile, Configuration.KnownDirectories.AppDir, - Configuration.KnownDirectories.MyDocs + Configuration.KnownDirectories.MyDocs, + Configuration.KnownDirectories.MyMusic, }; public string BooksText { get; } = Configuration.GetDescription(nameof(Configuration.Books)); diff --git a/Source/LibationFileManager/AudibleFileStorage.cs b/Source/LibationFileManager/AudibleFileStorage.cs index 0de15d83..1a1c8b73 100644 --- a/Source/LibationFileManager/AudibleFileStorage.cs +++ b/Source/LibationFileManager/AudibleFileStorage.cs @@ -50,7 +50,7 @@ namespace LibationFileManager get { if (string.IsNullOrWhiteSpace(Configuration.Instance.Books)) - Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books"); + Configuration.Instance.Books = Configuration.DefaultBooksDirectory; return Directory.CreateDirectory(Configuration.Instance.Books).FullName; } } diff --git a/Source/LibationFileManager/Configuration.KnownDirectories.cs b/Source/LibationFileManager/Configuration.KnownDirectories.cs index 0549d54d..8159e0ff 100644 --- a/Source/LibationFileManager/Configuration.KnownDirectories.cs +++ b/Source/LibationFileManager/Configuration.KnownDirectories.cs @@ -14,8 +14,12 @@ namespace LibationFileManager public static string AppDir_Relative => $@".{Path.PathSeparator}{LIBATION_FILES_KEY}"; public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LIBATION_FILES_KEY)); public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation")); + public static string MyMusic => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), "Libation")); public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")); public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")); + public static string LocalAppData => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation")); + public static string DefaultLibationFilesDirectory => !IsWindows ? LocalAppData : UserProfile; + public static string DefaultBooksDirectory => Path.Combine(!IsWindows ? MyMusic : UserProfile, nameof(Books)); public enum KnownDirectories { @@ -34,19 +38,27 @@ namespace LibationFileManager MyDocs = 4, [Description("Your settings folder (aka: Libation Files)")] - LibationFiles = 5 - } - // use func calls so we always get the latest value of LibationFiles - private static List<(KnownDirectories directory, Func getPathFunc)> directoryOptionsPaths { get; } = new() + LibationFiles = 5, + + [Description("User Application Data Folder")] + ApplicationData = 6, + + [Description("My Music")] + MyMusic = 7, + } + // use func calls so we always get the latest value of LibationFiles + private static List<(KnownDirectories directory, Func getPathFunc)> directoryOptionsPaths { get; } = new() { (KnownDirectories.None, () => null), + (KnownDirectories.ApplicationData, () => LocalAppData), + (KnownDirectories.MyMusic, () => MyMusic), (KnownDirectories.UserProfile, () => UserProfile), (KnownDirectories.AppDir, () => AppDir_Relative), (KnownDirectories.WinTemp, () => WinTemp), (KnownDirectories.MyDocs, () => MyDocs), // this is important to not let very early calls try to accidentally load LibationFiles too early. // also, keep this at bottom of this list - (KnownDirectories.LibationFiles, () => libationFilesPathCache) + (KnownDirectories.LibationFiles, () => LibationSettingsDirectory) }; public static string? GetKnownDirectoryPath(KnownDirectories directory) { diff --git a/Source/LibationFileManager/Configuration.LibationFiles.cs b/Source/LibationFileManager/Configuration.LibationFiles.cs index c7118fcf..875c1dc7 100644 --- a/Source/LibationFileManager/Configuration.LibationFiles.cs +++ b/Source/LibationFileManager/Configuration.LibationFiles.cs @@ -22,11 +22,11 @@ namespace LibationFileManager { get { - if (libationFilesPathCache is not null) - return libationFilesPathCache; + if (LibationSettingsDirectory is not null) + return LibationSettingsDirectory; // FIRST: must write here before SettingsFilePath in next step reads cache - libationFilesPathCache = getLibationFilesSettingFromJson(); + LibationSettingsDirectory = getLibationFilesSettingFromJson(); // SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist persistentDictionary = new PersistentDictionary(SettingsFilePath); @@ -42,11 +42,14 @@ namespace LibationFileManager SetWithJsonPath(jsonpath, "path", logPath, true); - return libationFilesPathCache; + return LibationSettingsDirectory; } } - private static string? libationFilesPathCache { get; set; } + /// + /// Directory pointed to by appsettings.json + /// + private static string? LibationSettingsDirectory { get; set; } /// /// Try to find appsettings.json in the following locations: @@ -79,7 +82,7 @@ namespace LibationFileManager string[] possibleAppsettingsDirectories = new[] { ProcessDirectory, - Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"), + LocalAppData, UserProfile, Path.Combine(Path.GetTempPath(), "Libation") }; @@ -106,9 +109,15 @@ namespace LibationFileManager } //Valid appsettings.json not found. Try to create it in each folder. - var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented); + var endingContents = new JObject { { LIBATION_FILES_KEY, DefaultLibationFilesDirectory } }.ToString(Formatting.Indented); + foreach (var dir in possibleAppsettingsDirectories) { + //Don't try to create appsettings.json in the program files directory on *.nix systems. + //However, still _look_ for one there for backwards compatibility with previous installations + if (!IsWindows && dir == ProcessDirectory) + continue; + var appsettingsFile = Path.Combine(dir, appsettings_filename); try @@ -180,7 +189,7 @@ namespace LibationFileManager public static void SetLibationFiles(string directory) { - libationFilesPathCache = null; + LibationSettingsDirectory = null; var startingContents = File.ReadAllText(AppsettingsJsonFile); var jObj = JObject.Parse(startingContents); diff --git a/Source/LibationFileManager/Configuration.cs b/Source/LibationFileManager/Configuration.cs index 4867ce27..4eb01822 100644 --- a/Source/LibationFileManager/Configuration.cs +++ b/Source/LibationFileManager/Configuration.cs @@ -18,9 +18,8 @@ namespace LibationFileManager var pDic = new PersistentDictionary(settingsFile, isReadOnly: false); - var booksDir = pDic.GetString(nameof(Books)); - - if (booksDir is null) return false; + if (pDic.GetString(nameof(Books)) is not string booksDir) + return false; if (!Directory.Exists(booksDir)) { @@ -28,17 +27,21 @@ namespace LibationFileManager throw new DirectoryNotFoundException(settingsFile); //"Books" is not null, so setup has already been run. - //Since Books can't be found, try to create it in Libation settings folder - booksDir = Path.Combine(dir, nameof(Books)); - try + //Since Books can't be found, try to create it + //and then revert to the default books directory + foreach (string d in new string[] { booksDir, DefaultBooksDirectory }) { - Directory.CreateDirectory(booksDir); + try + { + Directory.CreateDirectory(d); - pDic.SetString(nameof(Books), booksDir); + pDic.SetString(nameof(Books), d); - return booksDir is not null && Directory.Exists(booksDir); + return Directory.Exists(d); + } + catch { /* Do Nothing */ } } - catch { return false; } + return false; } return true; diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs index 0aa15dfa..49894a7c 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.DownloadDecrypt.cs @@ -37,6 +37,7 @@ namespace LibationWinForms.Dialogs inProgressSelectControl.SetDirectoryItems(new() { Configuration.KnownDirectories.WinTemp, + Configuration.KnownDirectories.ApplicationData, Configuration.KnownDirectories.UserProfile, Configuration.KnownDirectories.AppDir, Configuration.KnownDirectories.MyDocs, diff --git a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs index 7f68667a..fead9d0b 100644 --- a/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs +++ b/Source/LibationWinForms/Dialogs/SettingsDialog.Important.cs @@ -44,7 +44,8 @@ namespace LibationWinForms.Dialogs { Configuration.KnownDirectories.UserProfile, Configuration.KnownDirectories.AppDir, - Configuration.KnownDirectories.MyDocs + Configuration.KnownDirectories.MyDocs, + Configuration.KnownDirectories.MyMusic, }, Configuration.KnownDirectories.UserProfile, "Books"); diff --git a/Source/LibationWinForms/Program.cs b/Source/LibationWinForms/Program.cs index 2378c0e3..35aa0224 100644 --- a/Source/LibationWinForms/Program.cs +++ b/Source/LibationWinForms/Program.cs @@ -98,7 +98,7 @@ namespace LibationWinForms if (config.LibationSettingsAreValid) return; - var defaultLibationFilesDir = Configuration.UserProfile; + var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory; // check for existing settings in default location var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); @@ -154,7 +154,7 @@ namespace LibationWinForms // INIT DEFAULT SETTINGS // if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog - config.Books ??= Path.Combine(defaultLibationFilesDir, "Books"); + config.Books ??= Configuration.DefaultBooksDirectory; if (config.LibationSettingsAreValid) return; From 68cfae1d58852fee399340b6ecc368d897635192 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 28 Feb 2025 12:04:49 -0700 Subject: [PATCH 5/7] Fix ever-widening Form1 when form size is restored. --- Source/LibationWinForms/Form1.ProcessQueue.cs | 3 ++- Source/LibationWinForms/Form1.cs | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/Source/LibationWinForms/Form1.ProcessQueue.cs b/Source/LibationWinForms/Form1.ProcessQueue.cs index ab9c1147..306aba39 100644 --- a/Source/LibationWinForms/Form1.ProcessQueue.cs +++ b/Source/LibationWinForms/Form1.ProcessQueue.cs @@ -105,13 +105,14 @@ namespace LibationWinForms splitContainer1.Panel2Collapsed = false; processBookQueue1.popoutBtn.Visible = true; } + + Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed)); toggleQueueHideBtn.Text = splitContainer1.Panel2Collapsed ? "❰❰❰" : "❱❱❱"; } private void ToggleQueueHideBtn_Click(object sender, EventArgs e) { SetQueueCollapseState(!splitContainer1.Panel2Collapsed); - Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed)); } private void ProcessBookQueue1_PopOut(object sender, EventArgs e) diff --git a/Source/LibationWinForms/Form1.cs b/Source/LibationWinForms/Form1.cs index 50232c5c..33b06b0e 100644 --- a/Source/LibationWinForms/Form1.cs +++ b/Source/LibationWinForms/Form1.cs @@ -17,7 +17,7 @@ namespace LibationWinForms //Set this size before restoring form size and position splitContainer1.Panel2MinSize = this.DpiScale(350); this.RestoreSizeAndLocation(Configuration.Instance); - this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); + FormClosing += Form1_FormClosing; // this looks like a perfect opportunity to refactor per below. // since this loses design-time tooling and internal access, for now I'm opting for partial classes @@ -58,6 +58,14 @@ namespace LibationWinForms Shown += Form1_Shown; } + private void Form1_FormClosing(object sender, FormClosingEventArgs e) + { + //Always close the queue before saving the form to prevent + //Form1 from getting excessively wide when it's restored. + SetQueueCollapseState(true); + this.SaveSizeAndLocation(Configuration.Instance); + } + private async void Form1_Shown(object sender, EventArgs e) { if (Configuration.Instance.FirstLaunch) From 4170dcc1d5821662163f6a72f36fa7ef9ec4c371 Mon Sep 17 00:00:00 2001 From: Michael Bucari-Tovo Date: Fri, 28 Feb 2025 15:39:31 -0700 Subject: [PATCH 6/7] Chardonnay UI bug fixes and improvements - Theme changes do not require restart - Fix some text appearing black in dark mode - Fix dialog boxes not appearing correctly on Windows - Fix queue vertical scroll bar overlapping items --- Source/LibationAvalonia/App.axaml | 59 ++++++++++++ .../Assets/LibationVectorIcons.xaml | 26 ++++++ .../Controls/Settings/Important.axaml | 11 +-- .../LibationAvalonia/Dialogs/DialogWindow.cs | 93 +++++++++++++++++-- .../Dialogs/ScanAccountsDialog.axaml.cs | 1 - .../Dialogs/SearchSyntaxDialog.axaml.cs | 2 - Source/LibationAvalonia/FormSaveExtension.cs | 18 ---- Source/LibationAvalonia/MessageBox.cs | 10 +- .../Settings/ImportantSettingsVM.cs | 11 ++- .../Views/ProcessQueueControl.axaml | 5 +- 10 files changed, 188 insertions(+), 48 deletions(-) diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index 5ba253c0..e61e851a 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -2,6 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:local="using:LibationAvalonia" xmlns:controls="using:LibationAvalonia.Controls" + xmlns:dialogs="using:LibationAvalonia.Dialogs" x:Class="LibationAvalonia.App" Name="Libation"> @@ -12,6 +13,10 @@ + + + + @@ -81,6 +86,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml b/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml index 9c9d7ec2..095307ae 100644 --- a/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml +++ b/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml @@ -91,6 +91,32 @@ S 192,128 147,147 + + M262,8 + h-117 + a 192,200 0 0 0 -36,82 + a 222,334 41 0 0 138,236 + v158 + h-81 + a 16,16 0 0 0 0,32 + h192 + a 16 16 0 0 0 0,-32 + h-81 + v-158 + a 222,334 -41 0 0 138,-236 + a 192,200 0 0 0 -36,-82 + h-117 + m-99,30 + a 192,200 0 0 0 -26,95 + a 187.5,334 35 0 0 125,159 + a 187.5,334 -35 0 0 125,-159 + a 192,200 0 0 0 -26,-95 + h-198 + M158,136 + a 168,305 35 0 0 104,136 + a 168,305 -35 0 0 104,-136 + + diff --git a/Source/LibationAvalonia/Controls/Settings/Important.axaml b/Source/LibationAvalonia/Controls/Settings/Important.axaml index 9f77b898..6653b82a 100644 --- a/Source/LibationAvalonia/Controls/Settings/Important.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Important.axaml @@ -166,16 +166,7 @@ MinWidth="80" SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}" ItemsSource="{CompiledBinding Themes}"/> - - - + diff --git a/Source/LibationAvalonia/Dialogs/DialogWindow.cs b/Source/LibationAvalonia/Dialogs/DialogWindow.cs index e6213841..38dcd109 100644 --- a/Source/LibationAvalonia/Dialogs/DialogWindow.cs +++ b/Source/LibationAvalonia/Dialogs/DialogWindow.cs @@ -1,4 +1,6 @@ -using Avalonia.Controls; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Controls.Primitives; using LibationFileManager; using System; using System.Threading.Tasks; @@ -9,19 +11,98 @@ namespace LibationAvalonia.Dialogs { public bool SaveAndRestorePosition { get; set; } = true; public Control ControlToFocusOnShow { get; set; } + protected override Type StyleKeyOverride => typeof(DialogWindow); + + public static readonly StyledProperty UseCustomTitleBarProperty = + AvaloniaProperty.Register(nameof(UseCustomTitleBar)); + + public bool UseCustomTitleBar + { + get { return GetValue(UseCustomTitleBarProperty); } + set { SetValue(UseCustomTitleBarProperty, value); } + } + public DialogWindow() { - this.HideMinMaxBtns(); - this.KeyDown += DialogWindow_KeyDown; - this.Initialized += DialogWindow_Initialized; - this.Opened += DialogWindow_Opened; - this.Closing += DialogWindow_Closing; + KeyDown += DialogWindow_KeyDown; + Initialized += DialogWindow_Initialized; + Opened += DialogWindow_Opened; + Closing += DialogWindow_Closing; + + UseCustomTitleBar = Configuration.IsWindows; } + + private bool fixedMinHeight = false; + private bool fixedMaxHeight = false; + private bool fixedHeight = false; + + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + const int customTitleBarHeight = 30; + if (UseCustomTitleBar) + { + if (change.Property == MinHeightProperty && !fixedMinHeight) + { + fixedMinHeight = true; + MinHeight += customTitleBarHeight; + fixedMinHeight = false; + } + if (change.Property == MaxHeightProperty && !fixedMaxHeight) + { + fixedMaxHeight = true; + MaxHeight += customTitleBarHeight; + fixedMaxHeight = false; + } + if (change.Property == HeightProperty && !fixedHeight) + { + fixedHeight = true; + Height += customTitleBarHeight; + fixedHeight = false; + } + } + base.OnPropertyChanged(change); + } + public DialogWindow(bool saveAndRestorePosition) : this() { SaveAndRestorePosition = saveAndRestorePosition; } + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (!UseCustomTitleBar) + return; + + var closeButton = e.NameScope.Find