diff --git a/Scripts/Bundle_MacOS.sh b/Scripts/Bundle_MacOS.sh index 57bc60e1..b0fb88be 100644 --- a/Scripts/Bundle_MacOS.sh +++ b/Scripts/Bundle_MacOS.sh @@ -65,6 +65,9 @@ if [ $? -ne 0 ] exit fi +echo "Make fileicon executable..." +chmod +x $BUNDLE_MACOS/fileicon + echo "Moving icon..." mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 1756da77..fbe45a4e 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -75,13 +75,15 @@ namespace AppScaffolding ??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() } .Max(a => a.Version); - /// Run migrations before loading Configuration for the first time. Then load and return Configuration - public static Configuration RunPreConfigMigrations() + /// Run migrations before loading Configuration for the first time. Then load and return Configuration + public static Configuration RunPreConfigMigrations() { // must occur before access to Configuration instance // // outdated. kept here as an example of what belongs in this area // // Migrations.migrate_to_v5_2_0__pre_config(); + Configuration.SetLibationVersion(BuildVersion); + //***********************************************// // // // do not use Configuration before this line // diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index a051fa84..d166a158 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -242,18 +242,16 @@ namespace ApplicationServices #endregion #region remove/restore books - public static Task RemoveBooksAsync(List idsToRemove) => Task.Run(() => removeBooks(idsToRemove)); - public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove }); - private static int removeBooks(List idsToRemove) + public static Task RemoveBooksAsync(this IEnumerable idsToRemove) => Task.Run(() => removeBooks(idsToRemove)); + public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove }); + private static int removeBooks(IEnumerable removeLibraryBooks) { try { - if (idsToRemove is null || !idsToRemove.Any()) + if (removeLibraryBooks is null || !removeLibraryBooks.Any()) return 0; using var context = DbContexts.GetContext(); - var libBooks = context.GetLibrary_Flat_NoTracking(); - var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList(); // Attach() NoTracking entities before SaveChanges() foreach (var lb in removeLibraryBooks) @@ -275,7 +273,7 @@ namespace ApplicationServices } } - public static int RestoreBooks(this List libraryBooks) + public static int RestoreBooks(this IEnumerable libraryBooks) { try { @@ -303,6 +301,31 @@ namespace ApplicationServices throw; } } + + public static int PermanentlyDeleteBooks(this IEnumerable libraryBooks) + { + try + { + if (libraryBooks is null || !libraryBooks.Any()) + return 0; + + using var context = DbContexts.GetContext(); + + context.LibraryBooks.RemoveRange(libraryBooks); + context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book)); + + var qtyChanges = context.SaveChanges(); + if (qtyChanges > 0) + finalizeLibrarySizeChange(); + + return qtyChanges; + } + catch (Exception ex) + { + Log.Logger.Error(ex, "Error restoring books"); + throw; + } + } #endregion // call this whenever books are added or removed from library @@ -346,8 +369,10 @@ namespace ApplicationServices if (rating is not null) udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating); - }); + }); + public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus, Version libationVersion) + => book.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); }); public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus) => book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus); public static int UpdateBookStatus(this IEnumerable books, LiberatedStatus bookStatus) diff --git a/Source/DataLayer/Configurations/BookConfig.cs b/Source/DataLayer/Configurations/BookConfig.cs index 17054ea7..55c13038 100644 --- a/Source/DataLayer/Configurations/BookConfig.cs +++ b/Source/DataLayer/Configurations/BookConfig.cs @@ -1,5 +1,6 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; +using System; namespace DataLayer.Configurations { @@ -19,40 +20,45 @@ namespace DataLayer.Configurations entity.Ignore(nameof(Book.Authors)); entity.Ignore(nameof(Book.Narrators)); entity.Ignore(nameof(Book.AudioFormat)); - //// these don't seem to matter - //entity.Ignore(nameof(Book.AuthorNames)); - //entity.Ignore(nameof(Book.NarratorNames)); - //entity.Ignore(nameof(Book.HasPdfs)); + //// these don't seem to matter + //entity.Ignore(nameof(Book.AuthorNames)); + //entity.Ignore(nameof(Book.NarratorNames)); + //entity.Ignore(nameof(Book.HasPdfs)); - // OwnsMany: "Can only ever appear on navigation properties of other entity types. - // Are automatically loaded, and can only be tracked by a DbContext alongside their owner." - entity - .OwnsMany(b => b.Supplements, b_s => - { - b_s.WithOwner(s => s.Book) - .HasForeignKey(s => s.BookId); - b_s.HasKey(s => s.SupplementId); - }); + // OwnsMany: "Can only ever appear on navigation properties of other entity types. + // Are automatically loaded, and can only be tracked by a DbContext alongside their owner." + entity + .OwnsMany(b => b.Supplements, b_s => + { + b_s.WithOwner(s => s.Book) + .HasForeignKey(s => s.BookId); + b_s.HasKey(s => s.SupplementId); + }); // even though it's owned, we need to map its backing field entity .Metadata .FindNavigation(nameof(Book.Supplements)) .SetPropertyAccessMode(PropertyAccessMode.Field); - // owns it 1:1, store in separate table - entity - .OwnsOne(b => b.UserDefinedItem, b_udi => - { - b_udi.WithOwner(udi => udi.Book) - .HasForeignKey(udi => udi.BookId); - b_udi.Property(udi => udi.BookId).ValueGeneratedNever(); - b_udi.ToTable(nameof(Book.UserDefinedItem)); + // owns it 1:1, store in separate table + entity + .OwnsOne(b => b.UserDefinedItem, b_udi => + { + b_udi.WithOwner(udi => udi.Book) + .HasForeignKey(udi => udi.BookId); + b_udi.Property(udi => udi.BookId).ValueGeneratedNever(); + b_udi.ToTable(nameof(Book.UserDefinedItem)); - // owns it 1:1, store in same table - b_udi.OwnsOne(udi => udi.Rating); - }); + b_udi.Property(udi => udi.LastDownloaded); + b_udi + .Property(udi => udi.LastDownloadedVersion) + .HasConversion(ver => ver.ToString(), str => Version.Parse(str)); - entity + // owns it 1:1, store in same table + b_udi.OwnsOne(udi => udi.Rating); + }); + + entity .Metadata .FindNavigation(nameof(Book.ContributorsLink)) // PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field @@ -68,6 +74,6 @@ namespace DataLayer.Configurations .HasOne(b => b.Category) .WithMany() .HasForeignKey(b => b.CategoryId); - } + } } } \ No newline at end of file diff --git a/Source/DataLayer/EfClasses/UserDefinedItem.cs b/Source/DataLayer/EfClasses/UserDefinedItem.cs index d34c45e7..86c87fcf 100644 --- a/Source/DataLayer/EfClasses/UserDefinedItem.cs +++ b/Source/DataLayer/EfClasses/UserDefinedItem.cs @@ -24,8 +24,27 @@ namespace DataLayer { internal int BookId { get; private set; } public Book Book { get; private set; } + public DateTime? LastDownloaded { get; private set; } + public Version LastDownloadedVersion { get; private set; } - private UserDefinedItem() { } + public void SetLastDownloaded(Version version) + { + if (LastDownloadedVersion != version) + { + LastDownloadedVersion = version; + OnItemChanged(nameof(LastDownloadedVersion)); + } + + if (version is null) + LastDownloaded = null; + else + { + LastDownloaded = DateTime.Now; + OnItemChanged(nameof(LastDownloaded)); + } + } + + private UserDefinedItem() { } internal UserDefinedItem(Book book) { ArgumentValidator.EnsureNotNull(book, nameof(book)); diff --git a/Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs b/Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs new file mode 100644 index 00000000..ffcec6cc --- /dev/null +++ b/Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs @@ -0,0 +1,410 @@ +// +using System; +using DataLayer; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace DataLayer.Migrations +{ + [DbContext(typeof(LibationContext))] + [Migration("20230302220539_AddLastDownloadedInfo")] + partial class AddLastDownloadedInfo + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Property("BookId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property("CategoryId") + .HasColumnType("INTEGER"); + + b.Property("ContentType") + .HasColumnType("INTEGER"); + + b.Property("DatePublished") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAbridged") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LengthInMinutes") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .HasColumnType("TEXT"); + + b.Property("PictureId") + .HasColumnType("TEXT"); + + b.Property("PictureLarge") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("_audioFormat") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.HasIndex("AudibleProductId"); + + b.HasIndex("CategoryId"); + + b.ToTable("Books"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("ContributorId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.HasKey("BookId", "ContributorId", "Role"); + + b.HasIndex("BookId"); + + b.HasIndex("ContributorId"); + + b.ToTable("BookContributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.Property("CategoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleCategoryId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ParentCategoryCategoryId") + .HasColumnType("INTEGER"); + + b.HasKey("CategoryId"); + + b.HasIndex("AudibleCategoryId"); + + b.HasIndex("ParentCategoryCategoryId"); + + b.ToTable("Categories"); + + b.HasData( + new + { + CategoryId = -1, + AudibleCategoryId = "", + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Property("ContributorId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleContributorId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Account") + .HasColumnType("TEXT"); + + b.Property("DateAdded") + .HasColumnType("TEXT"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.HasKey("BookId"); + + b.ToTable("LibraryBooks"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Property("SeriesId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AudibleSeriesId") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId"); + + b.HasIndex("AudibleSeriesId"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("BookId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("TEXT"); + + b.HasKey("SeriesId", "BookId"); + + b.HasIndex("BookId"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBook"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.HasOne("DataLayer.Category", "Category") + .WithMany() + .HasForeignKey("CategoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("DataLayer.Rating", "Rating", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("OverallRating") + .HasColumnType("REAL"); + + b1.Property("PerformanceRating") + .HasColumnType("REAL"); + + b1.Property("StoryRating") + .HasColumnType("REAL"); + + b1.HasKey("BookId"); + + b1.ToTable("Books"); + + b1.WithOwner() + .HasForeignKey("BookId"); + }); + + b.OwnsMany("DataLayer.Supplement", "Supplements", b1 => + { + b1.Property("SupplementId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("Url") + .HasColumnType("TEXT"); + + b1.HasKey("SupplementId"); + + b1.HasIndex("BookId"); + + b1.ToTable("Supplement"); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.Navigation("Book"); + }); + + b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 => + { + b1.Property("BookId") + .HasColumnType("INTEGER"); + + b1.Property("BookStatus") + .HasColumnType("INTEGER"); + + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + + b1.Property("PdfStatus") + .HasColumnType("INTEGER"); + + b1.Property("Tags") + .HasColumnType("TEXT"); + + b1.HasKey("BookId"); + + b1.ToTable("UserDefinedItem", (string)null); + + b1.WithOwner("Book") + .HasForeignKey("BookId"); + + b1.OwnsOne("DataLayer.Rating", "Rating", b2 => + { + b2.Property("UserDefinedItemBookId") + .HasColumnType("INTEGER"); + + b2.Property("OverallRating") + .HasColumnType("REAL"); + + b2.Property("PerformanceRating") + .HasColumnType("REAL"); + + b2.Property("StoryRating") + .HasColumnType("REAL"); + + b2.HasKey("UserDefinedItemBookId"); + + b2.ToTable("UserDefinedItem"); + + b2.WithOwner() + .HasForeignKey("UserDefinedItemBookId"); + }); + + b1.Navigation("Book"); + + b1.Navigation("Rating"); + }); + + b.Navigation("Category"); + + b.Navigation("Rating"); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem"); + }); + + modelBuilder.Entity("DataLayer.BookContributor", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("ContributorsLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Contributor", "Contributor") + .WithMany("BooksLink") + .HasForeignKey("ContributorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Contributor"); + }); + + modelBuilder.Entity("DataLayer.Category", b => + { + b.HasOne("DataLayer.Category", "ParentCategory") + .WithMany() + .HasForeignKey("ParentCategoryCategoryId"); + + b.Navigation("ParentCategory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithOne() + .HasForeignKey("DataLayer.LibraryBook", "BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + }); + + modelBuilder.Entity("DataLayer.SeriesBook", b => + { + b.HasOne("DataLayer.Book", "Book") + .WithMany("SeriesLink") + .HasForeignKey("BookId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("DataLayer.Series", "Series") + .WithMany("BooksLink") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Book"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("DataLayer.Book", b => + { + b.Navigation("ContributorsLink"); + + b.Navigation("SeriesLink"); + }); + + modelBuilder.Entity("DataLayer.Contributor", b => + { + b.Navigation("BooksLink"); + }); + + modelBuilder.Entity("DataLayer.Series", b => + { + b.Navigation("BooksLink"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.cs b/Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.cs new file mode 100644 index 00000000..db823779 --- /dev/null +++ b/Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.cs @@ -0,0 +1,39 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// + public partial class AddLastDownloadedInfo : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "LastDownloaded", + table: "UserDefinedItem", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastDownloadedVersion", + table: "UserDefinedItem", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "LastDownloaded", + table: "UserDefinedItem"); + + migrationBuilder.DropColumn( + name: "LastDownloadedVersion", + table: "UserDefinedItem"); + } + } +} diff --git a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs index 080d7beb..747513d6 100644 --- a/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ namespace DataLayer.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.2"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.3"); modelBuilder.Entity("DataLayer.Book", b => { @@ -272,6 +272,12 @@ namespace DataLayer.Migrations b1.Property("BookStatus") .HasColumnType("INTEGER"); + b1.Property("LastDownloaded") + .HasColumnType("TEXT"); + + b1.Property("LastDownloadedVersion") + .HasColumnType("TEXT"); + b1.Property("PdfStatus") .HasColumnType("INTEGER"); diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index c6ea7e4b..3d43c8b6 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -41,7 +41,7 @@ namespace FileLiberator OnBegin(libraryBook); - try + try { if (libraryBook.Book.Audio_Exists()) return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; @@ -61,31 +61,30 @@ namespace FileLiberator } // decrypt failed - if (!success) + if (!success || getFirstAudioFile(entries) == default) { - foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC)) - FileUtility.SaferDelete(tmpFile.Path); + await Task.WhenAll( + entries + .Where(f => f.FileType != FileType.AAXC) + .Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path)))); - return abDownloader?.IsCanceled == true ? - new StatusHandler { "Cancelled" } : - new StatusHandler { "Decrypt failed" }; + return + abDownloader?.IsCanceled is true + ? new StatusHandler { "Cancelled" } + : new StatusHandler { "Decrypt failed" }; } - // moves new files from temp dir to final dest. - // This could take a few seconds if moving hundreds of files. - var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries)); + var finalStorageDir = getDestinationDirectory(libraryBook); - // decrypt failed - if (finalStorageDir is null) - return new StatusHandler { "Cannot find final audio file after decryption" }; + Task[] finalTasks = new[] + { + Task.Run(() => downloadCoverArt(libraryBook)), + Task.Run(() => moveFilesToBooksDir(libraryBook, entries)), + Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)), + Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir)) + }; - if (Configuration.Instance.DownloadCoverArt) - downloadCoverArt(libraryBook); - - // contains logic to check for config setting and OS - WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir); - - libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated); + await Task.WhenAll(finalTasks); return new StatusHandler(); } @@ -131,8 +130,8 @@ namespace FileLiberator abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path); - // REAL WORK DONE HERE - return await abDownloader.RunAsync(); + // REAL WORK DONE HERE + return await abDownloader.RunAsync(); } private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic) @@ -335,18 +334,12 @@ namespace FileLiberator /// Move new files to 'Books' directory /// Return directory if audiobook file(s) were successfully created and can be located on disk. Else null. - private static string moveFilesToBooksDir(LibraryBook libraryBook, List entries) + private static void moveFilesToBooksDir(LibraryBook libraryBook, List entries) { // create final directory. move each file into it - var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); - Directory.CreateDirectory(destinationDir); + var destinationDir = getDestinationDirectory(libraryBook); - FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio); - - if (getFirstAudio() == default) - return null; - - for (var i = 0; i < entries.Count; i++) + for (var i = 0; i < entries.Count; i++) { var entry = entries[i]; @@ -357,22 +350,33 @@ namespace FileLiberator entries[i] = entry with { Path = realDest }; } - var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); + var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); if (cue != default) - Cue.UpdateFileName(cue.Path, getFirstAudio().Path); + Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path); AudibleFileStorage.Audio.Refresh(); - - return destinationDir; } - private static void downloadCoverArt(LibraryBook libraryBook) + private static string getDestinationDirectory(LibraryBook libraryBook) { + var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); + if (!Directory.Exists(destinationDir)) + Directory.CreateDirectory(destinationDir); + return destinationDir; + } + + private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable entries) + => entries.FirstOrDefault(f => f.FileType == FileType.Audio); + + private static void downloadCoverArt(LibraryBook libraryBook) + { + if (!Configuration.Instance.DownloadCoverArt) return; + var coverPath = "[null]"; try { - var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook); + var destinationDir = getDestinationDirectory(libraryBook); coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg"); coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); diff --git a/Source/LibationAvalonia/Assets/Arrows_left.png b/Source/LibationAvalonia/Assets/Arrows_left.png new file mode 100644 index 00000000..a1a73311 Binary files /dev/null and b/Source/LibationAvalonia/Assets/Arrows_left.png differ diff --git a/Source/LibationAvalonia/Assets/Arrows_right.png b/Source/LibationAvalonia/Assets/Arrows_right.png new file mode 100644 index 00000000..126dfa40 Binary files /dev/null and b/Source/LibationAvalonia/Assets/Arrows_right.png differ diff --git a/Source/LibationAvalonia/Controls/CheckedListBox.axaml b/Source/LibationAvalonia/Controls/CheckedListBox.axaml new file mode 100644 index 00000000..cf70be9a --- /dev/null +++ b/Source/LibationAvalonia/Controls/CheckedListBox.axaml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs b/Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs new file mode 100644 index 00000000..f0ab1e61 --- /dev/null +++ b/Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs @@ -0,0 +1,46 @@ +using Avalonia; +using Avalonia.Collections; +using Avalonia.Controls; +using LibationAvalonia.ViewModels; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace LibationAvalonia.Controls +{ + public partial class CheckedListBox : UserControl + { + public static readonly StyledProperty> ItemsProperty = + AvaloniaProperty.Register>(nameof(Items)); + + public AvaloniaList Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); } + private CheckedListBoxViewModel _viewModel = new(); + + public CheckedListBox() + { + InitializeComponent(); + scroller.DataContext = _viewModel; + } + protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) + { + if (change.Property.Name == nameof(Items) && Items != null) + _viewModel.CheckboxItems = Items; + base.OnPropertyChanged(change); + } + + private class CheckedListBoxViewModel : ViewModelBase + { + private AvaloniaList _checkboxItems; + public AvaloniaList CheckboxItems { get => _checkboxItems; set => this.RaiseAndSetIfChanged(ref _checkboxItems, value); } + } + } + + public class CheckBoxViewModel : ViewModelBase + { + private bool _isChecked; + public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); } + private object _bookText; + public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); } + } +} diff --git a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml index ffaa7fc9..6c7c0419 100644 --- a/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml +++ b/Source/LibationAvalonia/Dialogs/SettingsDialog.axaml @@ -2,7 +2,7 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" - mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700" + mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750" MinWidth="900" MinHeight="700" x:Class="LibationAvalonia.Dialogs.SettingsDialog" xmlns:controls="clr-namespace:LibationAvalonia.Controls" @@ -376,7 +376,7 @@ Grid.Row="3" Margin="5" VerticalAlignment="Top" - IsVisible="{Binding IsWindows}" + IsVisible="{Binding !IsLinux}" IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}"> AppScaffolding.LibationScaffolding.ReleaseIdentifier is AppScaffolding.ReleaseIdentifier.WindowsAvalonia; + public bool IsLinux => Configuration.IsLinux; + public bool IsWindows => Configuration.IsWindows; public ImportantSettings ImportantSettings { get; private set; } public ImportSettings ImportSettings { get; private set; } public DownloadDecryptSettings DownloadDecryptSettings { get; private set; } diff --git a/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml b/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml new file mode 100644 index 00000000..1738a67e --- /dev/null +++ b/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs new file mode 100644 index 00000000..073a0845 --- /dev/null +++ b/Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs @@ -0,0 +1,145 @@ +using ApplicationServices; +using Avalonia.Collections; +using Avalonia.Controls; +using Avalonia.Threading; +using DataLayer; +using LibationAvalonia.Controls; +using LibationAvalonia.ViewModels; +using LibationFileManager; +using ReactiveUI; +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading.Tasks; + +namespace LibationAvalonia.Dialogs +{ + public partial class TrashBinDialog : Window + { + TrashBinViewModel _viewModel; + + public TrashBinDialog() + { + InitializeComponent(); + this.RestoreSizeAndLocation(Configuration.Instance); + DataContext = _viewModel = new(); + + this.Closing += (_,_) => this.SaveSizeAndLocation(Configuration.Instance); + } + + public async void EmptyTrash_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => await _viewModel.PermanentlyDeleteCheckedAsync(); + public async void Restore_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + => await _viewModel.RestoreCheckedAsync(); + } + + public class TrashBinViewModel : ViewModelBase, IDisposable + { + public AvaloniaList DeletedBooks { get; } + public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}"; + + private bool _controlsEnabled = true; + public bool ControlsEnabled { get => _controlsEnabled; set=> this.RaiseAndSetIfChanged(ref _controlsEnabled, value); } + + private bool? everythingChecked = false; + public bool? EverythingChecked + { + get => everythingChecked; + set + { + everythingChecked = value ?? false; + + if (everythingChecked is true) + CheckAll(); + else if (everythingChecked is false) + UncheckAll(); + } + } + + private int _totalBooksCount = 0; + private int _checkedBooksCount = -1; + public int CheckedBooksCount + { + get => _checkedBooksCount; + set + { + if (_checkedBooksCount != value) + { + _checkedBooksCount = value; + this.RaisePropertyChanged(nameof(CheckedCountText)); + } + + everythingChecked + = _checkedBooksCount == 0 || _totalBooksCount == 0 ? false + : _checkedBooksCount == _totalBooksCount ? true + : null; + + this.RaisePropertyChanged(nameof(EverythingChecked)); + } + } + + public IEnumerable CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast(); + + public TrashBinViewModel() + { + DeletedBooks = new() + { + ResetBehavior = ResetBehavior.Remove + }; + + tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged); + Reload(); + } + + public void CheckAll() + { + foreach (var item in DeletedBooks) + item.IsChecked = true; + } + + public void UncheckAll() + { + foreach (var item in DeletedBooks) + item.IsChecked = false; + } + + public async Task RestoreCheckedAsync() + { + ControlsEnabled = false; + var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks); + if (qtyChanges > 0) + Reload(); + ControlsEnabled = true; + } + + public async Task PermanentlyDeleteCheckedAsync() + { + ControlsEnabled = false; + var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks); + if (qtyChanges > 0) + Reload(); + ControlsEnabled = true; + } + + private void Reload() + { + var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks(); + + DeletedBooks.Clear(); + DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb })); + + _totalBooksCount = DeletedBooks.Count; + CheckedBooksCount = 0; + } + + private IDisposable tracker; + private void CheckboxPropertyChanged(Tuple e) + { + if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked)) + CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked); + } + + public void Dispose() => tracker?.Dispose(); + } +} diff --git a/Source/LibationAvalonia/LibationAvalonia.csproj b/Source/LibationAvalonia/LibationAvalonia.csproj index a8be561c..d7d12d4e 100644 --- a/Source/LibationAvalonia/LibationAvalonia.csproj +++ b/Source/LibationAvalonia/LibationAvalonia.csproj @@ -31,6 +31,8 @@ + + diff --git a/Source/LibationAvalonia/ViewModels/GridEntry.cs b/Source/LibationAvalonia/ViewModels/GridEntry.cs index cfe0180c..cf21da7c 100644 --- a/Source/LibationAvalonia/ViewModels/GridEntry.cs +++ b/Source/LibationAvalonia/ViewModels/GridEntry.cs @@ -35,18 +35,31 @@ namespace LibationAvalonia.ViewModels #region Model properties exposed to the view private Avalonia.Media.Imaging.Bitmap _cover; - public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } } - public string PurchaseDate { get; protected set; } - public string Series { get; protected set; } - public string Title { get; protected set; } - public string Length { get; protected set; } - public string Authors { get; protected set; } - public string Narrators { get; protected set; } - public string Category { get; protected set; } - public string Misc { get; protected set; } - public string Description { get; protected set; } - public Rating ProductRating { get; protected set; } + private string _purchasedate; + private string _series; + private string _title; + private string _length; + private string _authors; + private string _narrators; + private string _category; + private string _misc; + private LastDownloadStatus _lastDownload; + private string _description; + private Rating _productrating; protected Rating _myRating; + + public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); } + public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); } + public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); } + public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); } + public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); } + public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); } + public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); } + public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); } + public LastDownloadStatus LastDownload { get => _lastDownload; protected set => this.RaiseAndSetIfChanged(ref _lastDownload, value); } + public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); } + public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); } + public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); } public Rating MyRating { get => _myRating; @@ -111,6 +124,7 @@ namespace LibationAvalonia.ViewModels { typeof(bool), new ObjectComparer() }, { typeof(DateTime), new ObjectComparer() }, { typeof(LiberateButtonStatus), new ObjectComparer() }, + { typeof(LastDownloadStatus), new ObjectComparer() }, }; #endregion diff --git a/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs b/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs index e00c9461..8b823191 100644 --- a/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs +++ b/Source/LibationAvalonia/ViewModels/LibraryBookEntry.cs @@ -58,8 +58,22 @@ namespace LibationAvalonia.ViewModels public LibraryBookEntry(LibraryBook libraryBook) { - LibraryBook = libraryBook; + setLibraryBook(libraryBook); LoadCover(); + } + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + if (AudibleProductId != libraryBook.Book.AudibleProductId) + throw new Exception("Invalid grid entry update. IDs must match"); + + UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged; + setLibraryBook(libraryBook); + } + + private void setLibraryBook(LibraryBook libraryBook) + { + LibraryBook = libraryBook; Title = Book.Title; Series = Book.SeriesNames(); @@ -73,10 +87,12 @@ namespace LibationAvalonia.ViewModels Narrators = Book.NarratorNames(); Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(libraryBook); + LastDownload = new(Book.UserDefinedItem); LongDescription = GetDescriptionDisplay(Book); Description = TrimTextToWord(LongDescription, 62); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; + this.RaisePropertyChanged(nameof(MyRating)); UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged; } @@ -112,6 +128,10 @@ namespace LibationAvalonia.ViewModels _pdfStatus = udi.PdfStatus; this.RaisePropertyChanged(nameof(Liberate)); break; + case nameof(udi.LastDownloaded): + LastDownload = new(udi); + this.RaisePropertyChanged(nameof(LastDownload)); + break; } } @@ -134,6 +154,7 @@ namespace LibationAvalonia.ViewModels { nameof(Description), () => Description }, { nameof(Category), () => Category }, { nameof(Misc), () => Misc }, + { nameof(LastDownload), () => LastDownload }, { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, { nameof(Liberate), () => Liberate }, { nameof(DateAdded), () => DateAdded }, diff --git a/Source/LibationAvalonia/ViewModels/MainWindowViewModel.cs b/Source/LibationAvalonia/ViewModels/MainWindowViewModel.cs index 8f5fa93a..db96c036 100644 --- a/Source/LibationAvalonia/ViewModels/MainWindowViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/MainWindowViewModel.cs @@ -1,4 +1,5 @@ using ApplicationServices; +using Avalonia.Media.Imaging; using Dinah.Core; using LibationFileManager; using ReactiveUI; @@ -135,15 +136,9 @@ namespace LibationAvalonia.ViewModels set { this.RaiseAndSetIfChanged(ref _queueOpen, value); - QueueHideButtonText = _queueOpen? "❱❱❱" : "❰❰❰"; - this.RaisePropertyChanged(nameof(QueueHideButtonText)); } } - /// The Process Queue's Expand/Collapse button display text - public string QueueHideButtonText { get; private set; } - - /// The number of books visible in the Product Display public int VisibleCount diff --git a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs index c1dda16e..fbcc46d9 100644 --- a/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProcessQueueViewModel.cs @@ -1,4 +1,5 @@ using ApplicationServices; +using Avalonia.Collections; using Avalonia.Controls; using Avalonia.Threading; using DataLayer; @@ -16,9 +17,8 @@ namespace LibationAvalonia.ViewModels public class ProcessQueueViewModel : ViewModelBase, ILogForm { public ObservableCollection LogEntries { get; } = new(); - public TrackedQueue Items { get; } = new(); - - private TrackedQueue Queue => Items; + public AvaloniaList Items { get; } = new(); + public TrackedQueue Queue { get; } public ProcessBookViewModel SelectedItem { get; set; } public Task QueueRunner { get; private set; } public bool Running => !QueueRunner?.IsCompleted ?? false; @@ -28,6 +28,7 @@ namespace LibationAvalonia.ViewModels public ProcessQueueViewModel() { Logger = LogMe.RegisterForm(this); + Queue = new(Items); Queue.QueuededCountChanged += Queue_QueuededCountChanged; Queue.CompletedCountChanged += Queue_CompletedCountChanged; @@ -88,19 +89,19 @@ namespace LibationAvalonia.ViewModels public decimal SpeedLimitIncrement { get; private set; } - private 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); ErrorCount = errCount; CompletedCount = completeCount; - Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress))); + await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress))); } - private void Queue_QueuededCountChanged(object sender, int cueCount) + private async void Queue_QueuededCountChanged(object sender, int cueCount) { QueuedCount = cueCount; - Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress))); + await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress))); } public void WriteLine(string text) diff --git a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs index db25a07e..e7785de4 100644 --- a/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs +++ b/Source/LibationAvalonia/ViewModels/ProductsDisplayViewModel.cs @@ -20,11 +20,11 @@ namespace LibationAvalonia.ViewModels public event EventHandler RemovableCountChanged; /// Backing list of all grid entries - private readonly List SOURCE = new(); + private readonly AvaloniaList SOURCE = new(); /// Grid entries included in the filter set. If null, all grid entries are shown private List FilteredInGridEntries; public string FilterString { get; private set; } - public DataGridCollectionView GridEntries { get; } + public DataGridCollectionView GridEntries { get; private set; } private bool _removeColumnVisivle; public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } @@ -42,59 +42,60 @@ namespace LibationAvalonia.ViewModels public ProductsDisplayViewModel() { SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated; - GridEntries = new(SOURCE); - GridEntries.Filter = CollectionFilter; + VisibleCountChanged?.Invoke(this, 0); + } - GridEntries.CollectionChanged += (s, e) - => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); + private static readonly System.Reflection.MethodInfo SetFlagsMethod; + + /// + /// Tells the whether it should process changes to the underlying collection + /// + /// DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4 + private void SetShouldProcessCollectionChanged(bool flagSet) + => SetFlagsMethod.Invoke(GridEntries, new object[] { 4, flagSet }); + + static ProductsDisplayViewModel() + { + /* + * When a book is removed from the library, SearchEngineUpdated is fired before LibrarySizeChanged, so + * the book is removed from the filtered set and the grid is refreshed before RemoveBooks() is ever + * called. + * + * To remove an item from DataGridCollectionView, it must be be in the current filtered view. If it's + * not and you try to remove the book from the source list, the source will fire NotifyCollectionChanged + * on an invalid item and the DataGridCollectionView will throw an exception. There are two ways to + * remove an item that is filtered out of the DataGridCollectionView: + * + * (1) Re-add the item to the filtered-in list and refresh the grid so DataGridCollectionView knows + * that the item is present. This causes the whole grid to flicker to refresh twice in rapid + * succession, which is undesirable. + * + * (2) Remove it from the underlying collection and suppress NotifyCollectionChanged. This is the + * method used. Steps to complete a removal using this method: + * + * (a) Set DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged to false. + * (b) Remove the item from the source list. The source will fire NotifyCollectionChanged, but the + * DataGridCollectionView will ignore it. + * (c) Reset the flag to true. + */ + + SetFlagsMethod = + typeof(DataGridCollectionView) + .GetMethod("SetFlag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); } #region Display Functions - /// - /// Call when there's been a change to the library - /// - public async Task DisplayBooksAsync(List dbBooks) + internal void BindToGrid(List dbBooks) { - try + GridEntries = new(SOURCE) { - var existingSeriesEntries = SOURCE.SeriesEntries().ToList(); + Filter = CollectionFilter + }; - FilteredInGridEntries?.Clear(); - SOURCE.Clear(); - SOURCE.AddRange(CreateGridEntries(dbBooks)); + GridEntries.CollectionChanged += (_, _) + => VisibleCountChanged?.Invoke(this, GridEntries.OfType().Count()); - //If replacing the list, preserve user's existing collapse/expand - //state. When resetting a list, default state is cosed. - foreach (var series in existingSeriesEntries) - { - var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId); - if (sEntry is SeriesEntry se) - se.Liberate.Expanded = series.Liberate.Expanded; - } - - //Run query on new list - FilteredInGridEntries = QueryResults(SOURCE, FilterString); - - await refreshGrid(); - - } - catch (Exception ex) - { - Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel)); - } - } - - private async Task refreshGrid() - { - if (GridEntries.IsEditingItem) - await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit); - - await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); - } - - private static List CreateGridEntries(IEnumerable dbBooks) - { var geList = dbBooks .Where(lb => lb.Book.IsProduct()) .Select(b => new LibraryBookEntry(b)) @@ -103,26 +104,159 @@ namespace LibationAvalonia.ViewModels var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()); - foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent())) + var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList(); + + foreach (var parent in seriesBooks) { var seriesEpisodes = episodes.FindChildren(parent); if (!seriesEpisodes.Any()) continue; var seriesEntry = new SeriesEntry(parent, seriesEpisodes); + seriesEntry.Liberate.Expanded = false; geList.Add(seriesEntry); geList.AddRange(seriesEntry.Children); } - var bookList = geList.OrderByDescending(e => e.DateAdded).ToList(); + //Create the filtered-in list before adding entries to avoid a refresh + FilteredInGridEntries = QueryResults(geList, FilterString); + SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded)); + } - //ListIndex is used by RowComparer to make column sort stable - int index = 0; - foreach (GridEntry di in bookList) - di.ListIndex = index++; + /// + /// Call when there's been a change to the library + /// + internal async Task UpdateGridAsync(List dbBooks) + { + #region Add new or update existing grid entries - return bookList; + //Add absent entries to grid, or update existing entry + var allEntries = SOURCE.BookEntries(); + var seriesEntries = SOURCE.SeriesEntries().ToList(); + var parentedEpisodes = dbBooks.ParentedEpisodes(); + + await Dispatcher.UIThread.InvokeAsync(() => + { + foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded)) + { + var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId); + + if (libraryBook.Book.IsProduct()) + UpsertBook(libraryBook, existingEntry); + else if (parentedEpisodes.Any(lb => lb == libraryBook)) + //Only try to add or update is this LibraryBook is a know child of a parent + UpsertEpisode(libraryBook, existingEntry, seriesEntries, dbBooks); + } + }); + + #endregion + + #region Remove entries no longer in the library + + //Rapid successive book removals will cause changes to SOURCE after the update has + //begun but before it has completed, so perform all updates on a copy of the list. + var sourceSnapshot = SOURCE.ToList(); + + // remove deleted from grid. + // note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this + var removedBooks = + sourceSnapshot + .BookEntries() + .ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId); + + //Remove books in series from their parents' Children list + foreach (var removed in removedBooks.Where(b => b.Parent is not null)) + removed.Parent.RemoveChild(removed); + + //Remove series that have no children + var removedSeries = sourceSnapshot.EmptySeries(); + + await Dispatcher.UIThread.InvokeAsync(() => RemoveBooks(removedBooks, removedSeries)); + + #endregion + + await Filter(FilterString); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); + } + + private void RemoveBooks(IEnumerable removedBooks, IEnumerable removedSeries) + { + foreach (var removed in removedBooks.Cast().Concat(removedSeries).Where(b => b is not null).ToList()) + { + if (GridEntries.PassesFilter(removed)) + GridEntries.Remove(removed); + else + { + SetShouldProcessCollectionChanged(false); + SOURCE.Remove(removed); + SetShouldProcessCollectionChanged(true); + } + } + } + + private void UpsertBook(LibraryBook book, LibraryBookEntry existingBookEntry) + { + if (existingBookEntry is null) + // Add the new product to top + SOURCE.Insert(0, new LibraryBookEntry(book)); + else + // update existing + existingBookEntry.UpdateLibraryBook(book); + } + + private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List seriesEntries, IEnumerable dbBooks) + { + if (existingEpisodeEntry is null) + { + LibraryBookEntry episodeEntry; + + var seriesEntry = seriesEntries.FindSeriesParent(episodeBook); + + if (seriesEntry is null) + { + //Series doesn't exist yet, so create and add it + var seriesBook = dbBooks.FindSeriesParent(episodeBook); + + if (seriesBook is null) + { + //This is only possible if the user's db has some malformed + //entries from earlier Libation releases that could not be + //automatically fixed. Log, but don't throw. + Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames()); + return; + } + + seriesEntry = new SeriesEntry(seriesBook, new[] { episodeBook }); + seriesEntries.Add(seriesEntry); + + episodeEntry = seriesEntry.Children[0]; + seriesEntry.Liberate.Expanded = true; + SOURCE.Insert(0, seriesEntry); + } + else + { + //Series exists. Create and add episode child then update the SeriesEntry + episodeEntry = new(episodeBook) { Parent = seriesEntry }; + seriesEntry.Children.Add(episodeEntry); + var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId); + seriesEntry.UpdateLibraryBook(seriesBook); + } + + //Add episode to the grid beneath the parent + int seriesIndex = SOURCE.IndexOf(seriesEntry); + SOURCE.Insert(seriesIndex + 1, episodeEntry); + } + else + existingEpisodeEntry.UpdateLibraryBook(episodeBook); + } + + private async Task refreshGrid() + { + if (GridEntries.IsEditingItem) + await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit); + + await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh); } public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry) @@ -138,9 +272,6 @@ namespace LibationAvalonia.ViewModels public async Task Filter(string searchString) { - if (searchString == FilterString) - return; - FilterString = searchString; if (SOURCE.Count == 0) @@ -206,10 +337,10 @@ namespace LibationAvalonia.ViewModels if (selectedBooks.Count == 0) return; - var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList(); var result = await MessageBox.ShowConfirmationDialog( null, - libraryBooks, + booksToRemove, // do not use `$` string interpolation. See impl. "Are you sure you want to remove {0} from Libation's library?", "Remove books from Libation?"); @@ -220,8 +351,6 @@ namespace LibationAvalonia.ViewModels foreach (var book in selectedBooks) book.PropertyChanged -= GridEntry_PropertyChanged; - var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e) { if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset) @@ -240,7 +369,7 @@ namespace LibationAvalonia.ViewModels //The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(), //so there's no need to remove books from the grid display here. - var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + await booksToRemove.RemoveBooksAsync(); RemovableCountChanged?.Invoke(this, 0); } diff --git a/Source/LibationAvalonia/ViewModels/QueryExtensions.cs b/Source/LibationAvalonia/ViewModels/QueryExtensions.cs index e579c6a9..91357a45 100644 --- a/Source/LibationAvalonia/ViewModels/QueryExtensions.cs +++ b/Source/LibationAvalonia/ViewModels/QueryExtensions.cs @@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels public static IEnumerable SeriesEntries(this IEnumerable gridEntries) => gridEntries.OfType(); + public static T? FindByAsin(this IEnumerable gridEntries, string audibleProductID) where T : GridEntry + => gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID); + + public static IEnumerable EmptySeries(this IEnumerable gridEntries) + => gridEntries.SeriesEntries().Where(i => i.Children.Count == 0); + public static SeriesEntry? FindSeriesParent(this IEnumerable gridEntries, LibraryBook seriesEpisode) { if (seriesEpisode.Book.SeriesLink is null) return null; diff --git a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs index 9f2d9749..88d23d2f 100644 --- a/Source/LibationAvalonia/ViewModels/SeriesEntry.cs +++ b/Source/LibationAvalonia/ViewModels/SeriesEntry.cs @@ -56,15 +56,36 @@ namespace LibationAvalonia.ViewModels { Liberate = new LiberateButtonStatus(IsSeries); SeriesIndex = -1; - LibraryBook = parent; - - LoadCover(); Children = children .Select(c => new LibraryBookEntry(c) { Parent = this }) .OrderBy(c => c.SeriesIndex) .ToList(); + setLibraryBook(parent); + LoadCover(); + } + + public void RemoveChild(LibraryBookEntry lbe) + { + Children.Remove(lbe); + PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); + int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); + Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + } + + public void UpdateLibraryBook(LibraryBook libraryBook) + { + if (AudibleProductId != libraryBook.Book.AudibleProductId) + throw new Exception("Invalid grid entry update. IDs must match"); + + setLibraryBook(libraryBook); + } + + private void setLibraryBook(LibraryBook libraryBook) + { + LibraryBook = libraryBook; + Title = Book.Title; Series = Book.SeriesNames(); //Ratings are changed using Update(), which is a problem for Avalonia data bindings because @@ -75,12 +96,15 @@ namespace LibationAvalonia.ViewModels Narrators = Book.NarratorNames(); Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(LibraryBook); + LastDownload = new(); LongDescription = GetDescriptionDisplay(Book); Description = TrimTextToWord(LongDescription, 62); PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d"); int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes); Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min"; + + this.RaisePropertyChanged(nameof(MyRating)); } @@ -101,6 +125,7 @@ namespace LibationAvalonia.ViewModels { nameof(Description), () => Description }, { nameof(Category), () => Category }, { nameof(Misc), () => Misc }, + { nameof(LastDownload), () => LastDownload }, { nameof(BookTags), () => BookTags?.Tags ?? string.Empty }, { nameof(Liberate), () => Liberate }, { nameof(DateAdded), () => DateAdded }, diff --git a/Source/LibationAvalonia/Views/MainWindow.RemoveBooks.cs b/Source/LibationAvalonia/Views/MainWindow.RemoveBooks.cs index 85e3b135..e95b2bd4 100644 --- a/Source/LibationAvalonia/Views/MainWindow.RemoveBooks.cs +++ b/Source/LibationAvalonia/Views/MainWindow.RemoveBooks.cs @@ -1,4 +1,5 @@ using AudibleUtilities; +using LibationAvalonia.Dialogs; using System; using System.Linq; @@ -15,6 +16,12 @@ namespace LibationAvalonia.Views _viewModel.RemoveButtonsVisible = false; } + public async void openTrashBinToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var trash = new TrashBinDialog(); + await trash.ShowDialog(this); + } + public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { // if 0 accounts, this will not be visible diff --git a/Source/LibationAvalonia/Views/MainWindow.VisibleBooks.cs b/Source/LibationAvalonia/Views/MainWindow.VisibleBooks.cs index d3a2402f..c237f9f2 100644 --- a/Source/LibationAvalonia/Views/MainWindow.VisibleBooks.cs +++ b/Source/LibationAvalonia/Views/MainWindow.VisibleBooks.cs @@ -144,11 +144,8 @@ namespace LibationAvalonia.Views "Remove books from Libation?", MessageBoxDefaultButton.Button2); - if (confirmationResult != DialogResult.Yes) - return; - - var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - await LibraryCommands.RemoveBooksAsync(visibleIds); + if (confirmationResult is DialogResult.Yes) + await visibleLibraryBooks.RemoveBooksAsync(); } public async void ProductsDisplay_VisibleCountChanged(object sender, int qty) { diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml b/Source/LibationAvalonia/Views/MainWindow.axaml index 8b2fffb8..ae60660e 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml +++ b/Source/LibationAvalonia/Views/MainWindow.axaml @@ -131,6 +131,7 @@ + @@ -172,7 +173,12 @@ diff --git a/Source/LibationAvalonia/Views/MainWindow.axaml.cs b/Source/LibationAvalonia/Views/MainWindow.axaml.cs index 88f8a705..79fd7821 100644 --- a/Source/LibationAvalonia/Views/MainWindow.axaml.cs +++ b/Source/LibationAvalonia/Views/MainWindow.axaml.cs @@ -51,7 +51,7 @@ namespace LibationAvalonia.Views { this.LibraryLoaded += MainWindow_LibraryLoaded; - LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooksAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); } Closing += MainWindow_Closing; @@ -67,7 +67,7 @@ namespace LibationAvalonia.Views if (QuickFilters.UseDefault) await performFilter(QuickFilters.Filters.FirstOrDefault()); - await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks); + _viewModel.ProductsDisplay.BindToGrid(dbBooks); } private void InitializeComponent() diff --git a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs index 66673914..ed6dfef0 100644 --- a/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs +++ b/Source/LibationAvalonia/Views/ProcessQueueControl.axaml.cs @@ -15,7 +15,7 @@ namespace LibationAvalonia.Views { public partial class ProcessQueueControl : UserControl { - private TrackedQueue Queue => _viewModel.Items; + private TrackedQueue Queue => _viewModel.Queue; private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel; public ProcessQueueControl() @@ -76,14 +76,14 @@ namespace LibationAvalonia.Views }, }; - vm.Items.Enqueue(testList); - vm.Items.MoveNext(); - vm.Items.MoveNext(); - vm.Items.MoveNext(); - vm.Items.MoveNext(); - vm.Items.MoveNext(); - vm.Items.MoveNext(); - vm.Items.MoveNext(); + vm.Queue.Enqueue(testList); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); + vm.Queue.MoveNext(); return; } #endregion diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml b/Source/LibationAvalonia/Views/ProductsDisplay.axaml index 87dfb645..507a225d 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml @@ -183,6 +183,16 @@ + + + + + + + + + + diff --git a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs index c7f2a29a..7997f7c7 100644 --- a/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs +++ b/Source/LibationAvalonia/Views/ProductsDisplay.axaml.cs @@ -44,7 +44,7 @@ namespace LibationAvalonia.Views }; var pdvm = new ProductsDisplayViewModel(); - _ = pdvm.DisplayBooksAsync(sampleEntries); + pdvm.BindToGrid(sampleEntries); DataContext = pdvm; return; @@ -102,7 +102,7 @@ namespace LibationAvalonia.Views setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); var removeMenuItem = new MenuItem() { Header = "_Remove from library" }; - removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId)); + removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." }; locateFileMenuItem.Click += async (_, __) => @@ -306,6 +306,12 @@ namespace LibationAvalonia.Views imageDisplayDialog.Close(); } + public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args) + { + if (sender is Control panel && panel.DataContext is LibraryBookEntry lbe && lbe.LastDownload.IsValid) + lbe.LastDownload.OpenReleaseUrl(); + } + public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args) { if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry) diff --git a/Source/LibationFileManager/Configuration.Environment.cs b/Source/LibationFileManager/Configuration.Environment.cs index 1eec30a1..55ce4fdc 100644 --- a/Source/LibationFileManager/Configuration.Environment.cs +++ b/Source/LibationFileManager/Configuration.Environment.cs @@ -20,6 +20,8 @@ namespace LibationFileManager public static bool IsWindows { get; } = OperatingSystem.IsWindows(); public static bool IsLinux { get; } = OperatingSystem.IsLinux(); public static bool IsMacOs { get; } = OperatingSystem.IsMacOS(); + public static Version LibationVersion { get; private set; } + public static void SetLibationVersion(Version version) => LibationVersion = version; public static OS OS { get; } = IsLinux ? OS.Linux diff --git a/Source/LibationFileManager/Configuration.PersistentSettings.cs b/Source/LibationFileManager/Configuration.PersistentSettings.cs index 4902d289..846277d3 100644 --- a/Source/LibationFileManager/Configuration.PersistentSettings.cs +++ b/Source/LibationFileManager/Configuration.PersistentSettings.cs @@ -73,7 +73,7 @@ namespace LibationFileManager public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName); - [Description("Set cover art as the folder's icon. (Windows only)")] + [Description("Set cover art as the folder's icon. (Windows and macOS only)")] public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); } [Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")] diff --git a/Source/LibationFileManager/WindowsDirectory.cs b/Source/LibationFileManager/WindowsDirectory.cs index 1fc0cd58..10901ce9 100644 --- a/Source/LibationFileManager/WindowsDirectory.cs +++ b/Source/LibationFileManager/WindowsDirectory.cs @@ -9,11 +9,13 @@ namespace LibationFileManager { public static class WindowsDirectory { + public static void SetCoverAsFolderIcon(string pictureId, string directory) { try { - if (!Configuration.Instance.UseCoverAsFolderIcon || !Configuration.IsWindows) + //Currently only works for Windows and macOS + if (!Configuration.Instance.UseCoverAsFolderIcon || Configuration.IsLinux) return; // get path of cover art in Images dir. Download first if not exists diff --git a/Source/LibationUiBase/IcoEncoder.cs b/Source/LibationUiBase/IcoEncoder.cs new file mode 100644 index 00000000..d6546f7f --- /dev/null +++ b/Source/LibationUiBase/IcoEncoder.cs @@ -0,0 +1,52 @@ +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SixLabors.ImageSharp.Formats; + +namespace LibationUiBase +{ + public class IcoEncoder : IImageEncoder + { + public bool SkipMetadata { get; init; } = true; + + public void Encode(Image image, Stream stream) where TPixel : unmanaged, IPixel + { + // https://stackoverflow.com/a/21389253 + + using var ms = new MemoryStream(); + //Knowing the image size ahead of time removes the + //requirement of the output stream to support seeking. + image.SaveAsPng(ms); + + //Disposing of the BinaryWriter disposes the soutput stream. Let the caller clean up. + var bw = new BinaryWriter(stream); + + // Header + bw.Write((short)0); // 0-1 : reserved + bw.Write((short)1); // 2-3 : 1=ico, 2=cur + bw.Write((short)1); // 4-5 : number of images + + // Image directory + var w = image.Width; + if (w >= 256) w = 0; + bw.Write((byte)w); // 0 : width of image + var h = image.Height; + if (h >= 256) h = 0; + bw.Write((byte)h); // 1 : height of image + bw.Write((byte)0); // 2 : number of colors in palette + bw.Write((byte)0); // 3 : reserved + bw.Write((short)0); // 4 : number of color planes + bw.Write((short)0); // 6 : bits per pixel + bw.Write((int)ms.Position); // 8 : image size + bw.Write((int)stream.Position + 4); // 12: offset of image data + ms.Position = 0; + ms.CopyTo(stream); // Image data + } + + public Task EncodeAsync(Image image, Stream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel + => throw new NotImplementedException(); + } +} diff --git a/Source/LibationUiBase/LastDownloadStatus.cs b/Source/LibationUiBase/LastDownloadStatus.cs new file mode 100644 index 00000000..eddba0e1 --- /dev/null +++ b/Source/LibationUiBase/LastDownloadStatus.cs @@ -0,0 +1,41 @@ +using DataLayer; +using System; + +namespace LibationUiBase +{ + public class LastDownloadStatus : IComparable + { + public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue; + public Version LastDownloadedVersion { get; } + public DateTime? LastDownloaded { get; } + public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : ""; + + public LastDownloadStatus() { } + public LastDownloadStatus(UserDefinedItem udi) + { + LastDownloadedVersion = udi.LastDownloadedVersion; + LastDownloaded = udi.LastDownloaded; + } + + public void OpenReleaseUrl() + { + if (IsValid) + Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion.ToString(3)}"); + } + + public override string ToString() + => IsValid ? $"{dateString()}\n\nLibation v{LastDownloadedVersion.ToString(3)}" : ""; + + //Call ToShortDateString to use current culture's date format. + private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}"; + + public int CompareTo(object obj) + { + if (obj is not LastDownloadStatus second) return -1; + else if (IsValid && !second.IsValid) return -1; + else if (!IsValid && second.IsValid) return 1; + else if (!IsValid && !second.IsValid) return 0; + else return LastDownloaded.Value.CompareTo(second.LastDownloaded.Value); + } + } +} diff --git a/Source/LibationUiBase/LibationUiBase.csproj b/Source/LibationUiBase/LibationUiBase.csproj index 6290198a..454fe859 100644 --- a/Source/LibationUiBase/LibationUiBase.csproj +++ b/Source/LibationUiBase/LibationUiBase.csproj @@ -9,7 +9,7 @@ - + diff --git a/Source/LibationAvalonia/ViewModels/TrackedQueue[T].cs b/Source/LibationUiBase/TrackedQueue[T].cs similarity index 82% rename from Source/LibationAvalonia/ViewModels/TrackedQueue[T].cs rename to Source/LibationUiBase/TrackedQueue[T].cs index f4889a77..11f1a27e 100644 --- a/Source/LibationAvalonia/ViewModels/TrackedQueue[T].cs +++ b/Source/LibationUiBase/TrackedQueue[T].cs @@ -1,9 +1,8 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Linq; -namespace LibationAvalonia.ViewModels +namespace LibationUiBase { public enum QueuePosition { @@ -33,7 +32,7 @@ namespace LibationAvalonia.ViewModels * and is stored in ObservableCollection.Items. When the primary list changes, the * secondary list is cleared and reset to match the primary. */ - public class TrackedQueue : ObservableCollection where T : class + public class TrackedQueue where T : class { public event EventHandler CompletedCountChanged; public event EventHandler QueuededCountChanged; @@ -47,6 +46,60 @@ namespace LibationAvalonia.ViewModels private readonly List _completed = new(); private readonly object lockObject = new(); + private readonly ICollection _underlyingList; + + public TrackedQueue(ICollection underlyingList = null) + { + _underlyingList = underlyingList; + } + + public T this[int index] + { + get + { + lock (lockObject) + { + if (index < _completed.Count) + return _completed[index]; + index -= _completed.Count; + + if (index == 0 && Current != null) return Current; + + if (Current != null) index--; + + if (index < _queued.Count) return _queued.ElementAt(index); + + throw new IndexOutOfRangeException(); + } + } + } + + public int Count + { + get + { + lock (lockObject) + { + return _queued.Count + _completed.Count + (Current == null ? 0 : 1); + } + } + } + + public int IndexOf(T item) + { + lock (lockObject) + { + if (_completed.Contains(item)) + return _completed.IndexOf(item); + + if (Current == item) return _completed.Count; + + if (_queued.Contains(item)) + return _queued.IndexOf(item) + (Current is null ? 0 : 1); + return -1; + } + } + public bool RemoveQueued(T item) { bool itemsRemoved; @@ -188,29 +241,6 @@ namespace LibationAvalonia.ViewModels } } - public bool TryPeek(out T item) - { - lock (lockObject) - { - if (_queued.Count == 0) - { - item = null; - return false; - } - item = _queued[0]; - return true; - } - } - - public T Peek() - { - lock (lockObject) - { - if (_queued.Count == 0) throw new InvalidOperationException("Queue empty"); - return _queued.Count > 0 ? _queued[0] : default; - } - } - public void Enqueue(IEnumerable item) { int queueCount; @@ -220,15 +250,15 @@ namespace LibationAvalonia.ViewModels queueCount = _queued.Count; } foreach (var i in item) - base.Add(i); + _underlyingList?.Add(i); QueuededCountChanged?.Invoke(this, queueCount); } private void RebuildSecondary() { - base.ClearItems(); + _underlyingList?.Clear(); foreach (var item in GetAllItems()) - base.Add(item); + _underlyingList?.Add(item); } public IEnumerable GetAllItems() diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs b/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs new file mode 100644 index 00000000..947ab2f9 --- /dev/null +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs @@ -0,0 +1,129 @@ +namespace LibationWinForms.Dialogs +{ + partial class TrashBinDialog + { + /// + /// Required designer variable. + /// + private System.ComponentModel.IContainer components = null; + + /// + /// Clean up any resources being used. + /// + /// true if managed resources should be disposed; otherwise, false. + protected override void Dispose(bool disposing) + { + if (disposing && (components != null)) + { + components.Dispose(); + } + base.Dispose(disposing); + } + + #region Windows Form Designer generated code + + /// + /// Required method for Designer support - do not modify + /// the contents of this method with the code editor. + /// + private void InitializeComponent() + { + deletedCbl = new System.Windows.Forms.CheckedListBox(); + label1 = new System.Windows.Forms.Label(); + restoreBtn = new System.Windows.Forms.Button(); + permanentlyDeleteBtn = new System.Windows.Forms.Button(); + everythingCb = new System.Windows.Forms.CheckBox(); + deletedCheckedLbl = new System.Windows.Forms.Label(); + SuspendLayout(); + // + // deletedCbl + // + deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right; + deletedCbl.FormattingEnabled = true; + deletedCbl.Location = new System.Drawing.Point(12, 27); + deletedCbl.Name = "deletedCbl"; + deletedCbl.Size = new System.Drawing.Size(776, 364); + deletedCbl.TabIndex = 3; + deletedCbl.ItemCheck += deletedCbl_ItemCheck; + // + // label1 + // + label1.AutoSize = true; + label1.Location = new System.Drawing.Point(12, 9); + label1.Name = "label1"; + label1.Size = new System.Drawing.Size(388, 15); + label1.TabIndex = 4; + label1.Text = "Check books you want to permanently delete from or restore to Libation"; + // + // restoreBtn + // + restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; + restoreBtn.Location = new System.Drawing.Point(572, 398); + restoreBtn.Name = "restoreBtn"; + restoreBtn.Size = new System.Drawing.Size(75, 40); + restoreBtn.TabIndex = 5; + restoreBtn.Text = "Restore"; + restoreBtn.UseVisualStyleBackColor = true; + restoreBtn.Click += restoreBtn_Click; + // + // permanentlyDeleteBtn + // + permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right; + permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398); + permanentlyDeleteBtn.Name = "permanentlyDeleteBtn"; + permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40); + permanentlyDeleteBtn.TabIndex = 5; + permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation"; + permanentlyDeleteBtn.UseVisualStyleBackColor = true; + permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click; + // + // everythingCb + // + everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + everythingCb.AutoSize = true; + everythingCb.Location = new System.Drawing.Point(12, 410); + everythingCb.Name = "everythingCb"; + everythingCb.Size = new System.Drawing.Size(82, 19); + everythingCb.TabIndex = 6; + everythingCb.Text = "Everything"; + everythingCb.ThreeState = true; + everythingCb.UseVisualStyleBackColor = true; + everythingCb.CheckStateChanged += everythingCb_CheckStateChanged; + // + // deletedCheckedLbl + // + deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left; + deletedCheckedLbl.AutoSize = true; + deletedCheckedLbl.Location = new System.Drawing.Point(126, 411); + deletedCheckedLbl.Name = "deletedCheckedLbl"; + deletedCheckedLbl.Size = new System.Drawing.Size(104, 15); + deletedCheckedLbl.TabIndex = 7; + deletedCheckedLbl.Text = "Checked: {0} of {1}"; + // + // TrashBinDialog + // + AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F); + AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + ClientSize = new System.Drawing.Size(800, 450); + Controls.Add(deletedCheckedLbl); + Controls.Add(everythingCb); + Controls.Add(permanentlyDeleteBtn); + Controls.Add(restoreBtn); + Controls.Add(label1); + Controls.Add(deletedCbl); + Name = "TrashBinDialog"; + Text = "Trash Bin"; + ResumeLayout(false); + PerformLayout(); + } + + #endregion + + private System.Windows.Forms.CheckedListBox deletedCbl; + private System.Windows.Forms.Label label1; + private System.Windows.Forms.Button restoreBtn; + private System.Windows.Forms.Button permanentlyDeleteBtn; + private System.Windows.Forms.CheckBox everythingCb; + private System.Windows.Forms.Label deletedCheckedLbl; + } +} \ No newline at end of file diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.cs b/Source/LibationWinForms/Dialogs/TrashBinDialog.cs new file mode 100644 index 00000000..2a5fdac8 --- /dev/null +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.cs @@ -0,0 +1,116 @@ +using ApplicationServices; +using System; +using System.Data; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Forms; +using DataLayer; +using LibationFileManager; +using System.Collections; + +namespace LibationWinForms.Dialogs +{ + public partial class TrashBinDialog : Form + { + private readonly string deletedCheckedTemplate; + public TrashBinDialog() + { + InitializeComponent(); + + this.SetLibationIcon(); + this.RestoreSizeAndLocation(Configuration.Instance); + this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance); + + deletedCheckedTemplate = deletedCheckedLbl.Text; + + var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks(); + foreach (var lb in deletedBooks) + deletedCbl.Items.Add(lb); + + setLabel(); + } + + private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e) + { + // CheckedItems.Count is not updated until after the event fires + setLabel(e.NewValue); + } + + private async void permanentlyDeleteBtn_Click(object sender, EventArgs e) + { + setControlsEnabled(false); + + var removed = deletedCbl.CheckedItems.Cast().ToList(); + + removeFromCheckList(removed); + await Task.Run(removed.PermanentlyDeleteBooks); + + setControlsEnabled(true); + } + + private async void restoreBtn_Click(object sender, EventArgs e) + { + setControlsEnabled(false); + + var removed = deletedCbl.CheckedItems.Cast().ToList(); + + removeFromCheckList(removed); + await Task.Run(removed.RestoreBooks); + + setControlsEnabled(true); + } + + private void removeFromCheckList(IEnumerable objects) + { + foreach (var o in objects) + deletedCbl.Items.Remove(o); + + deletedCbl.Refresh(); + setLabel(); + } + + private void setControlsEnabled(bool enabled) + => restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled; + + private void everythingCb_CheckStateChanged(object sender, EventArgs e) + { + if (everythingCb.CheckState is CheckState.Indeterminate) + { + everythingCb.CheckState = CheckState.Unchecked; + return; + } + + deletedCbl.ItemCheck -= deletedCbl_ItemCheck; + + for (var i = 0; i < deletedCbl.Items.Count; i++) + deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked); + + setLabel(); + + deletedCbl.ItemCheck += deletedCbl_ItemCheck; + } + + + private void setLabel(CheckState? checkedState = null) + { + var pre = deletedCbl.CheckedItems.Count; + int count = checkedState switch + { + CheckState.Checked => pre + 1, + CheckState.Unchecked => pre - 1, + _ => pre, + }; + + everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged; + + everythingCb.CheckState + = count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked + : count == 0 ? CheckState.Unchecked + : CheckState.Indeterminate; + + everythingCb.CheckStateChanged += everythingCb_CheckStateChanged; + + deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count); + } + } +} diff --git a/Source/LibationWinForms/Dialogs/TrashBinDialog.resx b/Source/LibationWinForms/Dialogs/TrashBinDialog.resx new file mode 100644 index 00000000..f298a7be --- /dev/null +++ b/Source/LibationWinForms/Dialogs/TrashBinDialog.resx @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/Source/LibationWinForms/Form1.Designer.cs b/Source/LibationWinForms/Form1.Designer.cs index dc37bc58..e62fd0a4 100644 --- a/Source/LibationWinForms/Form1.Designer.cs +++ b/Source/LibationWinForms/Form1.Designer.cs @@ -63,6 +63,7 @@ this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator(); this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator(); + this.openTrashBinToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem(); @@ -383,8 +384,9 @@ this.accountsToolStripMenuItem, this.basicSettingsToolStripMenuItem, this.toolStripSeparator4, + this.openTrashBinToolStripMenuItem, this.launchHangoverToolStripMenuItem, - this.toolStripSeparator2, + this.toolStripSeparator2, this.aboutToolStripMenuItem}); this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem"; this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20); @@ -592,6 +594,13 @@ this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks"; this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click); // + // openTrashBinToolStripMenuItem + // + this.openTrashBinToolStripMenuItem.Name = "openTrashBinToolStripMenuItem"; + this.openTrashBinToolStripMenuItem.Size = new System.Drawing.Size(247, 22); + this.openTrashBinToolStripMenuItem.Text = "Trash Bin"; + this.openTrashBinToolStripMenuItem.Click += new System.EventHandler(this.openTrashBinToolStripMenuItem_Click); + // // launchHangoverToolStripMenuItem // this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem"; @@ -676,6 +685,7 @@ private System.Windows.Forms.ToolStripSeparator toolStripSeparator3; private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem; private System.Windows.Forms.ToolStripSeparator toolStripSeparator4; + private System.Windows.Forms.ToolStripMenuItem openTrashBinToolStripMenuItem; private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem; private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu; private System.Windows.Forms.SplitContainer splitContainer1; diff --git a/Source/LibationWinForms/Form1.RemoveBooks.cs b/Source/LibationWinForms/Form1.RemoveBooks.cs index 4a5753e8..51b710fb 100644 --- a/Source/LibationWinForms/Form1.RemoveBooks.cs +++ b/Source/LibationWinForms/Form1.RemoveBooks.cs @@ -16,6 +16,9 @@ namespace LibationWinForms private async void removeBooksBtn_Click(object sender, EventArgs e) => await productsDisplay.RemoveCheckedBooksAsync(); + private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e) + => new TrashBinDialog().ShowDialog(this); + private void doneRemovingBtn_Click(object sender, EventArgs e) { removeBooksBtn.Visible = false; diff --git a/Source/LibationWinForms/Form1.VisibleBooks.cs b/Source/LibationWinForms/Form1.VisibleBooks.cs index 26619024..2ecd336b 100644 --- a/Source/LibationWinForms/Form1.VisibleBooks.cs +++ b/Source/LibationWinForms/Form1.VisibleBooks.cs @@ -168,11 +168,8 @@ namespace LibationWinForms "Are you sure you want to remove {0} from Libation's library?", "Remove books from Libation?"); - if (confirmationResult != DialogResult.Yes) - return; - - var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - await LibraryCommands.RemoveBooksAsync(visibleIds); + if (confirmationResult is DialogResult.Yes) + await visibleLibraryBooks.RemoveBooksAsync(); } private async void productsDisplay_VisibleCountChanged(object sender, int qty) diff --git a/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs b/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs index e4532cfe..f02dd26e 100644 --- a/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs +++ b/Source/LibationWinForms/GridView/AsyncNotifyPropertyChanged.cs @@ -12,6 +12,6 @@ namespace LibationWinForms.GridView // per standard INotifyPropertyChanged pattern: // https://docs.microsoft.com/en-us/dotnet/desktop/wpf/data/how-to-implement-property-change-notification public void NotifyPropertyChanged([CallerMemberName] string propertyName = "") - => this.UIThreadAsync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); + => this.UIThreadSync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName))); } } diff --git a/Source/LibationWinForms/GridView/GridEntry.cs b/Source/LibationWinForms/GridView/GridEntry.cs index 026c5ad5..95ae7128 100644 --- a/Source/LibationWinForms/GridView/GridEntry.cs +++ b/Source/LibationWinForms/GridView/GridEntry.cs @@ -59,6 +59,7 @@ namespace LibationWinForms.GridView public string Narrators { get; protected set; } public string Category { get; protected set; } public string Misc { get; protected set; } + public virtual LastDownloadStatus LastDownload { get; protected set; } = new(); public string Description { get; protected set; } public string ProductRating { get; protected set; } protected Rating _myRating; @@ -120,6 +121,7 @@ namespace LibationWinForms.GridView { typeof(bool), new ObjectComparer() }, { typeof(DateTime), new ObjectComparer() }, { typeof(LiberateButtonStatus), new ObjectComparer() }, + { typeof(LastDownloadStatus), new ObjectComparer() }, }; #endregion diff --git a/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs b/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs new file mode 100644 index 00000000..c970f57d --- /dev/null +++ b/Source/LibationWinForms/GridView/LastDownloadedGridViewColumn.cs @@ -0,0 +1,39 @@ +using LibationUiBase; +using System; +using System.Drawing; +using System.Windows.Forms; + +namespace LibationWinForms.GridView +{ + public class LastDownloadedGridViewColumn : DataGridViewColumn + { + public LastDownloadedGridViewColumn() : base(new LastDownloadedGridViewCell()) { } + public override DataGridViewCell CellTemplate + { + get => base.CellTemplate; + set + { + if (value is not LastDownloadedGridViewCell) + throw new InvalidCastException($"Must be a {nameof(LastDownloadedGridViewCell)}"); + + base.CellTemplate = value; + } + } + } + + internal class LastDownloadedGridViewCell : DataGridViewTextBoxCell + { + private LastDownloadStatus LastDownload => (LastDownloadStatus)Value; + protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates cellState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts) + { + ToolTipText = ((LastDownloadStatus)value).ToolTipText; + base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts); + } + + protected override void OnDoubleClick(DataGridViewCellEventArgs e) + { + LastDownload.OpenReleaseUrl(); + base.OnDoubleClick(e); + } + } +} diff --git a/Source/LibationWinForms/GridView/LibraryBookEntry.cs b/Source/LibationWinForms/GridView/LibraryBookEntry.cs index ce029c79..a29320c7 100644 --- a/Source/LibationWinForms/GridView/LibraryBookEntry.cs +++ b/Source/LibationWinForms/GridView/LibraryBookEntry.cs @@ -1,6 +1,7 @@ using ApplicationServices; using DataLayer; using Dinah.Core; +using LibationUiBase; using System; using System.Collections.Generic; using System.ComponentModel; @@ -24,6 +25,8 @@ namespace LibationWinForms.GridView private LiberatedStatus _bookStatus; private LiberatedStatus? _pdfStatus; + public override LastDownloadStatus LastDownload { get; protected set; } + public override RemoveStatus Remove { get @@ -87,6 +90,7 @@ namespace LibationWinForms.GridView Narrators = Book.NarratorNames(); Category = string.Join(" > ", Book.CategoriesNames()); Misc = GetMiscDisplay(libraryBook); + LastDownload = new(Book.UserDefinedItem); LongDescription = GetDescriptionDisplay(Book); Description = TrimTextToWord(LongDescription, 62); SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0; @@ -126,6 +130,10 @@ namespace LibationWinForms.GridView _pdfStatus = udi.PdfStatus; NotifyPropertyChanged(nameof(Liberate)); break; + case nameof(udi.LastDownloaded): + LastDownload = new(udi); + NotifyPropertyChanged(nameof(LastDownload)); + break; } } @@ -153,6 +161,7 @@ namespace LibationWinForms.GridView { nameof(Description), () => Description }, { nameof(Category), () => Category }, { nameof(Misc), () => Misc }, + { nameof(LastDownload), () => LastDownload }, { nameof(DisplayTags), () => DisplayTags }, { nameof(Liberate), () => Liberate }, { nameof(DateAdded), () => DateAdded }, diff --git a/Source/LibationWinForms/GridView/ProductsDisplay.cs b/Source/LibationWinForms/GridView/ProductsDisplay.cs index 75027385..411c516c 100644 --- a/Source/LibationWinForms/GridView/ProductsDisplay.cs +++ b/Source/LibationWinForms/GridView/ProductsDisplay.cs @@ -107,9 +107,9 @@ namespace LibationWinForms.GridView if (selectedBooks.Count == 0) return; - var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList(); + var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList(); var result = MessageBoxLib.ShowConfirmationDialog( - libraryBooks, + booksToRemove, // do not use `$` string interpolation. See impl. "Are you sure you want to remove {0} from Libation's library?", "Remove books from Libation?"); @@ -118,8 +118,7 @@ namespace LibationWinForms.GridView return; productsGrid.RemoveBooks(selectedBooks); - var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList(); - var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove); + await booksToRemove.RemoveBooksAsync(); } public async Task ScanAndRemoveBooksAsync(params Account[] accounts) diff --git a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs index 15d3bd2d..55f0154d 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.Designer.cs @@ -45,6 +45,7 @@ this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn(); this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn(); + this.lastDownloadedGVColumn = new LastDownloadedGridViewColumn(); this.tagAndDetailsGVColumn = new LibationWinForms.GridView.EditTagsDataGridViewImageButtonColumn(); this.showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(this.components); this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components); @@ -75,7 +76,8 @@ this.purchaseDateGVColumn, this.myRatingGVColumn, this.miscGVColumn, - this.tagAndDetailsGVColumn}); + this.lastDownloadedGVColumn, + this.tagAndDetailsGVColumn}); this.gridEntryDataGridView.ContextMenuStrip = this.showHideColumnsContextMenuStrip; this.gridEntryDataGridView.DataSource = this.syncBindingSource; dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft; @@ -216,6 +218,15 @@ this.miscGVColumn.ReadOnly = true; this.miscGVColumn.Width = 135; // + // lastDownloadedGVColumn + // + this.lastDownloadedGVColumn.DataPropertyName = "LastDownload"; + this.lastDownloadedGVColumn.HeaderText = "Last Download"; + this.lastDownloadedGVColumn.Name = "lastDownloadedGVColumn"; + this.lastDownloadedGVColumn.ReadOnly = true; + this.lastDownloadedGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic; + this.lastDownloadedGVColumn.Width = 108; + // // tagAndDetailsGVColumn // this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags"; @@ -268,6 +279,7 @@ private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn; private MyRatingGridViewColumn myRatingGVColumn; private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn; + private LastDownloadedGridViewColumn lastDownloadedGVColumn; private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn; } } diff --git a/Source/LibationWinForms/GridView/ProductsGrid.cs b/Source/LibationWinForms/GridView/ProductsGrid.cs index 75355bf9..ec5e0cac 100644 --- a/Source/LibationWinForms/GridView/ProductsGrid.cs +++ b/Source/LibationWinForms/GridView/ProductsGrid.cs @@ -154,7 +154,7 @@ namespace LibationWinForms.GridView setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated); var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" }; - removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId)); + removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook); var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." }; locateFileMenuItem.Click += (_, __) => diff --git a/Source/LibationWinForms/GridView/SeriesEntry.cs b/Source/LibationWinForms/GridView/SeriesEntry.cs index f8d004f9..cf99c96b 100644 --- a/Source/LibationWinForms/GridView/SeriesEntry.cs +++ b/Source/LibationWinForms/GridView/SeriesEntry.cs @@ -122,6 +122,7 @@ namespace LibationWinForms.GridView { nameof(Description), () => Description }, { nameof(Category), () => Category }, { nameof(Misc), () => Misc }, + { nameof(LastDownload), () => LastDownload }, { nameof(DisplayTags), () => string.Empty }, { nameof(Liberate), () => Liberate }, { nameof(DateAdded), () => DateAdded }, diff --git a/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs b/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs deleted file mode 100644 index 80991f34..00000000 --- a/Source/LibationWinForms/ProcessQueue/TrackedQueue[T].cs +++ /dev/null @@ -1,250 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace LibationWinForms.ProcessQueue -{ - public enum QueuePosition - { - Fisrt, - OneUp, - OneDown, - Last - } - - /* - * This data structure is like lifting a metal chain one link at a time. - * Each time you grab and lift a new link (MoveNext call): - * - * 1) you're holding a new link in your hand (Current) - * 2) the remaining chain to be lifted shortens by 1 link (Queued) - * 3) the pile of chain at your feet grows by 1 link (Completed) - * - * The index is the link position from the first link you lifted to the - * last one in the chain. - */ - public class TrackedQueue where T : class - { - public event EventHandler CompletedCountChanged; - public event EventHandler QueuededCountChanged; - public T Current { get; private set; } - - public IReadOnlyList Queued => _queued; - public IReadOnlyList Completed => _completed; - - private readonly List _queued = new(); - private readonly List _completed = new(); - private readonly object lockObject = new(); - - public T this[int index] - { - get - { - lock (lockObject) - { - if (index < _completed.Count) - return _completed[index]; - index -= _completed.Count; - - if (index == 0 && Current != null) return Current; - - if (Current != null) index--; - - if (index < _queued.Count) return _queued.ElementAt(index); - - throw new IndexOutOfRangeException(); - } - } - } - - public int Count - { - get - { - lock (lockObject) - { - return _queued.Count + _completed.Count + (Current == null ? 0 : 1); - } - } - } - - public int IndexOf(T item) - { - lock (lockObject) - { - if (_completed.Contains(item)) - return _completed.IndexOf(item); - - if (Current == item) return _completed.Count; - - if (_queued.Contains(item)) - return _queued.IndexOf(item) + (Current is null ? 0 : 1); - return -1; - } - } - - public bool RemoveQueued(T item) - { - bool itemsRemoved; - int queuedCount; - - lock (lockObject) - { - itemsRemoved = _queued.Remove(item); - queuedCount = _queued.Count; - } - - if (itemsRemoved) - QueuededCountChanged?.Invoke(this, queuedCount); - return itemsRemoved; - } - - public void ClearCurrent() - { - lock(lockObject) - Current = null; - } - - public bool RemoveCompleted(T item) - { - bool itemsRemoved; - int completedCount; - - lock (lockObject) - { - itemsRemoved = _completed.Remove(item); - completedCount = _completed.Count; - } - - if (itemsRemoved) - CompletedCountChanged?.Invoke(this, completedCount); - return itemsRemoved; - } - - public void ClearQueue() - { - lock (lockObject) - _queued.Clear(); - QueuededCountChanged?.Invoke(this, 0); - } - - public void ClearCompleted() - { - lock (lockObject) - _completed.Clear(); - CompletedCountChanged?.Invoke(this, 0); - } - - public bool Any(Func predicate) - { - lock (lockObject) - { - return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate); - } - } - - public void MoveQueuePosition(T item, QueuePosition requestedPosition) - { - lock (lockObject) - { - if (_queued.Count == 0 || !_queued.Contains(item)) return; - - if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item) - return; - if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item) - return; - - int queueIndex = _queued.IndexOf(item); - - if (requestedPosition == QueuePosition.OneUp) - { - _queued.RemoveAt(queueIndex); - _queued.Insert(queueIndex - 1, item); - } - else if (requestedPosition == QueuePosition.OneDown) - { - _queued.RemoveAt(queueIndex); - _queued.Insert(queueIndex + 1, item); - } - else if (requestedPosition == QueuePosition.Fisrt) - { - _queued.RemoveAt(queueIndex); - _queued.Insert(0, item); - } - else - { - _queued.RemoveAt(queueIndex); - _queued.Insert(_queued.Count, item); - } - } - } - - public bool MoveNext() - { - int completedCount = 0, queuedCount = 0; - bool completedChanged = false; - try - { - lock (lockObject) - { - if (Current != null) - { - _completed.Add(Current); - completedCount = _completed.Count; - completedChanged = true; - } - if (_queued.Count == 0) - { - Current = null; - return false; - } - Current = _queued[0]; - _queued.RemoveAt(0); - - queuedCount = _queued.Count; - return true; - } - } - finally - { - if (completedChanged) - CompletedCountChanged?.Invoke(this, completedCount); - QueuededCountChanged?.Invoke(this, queuedCount); - } - } - - public bool TryPeek(out T item) - { - lock (lockObject) - { - if (_queued.Count == 0) - { - item = null; - return false; - } - item = _queued[0]; - return true; - } - } - - public T Peek() - { - lock (lockObject) - { - if (_queued.Count == 0) throw new InvalidOperationException("Queue empty"); - return _queued.Count > 0 ? _queued[0] : default; - } - } - - public void Enqueue(IEnumerable item) - { - int queueCount; - lock (lockObject) - { - _queued.AddRange(item); - queueCount = _queued.Count; - } - QueuededCountChanged?.Invoke(this, queueCount); - } - } -} diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj index 1d61b115..7150e29e 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj +++ b/Source/LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj @@ -29,6 +29,9 @@ + + Always + Always diff --git a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs index 23f26a7c..701238cc 100644 --- a/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs +++ b/Source/LoadByOS/MacOSConfigApp/MacOSInterop.cs @@ -1,4 +1,5 @@ -using LibationFileManager; +using Dinah.Core; +using LibationFileManager; using System.Diagnostics; namespace MacOSConfigApp @@ -9,8 +10,14 @@ namespace MacOSConfigApp public MacOSInterop() { } public MacOSInterop(params object[] values) { } - public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException(); - public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException(); + public void SetFolderIcon(string image, string directory) + { + Process.Start("fileicon", $"set {directory.SurroundWithQuotes()} {image.SurroundWithQuotes()}").WaitForExit(); + } + public void DeleteFolderIcon(string directory) + { + Process.Start("fileicon", $"rm {directory.SurroundWithQuotes()}").WaitForExit(); + } //I haven't figured out how to find the app bundle's directory from within //the running process, so don't upgrade unless it's "installed" in /Applications @@ -21,7 +28,7 @@ namespace MacOSConfigApp Serilog.Log.Information($"Extracting upgrade bundle to {AppPath}"); //tar wil overwrite existing without elevated privileges - Process.Start("tar", $"-xf \"{upgradeBundle}\" -C \"/Applications\"").WaitForExit(); + Process.Start("tar", $"-xf {upgradeBundle.SurroundWithQuotes()} -C \"/Applications\"").WaitForExit(); //For now, it seems like this step is unnecessary. We can overwrite and //run Libation without needing to re-add the exception. This is insurance. diff --git a/Source/LoadByOS/MacOSConfigApp/fileicon b/Source/LoadByOS/MacOSConfigApp/fileicon new file mode 100644 index 00000000..813945aa --- /dev/null +++ b/Source/LoadByOS/MacOSConfigApp/fileicon @@ -0,0 +1,732 @@ +#!/usr/bin/env bash + +### +# Home page: https://github.com/mklement0/fileicon +# Author: Michael Klement (http://same2u.net) +# Invoke with: +# --version for version information +# --help for usage information +### + +# --- STANDARD SCRIPT-GLOBAL CONSTANTS + +kTHIS_NAME=${BASH_SOURCE##*/} +kTHIS_HOMEPAGE='https://github.com/mklement0/fileicon' +kTHIS_VERSION='v0.3.3' # NOTE: This assignment is automatically updated by `make version VER=` - DO keep the 'v' prefix. + +unset CDPATH # To prevent unpredictable `cd` behavior. + +# --- Begin: STANDARD HELPER FUNCTIONS + +die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; } +dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; } + +# SYNOPSIS +# openUrl +# DESCRIPTION +# Opens the specified URL in the system's default browser. +openUrl() { + local url=$1 platform=$(uname) cmd=() + case $platform in + 'Darwin') # OSX + cmd=( open "$url" ) + ;; + 'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin + cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space. + ;; + 'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary + cmd=( start '' "$url" ) + ;; + *) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ... + cmd=( xdg-open "$url" ) + ;; + esac + "${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; } +} + +# Prints the embedded Markdown-formatted man-page source to stdout. +printManPageSource() { + /usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE" +} + +# Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online. +openManPage() { + local pager embeddedText + if ! man 1 "$kTHIS_NAME" 2>/dev/null; then + # 2nd attempt: if present, display the embedded Markdown-formatted man-page source + embeddedText=$(printManPageSource) + if [[ -n $embeddedText ]]; then + pager='more' + command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more` + printf '%s\n' "$embeddedText" | "$pager" + else # 3rd attempt: open the the man page on the utility's website + openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md" + fi + fi +} + +# Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference. +printUsage() { + local embeddedText + # Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source. + embeddedText=$(/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE") + if [[ -n $embeddedText ]]; then + # Print extracted synopsis chapter - remove backticks for uncluttered display. + printf '%s\n\n' "$embeddedText" | tr -d '`' + else # No SYNOPIS chapter found; fall back to displaying the man page. + echo "WARNING: usage information not found; opening man page instead." >&2 + openManPage + fi +} + +# --- End: STANDARD HELPER FUNCTIONS + +# --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS. +case $1 in + --version) + # Output version number and exit, if requested. + ver="v0.3.3"; echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0 + ;; + -h|--help) + # Print usage information and exit. + printUsage; exit + ;; + --man) + # Display the manual page and exit. + openManPage; exit + ;; + --man-source) # private option, used by `make update-doc` + # Print raw, embedded Markdown-formatted man-page source and exit + printManPageSource; exit + ;; + --home) + # Open the home page and exit. + openUrl "$kTHIS_HOMEPAGE"; exit + ;; +esac + +# --- Begin: SPECIFIC HELPER FUNCTIONS + +# NOTE: The functions below operate on byte strings such as the one above: +# A single single string of pairs of hex digits, without separators or line breaks. +# Thus, a given byte position is easily calculated: to get byte $byteIndex, use +# ${byteString:byteIndex*2:2} + +# Outputs the specified EXTENDED ATTRIBUTE VALUE as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000A2C". +# IMPORTANT: Hex. digits > 9 use UPPPERCASE characters. +# getAttribByteString +getAttribByteString() { + /usr/bin/xattr -px "$2" "$1" | tr -d ' \n' + return ${PIPESTATUS[0]} +} + +# Outputs the specified file's RESOURCE FORK as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000a2c". +# IMPORTANT: Hex. digits > 9 use *lowercase* characters. +# Note: This function relies on `xxd -p /..namedfork/rsrc | tr -d '\n'` rather than the conceptually equivalent call, +# `getAttribByteString com.apple.ResourceFork`, for PERFORMANCE reasons: +# getAttribByteString() (defined above) relies on `xattr`, which is a *Python* script [!! seemingly no longer, as of macOS 10.16] +# and therefore quite slow due to Python's startup cost. +# getResourceByteString +getResourceByteString() { + xxd -p "$1"/..namedfork/rsrc | tr -d '\n' +} + +# Patches a single byte in the byte string provided via stdin. +# patchByteInByteString ndx byteSpec +# ndx is the 0-based byte index +# - If has NO prefix: becomes the new byte +# - If has prefix '|': "adds" the value: the result of a bitwise OR with the existing byte becomes the new byte +# - If has prefix '~': "removes" the value: the result of a applying a bitwise AND with the bitwise complement of to the existing byte becomes the new byte +patchByteInByteString() { + local ndx=$1 byteSpec=$2 byteVal byteStr charPos op='' charsBefore='' charsAfter='' currByte + byteStr=$( 0 && charPos < ${#byteStr} )) || return 1 + # Determine the target byte, and strings before and after the byte to patch. + (( charPos >= 2 )) && charsBefore=${byteStr:0:charPos} + charsAfter=${byteStr:charPos + 2} + # Determine the new byte value + if [[ -n $op ]]; then + currByte=${byteStr:charPos:2} + printf -v patchedByte '%02X' "$(( 0x${currByte} $op 0x${byteVal} ))" + else + patchedByte=$byteSpec + fi + printf '%s%s%s' "$charsBefore" "$patchedByte" "$charsAfter" +} + +# hasAttrib +hasAttrib() { + /usr/bin/xattr "$1" | /usr/bin/grep -Fqx "$2" +} + +# hasIconData +# Test if the file has a resource fork with icon data in it, or, +# in the case of a .VolumeIcon.icns file, has icon data *as the file contents* +hasIconData() { + local file=$1 + if [[ $(basename "$file") == $kFILENAME_VOLUMECUSTOMICON ]]; then + # special file for any folder that is a *volume mountpoint*: has the icon data as the file's *content* + file "$file" | /usr/bin/grep -Fq ' Mac OS X icon' + else + # file itself or special helper file $'Icon\r' for a regular folder: has the icon data *in its resource fork*. + getResourceByteString "$file" | /usr/bin/grep -Fq "$kMAGICBYTES_ICNS_RESOURCE" + fi +} + +# isVolumeMountPoint +isVolumeMountPoint() { + local folder=$1 + # Must resolve to the physical underlying path, as that is what `mount` shows + folder=$(cd -P -- "$1"; pwd) + mount | grep -qF "on $folder (" # !! Is there a more robust way to test for mountpoints? +} + +# getFileWithIconData +# Returns the path of the file that contains the actual icon data, based on whether the target is +# * a file ... the file path itself +# * a folder: +# * regular folder: file $'Icon\r' inside that folder, with the icon data in its resource fork +# * volume mountpoint: file '.VolumeIcon.icns' inside that folder, with the icon data inside the file. +getFileWithIconData() { + local fileOrFolder=$1 + if [[ -f $fileOrFolder ]]; then # file + printf '%s' "$fileOrFolder" + elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint + printf '%s' "$fileOrFolder/$kFILENAME_VOLUMECUSTOMICON" + else # regular folder + printf '%s' "$fileOrFolder/$kFILENAME_FOLDERCUSTOMICON" + fi +} + +# getTargetType +# Returns a descriptor for the specified target path: +# * a file ... 'file' +# * a folder: +# * regular folder: 'folder' +# * volume mountpoint: 'volume' +getTargetType() { + local fileOrFolder=$1 + if [[ -f $fileOrFolder ]]; then # file + printf 'file' + elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint + printf 'volume' + else # regular folder + printf 'folder' + fi +} + + +# setCustomIcon +# Tips for debugging: +# * To exercise this function, from the repo dir.: +# touch /tmp/tf; ./bin/fileicon set /tmp/tf ./test/.fixtures/img.png +# !!??? VOLUME SUPPORT, as of macOS 13.1: +# !! * While targeting volume root folders is now supported *in principle*, +# !! assigning the 'com.apple.FinderInfo' extended attribute to the mountpoint folder +# !! typically (always??) fails, so the icon doesn't take effect. +# !! SetFile -a C also fails - with or without `sudo` - with "ERROR: Unexpected Error. (-5000) on file: " +# !! (SetFile -a c clears the custom-icon flag; note that SetFile isn't installed by default and is DEPRECATED: "Tools supporting Carbon development, including /usr/bin/SetFile, were deprecated with Xcode 6.") +# !! ?? It feels like at *some point* on 13.1 our NFS mount from our NAS seemed to support it, but inexplicably no longer. +# !! * SOME volumes, even if *network* volumes, support custom icons for their files and folders, +# !! such as a SMB mount. +# !! ?? Our NFS mount from our NAS seemed to support that *for a while* on 13.1, but inexplicably no longer. +setCustomIcon() { + + local fileOrFolder=$1 imgFile=$2 fileWithIconData + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 3 + [[ -f $imgFile ]] || return 3 + + # !! Sadly, Apple decided to remove the `-i` / `--addicon` option from the `sips` utility. + # !! Therefore, use of *Cocoa* is required, which we do *via AppleScript* and its ObjC bridge, + # !! which has the added advantage of creating a *set* of icons from the source image, scaling as necessary + # !! to create a 512 x 512 top resolution icon (whereas sips -i created a single, 128 x 128 icon). + # !! Thanks: + # !! * https://apple.stackexchange.com/a/161984/28668 (Python original) + # !! * @scriptingosx (https://github.com/mklement0/fileicon/issues/32#issuecomment-1074124748) (AppleScript-ObjC version) + # !! Note: We moved from Python to AppleScript when the system Python was removed in macOS 12.3 + + # !! Note: The setIcon method seemingly always indicates True, even with invalid image files, so + # !! we attempt no error handling in the AppleScript code, and instead verify success explicitly later. + osascript </dev/null || die + use framework "Cocoa" + + set sourcePath to "$imgFile" + set destPath to "$fileOrFolder" + + set imageData to (current application's NSImage's alloc()'s initWithContentsOfFile:sourcePath) + (current application's NSWorkspace's sharedWorkspace()'s setIcon:imageData forFile:destPath options:2) +EOF + + # Fully verify that everything worked as intended. + # Unfortunately, several things can go wrong. + testForCustomIcon "$targetFileOrFolder" 2>/dev/null && return 0 + ec=$? + + if (( ec == 1 )); then + cat >&2 <&2 <&2 < +getCustomIcon() { + + local fileOrFolder=$1 icnsOutFile=$2 byteStr fileWithIconData byteOffset byteCount + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3 + + # Determine what file to extract the resource fork from. + if [[ -d $fileOrFolder ]]; then + + fileWithIconData=$(getFileWithIconData "$fileOrFolder") + [[ -f $fileWithIconData ]] || { echo "Custom-icon file does not exist: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; } + + if [[ $(basename "$fileWithIconData") == $kFILENAME_VOLUMECUSTOMICON ]]; then + # !! Volume mount points are an exception: their helper file contains the icon data as the file's *content* + # !! rather than in a *resource fork*; therefore, simply *copying* the file's content is enough. + # !! However, we use `cat` rather than `cp`, so as not to also copy the extended attributes. + cat "$fileWithIconData" > "$icnsOutFile" || die + return 0 + fi + # Otherwise: proceed below to extract the data from the resource fork. + + else + fileWithIconData=$fileOrFolder + fi + + # Determine (based on format description at https://en.wikipedia.org/wiki/Apple_Icon_Image_format): + # - the byte offset at which the icns resource begins, via the magic literal identifying an icns resource + # - the length of the resource, which is encoded in the 4 bytes right after the magic literal. + read -r byteOffset byteCount < <(getResourceByteString "$fileWithIconData" | /usr/bin/awk -F "$kMAGICBYTES_ICNS_RESOURCE" '{ printf "%s %d", (length($1) + 2) / 2, "0x" substr($2, 0, 8) }') + (( byteOffset > 0 && byteCount > 0 )) || { echo "Custom-icon file contains no icons resource: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; } + + # Extract the actual bytes using tail and head and save them to the output file. + tail -c "+${byteOffset}" "$fileWithIconData/..namedfork/rsrc" | head -c $byteCount > "$icnsOutFile" || return + + return 0 +} + +# removeCustomIcon +removeCustomIcon() { + + local fileOrFolder=$1 byteStr + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 1 + + # Step 1: Turn off the custom-icon flag in the com.apple.FinderInfo extended attribute. + # Note: Using SetFile -a c is tempting, but SetFile doesn't come with macOS by default (part of XCode CLI package) + if hasAttrib "$fileOrFolder" com.apple.FinderInfo; then + byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo | patchByteInByteString $kFI_BYTEOFFSET_CUSTOMICON '~'$kFI_VAL_CUSTOMICON) || return + if [[ $byteStr == "$kFI_BYTES_BLANK" ]]; then # All bytes cleared? Remove the entire attribute. + /usr/bin/xattr -d com.apple.FinderInfo "$fileOrFolder" + else # Update the attribute. + /usr/bin/xattr -wx com.apple.FinderInfo "$byteStr" "$fileOrFolder" || return + fi + fi + + # Step 2: Remove the resource fork (if target is a file) / hidden file with custom icon (if target is a folder) + if [[ -d $fileOrFolder ]]; then # folder or volume -> remove the special file inside it. + rm -f "$(getFileWithIconData "$fileOrFolder")" + else # file -> remove the resource fork + if hasIconData "$fileOrFolder"; then + /usr/bin/xattr -d com.apple.ResourceFork "$fileOrFolder" + fi + fi + + return 0 +} + +# testForCustomIcon +testForCustomIcon() { + + local fileOrFolder=$1 byteStr byteVal fileWithIconData hasCustomIconFlag hasIconData + + [[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3 + + # Step 1: Check if the com.apple.FinderInfo extended attribute has the custom-icon + # flag set. This applies to *all* target types. + byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo 2>/dev/null) # || return 1 + byteVal=${byteStr:2*kFI_BYTEOFFSET_CUSTOMICON:2} + hasCustomIconFlag=$(( byteVal & kFI_VAL_CUSTOMICON )) + + fileWithIconData=$(getFileWithIconData "$fileOrFolder") + # Step 2: Check if there's actual icon data present, + # via the resource fork of the file or the folder's helper file or the file content of a + # volume mountpoint's helper file (./.VolumeIcon.icns) + hasIconData "$fileWithIconData" 2>/dev/null && hasIconData=1 || hasIconData=0 + + # Provide a specific exit code reflecting the state. + # !! This is used by setCustomIcon() + if (( hasCustomIconFlag && hasIconData )); then + return 0 # has custom icon + elif (( ! hasCustomIconFlag && ! hasIconData )); then + return 1 # typical case of file/folder NOT having a custom icon + elif (( ! hasCustomIconFlag )); then + echo "WARNING: Custom-icon data is present, but the 'com.apple.FinderInfo' extended attribute isn't set for $(getTargetType "$fileOrFolder") '$fileOrFolder'" >&2 + return 2 # broken state: has icons, but no custom flag + else # (( ! hasIconData )) + echo "WARNING: While the 'com.apple.FinderInfo' extended attribute is set for $(getTargetType "$fileOrFolder") '$fileOrFolder', associated icon data is missing." >&2 + return 3 # broken state: has custom flag, but no icons + fi + +} + +# --- End: SPECIFIC HELPER FUNCTIONS + +# --- Begin: SPECIFIC SCRIPT-GLOBAL CONSTANTS + +kFILENAME_FOLDERCUSTOMICON=$'Icon\r' # the helper file for regular folders, with the actual icon image data in its *resource fork* +kFILENAME_VOLUMECUSTOMICON='.VolumeIcon.icns' # the helper file for volume mountpoints, with the actual icon image data in the file's *content*. + +# The blank hex dump form (single string of pairs of hex digits) of the 32-byte data structure stored in extended attribute +# com.apple.FinderInfo +kFI_BYTES_BLANK='0000000000000000000000000000000000000000000000000000000000000000' + +# [UPDATE] +# * THIS CONSTANT ISN'T USED ANYMORE. +# * Also, on macOS 13 (Ventura): seemingly, the Icon\r file's com.apple.FinderInfo extended attribute is now +# SIMPLER: where the folder itself has 0x4 in its 9th byte, Icon\r now has 0x40 +# [ORIGINAL COMMENT] +# The hex dump form of the full 32 bytes that Finder assigns to the hidden $'Icon\r' +# file whose com.apple.ResourceFork extended attribute contains the icon image data for the enclosing folder. +# The first 8 bytes spell out the magic literal 'iconMACS'; they are followed by the invisibility flag, '40' in the 9th byte, and '10' (?? specifying what?) +# in the 10th byte. +# NOTE: Since file $'Icon\r' serves no other purpose than to store the icon, it is +# safe to simply assign all 32 bytes blindly, without having to worry about +# preserving existing values. +# kFI_BYTES_CUSTOMICONFILEFORFOLDER='69636F6E4D414353401000000000000000000000000000000000000000000000' + +# The hex dump form of the magic literal inside a resource fork that marks the +# start of an icns (icons) resource. +# NOTE: This will be used with `xxd -p .. | tr -d '\n'`, which uses *lowercase* +# hex digits, so we must use lowercase here. +kMAGICBYTES_ICNS_RESOURCE='69636e73' + +# The byte values (as hex strings) of the flags at the relevant byte position +# of the com.apple.FinderInfo extended attribute. +kFI_VAL_CUSTOMICON='04' + +# The custom-icon-flag byte offset in the com.apple.FinderInfo extended attribute. +kFI_BYTEOFFSET_CUSTOMICON=8 + +# --- End: SPECIFIC SCRIPT-GLOBAL CONSTANTS + +# Option defaults. +force=0 quiet=0 + +# --- Begin: OPTIONS PARSING +allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0 +while (( $# )); do + if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option + prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0 + for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do + acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq= + if (( isLong )); then # long option: parse into name and, if present, argument + optName=${1:2} + [[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; } + else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument. + optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1 + fi + (( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; } + # ---- BEGIN: CUSTOMIZE HERE + case $optName in + f|force) + force=1 + ;; + q|quiet) + quiet=1 + ;; + *) + dieSyntax "Unknown option: ${prefix}${optName}." + ;; + esac + # ---- END: CUSTOMIZE HERE + (( needOptArg )) && { (( ! haveOptArgAttached && ! haveOptArgAsNextArg )) && dieSyntax "Option ${prefix}${optName} is missing its argument." || (( haveOptArgAsNextArg )) && shift; } + (( acceptOptArg || needOptArg )) && break + done + else # an operand + if [[ $1 == '--' ]]; then + shift; operands+=( "$@" ); break + elif (( allowOptsAfterOperands )); then + operands+=( "$1" ) # continue + else + operands=( "$@" ) + break + fi + fi + shift +done +(( "${#operands[@]}" > 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg +# --- End: OPTIONS PARSING: "$@" now contains all operands (non-option arguments). + +# Validate the command +cmd=$(printf %s "$1" | tr '[:upper:]' '[:lower:]') # translate to all-lowercase - we don't want the command name to be case-sensitive +[[ $cmd == 'remove' ]] && cmd='rm' # support alias 'remove' for 'rm' +case $cmd in + set|get|rm|remove|test) + shift + ;; + *) + dieSyntax "Unrecognized or missing command: '$cmd'." + ;; +esac + +# Validate file operands +(( $# > 0 )) || dieSyntax "Missing operand(s)." + +# Target file or folder. +targetFileOrFolder=$1 imgFile= outFile= +[[ -f $targetFileOrFolder || -d $targetFileOrFolder ]] || die "Target not found or neither file nor folder: '$targetFileOrFolder'" +# Make sure the target file/folder is readable, and, unless only getting or testing for an icon are requested, writeable too. +[[ -r $targetFileOrFolder ]] || die "Cannot access '$targetFileOrFolder': you do not have read permissions." +[[ $cmd == 'test' || $cmd == 'get' || -w $targetFileOrFolder ]] || die "Cannot modify '$targetFileOrFolder': you do not have write permissions." + +# Other operands, if any, and their number. +valid=0 +case $cmd in + 'set') + (( $# <= 2 )) && { + valid=1 + # If no image file was specified, the target file is assumed to be an image file itself whose image should be self-assigned as an icon. + (( $# == 2 )) && imgFile=$2 || imgFile=$1 + # !! Apparently, a regular file is required - a process subsitution such + # !! as `<(base64 -D /dev/null + else + testForCustomIcon "$targetFileOrFolder" # This may issue warnings. + fi + ec=$? + if (( ! quiet )); then + echo "$( (( ec == 0 )) && printf 'HAS' || printf 'Has NO' ) custom icon: $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'" + fi + exit $ec + ;; + *) + die "DESIGN ERROR: unanticipated command: $cmd" + ;; +esac + +exit 0 + +#### +# MAN PAGE MARKDOWN SOURCE +# - Place a Markdown-formatted version of the man page for this script +# inside the here-document below. +# The document must be formatted to look good in all 3 viewing scenarios: +# - as a man page, after conversion to ROFF with marked-man +# - as plain text (raw Markdown source) +# - as HTML (rendered Markdown) +# Markdown formatting tips: +# - GENERAL +# To support plain-text rendering in the terminal, limit all lines to 80 chars., +# and, for similar rendering as HTML, *end every line with 2 trailing spaces*. +# - HEADINGS +# - For better plain-text rendering, leave an empty line after a heading +# marked-man will remove it from the ROFF version. +# - The first heading must be a level-1 heading containing the utility +# name and very brief description; append the manual-section number +# directly to the CLI name; e.g.: +# # foo(1) - does bar +# - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body +# must render reasonably as plain text, because it is printed to stdout +# when `-h`, `--help` is specified: +# Use 4-space indentation without markup for both the syntax line and the +# block of brief option descriptions; represent option-arguments and operands +# in angle brackets; e.g., '' +# - All other headings should be level-2 headings in ALL-CAPS. +# - TEXT +# - Use NO indentation for regular chapter text; if you do, it will +# be indented further than list items. +# - Use 4-space indentation, as usual, for code blocks. +# - Markup character-styling markup translates to ROFF rendering as follows: +# `...` and **...** render as bolded (red) text +# _..._ and *...* render as word-individually underlined text +# - LISTS +# - Indent list items by 2 spaces for better plain-text viewing, but note +# that the ROFF generated by marked-man still renders them unindented. +# - End every list item (bullet point) itself with 2 trailing spaces too so +# that it renders on its own line. +# - Avoid associating more than 1 paragraph with a list item, if possible, +# because it requires the following trick, which hampers plain-text readability: +# Use ' ' in lieu of an empty line. +#### +: <<'EOF_MAN_PAGE' +# fileicon(1) - manage file and folder custom icons + +## SYNOPSIS + +Manage custom icons for files and folders on macOS. + +SET a custom icon for a file or folder: + + fileicon set [] + +REMOVE a custom icon from a file or folder: + + fileicon rm + +GET a file or folder's custom icon: + + fileicon get [-f] [] + + -f ... force replacement of existing output file + +TEST if a file or folder has a custom icon: + + fileicon test + +All forms: option -q silences status output. + +Standard options: `--help`, `--man`, `--version`, `--home` + +## DESCRIPTION + +`` is the file or folder whose custom icon should be managed. +Note that symlinks are followed to their (ultimate target); that is, you +can only assign custom icons to regular files and folders, not to symlinks +to them. + +`` can be an image file of any format supported by the system. +It is converted to an icon and assigned to ``. +If you omit ``, `` must itself be an image file whose +image should become its own icon. + +`` specifies the file to extract the custom icon to: +Defaults to the filename of `` with extension `.icns` appended. +If a value is specified, extension `.icns` is appended, unless already present. +Either way, extraction fails if the target file already exists; use `-f` to +override. +Specify `-` to extract to stdout. + +Command `test` signals with its exit code whether a custom icon is set (0) +or not (1); any other exit code signals an unexpected error. + +**Options**: + + * `-f`, `--force` + When getting (extracting) a custom icon, forces replacement of the + output file, if it already exists. + + * `-q`, `--quiet` + Suppresses output of the status information that is by default output to + stdout. + Note that errors and warnings are still printed to stderr. + +## NOTES + +Custom icons are stored in extended attributes of the HFS+ filesystem. +Thus, if you copy files or folders to a different filesystem that doesn't +support such attributes, custom icons are lost; for instance, custom icons +cannot be stored in a Git repository. + +To determine if a give file or folder has extended attributes, use +`ls -l@ `. + +When setting an image as a custom icon, a set of icons with several resolutions +is created, with the highest resolution at 512 x 512 pixels. + +All icons created are square, so images with a non-square aspect ratio will +appear distorted; for best results, use square imges. + +## STANDARD OPTIONS + +All standard options provide information only. + +* `-h, --help` + Prints the contents of the synopsis chapter to stdout for quick reference. + +* `--man` + Displays this manual page, which is a helpful alternative to using `man`, + if the manual page isn't installed. + +* `--version` + Prints version information. + +* `--home` + Opens this utility's home page in the system's default web browser. + +## LICENSE + +For license information and more, visit the home page by running +`fileicon --home` + +EOF_MAN_PAGE diff --git a/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs b/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs index e35eb57c..2b4184dc 100644 --- a/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs +++ b/Source/LoadByOS/WindowsConfigApp/FolderIcon.cs @@ -1,45 +1,17 @@ -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Png; +using LibationUiBase; +using SixLabors.ImageSharp; using System; using System.IO; -using System.Runtime.InteropServices; namespace WindowsConfigApp -{ +{ internal static partial class FolderIcon { - // https://stackoverflow.com/a/21389253 + static readonly IcoEncoder IcoEncoder = new(); public static byte[] ToIcon(this Image img) { - using var ms = new MemoryStream(); - using var bw = new BinaryWriter(ms); - // Header - bw.Write((short)0); // 0-1 : reserved - bw.Write((short)1); // 2-3 : 1=ico, 2=cur - bw.Write((short)1); // 4-5 : number of images - // Image directory - var w = img.Width; - if (w >= 256) w = 0; - bw.Write((byte)w); // 0 : width of image - var h = img.Height; - if (h >= 256) h = 0; - bw.Write((byte)h); // 1 : height of image - bw.Write((byte)0); // 2 : number of colors in palette - bw.Write((byte)0); // 3 : reserved - bw.Write((short)0); // 4 : number of color planes - bw.Write((short)0); // 6 : bits per pixel - var sizeHere = ms.Position; - bw.Write((int)0); // 8 : image size - var start = (int)ms.Position + 4; - bw.Write(start); // 12: offset of image data - // Image data - img.Save(ms, new PngEncoder()); - var imageSize = (int)ms.Position - start; - ms.Seek(sizeHere, SeekOrigin.Begin); - bw.Write(imageSize); - ms.Seek(0, SeekOrigin.Begin); - - // And load it + using var ms = new MemoryStream(); + img.Save(ms, IcoEncoder); return ms.ToArray(); } @@ -57,50 +29,33 @@ namespace WindowsConfigApp File.Delete(text); } } - - refresh(); } // https://github.com/dimuththarindu/FIC-Folder-Icon-Changer/blob/master/project/FIC/Classes/IconCustomizer.cs - public static void SetIcon(this DirectoryInfo directoryInfo, string icoPath, string folderType) - => SetIcon(directoryInfo.FullName, icoPath, folderType); + public static void SetIcon(this DirectoryInfo directoryInfo, byte[] icon, string folderType) + => SetIcon(directoryInfo.FullName, icon, folderType); - public static void SetIcon(string dir, string icoPath, string folderType) + public static void SetIcon(string dir, byte[] icon, string folderType) { var desktop_ini = Path.Combine(dir, "desktop.ini"); var Icon_ico = Path.Combine(dir, "Icon.ico"); - var hidden = Path.Combine(dir, ".hidden"); //deleting existing files DeleteIcon(dir); //copying Icon file //overwriting - File.Copy(icoPath, Icon_ico, true); + File.WriteAllBytes(Icon_ico, icon); //writing configuration file - string[] desktopLines = { "[.ShellClassInfo]", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" }; + string[] desktopLines = { "[.ShellClassInfo]", "ConfirmFileOp=0", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" }; File.WriteAllLines(desktop_ini, desktopLines); - //configure file 2 - string[] hiddenLines = { "desktop.ini", "Icon.ico" }; - File.WriteAllLines(hidden, hiddenLines); + File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.ReadOnly); + File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.ReadOnly); - //making system files - File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly); - File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly); - File.SetAttributes(hidden, File.GetAttributes(hidden) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly); - - // this strangely completes the process. also hides these 3 hidden system files, even if "show hidden items" is checked - File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.ReadOnly); - - refresh(); + //https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini + File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.System); } - - private static void refresh() => SHChangeNotify(0x08000000, 0x0000, 0, 0); //SHCNE_ASSOCCHANGED SHCNF_IDLIST - - - [DllImport("shell32.dll", SetLastError = true)] - private static extern void SHChangeNotify(int wEventId, int uFlags, nint dwItem1, nint dwItem2); } } diff --git a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs index 5aef4bea..61d5c2ce 100644 --- a/Source/LoadByOS/WindowsConfigApp/WinInterop.cs +++ b/Source/LoadByOS/WindowsConfigApp/WinInterop.cs @@ -14,22 +14,9 @@ namespace WindowsConfigApp public void SetFolderIcon(string image, string directory) { - string iconPath = null; - - try - { - var icon = Image.Load(File.ReadAllBytes(image)).ToIcon(); - iconPath = Path.Combine(directory, $"{Guid.NewGuid()}.ico"); - File.WriteAllBytes(iconPath, icon); - - new DirectoryInfo(directory)?.SetIcon(iconPath, "Music"); - } - finally - { - if (File.Exists(iconPath)) - File.Delete(iconPath); - } - } + var icon = Image.Load(image).ToIcon(); + new DirectoryInfo(directory)?.SetIcon(icon, "Music"); + } public void DeleteFolderIcon(string directory) => new DirectoryInfo(directory)?.DeleteIcon();