Merge pull request #522 from Mbucari/master
Resolved Several Issues (It's not as bad as 2,453 lines suggests)
This commit is contained in:
commit
1b0fb2b316
@ -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
|
||||
|
||||
|
||||
@ -75,13 +75,15 @@ namespace AppScaffolding
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
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 //
|
||||
|
||||
@ -242,18 +242,16 @@ namespace ApplicationServices
|
||||
#endregion
|
||||
|
||||
#region remove/restore books
|
||||
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
|
||||
private static int removeBooks(List<string> idsToRemove)
|
||||
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
|
||||
private static int removeBooks(IEnumerable<LibraryBook> 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<LibraryBook> libraryBooks)
|
||||
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
@ -303,6 +301,31 @@ namespace ApplicationServices
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> 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<Book> books, LiberatedStatus bookStatus)
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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));
|
||||
|
||||
410
Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs
generated
Normal file
410
Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs
generated
Normal file
@ -0,0 +1,410 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("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<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("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<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("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<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLastDownloadedInfo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastDownloaded",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LastDownloadedVersion",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDownloaded",
|
||||
table: "UserDefinedItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDownloadedVersion",
|
||||
table: "UserDefinedItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> 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<FilePathCache.CacheEntry> 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));
|
||||
|
||||
|
||||
BIN
Source/LibationAvalonia/Assets/Arrows_left.png
Normal file
BIN
Source/LibationAvalonia/Assets/Arrows_left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 B |
BIN
Source/LibationAvalonia/Assets/Arrows_right.png
Normal file
BIN
Source/LibationAvalonia/Assets/Arrows_right.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 B |
30
Source/LibationAvalonia/Controls/CheckedListBox.axaml
Normal file
30
Source/LibationAvalonia/Controls/CheckedListBox.axaml
Normal file
@ -0,0 +1,30 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
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="800" d:DesignHeight="450"
|
||||
x:Class="LibationAvalonia.Controls.CheckedListBox">
|
||||
|
||||
<UserControl.Resources>
|
||||
<RecyclePool x:Key="RecyclePool" />
|
||||
<DataTemplate x:Key="queuedBook">
|
||||
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
|
||||
<RecyclingElementFactory.Templates>
|
||||
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
|
||||
</RecyclingElementFactory.Templates>
|
||||
</RecyclingElementFactory>
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer
|
||||
Name="scroller"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsRepeater IsVisible="True"
|
||||
VerticalCacheLength="1.2"
|
||||
HorizontalCacheLength="1"
|
||||
Items="{Binding CheckboxItems}"
|
||||
ItemTemplate="{StaticResource elementFactory}" />
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
46
Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs
Normal file
46
Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs
Normal file
@ -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<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
|
||||
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
|
||||
|
||||
public AvaloniaList<CheckBoxViewModel> 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<CheckBoxViewModel> _checkboxItems;
|
||||
public AvaloniaList<CheckBoxViewModel> 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); }
|
||||
}
|
||||
}
|
||||
@ -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}">
|
||||
|
||||
<TextBlock
|
||||
|
||||
@ -108,7 +108,8 @@ namespace LibationAvalonia.Dialogs
|
||||
LoadSettings(config);
|
||||
}
|
||||
|
||||
public bool IsWindows => 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; }
|
||||
|
||||
66
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml
Normal file
66
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml
Normal file
@ -0,0 +1,66 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
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="630" d:DesignHeight="480"
|
||||
x:Class="LibationAvalonia.Dialogs.TrashBinDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
MinWidth="630" MinHeight="480"
|
||||
Title="Trash Bin"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
|
||||
<Grid
|
||||
RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
Text="Check books you want to permanently delete from or restore to Libation" />
|
||||
|
||||
<controls:CheckedListBox
|
||||
Grid.Row="1"
|
||||
Margin="5,0,5,0"
|
||||
BorderThickness="1"
|
||||
BorderBrush="Gray"
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Items="{Binding DeletedBooks}" />
|
||||
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<CheckBox
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
IsThreeState="True"
|
||||
Margin="0,0,20,0"
|
||||
IsChecked="{Binding EverythingChecked}"
|
||||
Content="Everything" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CheckedCountText}" />
|
||||
|
||||
<Button
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,20,0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Stretch"
|
||||
VerticalContentAlignment="Center"
|
||||
Content="Restore"
|
||||
Click="Restore_Click" />
|
||||
|
||||
<Button
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Grid.Column="3"
|
||||
Click="EmptyTrash_Click" >
|
||||
<TextBlock
|
||||
TextAlignment="Center"
|
||||
Text="Permanently Delete
from Libation" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
145
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs
Normal file
145
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs
Normal file
@ -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<CheckBoxViewModel> 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<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
|
||||
|
||||
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<object, PropertyChangedEventArgs> e)
|
||||
{
|
||||
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
|
||||
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
|
||||
}
|
||||
|
||||
public void Dispose() => tracker?.Dispose();
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,8 @@
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Remove=".gitignore" />
|
||||
<None Remove="Assets\Arrows_left.png" />
|
||||
<None Remove="Assets\Arrows_right.png" />
|
||||
<None Remove="Assets\Asterisk.png" />
|
||||
<None Remove="Assets\cancel.png" />
|
||||
<None Remove="Assets\completed.png" />
|
||||
|
||||
@ -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<bool>() },
|
||||
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
||||
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
||||
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> The Process Queue's Expand/Collapse button display text </summary>
|
||||
public string QueueHideButtonText { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary> The number of books visible in the Product Display </summary>
|
||||
public int VisibleCount
|
||||
|
||||
@ -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<LogEntry> LogEntries { get; } = new();
|
||||
public TrackedQueue<ProcessBookViewModel> Items { get; } = new();
|
||||
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => Items;
|
||||
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
|
||||
public TrackedQueue<ProcessBookViewModel> 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)
|
||||
|
||||
@ -20,11 +20,11 @@ namespace LibationAvalonia.ViewModels
|
||||
public event EventHandler<int> RemovableCountChanged;
|
||||
|
||||
/// <summary>Backing list of all grid entries</summary>
|
||||
private readonly List<GridEntry> SOURCE = new();
|
||||
private readonly AvaloniaList<GridEntry> SOURCE = new();
|
||||
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
|
||||
private List<GridEntry> 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<LibraryBookEntry>().Count());
|
||||
private static readonly System.Reflection.MethodInfo SetFlagsMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the <see cref="DataGridCollectionView"/> whether it should process changes to the underlying collection
|
||||
/// </summary>
|
||||
/// <remarks> DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4</remarks>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Call when there's been a change to the library
|
||||
/// </summary>
|
||||
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
|
||||
internal void BindToGrid(List<LibraryBook> 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<LibraryBookEntry>().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<GridEntry> CreateGridEntries(IEnumerable<LibraryBook> 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++;
|
||||
/// <summary>
|
||||
/// Call when there's been a change to the library
|
||||
/// </summary>
|
||||
internal async Task UpdateGridAsync(List<LibraryBook> 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<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
|
||||
{
|
||||
foreach (var removed in removedBooks.Cast<GridEntry>().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<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> 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);
|
||||
}
|
||||
|
||||
@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels
|
||||
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
|
||||
=> gridEntries.OfType<SeriesEntry>();
|
||||
|
||||
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
|
||||
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
|
||||
|
||||
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
|
||||
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
|
||||
|
||||
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
|
||||
{
|
||||
if (seriesEpisode.Book.SeriesLink is null) return null;
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
{
|
||||
|
||||
@ -131,6 +131,7 @@
|
||||
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
|
||||
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
|
||||
<Separator />
|
||||
<MenuItem Click="openTrashBinToolStripMenuItem_Click" Header="Trash Bin" />
|
||||
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
|
||||
<Separator />
|
||||
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
|
||||
@ -172,7 +173,12 @@
|
||||
|
||||
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
|
||||
<Button Click="filterBtn_Click" Height="30" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click" Content="{Binding QueueHideButtonText}"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click">
|
||||
<Panel>
|
||||
<Image Stretch="None" IsVisible="{Binding !QueueOpen}" Source="/Assets/Arrows_left.png" />
|
||||
<Image Stretch="None" IsVisible="{Binding QueueOpen}" Source="/Assets/Arrows_right.png" />
|
||||
</Panel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -15,7 +15,7 @@ namespace LibationAvalonia.Views
|
||||
{
|
||||
public partial class ProcessQueueControl : UserControl
|
||||
{
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Items;
|
||||
private TrackedQueue<ProcessBookViewModel> 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
|
||||
|
||||
@ -183,6 +183,16 @@
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<controls:DataGridTemplateColumnExt Width="102" Header="Last
Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}" ToolTip.Tip="{Binding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
|
||||
<TextBlock Text="{Binding LastDownload}" TextWrapping="WrapWithOverflow" FontSize="10" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)")]
|
||||
|
||||
@ -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
|
||||
|
||||
52
Source/LibationUiBase/IcoEncoder.cs
Normal file
52
Source/LibationUiBase/IcoEncoder.cs
Normal file
@ -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<TPixel>(Image<TPixel> image, Stream stream) where TPixel : unmanaged, IPixel<TPixel>
|
||||
{
|
||||
// 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<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel<TPixel>
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
41
Source/LibationUiBase/LastDownloadStatus.cs
Normal file
41
Source/LibationUiBase/LastDownloadStatus.cs
Normal file
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@ -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<T> : ObservableCollection<T> where T : class
|
||||
public class TrackedQueue<T> where T : class
|
||||
{
|
||||
public event EventHandler<int> CompletedCountChanged;
|
||||
public event EventHandler<int> QueuededCountChanged;
|
||||
@ -47,6 +46,60 @@ namespace LibationAvalonia.ViewModels
|
||||
private readonly List<T> _completed = new();
|
||||
private readonly object lockObject = new();
|
||||
|
||||
private readonly ICollection<T> _underlyingList;
|
||||
|
||||
public TrackedQueue(ICollection<T> 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<T> 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<T> GetAllItems()
|
||||
129
Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs
generated
Normal file
129
Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs
generated
Normal file
@ -0,0 +1,129 @@
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
partial class TrashBinDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
116
Source/LibationWinForms/Dialogs/TrashBinDialog.cs
Normal file
116
Source/LibationWinForms/Dialogs/TrashBinDialog.cs
Normal file
@ -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<LibraryBook>().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<LibraryBook>().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);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Source/LibationWinForms/Dialogs/TrashBinDialog.resx
Normal file
60
Source/LibationWinForms/Dialogs/TrashBinDialog.resx
Normal file
@ -0,0 +1,60 @@
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
12
Source/LibationWinForms/Form1.Designer.cs
generated
12
Source/LibationWinForms/Form1.Designer.cs
generated
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<bool>() },
|
||||
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
||||
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
||||
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
|
||||
};
|
||||
|
||||
#endregion
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 },
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 += (_, __) =>
|
||||
|
||||
@ -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 },
|
||||
|
||||
@ -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<T> where T : class
|
||||
{
|
||||
public event EventHandler<int> CompletedCountChanged;
|
||||
public event EventHandler<int> QueuededCountChanged;
|
||||
public T Current { get; private set; }
|
||||
|
||||
public IReadOnlyList<T> Queued => _queued;
|
||||
public IReadOnlyList<T> Completed => _completed;
|
||||
|
||||
private readonly List<T> _queued = new();
|
||||
private readonly List<T> _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<T, bool> 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<T> item)
|
||||
{
|
||||
int queueCount;
|
||||
lock (lockObject)
|
||||
{
|
||||
_queued.AddRange(item);
|
||||
queueCount = _queued.Count;
|
||||
}
|
||||
QueuededCountChanged?.Invoke(this, queueCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -29,6 +29,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="fileicon">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Info.plist">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
||||
@ -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.
|
||||
|
||||
732
Source/LoadByOS/MacOSConfigApp/fileicon
Normal file
732
Source/LoadByOS/MacOSConfigApp/fileicon
Normal file
@ -0,0 +1,732 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
###
|
||||
# Home page: https://github.com/mklement0/fileicon
|
||||
# Author: Michael Klement <mklement0@gmail.com> (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=<newVer>` - 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 <url>
|
||||
# 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 <file> <attrib_name>
|
||||
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 <file>/..namedfork/rsrc | tr -d '\n'` rather than the conceptually equivalent call,
|
||||
# `getAttribByteString <file> 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 <file>
|
||||
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 <byteSpec> has NO prefix: <byteSpec> becomes the new byte
|
||||
# - If <byteSpec> has prefix '|': "adds" the value: the result of a bitwise OR with the existing byte becomes the new byte
|
||||
# - If <byteSpec> has prefix '~': "removes" the value: the result of a applying a bitwise AND with the bitwise complement of <byteSpec> to the existing byte becomes the new byte
|
||||
patchByteInByteString() {
|
||||
local ndx=$1 byteSpec=$2 byteVal byteStr charPos op='' charsBefore='' charsAfter='' currByte
|
||||
byteStr=$(</dev/stdin)
|
||||
charPos=$(( 2 * ndx ))
|
||||
# Validat the byte spec.
|
||||
case ${byteSpec:0:1} in
|
||||
'|')
|
||||
op='|'
|
||||
byteVal=${byteSpec:1}
|
||||
;;
|
||||
'~')
|
||||
op='& ~'
|
||||
byteVal=${byteSpec:1}
|
||||
;;
|
||||
*)
|
||||
byteVal=$byteSpec
|
||||
;;
|
||||
esac
|
||||
[[ $byteVal == [0-9A-Fa-f][0-9A-Fa-f] ]] || return 1
|
||||
# Validat the byte index.
|
||||
(( charPos > 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 <fileOrFolder> <attrib_name>
|
||||
hasAttrib() {
|
||||
/usr/bin/xattr "$1" | /usr/bin/grep -Fqx "$2"
|
||||
}
|
||||
|
||||
# hasIconData <file>
|
||||
# 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 <folder>
|
||||
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 <fileOrFolder>
|
||||
# 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 <fileOrFolder>
|
||||
# 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 <fileOrFolder> <imgFile>
|
||||
# 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 <file> also fails - with or without `sudo` - with "ERROR: Unexpected Error. (-5000) on file: <file>"
|
||||
# !! (SetFile -a c <file> 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 <<EOF >/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 <<EOF
|
||||
Failed to assign a custom icon.
|
||||
Typically, this means that the specified image file is not supported or corrupt: $imgFile
|
||||
Supported image formats: jpeg | tiff | png | gif | jp2 | pict | bmp | qtif| psd | sgi | tga
|
||||
EOF
|
||||
elif ((ec == 2 )); then
|
||||
cat >&2 <<EOF
|
||||
Failed to set the custom-icon flag in the 'com.apple.FinderInfo' extended attribute of: $targetFileOrFolder
|
||||
Typically, this means that you're targeting a volume itself or a file or folder on a volume that doesn't support custom icons.
|
||||
Rerun with "rm" to clean up.
|
||||
EOF
|
||||
elif ((ec == 3 )); then
|
||||
cat >&2 <<EOF
|
||||
The custom-icon flag in the 'com.apple.FinderInfo' extended attribute of: $imgFile
|
||||
was successfully set, but, unexpectedly, the associated icon data was not.
|
||||
Rerun with "rm" to clean up.
|
||||
EOF
|
||||
fi
|
||||
|
||||
return $ec
|
||||
|
||||
}
|
||||
|
||||
# getCustomIcon <fileOrFolder> <icnsOutFile>
|
||||
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 <fileOrFolder>
|
||||
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 <file> 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 <fileOrFolder>
|
||||
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 <encoded-file.txt)` is NOT supported by NSImage.initWithContentsOfFile()
|
||||
[[ -f $imgFile && -r $imgFile ]] || die "Image file not found or not a (readable) regular file: $imgFile"
|
||||
}
|
||||
;;
|
||||
'rm'|'test')
|
||||
(( $# == 1 )) && valid=1
|
||||
;;
|
||||
'get')
|
||||
(( $# == 1 || $# == 2 )) && {
|
||||
valid=1
|
||||
outFile=$2
|
||||
if [[ $outFile == '-' ]]; then
|
||||
outFile=/dev/stdout
|
||||
else
|
||||
# By default, we extract to a file with the same filename root + '.icns'
|
||||
# in the current folder.
|
||||
[[ -z $outFile ]] && outFile=${targetFileOrFolder##*/}
|
||||
# Unless already specified, we append '.icns' to the output filename.
|
||||
mustReset=$(shopt -q nocasematch; echo $?); shopt -s nocasematch
|
||||
[[ $outFile =~ \.icns$ ]] || outFile+='.icns'
|
||||
(( mustReset )) && shopt -u nocasematch
|
||||
[[ -e $outFile && $force -eq 0 ]] && die "Output file '$outFile' already exists. To force its replacement, use -f."
|
||||
fi
|
||||
}
|
||||
;;
|
||||
esac
|
||||
(( valid )) || dieSyntax "Unexpected number of operands."
|
||||
|
||||
case $cmd in
|
||||
'set')
|
||||
setCustomIcon "$targetFileOrFolder" "$imgFile" || die
|
||||
(( quiet )) || echo "Custom icon assigned to $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder' based on '$imgFile'."
|
||||
;;
|
||||
'rm')
|
||||
removeCustomIcon "$targetFileOrFolder" || die
|
||||
(( quiet )) || echo "Custom icon (if any) removed from $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'."
|
||||
;;
|
||||
'get')
|
||||
getCustomIcon "$targetFileOrFolder" "$outFile" || die
|
||||
(( quiet )) || { [[ $outFile != '/dev/stdout' ]] && echo "Custom icon extracted to '$outFile'."; }
|
||||
exit 0
|
||||
;;
|
||||
'test')
|
||||
if (( quiet )); then
|
||||
testForCustomIcon "$targetFileOrFolder" 2>/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., '<foo>'
|
||||
# - 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 ' <space><space>' 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 <fileOrFolder> [<imageFile>]
|
||||
|
||||
REMOVE a custom icon from a file or folder:
|
||||
|
||||
fileicon rm <fileOrFolder>
|
||||
|
||||
GET a file or folder's custom icon:
|
||||
|
||||
fileicon get [-f] <fileOrFolder> [<iconOutputFile>]
|
||||
|
||||
-f ... force replacement of existing output file
|
||||
|
||||
TEST if a file or folder has a custom icon:
|
||||
|
||||
fileicon test <fileOrFolder>
|
||||
|
||||
All forms: option -q silences status output.
|
||||
|
||||
Standard options: `--help`, `--man`, `--version`, `--home`
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`<fileOrFolder>` 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.
|
||||
|
||||
`<imageFile>` can be an image file of any format supported by the system.
|
||||
It is converted to an icon and assigned to `<fileOrFolder>`.
|
||||
If you omit `<imageFile>`, `<fileOrFolder>` must itself be an image file whose
|
||||
image should become its own icon.
|
||||
|
||||
`<iconOutputFile>` specifies the file to extract the custom icon to:
|
||||
Defaults to the filename of `<fileOrFolder>` 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@ <fileOrFolder>`.
|
||||
|
||||
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
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user