Compare commits

...

65 Commits

Author SHA1 Message Date
Robert McRackan
5a80a0cc06 Second bug fix for issue 263 2022-05-27 07:15:15 -04:00
rmcrackan
aebefac7e6 Merge pull request #266 from Mbucari/master
Fix my own screwup
2022-05-27 07:10:02 -04:00
Michael Bucari-Tovo
b2d0ee41f2 Fix my own screwup 2022-05-26 21:26:56 -06:00
Robert McRackan
9c20250b0a increm ver 2022-05-26 21:10:12 -04:00
rmcrackan
b196836fca Merge pull request #264 from Mbucari/master
Fix for episodes with no series link
2022-05-26 20:41:55 -04:00
Michael Bucari-Tovo
d9fbcc615a Change flow 2022-05-26 18:06:44 -06:00
Michael Bucari-Tovo
fb247fb33f Add better handling for parents and series with no children. 2022-05-26 17:29:55 -06:00
Michael Bucari-Tovo
61f4dbd896 No need to make a new list. 2022-05-26 16:50:43 -06:00
Michael Bucari-Tovo
2c86571818 Better identification of Chilv vs Parent from SeriesBook.Order 2022-05-26 16:49:03 -06:00
Michael Bucari-Tovo
1b2ec67726 Add series info for parent will null order. 2022-05-26 16:43:56 -06:00
Michael Bucari-Tovo
845af854bd Add exception handling to products display 2022-05-26 16:29:40 -06:00
Mbucari
15b6a66d98 Merge branch 'rmcrackan:master' into master 2022-05-26 16:13:13 -06:00
Michael Bucari-Tovo
c95ba0764b Fix bug and add groundwork for future feature 2022-05-26 16:11:52 -06:00
Robert McRackan
42c0648ba7 Bug fix #262 : 'file not found' after moved dir 2022-05-26 16:11:03 -04:00
Robert McRackan
0a6e55dcb7 * Much faster library scans
* Libraries of unlimited size now supported (prev limit was 10k)
2022-05-26 11:33:05 -04:00
rmcrackan
99b77decff Merge pull request #260 from Mbucari/master
Throttle episode scanning to 10 concurrent scans.
2022-05-26 11:29:18 -04:00
Robert McRackan
9e2ca4e586 update dependencies 2022-05-26 10:45:57 -04:00
Michael Bucari-Tovo
2e8acfdeef Limnit episode concurrency to 5 2022-05-26 08:44:39 -06:00
Michael Bucari-Tovo
630096e06d Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-26 08:43:33 -06:00
Michael Bucari-Tovo
d92d892dc7 logging 2022-05-25 20:45:18 -06:00
Michael Bucari-Tovo
a8f41841bd Throttle episode scanning to 10 concurrent scans. 2022-05-25 20:43:12 -06:00
rmcrackan
76954b5a0a Merge pull request #258 from Mbucari/master
Add support for unlimited library size.
2022-05-25 22:00:22 -04:00
Michael Bucari-Tovo
c57b184a09 Remove test params 2022-05-25 16:51:25 -06:00
Michael Bucari-Tovo
20ca4e0739 Refactor for clarity. 2022-05-25 16:49:22 -06:00
Mbucari
a972ed5e2e Merge branch 'rmcrackan:master' into master 2022-05-25 16:05:31 -06:00
Michael Bucari-Tovo
2b15bc6ebb Count Items as they come in and log total. 2022-05-25 15:11:38 -06:00
Robert McRackan
f7a482659c New feature #241 : Auto download episodes after scanning. Setting is on Import Library tab 2022-05-25 15:21:28 -04:00
Robert McRackan
99527453a7 add TODO 2022-05-25 12:56:34 -04:00
Robert McRackan
3408b4637c search engine cleanup 2022-05-25 12:49:24 -04:00
Robert McRackan
3f2899e97e * New event SearchEngineCommands.SearchEngineUpdated
* Clean up redundant event notifications
2022-05-25 10:09:27 -04:00
Michael Bucari-Tovo
562496cfaa Add more logging 2022-05-24 21:36:56 -06:00
Michael Bucari-Tovo
8283f19d6b Parallelize getChildEpisodesAsync 2022-05-24 21:17:59 -06:00
Michael Bucari-Tovo
242909b542 Don't import empty episode 2022-05-24 18:39:47 -06:00
Michael Bucari-Tovo
a7b83ad5e0 Remove 10,000 book limitation and simplify episode import 2022-05-24 18:27:20 -06:00
Michael Bucari-Tovo
ed66019d9a Cleanup 2022-05-24 18:24:53 -06:00
Michael Bucari-Tovo
bc0009be6c Use event return value instead of passing a set delegate. 2022-05-24 15:47:30 -06:00
Michael Bucari-Tovo
c88f47eed4 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-24 15:34:09 -06:00
Michael Bucari-Tovo
59de048ced Error handling network error. 2022-05-24 15:33:52 -06:00
Robert McRackan
7987dfb819 Rename 'liberate visible' menu items. Similar names are error-prone 2022-05-24 15:45:56 -04:00
Robert McRackan
1b101106e7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-05-24 15:32:42 -04:00
rmcrackan
7b75955aec Merge pull request #257 from Mbucari/master
Fix hang on grid update
2022-05-24 15:32:29 -04:00
Michael Bucari-Tovo
8f5467e6ca Revert stupid change. 2022-05-24 13:30:39 -06:00
Michael Bucari-Tovo
28764f92b9 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-24 13:16:56 -06:00
Michael Bucari-Tovo
777dfe4c62 Fix hang on grid update 2022-05-24 13:16:44 -06:00
Robert McRackan
0878a704d9 search engine: podcast and episode should allow plural 2022-05-24 15:07:53 -04:00
Robert McRackan
f880897542 Increm version 2022-05-24 14:04:53 -04:00
rmcrackan
b37472a954 Merge pull request #255 from Mbucari/master
Implemented Episode grouping and refactored ProductsGrid
2022-05-24 13:59:12 -04:00
Michael Bucari-Tovo
68735a45dd Change episode color 2022-05-24 11:52:33 -06:00
Michael Bucari-Tovo
e26deb9092 Address comments 2022-05-24 11:15:41 -06:00
Michael Bucari-Tovo
43d6ea82cd Change failure behavior to match previous implementation 2022-05-24 09:17:09 -06:00
Mbucari
db1aa495ac Merge branch 'rmcrackan:master' into master 2022-05-24 08:48:32 -06:00
Michael Bucari-Tovo
ee62d9ae8d Attempt to fix app hang on LogMe event 2022-05-24 07:36:17 -06:00
Robert McRackan
4001124cfa AudibleApi. Better logging around getting pdf url 2022-05-24 09:03:43 -04:00
Michael Bucari-Tovo
43a4d0d1d7 Cleanup 2022-05-23 22:24:45 -06:00
Michael Bucari-Tovo
632b432b7c Revert to old column indexing 2022-05-23 22:21:37 -06:00
Michael Bucari-Tovo
e778c7a59d Create GridView namespace 2022-05-23 21:34:43 -06:00
Michael Bucari-Tovo
d71cdecd35 Refactoring and addressing comments 2022-05-23 21:20:26 -06:00
Michael Bucari-Tovo
4a82541ffd Fix error while removing filter on a sorted binding list 2022-05-23 17:46:55 -06:00
Michael Bucari-Tovo
f29dff3386 Fix filtering bug 2022-05-23 17:22:02 -06:00
Michael Bucari-Tovo
718d21f6cb NotifyPropertyChanged series on update 2022-05-23 16:42:05 -06:00
Michael Bucari-Tovo
440550ded9 Add binding source at design time 2022-05-23 16:35:18 -06:00
Michael Bucari-Tovo
593fe57ea1 Refactor ProductsGrid 2022-05-23 15:29:26 -06:00
Michael Bucari-Tovo
e8a320dac9 Add grid categories 2022-05-22 20:00:41 -06:00
Michael Bucari-Tovo
3cb43e5d3e Improve display 2022-05-22 20:00:06 -06:00
Robert McRackan
f86bdba3c3 Test in-place upgrade 2022-05-20 16:26:58 -04:00
69 changed files with 3221 additions and 2479 deletions

View File

@@ -217,40 +217,47 @@ namespace AaxDecrypter
{
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ];
do
try
{
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
if (downloadPosition > nextFlush)
do
{
_writeFile.Flush();
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
}
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
} while (downloadPosition < ContentLength && !IsCancelled);
downloadPosition += bytesRead;
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
if (downloadPosition > nextFlush)
{
_writeFile.Flush();
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
}
downloadedPiece.Set();
downloadEnded.Set();
} while (downloadPosition < ContentLength && !IsCancelled);
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
downloadedPiece.Set();
downloadEnded.Set();
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
IsCancelled = true;
}
}
#endregion

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Version>7.7.0.1</Version>
<Version>7.10.2.1</Version>
</PropertyGroup>
<ItemGroup>
@@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core;
using Dinah.Core.IO;
@@ -137,14 +138,19 @@ namespace AppScaffolding
if (!config.Exists(nameof(config.DownloadCoverArt)))
config.DownloadCoverArt = true;
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
config.AutoDownloadEpisodes = false;
}
/// <summary>Initialize logging. Run after migration</summary>
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Configuration config)
{
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
wireUpSystemEvents(config);
}
private static void ensureSerilogConfig(Configuration config)
@@ -282,6 +288,12 @@ namespace AppScaffolding
});
}
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
}
public static UpgradeProperties GetLatestRelease()
{
// timed out

View File

@@ -253,11 +253,7 @@ namespace ApplicationServices
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange()
{
SearchEngineCommands.FullReIndex();
LibrarySizeChanged?.Invoke(null, null);
}
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
@@ -265,9 +261,47 @@ namespace ApplicationServices
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler BookUserDefinedItemCommitted;
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
{
book.UserDefinedItem.BookStatus = bookStatus;
return UpdateUserDefinedItem(book);
}
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
{
book.UserDefinedItem.PdfStatus = pdfStatus;
return UpdateUserDefinedItem(book);
}
public static int UpdateBook(
this Book book,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null)
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
public static int UpdateBooks(
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
params Book[] books)
{
foreach (var book in books)
{
// blank tags are expected. null tags are not
if (tags is not null && book.UserDefinedItem.Tags != tags)
book.UserDefinedItem.Tags = tags;
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
book.UserDefinedItem.BookStatus = bookStatus.Value;
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
}
return UpdateUserDefinedItem(books);
}
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
{
@@ -283,23 +317,8 @@ namespace ApplicationServices
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
if (qtyChanges == 0)
return 0;
// semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
// I did not benchmark before choosing the number here
if (qtyChanges > 15)
SearchEngineCommands.FullReIndex();
else
{
foreach (var book in books)
{
SearchEngineCommands.UpdateLiberatedStatus(book);
SearchEngineCommands.UpdateBookTags(book);
}
}
BookUserDefinedItemCommitted?.Invoke(null, null);
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, books);
return qtyChanges;
}
@@ -322,7 +341,14 @@ 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 pdfsDownloaded, int pdfsNotDownloaded)
{
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()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using LibationSearchEngine;
@@ -7,51 +9,99 @@ namespace ApplicationServices
{
public static class SearchEngineCommands
{
public static void FullReIndex(SearchEngine engine = null)
{
engine ??= new SearchEngine();
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
}
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
#region Search
public static SearchResultSet Search(string searchString) => performSafeQuery(e =>
e.Search(searchString)
);
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
private static T performSafeQuery<T>(Func<SearchEngine, T> func)
{
var engine = new SearchEngine();
try
{
return func(engine);
}
catch (FileNotFoundException)
{
fullReIndex(engine);
return func(engine);
}
}
#endregion
public static EventHandler SearchEngineUpdated;
#region Update
private static bool isUpdating;
public static void UpdateBooks(IEnumerable<Book> books)
{
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
// I did not benchmark before choosing the number here
if (books.Count() > 15)
FullReIndex();
else
{
foreach (var book in books)
{
UpdateLiberatedStatus(book);
UpdateBookTags(book);
}
}
}
public static void FullReIndex() => performSafeCommand(e =>
fullReIndex(e)
);
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
internal static void UpdateLiberatedStatus(Book book) => performSafeCommand(e =>
e.UpdateLiberatedStatus(book)
);
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
internal static void UpdateBookTags(Book book) => performSafeCommand(e =>
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
);
private static void performSafeCommand(Action<SearchEngine> action)
{
var engine = new SearchEngine();
try
{
action(engine);
update(action);
}
catch (FileNotFoundException)
{
FullReIndex(engine);
action(engine);
fullReIndex(new SearchEngine());
update(action);
}
}
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
private static void update(Action<SearchEngine> action)
{
var engine = new SearchEngine();
if (action is null)
return;
// support nesting incl recursion
var prevIsUpdating = isUpdating;
try
{
return func(engine);
isUpdating = true;
action(new SearchEngine());
if (!prevIsUpdating)
SearchEngineUpdated?.Invoke(null, null);
}
catch (FileNotFoundException)
finally
{
FullReIndex(engine);
return func(engine);
isUpdating = prevIsUpdating;
}
}
private static void fullReIndex(SearchEngine engine)
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
}
#endregion
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Common;
@@ -118,31 +119,38 @@ namespace AudibleUtilities
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
{
var items = new List<Item>();
#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));
//}
#endif
Serilog.Log.Logger.Debug("Begin initial library scan");
Serilog.Log.Logger.Debug("Beginning library scan.");
if (!items.Any())
items = await Api.GetAllLibraryItemsAsync(libraryOptions);
List<Task<List<Item>>> getChildEpisodesTasks = new();
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
await manageEpisodesAsync(items, importEpisodes);
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
{
if (item.IsEpisodes && importEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
}
else if (!item.IsEpisodes)
items.Add(item);
Serilog.Log.Logger.Debug("Episode scan complete");
count++;
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
//await and add all episides from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
Serilog.Log.Logger.Debug("Completed library scan.");
#if DEBUG
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
//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)
@@ -156,54 +164,24 @@ namespace AudibleUtilities
}
#region episodes and podcasts
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
{
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
await concurrencySemaphore.WaitAsync();
try
{
// get parents
var parents = items.Where(i => i.IsEpisodes).ToList();
#if DEBUG
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText("parents.json", parentsDebug);
#endif
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
if (!parents.Any())
return;
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
items.RemoveAll(i => i.IsEpisodes);
if (importEpisodes)
{
// add children
var children = await getEpisodesAsync(parents);
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
items.AddRange(children);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
}
}
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
{
var results = new List<Item>();
foreach (var parent in parents)
{
var children = await getEpisodeChildrenAsync(parent);
// actual individual episode, not the parent of a series.
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
if (!children.Any())
{
results.Add(parent);
continue;
//The parent is the only episode in the podcase series,
//so the parent is its own child.
parent.Series = new Series[] { new Series { Asin = parent.Asin, Sequence = RelationshipToProduct.Parent, Title = parent.TitleWithSubtitle } };
children.Add(parent);
return children;
}
foreach (var child in children)
@@ -217,7 +195,7 @@ namespace AudibleUtilities
{
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(),
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
@@ -232,10 +210,14 @@ namespace AudibleUtilities
};
}
results.AddRange(children);
}
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return results;
return children;
}
finally
{
concurrencySemaphore.Release();
}
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
@@ -261,8 +243,8 @@ namespace AudibleUtilities
{
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);
//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)
@@ -277,7 +259,7 @@ namespace AudibleUtilities
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
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;
@@ -295,7 +277,7 @@ namespace AudibleUtilities
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, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
throw ex;
}

View File

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

View File

@@ -12,7 +12,7 @@ namespace DataLayer
public float StoryRating { get; private set; }
private Rating() { }
internal Rating(float overallRating, float performanceRating, float storyRating)
public Rating(float overallRating, float performanceRating, float storyRating)
{
OverallRating = overallRating;
PerformanceRating = performanceRating;

View File

@@ -4,7 +4,8 @@ namespace FileLiberator
{
public abstract class AudioDecodable : Processable
{
public event EventHandler<Action<byte[]>> RequestCoverArt;
public delegate byte[] RequestCoverArtHandler(object sender, EventArgs eventArgs);
public event RequestCoverArtHandler RequestCoverArt;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
@@ -32,10 +33,10 @@ namespace FileLiberator
NarratorsDiscovered?.Invoke(this, narrators);
}
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
protected byte[] OnRequestCoverArt()
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
RequestCoverArt?.Invoke(this, setCoverArtDel);
return RequestCoverArt?.Invoke(this, new());
}
protected void OnCoverImageDiscovered(byte[] coverImage)

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
using AudibleApi;
using DataLayer;
using Dinah.Core;
@@ -82,8 +83,7 @@ namespace FileLiberator
if (Configuration.Instance.DownloadCoverArt)
DownloadCoverArt(libraryBook);
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
return new StatusHandler();
}
@@ -247,7 +247,7 @@ namespace FileLiberator
if (e is not null)
OnCoverImageDiscovered(e);
else if (Configuration.Instance.AllowLibationFixup)
OnRequestCoverArt(abDownloader.SetCoverArt);
abDownloader.SetCoverArt(OnRequestCoverArt());
}
/// <summary>Move new files to 'Books' directory</summary>

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ApplicationServices;
using DataLayer;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
@@ -29,8 +30,7 @@ namespace FileLiberator
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
return result;
}

View File

@@ -134,7 +134,8 @@ namespace FileManager
private void AddPath(string path)
{
if (!File.Exists(path)) return;
if (!File.Exists(path) && !Directory.Exists(path))
return;
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
else

View File

@@ -33,7 +33,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>

View File

@@ -27,7 +27,6 @@ namespace LibationCli
// //
//***********************************************//
Setup.Initialize();
Setup.SubscribeToDatabaseEvents();
var types = Setup.LoadVerbs();

View File

@@ -50,11 +50,6 @@ namespace LibationCli
}
}
public static void SubscribeToDatabaseEvents()
{
DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book);
}
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)

View File

@@ -268,6 +268,13 @@ namespace LibationFileManager
}
}
[Description("Auto download episodes? Efter scan, download new books in 'checked' accounts.")]
public bool AutoDownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoDownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(AutoDownloadEpisodes), value);
}
#region templates: custom file naming
[Description("How to format the folders in which files will be saved")]

View File

@@ -18,18 +18,11 @@ namespace LibationSearchEngine
{
public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30;
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
public const string _ID_ = "_ID_";
public const string TAGS = "tags";
// special field for each book which includes all major parts of the book's metadata. enables non-targetting searching
public const string ALL = "all";
// the workaround which allows displaying all books when query is empty
public const string ALL_QUERY = "*:*";
#region index rules
// common fields used in the "all" default search field
public const string ALL_AUDIBLE_PRODUCT_ID = nameof(Book.AudibleProductId);
@@ -124,14 +117,15 @@ namespace LibationSearchEngine
[nameof(Book.IsAbridged)] = lb => lb.Book.IsAbridged,
["Abridged"] = lb => lb.Book.IsAbridged,
// this will only be evaluated at time of re-index. ie: state of files moved later will be out of sync until next re-index
["IsLiberated"] = lb => isLiberated(lb.Book),
["Liberated"] = lb => isLiberated(lb.Book),
["LiberatedError"] = lb => liberatedError(lb.Book),
["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["Podcasts"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["Episode"] = lb => lb.Book.ContentType == ContentType.Episode,
["Episodes"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsEpisode"] = lb => lb.Book.ContentType == ContentType.Episode,
}
);
@@ -182,18 +176,6 @@ namespace LibationSearchEngine
foreach (var key in numberIndexRules.Keys)
yield return key;
}
public static IEnumerable<string> GetSearchFields()
{
foreach (var key in idIndexRules.Keys)
yield return key;
foreach (var key in stringIndexRules.Keys)
yield return key;
foreach (var key in boolIndexRules.Keys)
yield return key;
foreach (var key in numberIndexRules.Keys)
yield return key;
}
#endregion
#region create and update index
@@ -290,6 +272,10 @@ namespace LibationSearchEngine
book.AudibleProductId,
d =>
{
//
// TODO: better synonym handling. This is too easy to mess up
//
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
// ie: must remove old before adding new else will create unwanted duplicates.
var v1 = isLiberated(book);
@@ -331,6 +317,9 @@ namespace LibationSearchEngine
}
#endregion
// the workaround which allows displaying all books when query is empty
public const string ALL_QUERY = "*:*";
#region search
public SearchResultSet Search(string searchString)
{
@@ -345,7 +334,7 @@ namespace LibationSearchEngine
return results;
}
public static string FormatSearchQuery(string searchString)
internal static string FormatSearchQuery(string searchString)
{
if (string.IsNullOrWhiteSpace(searchString))
return ALL_QUERY;
@@ -491,5 +480,9 @@ namespace LibationSearchEngine
#endregion
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
}
}

View File

@@ -38,7 +38,7 @@ namespace LibationWinForms.Dialogs
this.authorsDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.miscDataGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.purchaseDateGridViewTextBoxColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.gridEntryBindingSource = new LibationWinForms.SyncBindingSource(this.components);
this.gridEntryBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
this.btnRemoveBooks = new System.Windows.Forms.Button();
this.label1 = new System.Windows.Forms.Label();
((System.ComponentModel.ISupportInitialize)(this._dataGridView)).BeginInit();
@@ -176,7 +176,7 @@ namespace LibationWinForms.Dialogs
#endregion
private System.Windows.Forms.DataGridView _dataGridView;
private LibationWinForms.SyncBindingSource gridEntryBindingSource;
private LibationWinForms.GridView.SyncBindingSource gridEntryBindingSource;
private System.Windows.Forms.Button btnRemoveBooks;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.DataGridViewCheckBoxColumn removeDataGridViewCheckBoxColumn;

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
@@ -121,10 +120,8 @@ namespace LibationWinForms.Dialogs
}
}
internal class RemovableGridEntry : GridEntry
internal class RemovableGridEntry : GridView.LibraryBookEntry
{
private static readonly IComparer BoolComparer = new ObjectComparer<bool>();
private bool _remove = false;
public RemovableGridEntry(LibraryBook libraryBook) : base(libraryBook) { }
@@ -147,12 +144,5 @@ namespace LibationWinForms.Dialogs
return Remove;
return base.GetMemberValue(memberName);
}
public override IComparer GetMemberComparer(Type memberType)
{
if (memberType == typeof(bool))
return BoolComparer;
return base.GetMemberComparer(memberType);
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -35,6 +35,7 @@ namespace LibationWinForms.Dialogs
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
this.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes));
this.booksLocationDescLbl.Text = desc(nameof(config.Books));
this.inProgressDescLbl.Text = desc(nameof(config.InProgress));
@@ -80,6 +81,7 @@ namespace LibationWinForms.Dialogs
showImportedStatsCb.Checked = config.ShowImportedStats;
importEpisodesCb.Checked = config.ImportEpisodes;
downloadEpisodesCb.Checked = config.DownloadEpisodes;
autoDownloadEpisodesCb.Checked = config.AutoDownloadEpisodes;
lameTargetRb_CheckedChanged(this, e);
LameMatchSourceBRCbox_CheckedChanged(this, e);
@@ -204,6 +206,7 @@ namespace LibationWinForms.Dialogs
config.ShowImportedStats = showImportedStatsCb.Checked;
config.ImportEpisodes = importEpisodesCb.Checked;
config.DownloadEpisodes = downloadEpisodesCb.Checked;
config.AutoDownloadEpisodes = autoDownloadEpisodesCb.Checked;
config.InProgress = inProgressSelectControl.SelectedDirectory;

View File

@@ -6,6 +6,8 @@ namespace LibationWinForms
{
public partial class Form1
{
private System.ComponentModel.BackgroundWorker updateCountsBw = new();
protected void Configure_BackupCounts()
{
// init formattable
@@ -16,22 +18,23 @@ namespace LibationWinForms
Load += setBackupCounts;
LibraryCommands.LibrarySizeChanged += setBackupCounts;
LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts;
}
private System.ComponentModel.BackgroundWorker updateCountsBw;
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
updateCountsBw.RunWorkerCompleted += exportMenuEnable;
updateCountsBw.RunWorkerCompleted += updateBottomBookNumbers;
updateCountsBw.RunWorkerCompleted += update_BeginBookBackups_menuItem;
updateCountsBw.RunWorkerCompleted += updateBottomPdfNumbers;
updateCountsBw.RunWorkerCompleted += udpate_BeginPdfOnlyBackups_menuItem;
}
private bool runBackupCountsAgain;
private void setBackupCounts(object _, object __)
{
runBackupCountsAgain = true;
if (updateCountsBw is not null)
return;
updateCountsBw = new System.ComponentModel.BackgroundWorker();
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
updateCountsBw.RunWorkerCompleted += UpdateCountsBw_RunWorkerCompleted;
updateCountsBw.RunWorkerAsync();
if (!updateCountsBw.IsBusy)
updateCountsBw.RunWorkerAsync();
}
private void UpdateCountsBw_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
@@ -39,87 +42,78 @@ namespace LibationWinForms
while (runBackupCountsAgain)
{
runBackupCountsAgain = false;
var libraryStats = LibraryCommands.GetCounts();
e.Result = libraryStats;
e.Result = LibraryCommands.GetCounts();
}
updateCountsBw = null;
}
private void UpdateCountsBw_RunWorkerCompleted(object sender, System.ComponentModel.RunWorkerCompletedEventArgs e)
private void exportMenuEnable(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{
var libraryStats = e.Result as LibraryCommands.LibraryStats;
setBookBackupCounts(libraryStats);
setPdfBackupCounts(libraryStats);
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 setBookBackupCounts(LibraryCommands.LibraryStats libraryStats)
private void updateBottomBookNumbers(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{
var pending = libraryStats.booksNoProgress + libraryStats.booksDownloadedOnly;
var hasResults = 0 < (libraryStats.booksFullyBackedUp + libraryStats.booksDownloadedOnly + libraryStats.booksNoProgress + libraryStats.booksError);
var libraryStats = e.Result as LibraryCommands.LibraryStats;
// enable/disable export
{
exportLibraryToolStripMenuItem.Enabled = hasResults;
}
// update bottom numbers
{
var formatString
= !hasResults ? "No books. Begin by importing your library"
: libraryStats.booksError > 0 ? backupsCountsLbl_Format + " Errors: {3}"
: pending > 0 ? 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);
}
// update 'begin book backups' menu item
{
var menuItemText
= pending > 0
? $"{pending} remaining"
: "All books have been liberated";
menuStrip1.UIThreadAsync(() =>
{
beginBookBackupsToolStripMenuItem.Format(menuItemText);
beginBookBackupsToolStripMenuItem.Enabled = pending > 0;
});
}
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);
}
private void setPdfBackupCounts(LibraryCommands.LibraryStats libraryStats)
{
// update bottom numbers
{
var hasResults = 0 < (libraryStats.pdfsNotDownloaded + libraryStats.pdfsDownloaded);
// don't need to assign the output of Format(). It just makes this logic cleaner
var statusStripText
= !hasResults ? ""
: 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
// update 'begin book backups' menu item
private void update_BeginBookBackups_menuItem(object _, System.ComponentModel.RunWorkerCompletedEventArgs e)
{
var libraryStats = e.Result as LibraryCommands.LibraryStats;
var menuItemText
= libraryStats.HasPendingBooks
? $"{libraryStats.PendingBooks} remaining"
: "All books have been liberated";
menuStrip1.UIThreadAsync(() =>
{
var menuItemText
= libraryStats.pdfsNotDownloaded > 0
? $"{libraryStats.pdfsNotDownloaded} remaining"
: "All PDFs have been downloaded";
menuStrip1.UIThreadAsync(() =>
{
beginPdfBackupsToolStripMenuItem.Format(menuItemText);
beginPdfBackupsToolStripMenuItem.Enabled = libraryStats.pdfsNotDownloaded > 0;
});
}
beginBookBackupsToolStripMenuItem.Format(menuItemText);
beginBookBackupsToolStripMenuItem.Enabled = libraryStats.HasPendingBooks;
});
}
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)
{
var libraryStats = e.Result as LibraryCommands.LibraryStats;
var menuItemText
= libraryStats.pdfsNotDownloaded > 0
? $"{libraryStats.pdfsNotDownloaded} remaining"
: "All PDFs have been downloaded";
menuStrip1.UIThreadAsync(() =>
{
beginPdfBackupsToolStripMenuItem.Format(menuItemText);
beginPdfBackupsToolStripMenuItem.Enabled = libraryStats.pdfsNotDownloaded > 0;
});
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -30,7 +30,7 @@ namespace LibationWinForms
try
{
productsGrid.Filter(filterString);
productsDisplay.Filter(filterString);
lastGoodFilter = filterString;
}
catch (Exception ex)

View File

@@ -10,7 +10,7 @@ namespace LibationWinForms
private void Configure_Liberate() { }
//GetLibrary_Flat_NoTracking() may take a long time on a hugh library. so run in new thread
private async void beginBookBackupsToolStripMenuItem_Click(object sender, EventArgs e)
private async void beginBookBackupsToolStripMenuItem_Click(object _ = null, EventArgs __ = null)
{
SetQueueCollapseState(false);
await Task.Run(() => processBookQueue1.AddDownloadDecrypt(ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking()

View File

@@ -1,17 +0,0 @@
using Dinah.Core.Drawing;
using LibationFileManager;
namespace LibationWinForms
{
public partial class Form1
{
private void Configure_PictureStorage()
{
// init default/placeholder cover art
var format = System.Drawing.Imaging.ImageFormat.Jpeg;
PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format));
}
}
}

View File

@@ -14,14 +14,13 @@ namespace LibationWinForms
int WidthChange = 0;
private void Configure_ProcessQueue()
{
productsGrid.LiberateClicked += ProductsGrid_LiberateClicked;
processBookQueue1.popoutBtn.Click += ProcessBookQueue1_PopOut;
var coppalseState = Configuration.Instance.GetNonString<bool>(nameof(splitContainer1.Panel2Collapsed));
WidthChange = splitContainer1.Panel2.Width + splitContainer1.SplitterWidth;
SetQueueCollapseState(coppalseState);
}
private void ProductsGrid_LiberateClicked(object sender, LibraryBook e)
private void ProductsDisplay_LiberateClicked(object sender, LibraryBook e)
{
if (e.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
{

View File

@@ -14,12 +14,6 @@ namespace LibationWinForms
Load += updateFiltersMenu;
QuickFilters.UseDefaultChanged += updateFirstFilterIsDefaultToolStripMenuItem;
QuickFilters.Updated += updateFiltersMenu;
productsGrid.InitialLoaded += (_, __) =>
{
if (QuickFilters.UseDefault)
performFilter(QuickFilters.Filters.FirstOrDefault());
};
}
private object quickFilterTag { get; } = new();
@@ -56,5 +50,11 @@ namespace LibationWinForms
private void addQuickFilterBtn_Click(object sender, EventArgs e) => QuickFilters.Add(this.filterSearchTb.Text);
private void editQuickFiltersToolStripMenuItem_Click(object sender, EventArgs e) => new EditQuickFilters().ShowDialog();
private void productsDisplay_InitialLoaded(object sender, EventArgs e)
{
if (QuickFilters.UseDefault)
performFilter(QuickFilters.Filters.FirstOrDefault());
}
}
}

View File

@@ -14,48 +14,36 @@ namespace LibationWinForms
{
// init formattable
visibleCountLbl.Format(0);
liberateVisibleToolStripMenuItem.Format(0);
liberateVisible2ToolStripMenuItem.Format(0);
// bottom-left visible count
productsGrid.VisibleCountChanged += (_, qty) => visibleCountLbl.Format(qty);
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(0);
liberateVisibleToolStripMenuItem_LiberateMenu.Format(0);
// top menu strip
visibleBooksToolStripMenuItem.Format(0);
productsGrid.VisibleCountChanged += (_, qty) => {
visibleBooksToolStripMenuItem.Format(qty);
visibleBooksToolStripMenuItem.Enabled = qty > 0;
var notLiberatedCount = productsGrid.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
};
productsGrid.VisibleCountChanged += setLiberatedVisibleMenuItemAsync;
LibraryCommands.BookUserDefinedItemCommitted += setLiberatedVisibleMenuItemAsync;
}
private async void setLiberatedVisibleMenuItemAsync(object _, int __)
=> await Task.Run(setLiberatedVisibleMenuItem);
private async void setLiberatedVisibleMenuItemAsync(object _, EventArgs __)
private async void setLiberatedVisibleMenuItemAsync(object _, object __)
=> await Task.Run(setLiberatedVisibleMenuItem);
void setLiberatedVisibleMenuItem()
{
var notLiberated = productsGrid.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
var notLiberated = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
this.UIThreadSync(() =>
{
if (notLiberated > 0)
{
liberateVisibleToolStripMenuItem.Format(notLiberated);
liberateVisibleToolStripMenuItem.Enabled = true;
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Format(notLiberated);
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = true;
liberateVisible2ToolStripMenuItem.Format(notLiberated);
liberateVisible2ToolStripMenuItem.Enabled = true;
liberateVisibleToolStripMenuItem_LiberateMenu.Format(notLiberated);
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = true;
}
else
{
liberateVisibleToolStripMenuItem.Text = "All visible books are liberated";
liberateVisibleToolStripMenuItem.Enabled = false;
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Text = "All visible books are liberated";
liberateVisibleToolStripMenuItem_VisibleBooksMenu.Enabled = false;
liberateVisible2ToolStripMenuItem.Text = "All visible books are liberated";
liberateVisible2ToolStripMenuItem.Enabled = false;
liberateVisibleToolStripMenuItem_LiberateMenu.Text = "All visible books are liberated";
liberateVisibleToolStripMenuItem_LiberateMenu.Enabled = false;
}
});
}
@@ -63,7 +51,7 @@ namespace LibationWinForms
private async void liberateVisible(object sender, EventArgs e)
{
SetQueueCollapseState(false);
await Task.Run(() => processBookQueue1.AddDownloadDecrypt(productsGrid.GetVisible()));
await Task.Run(() => processBookQueue1.AddDownloadDecrypt(productsDisplay.GetVisible()));
}
private void replaceTagsToolStripMenuItem_Click(object sender, EventArgs e)
@@ -73,7 +61,7 @@ namespace LibationWinForms
if (result != DialogResult.OK)
return;
var visibleLibraryBooks = productsGrid.GetVisible();
var visibleLibraryBooks = productsDisplay.GetVisible();
var confirmationResult = MessageBoxLib.ShowConfirmationDialog(
visibleLibraryBooks,
@@ -95,7 +83,7 @@ namespace LibationWinForms
if (result != DialogResult.OK)
return;
var visibleLibraryBooks = productsGrid.GetVisible();
var visibleLibraryBooks = productsDisplay.GetVisible();
var confirmationResult = MessageBoxLib.ShowConfirmationDialog(
visibleLibraryBooks,
@@ -112,7 +100,7 @@ namespace LibationWinForms
private async void removeToolStripMenuItem_Click(object sender, EventArgs e)
{
var visibleLibraryBooks = productsGrid.GetVisible();
var visibleLibraryBooks = productsDisplay.GetVisible();
var confirmationResult = MessageBoxLib.ShowConfirmationDialog(
visibleLibraryBooks,
@@ -125,5 +113,20 @@ namespace LibationWinForms
var visibleIds = visibleLibraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
await LibraryCommands.RemoveBooksAsync(visibleIds);
}
private async void productsDisplay_VisibleCountChanged(object sender, int qty)
{
// bottom-left visible count
visibleCountLbl.Format(qty);
// top menu strip
visibleBooksToolStripMenuItem.Format(qty);
visibleBooksToolStripMenuItem.Enabled = qty > 0;
//Not used for anything?
var notLiberatedCount = productsDisplay.GetVisible().Count(lb => lb.Book.UserDefinedItem.BookStatus == DataLayer.LiberatedStatus.NotLiberated);
await Task.Run(setLiberatedVisibleMenuItem);
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using ApplicationServices;
using Dinah.Core.Drawing;
using LibationFileManager;
namespace LibationWinForms
{
public partial class Form1
{
private void Configure_NonUI()
{
// init default/placeholder cover art
var format = System.Drawing.Imaging.ImageFormat.Jpeg;
PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize.Native, Properties.Resources.default_cover_500x500.ToBytes(format));
// wire-up event to automatically download after scan.
// winforms only. this should NOT be allowed in cli
updateCountsBw.RunWorkerCompleted += (object sender, System.ComponentModel.RunWorkerCompletedEventArgs e) =>
{
if (!Configuration.Instance.AutoDownloadEpisodes)
return;
var libraryStats = e.Result as LibraryCommands.LibraryStats;
if ((libraryStats.booksNoProgress + libraryStats.pdfsNotDownloaded) > 0)
beginBookBackupsToolStripMenuItem_Click();
};
}
}
}

View File

@@ -12,24 +12,10 @@ namespace LibationWinForms
{
public partial class Form1 : Form
{
private ProductsGrid productsGrid { get; }
public Form1()
{
InitializeComponent();
if (this.DesignMode)
return;
{
// I'd actually like these lines to be handled in the designer, but I'm currently getting this error when I try:
// Failed to create component 'ProductsGrid'. The error message follows:
// 'Microsoft.DotNet.DesignTools.Client.DesignToolsServerException: Object reference not set to an instance of an object.
// Since the designer's choking on it, I'm keeping it below the DesignMode check to be safe
productsGrid = new ProductsGrid { Dock = DockStyle.Fill };
gridPanel.Controls.Add(productsGrid);
}
// Pre-requisite:
// Before calling anything else, including subscribing to events, ensure database exists. If we wait and let it happen lazily, race conditions and errors are likely during new installs
using var _ = DbContexts.GetContext();
@@ -52,7 +38,6 @@ namespace LibationWinForms
// these should do nothing interesting yet (storing simple var, subscribe to events) and should never rely on each other for order.
// otherwise, order could be an issue.
// eg: if one of these init'd productsGrid, then another can't reliably subscribe to it
Configure_PictureStorage();
Configure_BackupCounts();
Configure_ScanAuto();
Configure_ScanNotification();
@@ -64,11 +49,13 @@ namespace LibationWinForms
Configure_Settings();
Configure_ProcessQueue();
Configure_Filter();
// misc which belongs in winforms app but doesn't have a UI element
Configure_NonUI();
// Configure_Grid(); // since it's just this, can keep here. If it needs more, then give grid it's own 'partial class Form1'
{
this.Load += (_, __) => productsGrid.Display();
LibraryCommands.LibrarySizeChanged += (_, __) => this.UIThreadAsync(() => productsGrid.Display());
this.Load += (_, __) => productsDisplay.Display();
LibraryCommands.LibrarySizeChanged += (_, __) => this.UIThreadAsync(() => productsDisplay.Display());
}
}

View File

@@ -57,12 +57,48 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="filterHelpBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="filterBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="filterSearchTb.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="menuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="menuStrip1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="statusStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>132, 17</value>
</metadata>
<metadata name="statusStrip1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="addQuickFilterBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="splitContainer1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="panel1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="productsDisplay.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="toggleQueueHideBtn.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="processBookQueue1.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="$this.Locked" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<assembly alias="System.Drawing" name="System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a" />
<data name="$this.Icon" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>

View File

@@ -2,7 +2,7 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
public abstract class AsyncNotifyPropertyChanged : SynchronizeInvoker, INotifyPropertyChanged
{

View File

@@ -1,7 +1,7 @@
using System.Drawing;
using System.Windows.Forms;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
public class DataGridViewImageButtonCell : DataGridViewButtonCell
{

View File

@@ -1,4 +1,4 @@
namespace LibationWinForms
namespace LibationWinForms.GridView
{
partial class DescriptionDisplay
{

View File

@@ -1,17 +1,15 @@
using System;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using System.Runtime.InteropServices;
using System.Windows.Forms;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
public partial class DescriptionDisplay : Form
{
private int borderThickness = 5;
public int BorderThickness
public int BorderThickness
{
get => borderThickness;
set

View File

@@ -1,7 +1,8 @@
using System.Drawing;
using Dinah.Core.Windows.Forms;
using System.Drawing;
using System.Windows.Forms;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
public class EditTagsDataGridViewImageButtonColumn : DataGridViewButtonColumn
{
@@ -18,6 +19,12 @@ namespace LibationWinForms
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)
{
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;

View File

@@ -0,0 +1,114 @@
using DataLayer;
using Dinah.Core.DataBinding;
using Dinah.Core.Drawing;
using LibationFileManager;
using System;
using System.Collections;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
namespace LibationWinForms.GridView
{
public abstract class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
{
protected abstract Book Book { get; }
private Image _cover;
#region Model properties exposed to the view
public Image Cover
{
get => _cover;
protected set
{
_cover = value;
NotifyPropertyChanged();
}
}
public new bool InvokeRequired => base.InvokeRequired;
public abstract DateTime DateAdded { get; }
public abstract float SeriesIndex { get; }
public abstract string ProductRating { get; protected set; }
public abstract string PurchaseDate { get; protected set; }
public abstract string MyRating { get; protected set; }
public abstract string Series { get; protected set; }
public abstract string Title { get; protected set; }
public abstract string Length { get; protected set; }
public abstract string Authors { get; protected set; }
public abstract string Narrators { get; protected set; }
public abstract string Category { get; protected set; }
public abstract string Misc { get; protected set; }
public abstract string Description { get; protected set; }
public abstract string DisplayTags { get; }
public abstract LiberateButtonStatus Liberate { get; }
#endregion
#region Sorting
public GridEntry() => _memberValues = CreateMemberValueDictionary();
private Dictionary<string, Func<object>> _memberValues { get; set; }
protected abstract Dictionary<string, Func<object>> 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];
#endregion
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)
{
if (e.Definition.PictureId == Book.PictureId)
{
Cover = ImageReader.ToImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ 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>() },
};
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
internal static class GridEntryExtensions
{
#nullable enable
public static IEnumerable<SeriesEntry> Series(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<SeriesEntry>();
public static IEnumerable<LibraryBookEntry> LibraryBooks(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.OfType<LibraryBookEntry>();
public static LibraryBookEntry? FindBookByAsin(this IEnumerable<LibraryBookEntry> gridEntries, string audibleProductID)
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
public static SeriesEntry? FindBookSeriesEntry(this IEnumerable<GridEntry> gridEntries, IEnumerable<SeriesBook> matchSeries)
=> gridEntries.Series().FirstOrDefault(i => matchSeries.Any(s => s.Series.Name == i.Series));
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
=> gridEntries.Series().Where(i => i.Children.Count == 0);
public static bool IsEpisodeChild(this LibraryBook lb) => lb.Book.ContentType == ContentType.Episode;
}
}

View File

@@ -0,0 +1,234 @@
using ApplicationServices;
using Dinah.Core.DataBinding;
using LibationSearchEngine;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms.GridView
{
/*
* Allows filtering and sorting of the underlying BindingList<GridEntry>
* by implementing IBindingListView and using SearchEngineCommands
*
* When filtering is applied, the filtered-out items are removed
* from the base list and added to the private FilterRemoved list.
* When filtering is removed, items in the FilterRemoved list are
* added back to the base list.
*
* 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
{
public GridEntryBindingList() : base(new List<GridEntry>()) { }
public GridEntryBindingList(IEnumerable<GridEntry> enumeration) : base(new List<GridEntry>(enumeration)) { }
/// <returns>All items in the list, including those filtered out.</returns>
public List<GridEntry> 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 override bool SupportsSortingCore => true;
protected override bool SupportsSearchingCore => true;
protected override bool IsSortedCore => isSorted;
protected override PropertyDescriptor SortPropertyCore => propertyDescriptor;
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 string FilterString;
private SearchResultSet SearchResults;
private bool isSorted;
private ListSortDirection listSortDirection;
private PropertyDescriptor propertyDescriptor;
#region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false;
//This ApplySort overload is only called if SupportsAdvancedSorting is true.
//Otherwise BindingList.ApplySort() is used
public void ApplySort(ListSortDescriptionCollection sorts) => throw new NotImplementedException();
public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException();
#endregion
public new void Remove(GridEntry entry)
{
FilterRemoved.Remove(entry);
base.Remove(entry);
}
private void ApplyFilter(string filterString)
{
if (filterString != FilterString)
RemoveFilter();
FilterString = filterString;
SearchResults = SearchEngineCommands.Search(filterString);
var booksFilteredIn = Items.LibraryBooks().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
//Find all series containing children that match the search criteria
var seriesFilteredIn = Items.Series().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList();
foreach (var item in filteredOut)
{
FilterRemoved.Add(item);
base.Remove(item);
}
}
public void CollapseAll()
{
foreach (var series in Items.Series().ToList())
CollapseItem(series);
}
public void ExpandAll()
{
foreach (var series in Items.Series().ToList())
ExpandItem(series);
}
public void CollapseItem(SeriesEntry sEntry)
{
foreach (var episode in Items.LibraryBooks().Where(b => b.Parent == sEntry).ToList())
{
FilterRemoved.Add(episode);
base.Remove(episode);
}
sEntry.Liberate.Expanded = false;
}
public void ExpandItem(SeriesEntry sEntry)
{
var sindex = Items.IndexOf(sEntry);
foreach (var episode in FilterRemoved.LibraryBooks().Where(b => b.Parent == sEntry).ToList())
{
if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId))
{
FilterRemoved.Remove(episode);
InsertItem(++sindex, episode);
}
}
sEntry.Liberate.Expanded = true;
}
public void RemoveFilter()
{
if (FilterString is null) return;
int visibleCount = Items.Count;
foreach (var item in FilterRemoved.ToList())
{
if (item is SeriesEntry || (item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded)))
{
FilterRemoved.Remove(item);
InsertItem(visibleCount++, item);
}
}
if (IsSortedCore)
Sort();
else
//No user sort is applied, so do default sorting by DateAdded, descending
{
Comparer.PropertyName = nameof(GridEntry.DateAdded);
Comparer.Direction = ListSortDirection.Descending;
Sort();
}
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
FilterString = null;
SearchResults = null;
}
protected override void ApplySortCore(PropertyDescriptor property, ListSortDirection direction)
{
Comparer.PropertyName = property.Name;
Comparer.Direction = direction;
Sort();
propertyDescriptor = property;
listSortDirection = direction;
isSorted = true;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}
protected void Sort()
{
var itemsList = (List<GridEntry>)Items;
var children = itemsList.LibraryBooks().Where(i => i.Parent is not null).ToList();
var sortedItems = itemsList.Except(children).OrderBy(ge => ge, Comparer).ToList();
itemsList.Clear();
//Only add parentless items at this stage. After these items are added in the
//correct sorting order, go back and add the children beneath their parents.
itemsList.AddRange(sortedItems);
foreach (var parent in children.Select(c => c.Parent).Distinct())
{
var pIndex = itemsList.IndexOf(parent);
//children should always be sorted by series index.
foreach (var c in children.Where(c => c.Parent == parent).OrderBy(c => c.SeriesIndex))
itemsList.Insert(++pIndex, c);
}
}
protected override void OnListChanged(ListChangedEventArgs e)
{
if (e.ListChangedType == ListChangedType.ItemChanged)
{
if (FilterString is not null && !SuspendFilteringOnUpdate && Items[e.NewIndex] is LibraryBookEntry lbItem)
{
SearchResults = SearchEngineCommands.Search(FilterString);
if (!SearchResults.Docs.Any(d => d.ProductId == lbItem.AudibleProductId))
{
FilterRemoved.Add(lbItem);
base.Remove(lbItem);
return;
}
}
if (isSorted && e.PropertyDescriptor == SortPropertyCore)
{
var item = Items[e.NewIndex];
Sort();
var newIndex = Items.IndexOf(item);
base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemMoved, newIndex, e.NewIndex));
return;
}
}
base.OnListChanged(e);
}
protected override void RemoveSortCore()
{
isSorted = false;
propertyDescriptor = base.SortPropertyCore;
listSortDirection = base.SortDirectionCore;
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
}
}
}

View File

@@ -1,4 +1,4 @@
namespace LibationWinForms
namespace LibationWinForms.GridView
{
partial class ImageDisplay
{

View File

@@ -1,11 +1,9 @@
using FileLiberator;
using LibationFileManager;
using System;
using System;
using System.Drawing;
using System.IO;
using System.Windows.Forms;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
public partial class ImageDisplay : Form
{

View File

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

View File

@@ -1,10 +1,10 @@
using System;
using DataLayer;
using Dinah.Core.Windows.Forms;
using System;
using System.Drawing;
using System.Windows.Forms;
using System.Linq;
using DataLayer;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
public class LiberateDataGridViewImageButtonColumn : DataGridViewButtonColumn
{
@@ -16,19 +16,32 @@ namespace LibationWinForms
internal class LiberateDataGridViewImageButtonCell : DataGridViewImageButtonCell
{
private static readonly Color SERIES_BG_COLOR = Color.FromArgb(230, 255, 230);
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)
{
base.Paint(graphics, clipBounds, cellBounds, rowIndex, elementState, null, null, null, cellStyle, advancedBorderStyle, paintParts);
if (value is (LiberatedStatus, LiberatedStatus) or (LiberatedStatus, null))
if (value is LiberateButtonStatus status)
{
var (bookState, pdfState) = ((LiberatedStatus bookState, LiberatedStatus? pdfState))value;
if (rowIndex >= 0 && DataGridView.GetBoundItem<GridEntry>(rowIndex) is LibraryBookEntry lbEntry && lbEntry.Parent is not null)
{
DataGridView.Rows[rowIndex].DefaultCellStyle.BackColor = SERIES_BG_COLOR;
}
(string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(bookState, pdfState);
if (status.IsSeries)
{
DrawButtonImage(graphics, status.Expanded ? Properties.Resources.minus: Properties.Resources.plus, cellBounds);
DrawButtonImage(graphics, buttonImage, cellBounds);
ToolTipText = status.Expanded ? "Click to Collpase" : "Click to Expand";
}
else
{
(string mouseoverText, Bitmap buttonImage) = GetLiberateDisplay(status.BookStatus, status.PdfStatus);
ToolTipText = mouseoverText;
DrawButtonImage(graphics, buttonImage, cellBounds);
ToolTipText = mouseoverText;
}
}
}

View File

@@ -1,23 +1,17 @@
using System;
using System.Collections;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
using System.Linq;
using ApplicationServices;
using DataLayer;
using Dinah.Core.DataBinding;
using Dinah.Core;
using Dinah.Core.Drawing;
using LibationFileManager;
using System.Threading.Tasks;
namespace LibationWinForms
namespace LibationWinForms.GridView
{
/// <summary>
/// The View Model for a LibraryBook
/// </summary>
internal class GridEntry : AsyncNotifyPropertyChanged, IMemberComparable
public class LibraryBookEntry : GridEntry
{
#region implementation properties NOT exposed to the view
// hide from public fields from Data Source GUI with [Browsable(false)]
@@ -30,37 +24,31 @@ namespace LibationWinForms
public string LongDescription { get; private set; }
#endregion
protected override Book Book => LibraryBook.Book;
#region Model properties exposed to the view
private Image _cover;
private DateTime lastStatusUpdate = default;
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public Image Cover
{
get => _cover;
private set
{
_cover = value;
NotifyPropertyChanged();
}
}
public string ProductRating { get; private set; }
public string PurchaseDate { get; private set; }
public string MyRating { get; private set; }
public string Series { get; private set; }
public string Title { get; private set; }
public string Length { get; private set; }
public string Authors { get; private set; }
public string Narrators { get; private set; }
public string Category { get; private set; }
public string Misc { get; private set; }
public string Description { get; private set; }
public string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
public override DateTime DateAdded => LibraryBook.DateAdded;
public override float SeriesIndex => Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
public override string ProductRating { get; protected set; }
public override string PurchaseDate { get; protected set; }
public override string MyRating { get; protected set; }
public override string Series { get; protected set; }
public override string Title { get; protected set; }
public override string Length { get; protected set; }
public override string Authors { get; protected set; }
public override string Narrators { get; protected set; }
public override string Category { get; protected set; }
public override string Misc { get; protected set; }
public override string Description { get; protected set; }
public override string DisplayTags => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
// these 2 values being in 1 field is the trick behind getting the liberated+pdf 'stoplight' icon to draw. See: LiberateDataGridViewImageButtonCell.Paint
public (LiberatedStatus BookStatus, LiberatedStatus? PdfStatus) Liberate
public override LiberateButtonStatus Liberate
{
get
{
@@ -71,21 +59,24 @@ namespace LibationWinForms
_pdfStatus = LibraryCommands.Pdf_Status(LibraryBook.Book);
lastStatusUpdate = DateTime.Now;
}
return (_bookStatus, _pdfStatus);
return new LiberateButtonStatus { BookStatus = _bookStatus, PdfStatus = _pdfStatus, IsSeries = false };
}
}
#endregion
// alias
private Book Book => LibraryBook.Book;
public GridEntry(LibraryBook libraryBook) => setLibraryBook(libraryBook);
public LibraryBookEntry(LibraryBook libraryBook)
{
setLibraryBook(libraryBook);
LoadCover();
}
public SeriesEntry Parent { get; init; }
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();
@@ -94,18 +85,6 @@ namespace LibationWinForms
private void setLibraryBook(LibraryBook libraryBook)
{
LibraryBook = libraryBook;
_memberValues = CreateMemberValueDictionary();
// 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);
}
// Immutable properties
{
@@ -126,20 +105,12 @@ namespace LibationWinForms
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == Book.PictureId)
{
Cover = ImageReader.ToImage(e.Picture);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
#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.
/// Save to the database and notify the view that it's changed.
/// Notify the view that it's changed.
/// </summary>
private void UserDefinedItem_ItemChanged(object sender, string itemName)
{
@@ -148,6 +119,9 @@ namespace LibationWinForms
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):
@@ -169,38 +143,15 @@ namespace LibationWinForms
/// <summary>Save edits to the database</summary>
public void Commit(string newTags, LiberatedStatus bookStatus, LiberatedStatus? pdfStatus)
{
// validate
if (DisplayTags.EqualsInsensitive(newTags) &&
Liberate.BookStatus == bookStatus &&
Liberate.PdfStatus == pdfStatus)
return;
// update cache
_bookStatus = bookStatus;
_pdfStatus = pdfStatus;
// set + save
Book.UserDefinedItem.Tags = newTags;
Book.UserDefinedItem.BookStatus = bookStatus;
Book.UserDefinedItem.PdfStatus = pdfStatus;
LibraryCommands.UpdateUserDefinedItem(Book);
}
// MVVM pass-through
=> Book.UpdateBook(newTags, bookStatus: bookStatus, pdfStatus: pdfStatus);
#endregion
#region Data Sorting
// These methods are implementation of Dinah.Core.DataBinding.IMemberComparable
// Used by Dinah.Core.DataBinding.SortableBindingList<T> for all sorting
public virtual object GetMemberValue(string memberName) => _memberValues[memberName]();
public virtual IComparer GetMemberComparer(Type memberType) => _memberTypeComparers[memberType];
private Dictionary<string, Func<object>> _memberValues { get; set; }
/// <summary>
/// Create getters for all member object values by name
/// </summary>
private Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
/// <summary>Create getters for all member object values by name </summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
@@ -214,25 +165,17 @@ namespace LibationWinForms
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(DisplayTags), () => DisplayTags },
{ nameof(Liberate), () => Liberate.BookStatus }
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> _memberTypeComparers = new()
{
{ typeof(string), new ObjectComparer<string>() },
{ typeof(int), new ObjectComparer<int>() },
{ typeof(float), new ObjectComparer<float>() },
{ typeof(DateTime), new ObjectComparer<DateTime>() },
{ typeof(LiberatedStatus), new ObjectComparer<LiberatedStatus>() },
};
#endregion
#region Static library display functions
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// This information should not change during <see cref="LibraryBookEntry"/> lifetime, so call only once.
/// </summary>
private static string GetDescriptionDisplay(Book book)
{
@@ -250,7 +193,7 @@ namespace LibationWinForms
}
/// <summary>
/// This information should not change during <see cref="GridEntry"/> lifetime, so call only once.
/// This information should not change during <see cref="LibraryBookEntry"/> lifetime, so call only once.
/// Maximum of 5 text rows will fit in 80-pixel row height.
/// </summary>
private static string GetMiscDisplay(LibraryBook libraryBook)
@@ -280,10 +223,9 @@ namespace LibationWinForms
#endregion
~GridEntry()
~LibraryBookEntry()
{
UserDefinedItem.ItemChanged -= UserDefinedItem_ItemChanged;
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}
}

View File

@@ -0,0 +1,64 @@
namespace LibationWinForms.GridView
{
partial class ProductsDisplay
{
/// <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 Component 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()
{
this.productsGrid = new LibationWinForms.GridView.ProductsGrid();
this.SuspendLayout();
//
// productsGrid
//
this.productsGrid.AutoScroll = true;
this.productsGrid.Dock = System.Windows.Forms.DockStyle.Fill;
this.productsGrid.Location = new System.Drawing.Point(0, 0);
this.productsGrid.Name = "productsGrid";
this.productsGrid.Size = new System.Drawing.Size(1510, 380);
this.productsGrid.TabIndex = 0;
this.productsGrid.LiberateClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_LiberateClicked);
this.productsGrid.CoverClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_CoverClicked);
this.productsGrid.DetailsClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryClickedEventHandler(this.productsGrid_DetailsClicked);
this.productsGrid.DescriptionClicked += new LibationWinForms.GridView.ProductsGrid.LibraryBookEntryRectangleClickedEventHandler(this.productsGrid_DescriptionClicked);
this.productsGrid.VisibleCountChanged += new System.EventHandler<int>(this.productsGrid_VisibleCountChanged);
//
// ProductsDisplay
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.Controls.Add(this.productsGrid);
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.Name = "ProductsDisplay";
this.Size = new System.Drawing.Size(1510, 380);
this.ResumeLayout(false);
}
#endregion
private GridView.ProductsGrid productsGrid;
}
}

View File

@@ -0,0 +1,130 @@
using ApplicationServices;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using LibationWinForms.Dialogs;
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForms.GridView
{
public partial class ProductsDisplay : UserControl
{
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event EventHandler<LibraryBook> LiberateClicked;
public event EventHandler InitialLoaded;
private bool hasBeenDisplayed;
public ProductsDisplay()
{
InitializeComponent();
}
#region Button controls
private ImageDisplay imageDisplay;
private async void productsGrid_CoverClicked(LibraryBookEntry liveGridEntry)
{
var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition));
(_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureId, PictureSize._80x80));
var windowTitle = $"{liveGridEntry.Title} - Cover";
if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible)
{
imageDisplay = new ImageDisplay();
imageDisplay.RestoreSizeAndLocation(Configuration.Instance);
imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance);
imageDisplay.Show(this);
}
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.CoverPicture = await picDlTask;
}
private void productsGrid_DescriptionClicked(LibraryBookEntry liveGridEntry, Rectangle cellRectangle)
{
var displayWindow = new DescriptionDisplay
{
SpawnLocation = PointToScreen(cellRectangle.Location + new Size(cellRectangle.Width, 0)),
DescriptionText = liveGridEntry.LongDescription,
BorderThickness = 2,
};
void CloseWindow(object o, EventArgs e)
{
displayWindow.Close();
}
productsGrid.Scroll += CloseWindow;
displayWindow.FormClosed += (_, _) => productsGrid.Scroll -= CloseWindow;
displayWindow.Show(this);
}
private void productsGrid_DetailsClicked(LibraryBookEntry liveGridEntry)
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
}
#endregion
#region UI display functions
public void Display()
{
try
{
// don't return early if lib size == 0. this will not update correctly if all books are removed
var lib = DbContexts.GetLibrary_Flat_NoTracking();
if (!hasBeenDisplayed)
{
// bind
productsGrid.BindToGrid(lib);
hasBeenDisplayed = true;
InitialLoaded?.Invoke(this, new());
}
else
productsGrid.UpdateGrid(lib);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplay));
}
}
#endregion
#region Filter
public void Filter(string searchString)
=> productsGrid.Filter(searchString);
#endregion
internal List<LibraryBook> GetVisible() => productsGrid.GetVisible().Select(v => v.LibraryBook).ToList();
private void productsGrid_VisibleCountChanged(object sender, int count)
{
VisibleCountChanged?.Invoke(this, count);
}
private void productsGrid_LiberateClicked(LibraryBookEntry liveGridEntry)
{
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}
}
}

View File

@@ -0,0 +1,63 @@
<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>
<metadata name="$this.TrayHeight" type="System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>81</value>
</metadata>
</root>

View File

@@ -1,4 +1,4 @@
namespace LibationWinForms
namespace LibationWinForms.GridView
{
partial class ProductsGrid
{
@@ -29,10 +29,9 @@
private void InitializeComponent()
{
this.components = new System.ComponentModel.Container();
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle1 = new System.Windows.Forms.DataGridViewCellStyle();
this.gridEntryBindingSource = new LibationWinForms.SyncBindingSource(this.components);
System.Windows.Forms.DataGridViewCellStyle dataGridViewCellStyle2 = new System.Windows.Forms.DataGridViewCellStyle();
this.gridEntryDataGridView = new System.Windows.Forms.DataGridView();
this.liberateGVColumn = new LibationWinForms.LiberateDataGridViewImageButtonColumn();
this.liberateGVColumn = new LibationWinForms.GridView.LiberateDataGridViewImageButtonColumn();
this.coverGVColumn = new System.Windows.Forms.DataGridViewImageColumn();
this.titleGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.authorsGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
@@ -45,16 +44,13 @@
this.purchaseDateGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.myRatingGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.miscGVColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.tagAndDetailsGVColumn = new LibationWinForms.EditTagsDataGridViewImageButtonColumn();
this.tagAndDetailsGVColumn = new LibationWinForms.GridView.EditTagsDataGridViewImageButtonColumn();
this.contextMenuStrip1 = new System.Windows.Forms.ContextMenuStrip(this.components);
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).BeginInit();
this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).BeginInit();
this.SuspendLayout();
//
// gridEntryBindingSource
//
this.gridEntryBindingSource.DataSource = typeof(LibationWinForms.GridEntry);
//
// gridEntryDataGridView
//
this.gridEntryDataGridView.AllowUserToAddRows = false;
@@ -64,42 +60,40 @@
this.gridEntryDataGridView.AutoGenerateColumns = false;
this.gridEntryDataGridView.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.gridEntryDataGridView.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.liberateGVColumn,
this.coverGVColumn,
this.titleGVColumn,
this.authorsGVColumn,
this.narratorsGVColumn,
this.lengthGVColumn,
this.seriesGVColumn,
this.descriptionGVColumn,
this.categoryGVColumn,
this.productRatingGVColumn,
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.liberateGVColumn,
this.coverGVColumn,
this.titleGVColumn,
this.authorsGVColumn,
this.narratorsGVColumn,
this.lengthGVColumn,
this.seriesGVColumn,
this.descriptionGVColumn,
this.categoryGVColumn,
this.productRatingGVColumn,
this.purchaseDateGVColumn,
this.myRatingGVColumn,
this.miscGVColumn,
this.tagAndDetailsGVColumn});
this.gridEntryDataGridView.ContextMenuStrip = this.contextMenuStrip1;
this.gridEntryDataGridView.DataSource = this.gridEntryBindingSource;
dataGridViewCellStyle1.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
dataGridViewCellStyle1.BackColor = System.Drawing.SystemColors.Window;
dataGridViewCellStyle1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
dataGridViewCellStyle1.ForeColor = System.Drawing.SystemColors.ControlText;
dataGridViewCellStyle1.SelectionBackColor = System.Drawing.SystemColors.Highlight;
dataGridViewCellStyle1.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
dataGridViewCellStyle1.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle1;
this.gridEntryDataGridView.DataSource = this.syncBindingSource;
dataGridViewCellStyle2.Alignment = System.Windows.Forms.DataGridViewContentAlignment.MiddleLeft;
dataGridViewCellStyle2.BackColor = System.Drawing.SystemColors.Window;
dataGridViewCellStyle2.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
dataGridViewCellStyle2.ForeColor = System.Drawing.SystemColors.ControlText;
dataGridViewCellStyle2.SelectionBackColor = System.Drawing.SystemColors.Highlight;
dataGridViewCellStyle2.SelectionForeColor = System.Drawing.SystemColors.HighlightText;
dataGridViewCellStyle2.WrapMode = System.Windows.Forms.DataGridViewTriState.True;
this.gridEntryDataGridView.DefaultCellStyle = dataGridViewCellStyle2;
this.gridEntryDataGridView.Dock = System.Windows.Forms.DockStyle.Fill;
this.gridEntryDataGridView.Location = new System.Drawing.Point(0, 0);
this.gridEntryDataGridView.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.gridEntryDataGridView.Name = "gridEntryDataGridView";
this.gridEntryDataGridView.ReadOnly = true;
this.gridEntryDataGridView.RowHeadersVisible = false;
this.gridEntryDataGridView.RowTemplate.Height = 82;
this.gridEntryDataGridView.Size = new System.Drawing.Size(1510, 380);
this.gridEntryDataGridView.TabIndex = 0;
this.gridEntryDataGridView.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView_CellContentClick);
this.gridEntryDataGridView.CellToolTipTextNeeded += new System.Windows.Forms.DataGridViewCellToolTipTextNeededEventHandler(this.gridEntryDataGridView_CellToolTipTextNeeded);
this.gridEntryDataGridView.ColumnDisplayIndexChanged += new System.Windows.Forms.DataGridViewColumnEventHandler(this.gridEntryDataGridView_ColumnDisplayIndexChanged);
this.gridEntryDataGridView.ColumnWidthChanged += new System.Windows.Forms.DataGridViewColumnEventHandler(this.gridEntryDataGridView_ColumnWidthChanged);
//
// liberateGVColumn
//
@@ -218,23 +212,27 @@
this.contextMenuStrip1.Name = "contextMenuStrip1";
this.contextMenuStrip1.Size = new System.Drawing.Size(61, 4);
//
// syncBindingSource
//
this.syncBindingSource.DataSource = typeof(LibationWinForms.GridView.GridEntry);
//
// ProductsGrid
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.AutoScroll = true;
this.Controls.Add(this.gridEntryDataGridView);
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.Name = "ProductsGrid";
this.Size = new System.Drawing.Size(1510, 380);
((System.ComponentModel.ISupportInitialize)(this.gridEntryBindingSource)).EndInit();
this.Load += new System.EventHandler(this.ProductsGrid_Load);
((System.ComponentModel.ISupportInitialize)(this.gridEntryDataGridView)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).EndInit();
this.ResumeLayout(false);
}
#endregion
private LibationWinForms.SyncBindingSource gridEntryBindingSource;
#endregion
private System.Windows.Forms.DataGridView gridEntryDataGridView;
private System.Windows.Forms.ContextMenuStrip contextMenuStrip1;
private LiberateDataGridViewImageButtonColumn liberateGVColumn;
@@ -251,5 +249,6 @@
private System.Windows.Forms.DataGridViewTextBoxColumn myRatingGVColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn miscGVColumn;
private EditTagsDataGridViewImageButtonColumn tagAndDetailsGVColumn;
private SyncBindingSource syncBindingSource;
}
}

View File

@@ -0,0 +1,324 @@
using DataLayer;
using Dinah.Core.Windows.Forms;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace LibationWinForms.GridView
{
public partial class ProductsGrid : UserControl
{
public delegate void LibraryBookEntryClickedEventHandler(LibraryBookEntry liveGridEntry);
public delegate void LibraryBookEntryRectangleClickedEventHandler(LibraryBookEntry liveGridEntry, Rectangle cellRectangle);
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler LiberateClicked;
public event LibraryBookEntryClickedEventHandler CoverClicked;
public event LibraryBookEntryClickedEventHandler DetailsClicked;
public event LibraryBookEntryRectangleClickedEventHandler DescriptionClicked;
public new event EventHandler<ScrollEventArgs> Scroll;
private GridEntryBindingList bindingList;
internal IEnumerable<LibraryBookEntry> GetVisible()
=> bindingList
.LibraryBooks();
public ProductsGrid()
{
InitializeComponent();
EnableDoubleBuffering();
gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s);
}
private void EnableDoubleBuffering()
{
var propertyInfo = gridEntryDataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
propertyInfo.SetValue(gridEntryDataGridView, true, null);
}
#region Button controls
private void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e)
{
// handle grid button click: https://stackoverflow.com/a/13687844
if (e.RowIndex < 0)
return;
var entry = getGridEntry(e.RowIndex);
if (entry is LibraryBookEntry lbEntry)
{
if (e.ColumnIndex == liberateGVColumn.Index)
LiberateClicked?.Invoke(lbEntry);
else if (e.ColumnIndex == tagAndDetailsGVColumn.Index)
DetailsClicked?.Invoke(lbEntry);
else if (e.ColumnIndex == descriptionGVColumn.Index)
DescriptionClicked?.Invoke(lbEntry, gridEntryDataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false));
else if (e.ColumnIndex == coverGVColumn.Index)
CoverClicked?.Invoke(lbEntry);
}
else if (entry is SeriesEntry sEntry && e.ColumnIndex == liberateGVColumn.Index)
{
if (sEntry.Liberate.Expanded)
bindingList.CollapseItem(sEntry);
else
bindingList.ExpandItem(sEntry);
sEntry.NotifyPropertyChanged(nameof(sEntry.Liberate));
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
}
}
private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);
#endregion
#region UI display functions
internal void BindToGrid(List<LibraryBook> dbBooks)
{
var geList = dbBooks.Where(b => b.Book.ContentType is not ContentType.Episode).Select(b => new LibraryBookEntry(b)).Cast<GridEntry>().ToList();
var episodes = dbBooks.Where(b => b.IsEpisodeChild()).ToList();
var allSeries = episodes.SelectMany(lb => lb.Book.SeriesLink.Where(s => !s.Series.AudibleSeriesId.StartsWith("SERIES_"))).DistinctBy(s => s.Series).ToList();
foreach (var series in allSeries)
{
var seriesEntry = new SeriesEntry(series, episodes.Where(lb => lb.Book.SeriesLink.Any(s => s.Series == series.Series)));
geList.Add(seriesEntry);
geList.AddRange(seriesEntry.Children);
}
bindingList = new GridEntryBindingList(geList.OrderByDescending(e => e.DateAdded));
bindingList.CollapseAll();
syncBindingSource.DataSource = bindingList;
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
}
internal void UpdateGrid(List<LibraryBook> dbBooks)
{
string existingFilter = syncBindingSource.Filter;
Filter(null);
bindingList.SuspendFilteringOnUpdate = true;
//Add absent books to grid, or update current books
var allItmes = bindingList.AllItems().LibraryBooks();
foreach (var libraryBook in dbBooks)
{
var existingItem = allItmes.FindBookByAsin(libraryBook.Book.AudibleProductId);
// add new to top
if (existingItem is null)
{
if (libraryBook.IsEpisodeChild())
{
LibraryBookEntry lbe;
//Find the series that libraryBook belongs to, if it exists
var series = bindingList.AllItems().FindBookSeriesEntry(libraryBook.Book.SeriesLink);
if (series is null)
{
//Series doesn't exist yet, so create and add it
var newSeries = new SeriesEntry(libraryBook.Book.SeriesLink.First(), libraryBook);
lbe = newSeries.Children[0];
newSeries.Liberate.Expanded = true;
bindingList.Insert(0, newSeries);
series = newSeries;
}
else
{
lbe = new(libraryBook) { Parent = series };
series.Children.Add(lbe);
}
//Add episode beneath the parent
int seriesIndex = bindingList.IndexOf(series);
bindingList.Insert(seriesIndex + 1, lbe);
if (series.Liberate.Expanded)
bindingList.ExpandItem(series);
else
bindingList.CollapseItem(series);
series.NotifyPropertyChanged();
}
else if (libraryBook.Book.ContentType is not ContentType.Episode)
//Add the new product
bindingList.Insert(0, new LibraryBookEntry(libraryBook));
}
// update existing
else
{
existingItem.UpdateLibraryBook(libraryBook);
}
}
bindingList.SuspendFilteringOnUpdate = false;
//Re-filter after updating existing / adding new books to capture any changes
Filter(existingFilter);
// 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 =
bindingList
.AllItems()
.LibraryBooks()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId);
//Remove books in series from their parents' Children list
foreach (var removed in removedBooks.Where(b => b.Parent is not null))
{
removed.Parent.Children.Remove(removed);
removed.Parent.NotifyPropertyChanged();
}
//Remove series that have no children
var removedSeries =
bindingList
.AllItems()
.EmptySeries();
foreach (var removed in removedBooks.Cast<GridEntry>().Concat(removedSeries))
//no need to re-filter for removed books
bindingList.Remove(removed);
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
}
#endregion
#region Filter
public void Filter(string searchString)
{
int visibleCount = bindingList.Count;
if (string.IsNullOrEmpty(searchString))
syncBindingSource.RemoveFilter();
else
syncBindingSource.Filter = searchString;
if (visibleCount != bindingList.Count)
VisibleCountChanged?.Invoke(this, bindingList.LibraryBooks().Count());
}
#endregion
#region Column Customizations
private void ProductsGrid_Load(object sender, EventArgs e)
{
//https://stackoverflow.com/a/4498512/3335599
if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return;
gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged;
gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged;
contextMenuStrip1.Items.Add(new ToolStripLabel("Show / Hide Columns"));
contextMenuStrip1.Items.Add(new ToolStripSeparator());
//Restore Grid Display Settings
var config = Configuration.Instance;
var gridColumnsVisibilities = config.GridColumnsVisibilities;
var gridColumnsWidths = config.GridColumnsWidths;
var displayIndices = config.GridColumnsDisplayIndices;
var cmsKiller = new ContextMenuStrip();
foreach (DataGridViewColumn column in gridEntryDataGridView.Columns)
{
var itemName = column.DataPropertyName;
var visible = gridColumnsVisibilities.GetValueOrDefault(itemName, true);
var menuItem = new ToolStripMenuItem()
{
Text = column.HeaderText,
Checked = visible,
Tag = itemName
};
menuItem.Click += HideMenuItem_Click;
contextMenuStrip1.Items.Add(menuItem);
column.Width = gridColumnsWidths.GetValueOrDefault(itemName, column.Width);
column.MinimumWidth = 10;
column.HeaderCell.ContextMenuStrip = contextMenuStrip1;
column.Visible = visible;
//Setting a default ContextMenuStrip will allow the columns to handle the
//Show() event so it is not passed up to the _dataGridView.ContextMenuStrip.
//This allows the ContextMenuStrip to be shown if right-clicking in the gray
//background of _dataGridView but not shown if right-clicking inside cells.
column.ContextMenuStrip = cmsKiller;
}
//We must set DisplayIndex properties in ascending order
foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key))
{
var column = gridEntryDataGridView.Columns
.Cast<DataGridViewColumn>()
.Single(c => c.DataPropertyName == itemName);
column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index);
}
}
private void HideMenuItem_Click(object sender, EventArgs e)
{
var menuItem = sender as ToolStripMenuItem;
var propertyName = menuItem.Tag as string;
var column = gridEntryDataGridView.Columns
.Cast<DataGridViewColumn>()
.FirstOrDefault(c => c.DataPropertyName == propertyName);
if (column != null)
{
var visible = menuItem.Checked;
menuItem.Checked = !visible;
column.Visible = !visible;
var config = Configuration.Instance;
var dictionary = config.GridColumnsVisibilities;
dictionary[propertyName] = column.Visible;
config.GridColumnsVisibilities = dictionary;
}
}
private void gridEntryDataGridView_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e)
{
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;
dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex;
config.GridColumnsDisplayIndices = dictionary;
}
private void gridEntryDataGridView_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e)
{
if (e.ColumnIndex == descriptionGVColumn.Index)
e.ToolTipText = "Click to see full description";
else if (e.ColumnIndex == coverGVColumn.Index)
e.ToolTipText = "Click to see full size";
}
private void gridEntryDataGridView_ColumnWidthChanged(object sender, DataGridViewColumnEventArgs e)
{
var config = Configuration.Instance;
var dictionary = config.GridColumnsWidths;
dictionary[e.Column.DataPropertyName] = e.Column.Width;
}
#endregion
}
}

View File

@@ -57,13 +57,16 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="gridEntryBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<metadata name="contextMenuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>171, 17</value>
</metadata>
<metadata name="syncBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="contextMenuStrip1.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>197, 17</value>
<metadata name="bindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>326, 17</value>
</metadata>
<metadata name="$this.TrayHeight" type="System.Int32, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>81</value>
<metadata name="bindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>326, 17</value>
</metadata>
</root>

View File

@@ -0,0 +1,109 @@
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
namespace LibationWinForms.GridView
{
public class SeriesEntry : GridEntry
{
public List<LibraryBookEntry> Children { get; init; }
public override DateTime DateAdded => Children.Max(c => c.DateAdded);
public override float SeriesIndex { get; }
public override string ProductRating
{
get
{
var productAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.Rating.StoryRating));
return productAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace("");
}
protected set => throw new NotImplementedException();
}
public override string PurchaseDate { get; protected set; }
public override string MyRating
{
get
{
var myAverageRating = new Rating(Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.OverallRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.PerformanceRating), Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.StoryRating));
return myAverageRating.ToStarString()?.DefaultIfNullOrWhiteSpace("");
}
protected set => throw new NotImplementedException();
}
public override string Series { get; protected set; }
public override string Title { get; protected set; }
public override string Length
{
get
{
int bookLenMins = Children.Sum(c => c.LibraryBook.Book.LengthInMinutes);
return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
protected set => throw new NotImplementedException();
}
public override string Authors { get; protected set; }
public override string Narrators { get; protected set; }
public override string Category { get; protected set; }
public override string Misc { get; protected set; } = string.Empty;
public override string Description { get; protected set; } = string.Empty;
public override string DisplayTags { get; } = string.Empty;
public override LiberateButtonStatus Liberate { get; }
protected override Book Book => SeriesBook.Book;
private SeriesBook SeriesBook { get; set; }
private SeriesEntry(SeriesBook seriesBook)
{
Liberate = new LiberateButtonStatus { IsSeries = true };
SeriesIndex = seriesBook.Index;
}
public SeriesEntry(SeriesBook seriesBook, IEnumerable<LibraryBook> children) : this(seriesBook)
{
Children = children.Select(c => new LibraryBookEntry(c) { Parent = this }).OrderBy(c => c.SeriesIndex).ToList();
SetSeriesBook(seriesBook);
}
public SeriesEntry(SeriesBook seriesBook, LibraryBook child) : this(seriesBook)
{
Children = new() { new LibraryBookEntry(child) { Parent = this } };
SetSeriesBook(seriesBook);
}
private void SetSeriesBook(SeriesBook seriesBook)
{
SeriesBook = seriesBook;
LoadCover();
// Immutable properties
{
Title = SeriesBook.Series.Name;
Series = SeriesBook.Series.Name;
PurchaseDate = Children.Min(c => c.LibraryBook.DateAdded).ToString("d");
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
}
}
/// <summary>Create getters for all member object values by name</summary>
protected override Dictionary<string, Func<object>> CreateMemberValueDictionary() => new()
{
{ nameof(Title), () => Book.SeriesSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(Length), () => Children.Sum(c => c.LibraryBook.Book.LengthInMinutes) },
{ nameof(MyRating), () => Children.Average(c => c.LibraryBook.Book.UserDefinedItem.Rating.FirstScore()) },
{ nameof(PurchaseDate), () => Children.Min(c => c.LibraryBook.DateAdded) },
{ nameof(ProductRating), () => Children.Average(c => c.LibraryBook.Book.Rating.FirstScore()) },
{ nameof(Authors), () => string.Empty },
{ nameof(Narrators), () => string.Empty },
{ nameof(Description), () => string.Empty },
{ nameof(Category), () => string.Empty },
{ nameof(Misc), () => string.Empty },
{ nameof(DisplayTags), () => string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
}
}

View File

@@ -0,0 +1,29 @@
using System.ComponentModel;
using System.Threading;
using System.Windows.Forms;
// https://stackoverflow.com/a/32886415
namespace LibationWinForms.GridView
{
public class SyncBindingSource : BindingSource
{
private SynchronizationContext syncContext { get; }
public SyncBindingSource() : base()
=> syncContext = SynchronizationContext.Current;
public SyncBindingSource(IContainer container) : base(container)
=> syncContext = SynchronizationContext.Current;
public SyncBindingSource(object dataSource, string dataMember) : base(dataSource, dataMember)
=> syncContext = SynchronizationContext.Current;
public override bool SupportsFiltering => true;
protected override void OnListChanged(ListChangedEventArgs e)
{
if (syncContext is not null)
syncContext.Send(_ => base.OnListChanged(e), null);
else
base.OnListChanged(e);
}
}
}

View File

@@ -19,7 +19,7 @@ namespace LibationWinForms.ProcessQueue
LogErrorString += (_, text) => Serilog.Log.Logger.Error(text);
LogError += (_, tuple) => Serilog.Log.Logger.Error(tuple.Item1, tuple.Item2 ?? "Automated backup: error");
}
private static ILogForm LogForm;
public static LogMe RegisterForm<T>(T form) where T : ILogForm
{
var logMe = new LogMe();
@@ -27,19 +27,31 @@ namespace LibationWinForms.ProcessQueue
if (form is null)
return logMe;
logMe.LogInfo += (_, text) => form?.WriteLine(text);
LogForm = form;
logMe.LogErrorString += (_, text) => form?.WriteLine(text);
logMe.LogError += (_, tuple) =>
{
form?.WriteLine(tuple.Item2 ?? "Automated backup: error");
form?.WriteLine("ERROR: " + tuple.Item1.Message);
};
logMe.LogInfo += LogMe_LogInfo;
logMe.LogErrorString += LogMe_LogErrorString;
logMe.LogError += LogMe_LogError;
return logMe;
}
private static async void LogMe_LogError(object sender, (Exception, string) tuple)
{
await Task.Run(() => LogForm?.WriteLine(tuple.Item2 ?? "Automated backup: error"));
await Task.Run(() => LogForm?.WriteLine("ERROR: " + tuple.Item1.Message));
}
private static async void LogMe_LogErrorString(object sender, string text)
{
await Task.Run(() => LogForm?.WriteLine(text));
}
private static async void LogMe_LogInfo(object sender, string text)
{
await Task.Run(() => LogForm?.WriteLine(text));
}
public void Info(string text) => LogInfo?.Invoke(this, text);
public void Error(string text) => LogErrorString?.Invoke(this, text);
public void Error(Exception ex, string text = null) => LogError?.Invoke(this, (ex, text));

View File

@@ -1,8 +1,4 @@
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using System;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Drawing;
@@ -10,6 +6,11 @@ using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
namespace LibationWinForms.ProcessQueue
{
@@ -59,7 +60,6 @@ namespace LibationWinForms.ProcessQueue
private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
private Processable NextProcessable() => _currentProcessable = null;
private Processable _currentProcessable;
private Func<byte[]> GetCoverArtDelegate;
private readonly Queue<Func<Processable>> Processes = new();
private readonly LogMe Logger;
@@ -131,7 +131,6 @@ namespace LibationWinForms.ProcessQueue
{
ProcessBookResult.Success => ProcessBookStatus.Completed,
ProcessBookResult.Cancelled => ProcessBookStatus.Cancelled,
ProcessBookResult.FailedRetry => ProcessBookStatus.Queued,
_ => ProcessBookStatus.Failed,
};
}
@@ -232,11 +231,14 @@ namespace LibationWinForms.ProcessQueue
BookText = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}";
}
public void AudioDecodable_RequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate)
private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e)
{
byte[] coverData = GetCoverArtDelegate();
setCoverArtDelegate(coverData);
byte[] coverData = PictureStorage
.GetPictureSynchronously(
new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500));
AudioDecodable_CoverImageDiscovered(this, coverData);
return coverData;
}
private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt)
@@ -273,11 +275,6 @@ namespace LibationWinForms.ProcessQueue
Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}");
GetCoverArtDelegate = () => PictureStorage.GetPictureSynchronously(
new PictureDefinition(
libraryBook.Book.PictureId,
PictureSize._500x500));
title = libraryBook.Book.Title;
authorNames = libraryBook.Book.AuthorNames();
narratorNames = libraryBook.Book.NarratorNames();
@@ -286,7 +283,6 @@ namespace LibationWinForms.ProcessQueue
private async void Processable_Completed(object sender, LibraryBook libraryBook)
{
Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}");
UnlinkProcessable((Processable)sender);
@@ -354,8 +350,7 @@ $@" Title: {libraryBook.Book.Title}
if (dialogResult == SkipResult)
{
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Error;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Error);
Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}");

View File

@@ -72,8 +72,8 @@ namespace LibationWinForms.ProcessQueue
Status = ProcessBookStatus.Cancelled;
break;
case ProcessBookResult.FailedRetry:
statusText = "Queued";
Status = ProcessBookStatus.Queued;
statusText = "Error, will retry later";
Status = ProcessBookStatus.Failed;
break;
case ProcessBookResult.FailedSkip:
statusText = "Error, Skippping";

View File

@@ -51,9 +51,9 @@
this.logEntryColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.panel4 = new System.Windows.Forms.Panel();
this.panel2 = new System.Windows.Forms.Panel();
this.logCopyBtn = new System.Windows.Forms.Button();
this.clearLogBtn = new System.Windows.Forms.Button();
this.counterTimer = new System.Windows.Forms.Timer(this.components);
this.logCopyBtn = new System.Windows.Forms.Button();
this.statusStrip1.SuspendLayout();
this.tabControl1.SuspendLayout();
this.tabPage1.SuspendLayout();
@@ -264,10 +264,21 @@
this.panel2.Size = new System.Drawing.Size(390, 25);
this.panel2.TabIndex = 1;
//
// logCopyBtn
//
this.logCopyBtn.Dock = System.Windows.Forms.DockStyle.Left;
this.logCopyBtn.Location = new System.Drawing.Point(0, 0);
this.logCopyBtn.Name = "logCopyBtn";
this.logCopyBtn.Size = new System.Drawing.Size(57, 23);
this.logCopyBtn.TabIndex = 1;
this.logCopyBtn.Text = "Copy";
this.logCopyBtn.UseVisualStyleBackColor = true;
this.logCopyBtn.Click += new System.EventHandler(this.LogCopyBtn_Click);
//
// clearLogBtn
//
this.clearLogBtn.Dock = System.Windows.Forms.DockStyle.Left;
this.clearLogBtn.Location = new System.Drawing.Point(0, 0);
this.clearLogBtn.Dock = System.Windows.Forms.DockStyle.Right;
this.clearLogBtn.Location = new System.Drawing.Point(328, 0);
this.clearLogBtn.Name = "clearLogBtn";
this.clearLogBtn.Size = new System.Drawing.Size(60, 23);
this.clearLogBtn.TabIndex = 0;
@@ -280,17 +291,6 @@
this.counterTimer.Interval = 950;
this.counterTimer.Tick += new System.EventHandler(this.CounterTimer_Tick);
//
// logCopyBtn
//
this.logCopyBtn.Dock = System.Windows.Forms.DockStyle.Right;
this.logCopyBtn.Location = new System.Drawing.Point(331, 0);
this.logCopyBtn.Name = "logCopyBtn";
this.logCopyBtn.Size = new System.Drawing.Size(57, 23);
this.logCopyBtn.TabIndex = 1;
this.logCopyBtn.Text = "Copy";
this.logCopyBtn.UseVisualStyleBackColor = true;
this.logCopyBtn.Click += new System.EventHandler(this.LogCopyBtn_Click);
//
// ProcessQueueControl
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);

View File

@@ -4,6 +4,7 @@ using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
namespace LibationWinForms.ProcessQueue
{
@@ -46,24 +47,31 @@ namespace LibationWinForms.ProcessQueue
public ProcessQueueControl()
{
InitializeComponent();
Logger = LogMe.RegisterForm(this);
runningTimeLbl.Text = string.Empty;
popoutBtn.DisplayStyle = ToolStripItemDisplayStyle.Text;
popoutBtn.Name = "popoutBtn";
popoutBtn.Text = "Pop Out";
popoutBtn.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
popoutBtn.Alignment = ToolStripItemAlignment.Right;
popoutBtn.Anchor = AnchorStyles.Bottom | AnchorStyles.Right;
statusStrip1.Items.Add(popoutBtn);
Logger = LogMe.RegisterForm(this);
virtualFlowControl2.RequestData += VirtualFlowControl1_RequestData;
virtualFlowControl2.ButtonClicked += VirtualFlowControl2_ButtonClicked;
Queue.QueuededCountChanged += Queue_QueuededCountChanged;
Queue.CompletedCountChanged += Queue_CompletedCountChanged;
Load += ProcessQueueControl_Load;
}
private void ProcessQueueControl_Load(object sender, EventArgs e)
{
if (DesignMode) return;
runningTimeLbl.Text = string.Empty;
QueuedCount = 0;
ErrorCount = 0;
CompletedCount = 0;
@@ -144,12 +152,12 @@ namespace LibationWinForms.ProcessQueue
var result = await nextBook.ProcessOneAsync();
if (result == ProcessBookResult.FailedRetry)
Queue.Enqueue(nextBook);
else if (result == ProcessBookResult.ValidationFail)
if (result == ProcessBookResult.ValidationFail)
Queue.ClearCurrent();
else if (result == ProcessBookResult.FailedAbort)
return;
Queue.ClearQueue();
else if (result == ProcessBookResult.FailedSkip)
nextBook.LibraryBook.Book.UpdateBookStatus(DataLayer.LiberatedStatus.Error);
}
Queue_CompletedCountChanged(this, 0);
counterTimer.Stop();
@@ -162,14 +170,14 @@ namespace LibationWinForms.ProcessQueue
if (IsDisposed) return;
var timeStamp = DateTime.Now;
logDGV.Rows.Add(timeStamp, text.Trim());
Invoke(() => logDGV.Rows.Add(timeStamp, text.Trim()));
}
#region Control event handlers
private void Queue_CompletedCountChanged(object sender, int e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.ValidationFail);
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
ErrorCount = errCount;

View File

@@ -29,6 +29,7 @@
private void InitializeComponent()
{
this.panel1 = new System.Windows.Forms.Panel();
this.vScrollBar1 = new System.Windows.Forms.VScrollBar();
this.SuspendLayout();
//
// panel1
@@ -39,12 +40,21 @@
this.panel1.BackColor = System.Drawing.SystemColors.ControlDark;
this.panel1.Location = new System.Drawing.Point(0, 0);
this.panel1.Name = "panel1";
this.panel1.Size = new System.Drawing.Size(377, 505);
this.panel1.Size = new System.Drawing.Size(357, 505);
this.panel1.TabIndex = 0;
//
// vScrollBar1
//
this.vScrollBar1.Dock = System.Windows.Forms.DockStyle.Right;
this.vScrollBar1.Location = new System.Drawing.Point(360, 0);
this.vScrollBar1.Name = "vScrollBar1";
this.vScrollBar1.Size = new System.Drawing.Size(17, 505);
this.vScrollBar1.TabIndex = 0;
//
// VirtualFlowControl
//
this.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.Controls.Add(this.vScrollBar1);
this.Controls.Add(this.panel1);
this.Name = "VirtualFlowControl";
this.Size = new System.Drawing.Size(377, 505);
@@ -55,5 +65,6 @@
#endregion
private System.Windows.Forms.Panel panel1;
private System.Windows.Forms.VScrollBar vScrollBar1;
}
}

View File

@@ -75,7 +75,6 @@ namespace LibationWinForms.ProcessQueue
/// </summary>
private readonly int TopMargin;
private readonly VScrollBar vScrollBar1;
private readonly List<ProcessBookControl> BookControls = new();
#endregion
@@ -101,16 +100,6 @@ namespace LibationWinForms.ProcessQueue
{
InitializeComponent();
vScrollBar1 = new VScrollBar
{
Minimum = 0,
Value = 0,
Dock = DockStyle.Right
};
Controls.Add(vScrollBar1);
vScrollBar1.Scroll += (_, s) => SetScrollPosition(s.NewValue);
panel1.Width -= vScrollBar1.Width + panel1.Margin.Right;
panel1.Resize += (_, _) =>
{
AdjustScrollBar();
@@ -125,9 +114,6 @@ namespace LibationWinForms.ProcessQueue
BookControls.Add(control);
panel1.Controls.Add(control);
if (DesignMode)
return;
for (int i = 1; i < NUM_ACTUAL_CONTROLS; i++)
{
control = InitControl(VirtualControlHeight * i);
@@ -135,6 +121,7 @@ namespace LibationWinForms.ProcessQueue
panel1.Controls.Add(control);
}
vScrollBar1.Scroll += (_, s) => SetScrollPosition(s.NewValue);
vScrollBar1.SmallChange = SmallScrollChange;
panel1.Height += NUM_BLANK_SPACES_AT_BOTTOM * VirtualControlHeight;
}
@@ -227,6 +214,8 @@ namespace LibationWinForms.ProcessQueue
/// </summary>
private void SetScrollPosition(int value)
{
if (!vScrollBar1.Enabled) return;
int newPos = (int)Math.Round((double)value / SmallScrollChange) * SmallScrollChange;
if (vScrollBar1.Value != newPos)
{

View File

@@ -6,5 +6,5 @@
cause the file to be unrecognizable by the program.
-->
<GenericObjectDataSource DisplayName="GridEntry" Version="1.0" xmlns="urn:schemas-microsoft-com:xml-msdatasource">
<TypeInfo>LibationWinForms.GridEntry, LibationWinForms, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
<TypeInfo>LibationWinForms.GridView.GridEntry, LibationWinForms, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null</TypeInfo>
</GenericObjectDataSource>

View File

@@ -229,5 +229,25 @@ namespace LibationWinForms.Properties {
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap minus {
get {
object obj = ResourceManager.GetObject("minus", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
/// <summary>
/// Looks up a localized resource of type System.Drawing.Bitmap.
/// </summary>
internal static System.Drawing.Bitmap plus {
get {
object obj = ResourceManager.GetObject("plus", resourceCulture);
return ((System.Drawing.Bitmap)(obj));
}
}
}
}

View File

@@ -169,4 +169,10 @@
<data name="liberate_yellow_pdf_yes" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\liberate_yellow_pdf_yes.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="minus" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\minus.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
<data name="plus" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\plus.png;System.Drawing.Bitmap, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a</value>
</data>
</root>

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 B

View File

@@ -1,30 +0,0 @@
using System;
using System.ComponentModel;
using System.Threading;
using System.Windows.Forms;
// https://stackoverflow.com/a/32886415
namespace LibationWinForms
{
public class SyncBindingSource : BindingSource
{
private SynchronizationContext syncContext { get; }
public SyncBindingSource() : base()
=> syncContext = SynchronizationContext.Current;
public SyncBindingSource(IContainer container) : base(container)
=> syncContext = SynchronizationContext.Current;
public SyncBindingSource(object dataSource, string dataMember) : base(dataSource, dataMember)
=> syncContext = SynchronizationContext.Current;
public override bool SupportsFiltering => true;
protected override void OnListChanged(ListChangedEventArgs e)
{
if (syncContext is not null)
syncContext.Send(_ => base.OnListChanged(e), null);
else
base.OnListChanged(e);
}
}
}

View File

@@ -1,94 +0,0 @@
using ApplicationServices;
using Dinah.Core.DataBinding;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace LibationWinForms
{
/*
* Allows filtering of the underlying SortableBindingList<GridEntry>
* by implementing IBindingListView and using SearchEngineCommands
*
* When filtering is applied, the filtered-out items are removed
* from the base list and added to the private FilterRemoved list.
* When filtering is removed, items in the FilterRemoved list are
* added back to the base list.
*
* Remove is overridden to ensure that removed items are removed from
* the base list (visible items) as well as the FilterRemoved list.
*/
internal class FilterableSortableBindingList : SortableBindingList<GridEntry>, IBindingListView
{
/// <summary>
/// Items that were removed from the base list due to filtering
/// </summary>
private readonly List<GridEntry> FilterRemoved = new();
private string FilterString;
public FilterableSortableBindingList(IEnumerable<GridEntry> enumeration) : base(enumeration) { }
public bool SupportsFiltering => true;
public string Filter { get => FilterString; set => ApplyFilter(value); }
#region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false;
//This ApplySort overload is only called if SupportsAdvancedSorting is true.
//Otherwise BindingList.ApplySort() is used
public void ApplySort(ListSortDescriptionCollection sorts) => throw new NotImplementedException();
public ListSortDescriptionCollection SortDescriptions => throw new NotImplementedException();
#endregion
public new void Remove(GridEntry entry)
{
FilterRemoved.Remove(entry);
base.Remove(entry);
}
/// <returns>All items in the list, including those filtered out.</returns>
public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
private void ApplyFilter(string filterString)
{
if (filterString != FilterString)
RemoveFilter();
FilterString = filterString;
var searchResults = SearchEngineCommands.Search(filterString);
var filteredOut = Items.ExceptBy(searchResults.Docs.Select(d => d.ProductId), ge => ge.AudibleProductId);
for (int i = Items.Count - 1; i >= 0; i--)
{
if (filteredOut.Contains(Items[i]))
{
FilterRemoved.Add(Items[i]);
Items.RemoveAt(i);
base.OnListChanged(new ListChangedEventArgs(ListChangedType.ItemDeleted, i));
}
}
}
public void RemoveFilter()
{
if (FilterString is null) return;
int visibleCount = Items.Count;
for (int i = 0; i < FilterRemoved.Count; i++)
base.InsertItem(i + visibleCount, FilterRemoved[i]);
OnListChanged(new ListChangedEventArgs(ListChangedType.Reset, -1));
FilterRemoved.Clear();
if (IsSortedCore)
Sort();
else
//No user-defined sort is applied, so do default sorting by date added, descending
((List<GridEntry>)Items).Sort((i1, i2) => i2.LibraryBook.DateAdded.CompareTo(i1.LibraryBook.DateAdded));
FilterString = null;
}
}
}

View File

@@ -1,351 +0,0 @@
using System;
using System.Collections.Generic;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using DataLayer;
using Dinah.Core.Windows.Forms;
using FileLiberator;
using LibationFileManager;
using LibationWinForms.Dialogs;
namespace LibationWinForms
{
#region // legacy instructions to update data_grid_view
// INSTRUCTIONS TO UPDATE DATA_GRID_VIEW
// - delete current DataGridView
// - view > other windows > data sources
// - refresh
// OR
// - Add New Data Source
// Object. Next
// LibationWinForms
// AudibleDTO
// GridEntry
// - go to Design view
// - click on Data Sources > ProductItem. dropdown: DataGridView
// - drag/drop ProductItem on design surface
//
// as of august 2021 this does not work in vs2019 with .net5 projects
// VS has improved since then with .net6+ but I haven't checked again
#endregion
public partial class ProductsGrid : UserControl
{
public event EventHandler<LibraryBook> LiberateClicked;
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
// alias
private DataGridView _dataGridView => gridEntryDataGridView;
public ProductsGrid()
{
InitializeComponent();
if (this.DesignMode)
return;
EnableDoubleBuffering();
_dataGridView.CellContentClick += DataGridView_CellContentClick;
this.Load += ProductsGrid_Load;
}
private void EnableDoubleBuffering()
{
var propertyInfo = _dataGridView.GetType().GetProperty("DoubleBuffered", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic);
propertyInfo.SetValue(_dataGridView, true, null);
}
#region Button controls
private async void DataGridView_CellContentClick(object sender, DataGridViewCellEventArgs e)
{
// handle grid button click: https://stackoverflow.com/a/13687844
if (e.RowIndex < 0)
return;
if (e.ColumnIndex == liberateGVColumn.Index)
Liberate_Click(getGridEntry(e.RowIndex));
else if (e.ColumnIndex == tagAndDetailsGVColumn.Index)
Details_Click(getGridEntry(e.RowIndex));
else if (e.ColumnIndex == descriptionGVColumn.Index)
Description_Click(getGridEntry(e.RowIndex), _dataGridView.GetCellDisplayRectangle(e.ColumnIndex, e.RowIndex, false));
else if (e.ColumnIndex == coverGVColumn.Index)
await Cover_Click(getGridEntry(e.RowIndex));
}
private ImageDisplay imageDisplay;
private async Task Cover_Click(GridEntry liveGridEntry)
{
var picDefinition = new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureLarge ?? liveGridEntry.LibraryBook.Book.PictureId, PictureSize.Native);
var picDlTask = Task.Run(() => PictureStorage.GetPictureSynchronously(picDefinition));
(_, byte[] initialImageBts) = PictureStorage.GetPicture(new PictureDefinition(liveGridEntry.LibraryBook.Book.PictureId, PictureSize._80x80));
var windowTitle = $"{liveGridEntry.Title} - Cover";
if (imageDisplay is null || imageDisplay.IsDisposed || !imageDisplay.Visible)
{
imageDisplay = new ImageDisplay();
imageDisplay.RestoreSizeAndLocation(Configuration.Instance);
imageDisplay.FormClosed += (_, _) => imageDisplay.SaveSizeAndLocation(Configuration.Instance);
imageDisplay.Show(this);
}
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.CoverPicture = await picDlTask;
}
private void Description_Click(GridEntry liveGridEntry, Rectangle cellDisplay)
{
var displayWindow = new DescriptionDisplay
{
SpawnLocation = PointToScreen(cellDisplay.Location + new Size(cellDisplay.Width, 0)),
DescriptionText = liveGridEntry.LongDescription,
BorderThickness = 2,
};
void CloseWindow(object o, EventArgs e)
{
displayWindow.Close();
}
_dataGridView.Scroll += CloseWindow;
displayWindow.FormClosed += (_, _) => _dataGridView.Scroll -= CloseWindow;
displayWindow.Show(this);
}
private void Liberate_Click(GridEntry liveGridEntry)
{
LiberateClicked?.Invoke(this, liveGridEntry.LibraryBook);
}
private static void Details_Click(GridEntry liveGridEntry)
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
liveGridEntry.Commit(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
}
#endregion
#region UI display functions
private FilterableSortableBindingList bindingList;
private bool hasBeenDisplayed;
public event EventHandler InitialLoaded;
public void Display()
{
// don't return early if lib size == 0. this will not update correctly if all books are removed
var lib = DbContexts.GetLibrary_Flat_NoTracking();
if (!hasBeenDisplayed)
{
// bind
bindToGrid(lib);
hasBeenDisplayed = true;
InitialLoaded?.Invoke(this, new());
VisibleCountChanged?.Invoke(this, bindingList.Count);
}
else
updateGrid(lib);
}
private void bindToGrid(List<LibraryBook> dbBooks)
{
bindingList = new FilterableSortableBindingList(dbBooks.OrderByDescending(lb => lb.DateAdded).Select(lb => new GridEntry(lb)));
gridEntryBindingSource.DataSource = bindingList;
}
private void updateGrid(List<LibraryBook> dbBooks)
{
int visibleCount = bindingList.Count;
string existingFilter = gridEntryBindingSource.Filter;
//Add absent books to grid, or update current books
var allItmes = bindingList.AllItems();
for (var i = dbBooks.Count - 1; i >= 0; i--)
{
var libraryBook = dbBooks[i];
var existingItem = allItmes.FirstOrDefault(i => i.AudibleProductId == libraryBook.Book.AudibleProductId);
// add new to top
if (existingItem is null)
bindingList.Insert(0, new GridEntry(libraryBook));
// update existing
else
existingItem.UpdateLibraryBook(libraryBook);
}
if (bindingList.Count != visibleCount)
{
//re-filter for newly added items
Filter(null);
Filter(existingFilter);
}
// 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 =
bindingList
.AllItems()
.ExceptBy(dbBooks.Select(lb => lb.Book.AudibleProductId), ge => ge.AudibleProductId)
.ToList();
foreach (var removed in removedBooks)
//no need to re-filter for removed books
bindingList.Remove(removed);
if (bindingList.Count != visibleCount)
VisibleCountChanged?.Invoke(this, bindingList.Count);
}
#endregion
#region Filter
public void Filter(string searchString)
{
int visibleCount = bindingList.Count;
if (string.IsNullOrEmpty(searchString))
gridEntryBindingSource.RemoveFilter();
else
gridEntryBindingSource.Filter = searchString;
if (visibleCount != bindingList.Count)
VisibleCountChanged?.Invoke(this, bindingList.Count);
}
#endregion
internal List<LibraryBook> GetVisible()
=> bindingList
.Select(row => row.LibraryBook)
.ToList();
private GridEntry getGridEntry(int rowIndex) => _dataGridView.GetBoundItem<GridEntry>(rowIndex);
#region Column Customizations
// to ensure this is only ever called once: Load instead of 'override OnVisibleChanged'
private void ProductsGrid_Load(object sender, EventArgs e)
{
if (this.DesignMode)
return;
contextMenuStrip1.Items.Add(new ToolStripLabel("Show / Hide Columns"));
contextMenuStrip1.Items.Add(new ToolStripSeparator());
//Restore Grid Display Settings
var config = Configuration.Instance;
var gridColumnsVisibilities = config.GridColumnsVisibilities;
var gridColumnsWidths = config.GridColumnsWidths;
var displayIndices = config.GridColumnsDisplayIndices;
var cmsKiller = new ContextMenuStrip();
foreach (DataGridViewColumn column in _dataGridView.Columns)
{
var itemName = column.DataPropertyName;
var visible = gridColumnsVisibilities.GetValueOrDefault(itemName, true);
var menuItem = new ToolStripMenuItem()
{
Text = column.HeaderText,
Checked = visible,
Tag = itemName
};
menuItem.Click += HideMenuItem_Click;
contextMenuStrip1.Items.Add(menuItem);
column.Width = gridColumnsWidths.GetValueOrDefault(itemName, column.Width);
column.MinimumWidth = 10;
column.HeaderCell.ContextMenuStrip = contextMenuStrip1;
column.Visible = visible;
//Setting a default ContextMenuStrip will allow the columns to handle the
//Show() event so it is not passed up to the _dataGridView.ContextMenuStrip.
//This allows the ContextMenuStrip to be shown if right-clicking in the gray
//background of _dataGridView but not shown if right-clicking inside cells.
column.ContextMenuStrip = cmsKiller;
}
//We must set DisplayIndex properties in ascending order
foreach (var itemName in displayIndices.OrderBy(i => i.Value).Select(i => i.Key))
{
var column = _dataGridView.Columns
.Cast<DataGridViewColumn>()
.Single(c => c.DataPropertyName == itemName);
column.DisplayIndex = displayIndices.GetValueOrDefault(itemName, column.Index);
}
base.OnVisibleChanged(e);
}
private void gridEntryDataGridView_ColumnDisplayIndexChanged(object sender, DataGridViewColumnEventArgs e)
{
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;
dictionary[e.Column.DataPropertyName] = e.Column.DisplayIndex;
config.GridColumnsDisplayIndices = dictionary;
}
private void gridEntryDataGridView_ColumnWidthChanged(object sender, DataGridViewColumnEventArgs e)
{
var config = Configuration.Instance;
var dictionary = config.GridColumnsWidths;
dictionary[e.Column.DataPropertyName] = e.Column.Width;
config.GridColumnsWidths = dictionary;
}
private void HideMenuItem_Click(object sender, EventArgs e)
{
var menuItem = sender as ToolStripMenuItem;
var propertyName = menuItem.Tag as string;
var column = _dataGridView.Columns
.Cast<DataGridViewColumn>()
.FirstOrDefault(c => c.DataPropertyName == propertyName);
if (column != null)
{
var visible = menuItem.Checked;
menuItem.Checked = !visible;
column.Visible = !visible;
var config = Configuration.Instance;
var dictionary = config.GridColumnsVisibilities;
dictionary[propertyName] = column.Visible;
config.GridColumnsVisibilities = dictionary;
}
}
private void gridEntryDataGridView_CellToolTipTextNeeded(object sender, DataGridViewCellToolTipTextNeededEventArgs e)
{
if (e.ColumnIndex == descriptionGVColumn.Index)
e.ToolTipText = "Click to see full description";
else if (e.ColumnIndex == coverGVColumn.Index)
e.ToolTipText = "Click to see full size";
}
#endregion
}
}