mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 10:28:21 -05:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34f56d2fd7 | ||
|
|
c45ffaf4a6 | ||
|
|
ae43ab103e | ||
|
|
559977ce0b | ||
|
|
ccd4d3e26d | ||
|
|
e76f99ff28 | ||
|
|
d3607583ab | ||
|
|
3ebd4ce243 | ||
|
|
f6dcc0db1d | ||
|
|
bd49db83e4 | ||
|
|
4140722a6d | ||
|
|
da36f9414d | ||
|
|
1510f71ca6 | ||
|
|
cdb27ef712 | ||
|
|
790319ed98 | ||
|
|
1b0fb2b316 | ||
|
|
02371f2221 | ||
|
|
2b672f86be | ||
|
|
36176bff33 | ||
|
|
174b0c26b8 | ||
|
|
26c60e8e79 | ||
|
|
d94759d868 | ||
|
|
bd7e45ca3c | ||
|
|
52a863c62a | ||
|
|
fe55b90ee3 | ||
|
|
df224cc7f3 | ||
|
|
2a59329350 | ||
|
|
abdf0e7261 | ||
|
|
b9c2a1cce3 | ||
|
|
aa86fca08f |
@@ -9,7 +9,9 @@ This walkthrough should get you up and running with Libation on your Mac.
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
|
||||
- Download the file from the latest release and extract it.
|
||||
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||
- Move the extracted Libation app bundle to your applications folder.
|
||||
- Open a terminal (Go > Utilities > Terminal)
|
||||
- Copy/paste/run the following command (you'll be prompted to enter your password)
|
||||
|
||||
@@ -65,6 +65,9 @@ if [ $? -ne 0 ]
|
||||
exit
|
||||
fi
|
||||
|
||||
echo "Make fileicon executable..."
|
||||
chmod +x $BUNDLE_MACOS/fileicon
|
||||
|
||||
echo "Moving icon..."
|
||||
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>9.4.2.1</Version>
|
||||
<Version>9.4.4.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="5.0.0" />
|
||||
|
||||
@@ -75,13 +75,15 @@ namespace AppScaffolding
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
Configuration.SetLibationVersion(BuildVersion);
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
@@ -242,18 +243,16 @@ namespace ApplicationServices
|
||||
#endregion
|
||||
|
||||
#region remove/restore books
|
||||
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
|
||||
private static int removeBooks(List<string> idsToRemove)
|
||||
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
|
||||
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
|
||||
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (idsToRemove is null || !idsToRemove.Any())
|
||||
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
var libBooks = context.GetLibrary_Flat_NoTracking();
|
||||
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in removeLibraryBooks)
|
||||
@@ -275,7 +274,7 @@ namespace ApplicationServices
|
||||
}
|
||||
}
|
||||
|
||||
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
|
||||
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -303,6 +302,31 @@ namespace ApplicationServices
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
context.LibraryBooks.RemoveRange(libraryBooks);
|
||||
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
finalizeLibrarySizeChange();
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error restoring books");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
// call this whenever books are added or removed from library
|
||||
@@ -346,8 +370,10 @@ namespace ApplicationServices
|
||||
|
||||
if (rating is not null)
|
||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||
});
|
||||
});
|
||||
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus, Version libationVersion)
|
||||
=> book.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
||||
@@ -428,40 +454,74 @@ namespace ApplicationServices
|
||||
|
||||
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
|
||||
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
|
||||
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
|
||||
{
|
||||
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
|
||||
public bool HasPendingBooks => PendingBooks > 0;
|
||||
|
||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
|
||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
|
||||
}
|
||||
public static LibraryStats GetCounts()
|
||||
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
|
||||
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
|
||||
|
||||
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
|
||||
|
||||
private string toBookStatusString()
|
||||
{
|
||||
if (!HasBookResults) return "No books. Begin by importing your library";
|
||||
|
||||
if (!HasPendingBooks && booksError + booksUnavailable == 0) return $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
|
||||
|
||||
var sb = new StringBuilder($"BACKUPS: No progress: {booksNoProgress} In process: {booksDownloadedOnly} Fully backed up: {booksFullyBackedUp}");
|
||||
|
||||
if (booksError > 0)
|
||||
sb.Append($" Errors: {booksError}");
|
||||
if (booksUnavailable > 0)
|
||||
sb.Append($" Unavailable: {booksUnavailable}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private string toPdfStatusString()
|
||||
{
|
||||
if (pdfsNotDownloaded + pdfsUnavailable == 0) return $"All {pdfsDownloaded} PDFs downloaded";
|
||||
|
||||
var sb = new StringBuilder($"PDFs: NOT d/l'ed: {pdfsNotDownloaded} Downloaded: {pdfsDownloaded}");
|
||||
|
||||
if (pdfsUnavailable > 0)
|
||||
sb.Append($" Unavailable: {pdfsUnavailable}");
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
|
||||
{
|
||||
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
|
||||
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
|
||||
|
||||
var results = libraryBooks
|
||||
.AsParallel()
|
||||
.Select(lb => Liberated_Status(lb.Book))
|
||||
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
|
||||
.ToList();
|
||||
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
|
||||
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
|
||||
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
|
||||
var booksError = results.Count(r => r == LiberatedStatus.Error);
|
||||
|
||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
|
||||
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
|
||||
var booksDownloadedOnly = results.Count(r => !r.absent && r.status == LiberatedStatus.PartialDownload);
|
||||
var booksNoProgress = results.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
|
||||
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
|
||||
|
||||
var boolResults = libraryBooks
|
||||
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
|
||||
|
||||
var pdfResults = libraryBooks
|
||||
.AsParallel()
|
||||
.Where(lb => lb.Book.HasPdf())
|
||||
.Select(lb => Pdf_Status(lb.Book))
|
||||
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
|
||||
.ToList();
|
||||
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
|
||||
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
|
||||
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
|
||||
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
|
||||
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
|
||||
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
|
||||
|
||||
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,6 +106,12 @@ namespace ApplicationServices
|
||||
|
||||
[Name("Language")]
|
||||
public string Language { get; set; }
|
||||
|
||||
[Name("LastDownloaded")]
|
||||
public DateTime? LastDownloaded { get; set; }
|
||||
|
||||
[Name("LastDownloadedVersion")]
|
||||
public string LastDownloadedVersion { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
@@ -140,7 +146,10 @@ namespace ApplicationServices
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
ContentType = a.Book.ContentType.ToString(),
|
||||
AudioFormat = a.Book.AudioFormat.ToString(),
|
||||
Language = a.Book.Language
|
||||
Language = a.Book.Language,
|
||||
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
|
||||
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
|
||||
|
||||
}).ToList();
|
||||
}
|
||||
public static class LibraryExporter
|
||||
@@ -212,7 +221,9 @@ namespace ApplicationServices
|
||||
nameof(ExportDto.PdfStatus),
|
||||
nameof(ExportDto.ContentType),
|
||||
nameof(ExportDto.AudioFormat),
|
||||
nameof(ExportDto.Language)
|
||||
nameof(ExportDto.Language),
|
||||
nameof(ExportDto.LastDownloaded),
|
||||
nameof(ExportDto.LastDownloadedVersion),
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
@@ -238,9 +249,9 @@ namespace ApplicationServices
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.Account);
|
||||
|
||||
var dateAddedCell = row.CreateCell(col++);
|
||||
dateAddedCell.CellStyle = dateStyle;
|
||||
dateAddedCell.SetCellValue(dto.DateAdded);
|
||||
var dateCell = row.CreateCell(col++);
|
||||
dateCell.CellStyle = dateStyle;
|
||||
dateCell.SetCellValue(dto.DateAdded);
|
||||
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
@@ -281,6 +292,15 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||
|
||||
if (dto.LastDownloaded.HasValue)
|
||||
{
|
||||
dateCell = row.CreateCell(col);
|
||||
dateCell.CellStyle = dateStyle;
|
||||
dateCell.SetCellValue(dto.LastDownloaded.Value);
|
||||
}
|
||||
|
||||
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
|
||||
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Channels;
|
||||
using System.Threading.Tasks;
|
||||
using System.Diagnostics;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Dinah.Core;
|
||||
@@ -19,6 +20,9 @@ namespace AudibleUtilities
|
||||
{
|
||||
public Api Api { get; private set; }
|
||||
|
||||
private const int MaxConcurrency = 10;
|
||||
private const int BatchSize = 50;
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
@@ -85,35 +89,51 @@ namespace AudibleUtilities
|
||||
|
||||
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
|
||||
{
|
||||
var items = new List<Item>();
|
||||
|
||||
Serilog.Log.Logger.Debug("Beginning library scan.");
|
||||
|
||||
List<Task<List<Item>>> getChildEpisodesTasks = new();
|
||||
int count = 0;
|
||||
List<Item> items = new();
|
||||
List<Item> seriesItems = new();
|
||||
|
||||
int count = 0, maxConcurrentEpisodeScans = 5;
|
||||
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
|
||||
var sw = Stopwatch.StartNew();
|
||||
|
||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
|
||||
//Scan the library for all added books, and add any episode-type items to seriesItems to be scanned for episodes/parents
|
||||
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions, BatchSize, MaxConcurrency))
|
||||
{
|
||||
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
|
||||
{
|
||||
//Get child episodes asynchronously and await all at the end
|
||||
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
|
||||
}
|
||||
seriesItems.Add(item);
|
||||
else if (!item.IsEpisodes && !item.IsSeriesParent)
|
||||
items.Add(item);
|
||||
|
||||
count++;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
|
||||
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on series episode scans to complete.", count);
|
||||
Serilog.Log.Logger.Debug("Beginning episode scan.");
|
||||
|
||||
//await and add all episodes from all parents
|
||||
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
|
||||
items.AddRange(epList);
|
||||
count = 0;
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed library scan.");
|
||||
//'get' Tasks are activated when they are written to the channel. To avoid more concurrency than is desired, the
|
||||
//channel is bounded with a capacity of 1. Channel write operations are blocked until the current item is read
|
||||
var episodeChannel = Channel.CreateBounded<Task<List<Item>>>(new BoundedChannelOptions(1) { SingleReader = true });
|
||||
|
||||
//Start scanning for all episodes. Episode batch 'get' Tasks are written to the channel.
|
||||
var scanAllSeriesTask = scanAllSeries(seriesItems, episodeChannel.Writer);
|
||||
|
||||
//Read all episodes from the channel and add them to the import items.
|
||||
//This method blocks until episodeChannel.Writer is closed by scanAllSeries()
|
||||
await foreach (var ep in getAllEpisodesAsync(episodeChannel.Reader))
|
||||
{
|
||||
items.AddRange(ep);
|
||||
count += ep.Count;
|
||||
}
|
||||
|
||||
//Be sure to await the scanAllSeries Task so that any exceptions are thrown
|
||||
await scanAllSeriesTask;
|
||||
|
||||
sw.Stop();
|
||||
Serilog.Log.Logger.Debug("Episode scan complete. Found {count} episodes and series.", count);
|
||||
Serilog.Log.Logger.Debug($"Completed library scan in {sw.Elapsed.TotalMilliseconds:F0} ms.");
|
||||
|
||||
#if DEBUG
|
||||
//// this will not work for multi accounts
|
||||
@@ -146,165 +166,178 @@ namespace AudibleUtilities
|
||||
|
||||
#region episodes and podcasts
|
||||
|
||||
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
|
||||
/// <summary>
|
||||
/// Read get tasks from the <paramref name="channel"/> and await results. This method maintains
|
||||
/// a list of up to <see cref="MaxConcurrency"/> get tasks. When any of the get tasks completes,
|
||||
/// the Items are yielded, that task is removed from the list, and a new get task is read from
|
||||
/// the channel.
|
||||
/// </summary>
|
||||
private async IAsyncEnumerable<List<Item>> getAllEpisodesAsync(ChannelReader<Task<List<Item>>> channel)
|
||||
{
|
||||
await concurrencySemaphore.WaitAsync();
|
||||
List<Task<List<Item>>> concurentGets = new();
|
||||
|
||||
for (int i = 0; i < MaxConcurrency && await channel.WaitToReadAsync(); i++)
|
||||
concurentGets.Add(await channel.ReadAsync());
|
||||
|
||||
while (concurentGets.Count > 0)
|
||||
{
|
||||
var completed = await Task.WhenAny(concurentGets);
|
||||
concurentGets.Remove(completed);
|
||||
|
||||
if (await channel.WaitToReadAsync())
|
||||
concurentGets.Add(await channel.ReadAsync());
|
||||
|
||||
yield return completed.Result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all child episodes and episode parents belonging to <paramref name="seriesItems"/> in batches and
|
||||
/// writes the get tasks to <paramref name="channel"/>.
|
||||
/// </summary>
|
||||
private async Task scanAllSeries(IEnumerable<Item> seriesItems, ChannelWriter<Task<List<Item>>> channel)
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
||||
List<Task> episodeScanTasks = new();
|
||||
|
||||
List<Item> children;
|
||||
|
||||
if (parent.IsEpisodes)
|
||||
foreach (var item in seriesItems)
|
||||
{
|
||||
//The 'parent' is a single episode that was added to the library.
|
||||
//Get the episode's parent and add it to the database.
|
||||
if (item.IsEpisodes)
|
||||
await channel.WriteAsync(getEpisodeParentAsync(item));
|
||||
else if (item.IsSeriesParent)
|
||||
episodeScanTasks.Add(getParentEpisodesAsync(item, channel));
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
|
||||
//episodeScanTasks complete only after all episode batch 'gets' have been written to the channel
|
||||
await Task.WhenAll(episodeScanTasks);
|
||||
}
|
||||
finally { channel.Complete(); }
|
||||
}
|
||||
|
||||
children = new() { parent };
|
||||
private async Task<List<Item>> getEpisodeParentAsync(Item episode)
|
||||
{
|
||||
//Item is a single episode that was added to the library.
|
||||
//Get the episode's parent and add it to the database.
|
||||
|
||||
var parentAsins = parent.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||
.Select(p => p.Asin);
|
||||
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", episode);
|
||||
|
||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
List<Item> children = new() { episode };
|
||||
|
||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
||||
if (numSeriesParents != 1)
|
||||
{
|
||||
//There should only ever be 1 top-level parent per episode. If not, log
|
||||
//so we can figure out what to do about those special cases, and don't
|
||||
//import the episode.
|
||||
JsonSerializerSettings Settings = new()
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
Converters =
|
||||
{
|
||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
||||
},
|
||||
};
|
||||
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
|
||||
return new List<Item>();
|
||||
var parentAsins = episode.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
|
||||
.Select(p => p.Asin);
|
||||
|
||||
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
|
||||
if (numSeriesParents != 1)
|
||||
{
|
||||
//There should only ever be 1 top-level parent per episode. If not, log
|
||||
//so we can figure out what to do about those special cases, and don't
|
||||
//import the episode.
|
||||
JsonSerializerSettings Settings = new()
|
||||
{
|
||||
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
|
||||
DateParseHandling = DateParseHandling.None,
|
||||
Converters = {
|
||||
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
|
||||
}
|
||||
};
|
||||
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {episode.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(episode, Formatting.None, Settings)}");
|
||||
return new();
|
||||
}
|
||||
|
||||
var realParent = seriesParents.Single(p => p.IsSeriesParent);
|
||||
realParent.PurchaseDate = parent.PurchaseDate;
|
||||
var parent = seriesParents.Single(p => p.IsSeriesParent);
|
||||
parent.PurchaseDate = episode.PurchaseDate;
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
|
||||
parent = realParent;
|
||||
}
|
||||
else
|
||||
setSeries(parent, children);
|
||||
children.Add(parent);
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed parent scan for {episode}", episode);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all episodes belonging to <paramref name="parent"/> in batches of <see cref="BatchSize"/> and writes the batch get tasks to <paramref name="channel"/>
|
||||
/// This method only completes after all episode batch 'gets' have been written to the channel
|
||||
/// </summary>
|
||||
private async Task getParentEpisodesAsync(Item parent, ChannelWriter<Task<List<Item>>> channel)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
|
||||
|
||||
var episodeIds = parent.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||
.Select(r => r.Asin);
|
||||
|
||||
for (int batchNum = 0; episodeIds.Any(); batchNum++)
|
||||
{
|
||||
var batch = episodeIds.Take(BatchSize);
|
||||
|
||||
await channel.WriteAsync(getEpisodeBatchAsync(batchNum, parent, batch));
|
||||
|
||||
episodeIds = episodeIds.Skip(BatchSize);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodeBatchAsync(int batchNum, Item parent, IEnumerable<string> childrenIds)
|
||||
{
|
||||
try
|
||||
{
|
||||
List<Item> episodeBatch = await Api.GetCatalogProductsAsync(childrenIds, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
|
||||
setSeries(parent, episodeBatch);
|
||||
|
||||
if (batchNum == 0)
|
||||
episodeBatch.Add(parent);
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {batchNum}: {episodeBatch.Count} results\t({{parent}})", parent);
|
||||
|
||||
return episodeBatch;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
|
||||
{
|
||||
children = await getEpisodeChildrenAsync(parent);
|
||||
if (!children.Any())
|
||||
return new();
|
||||
}
|
||||
ParentId = parent.Asin,
|
||||
ParentTitle = parent.Title,
|
||||
BatchNumber = batchNum,
|
||||
ChildIdBatch = childrenIds
|
||||
});
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
//A series parent will always have exactly 1 Series
|
||||
parent.Series = new Series[]
|
||||
private static void setSeries(Item parent, IEnumerable<Item> children)
|
||||
{
|
||||
//A series parent will always have exactly 1 Series
|
||||
parent.Series = new[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = "-1",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
child.PurchaseDate = parent.PurchaseDate;
|
||||
// parent is essentially a series
|
||||
child.Series = new[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
Sequence = "-1",
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
|
||||
child.PurchaseDate = parent.PurchaseDate;
|
||||
// parent is essentially a series
|
||||
child.Series = new Series[]
|
||||
{
|
||||
new Series
|
||||
{
|
||||
Asin = parent.Asin,
|
||||
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
|
||||
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
|
||||
Title = parent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
children.Add(parent);
|
||||
|
||||
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
|
||||
|
||||
return children;
|
||||
}
|
||||
finally
|
||||
{
|
||||
concurrencySemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
|
||||
{
|
||||
var childrenIds = parent.Relationships
|
||||
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
|
||||
.Select(r => r.Asin)
|
||||
.ToList();
|
||||
|
||||
// fetch children in batches
|
||||
const int batchSize = 20;
|
||||
|
||||
var results = new List<Item>();
|
||||
|
||||
for (var i = 1; ; i++)
|
||||
{
|
||||
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
|
||||
if (!idBatch.Any())
|
||||
break;
|
||||
|
||||
List<Item> childrenBatch;
|
||||
try
|
||||
{
|
||||
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
|
||||
#if DEBUG
|
||||
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
|
||||
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
|
||||
{
|
||||
ParentId = parent.Asin,
|
||||
ParentTitle = parent.Title,
|
||||
BatchNumber = i,
|
||||
ChildIdBatch = idBatch
|
||||
});
|
||||
throw;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
|
||||
// the service returned no results. probably indicates an error. stop running batches
|
||||
if (!childrenBatch.Any())
|
||||
break;
|
||||
|
||||
results.AddRange(childrenBatch);
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
|
||||
{
|
||||
ParentId = parent.Asin,
|
||||
ParentTitle = parent.Title,
|
||||
ChildCount = childrenIds.Count
|
||||
});
|
||||
|
||||
if (childrenIds.Count != results.Count)
|
||||
{
|
||||
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
|
||||
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
|
||||
throw ex;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using System;
|
||||
|
||||
namespace DataLayer.Configurations
|
||||
{
|
||||
@@ -19,40 +20,45 @@ namespace DataLayer.Configurations
|
||||
entity.Ignore(nameof(Book.Authors));
|
||||
entity.Ignore(nameof(Book.Narrators));
|
||||
entity.Ignore(nameof(Book.AudioFormat));
|
||||
//// these don't seem to matter
|
||||
//entity.Ignore(nameof(Book.AuthorNames));
|
||||
//entity.Ignore(nameof(Book.NarratorNames));
|
||||
//entity.Ignore(nameof(Book.HasPdfs));
|
||||
//// these don't seem to matter
|
||||
//entity.Ignore(nameof(Book.AuthorNames));
|
||||
//entity.Ignore(nameof(Book.NarratorNames));
|
||||
//entity.Ignore(nameof(Book.HasPdfs));
|
||||
|
||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||
entity
|
||||
.OwnsMany(b => b.Supplements, b_s =>
|
||||
{
|
||||
b_s.WithOwner(s => s.Book)
|
||||
.HasForeignKey(s => s.BookId);
|
||||
b_s.HasKey(s => s.SupplementId);
|
||||
});
|
||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||
entity
|
||||
.OwnsMany(b => b.Supplements, b_s =>
|
||||
{
|
||||
b_s.WithOwner(s => s.Book)
|
||||
.HasForeignKey(s => s.BookId);
|
||||
b_s.HasKey(s => s.SupplementId);
|
||||
});
|
||||
// even though it's owned, we need to map its backing field
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.Supplements))
|
||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
// owns it 1:1, store in separate table
|
||||
entity
|
||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||
{
|
||||
b_udi.WithOwner(udi => udi.Book)
|
||||
.HasForeignKey(udi => udi.BookId);
|
||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||
// owns it 1:1, store in separate table
|
||||
entity
|
||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||
{
|
||||
b_udi.WithOwner(udi => udi.Book)
|
||||
.HasForeignKey(udi => udi.BookId);
|
||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||
|
||||
// owns it 1:1, store in same table
|
||||
b_udi.OwnsOne(udi => udi.Rating);
|
||||
});
|
||||
b_udi.Property(udi => udi.LastDownloaded);
|
||||
b_udi
|
||||
.Property(udi => udi.LastDownloadedVersion)
|
||||
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||
|
||||
entity
|
||||
// owns it 1:1, store in same table
|
||||
b_udi.OwnsOne(udi => udi.Rating);
|
||||
});
|
||||
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.ContributorsLink))
|
||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
||||
@@ -68,6 +74,6 @@ namespace DataLayer.Configurations
|
||||
.HasOne(b => b.Category)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.CategoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace DataLayer
|
||||
public string Account { get; private set; }
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
public bool AbsentFromLastScan { get; set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||
|
||||
@@ -24,8 +24,27 @@ namespace DataLayer
|
||||
{
|
||||
internal int BookId { get; private set; }
|
||||
public Book Book { get; private set; }
|
||||
public DateTime? LastDownloaded { get; private set; }
|
||||
public Version LastDownloadedVersion { get; private set; }
|
||||
|
||||
private UserDefinedItem() { }
|
||||
public void SetLastDownloaded(Version version)
|
||||
{
|
||||
if (LastDownloadedVersion != version)
|
||||
{
|
||||
LastDownloadedVersion = version;
|
||||
OnItemChanged(nameof(LastDownloadedVersion));
|
||||
}
|
||||
|
||||
if (version is null)
|
||||
LastDownloaded = null;
|
||||
else
|
||||
{
|
||||
LastDownloaded = DateTime.Now;
|
||||
OnItemChanged(nameof(LastDownloaded));
|
||||
}
|
||||
}
|
||||
|
||||
private UserDefinedItem() { }
|
||||
internal UserDefinedItem(Book book)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(book, nameof(book));
|
||||
|
||||
410
Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs
generated
Normal file
410
Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs
generated
Normal file
@@ -0,0 +1,410 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20230302220539_AddLastDownloadedInfo")]
|
||||
partial class AddLastDownloadedInfo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLastDownloadedInfo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastDownloaded",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LastDownloadedVersion",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDownloaded",
|
||||
table: "UserDefinedItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDownloadedVersion",
|
||||
table: "UserDefinedItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
413
Source/DataLayer/Migrations/20230308013410_AddAbsentFromLastScan.Designer.cs
generated
Normal file
413
Source/DataLayer/Migrations/20230308013410_AddAbsentFromLastScan.Designer.cs
generated
Normal file
@@ -0,0 +1,413 @@
|
||||
// <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("20230308013410_AddAbsentFromLastScan")]
|
||||
partial class AddAbsentFromLastScan
|
||||
{
|
||||
/// <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<bool>("AbsentFromLastScan")
|
||||
.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,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAbsentFromLastScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AbsentFromLastScan",
|
||||
table: "LibraryBooks",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AbsentFromLastScan",
|
||||
table: "LibraryBooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -157,6 +157,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -272,6 +275,12 @@ namespace DataLayer.Migrations
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ namespace DataLayer
|
||||
=> bookList
|
||||
.Where(
|
||||
lb =>
|
||||
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
!lb.AbsentFromLastScan &&
|
||||
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@ namespace DtoImporterService
|
||||
public Item DtoItem { get; set; }
|
||||
public string AccountId { get; set; }
|
||||
public string LocaleName { get; set; }
|
||||
public override string ToString()
|
||||
=> DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
@@ -40,9 +41,8 @@ namespace DtoImporterService
|
||||
//
|
||||
// CURRENT SOLUTION: don't re-insert
|
||||
|
||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = importItems
|
||||
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
|
||||
.ExceptBy(DbContext.LibraryBooks.Select(lb => lb.Book.AudibleProductId), imp => imp.DtoItem.ProductId)
|
||||
.ToList();
|
||||
|
||||
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
||||
@@ -55,7 +55,11 @@ namespace DtoImporterService
|
||||
var libraryBook = new LibraryBook(
|
||||
bookImporter.Cache[newItem.DtoItem.ProductId],
|
||||
newItem.DtoItem.DateAdded,
|
||||
newItem.AccountId);
|
||||
newItem.AccountId)
|
||||
{
|
||||
AbsentFromLastScan = isPlusTitleUnavailable(newItem)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
DbContext.LibraryBooks.Add(libraryBook);
|
||||
@@ -66,8 +70,30 @@ namespace DtoImporterService
|
||||
}
|
||||
}
|
||||
|
||||
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
|
||||
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null))
|
||||
nullBook.AbsentFromLastScan = true;
|
||||
|
||||
//Join importItems on LibraryBooks before iterating over LibraryBooks to avoid
|
||||
//quadratic complexity caused by searching all of importItems for each LibraryBook.
|
||||
//Join uses hashing, so complexity should approach O(N) instead of O(N^2).
|
||||
var items_lbs
|
||||
= importItems
|
||||
.Join(DbContext.LibraryBooks, o => (o.AccountId, o.DtoItem.ProductId), i => (i.Account, i.Book?.AudibleProductId), (o, i) => (o, i));
|
||||
|
||||
foreach ((ImportItem item, LibraryBook lb) in items_lbs)
|
||||
lb.AbsentFromLastScan = isPlusTitleUnavailable(item);
|
||||
|
||||
var qtyNew = hash.Count;
|
||||
return qtyNew;
|
||||
}
|
||||
|
||||
|
||||
//This SEEMS to work to detect plus titles which are no longer available.
|
||||
//I have my doubts it won't yield false negatives, but I have more
|
||||
//confidence that it won't yield many/any false positives.
|
||||
private static bool isPlusTitleUnavailable(ImportItem item)
|
||||
=> item.DtoItem.IsAyce is true
|
||||
&& item.DtoItem.Plans?.Any(p => p.PlanName.ContainsInsensitive("Minerva") || p.PlanName.ContainsInsensitive("Free")) is not true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace FileLiberator
|
||||
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
@@ -61,31 +61,30 @@ namespace FileLiberator
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
if (!success || getFirstAudioFile(entries) == default)
|
||||
{
|
||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||
FileUtility.SaferDelete(tmpFile.Path);
|
||||
await Task.WhenAll(
|
||||
entries
|
||||
.Where(f => f.FileType != FileType.AAXC)
|
||||
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
|
||||
|
||||
return abDownloader?.IsCanceled == true ?
|
||||
new StatusHandler { "Cancelled" } :
|
||||
new StatusHandler { "Decrypt failed" };
|
||||
return
|
||||
abDownloader?.IsCanceled is true
|
||||
? new StatusHandler { "Cancelled" }
|
||||
: new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
|
||||
// moves new files from temp dir to final dest.
|
||||
// This could take a few seconds if moving hundreds of files.
|
||||
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
// decrypt failed
|
||||
if (finalStorageDir is null)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
Task[] finalTasks = new[]
|
||||
{
|
||||
Task.Run(() => downloadCoverArt(libraryBook)),
|
||||
Task.Run(() => moveFilesToBooksDir(libraryBook, entries)),
|
||||
Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)),
|
||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||
};
|
||||
|
||||
if (Configuration.Instance.DownloadCoverArt)
|
||||
downloadCoverArt(libraryBook);
|
||||
|
||||
// contains logic to check for config setting and OS
|
||||
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
|
||||
|
||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
await Task.WhenAll(finalTasks);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
@@ -131,8 +130,8 @@ namespace FileLiberator
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
return await abDownloader.RunAsync();
|
||||
// REAL WORK DONE HERE
|
||||
return await abDownloader.RunAsync();
|
||||
}
|
||||
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||
@@ -335,18 +334,12 @@ namespace FileLiberator
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
var destinationDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
|
||||
if (getFirstAudio() == default)
|
||||
return null;
|
||||
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
@@ -357,22 +350,33 @@ namespace FileLiberator
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||
private static string getDestinationDirectory(LibraryBook libraryBook)
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
if (!Directory.Exists(destinationDir))
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
|
||||
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
|
||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||
|
||||
var coverPath = "[null]";
|
||||
|
||||
try
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
var destinationDir = getDestinationDirectory(libraryBook);
|
||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
|
||||
|
||||
BIN
Source/LibationAvalonia/Assets/Arrows_left.png
Normal file
BIN
Source/LibationAvalonia/Assets/Arrows_left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 B |
BIN
Source/LibationAvalonia/Assets/Arrows_right.png
Normal file
BIN
Source/LibationAvalonia/Assets/Arrows_right.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 B |
@@ -8,6 +8,8 @@
|
||||
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="{StaticResource SystemAltHighColor}" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookBorderBrush" Color="Gray" />
|
||||
<SolidColorBrush x:Key="DisabledGrayBrush" Color="#60D3D3D3" />
|
||||
|
||||
</Styles.Resources>
|
||||
<Style Selector="TextBox[IsReadOnly=true]">
|
||||
<Setter Property="Background" Value="LightGray" />
|
||||
|
||||
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); }
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Media.Imaging;
|
||||
using Avalonia.Platform;
|
||||
using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
@@ -16,23 +13,8 @@ namespace LibationAvalonia.Dialogs
|
||||
public string PictureFileName { get; set; }
|
||||
public string BookSaveDirectory { get; set; }
|
||||
|
||||
private byte[] _coverBytes;
|
||||
public byte[] CoverBytes
|
||||
{
|
||||
get => _coverBytes;
|
||||
set
|
||||
{
|
||||
_coverBytes = value;
|
||||
var ms = new MemoryStream(_coverBytes);
|
||||
ms.Position = 0;
|
||||
_bitmapHolder.CoverImage = new Bitmap(ms);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private readonly BitmapHolder _bitmapHolder = new BitmapHolder();
|
||||
|
||||
|
||||
public ImageDisplayDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -45,6 +27,21 @@ namespace LibationAvalonia.Dialogs
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public void SetCoverBytes(byte[] cover)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ms = new MemoryStream(cover);
|
||||
_bitmapHolder.CoverImage = new Bitmap(ms);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error loading cover art for {file}", PictureFileName);
|
||||
using var ms = App.OpenAsset("img-coverart-prod-unavailable_500x500.jpg");
|
||||
_bitmapHolder.CoverImage = new Bitmap(ms);
|
||||
}
|
||||
}
|
||||
|
||||
public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var options = new FilePickerSaveOptions
|
||||
@@ -70,7 +67,7 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(uri.LocalPath, CoverBytes);
|
||||
_bitmapHolder.CoverImage.Save(uri.LocalPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750"
|
||||
MinWidth="900" MinHeight="700"
|
||||
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
@@ -376,7 +376,7 @@
|
||||
Grid.Row="3"
|
||||
Margin="5"
|
||||
VerticalAlignment="Top"
|
||||
IsVisible="{Binding IsWindows}"
|
||||
IsVisible="{Binding !IsLinux}"
|
||||
IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
|
||||
@@ -108,7 +108,8 @@ namespace LibationAvalonia.Dialogs
|
||||
LoadSettings(config);
|
||||
}
|
||||
|
||||
public bool IsWindows => AppScaffolding.LibationScaffolding.ReleaseIdentifier is AppScaffolding.ReleaseIdentifier.WindowsAvalonia;
|
||||
public bool IsLinux => Configuration.IsLinux;
|
||||
public bool IsWindows => Configuration.IsWindows;
|
||||
public ImportantSettings ImportantSettings { get; private set; }
|
||||
public ImportSettings ImportSettings { get; private set; }
|
||||
public DownloadDecryptSettings DownloadDecryptSettings { get; private set; }
|
||||
|
||||
66
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml
Normal file
66
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml
Normal file
@@ -0,0 +1,66 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="630" d:DesignHeight="480"
|
||||
x:Class="LibationAvalonia.Dialogs.TrashBinDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
MinWidth="630" MinHeight="480"
|
||||
Title="Trash Bin"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
|
||||
<Grid
|
||||
RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
Text="Check books you want to permanently delete from or restore to Libation" />
|
||||
|
||||
<controls:CheckedListBox
|
||||
Grid.Row="1"
|
||||
Margin="5,0,5,0"
|
||||
BorderThickness="1"
|
||||
BorderBrush="Gray"
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Items="{Binding DeletedBooks}" />
|
||||
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<CheckBox
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
IsThreeState="True"
|
||||
Margin="0,0,20,0"
|
||||
IsChecked="{Binding EverythingChecked}"
|
||||
Content="Everything" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CheckedCountText}" />
|
||||
|
||||
<Button
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,20,0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Stretch"
|
||||
VerticalContentAlignment="Center"
|
||||
Content="Restore"
|
||||
Click="Restore_Click" />
|
||||
|
||||
<Button
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Grid.Column="3"
|
||||
Click="EmptyTrash_Click" >
|
||||
<TextBlock
|
||||
TextAlignment="Center"
|
||||
Text="Permanently Delete
from Libation" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
145
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs
Normal file
145
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using LibationAvalonia.Controls;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class TrashBinDialog : Window
|
||||
{
|
||||
TrashBinViewModel _viewModel;
|
||||
|
||||
public TrashBinDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
this.RestoreSizeAndLocation(Configuration.Instance);
|
||||
DataContext = _viewModel = new();
|
||||
|
||||
this.Closing += (_,_) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
|
||||
public async void EmptyTrash_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.PermanentlyDeleteCheckedAsync();
|
||||
public async void Restore_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.RestoreCheckedAsync();
|
||||
}
|
||||
|
||||
public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
|
||||
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
||||
|
||||
private bool _controlsEnabled = true;
|
||||
public bool ControlsEnabled { get => _controlsEnabled; set=> this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
|
||||
|
||||
private bool? everythingChecked = false;
|
||||
public bool? EverythingChecked
|
||||
{
|
||||
get => everythingChecked;
|
||||
set
|
||||
{
|
||||
everythingChecked = value ?? false;
|
||||
|
||||
if (everythingChecked is true)
|
||||
CheckAll();
|
||||
else if (everythingChecked is false)
|
||||
UncheckAll();
|
||||
}
|
||||
}
|
||||
|
||||
private int _totalBooksCount = 0;
|
||||
private int _checkedBooksCount = -1;
|
||||
public int CheckedBooksCount
|
||||
{
|
||||
get => _checkedBooksCount;
|
||||
set
|
||||
{
|
||||
if (_checkedBooksCount != value)
|
||||
{
|
||||
_checkedBooksCount = value;
|
||||
this.RaisePropertyChanged(nameof(CheckedCountText));
|
||||
}
|
||||
|
||||
everythingChecked
|
||||
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
|
||||
: _checkedBooksCount == _totalBooksCount ? true
|
||||
: null;
|
||||
|
||||
this.RaisePropertyChanged(nameof(EverythingChecked));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
|
||||
|
||||
public TrashBinViewModel()
|
||||
{
|
||||
DeletedBooks = new()
|
||||
{
|
||||
ResetBehavior = ResetBehavior.Remove
|
||||
};
|
||||
|
||||
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
|
||||
Reload();
|
||||
}
|
||||
|
||||
public void CheckAll()
|
||||
{
|
||||
foreach (var item in DeletedBooks)
|
||||
item.IsChecked = true;
|
||||
}
|
||||
|
||||
public void UncheckAll()
|
||||
{
|
||||
foreach (var item in DeletedBooks)
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
|
||||
if (qtyChanges > 0)
|
||||
Reload();
|
||||
ControlsEnabled = true;
|
||||
}
|
||||
|
||||
public async Task PermanentlyDeleteCheckedAsync()
|
||||
{
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
|
||||
if (qtyChanges > 0)
|
||||
Reload();
|
||||
ControlsEnabled = true;
|
||||
}
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||
|
||||
DeletedBooks.Clear();
|
||||
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
|
||||
|
||||
_totalBooksCount = DeletedBooks.Count;
|
||||
CheckedBooksCount = 0;
|
||||
}
|
||||
|
||||
private IDisposable tracker;
|
||||
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
|
||||
{
|
||||
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
|
||||
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
|
||||
}
|
||||
|
||||
public void Dispose() => tracker?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Remove=".gitignore" />
|
||||
<None Remove="Assets\Arrows_left.png" />
|
||||
<None Remove="Assets\Arrows_right.png" />
|
||||
<None Remove="Assets\Asterisk.png" />
|
||||
<None Remove="Assets\cancel.png" />
|
||||
<None Remove="Assets\completed.png" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using FileLiberator;
|
||||
@@ -35,18 +36,31 @@ namespace LibationAvalonia.ViewModels
|
||||
#region Model properties exposed to the view
|
||||
|
||||
private Avalonia.Media.Imaging.Bitmap _cover;
|
||||
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set { this.RaiseAndSetIfChanged(ref _cover, value); } }
|
||||
public string PurchaseDate { get; protected set; }
|
||||
public string Series { get; protected set; }
|
||||
public string Title { get; protected set; }
|
||||
public string Length { get; protected set; }
|
||||
public string Authors { get; protected set; }
|
||||
public string Narrators { get; protected set; }
|
||||
public string Category { get; protected set; }
|
||||
public string Misc { get; protected set; }
|
||||
public string Description { get; protected set; }
|
||||
public Rating ProductRating { get; protected set; }
|
||||
private string _purchasedate;
|
||||
private string _series;
|
||||
private string _title;
|
||||
private string _length;
|
||||
private string _authors;
|
||||
private string _narrators;
|
||||
private string _category;
|
||||
private string _misc;
|
||||
private LastDownloadStatus _lastDownload;
|
||||
private string _description;
|
||||
private Rating _productrating;
|
||||
protected Rating _myRating;
|
||||
|
||||
public Avalonia.Media.Imaging.Bitmap Cover { get => _cover; protected set => this.RaiseAndSetIfChanged(ref _cover, value); }
|
||||
public string PurchaseDate { get => _purchasedate; protected set => this.RaiseAndSetIfChanged(ref _purchasedate, value); }
|
||||
public string Series { get => _series; protected set => this.RaiseAndSetIfChanged(ref _series, value); }
|
||||
public string Title { get => _title; protected set => this.RaiseAndSetIfChanged(ref _title, value); }
|
||||
public string Length { get => _length; protected set => this.RaiseAndSetIfChanged(ref _length, value); }
|
||||
public string Authors { get => _authors; protected set => this.RaiseAndSetIfChanged(ref _authors, value); }
|
||||
public string Narrators { get => _narrators; protected set => this.RaiseAndSetIfChanged(ref _narrators, value); }
|
||||
public string Category { get => _category; protected set => this.RaiseAndSetIfChanged(ref _category, value); }
|
||||
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => this.RaiseAndSetIfChanged(ref _lastDownload, value); }
|
||||
public string Misc { get => _misc; protected set => this.RaiseAndSetIfChanged(ref _misc, value); }
|
||||
public string Description { get => _description; protected set => this.RaiseAndSetIfChanged(ref _description, value); }
|
||||
public Rating ProductRating { get => _productrating; protected set => this.RaiseAndSetIfChanged(ref _productrating, value); }
|
||||
public Rating MyRating
|
||||
{
|
||||
get => _myRating;
|
||||
@@ -65,7 +79,6 @@ namespace LibationAvalonia.ViewModels
|
||||
public abstract bool? Remove { get; set; }
|
||||
public abstract LiberateButtonStatus Liberate { get; }
|
||||
public abstract BookTags BookTags { get; }
|
||||
public abstract bool IsSeries { get; }
|
||||
public abstract bool IsEpisode { get; }
|
||||
public abstract bool IsBook { get; }
|
||||
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
|
||||
@@ -111,6 +124,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{ typeof(bool), new ObjectComparer<bool>() },
|
||||
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
||||
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
||||
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
|
||||
};
|
||||
|
||||
#endregion
|
||||
@@ -126,8 +140,7 @@ namespace LibationAvalonia.ViewModels
|
||||
PictureStorage.PictureCached += PictureStorage_PictureCached;
|
||||
|
||||
// Mutable property. Set the field so PropertyChanged isn't fired.
|
||||
using var ms = new System.IO.MemoryStream(picture);
|
||||
_cover = new Avalonia.Media.Imaging.Bitmap(ms);
|
||||
_cover = loadImage(picture);
|
||||
}
|
||||
|
||||
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
|
||||
@@ -143,12 +156,28 @@ namespace LibationAvalonia.ViewModels
|
||||
// logic validation
|
||||
if (e.Definition.PictureId == Book.PictureId)
|
||||
{
|
||||
using var ms = new System.IO.MemoryStream(e.Picture);
|
||||
Cover = new Avalonia.Media.Imaging.Bitmap(ms);
|
||||
Cover = loadImage(e.Picture);
|
||||
PictureStorage.PictureCached -= PictureStorage_PictureCached;
|
||||
}
|
||||
}
|
||||
|
||||
private Bitmap loadImage(byte[] picture)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ms = new System.IO.MemoryStream(picture);
|
||||
return new Bitmap(ms);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
|
||||
return DefaultImage;
|
||||
}
|
||||
}
|
||||
|
||||
private static Bitmap _defaultImage;
|
||||
private static Bitmap DefaultImage => _defaultImage ??= new Bitmap(App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg"));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static library display functions
|
||||
|
||||
@@ -8,9 +8,10 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
public class LiberateButtonStatus : ViewModelBase, IComparable
|
||||
{
|
||||
public LiberateButtonStatus(bool isSeries)
|
||||
public LiberateButtonStatus(bool isSeries, bool isAbsent)
|
||||
{
|
||||
IsSeries = isSeries;
|
||||
IsAbsent = isAbsent;
|
||||
}
|
||||
public LiberatedStatus BookStatus { get; set; }
|
||||
public LiberatedStatus? PdfStatus { get; set; }
|
||||
@@ -26,7 +27,10 @@ namespace LibationAvalonia.ViewModels
|
||||
this.RaisePropertyChanged(nameof(ToolTip));
|
||||
}
|
||||
}
|
||||
private bool IsSeries { get; }
|
||||
|
||||
private bool IsAbsent { get; }
|
||||
public bool IsSeries { get; }
|
||||
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
|
||||
public Bitmap Image => GetLiberateIcon();
|
||||
public string ToolTip => GetTooltip();
|
||||
|
||||
@@ -40,6 +44,8 @@ namespace LibationAvalonia.ViewModels
|
||||
if (IsSeries && !second.IsSeries) return -1;
|
||||
else if (!IsSeries && second.IsSeries) return 1;
|
||||
else if (IsSeries && second.IsSeries) return 0;
|
||||
else if (IsUnavailable && !second.IsUnavailable) return 1;
|
||||
else if (!IsUnavailable && second.IsUnavailable) return -1;
|
||||
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
|
||||
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
|
||||
else return BookStatus.CompareTo(second.BookStatus);
|
||||
@@ -72,11 +78,15 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
return GetFromResources($"liberate_{image_lib}{image_pdf}");
|
||||
}
|
||||
|
||||
private string GetTooltip()
|
||||
{
|
||||
if (IsSeries)
|
||||
return Expanded ? "Click to Collpase" : "Click to Expand";
|
||||
|
||||
if (IsUnavailable)
|
||||
return "This book cannot be downloaded\nbecause it wasn't found during\nthe most recent library scan";
|
||||
|
||||
if (BookStatus == LiberatedStatus.Error)
|
||||
return "Book downloaded ERROR";
|
||||
|
||||
|
||||
@@ -44,13 +44,12 @@ namespace LibationAvalonia.ViewModels
|
||||
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
|
||||
lastStatusUpdate = DateTime.Now;
|
||||
}
|
||||
return new LiberateButtonStatus(IsSeries) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
|
||||
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
|
||||
}
|
||||
}
|
||||
|
||||
public override BookTags BookTags => new() { Tags = string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated) };
|
||||
|
||||
public override bool IsSeries => false;
|
||||
public override bool IsEpisode => Parent is not null;
|
||||
public override bool IsBook => Parent is null;
|
||||
|
||||
@@ -58,8 +57,22 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public LibraryBookEntry(LibraryBook libraryBook)
|
||||
{
|
||||
LibraryBook = libraryBook;
|
||||
setLibraryBook(libraryBook);
|
||||
LoadCover();
|
||||
}
|
||||
|
||||
public void UpdateLibraryBook(LibraryBook libraryBook)
|
||||
{
|
||||
if (AudibleProductId != libraryBook.Book.AudibleProductId)
|
||||
throw new Exception("Invalid grid entry update. IDs must match");
|
||||
|
||||
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
|
||||
setLibraryBook(libraryBook);
|
||||
}
|
||||
|
||||
private void setLibraryBook(LibraryBook libraryBook)
|
||||
{
|
||||
LibraryBook = libraryBook;
|
||||
|
||||
Title = Book.Title;
|
||||
Series = Book.SeriesNames();
|
||||
@@ -73,10 +86,13 @@ namespace LibationAvalonia.ViewModels
|
||||
Narrators = Book.NarratorNames();
|
||||
Category = string.Join(" > ", Book.CategoriesNames());
|
||||
Misc = GetMiscDisplay(libraryBook);
|
||||
LastDownload = new(Book.UserDefinedItem);
|
||||
LongDescription = GetDescriptionDisplay(Book);
|
||||
Description = TrimTextToWord(LongDescription, 62);
|
||||
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
|
||||
|
||||
this.RaisePropertyChanged(nameof(MyRating));
|
||||
this.RaisePropertyChanged(nameof(Liberate));
|
||||
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
|
||||
}
|
||||
|
||||
@@ -112,6 +128,10 @@ namespace LibationAvalonia.ViewModels
|
||||
_pdfStatus = udi.PdfStatus;
|
||||
this.RaisePropertyChanged(nameof(Liberate));
|
||||
break;
|
||||
case nameof(udi.LastDownloaded):
|
||||
LastDownload = new(udi);
|
||||
this.RaisePropertyChanged(nameof(LastDownload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,6 +154,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{ nameof(Description), () => Description },
|
||||
{ nameof(Category), () => Category },
|
||||
{ nameof(Misc), () => Misc },
|
||||
{ nameof(LastDownload), () => LastDownload },
|
||||
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
|
||||
{ nameof(Liberate), () => Liberate },
|
||||
{ nameof(DateAdded), () => DateAdded },
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using ApplicationServices;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
|
||||
@@ -135,15 +134,9 @@ namespace LibationAvalonia.ViewModels
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _queueOpen, value);
|
||||
QueueHideButtonText = _queueOpen? "❱❱❱" : "❰❰❰";
|
||||
this.RaisePropertyChanged(nameof(QueueHideButtonText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> The Process Queue's Expand/Collapse button display text </summary>
|
||||
public string QueueHideButtonText { get; private set; }
|
||||
|
||||
|
||||
|
||||
/// <summary> The number of books visible in the Product Display </summary>
|
||||
public int VisibleCount
|
||||
@@ -172,18 +165,6 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref _libraryStats, value);
|
||||
|
||||
var backupsCountText
|
||||
= !LibraryStats.HasBookResults ? "No books. Begin by importing your library"
|
||||
: !LibraryStats.HasPendingBooks ? $"All {"book".PluralizeWithCount(LibraryStats.booksFullyBackedUp)} backed up"
|
||||
: $"BACKUPS: No progress: {LibraryStats.booksNoProgress} In process: {LibraryStats.booksDownloadedOnly} Fully backed up: {LibraryStats.booksFullyBackedUp} {(LibraryStats.booksError > 0 ? $" Errors : {LibraryStats.booksError}" : "")}";
|
||||
|
||||
var pdfCountText
|
||||
= !LibraryStats.HasPdfResults ? ""
|
||||
: LibraryStats.pdfsNotDownloaded == 0 ? $" | All {LibraryStats.pdfsDownloaded} PDFs downloaded"
|
||||
: $" | PDFs: NOT d/l'ed: {LibraryStats.pdfsNotDownloaded} Downloaded: {LibraryStats.pdfsDownloaded}";
|
||||
|
||||
StatusCountText = backupsCountText + pdfCountText;
|
||||
|
||||
BookBackupsToolStripText
|
||||
= LibraryStats.HasPendingBooks
|
||||
? $"Begin _Book and PDF Backups: {LibraryStats.PendingBooks} remaining"
|
||||
@@ -194,21 +175,17 @@ namespace LibationAvalonia.ViewModels
|
||||
? $"Begin _PDF Only Backups: {LibraryStats.pdfsNotDownloaded} remaining"
|
||||
: "All PDFs have been downloaded";
|
||||
|
||||
this.RaisePropertyChanged(nameof(StatusCountText));
|
||||
this.RaisePropertyChanged(nameof(BookBackupsToolStripText));
|
||||
this.RaisePropertyChanged(nameof(PdfBackupsToolStripText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary> Bottom-left library statistics display text </summary>
|
||||
public string StatusCountText { get; private set; } = "[Calculating backed up book quantities] | [Calculating backed up PDFs]";
|
||||
/// <summary> The "Begin Book and PDF Backup" menu item header text </summary>
|
||||
public string BookBackupsToolStripText { get; private set; } = "Begin _Book and PDF Backups: 0";
|
||||
/// <summary> The "Begin PDF Only Backup" menu item header text </summary>
|
||||
public string PdfBackupsToolStripText { get; private set; } = "Begin _PDF Only Backups: 0";
|
||||
|
||||
|
||||
|
||||
/// <summary> The number of books visible in the Products Display that have not yet been liberated </summary>
|
||||
public int VisibleNotLiberated
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
@@ -16,9 +17,8 @@ namespace LibationAvalonia.ViewModels
|
||||
public class ProcessQueueViewModel : ViewModelBase, ILogForm
|
||||
{
|
||||
public ObservableCollection<LogEntry> LogEntries { get; } = new();
|
||||
public TrackedQueue<ProcessBookViewModel> Items { get; } = new();
|
||||
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => Items;
|
||||
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
|
||||
public TrackedQueue<ProcessBookViewModel> Queue { get; }
|
||||
public ProcessBookViewModel SelectedItem { get; set; }
|
||||
public Task QueueRunner { get; private set; }
|
||||
public bool Running => !QueueRunner?.IsCompleted ?? false;
|
||||
@@ -28,6 +28,7 @@ namespace LibationAvalonia.ViewModels
|
||||
public ProcessQueueViewModel()
|
||||
{
|
||||
Logger = LogMe.RegisterForm(this);
|
||||
Queue = new(Items);
|
||||
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
|
||||
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
|
||||
|
||||
@@ -88,19 +89,19 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public decimal SpeedLimitIncrement { get; private set; }
|
||||
|
||||
private void Queue_CompletedCountChanged(object sender, int e)
|
||||
private async void Queue_CompletedCountChanged(object sender, int e)
|
||||
{
|
||||
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
|
||||
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
|
||||
|
||||
ErrorCount = errCount;
|
||||
CompletedCount = completeCount;
|
||||
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||
}
|
||||
private void Queue_QueuededCountChanged(object sender, int cueCount)
|
||||
private async void Queue_QueuededCountChanged(object sender, int cueCount)
|
||||
{
|
||||
QueuedCount = cueCount;
|
||||
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
|
||||
}
|
||||
|
||||
public void WriteLine(string text)
|
||||
|
||||
@@ -20,11 +20,11 @@ namespace LibationAvalonia.ViewModels
|
||||
public event EventHandler<int> RemovableCountChanged;
|
||||
|
||||
/// <summary>Backing list of all grid entries</summary>
|
||||
private readonly List<GridEntry> SOURCE = new();
|
||||
private readonly AvaloniaList<GridEntry> SOURCE = new();
|
||||
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
|
||||
private List<GridEntry> FilteredInGridEntries;
|
||||
public string FilterString { get; private set; }
|
||||
public DataGridCollectionView GridEntries { get; }
|
||||
public DataGridCollectionView GridEntries { get; private set; }
|
||||
|
||||
private bool _removeColumnVisivle;
|
||||
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
|
||||
@@ -42,59 +42,60 @@ namespace LibationAvalonia.ViewModels
|
||||
public ProductsDisplayViewModel()
|
||||
{
|
||||
SearchEngineCommands.SearchEngineUpdated += SearchEngineCommands_SearchEngineUpdated;
|
||||
GridEntries = new(SOURCE);
|
||||
GridEntries.Filter = CollectionFilter;
|
||||
VisibleCountChanged?.Invoke(this, 0);
|
||||
}
|
||||
|
||||
GridEntries.CollectionChanged += (s, e)
|
||||
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
|
||||
private static readonly System.Reflection.MethodInfo SetFlagsMethod;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the <see cref="DataGridCollectionView"/> whether it should process changes to the underlying collection
|
||||
/// </summary>
|
||||
/// <remarks> DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4</remarks>
|
||||
private void SetShouldProcessCollectionChanged(bool flagSet)
|
||||
=> SetFlagsMethod.Invoke(GridEntries, new object[] { 4, flagSet });
|
||||
|
||||
static ProductsDisplayViewModel()
|
||||
{
|
||||
/*
|
||||
* When a book is removed from the library, SearchEngineUpdated is fired before LibrarySizeChanged, so
|
||||
* the book is removed from the filtered set and the grid is refreshed before RemoveBooks() is ever
|
||||
* called.
|
||||
*
|
||||
* To remove an item from DataGridCollectionView, it must be be in the current filtered view. If it's
|
||||
* not and you try to remove the book from the source list, the source will fire NotifyCollectionChanged
|
||||
* on an invalid item and the DataGridCollectionView will throw an exception. There are two ways to
|
||||
* remove an item that is filtered out of the DataGridCollectionView:
|
||||
*
|
||||
* (1) Re-add the item to the filtered-in list and refresh the grid so DataGridCollectionView knows
|
||||
* that the item is present. This causes the whole grid to flicker to refresh twice in rapid
|
||||
* succession, which is undesirable.
|
||||
*
|
||||
* (2) Remove it from the underlying collection and suppress NotifyCollectionChanged. This is the
|
||||
* method used. Steps to complete a removal using this method:
|
||||
*
|
||||
* (a) Set DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged to false.
|
||||
* (b) Remove the item from the source list. The source will fire NotifyCollectionChanged, but the
|
||||
* DataGridCollectionView will ignore it.
|
||||
* (c) Reset the flag to true.
|
||||
*/
|
||||
|
||||
SetFlagsMethod =
|
||||
typeof(DataGridCollectionView)
|
||||
.GetMethod("SetFlag", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
|
||||
}
|
||||
|
||||
#region Display Functions
|
||||
|
||||
/// <summary>
|
||||
/// Call when there's been a change to the library
|
||||
/// </summary>
|
||||
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
|
||||
internal void BindToGrid(List<LibraryBook> dbBooks)
|
||||
{
|
||||
try
|
||||
GridEntries = new(SOURCE)
|
||||
{
|
||||
var existingSeriesEntries = SOURCE.SeriesEntries().ToList();
|
||||
Filter = CollectionFilter
|
||||
};
|
||||
|
||||
FilteredInGridEntries?.Clear();
|
||||
SOURCE.Clear();
|
||||
SOURCE.AddRange(CreateGridEntries(dbBooks));
|
||||
GridEntries.CollectionChanged += (_, _)
|
||||
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
|
||||
|
||||
//If replacing the list, preserve user's existing collapse/expand
|
||||
//state. When resetting a list, default state is cosed.
|
||||
foreach (var series in existingSeriesEntries)
|
||||
{
|
||||
var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId);
|
||||
if (sEntry is SeriesEntry se)
|
||||
se.Liberate.Expanded = series.Liberate.Expanded;
|
||||
}
|
||||
|
||||
//Run query on new list
|
||||
FilteredInGridEntries = QueryResults(SOURCE, FilterString);
|
||||
|
||||
await refreshGrid();
|
||||
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel));
|
||||
}
|
||||
}
|
||||
|
||||
private async Task refreshGrid()
|
||||
{
|
||||
if (GridEntries.IsEditingItem)
|
||||
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
|
||||
}
|
||||
|
||||
private static List<GridEntry> CreateGridEntries(IEnumerable<LibraryBook> dbBooks)
|
||||
{
|
||||
var geList = dbBooks
|
||||
.Where(lb => lb.Book.IsProduct())
|
||||
.Select(b => new LibraryBookEntry(b))
|
||||
@@ -103,26 +104,159 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
|
||||
|
||||
foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent()))
|
||||
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
|
||||
|
||||
foreach (var parent in seriesBooks)
|
||||
{
|
||||
var seriesEpisodes = episodes.FindChildren(parent);
|
||||
|
||||
if (!seriesEpisodes.Any()) continue;
|
||||
|
||||
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
|
||||
seriesEntry.Liberate.Expanded = false;
|
||||
|
||||
geList.Add(seriesEntry);
|
||||
geList.AddRange(seriesEntry.Children);
|
||||
}
|
||||
|
||||
var bookList = geList.OrderByDescending(e => e.DateAdded).ToList();
|
||||
//Create the filtered-in list before adding entries to avoid a refresh
|
||||
FilteredInGridEntries = QueryResults(geList, FilterString);
|
||||
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
|
||||
}
|
||||
|
||||
//ListIndex is used by RowComparer to make column sort stable
|
||||
int index = 0;
|
||||
foreach (GridEntry di in bookList)
|
||||
di.ListIndex = index++;
|
||||
/// <summary>
|
||||
/// Call when there's been a change to the library
|
||||
/// </summary>
|
||||
internal async Task UpdateGridAsync(List<LibraryBook> dbBooks)
|
||||
{
|
||||
#region Add new or update existing grid entries
|
||||
|
||||
return bookList;
|
||||
//Add absent entries to grid, or update existing entry
|
||||
var allEntries = SOURCE.BookEntries();
|
||||
var seriesEntries = SOURCE.SeriesEntries().ToList();
|
||||
var parentedEpisodes = dbBooks.ParentedEpisodes();
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
|
||||
{
|
||||
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
|
||||
|
||||
if (libraryBook.Book.IsProduct())
|
||||
UpsertBook(libraryBook, existingEntry);
|
||||
else if (parentedEpisodes.Any(lb => lb == libraryBook))
|
||||
//Only try to add or update is this LibraryBook is a know child of a parent
|
||||
UpsertEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
|
||||
}
|
||||
});
|
||||
|
||||
#endregion
|
||||
|
||||
#region Remove entries no longer in the library
|
||||
|
||||
//Rapid successive book removals will cause changes to SOURCE after the update has
|
||||
//begun but before it has completed, so perform all updates on a copy of the list.
|
||||
var sourceSnapshot = SOURCE.ToList();
|
||||
|
||||
// remove deleted from grid.
|
||||
// note: actual deletion from db must still occur via the RemoveBook feature. deleting from audible will not trigger this
|
||||
var removedBooks =
|
||||
sourceSnapshot
|
||||
.BookEntries()
|
||||
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
|
||||
|
||||
//Remove books in series from their parents' Children list
|
||||
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
|
||||
removed.Parent.RemoveChild(removed);
|
||||
|
||||
//Remove series that have no children
|
||||
var removedSeries = sourceSnapshot.EmptySeries();
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() => RemoveBooks(removedBooks, removedSeries));
|
||||
|
||||
#endregion
|
||||
|
||||
await Filter(FilterString);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
|
||||
private void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks, IEnumerable<SeriesEntry> removedSeries)
|
||||
{
|
||||
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
|
||||
{
|
||||
if (GridEntries.PassesFilter(removed))
|
||||
GridEntries.Remove(removed);
|
||||
else
|
||||
{
|
||||
SetShouldProcessCollectionChanged(false);
|
||||
SOURCE.Remove(removed);
|
||||
SetShouldProcessCollectionChanged(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void UpsertBook(LibraryBook book, LibraryBookEntry existingBookEntry)
|
||||
{
|
||||
if (existingBookEntry is null)
|
||||
// Add the new product to top
|
||||
SOURCE.Insert(0, new LibraryBookEntry(book));
|
||||
else
|
||||
// update existing
|
||||
existingBookEntry.UpdateLibraryBook(book);
|
||||
}
|
||||
|
||||
private void UpsertEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
|
||||
{
|
||||
if (existingEpisodeEntry is null)
|
||||
{
|
||||
LibraryBookEntry episodeEntry;
|
||||
|
||||
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
|
||||
|
||||
if (seriesEntry is null)
|
||||
{
|
||||
//Series doesn't exist yet, so create and add it
|
||||
var seriesBook = dbBooks.FindSeriesParent(episodeBook);
|
||||
|
||||
if (seriesBook is null)
|
||||
{
|
||||
//This is only possible if the user's db has some malformed
|
||||
//entries from earlier Libation releases that could not be
|
||||
//automatically fixed. Log, but don't throw.
|
||||
Serilog.Log.Logger.Error("Episode={0}, Episode Series: {1}", episodeBook, episodeBook.Book.SeriesNames());
|
||||
return;
|
||||
}
|
||||
|
||||
seriesEntry = new SeriesEntry(seriesBook, new[] { episodeBook });
|
||||
seriesEntries.Add(seriesEntry);
|
||||
|
||||
episodeEntry = seriesEntry.Children[0];
|
||||
seriesEntry.Liberate.Expanded = true;
|
||||
SOURCE.Insert(0, seriesEntry);
|
||||
}
|
||||
else
|
||||
{
|
||||
//Series exists. Create and add episode child then update the SeriesEntry
|
||||
episodeEntry = new(episodeBook) { Parent = seriesEntry };
|
||||
seriesEntry.Children.Add(episodeEntry);
|
||||
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
|
||||
seriesEntry.UpdateLibraryBook(seriesBook);
|
||||
}
|
||||
|
||||
//Add episode to the grid beneath the parent
|
||||
int seriesIndex = SOURCE.IndexOf(seriesEntry);
|
||||
SOURCE.Insert(seriesIndex + 1, episodeEntry);
|
||||
}
|
||||
else
|
||||
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
|
||||
}
|
||||
|
||||
private async Task refreshGrid()
|
||||
{
|
||||
if (GridEntries.IsEditingItem)
|
||||
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
|
||||
}
|
||||
|
||||
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
|
||||
@@ -138,9 +272,6 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public async Task Filter(string searchString)
|
||||
{
|
||||
if (searchString == FilterString)
|
||||
return;
|
||||
|
||||
FilterString = searchString;
|
||||
|
||||
if (SOURCE.Count == 0)
|
||||
@@ -206,10 +337,10 @@ namespace LibationAvalonia.ViewModels
|
||||
if (selectedBooks.Count == 0)
|
||||
return;
|
||||
|
||||
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||
var result = await MessageBox.ShowConfirmationDialog(
|
||||
null,
|
||||
libraryBooks,
|
||||
booksToRemove,
|
||||
// do not use `$` string interpolation. See impl.
|
||||
"Are you sure you want to remove {0} from Libation's library?",
|
||||
"Remove books from Libation?");
|
||||
@@ -220,8 +351,6 @@ namespace LibationAvalonia.ViewModels
|
||||
foreach (var book in selectedBooks)
|
||||
book.PropertyChanged -= GridEntry_PropertyChanged;
|
||||
|
||||
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
|
||||
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
|
||||
@@ -240,7 +369,7 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
|
||||
//so there's no need to remove books from the grid display here.
|
||||
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
|
||||
await booksToRemove.RemoveBooksAsync();
|
||||
|
||||
RemovableCountChanged?.Invoke(this, 0);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,12 @@ namespace LibationAvalonia.ViewModels
|
||||
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
|
||||
=> gridEntries.OfType<SeriesEntry>();
|
||||
|
||||
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
|
||||
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
|
||||
|
||||
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
|
||||
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
|
||||
|
||||
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
|
||||
{
|
||||
if (seriesEpisode.Book.SeriesLink is null) return null;
|
||||
|
||||
@@ -46,7 +46,6 @@ namespace LibationAvalonia.ViewModels
|
||||
public override LiberateButtonStatus Liberate { get; }
|
||||
public override BookTags BookTags { get; } = new();
|
||||
|
||||
public override bool IsSeries => true;
|
||||
public override bool IsEpisode => false;
|
||||
public override bool IsBook => false;
|
||||
|
||||
@@ -54,17 +53,38 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
|
||||
{
|
||||
Liberate = new LiberateButtonStatus(IsSeries);
|
||||
Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false);
|
||||
SeriesIndex = -1;
|
||||
LibraryBook = parent;
|
||||
|
||||
LoadCover();
|
||||
|
||||
Children = children
|
||||
.Select(c => new LibraryBookEntry(c) { Parent = this })
|
||||
.OrderBy(c => c.SeriesIndex)
|
||||
.ToList();
|
||||
|
||||
setLibraryBook(parent);
|
||||
LoadCover();
|
||||
}
|
||||
|
||||
public void RemoveChild(LibraryBookEntry lbe)
|
||||
{
|
||||
Children.Remove(lbe);
|
||||
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
|
||||
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
|
||||
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
|
||||
}
|
||||
|
||||
public void UpdateLibraryBook(LibraryBook libraryBook)
|
||||
{
|
||||
if (AudibleProductId != libraryBook.Book.AudibleProductId)
|
||||
throw new Exception("Invalid grid entry update. IDs must match");
|
||||
|
||||
setLibraryBook(libraryBook);
|
||||
}
|
||||
|
||||
private void setLibraryBook(LibraryBook libraryBook)
|
||||
{
|
||||
LibraryBook = libraryBook;
|
||||
|
||||
Title = Book.Title;
|
||||
Series = Book.SeriesNames();
|
||||
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
|
||||
@@ -75,12 +95,15 @@ namespace LibationAvalonia.ViewModels
|
||||
Narrators = Book.NarratorNames();
|
||||
Category = string.Join(" > ", Book.CategoriesNames());
|
||||
Misc = GetMiscDisplay(LibraryBook);
|
||||
LastDownload = new();
|
||||
LongDescription = GetDescriptionDisplay(Book);
|
||||
Description = TrimTextToWord(LongDescription, 62);
|
||||
|
||||
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
|
||||
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
|
||||
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
|
||||
|
||||
this.RaisePropertyChanged(nameof(MyRating));
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +124,7 @@ namespace LibationAvalonia.ViewModels
|
||||
{ nameof(Description), () => Description },
|
||||
{ nameof(Category), () => Category },
|
||||
{ nameof(Misc), () => Misc },
|
||||
{ nameof(LastDownload), () => LastDownload },
|
||||
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
|
||||
{ nameof(Liberate), () => Liberate },
|
||||
{ nameof(DateAdded), () => DateAdded },
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AudibleUtilities;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -15,6 +16,12 @@ namespace LibationAvalonia.Views
|
||||
_viewModel.RemoveButtonsVisible = false;
|
||||
}
|
||||
|
||||
public async void openTrashBinToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var trash = new TrashBinDialog();
|
||||
await trash.ShowDialog(this);
|
||||
}
|
||||
|
||||
public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
// if 0 accounts, this will not be visible
|
||||
|
||||
@@ -144,11 +144,8 @@ namespace LibationAvalonia.Views
|
||||
"Remove books from Libation?",
|
||||
MessageBoxDefaultButton.Button2);
|
||||
|
||||
if (confirmationResult != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
await LibraryCommands.RemoveBooksAsync(visibleIds);
|
||||
if (confirmationResult is DialogResult.Yes)
|
||||
await visibleLibraryBooks.RemoveBooksAsync();
|
||||
}
|
||||
public async void ProductsDisplay_VisibleCountChanged(object sender, int qty)
|
||||
{
|
||||
@@ -157,9 +154,9 @@ namespace LibationAvalonia.Views
|
||||
await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem);
|
||||
}
|
||||
void setLiberatedVisibleMenuItem()
|
||||
=> _viewModel.VisibleNotLiberated
|
||||
= _viewModel.ProductsDisplay
|
||||
.GetVisibleBookEntries()
|
||||
.Count(lb => lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.NotLiberated);
|
||||
{
|
||||
var libraryStats = LibraryCommands.GetCounts(_viewModel.ProductsDisplay.GetVisibleBookEntries());
|
||||
_viewModel.VisibleNotLiberated = libraryStats.PendingBooks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
|
||||
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
|
||||
<Separator />
|
||||
<MenuItem Click="openTrashBinToolStripMenuItem_Click" Header="Trash Bin" />
|
||||
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
|
||||
<Separator />
|
||||
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
|
||||
@@ -172,7 +173,12 @@
|
||||
|
||||
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
|
||||
<Button Click="filterBtn_Click" Height="30" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click" Content="{Binding QueueHideButtonText}"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click">
|
||||
<Panel>
|
||||
<Image Stretch="None" IsVisible="{Binding !QueueOpen}" Source="/Assets/Arrows_left.png" />
|
||||
<Image Stretch="None" IsVisible="{Binding QueueOpen}" Source="/Assets/Arrows_right.png" />
|
||||
</Panel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
@@ -203,7 +209,7 @@
|
||||
<TextBlock FontSize="14" Grid.Column="0" Text="Upgrading:" VerticalAlignment="Center" IsVisible="{Binding DownloadProgress, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<ProgressBar Grid.Column="1" Margin="5,0,10,0" VerticalAlignment="Stretch" Width="100" Value="{Binding DownloadProgress}" IsVisible="{Binding DownloadProgress, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<TextBlock FontSize="14" Grid.Column="2" Text="{Binding VisibleCountText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontSize="14" Grid.Column="3" Text="{Binding StatusCountText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontSize="14" Grid.Column="3" Text="{Binding LibraryStats.StatusString}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -51,7 +51,7 @@ namespace LibationAvalonia.Views
|
||||
{
|
||||
this.LibraryLoaded += MainWindow_LibraryLoaded;
|
||||
|
||||
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooksAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
Closing += MainWindow_Closing;
|
||||
@@ -67,7 +67,7 @@ namespace LibationAvalonia.Views
|
||||
if (QuickFilters.UseDefault)
|
||||
await performFilter(QuickFilters.Filters.FirstOrDefault());
|
||||
|
||||
await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks);
|
||||
_viewModel.ProductsDisplay.BindToGrid(dbBooks);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace LibationAvalonia.Views
|
||||
{
|
||||
public partial class ProcessQueueControl : UserControl
|
||||
{
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Items;
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Queue;
|
||||
private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel;
|
||||
|
||||
public ProcessQueueControl()
|
||||
@@ -76,14 +76,14 @@ namespace LibationAvalonia.Views
|
||||
},
|
||||
};
|
||||
|
||||
vm.Items.Enqueue(testList);
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Queue.Enqueue(testList);
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
return;
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -61,9 +61,13 @@
|
||||
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="75" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Button Opacity="{Binding Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" ToolTip.Tip="{Binding Liberate.ToolTip}">
|
||||
<Image Source="{Binding Liberate.Image}" Stretch="None" />
|
||||
</Button>
|
||||
<Panel ToolTip.Tip="{Binding Liberate.ToolTip}">
|
||||
<Button Opacity="{Binding Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" IsVisible="{Binding !Liberate.IsUnavailable}">
|
||||
<Image Source="{Binding Liberate.Image}" Stretch="None" />
|
||||
</Button>
|
||||
<Image Source="{Binding Liberate.Image}" Stretch="None" IsVisible="{Binding Liberate.IsUnavailable}"/>
|
||||
<Panel Background="{StaticResource DisabledGrayBrush}" IsVisible="{Binding Liberate.IsUnavailable}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
@@ -183,6 +187,16 @@
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<controls:DataGridTemplateColumnExt Width="102" Header="Last
Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}" ToolTip.Tip="{Binding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
|
||||
<TextBlock Text="{Binding LastDownload}" TextWrapping="WrapWithOverflow" FontSize="10" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
|
||||
@@ -31,20 +31,25 @@ namespace LibationAvalonia.Views
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
List<LibraryBook> sampleEntries = new()
|
||||
List<LibraryBook> sampleEntries;
|
||||
try
|
||||
{
|
||||
//context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")
|
||||
};
|
||||
sampleEntries = new()
|
||||
{
|
||||
//context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),try{
|
||||
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")
|
||||
};
|
||||
}
|
||||
catch { sampleEntries = new(); }
|
||||
|
||||
var pdvm = new ProductsDisplayViewModel();
|
||||
_ = pdvm.DisplayBooksAsync(sampleEntries);
|
||||
pdvm.BindToGrid(sampleEntries);
|
||||
DataContext = pdvm;
|
||||
|
||||
return;
|
||||
@@ -84,7 +89,7 @@ namespace LibationAvalonia.Views
|
||||
{
|
||||
var entry = args.GridEntry;
|
||||
|
||||
if (entry.IsSeries)
|
||||
if (entry.Liberate.IsSeries)
|
||||
return;
|
||||
|
||||
var setDownloadMenuItem = new MenuItem()
|
||||
@@ -102,7 +107,7 @@ namespace LibationAvalonia.Views
|
||||
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||
|
||||
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
|
||||
removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId));
|
||||
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
|
||||
|
||||
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
|
||||
locateFileMenuItem.Click += async (_, __) =>
|
||||
@@ -135,7 +140,7 @@ namespace LibationAvalonia.Views
|
||||
var convertToMp3MenuItem = new MenuItem
|
||||
{
|
||||
Header = "_Convert to Mp3",
|
||||
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
|
||||
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
|
||||
};
|
||||
convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook);
|
||||
|
||||
@@ -306,6 +311,12 @@ namespace LibationAvalonia.Views
|
||||
imageDisplayDialog.Close();
|
||||
}
|
||||
|
||||
public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args)
|
||||
{
|
||||
if (sender is Control panel && panel.DataContext is LibraryBookEntry lbe && lbe.LastDownload.IsValid)
|
||||
lbe.LastDownload.OpenReleaseUrl();
|
||||
}
|
||||
|
||||
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
|
||||
{
|
||||
if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry)
|
||||
@@ -321,7 +332,7 @@ namespace LibationAvalonia.Views
|
||||
void PictureCached(object sender, PictureCachedEventArgs e)
|
||||
{
|
||||
if (e.Definition.PictureId == picDef.PictureId)
|
||||
imageDisplayDialog.CoverBytes = e.Picture;
|
||||
imageDisplayDialog.SetCoverBytes(e.Picture);
|
||||
|
||||
PictureStorage.PictureCached -= PictureCached;
|
||||
}
|
||||
@@ -336,7 +347,7 @@ namespace LibationAvalonia.Views
|
||||
imageDisplayDialog.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(gEntry.LibraryBook);
|
||||
imageDisplayDialog.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(gEntry.LibraryBook, ".jpg"));
|
||||
imageDisplayDialog.Title = windowTitle;
|
||||
imageDisplayDialog.CoverBytes = initialImageBts;
|
||||
imageDisplayDialog.SetCoverBytes(initialImageBts);
|
||||
|
||||
if (!isDefault)
|
||||
PictureStorage.PictureCached -= PictureCached;
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace LibationFileManager
|
||||
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
|
||||
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
|
||||
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
|
||||
public static Version LibationVersion { get; private set; }
|
||||
public static void SetLibationVersion(Version version) => LibationVersion = version;
|
||||
|
||||
public static OS OS { get; }
|
||||
= IsLinux ? OS.Linux
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace LibationFileManager
|
||||
|
||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
||||
|
||||
[Description("Set cover art as the folder's icon. (Windows only)")]
|
||||
[Description("Set cover art as the folder's icon. (Windows and macOS only)")]
|
||||
public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
|
||||
|
||||
@@ -9,11 +9,13 @@ namespace LibationFileManager
|
||||
{
|
||||
public static class WindowsDirectory
|
||||
{
|
||||
|
||||
public static void SetCoverAsFolderIcon(string pictureId, string directory)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Configuration.Instance.UseCoverAsFolderIcon || !Configuration.IsWindows)
|
||||
//Currently only works for Windows and macOS
|
||||
if (!Configuration.Instance.UseCoverAsFolderIcon || Configuration.IsLinux)
|
||||
return;
|
||||
|
||||
// get path of cover art in Images dir. Download first if not exists
|
||||
|
||||
52
Source/LibationUiBase/IcoEncoder.cs
Normal file
52
Source/LibationUiBase/IcoEncoder.cs
Normal file
@@ -0,0 +1,52 @@
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
using SixLabors.ImageSharp;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using SixLabors.ImageSharp.Formats;
|
||||
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public class IcoEncoder : IImageEncoder
|
||||
{
|
||||
public bool SkipMetadata { get; init; } = true;
|
||||
|
||||
public void Encode<TPixel>(Image<TPixel> image, Stream stream) where TPixel : unmanaged, IPixel<TPixel>
|
||||
{
|
||||
// https://stackoverflow.com/a/21389253
|
||||
|
||||
using var ms = new MemoryStream();
|
||||
//Knowing the image size ahead of time removes the
|
||||
//requirement of the output stream to support seeking.
|
||||
image.SaveAsPng(ms);
|
||||
|
||||
//Disposing of the BinaryWriter disposes the soutput stream. Let the caller clean up.
|
||||
var bw = new BinaryWriter(stream);
|
||||
|
||||
// Header
|
||||
bw.Write((short)0); // 0-1 : reserved
|
||||
bw.Write((short)1); // 2-3 : 1=ico, 2=cur
|
||||
bw.Write((short)1); // 4-5 : number of images
|
||||
|
||||
// Image directory
|
||||
var w = image.Width;
|
||||
if (w >= 256) w = 0;
|
||||
bw.Write((byte)w); // 0 : width of image
|
||||
var h = image.Height;
|
||||
if (h >= 256) h = 0;
|
||||
bw.Write((byte)h); // 1 : height of image
|
||||
bw.Write((byte)0); // 2 : number of colors in palette
|
||||
bw.Write((byte)0); // 3 : reserved
|
||||
bw.Write((short)0); // 4 : number of color planes
|
||||
bw.Write((short)0); // 6 : bits per pixel
|
||||
bw.Write((int)ms.Position); // 8 : image size
|
||||
bw.Write((int)stream.Position + 4); // 12: offset of image data
|
||||
ms.Position = 0;
|
||||
ms.CopyTo(stream); // Image data
|
||||
}
|
||||
|
||||
public Task EncodeAsync<TPixel>(Image<TPixel> image, Stream stream, CancellationToken cancellationToken) where TPixel : unmanaged, IPixel<TPixel>
|
||||
=> throw new NotImplementedException();
|
||||
}
|
||||
}
|
||||
41
Source/LibationUiBase/LastDownloadStatus.cs
Normal file
41
Source/LibationUiBase/LastDownloadStatus.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using DataLayer;
|
||||
using System;
|
||||
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public class LastDownloadStatus : IComparable
|
||||
{
|
||||
public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue;
|
||||
public Version LastDownloadedVersion { get; }
|
||||
public DateTime? LastDownloaded { get; }
|
||||
public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : "";
|
||||
|
||||
public LastDownloadStatus() { }
|
||||
public LastDownloadStatus(UserDefinedItem udi)
|
||||
{
|
||||
LastDownloadedVersion = udi.LastDownloadedVersion;
|
||||
LastDownloaded = udi.LastDownloaded;
|
||||
}
|
||||
|
||||
public void OpenReleaseUrl()
|
||||
{
|
||||
if (IsValid)
|
||||
Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion.ToString(3)}");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
=> IsValid ? $"{dateString()}\n\nLibation v{LastDownloadedVersion.ToString(3)}" : "";
|
||||
|
||||
//Call ToShortDateString to use current culture's date format.
|
||||
private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}";
|
||||
|
||||
public int CompareTo(object obj)
|
||||
{
|
||||
if (obj is not LastDownloadStatus second) return -1;
|
||||
else if (IsValid && !second.IsValid) return -1;
|
||||
else if (!IsValid && second.IsValid) return 1;
|
||||
else if (!IsValid && !second.IsValid) return 0;
|
||||
else return LastDownloaded.Value.CompareTo(second.LastDownloaded.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.ViewModels
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public enum QueuePosition
|
||||
{
|
||||
@@ -33,7 +32,7 @@ namespace LibationAvalonia.ViewModels
|
||||
* and is stored in ObservableCollection.Items. When the primary list changes, the
|
||||
* secondary list is cleared and reset to match the primary.
|
||||
*/
|
||||
public class TrackedQueue<T> : ObservableCollection<T> where T : class
|
||||
public class TrackedQueue<T> where T : class
|
||||
{
|
||||
public event EventHandler<int> CompletedCountChanged;
|
||||
public event EventHandler<int> QueuededCountChanged;
|
||||
@@ -47,6 +46,60 @@ namespace LibationAvalonia.ViewModels
|
||||
private readonly List<T> _completed = new();
|
||||
private readonly object lockObject = new();
|
||||
|
||||
private readonly ICollection<T> _underlyingList;
|
||||
|
||||
public TrackedQueue(ICollection<T> underlyingList = null)
|
||||
{
|
||||
_underlyingList = underlyingList;
|
||||
}
|
||||
|
||||
public T this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (index < _completed.Count)
|
||||
return _completed[index];
|
||||
index -= _completed.Count;
|
||||
|
||||
if (index == 0 && Current != null) return Current;
|
||||
|
||||
if (Current != null) index--;
|
||||
|
||||
if (index < _queued.Count) return _queued.ElementAt(index);
|
||||
|
||||
throw new IndexOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
return _queued.Count + _completed.Count + (Current == null ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int IndexOf(T item)
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_completed.Contains(item))
|
||||
return _completed.IndexOf(item);
|
||||
|
||||
if (Current == item) return _completed.Count;
|
||||
|
||||
if (_queued.Contains(item))
|
||||
return _queued.IndexOf(item) + (Current is null ? 0 : 1);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveQueued(T item)
|
||||
{
|
||||
bool itemsRemoved;
|
||||
@@ -188,29 +241,6 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryPeek(out T item)
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_queued.Count == 0)
|
||||
{
|
||||
item = null;
|
||||
return false;
|
||||
}
|
||||
item = _queued[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public T Peek()
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_queued.Count == 0) throw new InvalidOperationException("Queue empty");
|
||||
return _queued.Count > 0 ? _queued[0] : default;
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(IEnumerable<T> item)
|
||||
{
|
||||
int queueCount;
|
||||
@@ -220,15 +250,15 @@ namespace LibationAvalonia.ViewModels
|
||||
queueCount = _queued.Count;
|
||||
}
|
||||
foreach (var i in item)
|
||||
base.Add(i);
|
||||
_underlyingList?.Add(i);
|
||||
QueuededCountChanged?.Invoke(this, queueCount);
|
||||
}
|
||||
|
||||
private void RebuildSecondary()
|
||||
{
|
||||
base.ClearItems();
|
||||
_underlyingList?.Clear();
|
||||
foreach (var item in GetAllItems())
|
||||
base.Add(item);
|
||||
_underlyingList?.Add(item);
|
||||
}
|
||||
|
||||
public IEnumerable<T> GetAllItems()
|
||||
129
Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs
generated
Normal file
129
Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs
generated
Normal file
@@ -0,0 +1,129 @@
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
partial class TrashBinDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
deletedCbl = new System.Windows.Forms.CheckedListBox();
|
||||
label1 = new System.Windows.Forms.Label();
|
||||
restoreBtn = new System.Windows.Forms.Button();
|
||||
permanentlyDeleteBtn = new System.Windows.Forms.Button();
|
||||
everythingCb = new System.Windows.Forms.CheckBox();
|
||||
deletedCheckedLbl = new System.Windows.Forms.Label();
|
||||
SuspendLayout();
|
||||
//
|
||||
// deletedCbl
|
||||
//
|
||||
deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||
deletedCbl.FormattingEnabled = true;
|
||||
deletedCbl.Location = new System.Drawing.Point(12, 27);
|
||||
deletedCbl.Name = "deletedCbl";
|
||||
deletedCbl.Size = new System.Drawing.Size(776, 364);
|
||||
deletedCbl.TabIndex = 3;
|
||||
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
|
||||
//
|
||||
// label1
|
||||
//
|
||||
label1.AutoSize = true;
|
||||
label1.Location = new System.Drawing.Point(12, 9);
|
||||
label1.Name = "label1";
|
||||
label1.Size = new System.Drawing.Size(388, 15);
|
||||
label1.TabIndex = 4;
|
||||
label1.Text = "Check books you want to permanently delete from or restore to Libation";
|
||||
//
|
||||
// restoreBtn
|
||||
//
|
||||
restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
|
||||
restoreBtn.Location = new System.Drawing.Point(572, 398);
|
||||
restoreBtn.Name = "restoreBtn";
|
||||
restoreBtn.Size = new System.Drawing.Size(75, 40);
|
||||
restoreBtn.TabIndex = 5;
|
||||
restoreBtn.Text = "Restore";
|
||||
restoreBtn.UseVisualStyleBackColor = true;
|
||||
restoreBtn.Click += restoreBtn_Click;
|
||||
//
|
||||
// permanentlyDeleteBtn
|
||||
//
|
||||
permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
|
||||
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398);
|
||||
permanentlyDeleteBtn.Name = "permanentlyDeleteBtn";
|
||||
permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40);
|
||||
permanentlyDeleteBtn.TabIndex = 5;
|
||||
permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation";
|
||||
permanentlyDeleteBtn.UseVisualStyleBackColor = true;
|
||||
permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click;
|
||||
//
|
||||
// everythingCb
|
||||
//
|
||||
everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
|
||||
everythingCb.AutoSize = true;
|
||||
everythingCb.Location = new System.Drawing.Point(12, 410);
|
||||
everythingCb.Name = "everythingCb";
|
||||
everythingCb.Size = new System.Drawing.Size(82, 19);
|
||||
everythingCb.TabIndex = 6;
|
||||
everythingCb.Text = "Everything";
|
||||
everythingCb.ThreeState = true;
|
||||
everythingCb.UseVisualStyleBackColor = true;
|
||||
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
|
||||
//
|
||||
// deletedCheckedLbl
|
||||
//
|
||||
deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
|
||||
deletedCheckedLbl.AutoSize = true;
|
||||
deletedCheckedLbl.Location = new System.Drawing.Point(126, 411);
|
||||
deletedCheckedLbl.Name = "deletedCheckedLbl";
|
||||
deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
|
||||
deletedCheckedLbl.TabIndex = 7;
|
||||
deletedCheckedLbl.Text = "Checked: {0} of {1}";
|
||||
//
|
||||
// TrashBinDialog
|
||||
//
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
ClientSize = new System.Drawing.Size(800, 450);
|
||||
Controls.Add(deletedCheckedLbl);
|
||||
Controls.Add(everythingCb);
|
||||
Controls.Add(permanentlyDeleteBtn);
|
||||
Controls.Add(restoreBtn);
|
||||
Controls.Add(label1);
|
||||
Controls.Add(deletedCbl);
|
||||
Name = "TrashBinDialog";
|
||||
Text = "Trash Bin";
|
||||
ResumeLayout(false);
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.CheckedListBox deletedCbl;
|
||||
private System.Windows.Forms.Label label1;
|
||||
private System.Windows.Forms.Button restoreBtn;
|
||||
private System.Windows.Forms.Button permanentlyDeleteBtn;
|
||||
private System.Windows.Forms.CheckBox everythingCb;
|
||||
private System.Windows.Forms.Label deletedCheckedLbl;
|
||||
}
|
||||
}
|
||||
116
Source/LibationWinForms/Dialogs/TrashBinDialog.cs
Normal file
116
Source/LibationWinForms/Dialogs/TrashBinDialog.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using ApplicationServices;
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using System.Collections;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class TrashBinDialog : Form
|
||||
{
|
||||
private readonly string deletedCheckedTemplate;
|
||||
public TrashBinDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
this.SetLibationIcon();
|
||||
this.RestoreSizeAndLocation(Configuration.Instance);
|
||||
this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
|
||||
deletedCheckedTemplate = deletedCheckedLbl.Text;
|
||||
|
||||
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||
foreach (var lb in deletedBooks)
|
||||
deletedCbl.Items.Add(lb);
|
||||
|
||||
setLabel();
|
||||
}
|
||||
|
||||
private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e)
|
||||
{
|
||||
// CheckedItems.Count is not updated until after the event fires
|
||||
setLabel(e.NewValue);
|
||||
}
|
||||
|
||||
private async void permanentlyDeleteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
setControlsEnabled(false);
|
||||
|
||||
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||
|
||||
removeFromCheckList(removed);
|
||||
await Task.Run(removed.PermanentlyDeleteBooks);
|
||||
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
|
||||
private async void restoreBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
setControlsEnabled(false);
|
||||
|
||||
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||
|
||||
removeFromCheckList(removed);
|
||||
await Task.Run(removed.RestoreBooks);
|
||||
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
|
||||
private void removeFromCheckList(IEnumerable objects)
|
||||
{
|
||||
foreach (var o in objects)
|
||||
deletedCbl.Items.Remove(o);
|
||||
|
||||
deletedCbl.Refresh();
|
||||
setLabel();
|
||||
}
|
||||
|
||||
private void setControlsEnabled(bool enabled)
|
||||
=> restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled;
|
||||
|
||||
private void everythingCb_CheckStateChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (everythingCb.CheckState is CheckState.Indeterminate)
|
||||
{
|
||||
everythingCb.CheckState = CheckState.Unchecked;
|
||||
return;
|
||||
}
|
||||
|
||||
deletedCbl.ItemCheck -= deletedCbl_ItemCheck;
|
||||
|
||||
for (var i = 0; i < deletedCbl.Items.Count; i++)
|
||||
deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked);
|
||||
|
||||
setLabel();
|
||||
|
||||
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
|
||||
}
|
||||
|
||||
|
||||
private void setLabel(CheckState? checkedState = null)
|
||||
{
|
||||
var pre = deletedCbl.CheckedItems.Count;
|
||||
int count = checkedState switch
|
||||
{
|
||||
CheckState.Checked => pre + 1,
|
||||
CheckState.Unchecked => pre - 1,
|
||||
_ => pre,
|
||||
};
|
||||
|
||||
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
|
||||
|
||||
everythingCb.CheckState
|
||||
= count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked
|
||||
: count == 0 ? CheckState.Unchecked
|
||||
: CheckState.Indeterminate;
|
||||
|
||||
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
|
||||
|
||||
deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Source/LibationWinForms/Dialogs/TrashBinDialog.resx
Normal file
60
Source/LibationWinForms/Dialogs/TrashBinDialog.resx
Normal file
@@ -0,0 +1,60 @@
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -13,7 +13,6 @@ namespace LibationWinForms
|
||||
// init formattable
|
||||
beginBookBackupsToolStripMenuItem.Format(0);
|
||||
beginPdfBackupsToolStripMenuItem.Format(0);
|
||||
pdfsCountsLbl.Text = "| [Calculating backed up PDFs]";
|
||||
|
||||
Load += setBackupCounts;
|
||||
LibraryCommands.LibrarySizeChanged += setBackupCounts;
|
||||
@@ -21,9 +20,8 @@ namespace LibationWinForms
|
||||
|
||||
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
|
||||
updateCountsBw.RunWorkerCompleted += exportMenuEnable;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomStats;
|
||||
updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbers;
|
||||
updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem;
|
||||
}
|
||||
|
||||
@@ -52,27 +50,13 @@ namespace LibationWinForms
|
||||
Invoke(() => exportLibraryToolStripMenuItem.Enabled = libraryStats.HasBookResults);
|
||||
}
|
||||
|
||||
// this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text
|
||||
private const string backupsCountsLbl_Format = "BACKUPS: No progress: {0} In process: {1} Fully backed up: {2}";
|
||||
|
||||
private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
private void updateBottomStats(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
var formatString
|
||||
= !libraryStats.HasBookResults ? "No books. Begin by importing your library"
|
||||
: libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
|
||||
: libraryStats.HasPendingBooks ? backupsCountsLbl_Format
|
||||
: $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up";
|
||||
var statusStripText = string.Format(formatString,
|
||||
libraryStats.booksNoProgress,
|
||||
libraryStats.booksDownloadedOnly,
|
||||
libraryStats.booksFullyBackedUp,
|
||||
libraryStats.booksError);
|
||||
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = statusStripText);
|
||||
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = libraryStats.StatusString);
|
||||
}
|
||||
|
||||
// update 'begin book backups' menu item
|
||||
// update 'begin book and pdf backups' menu item
|
||||
private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
@@ -88,18 +72,6 @@ namespace LibationWinForms
|
||||
});
|
||||
}
|
||||
|
||||
private void updateBottomPdfNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
// don't need to assign the output of Format(). It just makes this logic cleaner
|
||||
var statusStripText
|
||||
= !libraryStats.HasPdfResults ? ""
|
||||
: libraryStats.pdfsNotDownloaded > 0 ? pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded)
|
||||
: $"| All {libraryStats.pdfsDownloaded} PDFs downloaded";
|
||||
statusStrip1.UIThreadAsync(() => pdfsCountsLbl.Text = statusStripText);
|
||||
}
|
||||
|
||||
// update 'begin pdf only backups' menu item
|
||||
private void udpate_BeginPdfOnlyBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
|
||||
24
Source/LibationWinForms/Form1.Designer.cs
generated
24
Source/LibationWinForms/Form1.Designer.cs
generated
@@ -63,6 +63,7 @@
|
||||
this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.openTrashBinToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -76,7 +77,6 @@
|
||||
this.visibleCountLbl = new LibationWinForms.FormattableToolStripStatusLabel();
|
||||
this.springLbl = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
this.backupsCountsLbl = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
this.pdfsCountsLbl = new LibationWinForms.FormattableToolStripStatusLabel();
|
||||
this.addQuickFilterBtn = new System.Windows.Forms.Button();
|
||||
this.splitContainer1 = new System.Windows.Forms.SplitContainer();
|
||||
this.panel1 = new System.Windows.Forms.Panel();
|
||||
@@ -383,8 +383,9 @@
|
||||
this.accountsToolStripMenuItem,
|
||||
this.basicSettingsToolStripMenuItem,
|
||||
this.toolStripSeparator4,
|
||||
this.openTrashBinToolStripMenuItem,
|
||||
this.launchHangoverToolStripMenuItem,
|
||||
this.toolStripSeparator2,
|
||||
this.toolStripSeparator2,
|
||||
this.aboutToolStripMenuItem});
|
||||
this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem";
|
||||
this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20);
|
||||
@@ -424,8 +425,7 @@
|
||||
this.upgradePb,
|
||||
this.visibleCountLbl,
|
||||
this.springLbl,
|
||||
this.backupsCountsLbl,
|
||||
this.pdfsCountsLbl});
|
||||
this.backupsCountsLbl});
|
||||
this.statusStrip1.Location = new System.Drawing.Point(0, 618);
|
||||
this.statusStrip1.Name = "statusStrip1";
|
||||
this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
|
||||
@@ -464,13 +464,6 @@
|
||||
this.backupsCountsLbl.Size = new System.Drawing.Size(218, 17);
|
||||
this.backupsCountsLbl.Text = "[Calculating backed up book quantities]";
|
||||
//
|
||||
// pdfsCountsLbl
|
||||
//
|
||||
this.pdfsCountsLbl.FormatText = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}";
|
||||
this.pdfsCountsLbl.Name = "pdfsCountsLbl";
|
||||
this.pdfsCountsLbl.Size = new System.Drawing.Size(218, 17);
|
||||
this.pdfsCountsLbl.Text = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}";
|
||||
//
|
||||
// addQuickFilterBtn
|
||||
//
|
||||
this.addQuickFilterBtn.Location = new System.Drawing.Point(50, 3);
|
||||
@@ -592,6 +585,13 @@
|
||||
this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks";
|
||||
this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click);
|
||||
//
|
||||
// openTrashBinToolStripMenuItem
|
||||
//
|
||||
this.openTrashBinToolStripMenuItem.Name = "openTrashBinToolStripMenuItem";
|
||||
this.openTrashBinToolStripMenuItem.Size = new System.Drawing.Size(247, 22);
|
||||
this.openTrashBinToolStripMenuItem.Text = "Trash Bin";
|
||||
this.openTrashBinToolStripMenuItem.Click += new System.EventHandler(this.openTrashBinToolStripMenuItem_Click);
|
||||
//
|
||||
// launchHangoverToolStripMenuItem
|
||||
//
|
||||
this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem";
|
||||
@@ -640,7 +640,6 @@
|
||||
private System.Windows.Forms.ToolStripMenuItem liberateToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripStatusLabel backupsCountsLbl;
|
||||
private LibationWinForms.FormattableToolStripMenuItem beginBookBackupsToolStripMenuItem;
|
||||
private LibationWinForms.FormattableToolStripStatusLabel pdfsCountsLbl;
|
||||
private LibationWinForms.FormattableToolStripMenuItem beginPdfBackupsToolStripMenuItem;
|
||||
private System.Windows.Forms.TextBox filterSearchTb;
|
||||
private System.Windows.Forms.Button filterBtn;
|
||||
@@ -676,6 +675,7 @@
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
||||
private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
||||
private System.Windows.Forms.ToolStripMenuItem openTrashBinToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem;
|
||||
private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu;
|
||||
private System.Windows.Forms.SplitContainer splitContainer1;
|
||||
|
||||
@@ -16,6 +16,9 @@ namespace LibationWinForms
|
||||
private async void removeBooksBtn_Click(object sender, EventArgs e)
|
||||
=> await productsDisplay.RemoveCheckedBooksAsync();
|
||||
|
||||
private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
=> new TrashBinDialog().ShowDialog(this);
|
||||
|
||||
private void doneRemovingBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
removeBooksBtn.Visible = false;
|
||||
|
||||
@@ -27,25 +27,22 @@ namespace LibationWinForms
|
||||
=> await Task.Run(setLiberatedVisibleMenuItem);
|
||||
void setLiberatedVisibleMenuItem()
|
||||
{
|
||||
var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
|
||||
var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible());
|
||||
this.UIThreadSync(() =>
|
||||
{
|
||||
if (notLiberated > 0)
|
||||
if (libraryStats.HasPendingBooks)
|
||||
{
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(notLiberated);
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = true;
|
||||
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Format(notLiberated);
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = true;
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(libraryStats.PendingBooks);
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Format(libraryStats.PendingBooks);
|
||||
}
|
||||
else
|
||||
{
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Text = "All visible books are liberated";
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = false;
|
||||
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Text = "All visible books are liberated";
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = false;
|
||||
}
|
||||
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = libraryStats.HasPendingBooks;
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = libraryStats.HasPendingBooks;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,11 +165,8 @@ namespace LibationWinForms
|
||||
"Are you sure you want to remove {0} from Libation's library?",
|
||||
"Remove books from Libation?");
|
||||
|
||||
if (confirmationResult != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
await LibraryCommands.RemoveBooksAsync(visibleIds);
|
||||
if (confirmationResult is DialogResult.Yes)
|
||||
await visibleLibraryBooks.RemoveBooksAsync();
|
||||
}
|
||||
|
||||
private async void productsDisplay_VisibleCountChanged(object sender, int qty)
|
||||
@@ -183,9 +177,6 @@ namespace LibationWinForms
|
||||
// top menu strip
|
||||
visibleBooksToolStripMenuItem.Format(qty);
|
||||
visibleBooksToolStripMenuItem.Enabled = qty > 0;
|
||||
|
||||
//Not used for anything?
|
||||
var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
|
||||
|
||||
await Task.Run(setLiberatedVisibleMenuItem);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,6 @@ namespace LibationWinForms.GridView
|
||||
// per standard INotifyPropertyChanged pattern:
|
||||
// https://docs.microsoft.com/en-us/dotnet/desktop/wpf/data/how-to-implement-property-change-notification
|
||||
public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
|
||||
=> this.UIThreadAsync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
|
||||
=> this.UIThreadSync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ namespace LibationWinForms.GridView
|
||||
public string Narrators { get; protected set; }
|
||||
public string Category { get; protected set; }
|
||||
public string Misc { get; protected set; }
|
||||
public virtual LastDownloadStatus LastDownload { get; protected set; } = new();
|
||||
public string Description { get; protected set; }
|
||||
public string ProductRating { get; protected set; }
|
||||
protected Rating _myRating;
|
||||
@@ -120,6 +121,7 @@ namespace LibationWinForms.GridView
|
||||
{ typeof(bool), new ObjectComparer<bool>() },
|
||||
{ typeof(DateTime), new ObjectComparer<DateTime>() },
|
||||
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
|
||||
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
|
||||
};
|
||||
|
||||
#endregion
|
||||
@@ -136,7 +138,7 @@ namespace LibationWinForms.GridView
|
||||
PictureStorage.PictureCached += PictureStorage_PictureCached;
|
||||
|
||||
// Mutable property. Set the field so PropertyChanged isn't fired.
|
||||
_cover = ImageReader.ToImage(picture);
|
||||
_cover = loadImage(picture);
|
||||
}
|
||||
|
||||
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
|
||||
@@ -152,11 +154,25 @@ namespace LibationWinForms.GridView
|
||||
// logic validation
|
||||
if (e.Definition.PictureId == Book.PictureId)
|
||||
{
|
||||
Cover = ImageReader.ToImage(e.Picture);
|
||||
Cover = loadImage(e.Picture);
|
||||
PictureStorage.PictureCached -= PictureStorage_PictureCached;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private Image loadImage(byte[] picture)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ImageReader.ToImage(picture);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error loading cover art for {Book}", Book);
|
||||
return Properties.Resources.default_cover_80x80;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Static library display functions
|
||||
|
||||
@@ -9,10 +9,6 @@ namespace LibationWinForms.GridView
|
||||
{
|
||||
public string PictureFileName { get; set; }
|
||||
public string BookSaveDirectory { get; set; }
|
||||
public byte[] CoverPicture { get => _coverBytes; set => pictureBox1.Image = Dinah.Core.WindowsDesktop.Drawing.ImageReader.ToImage(_coverBytes = value); }
|
||||
|
||||
private byte[] _coverBytes;
|
||||
|
||||
|
||||
public ImageDisplay()
|
||||
{
|
||||
@@ -21,6 +17,19 @@ namespace LibationWinForms.GridView
|
||||
lastHeight = Height;
|
||||
}
|
||||
|
||||
public void SetCoverArt(byte[] cover)
|
||||
{
|
||||
try
|
||||
{
|
||||
pictureBox1.Image = Dinah.Core.WindowsDesktop.Drawing.ImageReader.ToImage(cover);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error loading cover art for {file}", PictureFileName);
|
||||
pictureBox1.Image = Properties.Resources.default_cover_500x500;
|
||||
}
|
||||
}
|
||||
|
||||
#region Make the form's aspect ratio always match the picture's aspect ratio.
|
||||
|
||||
private bool detectedResizeDirection = false;
|
||||
@@ -106,7 +115,7 @@ namespace LibationWinForms.GridView
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(saveFileDialog.FileName, CoverPicture);
|
||||
pictureBox1.Image.Save(saveFileDialog.FileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,15 @@ namespace LibationWinForms.GridView
|
||||
public LiberatedStatus BookStatus { get; set; }
|
||||
public LiberatedStatus? PdfStatus { get; set; }
|
||||
public bool Expanded { get; set; }
|
||||
public bool IsSeries { get; init; }
|
||||
public bool IsSeries { get; }
|
||||
private bool IsAbsent { get; }
|
||||
public bool IsUnavailable => !IsSeries & IsAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
|
||||
|
||||
public LiberateButtonStatus(bool isSeries, bool isAbsent)
|
||||
{
|
||||
IsSeries = isSeries;
|
||||
IsAbsent = isAbsent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Defines the Liberate column's sorting behavior
|
||||
@@ -20,6 +28,8 @@ namespace LibationWinForms.GridView
|
||||
if (IsSeries && !second.IsSeries) return -1;
|
||||
else if (!IsSeries && second.IsSeries) return 1;
|
||||
else if (IsSeries && second.IsSeries) return 0;
|
||||
else if (IsUnavailable && !second.IsUnavailable) return 1;
|
||||
else if (!IsUnavailable && second.IsUnavailable) return -1;
|
||||
else if (BookStatus == LiberatedStatus.Liberated && second.BookStatus != LiberatedStatus.Liberated) return -1;
|
||||
else if (BookStatus != LiberatedStatus.Liberated && second.BookStatus == LiberatedStatus.Liberated) return 1;
|
||||
else return BookStatus.CompareTo(second.BookStatus);
|
||||
|
||||
@@ -17,11 +17,14 @@ namespace LibationWinForms.GridView
|
||||
internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell
|
||||
{
|
||||
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
|
||||
private static readonly Brush DISABLED_GRAY = new SolidBrush(Color.FromArgb(0x60, Color.LightGray));
|
||||
protected override void Paint(Graphics graphics, Rectangle clipBounds, Rectangle cellBounds, int rowIndex, DataGridViewElementStates elementState, object value, object formattedValue, string errorText, DataGridViewCellStyle cellStyle, DataGridViewAdvancedBorderStyle advancedBorderStyle, DataGridViewPaintParts paintParts)
|
||||
{
|
||||
if (value is LiberateButtonStatus status)
|
||||
{
|
||||
if (status.BookStatus is LiberatedStatus.Error)
|
||||
|
||||
if (status.BookStatus is LiberatedStatus.Error || status.IsUnavailable)
|
||||
//Don't paint the button graphic
|
||||
paintParts ^= DataGridViewPaintParts.ContentBackground | DataGridViewPaintParts.ContentForeground | DataGridViewPaintParts.SelectionBackground;
|
||||
|
||||
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null)
|
||||
@@ -41,7 +44,14 @@ namespace LibationWinForms.GridView
|
||||
|
||||
DrawButtonImage(graphics, buttonImage, cellBounds);
|
||||
|
||||
ToolTipText = mouseoverText;
|
||||
if (status.IsUnavailable)
|
||||
{
|
||||
//Create the "disabled" look by painting a transparent gray box over the buttom image.
|
||||
graphics.FillRectangle(DISABLED_GRAY, cellBounds);
|
||||
ToolTipText = "This book cannot be downloaded\r\nbecause it wasn't found during\r\nthe most recent library scan";
|
||||
}
|
||||
else
|
||||
ToolTipText = mouseoverText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
@@ -24,6 +25,8 @@ namespace LibationWinForms.GridView
|
||||
private LiberatedStatus _bookStatus;
|
||||
private LiberatedStatus? _pdfStatus;
|
||||
|
||||
public override LastDownloadStatus LastDownload { get; protected set; }
|
||||
|
||||
public override RemoveStatus Remove
|
||||
{
|
||||
get
|
||||
@@ -49,7 +52,7 @@ namespace LibationWinForms.GridView
|
||||
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
|
||||
lastStatusUpdate = DateTime.Now;
|
||||
}
|
||||
return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
|
||||
return new LiberateButtonStatus(isSeries: false, LibraryBook.AbsentFromLastScan) { BookStatus = _bookStatus, PdfStatus = _pdfStatus };
|
||||
}
|
||||
}
|
||||
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
|
||||
@@ -87,6 +90,7 @@ namespace LibationWinForms.GridView
|
||||
Narrators = Book.NarratorNames();
|
||||
Category = string.Join(" > ", Book.CategoriesNames());
|
||||
Misc = GetMiscDisplay(libraryBook);
|
||||
LastDownload = new(Book.UserDefinedItem);
|
||||
LongDescription = GetDescriptionDisplay(Book);
|
||||
Description = TrimTextToWord(LongDescription, 62);
|
||||
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
|
||||
@@ -126,6 +130,10 @@ namespace LibationWinForms.GridView
|
||||
_pdfStatus = udi.PdfStatus;
|
||||
NotifyPropertyChanged(nameof(Liberate));
|
||||
break;
|
||||
case nameof(udi.LastDownloaded):
|
||||
LastDownload = new(udi);
|
||||
NotifyPropertyChanged(nameof(LastDownload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,6 +161,7 @@ namespace LibationWinForms.GridView
|
||||
{ nameof(Description), () => Description },
|
||||
{ nameof(Category), () => Category },
|
||||
{ nameof(Misc), () => Misc },
|
||||
{ nameof(LastDownload), () => LastDownload },
|
||||
{ nameof(DisplayTags), () => DisplayTags },
|
||||
{ nameof(Liberate), () => Liberate },
|
||||
{ nameof(DateAdded), () => DateAdded },
|
||||
|
||||
@@ -39,7 +39,7 @@ namespace LibationWinForms.GridView
|
||||
void PictureCached(object sender, PictureCachedEventArgs e)
|
||||
{
|
||||
if (e.Definition.PictureId == picDef.PictureId)
|
||||
imageDisplay.CoverPicture = e.Picture;
|
||||
imageDisplay.SetCoverArt(e.Picture);
|
||||
|
||||
PictureStorage.PictureCached -= PictureCached;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ namespace LibationWinForms.GridView
|
||||
|
||||
if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible)
|
||||
{
|
||||
imageDisplay = new GridView.ImageDisplay();
|
||||
imageDisplay = new ImageDisplay();
|
||||
imageDisplay.RestoreSizeAndLocation(Configuration.Instance);
|
||||
imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
@@ -59,7 +59,7 @@ namespace LibationWinForms.GridView
|
||||
imageDisplay.BookSaveDirectory = AudibleFileStorage.Audio.GetDestinationDirectory(liveGridEntry.LibraryBook);
|
||||
imageDisplay.PictureFileName = System.IO.Path.GetFileName(AudibleFileStorage.Audio.GetBooksDirectoryFilename(liveGridEntry.LibraryBook, ".jpg"));
|
||||
imageDisplay.Text = windowTitle;
|
||||
imageDisplay.CoverPicture = initialImageBts;
|
||||
imageDisplay.SetCoverArt(initialImageBts);
|
||||
if (!isDefault)
|
||||
PictureStorage.PictureCached -= PictureCached;
|
||||
|
||||
@@ -107,9 +107,9 @@ namespace LibationWinForms.GridView
|
||||
if (selectedBooks.Count == 0)
|
||||
return;
|
||||
|
||||
var libraryBooks = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||
var booksToRemove = selectedBooks.Select(rge => rge.LibraryBook).ToList();
|
||||
var result = MessageBoxLib.ShowConfirmationDialog(
|
||||
libraryBooks,
|
||||
booksToRemove,
|
||||
// do not use `$` string interpolation. See impl.
|
||||
"Are you sure you want to remove {0} from Libation's library?",
|
||||
"Remove books from Libation?");
|
||||
@@ -118,8 +118,7 @@ namespace LibationWinForms.GridView
|
||||
return;
|
||||
|
||||
productsGrid.RemoveBooks(selectedBooks);
|
||||
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
|
||||
await booksToRemove.RemoveBooksAsync();
|
||||
}
|
||||
|
||||
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
|
||||
@@ -201,7 +200,8 @@ namespace LibationWinForms.GridView
|
||||
|
||||
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
|
||||
{
|
||||
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
|
||||
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error
|
||||
&& !liveGridEntry.Liberate.IsUnavailable)
|
||||
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.myRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
|
||||
this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
|
||||
this.lastDownloadedGVColumn = new LastDownloadedGridViewColumn();
|
||||
this.tagAndDetailsGVColumn = new LibationWinForms.GridView.EditTagsDataGridViewImageButtonColumn();
|
||||
this.showHideColumnsContextMenuStrip = new System.Windows.Forms.ContextMenuStrip(this.components);
|
||||
this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
|
||||
@@ -75,7 +76,8 @@
|
||||
this.purchaseDateGVColumn,
|
||||
this.myRatingGVColumn,
|
||||
this.miscGVColumn,
|
||||
this.tagAndDetailsGVColumn});
|
||||
this.lastDownloadedGVColumn,
|
||||
this.tagAndDetailsGVColumn});
|
||||
this.gridEntryDataGridView.ContextMenuStrip = this.showHideColumnsContextMenuStrip;
|
||||
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
|
||||
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
|
||||
@@ -216,6 +218,15 @@
|
||||
this.miscGVColumn.ReadOnly = true;
|
||||
this.miscGVColumn.Width = 135;
|
||||
//
|
||||
// lastDownloadedGVColumn
|
||||
//
|
||||
this.lastDownloadedGVColumn.DataPropertyName = "LastDownload";
|
||||
this.lastDownloadedGVColumn.HeaderText = "Last Download";
|
||||
this.lastDownloadedGVColumn.Name = "lastDownloadedGVColumn";
|
||||
this.lastDownloadedGVColumn.ReadOnly = true;
|
||||
this.lastDownloadedGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
|
||||
this.lastDownloadedGVColumn.Width = 108;
|
||||
//
|
||||
// tagAndDetailsGVColumn
|
||||
//
|
||||
this.tagAndDetailsGVColumn.DataPropertyName = "DisplayTags";
|
||||
@@ -268,6 +279,7 @@
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn;
|
||||
private MyRatingGridViewColumn myRatingGVColumn;
|
||||
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
|
||||
private LastDownloadedGridViewColumn lastDownloadedGVColumn;
|
||||
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +154,7 @@ namespace LibationWinForms.GridView
|
||||
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||
|
||||
var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" };
|
||||
removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId));
|
||||
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
|
||||
|
||||
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
|
||||
locateFileMenuItem.Click += (_, __) =>
|
||||
@@ -180,7 +180,7 @@ namespace LibationWinForms.GridView
|
||||
var convertToMp3MenuItem = new ToolStripMenuItem
|
||||
{
|
||||
Text = "&Convert to Mp3",
|
||||
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
|
||||
Enabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
|
||||
};
|
||||
convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as LibraryBookEntry);
|
||||
|
||||
|
||||
@@ -62,7 +62,7 @@ namespace LibationWinForms.GridView
|
||||
|
||||
private SeriesEntry(LibraryBook parent)
|
||||
{
|
||||
Liberate = new LiberateButtonStatus { IsSeries = true };
|
||||
Liberate = new LiberateButtonStatus(isSeries: true, isAbsent: false);
|
||||
SeriesIndex = -1;
|
||||
LibraryBook = parent;
|
||||
LoadCover();
|
||||
@@ -122,6 +122,7 @@ namespace LibationWinForms.GridView
|
||||
{ nameof(Description), () => Description },
|
||||
{ nameof(Category), () => Category },
|
||||
{ nameof(Misc), () => Misc },
|
||||
{ nameof(LastDownload), () => LastDownload },
|
||||
{ nameof(DisplayTags), () => string.Empty },
|
||||
{ nameof(Liberate), () => Liberate },
|
||||
{ nameof(DateAdded), () => DateAdded },
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationWinForms.ProcessQueue
|
||||
{
|
||||
public enum QueuePosition
|
||||
{
|
||||
Fisrt,
|
||||
OneUp,
|
||||
OneDown,
|
||||
Last
|
||||
}
|
||||
|
||||
/*
|
||||
* This data structure is like lifting a metal chain one link at a time.
|
||||
* Each time you grab and lift a new link (MoveNext call):
|
||||
*
|
||||
* 1) you're holding a new link in your hand (Current)
|
||||
* 2) the remaining chain to be lifted shortens by 1 link (Queued)
|
||||
* 3) the pile of chain at your feet grows by 1 link (Completed)
|
||||
*
|
||||
* The index is the link position from the first link you lifted to the
|
||||
* last one in the chain.
|
||||
*/
|
||||
public class TrackedQueue<T> where T : class
|
||||
{
|
||||
public event EventHandler<int> CompletedCountChanged;
|
||||
public event EventHandler<int> QueuededCountChanged;
|
||||
public T Current { get; private set; }
|
||||
|
||||
public IReadOnlyList<T> Queued => _queued;
|
||||
public IReadOnlyList<T> Completed => _completed;
|
||||
|
||||
private readonly List<T> _queued = new();
|
||||
private readonly List<T> _completed = new();
|
||||
private readonly object lockObject = new();
|
||||
|
||||
public T this[int index]
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (index < _completed.Count)
|
||||
return _completed[index];
|
||||
index -= _completed.Count;
|
||||
|
||||
if (index == 0 && Current != null) return Current;
|
||||
|
||||
if (Current != null) index--;
|
||||
|
||||
if (index < _queued.Count) return _queued.ElementAt(index);
|
||||
|
||||
throw new IndexOutOfRangeException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
return _queued.Count + _completed.Count + (Current == null ? 0 : 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public int IndexOf(T item)
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_completed.Contains(item))
|
||||
return _completed.IndexOf(item);
|
||||
|
||||
if (Current == item) return _completed.Count;
|
||||
|
||||
if (_queued.Contains(item))
|
||||
return _queued.IndexOf(item) + (Current is null ? 0 : 1);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
public bool RemoveQueued(T item)
|
||||
{
|
||||
bool itemsRemoved;
|
||||
int queuedCount;
|
||||
|
||||
lock (lockObject)
|
||||
{
|
||||
itemsRemoved = _queued.Remove(item);
|
||||
queuedCount = _queued.Count;
|
||||
}
|
||||
|
||||
if (itemsRemoved)
|
||||
QueuededCountChanged?.Invoke(this, queuedCount);
|
||||
return itemsRemoved;
|
||||
}
|
||||
|
||||
public void ClearCurrent()
|
||||
{
|
||||
lock(lockObject)
|
||||
Current = null;
|
||||
}
|
||||
|
||||
public bool RemoveCompleted(T item)
|
||||
{
|
||||
bool itemsRemoved;
|
||||
int completedCount;
|
||||
|
||||
lock (lockObject)
|
||||
{
|
||||
itemsRemoved = _completed.Remove(item);
|
||||
completedCount = _completed.Count;
|
||||
}
|
||||
|
||||
if (itemsRemoved)
|
||||
CompletedCountChanged?.Invoke(this, completedCount);
|
||||
return itemsRemoved;
|
||||
}
|
||||
|
||||
public void ClearQueue()
|
||||
{
|
||||
lock (lockObject)
|
||||
_queued.Clear();
|
||||
QueuededCountChanged?.Invoke(this, 0);
|
||||
}
|
||||
|
||||
public void ClearCompleted()
|
||||
{
|
||||
lock (lockObject)
|
||||
_completed.Clear();
|
||||
CompletedCountChanged?.Invoke(this, 0);
|
||||
}
|
||||
|
||||
public bool Any(Func<T, bool> predicate)
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
return (Current != null && predicate(Current)) || _completed.Any(predicate) || _queued.Any(predicate);
|
||||
}
|
||||
}
|
||||
|
||||
public void MoveQueuePosition(T item, QueuePosition requestedPosition)
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_queued.Count == 0 || !_queued.Contains(item)) return;
|
||||
|
||||
if ((requestedPosition == QueuePosition.Fisrt || requestedPosition == QueuePosition.OneUp) && _queued[0] == item)
|
||||
return;
|
||||
if ((requestedPosition == QueuePosition.Last || requestedPosition == QueuePosition.OneDown) && _queued[^1] == item)
|
||||
return;
|
||||
|
||||
int queueIndex = _queued.IndexOf(item);
|
||||
|
||||
if (requestedPosition == QueuePosition.OneUp)
|
||||
{
|
||||
_queued.RemoveAt(queueIndex);
|
||||
_queued.Insert(queueIndex - 1, item);
|
||||
}
|
||||
else if (requestedPosition == QueuePosition.OneDown)
|
||||
{
|
||||
_queued.RemoveAt(queueIndex);
|
||||
_queued.Insert(queueIndex + 1, item);
|
||||
}
|
||||
else if (requestedPosition == QueuePosition.Fisrt)
|
||||
{
|
||||
_queued.RemoveAt(queueIndex);
|
||||
_queued.Insert(0, item);
|
||||
}
|
||||
else
|
||||
{
|
||||
_queued.RemoveAt(queueIndex);
|
||||
_queued.Insert(_queued.Count, item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public bool MoveNext()
|
||||
{
|
||||
int completedCount = 0, queuedCount = 0;
|
||||
bool completedChanged = false;
|
||||
try
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (Current != null)
|
||||
{
|
||||
_completed.Add(Current);
|
||||
completedCount = _completed.Count;
|
||||
completedChanged = true;
|
||||
}
|
||||
if (_queued.Count == 0)
|
||||
{
|
||||
Current = null;
|
||||
return false;
|
||||
}
|
||||
Current = _queued[0];
|
||||
_queued.RemoveAt(0);
|
||||
|
||||
queuedCount = _queued.Count;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (completedChanged)
|
||||
CompletedCountChanged?.Invoke(this, completedCount);
|
||||
QueuededCountChanged?.Invoke(this, queuedCount);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryPeek(out T item)
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_queued.Count == 0)
|
||||
{
|
||||
item = null;
|
||||
return false;
|
||||
}
|
||||
item = _queued[0];
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
public T Peek()
|
||||
{
|
||||
lock (lockObject)
|
||||
{
|
||||
if (_queued.Count == 0) throw new InvalidOperationException("Queue empty");
|
||||
return _queued.Count > 0 ? _queued[0] : default;
|
||||
}
|
||||
}
|
||||
|
||||
public void Enqueue(IEnumerable<T> item)
|
||||
{
|
||||
int queueCount;
|
||||
lock (lockObject)
|
||||
{
|
||||
_queued.AddRange(item);
|
||||
queueCount = _queued.Count;
|
||||
}
|
||||
QueuededCountChanged?.Invoke(this, queueCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="fileicon">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Info.plist">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationFileManager;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace MacOSConfigApp
|
||||
@@ -9,8 +10,14 @@ namespace MacOSConfigApp
|
||||
public MacOSInterop() { }
|
||||
public MacOSInterop(params object[] values) { }
|
||||
|
||||
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
|
||||
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
|
||||
public void SetFolderIcon(string image, string directory)
|
||||
{
|
||||
Process.Start("fileicon", $"set {directory.SurroundWithQuotes()} {image.SurroundWithQuotes()}").WaitForExit();
|
||||
}
|
||||
public void DeleteFolderIcon(string directory)
|
||||
{
|
||||
Process.Start("fileicon", $"rm {directory.SurroundWithQuotes()}").WaitForExit();
|
||||
}
|
||||
|
||||
//I haven't figured out how to find the app bundle's directory from within
|
||||
//the running process, so don't upgrade unless it's "installed" in /Applications
|
||||
@@ -21,7 +28,7 @@ namespace MacOSConfigApp
|
||||
Serilog.Log.Information($"Extracting upgrade bundle to {AppPath}");
|
||||
|
||||
//tar wil overwrite existing without elevated privileges
|
||||
Process.Start("tar", $"-xf \"{upgradeBundle}\" -C \"/Applications\"").WaitForExit();
|
||||
Process.Start("tar", $"-xf {upgradeBundle.SurroundWithQuotes()} -C \"/Applications\"").WaitForExit();
|
||||
|
||||
//For now, it seems like this step is unnecessary. We can overwrite and
|
||||
//run Libation without needing to re-add the exception. This is insurance.
|
||||
|
||||
732
Source/LoadByOS/MacOSConfigApp/fileicon
Normal file
732
Source/LoadByOS/MacOSConfigApp/fileicon
Normal file
@@ -0,0 +1,732 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
###
|
||||
# Home page: https://github.com/mklement0/fileicon
|
||||
# Author: Michael Klement <mklement0@gmail.com> (http://same2u.net)
|
||||
# Invoke with:
|
||||
# --version for version information
|
||||
# --help for usage information
|
||||
###
|
||||
|
||||
# --- STANDARD SCRIPT-GLOBAL CONSTANTS
|
||||
|
||||
kTHIS_NAME=${BASH_SOURCE##*/}
|
||||
kTHIS_HOMEPAGE='https://github.com/mklement0/fileicon'
|
||||
kTHIS_VERSION='v0.3.3' # NOTE: This assignment is automatically updated by `make version VER=<newVer>` - DO keep the 'v' prefix.
|
||||
|
||||
unset CDPATH # To prevent unpredictable `cd` behavior.
|
||||
|
||||
# --- Begin: STANDARD HELPER FUNCTIONS
|
||||
|
||||
die() { echo "$kTHIS_NAME: ERROR: ${1:-"ABORTING due to unexpected error."}" 1>&2; exit ${2:-1}; }
|
||||
dieSyntax() { echo "$kTHIS_NAME: ARGUMENT ERROR: ${1:-"Invalid argument(s) specified."} Use -h for help." 1>&2; exit 2; }
|
||||
|
||||
# SYNOPSIS
|
||||
# openUrl <url>
|
||||
# DESCRIPTION
|
||||
# Opens the specified URL in the system's default browser.
|
||||
openUrl() {
|
||||
local url=$1 platform=$(uname) cmd=()
|
||||
case $platform in
|
||||
'Darwin') # OSX
|
||||
cmd=( open "$url" )
|
||||
;;
|
||||
'CYGWIN_'*) # Cygwin on Windows; must call cmd.exe with its `start` builtin
|
||||
cmd=( cmd.exe /c start '' "$url " ) # !! Note the required trailing space.
|
||||
;;
|
||||
'MINGW32_'*) # MSYS or Git Bash on Windows; they come with a Unix `start` binary
|
||||
cmd=( start '' "$url" )
|
||||
;;
|
||||
*) # Otherwise, assume a Freedesktop-compliant OS, which includes many Linux distros, PC-BSD, OpenSolaris, ...
|
||||
cmd=( xdg-open "$url" )
|
||||
;;
|
||||
esac
|
||||
"${cmd[@]}" || { echo "Cannot locate or failed to open default browser; please go to '$url' manually." >&2; return 1; }
|
||||
}
|
||||
|
||||
# Prints the embedded Markdown-formatted man-page source to stdout.
|
||||
printManPageSource() {
|
||||
/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/ { s///; t\np;}' "$BASH_SOURCE"
|
||||
}
|
||||
|
||||
# Opens the man page, if installed; otherwise, tries to display the embedded Markdown-formatted man-page source; if all else fails: tries to display the man page online.
|
||||
openManPage() {
|
||||
local pager embeddedText
|
||||
if ! man 1 "$kTHIS_NAME" 2>/dev/null; then
|
||||
# 2nd attempt: if present, display the embedded Markdown-formatted man-page source
|
||||
embeddedText=$(printManPageSource)
|
||||
if [[ -n $embeddedText ]]; then
|
||||
pager='more'
|
||||
command -v less &>/dev/null && pager='less' # see if the non-standard `less` is available, because it's preferable to the POSIX utility `more`
|
||||
printf '%s\n' "$embeddedText" | "$pager"
|
||||
else # 3rd attempt: open the the man page on the utility's website
|
||||
openUrl "${kTHIS_HOMEPAGE}/doc/${kTHIS_NAME}.md"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# Prints the contents of the synopsis chapter of the embedded Markdown-formatted man-page source for quick reference.
|
||||
printUsage() {
|
||||
local embeddedText
|
||||
# Extract usage information from the SYNOPSIS chapter of the embedded Markdown-formatted man-page source.
|
||||
embeddedText=$(/usr/bin/sed -n -e $'/^: <<\'EOF_MAN_PAGE\'/,/^EOF_MAN_PAGE/!d; /^## SYNOPSIS$/,/^#/{ s///; t\np; }' "$BASH_SOURCE")
|
||||
if [[ -n $embeddedText ]]; then
|
||||
# Print extracted synopsis chapter - remove backticks for uncluttered display.
|
||||
printf '%s\n\n' "$embeddedText" | tr -d '`'
|
||||
else # No SYNOPIS chapter found; fall back to displaying the man page.
|
||||
echo "WARNING: usage information not found; opening man page instead." >&2
|
||||
openManPage
|
||||
fi
|
||||
}
|
||||
|
||||
# --- End: STANDARD HELPER FUNCTIONS
|
||||
|
||||
# --- PROCESS STANDARD, OUTPUT-INFO-THEN-EXIT OPTIONS.
|
||||
case $1 in
|
||||
--version)
|
||||
# Output version number and exit, if requested.
|
||||
ver="v0.3.3"; echo "$kTHIS_NAME $kTHIS_VERSION"$'\nFor license information and more, visit '"$kTHIS_HOMEPAGE"; exit 0
|
||||
;;
|
||||
-h|--help)
|
||||
# Print usage information and exit.
|
||||
printUsage; exit
|
||||
;;
|
||||
--man)
|
||||
# Display the manual page and exit.
|
||||
openManPage; exit
|
||||
;;
|
||||
--man-source) # private option, used by `make update-doc`
|
||||
# Print raw, embedded Markdown-formatted man-page source and exit
|
||||
printManPageSource; exit
|
||||
;;
|
||||
--home)
|
||||
# Open the home page and exit.
|
||||
openUrl "$kTHIS_HOMEPAGE"; exit
|
||||
;;
|
||||
esac
|
||||
|
||||
# --- Begin: SPECIFIC HELPER FUNCTIONS
|
||||
|
||||
# NOTE: The functions below operate on byte strings such as the one above:
|
||||
# A single single string of pairs of hex digits, without separators or line breaks.
|
||||
# Thus, a given byte position is easily calculated: to get byte $byteIndex, use
|
||||
# ${byteString:byteIndex*2:2}
|
||||
|
||||
# Outputs the specified EXTENDED ATTRIBUTE VALUE as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000A2C".
|
||||
# IMPORTANT: Hex. digits > 9 use UPPPERCASE characters.
|
||||
# getAttribByteString <file> <attrib_name>
|
||||
getAttribByteString() {
|
||||
/usr/bin/xattr -px "$2" "$1" | tr -d ' \n'
|
||||
return ${PIPESTATUS[0]}
|
||||
}
|
||||
|
||||
# Outputs the specified file's RESOURCE FORK as a byte string (a hex dump that is a single-line string of pairs of hex digits, without separators or line breaks, such as "000a2c".
|
||||
# IMPORTANT: Hex. digits > 9 use *lowercase* characters.
|
||||
# Note: This function relies on `xxd -p <file>/..namedfork/rsrc | tr -d '\n'` rather than the conceptually equivalent call,
|
||||
# `getAttribByteString <file> com.apple.ResourceFork`, for PERFORMANCE reasons:
|
||||
# getAttribByteString() (defined above) relies on `xattr`, which is a *Python* script [!! seemingly no longer, as of macOS 10.16]
|
||||
# and therefore quite slow due to Python's startup cost.
|
||||
# getResourceByteString <file>
|
||||
getResourceByteString() {
|
||||
xxd -p "$1"/..namedfork/rsrc | tr -d '\n'
|
||||
}
|
||||
|
||||
# Patches a single byte in the byte string provided via stdin.
|
||||
# patchByteInByteString ndx byteSpec
|
||||
# ndx is the 0-based byte index
|
||||
# - If <byteSpec> has NO prefix: <byteSpec> becomes the new byte
|
||||
# - If <byteSpec> has prefix '|': "adds" the value: the result of a bitwise OR with the existing byte becomes the new byte
|
||||
# - If <byteSpec> has prefix '~': "removes" the value: the result of a applying a bitwise AND with the bitwise complement of <byteSpec> to the existing byte becomes the new byte
|
||||
patchByteInByteString() {
|
||||
local ndx=$1 byteSpec=$2 byteVal byteStr charPos op='' charsBefore='' charsAfter='' currByte
|
||||
byteStr=$(</dev/stdin)
|
||||
charPos=$(( 2 * ndx ))
|
||||
# Validat the byte spec.
|
||||
case ${byteSpec:0:1} in
|
||||
'|')
|
||||
op='|'
|
||||
byteVal=${byteSpec:1}
|
||||
;;
|
||||
'~')
|
||||
op='& ~'
|
||||
byteVal=${byteSpec:1}
|
||||
;;
|
||||
*)
|
||||
byteVal=$byteSpec
|
||||
;;
|
||||
esac
|
||||
[[ $byteVal == [0-9A-Fa-f][0-9A-Fa-f] ]] || return 1
|
||||
# Validat the byte index.
|
||||
(( charPos > 0 && charPos < ${#byteStr} )) || return 1
|
||||
# Determine the target byte, and strings before and after the byte to patch.
|
||||
(( charPos >= 2 )) && charsBefore=${byteStr:0:charPos}
|
||||
charsAfter=${byteStr:charPos + 2}
|
||||
# Determine the new byte value
|
||||
if [[ -n $op ]]; then
|
||||
currByte=${byteStr:charPos:2}
|
||||
printf -v patchedByte '%02X' "$(( 0x${currByte} $op 0x${byteVal} ))"
|
||||
else
|
||||
patchedByte=$byteSpec
|
||||
fi
|
||||
printf '%s%s%s' "$charsBefore" "$patchedByte" "$charsAfter"
|
||||
}
|
||||
|
||||
# hasAttrib <fileOrFolder> <attrib_name>
|
||||
hasAttrib() {
|
||||
/usr/bin/xattr "$1" | /usr/bin/grep -Fqx "$2"
|
||||
}
|
||||
|
||||
# hasIconData <file>
|
||||
# Test if the file has a resource fork with icon data in it, or,
|
||||
# in the case of a .VolumeIcon.icns file, has icon data *as the file contents*
|
||||
hasIconData() {
|
||||
local file=$1
|
||||
if [[ $(basename "$file") == $kFILENAME_VOLUMECUSTOMICON ]]; then
|
||||
# special file for any folder that is a *volume mountpoint*: has the icon data as the file's *content*
|
||||
file "$file" | /usr/bin/grep -Fq ' Mac OS X icon'
|
||||
else
|
||||
# file itself or special helper file $'Icon\r' for a regular folder: has the icon data *in its resource fork*.
|
||||
getResourceByteString "$file" | /usr/bin/grep -Fq "$kMAGICBYTES_ICNS_RESOURCE"
|
||||
fi
|
||||
}
|
||||
|
||||
# isVolumeMountPoint <folder>
|
||||
isVolumeMountPoint() {
|
||||
local folder=$1
|
||||
# Must resolve to the physical underlying path, as that is what `mount` shows
|
||||
folder=$(cd -P -- "$1"; pwd)
|
||||
mount | grep -qF "on $folder (" # !! Is there a more robust way to test for mountpoints?
|
||||
}
|
||||
|
||||
# getFileWithIconData <fileOrFolder>
|
||||
# Returns the path of the file that contains the actual icon data, based on whether the target is
|
||||
# * a file ... the file path itself
|
||||
# * a folder:
|
||||
# * regular folder: file $'Icon\r' inside that folder, with the icon data in its resource fork
|
||||
# * volume mountpoint: file '.VolumeIcon.icns' inside that folder, with the icon data inside the file.
|
||||
getFileWithIconData() {
|
||||
local fileOrFolder=$1
|
||||
if [[ -f $fileOrFolder ]]; then # file
|
||||
printf '%s' "$fileOrFolder"
|
||||
elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint
|
||||
printf '%s' "$fileOrFolder/$kFILENAME_VOLUMECUSTOMICON"
|
||||
else # regular folder
|
||||
printf '%s' "$fileOrFolder/$kFILENAME_FOLDERCUSTOMICON"
|
||||
fi
|
||||
}
|
||||
|
||||
# getTargetType <fileOrFolder>
|
||||
# Returns a descriptor for the specified target path:
|
||||
# * a file ... 'file'
|
||||
# * a folder:
|
||||
# * regular folder: 'folder'
|
||||
# * volume mountpoint: 'volume'
|
||||
getTargetType() {
|
||||
local fileOrFolder=$1
|
||||
if [[ -f $fileOrFolder ]]; then # file
|
||||
printf 'file'
|
||||
elif isVolumeMountPoint "$fileOrFolder"; then # volume mountpoint
|
||||
printf 'volume'
|
||||
else # regular folder
|
||||
printf 'folder'
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
# setCustomIcon <fileOrFolder> <imgFile>
|
||||
# Tips for debugging:
|
||||
# * To exercise this function, from the repo dir.:
|
||||
# touch /tmp/tf; ./bin/fileicon set /tmp/tf ./test/.fixtures/img.png
|
||||
# !!??? VOLUME SUPPORT, as of macOS 13.1:
|
||||
# !! * While targeting volume root folders is now supported *in principle*,
|
||||
# !! assigning the 'com.apple.FinderInfo' extended attribute to the mountpoint folder
|
||||
# !! typically (always??) fails, so the icon doesn't take effect.
|
||||
# !! SetFile -a C <file> also fails - with or without `sudo` - with "ERROR: Unexpected Error. (-5000) on file: <file>"
|
||||
# !! (SetFile -a c <file> clears the custom-icon flag; note that SetFile isn't installed by default and is DEPRECATED: "Tools supporting Carbon development, including /usr/bin/SetFile, were deprecated with Xcode 6.")
|
||||
# !! ?? It feels like at *some point* on 13.1 our NFS mount from our NAS seemed to support it, but inexplicably no longer.
|
||||
# !! * SOME volumes, even if *network* volumes, support custom icons for their files and folders,
|
||||
# !! such as a SMB mount.
|
||||
# !! ?? Our NFS mount from our NAS seemed to support that *for a while* on 13.1, but inexplicably no longer.
|
||||
setCustomIcon() {
|
||||
|
||||
local fileOrFolder=$1 imgFile=$2 fileWithIconData
|
||||
|
||||
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 3
|
||||
[[ -f $imgFile ]] || return 3
|
||||
|
||||
# !! Sadly, Apple decided to remove the `-i` / `--addicon` option from the `sips` utility.
|
||||
# !! Therefore, use of *Cocoa* is required, which we do *via AppleScript* and its ObjC bridge,
|
||||
# !! which has the added advantage of creating a *set* of icons from the source image, scaling as necessary
|
||||
# !! to create a 512 x 512 top resolution icon (whereas sips -i created a single, 128 x 128 icon).
|
||||
# !! Thanks:
|
||||
# !! * https://apple.stackexchange.com/a/161984/28668 (Python original)
|
||||
# !! * @scriptingosx (https://github.com/mklement0/fileicon/issues/32#issuecomment-1074124748) (AppleScript-ObjC version)
|
||||
# !! Note: We moved from Python to AppleScript when the system Python was removed in macOS 12.3
|
||||
|
||||
# !! Note: The setIcon method seemingly always indicates True, even with invalid image files, so
|
||||
# !! we attempt no error handling in the AppleScript code, and instead verify success explicitly later.
|
||||
osascript <<EOF >/dev/null || die
|
||||
use framework "Cocoa"
|
||||
|
||||
set sourcePath to "$imgFile"
|
||||
set destPath to "$fileOrFolder"
|
||||
|
||||
set imageData to (current application's NSImage's alloc()'s initWithContentsOfFile:sourcePath)
|
||||
(current application's NSWorkspace's sharedWorkspace()'s setIcon:imageData forFile:destPath options:2)
|
||||
EOF
|
||||
|
||||
# Fully verify that everything worked as intended.
|
||||
# Unfortunately, several things can go wrong.
|
||||
testForCustomIcon "$targetFileOrFolder" 2>/dev/null && return 0
|
||||
ec=$?
|
||||
|
||||
if (( ec == 1 )); then
|
||||
cat >&2 <<EOF
|
||||
Failed to assign a custom icon.
|
||||
Typically, this means that the specified image file is not supported or corrupt: $imgFile
|
||||
Supported image formats: jpeg | tiff | png | gif | jp2 | pict | bmp | qtif| psd | sgi | tga
|
||||
EOF
|
||||
elif ((ec == 2 )); then
|
||||
cat >&2 <<EOF
|
||||
Failed to set the custom-icon flag in the 'com.apple.FinderInfo' extended attribute of: $targetFileOrFolder
|
||||
Typically, this means that you're targeting a volume itself or a file or folder on a volume that doesn't support custom icons.
|
||||
Rerun with "rm" to clean up.
|
||||
EOF
|
||||
elif ((ec == 3 )); then
|
||||
cat >&2 <<EOF
|
||||
The custom-icon flag in the 'com.apple.FinderInfo' extended attribute of: $imgFile
|
||||
was successfully set, but, unexpectedly, the associated icon data was not.
|
||||
Rerun with "rm" to clean up.
|
||||
EOF
|
||||
fi
|
||||
|
||||
return $ec
|
||||
|
||||
}
|
||||
|
||||
# getCustomIcon <fileOrFolder> <icnsOutFile>
|
||||
getCustomIcon() {
|
||||
|
||||
local fileOrFolder=$1 icnsOutFile=$2 byteStr fileWithIconData byteOffset byteCount
|
||||
|
||||
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3
|
||||
|
||||
# Determine what file to extract the resource fork from.
|
||||
if [[ -d $fileOrFolder ]]; then
|
||||
|
||||
fileWithIconData=$(getFileWithIconData "$fileOrFolder")
|
||||
[[ -f $fileWithIconData ]] || { echo "Custom-icon file does not exist: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; }
|
||||
|
||||
if [[ $(basename "$fileWithIconData") == $kFILENAME_VOLUMECUSTOMICON ]]; then
|
||||
# !! Volume mount points are an exception: their helper file contains the icon data as the file's *content*
|
||||
# !! rather than in a *resource fork*; therefore, simply *copying* the file's content is enough.
|
||||
# !! However, we use `cat` rather than `cp`, so as not to also copy the extended attributes.
|
||||
cat "$fileWithIconData" > "$icnsOutFile" || die
|
||||
return 0
|
||||
fi
|
||||
# Otherwise: proceed below to extract the data from the resource fork.
|
||||
|
||||
else
|
||||
fileWithIconData=$fileOrFolder
|
||||
fi
|
||||
|
||||
# Determine (based on format description at https://en.wikipedia.org/wiki/Apple_Icon_Image_format):
|
||||
# - the byte offset at which the icns resource begins, via the magic literal identifying an icns resource
|
||||
# - the length of the resource, which is encoded in the 4 bytes right after the magic literal.
|
||||
read -r byteOffset byteCount < <(getResourceByteString "$fileWithIconData" | /usr/bin/awk -F "$kMAGICBYTES_ICNS_RESOURCE" '{ printf "%s %d", (length($1) + 2) / 2, "0x" substr($2, 0, 8) }')
|
||||
(( byteOffset > 0 && byteCount > 0 )) || { echo "Custom-icon file contains no icons resource: '${fileWithIconData/$'\r'/\\r}'" >&2; return 1; }
|
||||
|
||||
# Extract the actual bytes using tail and head and save them to the output file.
|
||||
tail -c "+${byteOffset}" "$fileWithIconData/..namedfork/rsrc" | head -c $byteCount > "$icnsOutFile" || return
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# removeCustomIcon <fileOrFolder>
|
||||
removeCustomIcon() {
|
||||
|
||||
local fileOrFolder=$1 byteStr
|
||||
|
||||
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder && -w $fileOrFolder ]] || return 1
|
||||
|
||||
# Step 1: Turn off the custom-icon flag in the com.apple.FinderInfo extended attribute.
|
||||
# Note: Using SetFile -a c <file> is tempting, but SetFile doesn't come with macOS by default (part of XCode CLI package)
|
||||
if hasAttrib "$fileOrFolder" com.apple.FinderInfo; then
|
||||
byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo | patchByteInByteString $kFI_BYTEOFFSET_CUSTOMICON '~'$kFI_VAL_CUSTOMICON) || return
|
||||
if [[ $byteStr == "$kFI_BYTES_BLANK" ]]; then # All bytes cleared? Remove the entire attribute.
|
||||
/usr/bin/xattr -d com.apple.FinderInfo "$fileOrFolder"
|
||||
else # Update the attribute.
|
||||
/usr/bin/xattr -wx com.apple.FinderInfo "$byteStr" "$fileOrFolder" || return
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 2: Remove the resource fork (if target is a file) / hidden file with custom icon (if target is a folder)
|
||||
if [[ -d $fileOrFolder ]]; then # folder or volume -> remove the special file inside it.
|
||||
rm -f "$(getFileWithIconData "$fileOrFolder")"
|
||||
else # file -> remove the resource fork
|
||||
if hasIconData "$fileOrFolder"; then
|
||||
/usr/bin/xattr -d com.apple.ResourceFork "$fileOrFolder"
|
||||
fi
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# testForCustomIcon <fileOrFolder>
|
||||
testForCustomIcon() {
|
||||
|
||||
local fileOrFolder=$1 byteStr byteVal fileWithIconData hasCustomIconFlag hasIconData
|
||||
|
||||
[[ (-f $fileOrFolder || -d $fileOrFolder) && -r $fileOrFolder ]] || return 3
|
||||
|
||||
# Step 1: Check if the com.apple.FinderInfo extended attribute has the custom-icon
|
||||
# flag set. This applies to *all* target types.
|
||||
byteStr=$(getAttribByteString "$fileOrFolder" com.apple.FinderInfo 2>/dev/null) # || return 1
|
||||
byteVal=${byteStr:2*kFI_BYTEOFFSET_CUSTOMICON:2}
|
||||
hasCustomIconFlag=$(( byteVal & kFI_VAL_CUSTOMICON ))
|
||||
|
||||
fileWithIconData=$(getFileWithIconData "$fileOrFolder")
|
||||
# Step 2: Check if there's actual icon data present,
|
||||
# via the resource fork of the file or the folder's helper file or the file content of a
|
||||
# volume mountpoint's helper file (./.VolumeIcon.icns)
|
||||
hasIconData "$fileWithIconData" 2>/dev/null && hasIconData=1 || hasIconData=0
|
||||
|
||||
# Provide a specific exit code reflecting the state.
|
||||
# !! This is used by setCustomIcon()
|
||||
if (( hasCustomIconFlag && hasIconData )); then
|
||||
return 0 # has custom icon
|
||||
elif (( ! hasCustomIconFlag && ! hasIconData )); then
|
||||
return 1 # typical case of file/folder NOT having a custom icon
|
||||
elif (( ! hasCustomIconFlag )); then
|
||||
echo "WARNING: Custom-icon data is present, but the 'com.apple.FinderInfo' extended attribute isn't set for $(getTargetType "$fileOrFolder") '$fileOrFolder'" >&2
|
||||
return 2 # broken state: has icons, but no custom flag
|
||||
else # (( ! hasIconData ))
|
||||
echo "WARNING: While the 'com.apple.FinderInfo' extended attribute is set for $(getTargetType "$fileOrFolder") '$fileOrFolder', associated icon data is missing." >&2
|
||||
return 3 # broken state: has custom flag, but no icons
|
||||
fi
|
||||
|
||||
}
|
||||
|
||||
# --- End: SPECIFIC HELPER FUNCTIONS
|
||||
|
||||
# --- Begin: SPECIFIC SCRIPT-GLOBAL CONSTANTS
|
||||
|
||||
kFILENAME_FOLDERCUSTOMICON=$'Icon\r' # the helper file for regular folders, with the actual icon image data in its *resource fork*
|
||||
kFILENAME_VOLUMECUSTOMICON='.VolumeIcon.icns' # the helper file for volume mountpoints, with the actual icon image data in the file's *content*.
|
||||
|
||||
# The blank hex dump form (single string of pairs of hex digits) of the 32-byte data structure stored in extended attribute
|
||||
# com.apple.FinderInfo
|
||||
kFI_BYTES_BLANK='0000000000000000000000000000000000000000000000000000000000000000'
|
||||
|
||||
# [UPDATE]
|
||||
# * THIS CONSTANT ISN'T USED ANYMORE.
|
||||
# * Also, on macOS 13 (Ventura): seemingly, the Icon\r file's com.apple.FinderInfo extended attribute is now
|
||||
# SIMPLER: where the folder itself has 0x4 in its 9th byte, Icon\r now has 0x40
|
||||
# [ORIGINAL COMMENT]
|
||||
# The hex dump form of the full 32 bytes that Finder assigns to the hidden $'Icon\r'
|
||||
# file whose com.apple.ResourceFork extended attribute contains the icon image data for the enclosing folder.
|
||||
# The first 8 bytes spell out the magic literal 'iconMACS'; they are followed by the invisibility flag, '40' in the 9th byte, and '10' (?? specifying what?)
|
||||
# in the 10th byte.
|
||||
# NOTE: Since file $'Icon\r' serves no other purpose than to store the icon, it is
|
||||
# safe to simply assign all 32 bytes blindly, without having to worry about
|
||||
# preserving existing values.
|
||||
# kFI_BYTES_CUSTOMICONFILEFORFOLDER='69636F6E4D414353401000000000000000000000000000000000000000000000'
|
||||
|
||||
# The hex dump form of the magic literal inside a resource fork that marks the
|
||||
# start of an icns (icons) resource.
|
||||
# NOTE: This will be used with `xxd -p .. | tr -d '\n'`, which uses *lowercase*
|
||||
# hex digits, so we must use lowercase here.
|
||||
kMAGICBYTES_ICNS_RESOURCE='69636e73'
|
||||
|
||||
# The byte values (as hex strings) of the flags at the relevant byte position
|
||||
# of the com.apple.FinderInfo extended attribute.
|
||||
kFI_VAL_CUSTOMICON='04'
|
||||
|
||||
# The custom-icon-flag byte offset in the com.apple.FinderInfo extended attribute.
|
||||
kFI_BYTEOFFSET_CUSTOMICON=8
|
||||
|
||||
# --- End: SPECIFIC SCRIPT-GLOBAL CONSTANTS
|
||||
|
||||
# Option defaults.
|
||||
force=0 quiet=0
|
||||
|
||||
# --- Begin: OPTIONS PARSING
|
||||
allowOptsAfterOperands=1 operands=() i=0 optName= isLong=0 prefix= optArg= haveOptArgAttached=0 haveOptArgAsNextArg=0 acceptOptArg=0 needOptArg=0
|
||||
while (( $# )); do
|
||||
if [[ $1 =~ ^(-)[a-zA-Z0-9]+.*$ || $1 =~ ^(--)[a-zA-Z0-9]+.*$ ]]; then # an option: either a short option / multiple short options in compressed form or a long option
|
||||
prefix=${BASH_REMATCH[1]}; [[ $prefix == '--' ]] && isLong=1 || isLong=0
|
||||
for (( i = 1; i < (isLong ? 2 : ${#1}); i++ )); do
|
||||
acceptOptArg=0 needOptArg=0 haveOptArgAttached=0 haveOptArgAsNextArg=0 optArgAttached= optArgOpt= optArgReq=
|
||||
if (( isLong )); then # long option: parse into name and, if present, argument
|
||||
optName=${1:2}
|
||||
[[ $optName =~ ^([^=]+)=(.*)$ ]] && { optName=${BASH_REMATCH[1]}; optArgAttached=${BASH_REMATCH[2]}; haveOptArgAttached=1; }
|
||||
else # short option: *if* it takes an argument, the rest of the string, if any, is by definition the argument.
|
||||
optName=${1:i:1}; optArgAttached=${1:i+1}; (( ${#optArgAttached} >= 1 )) && haveOptArgAttached=1
|
||||
fi
|
||||
(( haveOptArgAttached )) && optArgOpt=$optArgAttached optArgReq=$optArgAttached || { (( $# > 1 )) && { optArgReq=$2; haveOptArgAsNextArg=1; }; }
|
||||
# ---- BEGIN: CUSTOMIZE HERE
|
||||
case $optName in
|
||||
f|force)
|
||||
force=1
|
||||
;;
|
||||
q|quiet)
|
||||
quiet=1
|
||||
;;
|
||||
*)
|
||||
dieSyntax "Unknown option: ${prefix}${optName}."
|
||||
;;
|
||||
esac
|
||||
# ---- END: CUSTOMIZE HERE
|
||||
(( needOptArg )) && { (( ! haveOptArgAttached && ! haveOptArgAsNextArg )) && dieSyntax "Option ${prefix}${optName} is missing its argument." || (( haveOptArgAsNextArg )) && shift; }
|
||||
(( acceptOptArg || needOptArg )) && break
|
||||
done
|
||||
else # an operand
|
||||
if [[ $1 == '--' ]]; then
|
||||
shift; operands+=( "$@" ); break
|
||||
elif (( allowOptsAfterOperands )); then
|
||||
operands+=( "$1" ) # continue
|
||||
else
|
||||
operands=( "$@" )
|
||||
break
|
||||
fi
|
||||
fi
|
||||
shift
|
||||
done
|
||||
(( "${#operands[@]}" > 0 )) && set -- "${operands[@]}"; unset allowOptsAfterOperands operands i optName isLong prefix optArgAttached haveOptArgAttached haveOptArgAsNextArg acceptOptArg needOptArg
|
||||
# --- End: OPTIONS PARSING: "$@" now contains all operands (non-option arguments).
|
||||
|
||||
# Validate the command
|
||||
cmd=$(printf %s "$1" | tr '[:upper:]' '[:lower:]') # translate to all-lowercase - we don't want the command name to be case-sensitive
|
||||
[[ $cmd == 'remove' ]] && cmd='rm' # support alias 'remove' for 'rm'
|
||||
case $cmd in
|
||||
set|get|rm|remove|test)
|
||||
shift
|
||||
;;
|
||||
*)
|
||||
dieSyntax "Unrecognized or missing command: '$cmd'."
|
||||
;;
|
||||
esac
|
||||
|
||||
# Validate file operands
|
||||
(( $# > 0 )) || dieSyntax "Missing operand(s)."
|
||||
|
||||
# Target file or folder.
|
||||
targetFileOrFolder=$1 imgFile= outFile=
|
||||
[[ -f $targetFileOrFolder || -d $targetFileOrFolder ]] || die "Target not found or neither file nor folder: '$targetFileOrFolder'"
|
||||
# Make sure the target file/folder is readable, and, unless only getting or testing for an icon are requested, writeable too.
|
||||
[[ -r $targetFileOrFolder ]] || die "Cannot access '$targetFileOrFolder': you do not have read permissions."
|
||||
[[ $cmd == 'test' || $cmd == 'get' || -w $targetFileOrFolder ]] || die "Cannot modify '$targetFileOrFolder': you do not have write permissions."
|
||||
|
||||
# Other operands, if any, and their number.
|
||||
valid=0
|
||||
case $cmd in
|
||||
'set')
|
||||
(( $# <= 2 )) && {
|
||||
valid=1
|
||||
# If no image file was specified, the target file is assumed to be an image file itself whose image should be self-assigned as an icon.
|
||||
(( $# == 2 )) && imgFile=$2 || imgFile=$1
|
||||
# !! Apparently, a regular file is required - a process subsitution such
|
||||
# !! as `<(base64 -D <encoded-file.txt)` is NOT supported by NSImage.initWithContentsOfFile()
|
||||
[[ -f $imgFile && -r $imgFile ]] || die "Image file not found or not a (readable) regular file: $imgFile"
|
||||
}
|
||||
;;
|
||||
'rm'|'test')
|
||||
(( $# == 1 )) && valid=1
|
||||
;;
|
||||
'get')
|
||||
(( $# == 1 || $# == 2 )) && {
|
||||
valid=1
|
||||
outFile=$2
|
||||
if [[ $outFile == '-' ]]; then
|
||||
outFile=/dev/stdout
|
||||
else
|
||||
# By default, we extract to a file with the same filename root + '.icns'
|
||||
# in the current folder.
|
||||
[[ -z $outFile ]] && outFile=${targetFileOrFolder##*/}
|
||||
# Unless already specified, we append '.icns' to the output filename.
|
||||
mustReset=$(shopt -q nocasematch; echo $?); shopt -s nocasematch
|
||||
[[ $outFile =~ \.icns$ ]] || outFile+='.icns'
|
||||
(( mustReset )) && shopt -u nocasematch
|
||||
[[ -e $outFile && $force -eq 0 ]] && die "Output file '$outFile' already exists. To force its replacement, use -f."
|
||||
fi
|
||||
}
|
||||
;;
|
||||
esac
|
||||
(( valid )) || dieSyntax "Unexpected number of operands."
|
||||
|
||||
case $cmd in
|
||||
'set')
|
||||
setCustomIcon "$targetFileOrFolder" "$imgFile" || die
|
||||
(( quiet )) || echo "Custom icon assigned to $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder' based on '$imgFile'."
|
||||
;;
|
||||
'rm')
|
||||
removeCustomIcon "$targetFileOrFolder" || die
|
||||
(( quiet )) || echo "Custom icon (if any) removed from $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'."
|
||||
;;
|
||||
'get')
|
||||
getCustomIcon "$targetFileOrFolder" "$outFile" || die
|
||||
(( quiet )) || { [[ $outFile != '/dev/stdout' ]] && echo "Custom icon extracted to '$outFile'."; }
|
||||
exit 0
|
||||
;;
|
||||
'test')
|
||||
if (( quiet )); then
|
||||
testForCustomIcon "$targetFileOrFolder" 2>/dev/null
|
||||
else
|
||||
testForCustomIcon "$targetFileOrFolder" # This may issue warnings.
|
||||
fi
|
||||
ec=$?
|
||||
if (( ! quiet )); then
|
||||
echo "$( (( ec == 0 )) && printf 'HAS' || printf 'Has NO' ) custom icon: $(getTargetType "$targetFileOrFolder") '$targetFileOrFolder'"
|
||||
fi
|
||||
exit $ec
|
||||
;;
|
||||
*)
|
||||
die "DESIGN ERROR: unanticipated command: $cmd"
|
||||
;;
|
||||
esac
|
||||
|
||||
exit 0
|
||||
|
||||
####
|
||||
# MAN PAGE MARKDOWN SOURCE
|
||||
# - Place a Markdown-formatted version of the man page for this script
|
||||
# inside the here-document below.
|
||||
# The document must be formatted to look good in all 3 viewing scenarios:
|
||||
# - as a man page, after conversion to ROFF with marked-man
|
||||
# - as plain text (raw Markdown source)
|
||||
# - as HTML (rendered Markdown)
|
||||
# Markdown formatting tips:
|
||||
# - GENERAL
|
||||
# To support plain-text rendering in the terminal, limit all lines to 80 chars.,
|
||||
# and, for similar rendering as HTML, *end every line with 2 trailing spaces*.
|
||||
# - HEADINGS
|
||||
# - For better plain-text rendering, leave an empty line after a heading
|
||||
# marked-man will remove it from the ROFF version.
|
||||
# - The first heading must be a level-1 heading containing the utility
|
||||
# name and very brief description; append the manual-section number
|
||||
# directly to the CLI name; e.g.:
|
||||
# # foo(1) - does bar
|
||||
# - The 2nd, level-2 heading must be '## SYNOPSIS' and the chapter's body
|
||||
# must render reasonably as plain text, because it is printed to stdout
|
||||
# when `-h`, `--help` is specified:
|
||||
# Use 4-space indentation without markup for both the syntax line and the
|
||||
# block of brief option descriptions; represent option-arguments and operands
|
||||
# in angle brackets; e.g., '<foo>'
|
||||
# - All other headings should be level-2 headings in ALL-CAPS.
|
||||
# - TEXT
|
||||
# - Use NO indentation for regular chapter text; if you do, it will
|
||||
# be indented further than list items.
|
||||
# - Use 4-space indentation, as usual, for code blocks.
|
||||
# - Markup character-styling markup translates to ROFF rendering as follows:
|
||||
# `...` and **...** render as bolded (red) text
|
||||
# _..._ and *...* render as word-individually underlined text
|
||||
# - LISTS
|
||||
# - Indent list items by 2 spaces for better plain-text viewing, but note
|
||||
# that the ROFF generated by marked-man still renders them unindented.
|
||||
# - End every list item (bullet point) itself with 2 trailing spaces too so
|
||||
# that it renders on its own line.
|
||||
# - Avoid associating more than 1 paragraph with a list item, if possible,
|
||||
# because it requires the following trick, which hampers plain-text readability:
|
||||
# Use ' <space><space>' in lieu of an empty line.
|
||||
####
|
||||
: <<'EOF_MAN_PAGE'
|
||||
# fileicon(1) - manage file and folder custom icons
|
||||
|
||||
## SYNOPSIS
|
||||
|
||||
Manage custom icons for files and folders on macOS.
|
||||
|
||||
SET a custom icon for a file or folder:
|
||||
|
||||
fileicon set <fileOrFolder> [<imageFile>]
|
||||
|
||||
REMOVE a custom icon from a file or folder:
|
||||
|
||||
fileicon rm <fileOrFolder>
|
||||
|
||||
GET a file or folder's custom icon:
|
||||
|
||||
fileicon get [-f] <fileOrFolder> [<iconOutputFile>]
|
||||
|
||||
-f ... force replacement of existing output file
|
||||
|
||||
TEST if a file or folder has a custom icon:
|
||||
|
||||
fileicon test <fileOrFolder>
|
||||
|
||||
All forms: option -q silences status output.
|
||||
|
||||
Standard options: `--help`, `--man`, `--version`, `--home`
|
||||
|
||||
## DESCRIPTION
|
||||
|
||||
`<fileOrFolder>` is the file or folder whose custom icon should be managed.
|
||||
Note that symlinks are followed to their (ultimate target); that is, you
|
||||
can only assign custom icons to regular files and folders, not to symlinks
|
||||
to them.
|
||||
|
||||
`<imageFile>` can be an image file of any format supported by the system.
|
||||
It is converted to an icon and assigned to `<fileOrFolder>`.
|
||||
If you omit `<imageFile>`, `<fileOrFolder>` must itself be an image file whose
|
||||
image should become its own icon.
|
||||
|
||||
`<iconOutputFile>` specifies the file to extract the custom icon to:
|
||||
Defaults to the filename of `<fileOrFolder>` with extension `.icns` appended.
|
||||
If a value is specified, extension `.icns` is appended, unless already present.
|
||||
Either way, extraction fails if the target file already exists; use `-f` to
|
||||
override.
|
||||
Specify `-` to extract to stdout.
|
||||
|
||||
Command `test` signals with its exit code whether a custom icon is set (0)
|
||||
or not (1); any other exit code signals an unexpected error.
|
||||
|
||||
**Options**:
|
||||
|
||||
* `-f`, `--force`
|
||||
When getting (extracting) a custom icon, forces replacement of the
|
||||
output file, if it already exists.
|
||||
|
||||
* `-q`, `--quiet`
|
||||
Suppresses output of the status information that is by default output to
|
||||
stdout.
|
||||
Note that errors and warnings are still printed to stderr.
|
||||
|
||||
## NOTES
|
||||
|
||||
Custom icons are stored in extended attributes of the HFS+ filesystem.
|
||||
Thus, if you copy files or folders to a different filesystem that doesn't
|
||||
support such attributes, custom icons are lost; for instance, custom icons
|
||||
cannot be stored in a Git repository.
|
||||
|
||||
To determine if a give file or folder has extended attributes, use
|
||||
`ls -l@ <fileOrFolder>`.
|
||||
|
||||
When setting an image as a custom icon, a set of icons with several resolutions
|
||||
is created, with the highest resolution at 512 x 512 pixels.
|
||||
|
||||
All icons created are square, so images with a non-square aspect ratio will
|
||||
appear distorted; for best results, use square imges.
|
||||
|
||||
## STANDARD OPTIONS
|
||||
|
||||
All standard options provide information only.
|
||||
|
||||
* `-h, --help`
|
||||
Prints the contents of the synopsis chapter to stdout for quick reference.
|
||||
|
||||
* `--man`
|
||||
Displays this manual page, which is a helpful alternative to using `man`,
|
||||
if the manual page isn't installed.
|
||||
|
||||
* `--version`
|
||||
Prints version information.
|
||||
|
||||
* `--home`
|
||||
Opens this utility's home page in the system's default web browser.
|
||||
|
||||
## LICENSE
|
||||
|
||||
For license information and more, visit the home page by running
|
||||
`fileicon --home`
|
||||
|
||||
EOF_MAN_PAGE
|
||||
@@ -1,45 +1,17 @@
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Png;
|
||||
using LibationUiBase;
|
||||
using SixLabors.ImageSharp;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace WindowsConfigApp
|
||||
{
|
||||
{
|
||||
internal static partial class FolderIcon
|
||||
{
|
||||
// https://stackoverflow.com/a/21389253
|
||||
static readonly IcoEncoder IcoEncoder = new();
|
||||
public static byte[] ToIcon(this Image img)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using var bw = new BinaryWriter(ms);
|
||||
// Header
|
||||
bw.Write((short)0); // 0-1 : reserved
|
||||
bw.Write((short)1); // 2-3 : 1=ico, 2=cur
|
||||
bw.Write((short)1); // 4-5 : number of images
|
||||
// Image directory
|
||||
var w = img.Width;
|
||||
if (w >= 256) w = 0;
|
||||
bw.Write((byte)w); // 0 : width of image
|
||||
var h = img.Height;
|
||||
if (h >= 256) h = 0;
|
||||
bw.Write((byte)h); // 1 : height of image
|
||||
bw.Write((byte)0); // 2 : number of colors in palette
|
||||
bw.Write((byte)0); // 3 : reserved
|
||||
bw.Write((short)0); // 4 : number of color planes
|
||||
bw.Write((short)0); // 6 : bits per pixel
|
||||
var sizeHere = ms.Position;
|
||||
bw.Write((int)0); // 8 : image size
|
||||
var start = (int)ms.Position + 4;
|
||||
bw.Write(start); // 12: offset of image data
|
||||
// Image data
|
||||
img.Save(ms, new PngEncoder());
|
||||
var imageSize = (int)ms.Position - start;
|
||||
ms.Seek(sizeHere, SeekOrigin.Begin);
|
||||
bw.Write(imageSize);
|
||||
ms.Seek(0, SeekOrigin.Begin);
|
||||
|
||||
// And load it
|
||||
using var ms = new MemoryStream();
|
||||
img.Save(ms, IcoEncoder);
|
||||
return ms.ToArray();
|
||||
}
|
||||
|
||||
@@ -57,50 +29,33 @@ namespace WindowsConfigApp
|
||||
File.Delete(text);
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
}
|
||||
|
||||
// https://github.com/dimuththarindu/FIC-Folder-Icon-Changer/blob/master/project/FIC/Classes/IconCustomizer.cs
|
||||
|
||||
public static void SetIcon(this DirectoryInfo directoryInfo, string icoPath, string folderType)
|
||||
=> SetIcon(directoryInfo.FullName, icoPath, folderType);
|
||||
public static void SetIcon(this DirectoryInfo directoryInfo, byte[] icon, string folderType)
|
||||
=> SetIcon(directoryInfo.FullName, icon, folderType);
|
||||
|
||||
public static void SetIcon(string dir, string icoPath, string folderType)
|
||||
public static void SetIcon(string dir, byte[] icon, string folderType)
|
||||
{
|
||||
var desktop_ini = Path.Combine(dir, "desktop.ini");
|
||||
var Icon_ico = Path.Combine(dir, "Icon.ico");
|
||||
var hidden = Path.Combine(dir, ".hidden");
|
||||
|
||||
//deleting existing files
|
||||
DeleteIcon(dir);
|
||||
|
||||
//copying Icon file //overwriting
|
||||
File.Copy(icoPath, Icon_ico, true);
|
||||
File.WriteAllBytes(Icon_ico, icon);
|
||||
|
||||
//writing configuration file
|
||||
string[] desktopLines = { "[.ShellClassInfo]", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" };
|
||||
string[] desktopLines = { "[.ShellClassInfo]", "ConfirmFileOp=0", "IconResource=Icon.ico,0", "[ViewState]", "Mode=", "Vid=", $"FolderType={folderType}" };
|
||||
File.WriteAllLines(desktop_ini, desktopLines);
|
||||
|
||||
//configure file 2
|
||||
string[] hiddenLines = { "desktop.ini", "Icon.ico" };
|
||||
File.WriteAllLines(hidden, hiddenLines);
|
||||
File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.ReadOnly);
|
||||
File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.ReadOnly);
|
||||
|
||||
//making system files
|
||||
File.SetAttributes(desktop_ini, File.GetAttributes(desktop_ini) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
|
||||
File.SetAttributes(Icon_ico, File.GetAttributes(Icon_ico) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
|
||||
File.SetAttributes(hidden, File.GetAttributes(hidden) | FileAttributes.Hidden | FileAttributes.System | FileAttributes.ReadOnly);
|
||||
|
||||
// this strangely completes the process. also hides these 3 hidden system files, even if "show hidden items" is checked
|
||||
File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.ReadOnly);
|
||||
|
||||
refresh();
|
||||
//https://learn.microsoft.com/en-us/windows/win32/shell/how-to-customize-folders-with-desktop-ini
|
||||
File.SetAttributes(dir, File.GetAttributes(dir) | FileAttributes.System);
|
||||
}
|
||||
|
||||
private static void refresh() => SHChangeNotify(0x08000000, 0x0000, 0, 0); //SHCNE_ASSOCCHANGED SHCNF_IDLIST
|
||||
|
||||
|
||||
[DllImport("shell32.dll", SetLastError = true)]
|
||||
private static extern void SHChangeNotify(int wEventId, int uFlags, nint dwItem1, nint dwItem2);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,22 +14,9 @@ namespace WindowsConfigApp
|
||||
|
||||
public void SetFolderIcon(string image, string directory)
|
||||
{
|
||||
string iconPath = null;
|
||||
|
||||
try
|
||||
{
|
||||
var icon = Image.Load(File.ReadAllBytes(image)).ToIcon();
|
||||
iconPath = Path.Combine(directory, $"{Guid.NewGuid()}.ico");
|
||||
File.WriteAllBytes(iconPath, icon);
|
||||
|
||||
new DirectoryInfo(directory)?.SetIcon(iconPath, "Music");
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(iconPath))
|
||||
File.Delete(iconPath);
|
||||
}
|
||||
}
|
||||
var icon = Image.Load(image).ToIcon();
|
||||
new DirectoryInfo(directory)?.SetIcon(icon, "Music");
|
||||
}
|
||||
|
||||
public void DeleteFolderIcon(string directory)
|
||||
=> new DirectoryInfo(directory)?.DeleteIcon();
|
||||
|
||||
Reference in New Issue
Block a user