Compare commits

..

49 Commits

Author SHA1 Message Date
Robert McRackan
36076242a7 Bug fix #532 : Possible rull ref exception for pre-amazon germany 2023-03-15 13:49:11 -04:00
Robert McRackan
718e6c14d0 update dependency 2023-03-14 22:10:55 -04:00
rmcrackan
eb61ba3d69 Merge pull request #531 from Mbucari/master
Bug fixes and performance improvements
2023-03-14 07:53:25 -04:00
MBucari
defabf7356 Use new AudibleApi methods 2023-03-13 21:00:25 -06:00
Mbucari
1149c10cf1 Merge branch 'rmcrackan:master' into master 2023-03-13 20:49:56 -06:00
MBucari
ec7dd1b54a Use new AudibleApi methods 2023-03-13 20:47:32 -06:00
Robert McRackan
bb900b31ef update dependencies 2023-03-13 22:28:46 -04:00
MBucari
eed42bd108 Improve grid update performance 2023-03-11 21:50:30 -07:00
MBucari
3f0e6b9ee5 Fix window restore maximize statate on secondary monitor. 2023-03-11 21:35:33 -07:00
MBucari
5ec01913d5 Fix bug where book with corrupt image cannot be queued. 2023-03-11 20:58:06 -07:00
rmcrackan
245e55782e Merge pull request #527 from Mbucari/master
Improve Library Display performance and Refactor grid viewmodels
2023-03-11 16:44:31 -05:00
MBucari
cc306e0e19 Fix expand/collapse button icon in Avalonia 2023-03-11 12:28:34 -07:00
MBucari
26a9bc6bbf Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-11 11:12:05 -07:00
MBucari
fb9d062545 WinForms and Avalonia now share all GridEntry view models 2023-03-11 11:10:58 -07:00
MBucari
49c6b391fd WinForms and Avalonia now share all GridEntry view models 2023-03-10 20:00:25 -07:00
MBucari
e1cd8b8f94 Improve Library load and refresh performance 2023-03-10 19:01:49 -07:00
Robert McRackan
ef1edf1136 AYCL bug fix: US and Italy 2023-03-10 15:37:51 -05:00
rmcrackan
0def1b426a Merge pull request #526 from Mbucari/master
Add better AYCL detection and add verbose library scan logging
2023-03-10 15:26:41 -05:00
Mbucari
230e014bb1 Add better AYCL detection and add verbose library scan logging 2023-03-10 13:09:59 -07:00
Robert McRackan
34f56d2fd7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-08 14:07:51 -05:00
Robert McRackan
c45ffaf4a6 Incr ver 2023-03-08 14:07:47 -05:00
rmcrackan
ae43ab103e Merge pull request #524 from Mbucari/master
Improve library scan speed and Track and display book availability
2023-03-08 14:06:45 -05:00
Mbucari
559977ce0b Add 'Unavailable' book and pdf counts. 2023-03-08 11:26:07 -07:00
Mbucari
ccd4d3e26d Check for null Plan array 2023-03-08 11:21:47 -07:00
MBucari
e76f99ff28 Fix rmcrackan/Libation#523 2023-03-07 22:34:36 -07:00
MBucari
d3607583ab Tweak episode scan 2023-03-07 20:32:50 -07:00
MBucari
3ebd4ce243 Show AbsentFromLastScan book status in grid 2023-03-07 20:02:22 -07:00
Mbucari
f6dcc0db1d Add AbsentFromLastScan 2023-03-07 18:58:18 -07:00
MBucari
bd49db83e4 Improve library scan performance 2023-03-07 15:30:22 -07:00
Mbucari
4140722a6d Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-06 16:56:57 -07:00
Mbucari
da36f9414d Improve library scan performance 2023-03-06 16:49:52 -07:00
Mbucari
1510f71ca6 Merge branch 'rmcrackan:master' into master 2023-03-03 16:33:36 -07:00
Mbucari
cdb27ef712 Add last downloaded info to exports 2023-03-03 15:06:06 -07:00
Robert McRackan
790319ed98 incr ver 2023-03-03 15:58:05 -05:00
rmcrackan
1b0fb2b316 Merge pull request #522 from Mbucari/master
Resolved Several Issues (It's not as bad as  2,453 lines suggests)
2023-03-03 15:56:59 -05:00
Mbucari
02371f2221 Deleting folders with custom icons no longer triggers system file warning 2023-03-03 10:56:31 -07:00
Mbucari
2b672f86be Fatten up the chevrons 2023-03-02 19:57:43 -07:00
Mbucari
36176bff33 Update ImageSharp 2023-03-02 19:41:59 -07:00
Mbucari
174b0c26b8 Update fileicon to latest version 2023-03-02 19:40:38 -07:00
Mbucari
26c60e8e79 Convert queue expand/collapse button text to images (rmcrackan/Libation#339) 2023-03-02 19:23:03 -07:00
Mbucari
d94759d868 Add Last Download column to grid (rmcrackan/Libation#498) 2023-03-02 18:52:45 -07:00
Mbucari
bd7e45ca3c Add last download into to database 2023-03-02 15:09:10 -07:00
Mbucari
52a863c62a Add audiobook Trash Bin 2023-03-02 13:12:32 -07:00
Mbucari
fe55b90ee3 Fix rmcrackan/Libation#511 2023-03-01 22:14:57 -07:00
Mbucari
df224cc7f3 Move TrackedQueue to LibationUiBase 2023-03-01 09:33:17 -07:00
Mbucari
2a59329350 Merge branch 'rmcrackan:master' into master 2023-02-28 16:41:14 -07:00
Mbucari
abdf0e7261 Parallelize post-liberation tasks 2023-02-28 16:40:53 -07:00
Mbucari
b9c2a1cce3 Add folder icon support to MacOS 2023-02-28 15:57:27 -07:00
rmcrackan
aa86fca08f Update InstallOnMac.md 2023-02-28 15:46:51 -05:00
109 changed files with 4378 additions and 2462 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>9.4.2.1</Version>
<Version>9.4.6.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="5.0.0" />
<PackageReference Include="Octokit" Version="5.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />

View File

@@ -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 //

View File

@@ -1,13 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AudibleApi;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Logging;
using DtoImporterService;
using FileManager;
using LibationFileManager;
using Newtonsoft.Json.Linq;
using Serilog;
using static DtoImporterService.PerfLogger;
@@ -91,7 +95,8 @@ namespace ApplicationServices
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
#region FULL LIBRARY scan and import
@@ -162,19 +167,28 @@ namespace ApplicationServices
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
await using LogArchiver archiver
= Log.Logger.IsDebugEnabled()
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);
foreach (var account in accounts)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
}
// import library in parallel
@@ -183,7 +197,7 @@ namespace ApplicationServices
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
@@ -196,6 +210,21 @@ namespace ApplicationServices
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
if (archiver is not null)
{
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
var scanFile = new JObject
{
{ "Account", account.MaskedLogEntry },
{ "ScannedDateTime", DateTime.Now.ToString("u") },
{ "Items", items}
};
await archiver.AddFileAsync(fileName, scanFile);
}
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
@@ -242,18 +271,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 +302,7 @@ namespace ApplicationServices
}
}
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
{
@@ -303,6 +330,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 +398,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 +482,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);
}
}
}

View File

@@ -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++;
}

View File

@@ -1,16 +1,15 @@
using System;
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;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Polly;
using Polly.Retry;
using System.Threading;
namespace AudibleUtilities
{
@@ -19,6 +18,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,44 +87,76 @@ 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();
List<Item> items = new();
var sw = Stopwatch.StartNew();
var totalTime = TimeSpan.Zero;
using var semaphore = new SemaphoreSlim(MaxConcurrency);
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
var episodeChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore);
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
//Scan the library for all added books.
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
await foreach (var item in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
{
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
if (importEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
}
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
var episodes = item.Where(i => i.IsEpisodes).ToList();
var series = item.Where(i => i.IsSeriesParent).ToList();
count++;
var parentAsins = episodes
.SelectMany(i => i.Relationships)
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(r => r.Asin);
var episodeAsins = series
.SelectMany(i => i.Relationships)
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin);
foreach (var asin in parentAsins.Concat(episodeAsins))
episodeChannel.Writer.TryWrite(asin);
items.AddRange(episodes);
items.AddRange(series);
}
items.AddRange(item.Where(i => !i.IsSeriesParent && !i.IsEpisodes));
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count);
sw.Restart();
//await and add all episodes from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
//Signal that we're done adding asins
episodeChannel.Writer.Complete();
Serilog.Log.Logger.Debug("Completed library scan.");
//Wait for all episodes/parents to be retrived
var allEps = await batchReaderTask;
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count);
sw.Restart();
Serilog.Log.Logger.Debug("Begin indexing series episodes");
items.AddRange(allEps);
//Set the Item.Series info for episodes and parents.
foreach (var parent in items.Where(i => i.IsSeriesParent))
{
var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin));
setSeries(parent, children);
}
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
#if DEBUG
//// this will not work for multi accounts
//var library_json = "library.json";
//library_json = System.IO.Path.GetFullPath(library_json);
//if (System.IO.File.Exists(library_json))
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
#endif
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
@@ -146,166 +180,91 @@ namespace AudibleUtilities
#region episodes and podcasts
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
/// <summary>
/// Read asins from the channel and request catalog item info in batches of <see cref="BatchSize"/>. Blocks until <paramref name="channelReader"/> is closed.
/// </summary>
/// <param name="channelReader">Input asins to batch</param>
/// <param name="semaphore">Shared semaphore to limit concurrency</param>
/// <returns>All <see cref="Item"/>s of asins written to the channel.</returns>
private async Task<List<Item>> readAllAsinsAsync(ChannelReader<string> channelReader, SemaphoreSlim semaphore)
{
await concurrencySemaphore.WaitAsync();
int batchNum = 1;
List<Task<List<Item>>> getTasks = new();
while (await channelReader.WaitToReadAsync())
{
List<string> asins = new();
while (asins.Count < BatchSize && await channelReader.WaitToReadAsync())
{
var asin = await channelReader.ReadAsync();
if (!asins.Contains(asin))
asins.Add(asin);
}
await semaphore.WaitAsync();
getTasks.Add(getProductsAsync(batchNum++, asins, semaphore));
}
var completed = await Task.WhenAll(getTasks);
//We only want Series parents and Series episodes. Explude other relationship types (e.g. 'season')
return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList();
}
private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins, SemaphoreSlim semaphore)
{
Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins");
try
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
var sw = Stopwatch.StartNew();
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
sw.Stop();
List<Item> children;
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
if (parent.IsEpisodes)
return items;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins });
throw;
}
finally { semaphore.Release(); }
}
private static void setSeries(Item parent, IEnumerable<Item> children)
{
//A series parent will always have exactly 1 Series
parent.Series = new[]
{
new Series
{
//The 'parent' is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
children = new() { parent };
var parentAsins = parent.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 {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
return new List<Item>();
}
var realParent = seriesParents.Single(p => p.IsSeriesParent);
realParent.PurchaseDate = parent.PurchaseDate;
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
parent = realParent;
}
else
{
children = await getEpisodeChildrenAsync(parent);
if (!children.Any())
return new();
Asin = parent.Asin,
Sequence = "-1",
Title = parent.TitleWithSubtitle
}
};
//A series parent will always have exactly 1 Series
parent.Series = new Series[]
if (parent.PurchaseDate == default)
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().First();
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
}
}
}

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="8.1.0.1" />
<PackageReference Include="AudibleApi" Version="8.2.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -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);
}
}
}
}

View File

@@ -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)

View File

@@ -5,7 +5,7 @@ using Dinah.Core;
namespace DataLayer
{
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
public class Rating : ValueObject_Static<Rating>
public class Rating : ValueObject_Static<Rating>, IComparable<Rating>, IComparable
{
public float OverallRating { get; private set; }
public float PerformanceRating { get; private set; }
@@ -38,6 +38,16 @@ namespace DataLayer
yield return StoryRating;
}
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
}
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
public int CompareTo(Rating other)
{
var compare = OverallRating.CompareTo(other.OverallRating);
if (compare != 0) return compare;
compare = PerformanceRating.CompareTo(other.PerformanceRating);
if (compare != 0) return compare;
return StoryRating.CompareTo(other.StoryRating);
}
public int CompareTo(object obj) => obj is Rating second ? CompareTo(second) : -1;
}
}

View File

@@ -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));
@@ -103,7 +122,11 @@ namespace DataLayer
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
{
var changed = Rating.OverallRating != overallRating || Rating.PerformanceRating != performanceRating || Rating.StoryRating != storyRating;
Rating.Update(overallRating, performanceRating, storyRating);
if (changed) OnItemChanged(nameof(Rating));
}
#endregion
#region LiberatedStatuses

View 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
}
}
}

View File

@@ -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");
}
}
}

View 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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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");

View File

@@ -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)
);
}
}

View File

@@ -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}";
}
}

View File

@@ -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,29 @@ namespace DtoImporterService
}
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
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;
}
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.IsAyce is true
&& item.DtoItem.Plans?.Any(p => p.IsAyce) is not true;
}
}

View File

@@ -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));

View File

@@ -0,0 +1,77 @@
using Dinah.Core;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace FileManager
{
public sealed class LogArchiver : IAsyncDisposable
{
public Encoding Encoding { get; set; }
public string FileName { get; }
private readonly ZipArchive archive;
public LogArchiver(string filename) : this(filename, Encoding.UTF8) { }
public LogArchiver(string filename, Encoding encoding)
{
FileName = ArgumentValidator.EnsureNotNull(filename, nameof(filename));
Encoding = ArgumentValidator.EnsureNotNull(encoding, nameof(encoding));
archive = new ZipArchive(File.Open(FileName, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.ReadWrite), ZipArchiveMode.Update, false, Encoding);
}
public void DeleteOlderThan(DateTime cutoffDate)
=> DeleteEntries(archive.Entries.Where(e => e.LastWriteTime < cutoffDate).ToList());
public void DeleteOldestN(int quantity)
=> DeleteEntries(archive.Entries.OrderBy(e => e.LastWriteTime).Take(quantity).ToList());
public void DeleteAllButNewestN(int quantity)
=> DeleteEntries(archive.Entries.OrderByDescending(e => e.LastWriteTime).Skip(quantity).ToList());
private void DeleteEntries(List<ZipArchiveEntry> entries)
{
foreach (var e in entries)
e.Delete();
}
public async Task AddFileAsync(string name, JObject contents, string comment = null)
{
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment);
}
public async Task AddFileAsync(string name, string contents, string comment = null)
{
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents), comment);
}
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string comment = null)
{
ArgumentValidator.EnsureNotNull(name, nameof(name));
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
return Task.Run(() => AddfileInternal(name, contents.Span, comment));
}
private readonly object lockObj = new();
private void AddfileInternal(string name, ReadOnlySpan<byte> contents, string comment)
{
lock (lockObj)
{
var entry = archive.CreateEntry(name, CompressionLevel.SmallestSize);
entry.Comment = comment;
using var entryStream = entry.Open();
entryStream.Write(contents);
}
}
public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose);
}
}

View File

@@ -72,7 +72,7 @@
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview4" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.5.1" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.6.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 455 B

View File

@@ -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" />

View File

@@ -1,6 +1,8 @@
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using LibationAvalonia.Dialogs;
using LibationFileManager;
using System.Threading.Tasks;
namespace LibationAvalonia
@@ -20,5 +22,21 @@ namespace LibationAvalonia
=> dialogWindow.ShowDialog<DialogResult>(owner ?? App.MainWindow);
public static Window GetParentWindow(this IControl control) => control.VisualRoot as Window;
private static Bitmap defaultImage;
public static Bitmap TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native)
{
try
{
using var ms = new System.IO.MemoryStream(picture);
return new Bitmap(ms);
}
catch
{
using var ms = new System.IO.MemoryStream(PictureStorage.GetDefaultImage(defaultSize));
return defaultImage ??= new Bitmap(ms);
}
}
}
}

View 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>

View 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); }
}
}

View File

@@ -1,17 +1,15 @@
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using System;
using System.Linq;
using LibationUiBase.GridView;
namespace LibationAvalonia.Controls
{
public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn
public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn
{
protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem)
{
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
ele.IsThreeState = dataItem is SeriesEntry;
ele.IsThreeState = dataItem is ISeriesEntry;
return ele;
}
}

View File

@@ -1,6 +1,6 @@
using Avalonia.Collections;
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using LibationUiBase.GridView;
using System;
using System.Reflection;
@@ -30,7 +30,7 @@ namespace LibationAvalonia.Controls
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell && cell.DataContext is GridEntry entry)
if (sender is DataGridCell cell && cell.DataContext is IGridEntry entry)
{
var args = new DataGridCellContextMenuStripNeededEventArgs
{
@@ -63,7 +63,7 @@ namespace LibationAvalonia.Controls
public string CellClipboardContents => GetCellValue(Column, GridEntry);
public DataGridColumn Column { get; init; }
public GridEntry GridEntry { get; init; }
public IGridEntry GridEntry { get; init; }
public ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.Items as AvaloniaList<Control>;

View File

@@ -1,5 +1,6 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Interactivity;
using DataLayer;
using ReactiveUI;
@@ -7,19 +8,10 @@ using System;
namespace LibationAvalonia.Controls
{
public class StarStringConverter : Avalonia.Data.Converters.IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
=> value is Rating rating ? rating.ToStarString() : string.Empty;
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
=> throw new NotImplementedException();
}
public class DataGridMyRatingColumn : DataGridBoundColumn
{
[Avalonia.Data.AssignBinding]
public Avalonia.Data.IBinding BackgroundBinding { get; set; }
[AssignBinding] public IBinding BackgroundBinding { get; set; }
[AssignBinding] public IBinding OpacityBinding { get; set; }
private static Rating DefaultRating => new Rating(0, 0, 0);
public DataGridMyRatingColumn()
{
@@ -40,13 +32,11 @@ namespace LibationAvalonia.Controls
ToolTip.SetTip(myRatingElement, "Click to change ratings");
if (Binding != null)
{
myRatingElement.Bind(BindingTarget, Binding);
}
if (BackgroundBinding != null)
{
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
}
if (OpacityBinding != null)
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
return myRatingElement;
}
@@ -58,10 +48,11 @@ namespace LibationAvalonia.Controls
Name = "CellMyRatingEditor",
IsEditingMode = true
};
if (BackgroundBinding != null)
{
myRatingElement.Bind(MyRatingCellEditor.BackgroundProperty, BackgroundBinding);
}
if (OpacityBinding != null)
myRatingElement.Bind(MyRatingCellEditor.OpacityProperty, OpacityBinding);
return myRatingElement;
}

View File

@@ -1,5 +1,4 @@
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
@@ -10,7 +9,6 @@ using LibationAvalonia.ViewModels;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System;
namespace LibationAvalonia.Dialogs
{
@@ -112,8 +110,7 @@ namespace LibationAvalonia.Dialogs
//init cover image
var picture = PictureStorage.GetPictureSynchronously(new PictureDefinition(libraryBook.Book.PictureId, PictureSize._80x80));
using var ms = new System.IO.MemoryStream(picture);
Cover = new Bitmap(ms);
Cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80);
//init book details
DetailsText = @$"

View File

@@ -1,11 +1,7 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
using System;
using System.ComponentModel;
using System.IO;
using ReactiveUI;
using Avalonia.Platform.Storage;
@@ -16,23 +12,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 +26,11 @@ namespace LibationAvalonia.Dialogs
AvaloniaXamlLoader.Load(this);
}
public void SetCoverBytes(byte[] cover)
{
_bitmapHolder.CoverImage = AvaloniaUtils.TryLoadImageOrDefault(cover);
}
public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var options = new FilePickerSaveOptions
@@ -70,7 +56,7 @@ namespace LibationAvalonia.Dialogs
try
{
File.WriteAllBytes(uri.LocalPath, CoverBytes);
_bitmapHolder.CoverImage.Save(uri.LocalPath);
}
catch (Exception ex)
{

View File

@@ -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

View File

@@ -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; }

View 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&#xa;from Libation" />
</Button>
</Grid>
</Grid>
</Window>

View 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();
}
}

View File

@@ -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" />

View File

@@ -0,0 +1,26 @@
using Avalonia.Media;
using Avalonia.Media.Imaging;
using DataLayer;
using LibationUiBase.GridView;
using System;
namespace LibationAvalonia.ViewModels
{
public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable
{
public override IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
private AvaloniaEntryStatus(LibraryBook libraryBook) : base(libraryBook) { }
public static EntryStatus Create(LibraryBook libraryBook) => new AvaloniaEntryStatus(libraryBook);
protected override Bitmap LoadImage(byte[] picture)
=> AvaloniaUtils.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80);
protected override Bitmap GetResourceImage(string rescName)
{
//These images are assest, so assume they will never corrupt.
using var stream = App.OpenAsset(rescName + ".png");
return new Bitmap(stream);
}
}
}

View File

@@ -1,9 +0,0 @@
namespace LibationAvalonia.ViewModels
{
public class BookTags
{
private string _tags;
public string Tags { get => _tags; init { _tags = value; HasTags = !string.IsNullOrEmpty(_tags); } }
public bool HasTags { get; init; }
}
}

View File

@@ -1,209 +0,0 @@
using ApplicationServices;
using Avalonia.Media;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using LibationUiBase;
using ReactiveUI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : ViewModelBase
{
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public int ListIndex { get; set; }
[Browsable(false)] public Book Book => LibraryBook.Book;
#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; }
protected Rating _myRating;
public Rating MyRating
{
get => _myRating;
set
{
if (_myRating != value
&& value.OverallRating != 0
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
}
}
}
protected bool? _remove = false;
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;
#endregion
#region User rating
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
{
_myRating = rating;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
this.RaisePropertyChanged(nameof(MyRating));
}
#endregion
#region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary();
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by GridEntryBindingList for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
};
#endregion
#region Cover Art
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
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);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
// state validation
if (e is null ||
e.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture is null ||
e.Picture.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
using var ms = new System.IO.MemoryStream(e.Picture);
Cover = new Avalonia.Media.Imaging.Bitmap(ms);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
protected static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
protected static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
protected static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
details.Add($"Account: {locale} - {acct}");
if (libraryBook.Book.HasPdf())
details.Add("Has PDF");
if (libraryBook.Book.IsAbridged)
details.Add("Abridged");
if (libraryBook.Book.DatePublished.HasValue)
details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
// this goes last since it's most likely to have a line-break
if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
if (!details.Any())
return "[details not imported]";
return string.Join("\r\n", details);
}
#endregion
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
}

View File

@@ -1,119 +0,0 @@
using Avalonia.Media.Imaging;
using DataLayer;
using ReactiveUI;
using System;
using System.Collections.Generic;
namespace LibationAvalonia.ViewModels
{
public class LiberateButtonStatus : ViewModelBase, IComparable
{
public LiberateButtonStatus(bool isSeries)
{
IsSeries = isSeries;
}
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
private bool _expanded;
public bool Expanded
{
get => _expanded;
set
{
this.RaiseAndSetIfChanged(ref _expanded, value);
this.RaisePropertyChanged(nameof(Image));
this.RaisePropertyChanged(nameof(ToolTip));
}
}
private bool IsSeries { get; }
public Bitmap Image => GetLiberateIcon();
public string ToolTip => GetTooltip();
static Dictionary<string, Bitmap> iconCache = new();
/// <summary> Defines the Liberate column's sorting behavior </summary>
public int CompareTo(object obj)
{
if (obj is not LiberateButtonStatus second) return -1;
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
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);
}
private Bitmap GetLiberateIcon()
{
if (IsSeries)
return Expanded ? GetFromResources("minus") : GetFromResources("plus");
if (BookStatus == LiberatedStatus.Error)
return GetFromResources("error");
string image_lib = BookStatus switch
{
LiberatedStatus.Liberated => "green",
LiberatedStatus.PartialDownload => "yellow",
LiberatedStatus.NotLiberated => "red",
_ => throw new Exception("Unexpected liberation state")
};
string image_pdf = PdfStatus switch
{
LiberatedStatus.Liberated => "_pdf_yes",
LiberatedStatus.NotLiberated => "_pdf_no",
LiberatedStatus.Error => "_pdf_no",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
return GetFromResources($"liberate_{image_lib}{image_pdf}");
}
private string GetTooltip()
{
if (IsSeries)
return Expanded ? "Click to Collpase" : "Click to Expand";
if (BookStatus == LiberatedStatus.Error)
return "Book downloaded ERROR";
string libState = BookStatus switch
{
LiberatedStatus.Liberated => "Liberated",
LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded",
LiberatedStatus.NotLiberated => "Book NOT downloaded",
_ => throw new Exception("Unexpected liberation state")
};
string pdfState = PdfStatus switch
{
LiberatedStatus.Liberated => "\r\nPDF downloaded",
LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded",
LiberatedStatus.Error => "\r\nPDF downloaded ERROR",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
var mouseoverText = libState + pdfState;
if (BookStatus == LiberatedStatus.NotLiberated ||
BookStatus == LiberatedStatus.PartialDownload ||
PdfStatus == LiberatedStatus.NotLiberated)
mouseoverText += "\r\nClick to complete";
return mouseoverText;
}
private static Bitmap GetFromResources(string rescName)
{
if (iconCache.ContainsKey(rescName)) return iconCache[rescName];
iconCache[rescName] = new Bitmap(App.OpenAsset(rescName + ".png"));
return iconCache[rescName];
}
}
}

View File

@@ -1,149 +0,0 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationAvalonia.ViewModels
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry : GridEntry
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public SeriesEntry Parent { get; init; }
#region Model properties exposed to the view
private DateTime lastStatusUpdate = default;
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override bool? Remove
{
get => _remove;
set
{
_remove = value ?? false;
Parent?.ChildRemoveUpdate();
this.RaisePropertyChanged(nameof(Remove));
}
}
public override LiberateButtonStatus Liberate
{
get
{
//Cache these statuses for faster sorting.
if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2)
{
_bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book);
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus(IsSeries) { 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;
#endregion
public LibraryBookEntry(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
LoadCover();
Title = Book.Title;
Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
#region detect changes to the model, update the view.
/// <summary>
/// This event handler receives notifications from the model that it has changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
var udi = sender as UserDefinedItem;
if (udi.Book.AudibleProductId != Book.AudibleProductId)
return;
// UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
// - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
// - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
switch (itemName)
{
case nameof(udi.Tags):
Book.UserDefinedItem.Tags = udi.Tags;
this.RaisePropertyChanged(nameof(BookTags));
break;
case nameof(udi.BookStatus):
Book.UserDefinedItem.BookStatus = udi.BookStatus;
_bookStatus = udi.BookStatus;
this.RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.PdfStatus):
Book.UserDefinedItem.SetPdfStatus(udi.PdfStatus);
_pdfStatus = udi.PdfStatus;
this.RaisePropertyChanged(nameof(Liberate));
break;
}
}
#endregion
#region Data Sorting
/// <summary>Create getters for all member object values by name </summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Book.LengthInMinutes },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => LibraryBook.DateAdded },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
~LibraryBookEntry()
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
}
}
}

View File

@@ -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
{

View File

@@ -115,16 +115,14 @@ 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 Bitmap(ms);
_cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == LibraryBook.Book.PictureId)
{
using var ms = new System.IO.MemoryStream(e.Picture);
Cover = new Bitmap(ms);
Cover = AvaloniaUtils.TryLoadImageOrDefault(e.Picture, PictureSize._80x80);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}

View File

@@ -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)

View File

@@ -1,15 +1,16 @@
using ApplicationServices;
using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Threading;
using DataLayer;
using LibationAvalonia.Dialogs.Login;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using ReactiveUI;
using Avalonia.Threading;
using ApplicationServices;
using AudibleUtilities;
using LibationAvalonia.Dialogs.Login;
using Avalonia.Collections;
namespace LibationAvalonia.ViewModels
{
@@ -20,69 +21,232 @@ 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<IGridEntry> SOURCE = new();
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private List<GridEntry> FilteredInGridEntries;
private List<IGridEntry> 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); }
public List<LibraryBook> GetVisibleBookEntries()
=> GridEntries
.OfType<LibraryBookEntry>()
.OfType<ILibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList();
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
private IEnumerable<ILibraryBookEntry> GetAllBookEntries()
=> SOURCE
.BookEntries();
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
internal void BindToGrid(List<LibraryBook> dbBooks)
{
GridEntries = new(SOURCE) { Filter = CollectionFilter };
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry<AvaloniaEntryStatus>(b))
.ToList<IGridEntry>();
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()).ToList();
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<AvaloniaEntryStatus>(parent, seriesEpisodes);
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
}
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = QueryResults(geList, FilterString);
SOURCE.AddRange(geList.OrderByDescending(e => e.DateAdded));
GridEntries.CollectionChanged += (_, _)
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<ILibraryBookEntry>().Count());
VisibleCountChanged?.Invoke(this, GridEntries.OfType<ILibraryBookEntry>().Count());
}
/// <summary>
/// Call when there's been a change to the library
/// </summary>
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
internal async Task UpdateGridAsync(List<LibraryBook> dbBooks)
{
try
#region Add new or update existing grid entries
//Add absent entries to grid, or update existing entry
var allEntries = SOURCE.BookEntries().ToList();
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
await Dispatcher.UIThread.InvokeAsync(() =>
{
var existingSeriesEntries = SOURCE.SeriesEntries().ToList();
FilteredInGridEntries?.Clear();
SOURCE.Clear();
SOURCE.AddRange(CreateGridEntries(dbBooks));
//If replacing the list, preserve user's existing collapse/expand
//state. When resetting a list, default state is cosed.
foreach (var series in existingSeriesEntries)
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId);
if (sEntry is SeriesEntry se)
se.Liberate.Expanded = series.Liberate.Expanded;
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
if (libraryBook.Book.IsProduct())
UpsertBook(libraryBook, existingEntry);
else if (parentedEpisodes.Contains(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.Liberate.IsEpisode))
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<ILibraryBookEntry> removedBooks, IEnumerable<ISeriesEntry> removedSeries)
{
foreach (var removed in removedBooks.Cast<IGridEntry>().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, ILibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
SOURCE.Insert(0, new LibraryBookEntry<AvaloniaEntryStatus>(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
ILibraryBookEntry 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<AvaloniaEntryStatus>(seriesBook, 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 LibraryBookEntry<AvaloniaEntryStatus>(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//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));
//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()
@@ -93,39 +257,7 @@ namespace LibationAvalonia.ViewModels
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))
.Cast<GridEntry>()
.ToList();
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent()))
{
var seriesEpisodes = episodes.FindChildren(parent);
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
}
var bookList = geList.OrderByDescending(e => e.DateAdded).ToList();
//ListIndex is used by RowComparer to make column sort stable
int index = 0;
foreach (GridEntry di in bookList)
di.ListIndex = index++;
return bookList;
}
public async Task ToggleSeriesExpanded(SeriesEntry seriesEntry)
public async Task ToggleSeriesExpanded(ISeriesEntry seriesEntry)
{
seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded;
@@ -138,9 +270,6 @@ namespace LibationAvalonia.ViewModels
public async Task Filter(string searchString)
{
if (searchString == FilterString)
return;
FilterString = searchString;
if (SOURCE.Count == 0)
@@ -153,8 +282,8 @@ namespace LibationAvalonia.ViewModels
private bool CollectionFilter(object item)
{
if (item is LibraryBookEntry lbe
&& lbe.IsEpisode
if (item is ILibraryBookEntry lbe
&& lbe.Liberate.IsEpisode
&& lbe.Parent?.Liberate?.Expanded != true)
return false;
@@ -163,13 +292,13 @@ namespace LibationAvalonia.ViewModels
return FilteredInGridEntries.Contains(item);
}
private static List<GridEntry> QueryResults(IEnumerable<GridEntry> entries, string searchString)
private static List<IGridEntry> QueryResults(IEnumerable<IGridEntry> entries, string searchString)
{
if (string.IsNullOrEmpty(searchString)) return null;
var searchResultSet = SearchEngineCommands.Search(searchString);
var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
var booksFilteredIn = entries.BookEntries().Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe);
//Find all series containing children that match the search criteria
var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(searchResultSet.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
@@ -206,10 +335,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 +349,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 +367,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);
}
@@ -286,7 +413,7 @@ namespace LibationAvalonia.ViewModels
private void GridEntry_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry lbEntry)
if (e.PropertyName == nameof(IGridEntry.Remove) && sender is ILibraryBookEntry)
{
int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true);
RemovableCountChanged?.Invoke(this, removeCount);

View File

@@ -1,38 +0,0 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationAvalonia.ViewModels
{
#nullable enable
internal static class QueryExtensions
{
public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return gridEntries.SeriesEntries().FirstOrDefault(
lb =>
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.LibraryBook.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
}
#nullable disable
}

View File

@@ -1,5 +1,5 @@
using Avalonia.Controls;
using System;
using LibationUiBase.GridView;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
@@ -13,7 +13,7 @@ namespace LibationAvalonia.ViewModels
/// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex
/// properties when 2 items compare equal.
/// </summary>
internal class RowComparer : IComparer, IComparer<GridEntry>, IComparer<object>
internal class RowComparer : IComparer, IComparer<IGridEntry>, IComparer<object>
{
private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance);
@@ -33,22 +33,22 @@ namespace LibationAvalonia.ViewModels
if (x is not null && y is null) return 1;
if (x is null && y is null) return 0;
var geA = (GridEntry)x;
var geB = (GridEntry)y;
var geA = (IGridEntry)x;
var geB = (IGridEntry)y;
var sortDirection = GetSortOrder();
SeriesEntry parentA = null;
SeriesEntry parentB = null;
ISeriesEntry parentA = null;
ISeriesEntry parentB = null;
if (geA is LibraryBookEntry lbA && lbA.Parent is SeriesEntry seA)
if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA)
parentA = seA;
if (geB is LibraryBookEntry lbB && lbB.Parent is SeriesEntry seB)
if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB)
parentB = seB;
//both a and b are top-level grid entries
if (parentA is null && parentB is null)
return InternalCompare(geA, geB, sortDirection);
return InternalCompare(geA, geB);
//a is top-level, b is a child
if (parentA is null && parentB is not null)
@@ -57,7 +57,7 @@ namespace LibationAvalonia.ViewModels
if (parentB == geA)
return sortDirection is ListSortDirection.Ascending ? -1 : 1;
else
return InternalCompare(geA, parentB, sortDirection);
return InternalCompare(geA, parentB);
}
//a is a child, b is a top-level
@@ -67,7 +67,7 @@ namespace LibationAvalonia.ViewModels
if (parentA == geB)
return sortDirection is ListSortDirection.Ascending ? 1 : -1;
else
return InternalCompare(parentA, geB, sortDirection);
return InternalCompare(parentA, geB);
}
//both are children of the same series, always present in order of series index, ascending
@@ -75,29 +75,22 @@ namespace LibationAvalonia.ViewModels
return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1);
//a and b are children of different series.
return InternalCompare(parentA, parentB, sortDirection);
return InternalCompare(parentA, parentB);
}
//Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection
private ListSortDirection? GetSortOrder()
=> CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?;
private int InternalCompare(GridEntry x, GridEntry y, ListSortDirection? sortDirection)
private int InternalCompare(IGridEntry x, IGridEntry y)
{
var val1 = x.GetMemberValue(PropertyName);
var val2 = y.GetMemberValue(PropertyName);
var compareResult = x.GetMemberComparer(val1.GetType()).Compare(val1, val2);
//If items compare equal, compare them by their positions in the the list.
//This is how you achieve a stable sort.
if (compareResult == 0)
return x.ListIndex.CompareTo(y.ListIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1);
else
return compareResult;
return x.GetMemberComparer(val1.GetType()).Compare(val1, val2); ;
}
public int Compare(GridEntry x, GridEntry y)
public int Compare(IGridEntry x, IGridEntry y)
{
return Compare((object)x, y);
}

View File

@@ -1,111 +0,0 @@
using DataLayer;
using Dinah.Core;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationAvalonia.ViewModels
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry
{
[Browsable(false)] public List<LibraryBookEntry> Children { get; }
[Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove == true);
_remove = removeCount == 0 ? false : (removeCount == Children.Count ? true : null);
this.RaisePropertyChanged(nameof(Remove));
}
#region Model properties exposed to the view
public override bool? Remove
{
get => _remove;
set
{
_remove = value ?? false;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
this.RaisePropertyChanged(nameof(Remove));
}
}
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;
#endregion
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
Liberate = new LiberateButtonStatus(IsSeries);
SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
Children = children
.Select(c => new LibraryBookEntry(c) { Parent = this })
.OrderBy(c => c.SeriesIndex)
.ToList();
Title = Book.Title;
Series = Book.SeriesNames();
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(LibraryBook);
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";
}
#region Data Sorting
/// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(BookTags), () => BookTags?.Tags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
}
}

View File

@@ -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

View File

@@ -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;
}
}
}

View File

@@ -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>

View File

@@ -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()

View File

@@ -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

View File

@@ -32,11 +32,7 @@
<Setter Property="Padding" Value="4"/>
</Style>
</DataGrid.Styles>
<DataGrid.Resources>
<controls:StarStringConverter x:Key="starStringConverter" />
</DataGrid.Resources>
<DataGrid.Columns>
<DataGridTemplateColumn
@@ -61,9 +57,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 Liberate.Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" IsVisible="{Binding !Liberate.IsUnavailable}">
<Image Source="{Binding Liberate.ButtonImage}" Stretch="None" />
</Button>
<Image Source="{Binding Liberate.ButtonImage}" Stretch="None" IsVisible="{Binding Liberate.IsUnavailable}"/>
<Panel Background="{StaticResource DisabledGrayBrush}" IsVisible="{Binding Liberate.IsUnavailable}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
@@ -71,7 +71,7 @@
<DataGridTemplateColumn CanUserSort="False" Width="80" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Image Opacity="{Binding Opacity}" Tapped="Cover_Click" Height="80" Source="{Binding Cover}" ToolTip.Tip="Click to see full size" />
<Image Opacity="{Binding Liberate.Opacity}" Tapped="Cover_Click" Height="80" Source="{Binding Cover}" ToolTip.Tip="Click to see full size" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -79,7 +79,7 @@
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Title}" />
</Panel>
</DataTemplate>
@@ -89,7 +89,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Authors}" />
</Panel>
</DataTemplate>
@@ -99,7 +99,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Narrators}" />
</Panel>
</DataTemplate>
@@ -109,7 +109,7 @@
<controls:DataGridTemplateColumnExt Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Length}" />
</Panel>
</DataTemplate>
@@ -119,7 +119,7 @@
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Series}" />
</Panel>
</DataTemplate>
@@ -129,7 +129,7 @@
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<TextBlock Text="{Binding Description}" FontSize="11" VerticalAlignment="Top" />
</Panel>
</DataTemplate>
@@ -139,7 +139,7 @@
<controls:DataGridTemplateColumnExt Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Category}" />
</Panel>
</DataTemplate>
@@ -151,14 +151,15 @@
IsReadOnly="true"
Width="115"
SortMemberPath="ProductRating" CanUserSort="True"
BackgroundBinding="{Binding BackgroundBrush}"
ClipboardContentBinding="{Binding ProductRating, Converter={StaticResource starStringConverter}}"
OpacityBinding="{Binding Liberate.Opacity}"
BackgroundBinding="{Binding Liberate.BackgroundBrush}"
ClipboardContentBinding="{Binding ProductRating}"
Binding="{Binding ProductRating}" />
<controls:DataGridTemplateColumnExt Width="90" Header="Purchase&#xA;Date" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding PurchaseDate}" />
</Panel>
</DataTemplate>
@@ -170,27 +171,38 @@
IsReadOnly="false"
Width="115"
SortMemberPath="MyRating" CanUserSort="True"
BackgroundBinding="{Binding BackgroundBrush}"
ClipboardContentBinding="{Binding MyRating, Converter={StaticResource starStringConverter}}"
OpacityBinding="{Binding Liberate.Opacity}"
BackgroundBinding="{Binding Liberate.BackgroundBrush}"
ClipboardContentBinding="{Binding MyRating}"
Binding="{Binding MyRating, Mode=TwoWay}" />
<controls:DataGridTemplateColumnExt Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Panel Background="{Binding BackgroundBrush}">
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.BackgroundBrush}">
<TextBlock Text="{Binding Misc}" TextWrapping="WrapWithOverflow" FontSize="10" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
<controls:DataGridTemplateColumnExt Width="102" Header="Last&#xA;Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button IsVisible="{Binding !IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
<Panel>
<Image IsVisible="{Binding !BookTags.HasTags}" Stretch="None" Source="/Assets/edit_25x25.png" />
<TextBlock IsVisible="{Binding BookTags.HasTags}" FontSize="12" TextWrapping="WrapWithOverflow" Text="{Binding BookTags.Tags}"/>
<Panel Opacity="{Binding Liberate.Opacity}" Background="{Binding Liberate.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}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Button IsVisible="{Binding !Liberate.IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
<Panel Opacity="{Binding Liberate.Opacity}">
<Image IsVisible="{Binding BookTags, Converter={x:Static StringConverters.IsNullOrEmpty}}" Stretch="None" Source="/Assets/edit_25x25.png" />
<TextBlock IsVisible="{Binding BookTags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" FontSize="12" TextWrapping="WrapWithOverflow" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{Binding BookTags}"/>
</Panel>
</Button>
</DataTemplate>

View File

@@ -1,7 +1,3 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
@@ -13,6 +9,11 @@ using LibationAvalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Views
{
@@ -31,20 +32,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;
@@ -56,7 +62,7 @@ namespace LibationAvalonia.Views
{
column.CustomSortComparer = new RowComparer(column);
}
}
}
private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
{
@@ -78,37 +84,37 @@ namespace LibationAvalonia.Views
#region Cell Context Menu
public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args)
{
// stop light
if (args.Column.SortMemberPath == "Liberate")
{
// stop light
if (args.Column.SortMemberPath == "Liberate")
{
var entry = args.GridEntry;
if (entry.IsSeries)
if (entry.Liberate.IsSeries)
return;
var setDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated
};
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
var setDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated
};
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
var setNotDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Not Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
};
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var setNotDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Not Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
};
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));
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
locateFileMenuItem.Click += async (_, __) =>
{
try
{
locateFileMenuItem.Click += async (_, __) =>
{
try
{
var openFileDialogOptions = new FilePickerOpenOptions
{
Title = $"Locate the audio file for '{entry.Book.Title}'",
@@ -123,19 +129,19 @@ namespace LibationAvalonia.Views
var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
var selectedFile = selectedFiles.SingleOrDefault();
if (selectedFile?.TryGetUri(out var uri) is true)
FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath);
}
catch (Exception ex)
{
var msg = "Error saving book's location";
await MessageBox.ShowAdminAlert(null, msg, msg, ex);
}
};
if (selectedFile?.TryGetUri(out var uri) is true)
FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath);
}
catch (Exception ex)
{
var msg = "Error saving book's location";
await MessageBox.ShowAdminAlert(null, msg, msg, ex);
}
};
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);
@@ -152,7 +158,7 @@ namespace LibationAvalonia.Views
new Separator(),
bookRecordMenuItem
});
}
}
else
{
// any non-stop light column
@@ -195,7 +201,7 @@ namespace LibationAvalonia.Views
{
var itemName = column.SortMemberPath;
if (itemName == nameof(GridEntry.Remove))
if (itemName == nameof(IGridEntry.Remove))
continue;
menuItems.Add
@@ -286,7 +292,7 @@ namespace LibationAvalonia.Views
{
var button = args.Source as Button;
if (button.DataContext is SeriesEntry sEntry)
if (button.DataContext is ISeriesEntry sEntry)
{
await _viewModel.ToggleSeriesExpanded(sEntry);
@@ -294,7 +300,7 @@ namespace LibationAvalonia.Views
//to the topright cell. Reset focus onto the clicked button's cell.
(sender as Button).Parent?.Focus();
}
else if (button.DataContext is LibraryBookEntry lbEntry)
else if (button.DataContext is ILibraryBookEntry lbEntry)
{
LiberateClicked?.Invoke(this, lbEntry.LibraryBook);
}
@@ -306,9 +312,15 @@ namespace LibationAvalonia.Views
imageDisplayDialog.Close();
}
public void Version_DoubleClick(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is Control panel && panel.DataContext is ILibraryBookEntry 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)
if (sender is not Image tblock || tblock.DataContext is not IGridEntry gEntry)
return;
if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible)
@@ -321,7 +333,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 +348,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;
@@ -347,7 +359,7 @@ namespace LibationAvalonia.Views
public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args)
{
if (sender is Control tblock && tblock.DataContext is GridEntry gEntry)
if (sender is Control tblock && tblock.DataContext is IGridEntry gEntry)
{
var pt = tblock.PointToScreen(tblock.Bounds.TopRight);
var displayWindow = new DescriptionDisplayDialog
@@ -376,7 +388,7 @@ namespace LibationAvalonia.Views
{
var button = args.Source as Button;
if (button.DataContext is LibraryBookEntry lbEntry && VisualRoot is Window window)
if (button.DataContext is ILibraryBookEntry lbEntry && VisualRoot is Window window)
{
if (bookDetailsForm is null || !bookDetailsForm.IsVisible)
{

View File

@@ -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

View File

@@ -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)")]

View File

@@ -67,7 +67,7 @@ namespace LibationFileManager
}
DownloadQueue.Add(def);
return (true, getDefaultImage(def.Size));
return (true, GetDefaultImage(def.Size));
}
}
@@ -96,7 +96,7 @@ namespace LibationFileManager
public static void SetDefaultImage(PictureSize pictureSize, byte[] bytes)
=> defaultImages[pictureSize] = bytes;
private static byte[] getDefaultImage(PictureSize size)
public static byte[] GetDefaultImage(PictureSize size)
=> defaultImages.ContainsKey(size)
? defaultImages[size]
: new byte[0];
@@ -120,7 +120,7 @@ namespace LibationFileManager
private static byte[] downloadBytes(PictureDefinition def)
{
if (def.PictureId is null)
return getDefaultImage(def.Size);
return GetDefaultImage(def.Size);
try
{
@@ -135,7 +135,7 @@ namespace LibationFileManager
}
catch
{
return getDefaultImage(def.Size);
return GetDefaultImage(def.Size);
}
}
}

View File

@@ -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

View File

@@ -0,0 +1,178 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using System;
using System.Collections.Generic;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
public interface IEntryStatus
{
static abstract EntryStatus Create(LibraryBook libraryBook);
}
//This Class holds all book entry status info to help the grid properly render entries.
//The reason this info is in here instead of GridEntry is because all of this info is needed
//for the "Liberate" column's display and sorting functions.
public abstract class EntryStatus : SynchronizeInvoker, IComparable, INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public LiberatedStatus? PdfStatus => LibraryCommands.Pdf_Status(Book);
public LiberatedStatus BookStatus
{
get
{
if (IsSeries) return default;
if ((DateTime.Now - lastBookUpdate).TotalSeconds > 2)
{
//Cache the BookStatus so AudibleFileStorage.AaxcExists isn't
//called multiple times per book while sorting the solumn.
bookStatus = LibraryCommands.Liberated_Status(Book);
lastBookUpdate = DateTime.Now;
}
return bookStatus;
}
}
public bool Expanded
{
get => expanded;
set
{
if (value != expanded)
{
expanded = value;
Invalidate(nameof(Expanded), nameof(ButtonImage));
}
}
}
public bool IsSeries { get; }
public bool IsEpisode { get; }
public bool IsBook => !IsSeries && !IsEpisode;
public bool IsUnavailable => !IsSeries & isAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1;
public abstract object BackgroundBrush { get; }
public object ButtonImage => GetLiberateIcon();
public string ToolTip => GetTooltip();
protected Book Book { get; }
private DateTime lastBookUpdate;
private LiberatedStatus bookStatus;
private bool expanded;
private readonly bool isAbsent;
private static readonly Dictionary<string, object> iconCache = new();
protected EntryStatus(LibraryBook libraryBook)
{
Book = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)).Book;
isAbsent = libraryBook.AbsentFromLastScan is true;
IsEpisode = Book.ContentType is ContentType.Episode;
IsSeries = Book.ContentType is ContentType.Parent;
}
internal protected abstract object LoadImage(byte[] picture);
protected abstract object GetResourceImage(string rescName);
public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args));
public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
/// <summary>Refresh BookStatus (so partial download files are checked again in the filesystem) and raise PropertyChanged for property names.</summary>
public void Invalidate(params string[] properties)
{
lastBookUpdate = default;
foreach (var property in properties)
RaisePropertyChanged(property);
}
/// <summary> Defines the Liberate column's sorting behavior </summary>
public int CompareTo(object obj)
{
if (obj is not EntryStatus second) return -1;
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);
}
private object GetLiberateIcon()
{
if (IsSeries)
return Expanded ? GetAndCacheResource("minus") : GetAndCacheResource("plus");
if (BookStatus == LiberatedStatus.Error)
return GetAndCacheResource("error");
string image_lib = BookStatus switch
{
LiberatedStatus.Liberated => "green",
LiberatedStatus.PartialDownload => "yellow",
LiberatedStatus.NotLiberated => "red",
_ => throw new Exception("Unexpected liberation state")
};
string image_pdf = PdfStatus switch
{
LiberatedStatus.Liberated => "_pdf_yes",
LiberatedStatus.NotLiberated => "_pdf_no",
LiberatedStatus.Error => "_pdf_no",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
return GetAndCacheResource($"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";
string libState = BookStatus switch
{
LiberatedStatus.Liberated => "Liberated",
LiberatedStatus.PartialDownload => "File has been at least\r\npartially downloaded",
LiberatedStatus.NotLiberated => "Book NOT downloaded",
_ => throw new Exception("Unexpected liberation state")
};
string pdfState = PdfStatus switch
{
LiberatedStatus.Liberated => "\r\nPDF downloaded",
LiberatedStatus.NotLiberated => "\r\nPDF NOT downloaded",
LiberatedStatus.Error => "\r\nPDF downloaded ERROR",
null => "",
_ => throw new Exception("Unexpected PDF state")
};
var mouseoverText = libState + pdfState;
if (BookStatus == LiberatedStatus.NotLiberated ||
BookStatus == LiberatedStatus.PartialDownload ||
PdfStatus == LiberatedStatus.NotLiberated)
mouseoverText += "\r\nClick to complete";
return mouseoverText;
}
private object GetAndCacheResource(string rescName)
{
if (!iconCache.ContainsKey(rescName))
iconCache[rescName] = GetResourceImage(rescName);
return iconCache[rescName];
}
}
}

View File

@@ -0,0 +1,324 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using FileLiberator;
using LibationFileManager;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry<TStatus> : SynchronizeInvoker, IGridEntry where TStatus : IEntryStatus
{
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public Book Book => LibraryBook.Book;
#region Model properties exposed to the view
protected bool? remove = false;
private string _purchasedate;
private string _length;
private LastDownloadStatus _lastDownload;
private object _cover;
private string _series;
private string _title;
private string _authors;
private string _narrators;
private string _category;
private string _misc;
private string _description;
private Rating _productrating;
private string _bookTags;
private Rating _myRating;
public abstract bool? Remove { get; set; }
public EntryStatus Liberate { get; private set; }
public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); }
public string Length { get => _length; protected set => RaiseAndSetIfChanged(ref _length, value); }
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => RaiseAndSetIfChanged(ref _lastDownload, value); }
public object Cover { get => _cover; private set => RaiseAndSetIfChanged(ref _cover, value); }
public string Series { get => _series; private set => RaiseAndSetIfChanged(ref _series, value); }
public string Title { get => _title; private set => RaiseAndSetIfChanged(ref _title, value); }
public string Authors { get => _authors; private set => RaiseAndSetIfChanged(ref _authors, value); }
public string Narrators { get => _narrators; private set => RaiseAndSetIfChanged(ref _narrators, value); }
public string Category { get => _category; private set => RaiseAndSetIfChanged(ref _category, value); }
public string Misc { get => _misc; private set => RaiseAndSetIfChanged(ref _misc, value); }
public string Description { get => _description; private set => RaiseAndSetIfChanged(ref _description, value); }
public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); }
public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); }
public Rating MyRating
{
get => _myRating;
set
{
if (_myRating != value && value.OverallRating != 0 && updateReviewTask?.IsCompleted is not false)
updateReviewTask = UpdateRating(value);
}
}
#endregion
#region User rating
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
#endregion
#region View property updating
public void UpdateLibraryBook(LibraryBook libraryBook)
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
LibraryBook = libraryBook;
Liberate = TStatus.Create(libraryBook);
Title = Book.Title;
Series = Book.SeriesNames();
Length = GetBookLengthString();
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
PurchaseDate = GetPurchaseDateString();
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
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;
BookTags = GetBookTags();
RaisePropertyChanged(nameof(MyRating));
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
protected abstract string GetBookTags();
protected virtual DateTime GetPurchaseDate() => LibraryBook.DateAdded;
protected virtual int GetLengthInMinutes() => Book.LengthInMinutes;
protected string GetPurchaseDateString() => GetPurchaseDate().ToString("d");
protected string GetBookLengthString()
{
int bookLenMins = GetLengthInMinutes();
return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
#endregion
#region detect changes to the model, update the view.
/// <summary>
/// This event handler receives notifications from the model that it has changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
var udi = sender as UserDefinedItem;
if (udi.Book.AudibleProductId != Book.AudibleProductId)
return;
// UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
// - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
// - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
switch (itemName)
{
case nameof(udi.BookStatus):
case nameof(udi.PdfStatus):
Liberate.Invalidate(nameof(Liberate.BookStatus), nameof(Liberate.PdfStatus), nameof(Liberate.IsUnavailable), nameof(Liberate.ButtonImage), nameof(Liberate.ToolTip));
RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.Tags):
BookTags = GetBookTags();
Liberate.Invalidate(nameof(Liberate.Opacity));
RaisePropertyChanged(nameof(Liberate));
break;
case nameof(udi.LastDownloaded):
LastDownload = new (udi);
break;
case nameof(udi.Rating):
_myRating = udi.Rating;
RaisePropertyChanged(nameof(MyRating));
break;
}
}
private TRet RaiseAndSetIfChanged<TRet>(ref TRet backingField, TRet newValue, [CallerMemberName] string propertyName = null)
{
if (EqualityComparer<TRet>.Default.Equals(backingField, newValue)) return newValue;
backingField = newValue;
RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
return newValue;
}
public event PropertyChangedEventHandler PropertyChanged;
public void RaisePropertyChanged(PropertyChangedEventArgs args) => this.UIThreadSync(() => PropertyChanged?.Invoke(this, args));
public void RaisePropertyChanged(string propertyName) => RaisePropertyChanged(new PropertyChangedEventArgs(propertyName));
#endregion
#region Sorting
public GridEntry()
{
memberValues = new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => GetLengthInMinutes() },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating },
{ nameof(PurchaseDate), () => GetPurchaseDate() },
{ nameof(ProductRating), () => Book.Rating },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
}
public object GetMemberValue(string memberName) => memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType)
=> memberTypeComparers.TryGetValue(memberType, out IComparer value) ? value : memberTypeComparers[memberType.BaseType];
private readonly Dictionary<string, Func<object>> memberValues;
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(Rating), new ObjectComparer<Rating>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(EntryStatus), new ObjectComparer<EntryStatus>() },
{ typeof(LastDownloadStatus), new ObjectComparer<LastDownloadStatus>() },
};
#endregion
#region Cover Art
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = Liberate.LoadImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
// state validation
if (e?.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture?.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = Liberate.LoadImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
private static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
private static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
private static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
details.Add($"Account: {locale} - {acct}");
if (libraryBook.Book.HasPdf())
details.Add("Has PDF");
if (libraryBook.Book.IsAbridged)
details.Add("Abridged");
if (libraryBook.Book.DatePublished.HasValue)
details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
// this goes last since it's most likely to have a line-break
if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
if (!details.Any())
return "[details not imported]";
return string.Join("\r\n", details);
}
#endregion
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
}
}
}

View File

@@ -0,0 +1,34 @@
using DataLayer;
using Dinah.Core.DataBinding;
using System;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
public interface IGridEntry : IMemberComparable, INotifyPropertyChanged
{
EntryStatus Liberate { get; }
float SeriesIndex { get; }
string AudibleProductId { get; }
string LongDescription { get; }
LibraryBook LibraryBook { get; }
Book Book { get; }
DateTime DateAdded { get; }
bool? Remove { get; set; }
string PurchaseDate { get; }
object Cover { get; }
string Length { get; }
LastDownloadStatus LastDownload { get; }
string Series { get; }
string Title { get; }
string Authors { get; }
string Narrators { get; }
string Category { get; }
string Misc { get; }
string Description { get; }
Rating ProductRating { get; }
Rating MyRating { get; set; }
string BookTags { get; }
void UpdateLibraryBook(LibraryBook libraryBook);
}
}

View File

@@ -0,0 +1,7 @@
namespace LibationUiBase.GridView
{
public interface ILibraryBookEntry : IGridEntry
{
ISeriesEntry Parent { get; }
}
}

View File

@@ -0,0 +1,11 @@
using System.Collections.Generic;
namespace LibationUiBase.GridView
{
public interface ISeriesEntry : IGridEntry
{
List<ILibraryBookEntry> Children { get; }
void ChildRemoveUpdate();
void RemoveChild(ILibraryBookEntry libraryBookEntry);
}
}

View File

@@ -0,0 +1,41 @@
using DataLayer;
using System;
namespace LibationUiBase.GridView
{
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);
}
}
}

View File

@@ -0,0 +1,34 @@
using DataLayer;
using System;
using System.ComponentModel;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry<TStatus> : GridEntry<TStatus>, ILibraryBookEntry where TStatus : IEntryStatus
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public ISeriesEntry Parent { get; }
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
Parent?.ChildRemoveUpdate();
RaisePropertyChanged(nameof(Remove));
}
}
public LibraryBookEntry(LibraryBook libraryBook, ISeriesEntry parent = null)
{
Parent = parent;
UpdateLibraryBook(libraryBook);
LoadCover();
}
protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
}
}

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections;
namespace LibationUiBase
namespace LibationUiBase.GridView
{
public class ObjectComparer<T> : IComparer where T : IComparable
{

View File

@@ -3,24 +3,24 @@ using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationWinForms.GridView
namespace LibationUiBase.GridView
{
#nullable enable
internal static class QueryExtensions
public static class QueryExtensions
{
public static IEnumerable<LibraryBookEntry> BookEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static IEnumerable<ILibraryBookEntry> BookEntries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.OfType<ILibraryBookEntry>();
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static IEnumerable<ISeriesEntry> SeriesEntries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.OfType<ISeriesEntry>();
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : IGridEntry
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
public static IEnumerable<ISeriesEntry> EmptySeries(this IEnumerable<IGridEntry> gridEntries)
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
public static ISeriesEntry? FindSeriesParent(this IEnumerable<IGridEntry> gridEntries, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;

View File

@@ -0,0 +1,68 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationUiBase.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry<TStatus> : GridEntry<TStatus>, ISeriesEntry where TStatus : IEntryStatus
{
public List<ILibraryBookEntry> Children { get; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded);
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove == true);
remove = removeCount == 0 ? false : removeCount == Children.Count ? true : null;
RaisePropertyChanged(nameof(Remove));
}
public override bool? Remove
{
get => remove;
set
{
remove = value ?? false;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
RaisePropertyChanged(nameof(Remove));
}
}
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent, new[] { child }) { }
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
{
LastDownload = new();
SeriesIndex = -1;
Children = children
.Select(c => new LibraryBookEntry<TStatus>(c, this))
.OrderBy(c => c.SeriesIndex)
.ToList<ILibraryBookEntry>();
UpdateLibraryBook(parent);
LoadCover();
}
public void RemoveChild(ILibraryBookEntry lbe)
{
Children.Remove(lbe);
PurchaseDate = GetPurchaseDateString();
Length = GetBookLengthString();
}
protected override string GetBookTags() => null;
protected override int GetLengthInMinutes() => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
protected override DateTime GetPurchaseDate() => Children.Min(c => c.LibraryBook.DateAdded);
}
}

View File

@@ -0,0 +1,52 @@
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats;
using SixLabors.ImageSharp.PixelFormats;
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
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();
}
}

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,10 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LibationUiBase
namespace LibationUiBase
{
public class SampleRateSelection
{

View File

@@ -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;
@@ -68,11 +121,11 @@ namespace LibationAvalonia.ViewModels
public void ClearCurrent()
{
lock(lockObject)
lock (lockObject)
Current = null;
RebuildSecondary();
}
public bool RemoveCompleted(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()

View File

@@ -40,7 +40,7 @@ namespace LibationUiBase
public event EventHandler<DownloadProgress> DownloadProgress;
public event EventHandler<bool> DownloadCompleted;
public async Task CheckForUpgradeAsync(Func<UpgradeEventArgs,Task> upgradeAvailableHandler)
public async Task CheckForUpgradeAsync(Func<UpgradeEventArgs, Task> upgradeAvailableHandler)
{
try
{

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Forms;
using DataLayer;
@@ -42,7 +41,7 @@ namespace LibationWinForms.Dialogs
this.Text = Book.Title;
(_, var picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
this.coverPb.Image = Dinah.Core.WindowsDesktop.Drawing.ImageReader.ToImage(picture);
this.coverPb.Image = WinFormsUtil.TryLoadImageOrDefault(picture, PictureSize._80x80);
var t = @$"
Title: {Book.Title}

View 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;
}
}

View 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);
}
}
}

View 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>

View File

@@ -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)
{

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -2,6 +2,7 @@
using System.Linq;
using System.Windows.Forms;
using LibationFileManager;
using Microsoft.EntityFrameworkCore.Query.SqlExpressions;
namespace LibationWinForms
{
@@ -44,20 +45,31 @@ namespace LibationWinForms
var rect = new Rectangle(x, y, savedState.Width, savedState.Height);
// is proposed rect on a screen?
if (Screen.AllScreens.Any(screen => screen.WorkingArea.Contains(rect)))
if (savedState.IsMaximized)
{
//When a window is maximized, the client rectangle is not on a screen (y is negative).
form.StartPosition = FormStartPosition.Manual;
form.DesktopBounds = rect;
// FINAL: for Maximized: start normal state, set size and location, THEN set max state
form.WindowState = FormWindowState.Maximized;
}
else
{
form.StartPosition = FormStartPosition.WindowsDefaultLocation;
form.Size = rect.Size;
}
// is proposed rect on a screen?
if (Screen.AllScreens.Any(screen => screen.WorkingArea.Contains(rect)))
{
form.StartPosition = FormStartPosition.Manual;
form.DesktopBounds = rect;
}
else
{
form.StartPosition = FormStartPosition.WindowsDefaultLocation;
form.Size = rect.Size;
}
// FINAL: for Maximized: start normal state, set size and location, THEN set max state
form.WindowState = savedState.IsMaximized ? FormWindowState.Maximized : FormWindowState.Normal;
form.WindowState = FormWindowState.Normal;
}
}
public static void SaveSizeAndLocation(this Form form, Configuration config)

View File

@@ -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)));
}
}

View File

@@ -1,10 +1,11 @@
using System.Drawing;
using System.Windows.Forms;
using Dinah.Core.WindowsDesktop.Forms;
using LibationUiBase.GridView;
namespace LibationWinForms.GridView
{
public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn
public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn
{
public EditTagsDataGridViewImageButtonColumn()
{
@@ -15,34 +16,18 @@ namespace LibationWinForms.GridView
internal class EditTagsDataGridViewImageButtonCell : DataGridViewImageButtonCell
{
private static Image ButtonImage { get; } = Properties.Resources.edit_25x25;
private static Color HiddenForeColor { get; } = 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 (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is SeriesEntry)
{
if (rowIndex >= 0 && DataGridView.GetBoundItem<IGridEntry>(rowIndex) is ISeriesEntry)
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, DataGridViewPaintParts.Background | DataGridViewPaintParts.Border);
return;
}
var tagsString = (string)value;
var foreColor = tagsString?.Contains("hidden") == true ? HiddenForeColor : DataGridView.DefaultCellStyle.ForeColor;
if (DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor != foreColor)
{
DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = foreColor;
}
if (tagsString?.Length == 0)
else if (value is string tagStr && tagStr.Length == 0)
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
DrawButtonImage(graphics, ButtonImage, cellBounds);
}
else
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, value, formattedValue, errorText, cellStyle, advancedBorderStyle, paintParts);
}
}
}
}

View File

@@ -1,217 +0,0 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.DataBinding;
using Dinah.Core.WindowsDesktop.Drawing;
using FileLiberator;
using LibationFileManager;
using LibationUiBase;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
namespace LibationWinForms.GridView
{
public enum RemoveStatus
{
NotRemoved,
Removed,
SomeRemoved
}
/// <summary>The View Model base for the DataGridView</summary>
public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
{
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public Book Book => LibraryBook.Book;
[Browsable(false)] public abstract bool IsSeries { get; }
[Browsable(false)] public abstract bool IsEpisode { get; }
[Browsable(false)] public abstract bool IsBook { get; }
#region Model properties exposed to the view
protected RemoveStatus _remove = RemoveStatus.NotRemoved;
public abstract RemoveStatus Remove { get; set; }
public abstract LiberateButtonStatus Liberate { get; }
public Image Cover
{
get => _cover;
protected set
{
_cover = value;
NotifyPropertyChanged();
}
}
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 string ProductRating { get; protected set; }
protected Rating _myRating;
public Rating MyRating
{
get => _myRating;
set
{
if (_myRating != value
&& value.OverallRating != 0
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
updateReviewTask.ContinueWith(t =>
{
if (t.Result)
{
_myRating = value;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, value);
}
NotifyPropertyChanged();
});
}
}
}
public abstract string DisplayTags { get; }
#endregion
#region User rating
private Task<bool> updateReviewTask;
private async Task<bool> UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
return await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating);
}
#endregion
#region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary();
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by GridEntryBindingList for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
protected abstract Dictionary<string, Func<object>> CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ typeof(RemoveStatus), new ObjectComparer<RemoveStatus>() },
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(bool), new ObjectComparer<bool>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberateButtonStatus), new ObjectComparer<LiberateButtonStatus>() },
};
#endregion
#region Cover Art
private Image _cover;
protected void LoadCover()
{
// Get cover art. If it's default, subscribe to PictureCached
(bool isDefault, byte[] picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
if (isDefault)
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = ImageReader.ToImage(picture);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
// state validation
if (e is null ||
e.Definition.PictureId is null ||
Book?.PictureId is null ||
e.Picture is null ||
e.Picture.Length == 0)
return;
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = ImageReader.ToImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
#endregion
#region Static library display functions
/// <summary>This information should not change during <see cref="GridEntry"/> lifetime, so call only once.</summary>
protected static string GetDescriptionDisplay(Book book)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(book?.Description?.Replace("</p> ", "\r\n\r\n</p>") ?? "");
return doc.DocumentNode.InnerText.Trim();
}
protected static string TrimTextToWord(string text, int maxLength)
{
return
text.Length <= maxLength ?
text :
text.Substring(0, maxLength - 3) + "...";
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
protected static string GetMiscDisplay(LibraryBook libraryBook)
{
var details = new List<string>();
var locale = libraryBook.Book.Locale.DefaultIfNullOrWhiteSpace("[unknown]");
var acct = libraryBook.Account.DefaultIfNullOrWhiteSpace("[unknown]");
details.Add($"Account: {locale} - {acct}");
if (libraryBook.Book.HasPdf())
details.Add("Has PDF");
if (libraryBook.Book.IsAbridged)
details.Add("Abridged");
if (libraryBook.Book.DatePublished.HasValue)
details.Add($"Date pub'd: {libraryBook.Book.DatePublished.Value:MM/dd/yyyy}");
// this goes last since it's most likely to have a line-break
if (!string.IsNullOrWhiteSpace(libraryBook.Book.Publisher))
details.Add($"Pub: {libraryBook.Book.Publisher.Trim()}");
if (!details.Any())
return "[details not imported]";
return string.Join("\r\n", details);
}
#endregion
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
}

View File

@@ -1,6 +1,7 @@
using ApplicationServices;
using Dinah.Core.DataBinding;
using LibationSearchEngine;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.ComponentModel;
@@ -20,20 +21,20 @@ namespace LibationWinForms.GridView
* Remove is overridden to ensure that removed items are removed from
* the base list (visible items) as well as the FilterRemoved list.
*/
internal class GridEntryBindingList : BindingList<GridEntry>, IBindingListView
internal class GridEntryBindingList : BindingList<IGridEntry>, IBindingListView
{
public GridEntryBindingList() : base(new List<GridEntry>()) { }
public GridEntryBindingList(IEnumerable<GridEntry> enumeration) : base(new List<GridEntry>(enumeration)) { }
public GridEntryBindingList() : base(new List<IGridEntry>()) { }
public GridEntryBindingList(IEnumerable<IGridEntry> enumeration) : base(new List<IGridEntry>(enumeration)) { }
/// <returns>All items in the list, including those filtered out.</returns>
public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
public List<IGridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
public bool SupportsFiltering => true;
public string Filter { get => FilterString; set => ApplyFilter(value); }
/// <summary>When true, itms will not be checked filtered by search criteria on item changed<summary>
public bool SuspendFilteringOnUpdate { get; set; }
protected MemberComparer<GridEntry> Comparer { get; } = new();
protected MemberComparer<IGridEntry> Comparer { get; } = new();
protected override bool SupportsSortingCore => true;
protected override bool SupportsSearchingCore => true;
protected override bool IsSortedCore => isSorted;
@@ -41,7 +42,7 @@ namespace LibationWinForms.GridView
protected override ListSortDirection SortDirectionCore => listSortDirection;
/// <summary> Items that were removed from the base list due to filtering </summary>
private readonly List<GridEntry> FilterRemoved = new();
private readonly List<IGridEntry> FilterRemoved = new();
private string FilterString;
private SearchResultSet SearchResults;
private bool isSorted;
@@ -59,7 +60,7 @@ namespace LibationWinForms.GridView
public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException();
#endregion
public new void Remove(GridEntry entry)
public new void Remove(IGridEntry entry)
{
FilterRemoved.Remove(entry);
base.Remove(entry);
@@ -73,7 +74,7 @@ namespace LibationWinForms.GridView
FilterString = filterString;
SearchResults = SearchEngineCommands.Search(filterString);
var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (IGridEntry)lbe);
//Find all series containing children that match the search criteria
var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
@@ -99,7 +100,7 @@ namespace LibationWinForms.GridView
ExpandItem(series);
}
public void CollapseItem(SeriesEntry sEntry)
public void CollapseItem(ISeriesEntry sEntry)
{
foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).ToList())
{
@@ -110,7 +111,7 @@ namespace LibationWinForms.GridView
sEntry.Liberate.Expanded = false;
}
public void ExpandItem(SeriesEntry sEntry)
public void ExpandItem(ISeriesEntry sEntry)
{
var sindex = Items.IndexOf(sEntry);
@@ -133,7 +134,7 @@ namespace LibationWinForms.GridView
foreach (var item in FilterRemoved.ToList())
{
if (item is SeriesEntry || (item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)))
if (item is ISeriesEntry || (item is ILibraryBookEntry lbe && (lbe.Liberate.IsBook || lbe.Parent.Liberate.Expanded)))
{
FilterRemoved.Remove(item);
InsertItem(visibleCount++, item);
@@ -145,7 +146,7 @@ namespace LibationWinForms.GridView
else
//No user sort is applied, so do default sorting by DateAdded, descending
{
Comparer.PropertyName = nameof(GridEntry.DateAdded);
Comparer.PropertyName = nameof(IGridEntry.DateAdded);
Comparer.Direction = ListSortDirection.Descending;
Sort();
}
@@ -172,9 +173,9 @@ namespace LibationWinForms.GridView
protected void Sort()
{
var itemsList = (List<GridEntry>)Items;
var itemsList = (List<IGridEntry>)Items;
var children = itemsList.BookEntries().Where(i => i.Parent is not null).ToList();
var children = itemsList.BookEntries().Where(i => i.Liberate.IsEpisode).ToList();
var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList();
@@ -198,7 +199,7 @@ namespace LibationWinForms.GridView
{
if (e.ListChangedType == ListChangedType.ItemChanged)
{
if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is LibraryBookEntry lbItem)
if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is ILibraryBookEntry lbItem)
{
SearchResults = SearchEngineCommands.Search(FilterString);
if (!SearchResults.Docs.Any(d => d.ProductId == lbItem.AudibleProductId))

View File

@@ -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,11 @@ namespace LibationWinForms.GridView
lastHeight = Height;
}
public void SetCoverArt(byte[] cover)
{
pictureBox1.Image = WinFormsUtil.TryLoadImageOrDefault(cover);
}
#region Make the form's aspect ratio always match the picture's aspect ratio.
private bool detectedResizeDirection = false;
@@ -106,7 +107,7 @@ namespace LibationWinForms.GridView
try
{
File.WriteAllBytes(saveFileDialog.FileName, CoverPicture);
pictureBox1.Image.Save(saveFileDialog.FileName);
}
catch (Exception ex)
{

View File

@@ -0,0 +1,40 @@
using LibationUiBase.GridView;
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)
{
if (value is LastDownloadStatus lastDl)
ToolTipText = lastDl.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);
}
}
}

View File

@@ -1,28 +0,0 @@
using DataLayer;
using System;
namespace LibationWinForms.GridView
{
public class LiberateButtonStatus : IComparable
{
public LiberatedStatus BookStatus { get; set; }
public LiberatedStatus? PdfStatus { get; set; }
public bool Expanded { get; set; }
public bool IsSeries { get; init; }
/// <summary>
/// Defines the Liberate column's sorting behavior
/// </summary>
public int CompareTo(object obj)
{
if (obj is not LiberateButtonStatus second) return -1;
if (IsSeries && !second.IsSeries) return -1;
else if (!IsSeries && second.IsSeries) return 1;
else if (IsSeries && second.IsSeries) return 0;
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);
}
}
}

View File

@@ -1,8 +1,6 @@
using System;
using DataLayer;
using System.Drawing;
using System.Windows.Forms;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
namespace LibationWinForms.GridView
{
@@ -16,68 +14,26 @@ 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));
private static readonly Color HiddenForeColor = 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 (value is WinFormsEntryStatus 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)
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR;
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = (Color)status.BackgroundBrush;
DataGridView.Rows[rowIndex].DefaultCellStyle.ForeColor = status.Opacity == 1 ? DataGridView.DefaultCellStyle.ForeColor : HiddenForeColor;
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (status.IsSeries)
{
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus : Properties.Resources.plus, cellBounds);
DrawButtonImage(graphics, (Image)status.ButtonImage, cellBounds);
ToolTipText = status.ToolTip;
ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand";
}
else
{
(string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(status.BookStatus, status.PdfStatus);
DrawButtonImage(graphics, buttonImage, cellBounds);
ToolTipText = mouseoverText;
}
if (status.IsUnavailable || status.Opacity < 1)
graphics.FillRectangle(DISABLED_GRAY, cellBounds);
}
}
private static (string mouseoverText, Bitmap buttonImage) GetLiberateDisplay(LiberatedStatus liberatedStatus, LiberatedStatus? pdfStatus)
{
if (liberatedStatus == LiberatedStatus.Error)
return ("Book downloaded ERROR", Properties.Resources.error);
(string libState, string image_lib) = liberatedStatus switch
{
LiberatedStatus.Liberated => ("Liberated", "green"),
LiberatedStatus.PartialDownload => ("File has been at least\r\npartially downloaded", "yellow"),
LiberatedStatus.NotLiberated => ("Book NOT downloaded", "red"),
_ => throw new Exception("Unexpected liberation state")
};
(string pdfState, string image_pdf) = pdfStatus switch
{
LiberatedStatus.Liberated => ("\r\nPDF downloaded", "_pdf_yes"),
LiberatedStatus.NotLiberated => ("\r\nPDF NOT downloaded", "_pdf_no"),
LiberatedStatus.Error => ("\r\nPDF downloaded ERROR", "_pdf_no"),
null => ("", ""),
_ => throw new Exception("Unexpected PDF state")
};
var mouseoverText = libState + pdfState;
if (liberatedStatus == LiberatedStatus.NotLiberated ||
liberatedStatus == LiberatedStatus.PartialDownload ||
pdfStatus == LiberatedStatus.NotLiberated)
mouseoverText += "\r\nClick to complete";
var buttonImage = (Bitmap)Properties.Resources.ResourceManager.GetObject($"liberate_{image_lib}{image_pdf}");
return (mouseoverText, buttonImage);
}
}
}

View File

@@ -1,168 +0,0 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Product or ContentType.Episode</summary>
public class LibraryBookEntry : GridEntry
{
[Browsable(false)] public override DateTime DateAdded => LibraryBook.DateAdded;
[Browsable(false)] public SeriesEntry Parent { get; init; }
[Browsable(false)] public override bool IsSeries => false;
[Browsable(false)] public override bool IsEpisode => Parent is not null;
[Browsable(false)] public override bool IsBook => Parent is null;
#region Model properties exposed to the view
private DateTime lastStatusUpdate = default;
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
Parent?.ChildRemoveUpdate();
NotifyPropertyChanged();
}
}
public override LiberateButtonStatus Liberate
{
get
{
//Cache these statuses for faster sorting.
if ((DateTime.Now - lastStatusUpdate).TotalSeconds > 2)
{
_bookStatus = LibraryCommands.Liberated_Status(LibraryBook.Book);
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
}
}
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
#endregion
public LibraryBookEntry(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);
NotifyPropertyChanged();
}
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
Title = Book.Title;
Series = Book.SeriesNames();
Length = Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min";
_myRating = Book.UserDefinedItem.Rating;
PurchaseDate = libraryBook.DateAdded.ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(libraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
#region detect changes to the model, update the view, and save to database.
/// <summary>
/// This event handler receives notifications from the model that it has changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
var udi = sender as UserDefinedItem;
if (udi.Book.AudibleProductId != Book.AudibleProductId)
return;
// UDI changed, possibly in a different context/view. Update this viewmodel. Call NotifyPropertyChanged to notify view.
// - This method responds to tons of incidental changes. Do not persist to db from here. Committing to db must be a volitional action by the caller, not incidental. Otherwise batch changes would be impossible; we would only have slow one-offs
// - Don't restrict notifying view to 'only if property changed'. This same book instance can get passed to a different view, then changed there. When the chain of events makes its way back here, the property is unchanged (because it's the same instance), but this view is out of sync. NotifyPropertyChanged will then update this view.
switch (itemName)
{
case nameof(udi.Tags):
Book.UserDefinedItem.Tags = udi.Tags;
NotifyPropertyChanged(nameof(DisplayTags));
break;
case nameof(udi.BookStatus):
Book.UserDefinedItem.BookStatus = udi.BookStatus;
_bookStatus = udi.BookStatus;
NotifyPropertyChanged(nameof(Liberate));
break;
case nameof(udi.PdfStatus):
Book.UserDefinedItem.SetPdfStatus(udi.PdfStatus);
_pdfStatus = udi.PdfStatus;
NotifyPropertyChanged(nameof(Liberate));
break;
}
}
/// <summary>Save edits to the database</summary>
public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus)
// MVVM pass-through
=> Book.UpdateUserDefinedItem(newTags, bookStatus: bookStatus, pdfStatus: pdfStatus);
#endregion
#region Data Sorting
/// <summary>Create getters for all member object values by name </summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Book.LengthInMinutes },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => LibraryBook.DateAdded },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(DisplayTags), () => DisplayTags },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
~LibraryBookEntry()
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
}
}
}

View File

@@ -31,7 +31,7 @@ namespace LibationWinForms.GridView
public override Type EditType => typeof(MyRatingCellEditor);
public override Type ValueType => typeof(Rating);
public MyRatingGridViewCell() { ToolTipText = "Click to change ratings"; }
public MyRatingGridViewCell() { ToolTipText = ReadOnly ? "" : "Click to change ratings"; }
public override void InitializeEditingControl(int rowIndex, object initialFormattedValue, DataGridViewCellStyle dataGridViewCellStyle)
{
@@ -46,7 +46,7 @@ namespace LibationWinForms.GridView
{
if (value is Rating rating)
{
ToolTipText = "Click to change ratings";
ToolTipText = ReadOnly ? "" : "Click to change ratings";
var starString = rating.ToStarString();
base.Paint(graphics, clipBounds, cellBounds, rowIndex, cellState, starString, starString, errorText, cellStyle, advancedBorderStyle, paintParts);

View File

@@ -3,6 +3,7 @@ using AudibleUtilities;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using LibationUiBase.GridView;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
@@ -32,14 +33,14 @@ namespace LibationWinForms.GridView
#region Button controls
private ImageDisplay imageDisplay;
private void productsGrid_CoverClicked(GridEntry liveGridEntry)
private void productsGrid_CoverClicked(IGridEntry liveGridEntry)
{
var picDef = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
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 +52,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 +60,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;
@@ -67,7 +68,7 @@ namespace LibationWinForms.GridView
imageDisplay.Show(null);
}
private void productsGrid_DescriptionClicked(GridEntry liveGridEntry, Rectangle cellRectangle)
private void productsGrid_DescriptionClicked(IGridEntry liveGridEntry, Rectangle cellRectangle)
{
var displayWindow = new DescriptionDisplay
{
@@ -86,11 +87,11 @@ namespace LibationWinForms.GridView
displayWindow.Show(this);
}
private void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry)
private void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry)
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
liveGridEntry.Book.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
}
#endregion
@@ -102,24 +103,23 @@ namespace LibationWinForms.GridView
public async Task RemoveCheckedBooksAsync()
{
var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is RemoveStatus.Removed).ToList();
var selectedBooks = productsGrid.GetAllBookEntries().Where(lbe => lbe.Remove is true).ToList();
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,
// do not use `$` string interpolation. See impl.
"Are you sure you want to remove {0} from Libation's library?",
booksToRemove,
// do not use `$` string interpolation. See impl.
"Are you sure you want to remove {0} from Libation's library?",
"Remove books from Libation?");
if (result != DialogResult.Yes)
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)
@@ -142,7 +142,7 @@ namespace LibationWinForms.GridView
var removable = allBooks.Where(lbe => removedBooks.Any(rb => rb.Book.AudibleProductId == lbe.AudibleProductId)).ToList();
foreach (var r in removable)
r.Remove = RemoveStatus.Removed;
r.Remove = true;
productsGrid_RemovableCountChanged(this, null);
}
@@ -199,13 +199,14 @@ namespace LibationWinForms.GridView
VisibleCountChanged?.Invoke(this, count);
}
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
private void productsGrid_LiberateClicked(ILibraryBookEntry 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);
}
private void productsGrid_ConvertToMp3Clicked(LibraryBookEntry liveGridEntry)
private void productsGrid_ConvertToMp3Clicked(ILibraryBookEntry liveGridEntry)
{
if (liveGridEntry.LibraryBook.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Error)
ConvertToMp3Clicked?.Invoke(this, liveGridEntry.LibraryBook);
@@ -213,7 +214,7 @@ namespace LibationWinForms.GridView
private void productsGrid_RemovableCountChanged(object sender, EventArgs e)
{
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is RemoveStatus.Removed));
RemovableCountChanged?.Invoke(sender, productsGrid.GetAllBookEntries().Count(lbe => lbe.Remove is true));
}
}
}

View File

@@ -1,6 +1,8 @@
namespace LibationWinForms.GridView
using LibationUiBase.GridView;
namespace LibationWinForms.GridView
{
partial class ProductsGrid
partial class ProductsGrid
{
/// <summary>
/// Required designer variable.
@@ -41,10 +43,11 @@
this.seriesGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.descriptionGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.categoryGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.productRatingGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.productRatingGVColumn = new LibationWinForms.GridView.MyRatingGridViewColumn();
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 +78,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;
@@ -191,6 +195,7 @@
this.productRatingGVColumn.HeaderText = "Product Rating";
this.productRatingGVColumn.Name = "productRatingGVColumn";
this.productRatingGVColumn.ReadOnly = true;
this.productRatingGVColumn.SortMode = System.Windows.Forms.DataGridViewColumnSortMode.Automatic;
this.productRatingGVColumn.Width = 108;
//
// purchaseDateGVColumn
@@ -216,9 +221,18 @@
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";
this.tagAndDetailsGVColumn.DataPropertyName = "BookTags";
this.tagAndDetailsGVColumn.HeaderText = "Tags and Details";
this.tagAndDetailsGVColumn.Name = "tagAndDetailsGVColumn";
this.tagAndDetailsGVColumn.ReadOnly = true;
@@ -232,7 +246,7 @@
//
// syncBindingSource
//
this.syncBindingSource.DataSource = typeof(LibationWinForms.GridView.GridEntry);
this.syncBindingSource.DataSource = typeof(IGridEntry);
//
// ProductsGrid
//
@@ -264,10 +278,11 @@
private System.Windows.Forms.DataGridViewTextBoxColumn seriesGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn descriptionGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn categoryGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn productRatingGVColumn;
private MyRatingGridViewColumn productRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn purchaseDateGVColumn;
private MyRatingGridViewColumn myRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
private LastDownloadedGridViewColumn lastDownloadedGVColumn;
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
}
}

View File

@@ -1,21 +1,23 @@
using System;
using ApplicationServices;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationUiBase.GridView;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
using System.Data;
using System.Diagnostics;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationWinForms.Dialogs;
namespace LibationWinForms.GridView
{
public delegate void GridEntryClickedEventHandler(GridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(GridEntry liveGridEntry, Rectangle cellRectangle);
public delegate void GridEntryClickedEventHandler(IGridEntry liveGridEntry);
public delegate void LibraryBookEntryClickedEventHandler(ILibraryBookEntry liveGridEntry);
public delegate void GridEntryRectangleClickedEventHandler(IGridEntry liveGridEntry, Rectangle cellRectangle);
public partial class ProductsGrid : UserControl
{
@@ -34,7 +36,7 @@ namespace LibationWinForms.GridView
=> bindingList
.BookEntries()
.Select(lbe => lbe.LibraryBook);
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
internal IEnumerable<ILibraryBookEntry> GetAllBookEntries()
=> bindingList.AllItems().BookEntries();
public ProductsGrid()
@@ -62,7 +64,7 @@ namespace LibationWinForms.GridView
return;
var entry = getGridEntry(e.RowIndex);
if (entry is LibraryBookEntry lbEntry)
if (entry is ILibraryBookEntry lbEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
LiberateClicked?.Invoke(lbEntry);
@@ -73,7 +75,7 @@ namespace LibationWinForms.GridView
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry);
}
else if (entry is SeriesEntry sEntry)
else if (entry is ISeriesEntry sEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
{
@@ -82,8 +84,6 @@ namespace LibationWinForms.GridView
else
bindingList.ExpandItem(sEntry);
sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate));
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
else if (e.ColumnIndex == descriptionGVColumn.Index)
@@ -98,108 +98,108 @@ namespace LibationWinForms.GridView
RemovableCountChanged?.Invoke(this, EventArgs.Empty);
}
}
catch(Exception ex)
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"An error was encountered while processing a user click in the {nameof(ProductsGrid)}");
}
}
private void gridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e)
{
private void gridEntryDataGridView_CellContextMenuStripNeeded(object sender, DataGridViewCellContextMenuStripNeededEventArgs e)
{
// header
if (e.RowIndex < 0)
return;
if (e.RowIndex < 0)
return;
// cover
if (e.ColumnIndex == coverGVColumn.Index)
return;
// cover
if (e.ColumnIndex == coverGVColumn.Index)
return;
// any non-stop light
if (e.ColumnIndex != liberateGVColumn.Index)
// any non-stop light
if (e.ColumnIndex != liberateGVColumn.Index)
{
var copyContextMenu = new ContextMenuStrip();
copyContextMenu.Items.Add("Copy", null, (_, __) =>
var copyContextMenu = new ContextMenuStrip();
copyContextMenu.Items.Add("Copy", null, (_, __) =>
{
try
{
var dgv = (DataGridView)sender;
var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString();
{
var dgv = (DataGridView)sender;
var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString();
Clipboard.SetDataObject(text, false, 5, 150);
}
}
catch { }
});
});
e.ContextMenuStrip = copyContextMenu;
return;
}
e.ContextMenuStrip = copyContextMenu;
return;
}
// else: stop light
var entry = getGridEntry(e.RowIndex);
if (entry.IsSeries)
var entry = getGridEntry(e.RowIndex);
if (entry.Liberate.IsSeries)
return;
var setDownloadMenuItem = new ToolStripMenuItem()
{
Text = "Set Download status to '&Downloaded'",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated
{
Text = "Set Download status to '&Downloaded'",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated
};
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
var setNotDownloadMenuItem = new ToolStripMenuItem()
{
Text = "Set Download status to '&Not Downloaded'",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
};
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var setNotDownloadMenuItem = new ToolStripMenuItem()
{
Text = "Set Download status to '&Not Downloaded'",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
};
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));
var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" };
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
locateFileMenuItem.Click += (_, __) =>
{
try
{
var openFileDialog = new OpenFileDialog
{
Title = $"Locate the audio file for '{entry.Book.Title}'",
Filter = "All files (*.*)|*.*",
FilterIndex = 1
};
if (openFileDialog.ShowDialog() == DialogResult.OK)
FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName);
}
catch (Exception ex)
{
var msg = "Error saving book's location";
MessageBoxLib.ShowAdminAlert(this, msg, msg, ex);
}
};
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
locateFileMenuItem.Click += (_, __) =>
{
try
{
var openFileDialog = new OpenFileDialog
{
Title = $"Locate the audio file for '{entry.Book.Title}'",
Filter = "All files (*.*)|*.*",
FilterIndex = 1
};
if (openFileDialog.ShowDialog() == DialogResult.OK)
FilePathCache.Insert(entry.AudibleProductId, openFileDialog.FileName);
}
catch (Exception ex)
{
var msg = "Error saving book's location";
MessageBoxLib.ShowAdminAlert(this, msg, msg, ex);
}
};
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);
convertToMp3MenuItem.Click += (_, e) => ConvertToMp3Clicked?.Invoke(entry as ILibraryBookEntry);
var bookRecordMenuItem = new ToolStripMenuItem { Text = "View &Bookmarks/Clips" };
bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this);
var stopLightContextMenu = new ContextMenuStrip();
stopLightContextMenu.Items.Add(setDownloadMenuItem);
stopLightContextMenu.Items.Add(setNotDownloadMenuItem);
stopLightContextMenu.Items.Add(removeMenuItem);
stopLightContextMenu.Items.Add(locateFileMenuItem);
stopLightContextMenu.Items.Add(convertToMp3MenuItem);
stopLightContextMenu.Items.Add(setDownloadMenuItem);
stopLightContextMenu.Items.Add(setNotDownloadMenuItem);
stopLightContextMenu.Items.Add(removeMenuItem);
stopLightContextMenu.Items.Add(locateFileMenuItem);
stopLightContextMenu.Items.Add(convertToMp3MenuItem);
stopLightContextMenu.Items.Add(new ToolStripSeparator());
stopLightContextMenu.Items.Add(bookRecordMenuItem);
e.ContextMenuStrip = stopLightContextMenu;
}
}
private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);
private IGridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<IGridEntry>(rowIndex);
#endregion
@@ -213,7 +213,7 @@ namespace LibationWinForms.GridView
if (value)
{
foreach (var book in bindingList.AllItems())
book.Remove = RemoveStatus.NotRemoved;
book.Remove = false;
}
removeGVColumn.DisplayIndex = 0;
@@ -226,9 +226,8 @@ namespace LibationWinForms.GridView
{
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry(b))
.Cast<GridEntry>()
.ToList();
.Select(b => new LibraryBookEntry<WinFormsEntryStatus>(b))
.ToList<IGridEntry>();
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
@@ -240,7 +239,7 @@ namespace LibationWinForms.GridView
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry(parent, seriesEpisodes);
var seriesEntry = new SeriesEntry<WinFormsEntryStatus>(parent, seriesEpisodes);
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
@@ -266,18 +265,23 @@ namespace LibationWinForms.GridView
var allEntries = bindingList.AllItems().BookEntries();
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
if (libraryBook.Book.IsProduct())
{
AddOrUpdateBook(libraryBook, existingEntry);
else if(parentedEpisodes.Any(lb => lb == libraryBook))
continue;
}
if (parentedEpisodes.Contains(libraryBook))
{
//Only try to add or update is this LibraryBook is a know child of a parent
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
AddOrUpdateEpisode(libraryBook, existingEntry, seriesEntries, dbBooks);
}
}
bindingList.SuspendFilteringOnUpdate = false;
@@ -297,14 +301,11 @@ namespace LibationWinForms.GridView
RemoveBooks(removedBooks);
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
public void RemoveBooks(IEnumerable<ILibraryBookEntry> removedBooks)
{
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
{
removed.Parent.Children.Remove(removed);
removed.Parent.NotifyPropertyChanged();
}
foreach (var removed in removedBooks.Where(b => b.Liberate.IsEpisode))
removed.Parent.RemoveChild(removed);
//Remove series that have no children
var removedSeries =
@@ -312,28 +313,28 @@ namespace LibationWinForms.GridView
.AllItems()
.EmptySeries();
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries))
foreach (var removed in removedBooks.Cast<IGridEntry>().Concat(removedSeries))
//no need to re-filter for removed books
bindingList.Remove(removed);
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
private void AddOrUpdateBook(LibraryBook book, LibraryBookEntry existingBookEntry)
private void AddOrUpdateBook(LibraryBook book, ILibraryBookEntry existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
bindingList.Insert(0, new LibraryBookEntry(book));
bindingList.Insert(0, new LibraryBookEntry<WinFormsEntryStatus>(book));
else
// update existing
existingBookEntry.UpdateLibraryBook(book);
}
private void AddOrUpdateEpisode(LibraryBook episodeBook, LibraryBookEntry existingEpisodeEntry, List<SeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
private void AddOrUpdateEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
LibraryBookEntry episodeEntry;
ILibraryBookEntry episodeEntry;
var seriesEntry = seriesEntries.FindSeriesParent(episodeBook);
@@ -351,8 +352,7 @@ namespace LibationWinForms.GridView
return;
}
seriesEntry = new SeriesEntry(seriesBook, episodeBook);
seriesEntry = new SeriesEntry<WinFormsEntryStatus>(seriesBook, episodeBook);
seriesEntries.Add(seriesEntry);
episodeEntry = seriesEntry.Children[0];
@@ -362,10 +362,10 @@ namespace LibationWinForms.GridView
else
{
//Series exists. Create and add episode child then update the SeriesEntry
episodeEntry = new(episodeBook) { Parent = seriesEntry };
episodeEntry = new LibraryBookEntry<WinFormsEntryStatus>(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
seriesEntry.UpdateSeries(seriesBook);
seriesEntry.UpdateLibraryBook(seriesBook);
}
//Add episode to the grid beneath the parent
@@ -376,9 +376,6 @@ namespace LibationWinForms.GridView
bindingList.ExpandItem(seriesEntry);
else
bindingList.CollapseItem(seriesEntry);
seriesEntry.NotifyPropertyChanged();
}
else
existingEpisodeEntry.UpdateLibraryBook(episodeBook);
@@ -399,7 +396,7 @@ namespace LibationWinForms.GridView
if (visibleCount != bindingList.Count)
VisibleCountChanged?.Invoke(this, bindingList.BookEntries().Count());
}
}
#endregion
@@ -465,10 +462,10 @@ namespace LibationWinForms.GridView
//Remove column is always first;
removeGVColumn.DisplayIndex = 0;
removeGVColumn.Visible = false;
removeGVColumn.ValueType = typeof(RemoveStatus);
removeGVColumn.FalseValue = RemoveStatus.NotRemoved;
removeGVColumn.TrueValue = RemoveStatus.Removed;
removeGVColumn.IndeterminateValue = RemoveStatus.SomeRemoved;
removeGVColumn.ValueType = typeof(bool?);
removeGVColumn.FalseValue = false;
removeGVColumn.TrueValue = true;
removeGVColumn.IndeterminateValue = null;
}
private void HideMenuItem_Click(object sender, EventArgs e)

View File

@@ -1,132 +0,0 @@
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.GridView
{
/// <summary>The View Model for a LibraryBook that is ContentType.Parent</summary>
public class SeriesEntry : GridEntry
{
[Browsable(false)] public List<LibraryBookEntry> Children { get; }
[Browsable(false)] public override DateTime DateAdded => Children.Max(c => c.DateAdded);
[Browsable(false)] public override bool IsSeries => true;
[Browsable(false)] public override bool IsEpisode => false;
[Browsable(false)] public override bool IsBook => false;
private bool suspendCounting = false;
public void ChildRemoveUpdate()
{
if (suspendCounting) return;
var removeCount = Children.Count(c => c.Remove is RemoveStatus.Removed);
if (removeCount == 0)
_remove = RemoveStatus.NotRemoved;
else if (removeCount == Children.Count)
_remove = RemoveStatus.Removed;
else
_remove = RemoveStatus.SomeRemoved;
NotifyPropertyChanged(nameof(Remove));
}
#region Model properties exposed to the view
public override RemoveStatus Remove
{
get
{
return _remove;
}
set
{
_remove = value is RemoveStatus.SomeRemoved ? RemoveStatus.NotRemoved : value;
suspendCounting = true;
foreach (var item in Children)
item.Remove = value;
suspendCounting = false;
NotifyPropertyChanged();
}
}
public override LiberateButtonStatus Liberate { get; }
public override string DisplayTags { get; } = string.Empty;
#endregion
private SeriesEntry(LibraryBook parent)
{
Liberate = new LiberateButtonStatus { IsSeries = true };
SeriesIndex = -1;
LibraryBook = parent;
LoadCover();
}
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children) : this(parent)
{
Children = children
.Select(c => new LibraryBookEntry(c) { Parent = this })
.OrderBy(c => c.SeriesIndex)
.ToList();
UpdateSeries(parent);
}
public SeriesEntry(LibraryBook parent, LibraryBook child) : this(parent)
{
Children = new() { new LibraryBookEntry(child) { Parent = this } };
UpdateSeries(parent);
}
public void UpdateSeries(LibraryBook parent)
{
LibraryBook = parent;
Title = Book.Title;
Series = Book.SeriesNames();
_myRating = Book.UserDefinedItem.Rating;
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
ProductRating = Book.Rating?.ToStarString()?.DefaultIfNullOrWhiteSpace("");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Misc = GetMiscDisplay(LibraryBook);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
Length = bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
NotifyPropertyChanged();
}
#region Data Sorting
/// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Remove), () => Remove },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating.FirstScore() },
{ nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Book.Rating.FirstScore() },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(DisplayTags), () => string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
#endregion
}
}

Some files were not shown because too many files have changed in this diff Show More