mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 18:38:01 -05:00
Compare commits
49 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36076242a7 | ||
|
|
718e6c14d0 | ||
|
|
eb61ba3d69 | ||
|
|
defabf7356 | ||
|
|
1149c10cf1 | ||
|
|
ec7dd1b54a | ||
|
|
bb900b31ef | ||
|
|
eed42bd108 | ||
|
|
3f0e6b9ee5 | ||
|
|
5ec01913d5 | ||
|
|
245e55782e | ||
|
|
cc306e0e19 | ||
|
|
26a9bc6bbf | ||
|
|
fb9d062545 | ||
|
|
49c6b391fd | ||
|
|
e1cd8b8f94 | ||
|
|
ef1edf1136 | ||
|
|
0def1b426a | ||
|
|
230e014bb1 | ||
|
|
34f56d2fd7 | ||
|
|
c45ffaf4a6 | ||
|
|
ae43ab103e | ||
|
|
559977ce0b | ||
|
|
ccd4d3e26d | ||
|
|
e76f99ff28 | ||
|
|
d3607583ab | ||
|
|
3ebd4ce243 | ||
|
|
f6dcc0db1d | ||
|
|
bd49db83e4 | ||
|
|
4140722a6d | ||
|
|
da36f9414d | ||
|
|
1510f71ca6 | ||
|
|
cdb27ef712 | ||
|
|
790319ed98 | ||
|
|
1b0fb2b316 | ||
|
|
02371f2221 | ||
|
|
2b672f86be | ||
|
|
36176bff33 | ||
|
|
174b0c26b8 | ||
|
|
26c60e8e79 | ||
|
|
d94759d868 | ||
|
|
bd7e45ca3c | ||
|
|
52a863c62a | ||
|
|
fe55b90ee3 | ||
|
|
df224cc7f3 | ||
|
|
2a59329350 | ||
|
|
abdf0e7261 | ||
|
|
b9c2a1cce3 | ||
|
|
aa86fca08f |
@@ -9,7 +9,9 @@ This walkthrough should get you up and running with Libation on your Mac.
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
|
||||
- Download the file from the latest release and extract it.
|
||||
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
|
||||
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
|
||||
- Move the extracted Libation app bundle to your applications folder.
|
||||
- Open a terminal (Go > Utilities > Terminal)
|
||||
- Copy/paste/run the following command (you'll be prompted to enter your password)
|
||||
|
||||
@@ -65,6 +65,9 @@ if [ $? -ne 0 ]
|
||||
exit
|
||||
fi
|
||||
|
||||
echo "Make fileicon executable..."
|
||||
chmod +x $BUNDLE_MACOS/fileicon
|
||||
|
||||
echo "Moving icon..."
|
||||
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||
|
||||
|
||||
@@ -2,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" />
|
||||
|
||||
@@ -75,13 +75,15 @@ namespace AppScaffolding
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
Configuration.SetLibationVersion(BuildVersion);
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
|
||||
@@ -1,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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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++;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="8.1.0.1" />
|
||||
<PackageReference Include="AudibleApi" Version="8.2.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using System;
|
||||
|
||||
namespace DataLayer.Configurations
|
||||
{
|
||||
@@ -19,40 +20,45 @@ namespace DataLayer.Configurations
|
||||
entity.Ignore(nameof(Book.Authors));
|
||||
entity.Ignore(nameof(Book.Narrators));
|
||||
entity.Ignore(nameof(Book.AudioFormat));
|
||||
//// these don't seem to matter
|
||||
//entity.Ignore(nameof(Book.AuthorNames));
|
||||
//entity.Ignore(nameof(Book.NarratorNames));
|
||||
//entity.Ignore(nameof(Book.HasPdfs));
|
||||
//// these don't seem to matter
|
||||
//entity.Ignore(nameof(Book.AuthorNames));
|
||||
//entity.Ignore(nameof(Book.NarratorNames));
|
||||
//entity.Ignore(nameof(Book.HasPdfs));
|
||||
|
||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||
entity
|
||||
.OwnsMany(b => b.Supplements, b_s =>
|
||||
{
|
||||
b_s.WithOwner(s => s.Book)
|
||||
.HasForeignKey(s => s.BookId);
|
||||
b_s.HasKey(s => s.SupplementId);
|
||||
});
|
||||
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
|
||||
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
|
||||
entity
|
||||
.OwnsMany(b => b.Supplements, b_s =>
|
||||
{
|
||||
b_s.WithOwner(s => s.Book)
|
||||
.HasForeignKey(s => s.BookId);
|
||||
b_s.HasKey(s => s.SupplementId);
|
||||
});
|
||||
// even though it's owned, we need to map its backing field
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.Supplements))
|
||||
.SetPropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
// owns it 1:1, store in separate table
|
||||
entity
|
||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||
{
|
||||
b_udi.WithOwner(udi => udi.Book)
|
||||
.HasForeignKey(udi => udi.BookId);
|
||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||
// owns it 1:1, store in separate table
|
||||
entity
|
||||
.OwnsOne(b => b.UserDefinedItem, b_udi =>
|
||||
{
|
||||
b_udi.WithOwner(udi => udi.Book)
|
||||
.HasForeignKey(udi => udi.BookId);
|
||||
b_udi.Property(udi => udi.BookId).ValueGeneratedNever();
|
||||
b_udi.ToTable(nameof(Book.UserDefinedItem));
|
||||
|
||||
// owns it 1:1, store in same table
|
||||
b_udi.OwnsOne(udi => udi.Rating);
|
||||
});
|
||||
b_udi.Property(udi => udi.LastDownloaded);
|
||||
b_udi
|
||||
.Property(udi => udi.LastDownloadedVersion)
|
||||
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
|
||||
|
||||
entity
|
||||
// owns it 1:1, store in same table
|
||||
b_udi.OwnsOne(udi => udi.Rating);
|
||||
});
|
||||
|
||||
entity
|
||||
.Metadata
|
||||
.FindNavigation(nameof(Book.ContributorsLink))
|
||||
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
|
||||
@@ -68,6 +74,6 @@ namespace DataLayer.Configurations
|
||||
.HasOne(b => b.Category)
|
||||
.WithMany()
|
||||
.HasForeignKey(b => b.CategoryId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ namespace DataLayer
|
||||
public string Account { get; private set; }
|
||||
|
||||
public bool IsDeleted { get; set; }
|
||||
public bool AbsentFromLastScan { get; set; }
|
||||
|
||||
private LibraryBook() { }
|
||||
public LibraryBook(Book book, DateTime dateAdded, string account)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
410
Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs
generated
Normal file
410
Source/DataLayer/Migrations/20230302220539_AddLastDownloadedInfo.Designer.cs
generated
Normal file
@@ -0,0 +1,410 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20230302220539_AddLastDownloadedInfo")]
|
||||
partial class AddLastDownloadedInfo
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLastDownloadedInfo : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastDownloaded",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "LastDownloadedVersion",
|
||||
table: "UserDefinedItem",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDownloaded",
|
||||
table: "UserDefinedItem");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastDownloadedVersion",
|
||||
table: "UserDefinedItem");
|
||||
}
|
||||
}
|
||||
}
|
||||
413
Source/DataLayer/Migrations/20230308013410_AddAbsentFromLastScan.Designer.cs
generated
Normal file
413
Source/DataLayer/Migrations/20230308013410_AddAbsentFromLastScan.Designer.cs
generated
Normal file
@@ -0,0 +1,413 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20230308013410_AddAbsentFromLastScan")]
|
||||
partial class AddAbsentFromLastScan
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ContributorId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("Role")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<byte>("Order")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "ContributorId", "Role");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("ContributorId");
|
||||
|
||||
b.ToTable("BookContributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.Property<int>("CategoryId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ContributorId");
|
||||
|
||||
b.HasIndex("Name");
|
||||
|
||||
b.ToTable("Contributors");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
ContributorId = -1,
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId");
|
||||
|
||||
b.HasIndex("AudibleSeriesId");
|
||||
|
||||
b.ToTable("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Order")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("SeriesId", "BookId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("SeriesId");
|
||||
|
||||
b.ToTable("SeriesBook");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("Books");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("BookId");
|
||||
});
|
||||
|
||||
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
|
||||
{
|
||||
b1.Property<int>("SupplementId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("SupplementId");
|
||||
|
||||
b1.HasIndex("BookId");
|
||||
|
||||
b1.ToTable("Supplement");
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.Navigation("Book");
|
||||
});
|
||||
|
||||
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
|
||||
{
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.HasKey("BookId");
|
||||
|
||||
b1.ToTable("UserDefinedItem", (string)null);
|
||||
|
||||
b1.WithOwner("Book")
|
||||
.HasForeignKey("BookId");
|
||||
|
||||
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
|
||||
{
|
||||
b2.Property<int>("UserDefinedItemBookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b2.Property<float>("OverallRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("PerformanceRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.Property<float>("StoryRating")
|
||||
.HasColumnType("REAL");
|
||||
|
||||
b2.HasKey("UserDefinedItemBookId");
|
||||
|
||||
b2.ToTable("UserDefinedItem");
|
||||
|
||||
b2.WithOwner()
|
||||
.HasForeignKey("UserDefinedItemBookId");
|
||||
});
|
||||
|
||||
b1.Navigation("Book");
|
||||
|
||||
b1.Navigation("Rating");
|
||||
});
|
||||
|
||||
b.Navigation("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookContributor", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("ContributorsLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Contributor", "Contributor")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("ContributorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Contributor");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.LibraryBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithOne()
|
||||
.HasForeignKey("DataLayer.LibraryBook", "BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.SeriesBook", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("SeriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.Series", "Series")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("SeriesId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("Series");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddAbsentFromLastScan : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "AbsentFromLastScan",
|
||||
table: "LibraryBooks",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "AbsentFromLastScan",
|
||||
table: "LibraryBooks");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -157,6 +157,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -272,6 +275,12 @@ namespace DataLayer.Migrations
|
||||
b1.Property<int>("BookStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -107,8 +107,9 @@ namespace DataLayer
|
||||
=> bookList
|
||||
.Where(
|
||||
lb =>
|
||||
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
!lb.AbsentFromLastScan &&
|
||||
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|
||||
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,7 @@ namespace DtoImporterService
|
||||
public Item DtoItem { get; set; }
|
||||
public string AccountId { get; set; }
|
||||
public string LocaleName { get; set; }
|
||||
public override string ToString()
|
||||
=> DtoItem is null ? base.ToString() : $"[{DtoItem.ProductId}] {DtoItem.Title}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace DtoImporterService
|
||||
@@ -40,9 +41,8 @@ namespace DtoImporterService
|
||||
//
|
||||
// CURRENT SOLUTION: don't re-insert
|
||||
|
||||
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
|
||||
var newItems = importItems
|
||||
.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId))
|
||||
.ExceptBy(DbContext.LibraryBooks.Select(lb => lb.Book.AudibleProductId), imp => imp.DtoItem.ProductId)
|
||||
.ToList();
|
||||
|
||||
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
|
||||
@@ -55,7 +55,11 @@ namespace DtoImporterService
|
||||
var libraryBook = new LibraryBook(
|
||||
bookImporter.Cache[newItem.DtoItem.ProductId],
|
||||
newItem.DtoItem.DateAdded,
|
||||
newItem.AccountId);
|
||||
newItem.AccountId)
|
||||
{
|
||||
AbsentFromLastScan = isPlusTitleUnavailable(newItem)
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
DbContext.LibraryBooks.Add(libraryBook);
|
||||
@@ -66,8 +70,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
77
Source/FileManager/LogArchiver.cs
Normal file
77
Source/FileManager/LogArchiver.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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" />
|
||||
|
||||
BIN
Source/LibationAvalonia/Assets/Arrows_left.png
Normal file
BIN
Source/LibationAvalonia/Assets/Arrows_left.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 469 B |
BIN
Source/LibationAvalonia/Assets/Arrows_right.png
Normal file
BIN
Source/LibationAvalonia/Assets/Arrows_right.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 455 B |
@@ -8,6 +8,8 @@
|
||||
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="{StaticResource SystemAltHighColor}" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookBorderBrush" Color="Gray" />
|
||||
<SolidColorBrush x:Key="DisabledGrayBrush" Color="#60D3D3D3" />
|
||||
|
||||
</Styles.Resources>
|
||||
<Style Selector="TextBox[IsReadOnly=true]">
|
||||
<Setter Property="Background" Value="LightGray" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
30
Source/LibationAvalonia/Controls/CheckedListBox.axaml
Normal file
30
Source/LibationAvalonia/Controls/CheckedListBox.axaml
Normal file
@@ -0,0 +1,30 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="LibationAvalonia.Controls.CheckedListBox">
|
||||
|
||||
<UserControl.Resources>
|
||||
<RecyclePool x:Key="RecyclePool" />
|
||||
<DataTemplate x:Key="queuedBook">
|
||||
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
|
||||
<RecyclingElementFactory.Templates>
|
||||
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
|
||||
</RecyclingElementFactory.Templates>
|
||||
</RecyclingElementFactory>
|
||||
</UserControl.Resources>
|
||||
|
||||
<ScrollViewer
|
||||
Name="scroller"
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsRepeater IsVisible="True"
|
||||
VerticalCacheLength="1.2"
|
||||
HorizontalCacheLength="1"
|
||||
Items="{Binding CheckboxItems}"
|
||||
ItemTemplate="{StaticResource elementFactory}" />
|
||||
</ScrollViewer>
|
||||
</UserControl>
|
||||
46
Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs
Normal file
46
Source/LibationAvalonia/Controls/CheckedListBox.axaml.cs
Normal file
@@ -0,0 +1,46 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.Controls
|
||||
{
|
||||
public partial class CheckedListBox : UserControl
|
||||
{
|
||||
public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
|
||||
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
|
||||
|
||||
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
|
||||
private CheckedListBoxViewModel _viewModel = new();
|
||||
|
||||
public CheckedListBox()
|
||||
{
|
||||
InitializeComponent();
|
||||
scroller.DataContext = _viewModel;
|
||||
}
|
||||
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
|
||||
{
|
||||
if (change.Property.Name == nameof(Items) && Items != null)
|
||||
_viewModel.CheckboxItems = Items;
|
||||
base.OnPropertyChanged(change);
|
||||
}
|
||||
|
||||
private class CheckedListBoxViewModel : ViewModelBase
|
||||
{
|
||||
private AvaloniaList<CheckBoxViewModel> _checkboxItems;
|
||||
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get => _checkboxItems; set => this.RaiseAndSetIfChanged(ref _checkboxItems, value); }
|
||||
}
|
||||
}
|
||||
|
||||
public class CheckBoxViewModel : ViewModelBase
|
||||
{
|
||||
private bool _isChecked;
|
||||
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
|
||||
private object _bookText;
|
||||
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
|
||||
}
|
||||
}
|
||||
@@ -1,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = @$"
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="700"
|
||||
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750"
|
||||
MinWidth="900" MinHeight="700"
|
||||
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
@@ -376,7 +376,7 @@
|
||||
Grid.Row="3"
|
||||
Margin="5"
|
||||
VerticalAlignment="Top"
|
||||
IsVisible="{Binding IsWindows}"
|
||||
IsVisible="{Binding !IsLinux}"
|
||||
IsChecked="{Binding DownloadDecryptSettings.UseCoverAsFolderIcon, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
|
||||
@@ -108,7 +108,8 @@ namespace LibationAvalonia.Dialogs
|
||||
LoadSettings(config);
|
||||
}
|
||||
|
||||
public bool IsWindows => AppScaffolding.LibationScaffolding.ReleaseIdentifier is AppScaffolding.ReleaseIdentifier.WindowsAvalonia;
|
||||
public bool IsLinux => Configuration.IsLinux;
|
||||
public bool IsWindows => Configuration.IsWindows;
|
||||
public ImportantSettings ImportantSettings { get; private set; }
|
||||
public ImportSettings ImportSettings { get; private set; }
|
||||
public DownloadDecryptSettings DownloadDecryptSettings { get; private set; }
|
||||
|
||||
66
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml
Normal file
66
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml
Normal file
@@ -0,0 +1,66 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="630" d:DesignHeight="480"
|
||||
x:Class="LibationAvalonia.Dialogs.TrashBinDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
MinWidth="630" MinHeight="480"
|
||||
Title="Trash Bin"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
Icon="/Assets/libation.ico">
|
||||
|
||||
<Grid
|
||||
RowDefinitions="Auto,*,Auto">
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
Text="Check books you want to permanently delete from or restore to Libation" />
|
||||
|
||||
<controls:CheckedListBox
|
||||
Grid.Row="1"
|
||||
Margin="5,0,5,0"
|
||||
BorderThickness="1"
|
||||
BorderBrush="Gray"
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Items="{Binding DeletedBooks}" />
|
||||
|
||||
<Grid
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
<CheckBox
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
IsThreeState="True"
|
||||
Margin="0,0,20,0"
|
||||
IsChecked="{Binding EverythingChecked}"
|
||||
Content="Everything" />
|
||||
|
||||
<TextBlock
|
||||
Grid.Column="1"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding CheckedCountText}" />
|
||||
|
||||
<Button
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Grid.Column="2"
|
||||
Margin="0,0,20,0"
|
||||
HorizontalAlignment="Right"
|
||||
VerticalAlignment="Stretch"
|
||||
VerticalContentAlignment="Center"
|
||||
Content="Restore"
|
||||
Click="Restore_Click" />
|
||||
|
||||
<Button
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
Grid.Column="3"
|
||||
Click="EmptyTrash_Click" >
|
||||
<TextBlock
|
||||
TextAlignment="Center"
|
||||
Text="Permanently Delete
from Libation" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
145
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs
Normal file
145
Source/LibationAvalonia/Dialogs/TrashBinDialog.axaml.cs
Normal file
@@ -0,0 +1,145 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using LibationAvalonia.Controls;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class TrashBinDialog : Window
|
||||
{
|
||||
TrashBinViewModel _viewModel;
|
||||
|
||||
public TrashBinDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
this.RestoreSizeAndLocation(Configuration.Instance);
|
||||
DataContext = _viewModel = new();
|
||||
|
||||
this.Closing += (_,_) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
|
||||
public async void EmptyTrash_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.PermanentlyDeleteCheckedAsync();
|
||||
public async void Restore_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.RestoreCheckedAsync();
|
||||
}
|
||||
|
||||
public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
{
|
||||
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
|
||||
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
|
||||
|
||||
private bool _controlsEnabled = true;
|
||||
public bool ControlsEnabled { get => _controlsEnabled; set=> this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
|
||||
|
||||
private bool? everythingChecked = false;
|
||||
public bool? EverythingChecked
|
||||
{
|
||||
get => everythingChecked;
|
||||
set
|
||||
{
|
||||
everythingChecked = value ?? false;
|
||||
|
||||
if (everythingChecked is true)
|
||||
CheckAll();
|
||||
else if (everythingChecked is false)
|
||||
UncheckAll();
|
||||
}
|
||||
}
|
||||
|
||||
private int _totalBooksCount = 0;
|
||||
private int _checkedBooksCount = -1;
|
||||
public int CheckedBooksCount
|
||||
{
|
||||
get => _checkedBooksCount;
|
||||
set
|
||||
{
|
||||
if (_checkedBooksCount != value)
|
||||
{
|
||||
_checkedBooksCount = value;
|
||||
this.RaisePropertyChanged(nameof(CheckedCountText));
|
||||
}
|
||||
|
||||
everythingChecked
|
||||
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
|
||||
: _checkedBooksCount == _totalBooksCount ? true
|
||||
: null;
|
||||
|
||||
this.RaisePropertyChanged(nameof(EverythingChecked));
|
||||
}
|
||||
}
|
||||
|
||||
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
|
||||
|
||||
public TrashBinViewModel()
|
||||
{
|
||||
DeletedBooks = new()
|
||||
{
|
||||
ResetBehavior = ResetBehavior.Remove
|
||||
};
|
||||
|
||||
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
|
||||
Reload();
|
||||
}
|
||||
|
||||
public void CheckAll()
|
||||
{
|
||||
foreach (var item in DeletedBooks)
|
||||
item.IsChecked = true;
|
||||
}
|
||||
|
||||
public void UncheckAll()
|
||||
{
|
||||
foreach (var item in DeletedBooks)
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
|
||||
if (qtyChanges > 0)
|
||||
Reload();
|
||||
ControlsEnabled = true;
|
||||
}
|
||||
|
||||
public async Task PermanentlyDeleteCheckedAsync()
|
||||
{
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
|
||||
if (qtyChanges > 0)
|
||||
Reload();
|
||||
ControlsEnabled = true;
|
||||
}
|
||||
|
||||
private void Reload()
|
||||
{
|
||||
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||
|
||||
DeletedBooks.Clear();
|
||||
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
|
||||
|
||||
_totalBooksCount = DeletedBooks.Count;
|
||||
CheckedBooksCount = 0;
|
||||
}
|
||||
|
||||
private IDisposable tracker;
|
||||
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
|
||||
{
|
||||
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
|
||||
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
|
||||
}
|
||||
|
||||
public void Dispose() => tracker?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Remove=".gitignore" />
|
||||
<None Remove="Assets\Arrows_left.png" />
|
||||
<None Remove="Assets\Arrows_right.png" />
|
||||
<None Remove="Assets\Asterisk.png" />
|
||||
<None Remove="Assets\cancel.png" />
|
||||
<None Remove="Assets\completed.png" />
|
||||
|
||||
26
Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs
Normal file
26
Source/LibationAvalonia/ViewModels/AvaloniaEntryStatus.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using AudibleUtilities;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -15,6 +16,12 @@ namespace LibationAvalonia.Views
|
||||
_viewModel.RemoveButtonsVisible = false;
|
||||
}
|
||||
|
||||
public async void openTrashBinToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var trash = new TrashBinDialog();
|
||||
await trash.ShowDialog(this);
|
||||
}
|
||||
|
||||
public void removeLibraryBooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
// if 0 accounts, this will not be visible
|
||||
|
||||
@@ -144,11 +144,8 @@ namespace LibationAvalonia.Views
|
||||
"Remove books from Libation?",
|
||||
MessageBoxDefaultButton.Button2);
|
||||
|
||||
if (confirmationResult != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
await LibraryCommands.RemoveBooksAsync(visibleIds);
|
||||
if (confirmationResult is DialogResult.Yes)
|
||||
await visibleLibraryBooks.RemoveBooksAsync();
|
||||
}
|
||||
public async void ProductsDisplay_VisibleCountChanged(object sender, int qty)
|
||||
{
|
||||
@@ -157,9 +154,9 @@ namespace LibationAvalonia.Views
|
||||
await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem);
|
||||
}
|
||||
void setLiberatedVisibleMenuItem()
|
||||
=> _viewModel.VisibleNotLiberated
|
||||
= _viewModel.ProductsDisplay
|
||||
.GetVisibleBookEntries()
|
||||
.Count(lb => lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.NotLiberated);
|
||||
{
|
||||
var libraryStats = LibraryCommands.GetCounts(_viewModel.ProductsDisplay.GetVisibleBookEntries());
|
||||
_viewModel.VisibleNotLiberated = libraryStats.PendingBooks;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,6 +131,7 @@
|
||||
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
|
||||
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
|
||||
<Separator />
|
||||
<MenuItem Click="openTrashBinToolStripMenuItem_Click" Header="Trash Bin" />
|
||||
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
|
||||
<Separator />
|
||||
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
|
||||
@@ -172,7 +173,12 @@
|
||||
|
||||
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
|
||||
<Button Click="filterBtn_Click" Height="30" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click" Content="{Binding QueueHideButtonText}"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click">
|
||||
<Panel>
|
||||
<Image Stretch="None" IsVisible="{Binding !QueueOpen}" Source="/Assets/Arrows_left.png" />
|
||||
<Image Stretch="None" IsVisible="{Binding QueueOpen}" Source="/Assets/Arrows_right.png" />
|
||||
</Panel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
@@ -203,7 +209,7 @@
|
||||
<TextBlock FontSize="14" Grid.Column="0" Text="Upgrading:" VerticalAlignment="Center" IsVisible="{Binding DownloadProgress, Converter={x:Static ObjectConverters.IsNotNull}}" />
|
||||
<ProgressBar Grid.Column="1" Margin="5,0,10,0" VerticalAlignment="Stretch" Width="100" Value="{Binding DownloadProgress}" IsVisible="{Binding DownloadProgress, Converter={x:Static ObjectConverters.IsNotNull}}"/>
|
||||
<TextBlock FontSize="14" Grid.Column="2" Text="{Binding VisibleCountText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontSize="14" Grid.Column="3" Text="{Binding StatusCountText}" VerticalAlignment="Center" />
|
||||
<TextBlock FontSize="14" Grid.Column="3" Text="{Binding LibraryStats.StatusString}" VerticalAlignment="Center" />
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Border>
|
||||
|
||||
@@ -51,7 +51,7 @@ namespace LibationAvalonia.Views
|
||||
{
|
||||
this.LibraryLoaded += MainWindow_LibraryLoaded;
|
||||
|
||||
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooksAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
Closing += MainWindow_Closing;
|
||||
@@ -67,7 +67,7 @@ namespace LibationAvalonia.Views
|
||||
if (QuickFilters.UseDefault)
|
||||
await performFilter(QuickFilters.Filters.FirstOrDefault());
|
||||
|
||||
await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks);
|
||||
_viewModel.ProductsDisplay.BindToGrid(dbBooks);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace LibationAvalonia.Views
|
||||
{
|
||||
public partial class ProcessQueueControl : UserControl
|
||||
{
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Items;
|
||||
private TrackedQueue<ProcessBookViewModel> Queue => _viewModel.Queue;
|
||||
private ProcessQueueViewModel _viewModel => DataContext as ProcessQueueViewModel;
|
||||
|
||||
public ProcessQueueControl()
|
||||
@@ -76,14 +76,14 @@ namespace LibationAvalonia.Views
|
||||
},
|
||||
};
|
||||
|
||||
vm.Items.Enqueue(testList);
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Items.MoveNext();
|
||||
vm.Queue.Enqueue(testList);
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
vm.Queue.MoveNext();
|
||||
return;
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -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
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
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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -20,6 +20,8 @@ namespace LibationFileManager
|
||||
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
|
||||
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
|
||||
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
|
||||
public static Version LibationVersion { get; private set; }
|
||||
public static void SetLibationVersion(Version version) => LibationVersion = version;
|
||||
|
||||
public static OS OS { get; }
|
||||
= IsLinux ? OS.Linux
|
||||
|
||||
@@ -73,7 +73,7 @@ namespace LibationFileManager
|
||||
|
||||
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
|
||||
|
||||
[Description("Set cover art as the folder's icon. (Windows only)")]
|
||||
[Description("Set cover art as the folder's icon. (Windows and macOS only)")]
|
||||
public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
178
Source/LibationUiBase/GridView/EntryStatus.cs
Normal file
178
Source/LibationUiBase/GridView/EntryStatus.cs
Normal 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];
|
||||
}
|
||||
}
|
||||
}
|
||||
324
Source/LibationUiBase/GridView/GridEntry[TStatus].cs
Normal file
324
Source/LibationUiBase/GridView/GridEntry[TStatus].cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Source/LibationUiBase/GridView/IGridEntry.cs
Normal file
34
Source/LibationUiBase/GridView/IGridEntry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
Source/LibationUiBase/GridView/ILibraryBookEntry.cs
Normal file
7
Source/LibationUiBase/GridView/ILibraryBookEntry.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace LibationUiBase.GridView
|
||||
{
|
||||
public interface ILibraryBookEntry : IGridEntry
|
||||
{
|
||||
ISeriesEntry Parent { get; }
|
||||
}
|
||||
}
|
||||
11
Source/LibationUiBase/GridView/ISeriesEntry.cs
Normal file
11
Source/LibationUiBase/GridView/ISeriesEntry.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
41
Source/LibationUiBase/GridView/LastDownloadStatus.cs
Normal file
41
Source/LibationUiBase/GridView/LastDownloadStatus.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
34
Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs
Normal file
34
Source/LibationUiBase/GridView/LibraryBookEntry[TStatus].cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
|
||||
namespace LibationUiBase
|
||||
namespace LibationUiBase.GridView
|
||||
{
|
||||
public class ObjectComparer<T> : IComparer where T : IComparable
|
||||
{
|
||||
@@ -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;
|
||||
|
||||
68
Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs
Normal file
68
Source/LibationUiBase/GridView/SeriesEntry[TStatus].cs
Normal 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);
|
||||
}
|
||||
}
|
||||
52
Source/LibationUiBase/IcoEncoder.cs
Normal file
52
Source/LibationUiBase/IcoEncoder.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="2.1.3" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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}
|
||||
|
||||
129
Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs
generated
Normal file
129
Source/LibationWinForms/Dialogs/TrashBinDialog.Designer.cs
generated
Normal file
@@ -0,0 +1,129 @@
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
partial class TrashBinDialog
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
/// </summary>
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && (components != null))
|
||||
{
|
||||
components.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
/// <summary>
|
||||
/// Required method for Designer support - do not modify
|
||||
/// the contents of this method with the code editor.
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
deletedCbl = new System.Windows.Forms.CheckedListBox();
|
||||
label1 = new System.Windows.Forms.Label();
|
||||
restoreBtn = new System.Windows.Forms.Button();
|
||||
permanentlyDeleteBtn = new System.Windows.Forms.Button();
|
||||
everythingCb = new System.Windows.Forms.CheckBox();
|
||||
deletedCheckedLbl = new System.Windows.Forms.Label();
|
||||
SuspendLayout();
|
||||
//
|
||||
// deletedCbl
|
||||
//
|
||||
deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||
deletedCbl.FormattingEnabled = true;
|
||||
deletedCbl.Location = new System.Drawing.Point(12, 27);
|
||||
deletedCbl.Name = "deletedCbl";
|
||||
deletedCbl.Size = new System.Drawing.Size(776, 364);
|
||||
deletedCbl.TabIndex = 3;
|
||||
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
|
||||
//
|
||||
// label1
|
||||
//
|
||||
label1.AutoSize = true;
|
||||
label1.Location = new System.Drawing.Point(12, 9);
|
||||
label1.Name = "label1";
|
||||
label1.Size = new System.Drawing.Size(388, 15);
|
||||
label1.TabIndex = 4;
|
||||
label1.Text = "Check books you want to permanently delete from or restore to Libation";
|
||||
//
|
||||
// restoreBtn
|
||||
//
|
||||
restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
|
||||
restoreBtn.Location = new System.Drawing.Point(572, 398);
|
||||
restoreBtn.Name = "restoreBtn";
|
||||
restoreBtn.Size = new System.Drawing.Size(75, 40);
|
||||
restoreBtn.TabIndex = 5;
|
||||
restoreBtn.Text = "Restore";
|
||||
restoreBtn.UseVisualStyleBackColor = true;
|
||||
restoreBtn.Click += restoreBtn_Click;
|
||||
//
|
||||
// permanentlyDeleteBtn
|
||||
//
|
||||
permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
|
||||
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398);
|
||||
permanentlyDeleteBtn.Name = "permanentlyDeleteBtn";
|
||||
permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40);
|
||||
permanentlyDeleteBtn.TabIndex = 5;
|
||||
permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation";
|
||||
permanentlyDeleteBtn.UseVisualStyleBackColor = true;
|
||||
permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click;
|
||||
//
|
||||
// everythingCb
|
||||
//
|
||||
everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
|
||||
everythingCb.AutoSize = true;
|
||||
everythingCb.Location = new System.Drawing.Point(12, 410);
|
||||
everythingCb.Name = "everythingCb";
|
||||
everythingCb.Size = new System.Drawing.Size(82, 19);
|
||||
everythingCb.TabIndex = 6;
|
||||
everythingCb.Text = "Everything";
|
||||
everythingCb.ThreeState = true;
|
||||
everythingCb.UseVisualStyleBackColor = true;
|
||||
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
|
||||
//
|
||||
// deletedCheckedLbl
|
||||
//
|
||||
deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
|
||||
deletedCheckedLbl.AutoSize = true;
|
||||
deletedCheckedLbl.Location = new System.Drawing.Point(126, 411);
|
||||
deletedCheckedLbl.Name = "deletedCheckedLbl";
|
||||
deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
|
||||
deletedCheckedLbl.TabIndex = 7;
|
||||
deletedCheckedLbl.Text = "Checked: {0} of {1}";
|
||||
//
|
||||
// TrashBinDialog
|
||||
//
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
ClientSize = new System.Drawing.Size(800, 450);
|
||||
Controls.Add(deletedCheckedLbl);
|
||||
Controls.Add(everythingCb);
|
||||
Controls.Add(permanentlyDeleteBtn);
|
||||
Controls.Add(restoreBtn);
|
||||
Controls.Add(label1);
|
||||
Controls.Add(deletedCbl);
|
||||
Name = "TrashBinDialog";
|
||||
Text = "Trash Bin";
|
||||
ResumeLayout(false);
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.CheckedListBox deletedCbl;
|
||||
private System.Windows.Forms.Label label1;
|
||||
private System.Windows.Forms.Button restoreBtn;
|
||||
private System.Windows.Forms.Button permanentlyDeleteBtn;
|
||||
private System.Windows.Forms.CheckBox everythingCb;
|
||||
private System.Windows.Forms.Label deletedCheckedLbl;
|
||||
}
|
||||
}
|
||||
116
Source/LibationWinForms/Dialogs/TrashBinDialog.cs
Normal file
116
Source/LibationWinForms/Dialogs/TrashBinDialog.cs
Normal file
@@ -0,0 +1,116 @@
|
||||
using ApplicationServices;
|
||||
using System;
|
||||
using System.Data;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using System.Collections;
|
||||
|
||||
namespace LibationWinForms.Dialogs
|
||||
{
|
||||
public partial class TrashBinDialog : Form
|
||||
{
|
||||
private readonly string deletedCheckedTemplate;
|
||||
public TrashBinDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
this.SetLibationIcon();
|
||||
this.RestoreSizeAndLocation(Configuration.Instance);
|
||||
this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
|
||||
deletedCheckedTemplate = deletedCheckedLbl.Text;
|
||||
|
||||
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
|
||||
foreach (var lb in deletedBooks)
|
||||
deletedCbl.Items.Add(lb);
|
||||
|
||||
setLabel();
|
||||
}
|
||||
|
||||
private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e)
|
||||
{
|
||||
// CheckedItems.Count is not updated until after the event fires
|
||||
setLabel(e.NewValue);
|
||||
}
|
||||
|
||||
private async void permanentlyDeleteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
setControlsEnabled(false);
|
||||
|
||||
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||
|
||||
removeFromCheckList(removed);
|
||||
await Task.Run(removed.PermanentlyDeleteBooks);
|
||||
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
|
||||
private async void restoreBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
setControlsEnabled(false);
|
||||
|
||||
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||
|
||||
removeFromCheckList(removed);
|
||||
await Task.Run(removed.RestoreBooks);
|
||||
|
||||
setControlsEnabled(true);
|
||||
}
|
||||
|
||||
private void removeFromCheckList(IEnumerable objects)
|
||||
{
|
||||
foreach (var o in objects)
|
||||
deletedCbl.Items.Remove(o);
|
||||
|
||||
deletedCbl.Refresh();
|
||||
setLabel();
|
||||
}
|
||||
|
||||
private void setControlsEnabled(bool enabled)
|
||||
=> restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled;
|
||||
|
||||
private void everythingCb_CheckStateChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (everythingCb.CheckState is CheckState.Indeterminate)
|
||||
{
|
||||
everythingCb.CheckState = CheckState.Unchecked;
|
||||
return;
|
||||
}
|
||||
|
||||
deletedCbl.ItemCheck -= deletedCbl_ItemCheck;
|
||||
|
||||
for (var i = 0; i < deletedCbl.Items.Count; i++)
|
||||
deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked);
|
||||
|
||||
setLabel();
|
||||
|
||||
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
|
||||
}
|
||||
|
||||
|
||||
private void setLabel(CheckState? checkedState = null)
|
||||
{
|
||||
var pre = deletedCbl.CheckedItems.Count;
|
||||
int count = checkedState switch
|
||||
{
|
||||
CheckState.Checked => pre + 1,
|
||||
CheckState.Unchecked => pre - 1,
|
||||
_ => pre,
|
||||
};
|
||||
|
||||
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
|
||||
|
||||
everythingCb.CheckState
|
||||
= count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked
|
||||
: count == 0 ? CheckState.Unchecked
|
||||
: CheckState.Indeterminate;
|
||||
|
||||
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
|
||||
|
||||
deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
Source/LibationWinForms/Dialogs/TrashBinDialog.resx
Normal file
60
Source/LibationWinForms/Dialogs/TrashBinDialog.resx
Normal file
@@ -0,0 +1,60 @@
|
||||
<root>
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
</root>
|
||||
@@ -13,7 +13,6 @@ namespace LibationWinForms
|
||||
// init formattable
|
||||
beginBookBackupsToolStripMenuItem.Format(0);
|
||||
beginPdfBackupsToolStripMenuItem.Format(0);
|
||||
pdfsCountsLbl.Text = "| [Calculating backed up PDFs]";
|
||||
|
||||
Load += setBackupCounts;
|
||||
LibraryCommands.LibrarySizeChanged += setBackupCounts;
|
||||
@@ -21,9 +20,8 @@ namespace LibationWinForms
|
||||
|
||||
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
|
||||
updateCountsBw.RunWorkerCompleted += exportMenuEnable;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomStats;
|
||||
updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem;
|
||||
updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbers;
|
||||
updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem;
|
||||
}
|
||||
|
||||
@@ -52,27 +50,13 @@ namespace LibationWinForms
|
||||
Invoke(() => exportLibraryToolStripMenuItem.Enabled = libraryStats.HasBookResults);
|
||||
}
|
||||
|
||||
// this cannot be cleanly be FormattableToolStripMenuItem because of the optional "Errors" text
|
||||
private const string backupsCountsLbl_Format = "BACKUPS: No progress: {0} In process: {1} Fully backed up: {2}";
|
||||
|
||||
private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
private void updateBottomStats(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
var formatString
|
||||
= !libraryStats.HasBookResults ? "No books. Begin by importing your library"
|
||||
: libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
|
||||
: libraryStats.HasPendingBooks ? backupsCountsLbl_Format
|
||||
: $"All {"book".PluralizeWithCount(libraryStats.booksFullyBackedUp)} backed up";
|
||||
var statusStripText = string.Format(formatString,
|
||||
libraryStats.booksNoProgress,
|
||||
libraryStats.booksDownloadedOnly,
|
||||
libraryStats.booksFullyBackedUp,
|
||||
libraryStats.booksError);
|
||||
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = statusStripText);
|
||||
statusStrip1.UIThreadAsync(() => backupsCountsLbl.Text = libraryStats.StatusString);
|
||||
}
|
||||
|
||||
// update 'begin book backups' menu item
|
||||
// update 'begin book and pdf backups' menu item
|
||||
private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
@@ -88,18 +72,6 @@ namespace LibationWinForms
|
||||
});
|
||||
}
|
||||
|
||||
private void updateBottomPdfNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
var libraryStats = e.Result as LibraryCommands.LibraryStats;
|
||||
|
||||
// don't need to assign the output of Format(). It just makes this logic cleaner
|
||||
var statusStripText
|
||||
= !libraryStats.HasPdfResults ? ""
|
||||
: libraryStats.pdfsNotDownloaded > 0 ? pdfsCountsLbl.Format(libraryStats.pdfsNotDownloaded, libraryStats.pdfsDownloaded)
|
||||
: $"| All {libraryStats.pdfsDownloaded} PDFs downloaded";
|
||||
statusStrip1.UIThreadAsync(() => pdfsCountsLbl.Text = statusStripText);
|
||||
}
|
||||
|
||||
// update 'begin pdf only backups' menu item
|
||||
private void udpate_BeginPdfOnlyBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
|
||||
{
|
||||
|
||||
24
Source/LibationWinForms/Form1.Designer.cs
generated
24
Source/LibationWinForms/Form1.Designer.cs
generated
@@ -63,6 +63,7 @@
|
||||
this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
|
||||
this.openTrashBinToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
|
||||
@@ -76,7 +77,6 @@
|
||||
this.visibleCountLbl = new LibationWinForms.FormattableToolStripStatusLabel();
|
||||
this.springLbl = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
this.backupsCountsLbl = new System.Windows.Forms.ToolStripStatusLabel();
|
||||
this.pdfsCountsLbl = new LibationWinForms.FormattableToolStripStatusLabel();
|
||||
this.addQuickFilterBtn = new System.Windows.Forms.Button();
|
||||
this.splitContainer1 = new System.Windows.Forms.SplitContainer();
|
||||
this.panel1 = new System.Windows.Forms.Panel();
|
||||
@@ -383,8 +383,9 @@
|
||||
this.accountsToolStripMenuItem,
|
||||
this.basicSettingsToolStripMenuItem,
|
||||
this.toolStripSeparator4,
|
||||
this.openTrashBinToolStripMenuItem,
|
||||
this.launchHangoverToolStripMenuItem,
|
||||
this.toolStripSeparator2,
|
||||
this.toolStripSeparator2,
|
||||
this.aboutToolStripMenuItem});
|
||||
this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem";
|
||||
this.settingsToolStripMenuItem.Size = new System.Drawing.Size(61, 20);
|
||||
@@ -424,8 +425,7 @@
|
||||
this.upgradePb,
|
||||
this.visibleCountLbl,
|
||||
this.springLbl,
|
||||
this.backupsCountsLbl,
|
||||
this.pdfsCountsLbl});
|
||||
this.backupsCountsLbl});
|
||||
this.statusStrip1.Location = new System.Drawing.Point(0, 618);
|
||||
this.statusStrip1.Name = "statusStrip1";
|
||||
this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 16, 0);
|
||||
@@ -464,13 +464,6 @@
|
||||
this.backupsCountsLbl.Size = new System.Drawing.Size(218, 17);
|
||||
this.backupsCountsLbl.Text = "[Calculating backed up book quantities]";
|
||||
//
|
||||
// pdfsCountsLbl
|
||||
//
|
||||
this.pdfsCountsLbl.FormatText = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}";
|
||||
this.pdfsCountsLbl.Name = "pdfsCountsLbl";
|
||||
this.pdfsCountsLbl.Size = new System.Drawing.Size(218, 17);
|
||||
this.pdfsCountsLbl.Text = "| PDFs: NOT d/l\'ed: {0} Downloaded: {1}";
|
||||
//
|
||||
// addQuickFilterBtn
|
||||
//
|
||||
this.addQuickFilterBtn.Location = new System.Drawing.Point(50, 3);
|
||||
@@ -592,6 +585,13 @@
|
||||
this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks";
|
||||
this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click);
|
||||
//
|
||||
// openTrashBinToolStripMenuItem
|
||||
//
|
||||
this.openTrashBinToolStripMenuItem.Name = "openTrashBinToolStripMenuItem";
|
||||
this.openTrashBinToolStripMenuItem.Size = new System.Drawing.Size(247, 22);
|
||||
this.openTrashBinToolStripMenuItem.Text = "Trash Bin";
|
||||
this.openTrashBinToolStripMenuItem.Click += new System.EventHandler(this.openTrashBinToolStripMenuItem_Click);
|
||||
//
|
||||
// launchHangoverToolStripMenuItem
|
||||
//
|
||||
this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem";
|
||||
@@ -640,7 +640,6 @@
|
||||
private System.Windows.Forms.ToolStripMenuItem liberateToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripStatusLabel backupsCountsLbl;
|
||||
private LibationWinForms.FormattableToolStripMenuItem beginBookBackupsToolStripMenuItem;
|
||||
private LibationWinForms.FormattableToolStripStatusLabel pdfsCountsLbl;
|
||||
private LibationWinForms.FormattableToolStripMenuItem beginPdfBackupsToolStripMenuItem;
|
||||
private System.Windows.Forms.TextBox filterSearchTb;
|
||||
private System.Windows.Forms.Button filterBtn;
|
||||
@@ -676,6 +675,7 @@
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
|
||||
private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
|
||||
private System.Windows.Forms.ToolStripMenuItem openTrashBinToolStripMenuItem;
|
||||
private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem;
|
||||
private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu;
|
||||
private System.Windows.Forms.SplitContainer splitContainer1;
|
||||
|
||||
@@ -16,6 +16,9 @@ namespace LibationWinForms
|
||||
private async void removeBooksBtn_Click(object sender, EventArgs e)
|
||||
=> await productsDisplay.RemoveCheckedBooksAsync();
|
||||
|
||||
private void openTrashBinToolStripMenuItem_Click(object sender, EventArgs e)
|
||||
=> new TrashBinDialog().ShowDialog(this);
|
||||
|
||||
private void doneRemovingBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
removeBooksBtn.Visible = false;
|
||||
|
||||
@@ -27,25 +27,22 @@ namespace LibationWinForms
|
||||
=> await Task.Run(setLiberatedVisibleMenuItem);
|
||||
void setLiberatedVisibleMenuItem()
|
||||
{
|
||||
var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
|
||||
var libraryStats = LibraryCommands.GetCounts(productsDisplay.GetVisible());
|
||||
this.UIThreadSync(() =>
|
||||
{
|
||||
if (notLiberated > 0)
|
||||
if (libraryStats.HasPendingBooks)
|
||||
{
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(notLiberated);
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = true;
|
||||
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Format(notLiberated);
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = true;
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(libraryStats.PendingBooks);
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Format(libraryStats.PendingBooks);
|
||||
}
|
||||
else
|
||||
{
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Text = "All visible books are liberated";
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = false;
|
||||
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Text = "All visible books are liberated";
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = false;
|
||||
}
|
||||
|
||||
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = libraryStats.HasPendingBooks;
|
||||
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = libraryStats.HasPendingBooks;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -168,11 +165,8 @@ namespace LibationWinForms
|
||||
"Are you sure you want to remove {0} from Libation's library?",
|
||||
"Remove books from Libation?");
|
||||
|
||||
if (confirmationResult != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
await LibraryCommands.RemoveBooksAsync(visibleIds);
|
||||
if (confirmationResult is DialogResult.Yes)
|
||||
await visibleLibraryBooks.RemoveBooksAsync();
|
||||
}
|
||||
|
||||
private async void productsDisplay_VisibleCountChanged(object sender, int qty)
|
||||
@@ -183,9 +177,6 @@ namespace LibationWinForms
|
||||
// top menu strip
|
||||
visibleBooksToolStripMenuItem.Format(qty);
|
||||
visibleBooksToolStripMenuItem.Enabled = qty > 0;
|
||||
|
||||
//Not used for anything?
|
||||
var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
|
||||
|
||||
await Task.Run(setLiberatedVisibleMenuItem);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user