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
|
exit
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
echo "Make fileicon executable..."
|
||||||
|
chmod +x $BUNDLE_MACOS/fileicon
|
||||||
|
|
||||||
echo "Moving icon..."
|
echo "Moving icon..."
|
||||||
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||||
|
|
||||||
|
|||||||
@ -75,13 +75,15 @@ namespace AppScaffolding
|
|||||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||||
.Max(a => a.Version);
|
.Max(a => a.Version);
|
||||||
|
|
||||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||||
public static Configuration RunPreConfigMigrations()
|
public static Configuration RunPreConfigMigrations()
|
||||||
{
|
{
|
||||||
// must occur before access to Configuration instance
|
// must occur before access to Configuration instance
|
||||||
// // outdated. kept here as an example of what belongs in this area
|
// // outdated. kept here as an example of what belongs in this area
|
||||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||||
|
|
||||||
|
Configuration.SetLibationVersion(BuildVersion);
|
||||||
|
|
||||||
//***********************************************//
|
//***********************************************//
|
||||||
// //
|
// //
|
||||||
// do not use Configuration before this line //
|
// do not use Configuration before this line //
|
||||||
|
|||||||
@ -242,18 +242,16 @@ namespace ApplicationServices
|
|||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region remove/restore books
|
#region remove/restore books
|
||||||
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||||
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
|
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
|
||||||
private static int removeBooks(List<string> idsToRemove)
|
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (idsToRemove is null || !idsToRemove.Any())
|
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||||
return 0;
|
return 0;
|
||||||
|
|
||||||
using var context = DbContexts.GetContext();
|
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()
|
// Attach() NoTracking entities before SaveChanges()
|
||||||
foreach (var lb in removeLibraryBooks)
|
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
|
try
|
||||||
{
|
{
|
||||||
@ -303,6 +301,31 @@ namespace ApplicationServices
|
|||||||
throw;
|
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
|
#endregion
|
||||||
|
|
||||||
// call this whenever books are added or removed from library
|
// call this whenever books are added or removed from library
|
||||||
@ -346,8 +369,10 @@ namespace ApplicationServices
|
|||||||
|
|
||||||
if (rating is not null)
|
if (rating is not null)
|
||||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
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)
|
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||||
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||||
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||||
|
using System;
|
||||||
|
|
||||||
namespace DataLayer.Configurations
|
namespace DataLayer.Configurations
|
||||||
{
|
{
|
||||||
@ -19,40 +20,45 @@ namespace DataLayer.Configurations
|
|||||||
entity.Ignore(nameof(Book.Authors));
|
entity.Ignore(nameof(Book.Authors));
|
||||||
entity.Ignore(nameof(Book.Narrators));
|
entity.Ignore(nameof(Book.Narrators));
|
||||||
entity.Ignore(nameof(Book.AudioFormat));
|
entity.Ignore(nameof(Book.AudioFormat));
|
||||||
//// these don't seem to matter
|
//// these don't seem to matter
|
||||||
//entity.Ignore(nameof(Book.AuthorNames));
|
//entity.Ignore(nameof(Book.AuthorNames));
|
||||||
//entity.Ignore(nameof(Book.NarratorNames));
|
//entity.Ignore(nameof(Book.NarratorNames));
|
||||||
//entity.Ignore(nameof(Book.HasPdfs));
|
//entity.Ignore(nameof(Book.HasPdfs));
|
||||||
|
|
||||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
// 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."
|
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||||
entity
|
entity
|
||||||
.OwnsMany(b => b.Supplements, b_s =>
|
.OwnsMany(b => b.Supplements, b_s =>
|
||||||
{
|
{
|
||||||
b_s.WithOwner(s => s.Book)
|
b_s.WithOwner(s => s.Book)
|
||||||
.HasForeignKey(s => s.BookId);
|
.HasForeignKey(s => s.BookId);
|
||||||
b_s.HasKey(s => s.SupplementId);
|
b_s.HasKey(s => s.SupplementId);
|
||||||
});
|
});
|
||||||
// even though it's owned, we need to map its backing field
|
// even though it's owned, we need to map its backing field
|
||||||
entity
|
entity
|
||||||
.Metadata
|
.Metadata
|
||||||
.FindNavigation(nameof(Book.Supplements))
|
.FindNavigation(nameof(Book.Supplements))
|
||||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
// owns it 1:1, store in separate table
|
// owns it 1:1, store in separate table
|
||||||
entity
|
entity
|
||||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||||
{
|
{
|
||||||
b_udi.WithOwner(udi => udi.Book)
|
b_udi.WithOwner(udi => udi.Book)
|
||||||
.HasForeignKey(udi => udi.BookId);
|
.HasForeignKey(udi => udi.BookId);
|
||||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||||
|
|
||||||
// owns it 1:1, store in same table
|
b_udi.Property(udi => udi.LastDownloaded);
|
||||||
b_udi.OwnsOne(udi => udi.Rating);
|
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
|
.Metadata
|
||||||
.FindNavigation(nameof(Book.ContributorsLink))
|
.FindNavigation(nameof(Book.ContributorsLink))
|
||||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
// 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)
|
.HasOne(b => b.Category)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey(b => b.CategoryId);
|
.HasForeignKey(b => b.CategoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -24,8 +24,27 @@ namespace DataLayer
|
|||||||
{
|
{
|
||||||
internal int BookId { get; private set; }
|
internal int BookId { get; private set; }
|
||||||
public Book Book { 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)
|
internal UserDefinedItem(Book book)
|
||||||
{
|
{
|
||||||
ArgumentValidator.EnsureNotNull(book, nameof(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)
|
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||||
|
|
||||||
modelBuilder.Entity("DataLayer.Book", b =>
|
modelBuilder.Entity("DataLayer.Book", b =>
|
||||||
{
|
{
|
||||||
@ -272,6 +272,12 @@ namespace DataLayer.Migrations
|
|||||||
b1.Property<int>("BookStatus")
|
b1.Property<int>("BookStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b1.Property<DateTime?>("LastDownloaded")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b1.Property<string>("LastDownloadedVersion")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b1.Property<int?>("PdfStatus")
|
b1.Property<int?>("PdfStatus")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
|||||||
@ -41,7 +41,7 @@ namespace FileLiberator
|
|||||||
|
|
||||||
OnBegin(libraryBook);
|
OnBegin(libraryBook);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (libraryBook.Book.Audio_Exists())
|
if (libraryBook.Book.Audio_Exists())
|
||||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||||
@ -61,31 +61,30 @@ namespace FileLiberator
|
|||||||
}
|
}
|
||||||
|
|
||||||
// decrypt failed
|
// decrypt failed
|
||||||
if (!success)
|
if (!success || getFirstAudioFile(entries) == default)
|
||||||
{
|
{
|
||||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
await Task.WhenAll(
|
||||||
FileUtility.SaferDelete(tmpFile.Path);
|
entries
|
||||||
|
.Where(f => f.FileType != FileType.AAXC)
|
||||||
|
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
||||||
|
|
||||||
return abDownloader?.IsCanceled == true ?
|
return
|
||||||
new StatusHandler { "Cancelled" } :
|
abDownloader?.IsCanceled is true
|
||||||
new StatusHandler { "Decrypt failed" };
|
? new StatusHandler { "Cancelled" }
|
||||||
|
: new StatusHandler { "Decrypt failed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// moves new files from temp dir to final dest.
|
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||||
// This could take a few seconds if moving hundreds of files.
|
|
||||||
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
|
||||||
|
|
||||||
// decrypt failed
|
Task[] finalTasks = new[]
|
||||||
if (finalStorageDir is null)
|
{
|
||||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
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)
|
await Task.WhenAll(finalTasks);
|
||||||
downloadCoverArt(libraryBook);
|
|
||||||
|
|
||||||
// contains logic to check for config setting and OS
|
|
||||||
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
|
|
||||||
|
|
||||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
|
||||||
|
|
||||||
return new StatusHandler();
|
return new StatusHandler();
|
||||||
}
|
}
|
||||||
@ -131,8 +130,8 @@ namespace FileLiberator
|
|||||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||||
|
|
||||||
// REAL WORK DONE HERE
|
// REAL WORK DONE HERE
|
||||||
return await abDownloader.RunAsync();
|
return await abDownloader.RunAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
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>
|
/// <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>
|
/// <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
|
// create final directory. move each file into it
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
Directory.CreateDirectory(destinationDir);
|
|
||||||
|
|
||||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
for (var i = 0; i < entries.Count; i++)
|
||||||
|
|
||||||
if (getFirstAudio() == default)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
for (var i = 0; i < entries.Count; i++)
|
|
||||||
{
|
{
|
||||||
var entry = entries[i];
|
var entry = entries[i];
|
||||||
|
|
||||||
@ -357,22 +350,33 @@ namespace FileLiberator
|
|||||||
entries[i] = entry with { Path = realDest };
|
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)
|
if (cue != default)
|
||||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
||||||
|
|
||||||
AudibleFileStorage.Audio.Refresh();
|
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]";
|
var coverPath = "[null]";
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
var destinationDir = getDestinationDirectory(libraryBook);
|
||||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
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:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
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"
|
MinWidth="900" MinHeight="700"
|
||||||
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
|
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
|
||||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||||
@ -376,7 +376,7 @@
|
|||||||
Grid.Row="3"
|
Grid.Row="3"
|
||||||
Margin="5"
|
Margin="5"
|
||||||
VerticalAlignment="Top"
|
VerticalAlignment="Top"
|
||||||
IsVisible="{Binding IsWindows}"
|
IsVisible="{Binding !IsLinux}"
|
||||||
IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}">
|
IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}">
|
||||||
|
|
||||||
<TextBlock
|
<TextBlock
|
||||||
|
|||||||
@ -108,7 +108,8 @@ namespace LibationAvalonia.Dialogs
|
|||||||
LoadSettings(config);
|
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 ImportantSettings ImportantSettings { get; private set; }
|
||||||
public ImportSettings ImportSettings { get; private set; }
|
public ImportSettings ImportSettings { get; private set; }
|
||||||
public DownloadDecryptSettings DownloadDecryptSettings { 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>
|
<ItemGroup>
|
||||||
<AvaloniaResource Include="Assets\**" />
|
<AvaloniaResource Include="Assets\**" />
|
||||||
<None Remove=".gitignore" />
|
<None Remove=".gitignore" />
|
||||||
|
<None Remove="Assets\Arrows_left.png" />
|
||||||
|
<None Remove="Assets\Arrows_right.png" />
|
||||||
<None Remove="Assets\Asterisk.png" />
|
<None Remove="Assets\Asterisk.png" />
|
||||||
<None Remove="Assets\cancel.png" />
|
<None Remove="Assets\cancel.png" />
|
||||||
<None Remove="Assets\completed.png" />
|
<None Remove="Assets\completed.png" />
|
||||||
|
|||||||
@ -35,18 +35,31 @@ namespace LibationAvalonia.ViewModels
|
|||||||
#region Model properties exposed to the view
|
#region Model properties exposed to the view
|
||||||
|
|
||||||
private Avalonia.Media.Imaging.Bitmap _cover;
|
private Avalonia.Media.Imaging.Bitmap _cover;
|
||||||
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } }
|
private string _purchasedate;
|
||||||
public string PurchaseDate { get; protected set; }
|
private string _series;
|
||||||
public string Series { get; protected set; }
|
private string _title;
|
||||||
public string Title { get; protected set; }
|
private string _length;
|
||||||
public string Length { get; protected set; }
|
private string _authors;
|
||||||
public string Authors { get; protected set; }
|
private string _narrators;
|
||||||
public string Narrators { get; protected set; }
|
private string _category;
|
||||||
public string Category { get; protected set; }
|
private string _misc;
|
||||||
public string Misc { get; protected set; }
|
private LastDownloadStatus _lastDownload;
|
||||||
public string Description { get; protected set; }
|
private string _description;
|
||||||
public Rating ProductRating { get; protected set; }
|
private Rating _productrating;
|
||||||
protected Rating _myRating;
|
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
|
public Rating MyRating
|
||||||
{
|
{
|
||||||
get => _myRating;
|
get => _myRating;
|
||||||
@ -111,6 +124,7 @@ namespace LibationAvalonia.ViewModels
|
|||||||
{ typeof(bool), new ObjectComparer<bool>() },
|
{ typeof(bool), new ObjectComparer<bool>() },
|
||||||
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
||||||
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
||||||
|
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
|
||||||
};
|
};
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -58,8 +58,22 @@ namespace LibationAvalonia.ViewModels
|
|||||||
|
|
||||||
public LibraryBookEntry(LibraryBook libraryBook)
|
public LibraryBookEntry(LibraryBook libraryBook)
|
||||||
{
|
{
|
||||||
LibraryBook = libraryBook;
|
setLibraryBook(libraryBook);
|
||||||
LoadCover();
|
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;
|
Title = Book.Title;
|
||||||
Series = Book.SeriesNames();
|
Series = Book.SeriesNames();
|
||||||
@ -73,10 +87,12 @@ namespace LibationAvalonia.ViewModels
|
|||||||
Narrators = Book.NarratorNames();
|
Narrators = Book.NarratorNames();
|
||||||
Category = string.Join(" > ", Book.CategoriesNames());
|
Category = string.Join(" > ", Book.CategoriesNames());
|
||||||
Misc = GetMiscDisplay(libraryBook);
|
Misc = GetMiscDisplay(libraryBook);
|
||||||
|
LastDownload = new(Book.UserDefinedItem);
|
||||||
LongDescription = GetDescriptionDisplay(Book);
|
LongDescription = GetDescriptionDisplay(Book);
|
||||||
Description = TrimTextToWord(LongDescription, 62);
|
Description = TrimTextToWord(LongDescription, 62);
|
||||||
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
|
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
|
||||||
|
|
||||||
|
this.RaisePropertyChanged(nameof(MyRating));
|
||||||
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
|
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -112,6 +128,10 @@ namespace LibationAvalonia.ViewModels
|
|||||||
_pdfStatus = udi.PdfStatus;
|
_pdfStatus = udi.PdfStatus;
|
||||||
this.RaisePropertyChanged(nameof(Liberate));
|
this.RaisePropertyChanged(nameof(Liberate));
|
||||||
break;
|
break;
|
||||||
|
case nameof(udi.LastDownloaded):
|
||||||
|
LastDownload = new(udi);
|
||||||
|
this.RaisePropertyChanged(nameof(LastDownload));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -134,6 +154,7 @@ namespace LibationAvalonia.ViewModels
|
|||||||
{ nameof(Description), () => Description },
|
{ nameof(Description), () => Description },
|
||||||
{ nameof(Category), () => Category },
|
{ nameof(Category), () => Category },
|
||||||
{ nameof(Misc), () => Misc },
|
{ nameof(Misc), () => Misc },
|
||||||
|
{ nameof(LastDownload), () => LastDownload },
|
||||||
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
|
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
|
||||||
{ nameof(Liberate), () => Liberate },
|
{ nameof(Liberate), () => Liberate },
|
||||||
{ nameof(DateAdded), () => DateAdded },
|
{ nameof(DateAdded), () => DateAdded },
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
|
using Avalonia.Media.Imaging;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
using LibationFileManager;
|
using LibationFileManager;
|
||||||
using ReactiveUI;
|
using ReactiveUI;
|
||||||
@ -135,15 +136,9 @@ namespace LibationAvalonia.ViewModels
|
|||||||
set
|
set
|
||||||
{
|
{
|
||||||
this.RaiseAndSetIfChanged(ref _queueOpen, value);
|
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>
|
/// <summary> The number of books visible in the Product Display </summary>
|
||||||
public int VisibleCount
|
public int VisibleCount
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using ApplicationServices;
|
using ApplicationServices;
|
||||||
|
using Avalonia.Collections;
|
||||||
using Avalonia.Controls;
|
using Avalonia.Controls;
|
||||||
using Avalonia.Threading;
|
using Avalonia.Threading;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
@ -16,9 +17,8 @@ namespace LibationAvalonia.ViewModels
|
|||||||
public class ProcessQueueViewModel : ViewModelBase, ILogForm
|
public class ProcessQueueViewModel : ViewModelBase, ILogForm
|
||||||
{
|
{
|
||||||
public ObservableCollection<LogEntry> LogEntries { get; } = new();
|
public ObservableCollection<LogEntry> LogEntries { get; } = new();
|
||||||
public TrackedQueue<ProcessBookViewModel> Items { get; } = new();
|
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
|
||||||
|
public TrackedQueue<ProcessBookViewModel> Queue { get; }
|
||||||
private TrackedQueue<ProcessBookViewModel> Queue => Items;
|
|
||||||
public ProcessBookViewModel SelectedItem { get; set; }
|
public ProcessBookViewModel SelectedItem { get; set; }
|
||||||
public Task QueueRunner { get; private set; }
|
public Task QueueRunner { get; private set; }
|
||||||
public bool Running => !QueueRunner?.IsCompleted ?? false;
|
public bool Running => !QueueRunner?.IsCompleted ?? false;
|
||||||
@ -28,6 +28,7 @@ namespace LibationAvalonia.ViewModels
|
|||||||
public ProcessQueueViewModel()
|
public ProcessQueueViewModel()
|
||||||
{
|
{
|
||||||
Logger = LogMe.RegisterForm(this);
|
Logger = LogMe.RegisterForm(this);
|
||||||
|
Queue = new(Items);
|
||||||
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
|
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
|
||||||
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
|
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
|
||||||
|
|
||||||
@ -88,19 +89,19 @@ namespace LibationAvalonia.ViewModels
|
|||||||
|
|
||||||
public decimal SpeedLimitIncrement { get; private set; }
|
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 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);
|
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
|
||||||
|
|
||||||
ErrorCount = errCount;
|
ErrorCount = errCount;
|
||||||
CompletedCount = completeCount;
|
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;
|
QueuedCount = cueCount;
|
||||||
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
|
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||||
}
|
}
|
||||||
|
|
||||||
public void WriteLine(string text)
|
public void WriteLine(string text)
|
||||||
|
|||||||
@ -20,11 +20,11 @@ namespace LibationAvalonia.ViewModels
|
|||||||
public event EventHandler<int> RemovableCountChanged;
|
public event EventHandler<int> RemovableCountChanged;
|
||||||
|
|
||||||
/// <summary>Backing list of all grid entries</summary>
|
/// <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>
|
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
|
||||||
private List<GridEntry> FilteredInGridEntries;
|
private List<GridEntry> FilteredInGridEntries;
|
||||||
public string FilterString { get; private set; }
|
public string FilterString { get; private set; }
|
||||||
public DataGridCollectionView GridEntries { get; }
|
public DataGridCollectionView GridEntries { get; private set; }
|
||||||
|
|
||||||
private bool _removeColumnVisivle;
|
private bool _removeColumnVisivle;
|
||||||
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
|
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
|
||||||
@ -42,59 +42,60 @@ namespace LibationAvalonia.ViewModels
|
|||||||
public ProductsDisplayViewModel()
|
public ProductsDisplayViewModel()
|
||||||
{
|
{
|
||||||
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
|
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
|
||||||
GridEntries = new(SOURCE);
|
VisibleCountChanged?.Invoke(this, 0);
|
||||||
GridEntries.Filter = CollectionFilter;
|
}
|
||||||
|
|
||||||
GridEntries.CollectionChanged += (s, e)
|
private static readonly System.Reflection.MethodInfo SetFlagsMethod;
|
||||||
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
|
|
||||||
|
/// <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
|
#region Display Functions
|
||||||
|
|
||||||
/// <summary>
|
internal void BindToGrid(List<LibraryBook> dbBooks)
|
||||||
/// Call when there's been a change to the library
|
|
||||||
/// </summary>
|
|
||||||
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
|
|
||||||
{
|
{
|
||||||
try
|
GridEntries = new(SOURCE)
|
||||||
{
|
{
|
||||||
var existingSeriesEntries = SOURCE.SeriesEntries().ToList();
|
Filter = CollectionFilter
|
||||||
|
};
|
||||||
|
|
||||||
FilteredInGridEntries?.Clear();
|
GridEntries.CollectionChanged += (_, _)
|
||||||
SOURCE.Clear();
|
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
|
||||||
SOURCE.AddRange(CreateGridEntries(dbBooks));
|
|
||||||
|
|
||||||
//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
|
var geList = dbBooks
|
||||||
.Where(lb => lb.Book.IsProduct())
|
.Where(lb => lb.Book.IsProduct())
|
||||||
.Select(b => new LibraryBookEntry(b))
|
.Select(b => new LibraryBookEntry(b))
|
||||||
@ -103,26 +104,159 @@ namespace LibationAvalonia.ViewModels
|
|||||||
|
|
||||||
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
|
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);
|
var seriesEpisodes = episodes.FindChildren(parent);
|
||||||
|
|
||||||
if (!seriesEpisodes.Any()) continue;
|
if (!seriesEpisodes.Any()) continue;
|
||||||
|
|
||||||
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
|
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
|
||||||
|
seriesEntry.Liberate.Expanded = false;
|
||||||
|
|
||||||
geList.Add(seriesEntry);
|
geList.Add(seriesEntry);
|
||||||
geList.AddRange(seriesEntry.Children);
|
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
|
/// <summary>
|
||||||
int index = 0;
|
/// Call when there's been a change to the library
|
||||||
foreach (GridEntry di in bookList)
|
/// </summary>
|
||||||
di.ListIndex = index++;
|
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)
|
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
|
||||||
@ -138,9 +272,6 @@ namespace LibationAvalonia.ViewModels
|
|||||||
|
|
||||||
public async Task Filter(string searchString)
|
public async Task Filter(string searchString)
|
||||||
{
|
{
|
||||||
if (searchString == FilterString)
|
|
||||||
return;
|
|
||||||
|
|
||||||
FilterString = searchString;
|
FilterString = searchString;
|
||||||
|
|
||||||
if (SOURCE.Count == 0)
|
if (SOURCE.Count == 0)
|
||||||
@ -206,10 +337,10 @@ namespace LibationAvalonia.ViewModels
|
|||||||
if (selectedBooks.Count == 0)
|
if (selectedBooks.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||||
var result = await MessageBox.ShowConfirmationDialog(
|
var result = await MessageBox.ShowConfirmationDialog(
|
||||||
null,
|
null,
|
||||||
libraryBooks,
|
booksToRemove,
|
||||||
// do not use `$` string interpolation. See impl.
|
// do not use `$` string interpolation. See impl.
|
||||||
"Are you sure you want to remove {0} from Libation's library?",
|
"Are you sure you want to remove {0} from Libation's library?",
|
||||||
"Remove books from Libation?");
|
"Remove books from Libation?");
|
||||||
@ -220,8 +351,6 @@ namespace LibationAvalonia.ViewModels
|
|||||||
foreach (var book in selectedBooks)
|
foreach (var book in selectedBooks)
|
||||||
book.PropertyChanged -= GridEntry_PropertyChanged;
|
book.PropertyChanged -= GridEntry_PropertyChanged;
|
||||||
|
|
||||||
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
|
||||||
|
|
||||||
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
|
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
|
||||||
@ -240,7 +369,7 @@ namespace LibationAvalonia.ViewModels
|
|||||||
|
|
||||||
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
|
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
|
||||||
//so there's no need to remove books from the grid display here.
|
//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);
|
RemovableCountChanged?.Invoke(this, 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels
|
|||||||
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
|
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
|
||||||
=> gridEntries.OfType<SeriesEntry>();
|
=> 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)
|
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
|
||||||
{
|
{
|
||||||
if (seriesEpisode.Book.SeriesLink is null) return null;
|
if (seriesEpisode.Book.SeriesLink is null) return null;
|
||||||
|
|||||||
@ -56,15 +56,36 @@ namespace LibationAvalonia.ViewModels
|
|||||||
{
|
{
|
||||||
Liberate = new LiberateButtonStatus(IsSeries);
|
Liberate = new LiberateButtonStatus(IsSeries);
|
||||||
SeriesIndex = -1;
|
SeriesIndex = -1;
|
||||||
LibraryBook = parent;
|
|
||||||
|
|
||||||
LoadCover();
|
|
||||||
|
|
||||||
Children = children
|
Children = children
|
||||||
.Select(c => new LibraryBookEntry(c) { Parent = this })
|
.Select(c => new LibraryBookEntry(c) { Parent = this })
|
||||||
.OrderBy(c => c.SeriesIndex)
|
.OrderBy(c => c.SeriesIndex)
|
||||||
.ToList();
|
.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;
|
Title = Book.Title;
|
||||||
Series = Book.SeriesNames();
|
Series = Book.SeriesNames();
|
||||||
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
|
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
|
||||||
@ -75,12 +96,15 @@ namespace LibationAvalonia.ViewModels
|
|||||||
Narrators = Book.NarratorNames();
|
Narrators = Book.NarratorNames();
|
||||||
Category = string.Join(" > ", Book.CategoriesNames());
|
Category = string.Join(" > ", Book.CategoriesNames());
|
||||||
Misc = GetMiscDisplay(LibraryBook);
|
Misc = GetMiscDisplay(LibraryBook);
|
||||||
|
LastDownload = new();
|
||||||
LongDescription = GetDescriptionDisplay(Book);
|
LongDescription = GetDescriptionDisplay(Book);
|
||||||
Description = TrimTextToWord(LongDescription, 62);
|
Description = TrimTextToWord(LongDescription, 62);
|
||||||
|
|
||||||
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
|
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
|
||||||
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
|
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
|
||||||
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
|
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
|
||||||
|
|
||||||
|
this.RaisePropertyChanged(nameof(MyRating));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -101,6 +125,7 @@ namespace LibationAvalonia.ViewModels
|
|||||||
{ nameof(Description), () => Description },
|
{ nameof(Description), () => Description },
|
||||||
{ nameof(Category), () => Category },
|
{ nameof(Category), () => Category },
|
||||||
{ nameof(Misc), () => Misc },
|
{ nameof(Misc), () => Misc },
|
||||||
|
{ nameof(LastDownload), () => LastDownload },
|
||||||
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
|
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
|
||||||
{ nameof(Liberate), () => Liberate },
|
{ nameof(Liberate), () => Liberate },
|
||||||
{ nameof(DateAdded), () => DateAdded },
|
{ nameof(DateAdded), () => DateAdded },
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using AudibleUtilities;
|
using AudibleUtilities;
|
||||||
|
using LibationAvalonia.Dialogs;
|
||||||
using System;
|
using System;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
@ -15,6 +16,12 @@ namespace LibationAvalonia.Views
|
|||||||
_viewModel.RemoveButtonsVisible = false;
|
_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)
|
public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
// if 0 accounts, this will not be visible
|
// if 0 accounts, this will not be visible
|
||||||
|
|||||||
@ -144,11 +144,8 @@ namespace LibationAvalonia.Views
|
|||||||
"Remove books from Libation?",
|
"Remove books from Libation?",
|
||||||
MessageBoxDefaultButton.Button2);
|
MessageBoxDefaultButton.Button2);
|
||||||
|
|
||||||
if (confirmationResult != DialogResult.Yes)
|
if (confirmationResult is DialogResult.Yes)
|
||||||
return;
|
await visibleLibraryBooks.RemoveBooksAsync();
|
||||||
|
|
||||||
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
|
||||||
await LibraryCommands.RemoveBooksAsync(visibleIds);
|
|
||||||
}
|
}
|
||||||
public async void ProductsDisplay_VisibleCountChanged(object sender, int qty)
|
public async void ProductsDisplay_VisibleCountChanged(object sender, int qty)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -131,6 +131,7 @@
|
|||||||
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
|
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
|
||||||
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
|
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<MenuItem Click="openTrashBinToolStripMenuItem_Click" Header="Trash Bin" />
|
||||||
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
|
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
|
||||||
<Separator />
|
<Separator />
|
||||||
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
|
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
|
||||||
@ -172,7 +173,12 @@
|
|||||||
|
|
||||||
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
|
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
|
||||||
<Button Click="filterBtn_Click" Height="30" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
|
<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>
|
</StackPanel>
|
||||||
|
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@ -51,7 +51,7 @@ namespace LibationAvalonia.Views
|
|||||||
{
|
{
|
||||||
this.LibraryLoaded += MainWindow_LibraryLoaded;
|
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 += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||||
}
|
}
|
||||||
Closing += MainWindow_Closing;
|
Closing += MainWindow_Closing;
|
||||||
@ -67,7 +67,7 @@ namespace LibationAvalonia.Views
|
|||||||
if (QuickFilters.UseDefault)
|
if (QuickFilters.UseDefault)
|
||||||
await performFilter(QuickFilters.Filters.FirstOrDefault());
|
await performFilter(QuickFilters.Filters.FirstOrDefault());
|
||||||
|
|
||||||
await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks);
|
_viewModel.ProductsDisplay.BindToGrid(dbBooks);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void InitializeComponent()
|
private void InitializeComponent()
|
||||||
|
|||||||
@ -15,7 +15,7 @@ namespace LibationAvalonia.Views
|
|||||||
{
|
{
|
||||||
public partial class ProcessQueueControl : UserControl
|
public partial class ProcessQueueControl : UserControl
|
||||||
{
|
{
|
||||||
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Items;
|
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Queue;
|
||||||
private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel;
|
private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel;
|
||||||
|
|
||||||
public ProcessQueueControl()
|
public ProcessQueueControl()
|
||||||
@ -76,14 +76,14 @@ namespace LibationAvalonia.Views
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
vm.Items.Enqueue(testList);
|
vm.Queue.Enqueue(testList);
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
vm.Items.MoveNext();
|
vm.Queue.MoveNext();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|||||||
@ -184,6 +184,16 @@
|
|||||||
</DataGridTemplateColumn.CellTemplate>
|
</DataGridTemplateColumn.CellTemplate>
|
||||||
</controls:DataGridTemplateColumnExt>
|
</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}">
|
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
|
||||||
<DataGridTemplateColumn.CellTemplate>
|
<DataGridTemplateColumn.CellTemplate>
|
||||||
<DataTemplate>
|
<DataTemplate>
|
||||||
|
|||||||
@ -44,7 +44,7 @@ namespace LibationAvalonia.Views
|
|||||||
};
|
};
|
||||||
|
|
||||||
var pdvm = new ProductsDisplayViewModel();
|
var pdvm = new ProductsDisplayViewModel();
|
||||||
_ = pdvm.DisplayBooksAsync(sampleEntries);
|
pdvm.BindToGrid(sampleEntries);
|
||||||
DataContext = pdvm;
|
DataContext = pdvm;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -102,7 +102,7 @@ namespace LibationAvalonia.Views
|
|||||||
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
|
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..." };
|
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
|
||||||
locateFileMenuItem.Click += async (_, __) =>
|
locateFileMenuItem.Click += async (_, __) =>
|
||||||
@ -306,6 +306,12 @@ namespace LibationAvalonia.Views
|
|||||||
imageDisplayDialog.Close();
|
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)
|
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
|
||||||
{
|
{
|
||||||
if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry)
|
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 IsWindows { get; } = OperatingSystem.IsWindows();
|
||||||
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
|
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
|
||||||
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
|
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; }
|
public static OS OS { get; }
|
||||||
= IsLinux ? OS.Linux
|
= IsLinux ? OS.Linux
|
||||||
|
|||||||
@ -73,7 +73,7 @@ namespace LibationFileManager
|
|||||||
|
|
||||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
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); }
|
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)")]
|
[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 class WindowsDirectory
|
||||||
{
|
{
|
||||||
|
|
||||||
public static void SetCoverAsFolderIcon(string pictureId, string directory)
|
public static void SetCoverAsFolderIcon(string pictureId, string directory)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (!Configuration.Instance.UseCoverAsFolderIcon || !Configuration.IsWindows)
|
//Currently only works for Windows and macOS
|
||||||
|
if (!Configuration.Instance.UseCoverAsFolderIcon || Configuration.IsLinux)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// get path of cover art in Images dir. Download first if not exists
|
// 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>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Collections.ObjectModel;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
namespace LibationAvalonia.ViewModels
|
namespace LibationUiBase
|
||||||
{
|
{
|
||||||
public enum QueuePosition
|
public enum QueuePosition
|
||||||
{
|
{
|
||||||
@ -33,7 +32,7 @@ namespace LibationAvalonia.ViewModels
|
|||||||
* and is stored in ObservableCollection.Items. When the primary list changes, the
|
* and is stored in ObservableCollection.Items. When the primary list changes, the
|
||||||
* secondary list is cleared and reset to match the primary.
|
* 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> CompletedCountChanged;
|
||||||
public event EventHandler<int> QueuededCountChanged;
|
public event EventHandler<int> QueuededCountChanged;
|
||||||
@ -47,6 +46,60 @@ namespace LibationAvalonia.ViewModels
|
|||||||
private readonly List<T> _completed = new();
|
private readonly List<T> _completed = new();
|
||||||
private readonly object lockObject = 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)
|
public bool RemoveQueued(T item)
|
||||||
{
|
{
|
||||||
bool itemsRemoved;
|
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)
|
public void Enqueue(IEnumerable<T> item)
|
||||||
{
|
{
|
||||||
int queueCount;
|
int queueCount;
|
||||||
@ -220,15 +250,15 @@ namespace LibationAvalonia.ViewModels
|
|||||||
queueCount = _queued.Count;
|
queueCount = _queued.Count;
|
||||||
}
|
}
|
||||||
foreach (var i in item)
|
foreach (var i in item)
|
||||||
base.Add(i);
|
_underlyingList?.Add(i);
|
||||||
QueuededCountChanged?.Invoke(this, queueCount);
|
QueuededCountChanged?.Invoke(this, queueCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void RebuildSecondary()
|
private void RebuildSecondary()
|
||||||
{
|
{
|
||||||
base.ClearItems();
|
_underlyingList?.Clear();
|
||||||
foreach (var item in GetAllItems())
|
foreach (var item in GetAllItems())
|
||||||
base.Add(item);
|
_underlyingList?.Add(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public IEnumerable<T> GetAllItems()
|
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.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
|
||||||
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
|
this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
|
||||||
|
this.openTrashBinToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||||
@ -383,8 +384,9 @@
|
|||||||
this.accountsToolStripMenuItem,
|
this.accountsToolStripMenuItem,
|
||||||
this.basicSettingsToolStripMenuItem,
|
this.basicSettingsToolStripMenuItem,
|
||||||
this.toolStripSeparator4,
|
this.toolStripSeparator4,
|
||||||
|
this.openTrashBinToolStripMenuItem,
|
||||||
this.launchHangoverToolStripMenuItem,
|
this.launchHangoverToolStripMenuItem,
|
||||||
this.toolStripSeparator2,
|
this.toolStripSeparator2,
|
||||||
this.aboutToolStripMenuItem});
|
this.aboutToolStripMenuItem});
|
||||||
this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem";
|
this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem";
|
||||||
this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20);
|
this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20);
|
||||||
@ -592,6 +594,13 @@
|
|||||||
this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks";
|
this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks";
|
||||||
this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click);
|
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
|
// launchHangoverToolStripMenuItem
|
||||||
//
|
//
|
||||||
this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem";
|
this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem";
|
||||||
@ -676,6 +685,7 @@
|
|||||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
||||||
private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem;
|
private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem;
|
||||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
||||||
|
private System.Windows.Forms.ToolStripMenuItem openTrashBinToolStripMenuItem;
|
||||||
private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem;
|
private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem;
|
||||||
private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu;
|
private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu;
|
||||||
private System.Windows.Forms.SplitContainer splitContainer1;
|
private System.Windows.Forms.SplitContainer splitContainer1;
|
||||||
|
|||||||
@ -16,6 +16,9 @@ namespace LibationWinForms
|
|||||||
private async void removeBooksBtn_Click(object sender, EventArgs e)
|
private async void removeBooksBtn_Click(object sender, EventArgs e)
|
||||||
=> await productsDisplay.RemoveCheckedBooksAsync();
|
=> await productsDisplay.RemoveCheckedBooksAsync();
|
||||||
|
|
||||||
|
private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e)
|
||||||
|
=> new TrashBinDialog().ShowDialog(this);
|
||||||
|
|
||||||
private void doneRemovingBtn_Click(object sender, EventArgs e)
|
private void doneRemovingBtn_Click(object sender, EventArgs e)
|
||||||
{
|
{
|
||||||
removeBooksBtn.Visible = false;
|
removeBooksBtn.Visible = false;
|
||||||
|
|||||||
@ -168,11 +168,8 @@ namespace LibationWinForms
|
|||||||
"Are you sure you want to remove {0} from Libation's library?",
|
"Are you sure you want to remove {0} from Libation's library?",
|
||||||
"Remove books from Libation?");
|
"Remove books from Libation?");
|
||||||
|
|
||||||
if (confirmationResult != DialogResult.Yes)
|
if (confirmationResult is DialogResult.Yes)
|
||||||
return;
|
await visibleLibraryBooks.RemoveBooksAsync();
|
||||||
|
|
||||||
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
|
||||||
await LibraryCommands.RemoveBooksAsync(visibleIds);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async void productsDisplay_VisibleCountChanged(object sender, int qty)
|
private async void productsDisplay_VisibleCountChanged(object sender, int qty)
|
||||||
|
|||||||
@ -12,6 +12,6 @@ namespace LibationWinForms.GridView
|
|||||||
// per standard INotifyPropertyChanged pattern:
|
// per standard INotifyPropertyChanged pattern:
|
||||||
// https://docs.microsoft.com/en-us/dotnet/desktop/wpf/data/how-to-implement-property-change-notification
|
// https://docs.microsoft.com/en-us/dotnet/desktop/wpf/data/how-to-implement-property-change-notification
|
||||||
public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
|
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 Narrators { get; protected set; }
|
||||||
public string Category { get; protected set; }
|
public string Category { get; protected set; }
|
||||||
public string Misc { 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 Description { get; protected set; }
|
||||||
public string ProductRating { get; protected set; }
|
public string ProductRating { get; protected set; }
|
||||||
protected Rating _myRating;
|
protected Rating _myRating;
|
||||||
@ -120,6 +121,7 @@ namespace LibationWinForms.GridView
|
|||||||
{ typeof(bool), new ObjectComparer<bool>() },
|
{ typeof(bool), new ObjectComparer<bool>() },
|
||||||
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
||||||
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
||||||
|
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
|
||||||
};
|
};
|
||||||
|
|
||||||
#endregion
|
#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 ApplicationServices;
|
||||||
using DataLayer;
|
using DataLayer;
|
||||||
using Dinah.Core;
|
using Dinah.Core;
|
||||||
|
using LibationUiBase;
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
@ -24,6 +25,8 @@ namespace LibationWinForms.GridView
|
|||||||
private LiberatedStatus _bookStatus;
|
private LiberatedStatus _bookStatus;
|
||||||
private LiberatedStatus? _pdfStatus;
|
private LiberatedStatus? _pdfStatus;
|
||||||
|
|
||||||
|
public override LastDownloadStatus LastDownload { get; protected set; }
|
||||||
|
|
||||||
public override RemoveStatus Remove
|
public override RemoveStatus Remove
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@ -87,6 +90,7 @@ namespace LibationWinForms.GridView
|
|||||||
Narrators = Book.NarratorNames();
|
Narrators = Book.NarratorNames();
|
||||||
Category = string.Join(" > ", Book.CategoriesNames());
|
Category = string.Join(" > ", Book.CategoriesNames());
|
||||||
Misc = GetMiscDisplay(libraryBook);
|
Misc = GetMiscDisplay(libraryBook);
|
||||||
|
LastDownload = new(Book.UserDefinedItem);
|
||||||
LongDescription = GetDescriptionDisplay(Book);
|
LongDescription = GetDescriptionDisplay(Book);
|
||||||
Description = TrimTextToWord(LongDescription, 62);
|
Description = TrimTextToWord(LongDescription, 62);
|
||||||
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
|
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
|
||||||
@ -126,6 +130,10 @@ namespace LibationWinForms.GridView
|
|||||||
_pdfStatus = udi.PdfStatus;
|
_pdfStatus = udi.PdfStatus;
|
||||||
NotifyPropertyChanged(nameof(Liberate));
|
NotifyPropertyChanged(nameof(Liberate));
|
||||||
break;
|
break;
|
||||||
|
case nameof(udi.LastDownloaded):
|
||||||
|
LastDownload = new(udi);
|
||||||
|
NotifyPropertyChanged(nameof(LastDownload));
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -153,6 +161,7 @@ namespace LibationWinForms.GridView
|
|||||||
{ nameof(Description), () => Description },
|
{ nameof(Description), () => Description },
|
||||||
{ nameof(Category), () => Category },
|
{ nameof(Category), () => Category },
|
||||||
{ nameof(Misc), () => Misc },
|
{ nameof(Misc), () => Misc },
|
||||||
|
{ nameof(LastDownload), () => LastDownload },
|
||||||
{ nameof(DisplayTags), () => DisplayTags },
|
{ nameof(DisplayTags), () => DisplayTags },
|
||||||
{ nameof(Liberate), () => Liberate },
|
{ nameof(Liberate), () => Liberate },
|
||||||
{ nameof(DateAdded), () => DateAdded },
|
{ nameof(DateAdded), () => DateAdded },
|
||||||
|
|||||||
@ -107,9 +107,9 @@ namespace LibationWinForms.GridView
|
|||||||
if (selectedBooks.Count == 0)
|
if (selectedBooks.Count == 0)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||||
var result = MessageBoxLib.ShowConfirmationDialog(
|
var result = MessageBoxLib.ShowConfirmationDialog(
|
||||||
libraryBooks,
|
booksToRemove,
|
||||||
// do not use `$` string interpolation. See impl.
|
// do not use `$` string interpolation. See impl.
|
||||||
"Are you sure you want to remove {0} from Libation's library?",
|
"Are you sure you want to remove {0} from Libation's library?",
|
||||||
"Remove books from Libation?");
|
"Remove books from Libation?");
|
||||||
@ -118,8 +118,7 @@ namespace LibationWinForms.GridView
|
|||||||
return;
|
return;
|
||||||
|
|
||||||
productsGrid.RemoveBooks(selectedBooks);
|
productsGrid.RemoveBooks(selectedBooks);
|
||||||
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
await booksToRemove.RemoveBooksAsync();
|
||||||
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
|
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
|
||||||
|
|||||||
@ -45,6 +45,7 @@
|
|||||||
this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||||
this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
|
this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
|
||||||
this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||||
|
this.lastDownloadedGVColumn = new LastDownloadedGridViewColumn();
|
||||||
this.tagAndDetailsGVColumn = new LibationWinForms.GridView.EditTagsDataGridViewImageButtonColumn();
|
this.tagAndDetailsGVColumn = new LibationWinForms.GridView.EditTagsDataGridViewImageButtonColumn();
|
||||||
this.showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(this.components);
|
this.showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(this.components);
|
||||||
this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
|
this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
|
||||||
@ -75,7 +76,8 @@
|
|||||||
this.purchaseDateGVColumn,
|
this.purchaseDateGVColumn,
|
||||||
this.myRatingGVColumn,
|
this.myRatingGVColumn,
|
||||||
this.miscGVColumn,
|
this.miscGVColumn,
|
||||||
this.tagAndDetailsGVColumn});
|
this.lastDownloadedGVColumn,
|
||||||
|
this.tagAndDetailsGVColumn});
|
||||||
this.gridEntryDataGridView.ContextMenuStrip = this.showHideColumnsContextMenuStrip;
|
this.gridEntryDataGridView.ContextMenuStrip = this.showHideColumnsContextMenuStrip;
|
||||||
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
|
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
|
||||||
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
|
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
|
||||||
@ -216,6 +218,15 @@
|
|||||||
this.miscGVColumn.ReadOnly = true;
|
this.miscGVColumn.ReadOnly = true;
|
||||||
this.miscGVColumn.Width = 135;
|
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
|
// tagAndDetailsGVColumn
|
||||||
//
|
//
|
||||||
this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags";
|
this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags";
|
||||||
@ -268,6 +279,7 @@
|
|||||||
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn;
|
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn;
|
||||||
private MyRatingGridViewColumn myRatingGVColumn;
|
private MyRatingGridViewColumn myRatingGVColumn;
|
||||||
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
|
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
|
||||||
|
private LastDownloadedGridViewColumn lastDownloadedGVColumn;
|
||||||
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
|
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -154,7 +154,7 @@ namespace LibationWinForms.GridView
|
|||||||
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||||
|
|
||||||
var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" };
|
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..." };
|
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
|
||||||
locateFileMenuItem.Click += (_, __) =>
|
locateFileMenuItem.Click += (_, __) =>
|
||||||
|
|||||||
@ -122,6 +122,7 @@ namespace LibationWinForms.GridView
|
|||||||
{ nameof(Description), () => Description },
|
{ nameof(Description), () => Description },
|
||||||
{ nameof(Category), () => Category },
|
{ nameof(Category), () => Category },
|
||||||
{ nameof(Misc), () => Misc },
|
{ nameof(Misc), () => Misc },
|
||||||
|
{ nameof(LastDownload), () => LastDownload },
|
||||||
{ nameof(DisplayTags), () => string.Empty },
|
{ nameof(DisplayTags), () => string.Empty },
|
||||||
{ nameof(Liberate), () => Liberate },
|
{ nameof(Liberate), () => Liberate },
|
||||||
{ nameof(DateAdded), () => DateAdded },
|
{ 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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<None Update="fileicon">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
<None Update="Info.plist">
|
<None Update="Info.plist">
|
||||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
</None>
|
</None>
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
using LibationFileManager;
|
using Dinah.Core;
|
||||||
|
using LibationFileManager;
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
|
|
||||||
namespace MacOSConfigApp
|
namespace MacOSConfigApp
|
||||||
@ -9,8 +10,14 @@ namespace MacOSConfigApp
|
|||||||
public MacOSInterop() { }
|
public MacOSInterop() { }
|
||||||
public MacOSInterop(params object[] values) { }
|
public MacOSInterop(params object[] values) { }
|
||||||
|
|
||||||
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
|
public void SetFolderIcon(string image, string directory)
|
||||||
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
|
{
|
||||||
|
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
|
//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
|
//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}");
|
Serilog.Log.Information($"Extracting upgrade bundle to {AppPath}");
|
||||||
|
|
||||||
//tar wil overwrite existing without elevated privileges
|
//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
|
//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.
|
//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 LibationUiBase;
|
||||||
using SixLabors.ImageSharp.Formats.Png;
|
using SixLabors.ImageSharp;
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
|
|
||||||
namespace WindowsConfigApp
|
namespace WindowsConfigApp
|
||||||
{
|
{
|
||||||
internal static partial class FolderIcon
|
internal static partial class FolderIcon
|
||||||
{
|
{
|
||||||
// https://stackoverflow.com/a/21389253
|
static readonly IcoEncoder IcoEncoder = new();
|
||||||
public static byte[] ToIcon(this Image img)
|
public static byte[] ToIcon(this Image img)
|
||||||
{
|
{
|
||||||
using var ms = new MemoryStream();
|
using var ms = new MemoryStream();
|
||||||
using var bw = new BinaryWriter(ms);
|
img.Save(ms, IcoEncoder);
|
||||||
// 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
|
|
||||||
return ms.ToArray();
|
return ms.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,50 +29,33 @@ namespace WindowsConfigApp
|
|||||||
File.Delete(text);
|
File.Delete(text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://github.com/dimuththarindu/FIC-Folder-Icon-Changer/blob/master/project/FIC/Classes/IconCustomizer.cs
|
// 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)
|
public static void SetIcon(this DirectoryInfo directoryInfo, byte[] icon, string folderType)
|
||||||
=> SetIcon(directoryInfo.FullName, icoPath, 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 desktop_ini = Path.Combine(dir, "desktop.ini");
|
||||||
var Icon_ico = Path.Combine(dir, "Icon.ico");
|
var Icon_ico = Path.Combine(dir, "Icon.ico");
|
||||||
var hidden = Path.Combine(dir, ".hidden");
|
|
||||||
|
|
||||||
//deleting existing files
|
//deleting existing files
|
||||||
DeleteIcon(dir);
|
DeleteIcon(dir);
|
||||||
|
|
||||||
//copying Icon file //overwriting
|
//copying Icon file //overwriting
|
||||||
File.Copy(icoPath, Icon_ico, true);
|
File.WriteAllBytes(Icon_ico, icon);
|
||||||
|
|
||||||
//writing configuration file
|
//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);
|
File.WriteAllLines(desktop_ini, desktopLines);
|
||||||
|
|
||||||
//configure file 2
|
File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.ReadOnly);
|
||||||
string[] hiddenLines = { "desktop.ini", "Icon.ico" };
|
File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.ReadOnly);
|
||||||
File.WriteAllLines(hidden, hiddenLines);
|
|
||||||
|
|
||||||
//making system files
|
//https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini
|
||||||
File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
|
File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.System);
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
public void SetFolderIcon(string image, string directory)
|
||||||
{
|
{
|
||||||
string iconPath = null;
|
var icon = Image.Load(image).ToIcon();
|
||||||
|
new DirectoryInfo(directory)?.SetIcon(icon, "Music");
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void DeleteFolderIcon(string directory)
|
public void DeleteFolderIcon(string directory)
|
||||||
=> new DirectoryInfo(directory)?.DeleteIcon();
|
=> new DirectoryInfo(directory)?.DeleteIcon();
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user