Compare commits

..

67 Commits

Author SHA1 Message Date
Robert McRackan
f175b7592e incr major ver 2023-08-12 21:45:11 -04:00
rmcrackan
415e6e7bc6 Merge pull request #710 from Mbucari/master
Batch of Fixxed Issues
2023-08-12 21:20:37 -04:00
MBucari
d6a413e8d9 Update Avalonia to 11.0.3 2023-08-12 17:30:08 -06:00
Mbucari
3049de6246 Merge branch 'rmcrackan:master' into master 2023-08-12 17:20:09 -06:00
MBucari
fb9b4eb77e Update audio duration on library scan (#707) 2023-08-12 17:19:56 -06:00
MBucari
e65b6c76a8 Add ability to preview templates on user's books (#700)
Add template editor menu items to main grid context menu
2023-08-12 17:12:50 -06:00
Mbucari
167a021eb1 Change glass icon shading 2023-08-11 10:55:31 -06:00
Mbucari
ff3ac2d6fd Fix MessageBox crash when UI has no Screens (#708) 2023-08-11 09:04:05 -06:00
Robert McRackan
f733079a49 remove Moq 2023-08-10 15:24:43 -04:00
Mbucari
893d68190d Lazy load cover to improve startup time 2023-08-10 10:18:28 -06:00
Mbucari
97f94d8782 Add custom column widths to chardonnay 2023-08-07 15:54:50 -06:00
Mbucari
4b2ce0c2d1 Brighter stoplight colors (#702) 2023-08-07 11:31:22 -06:00
Mbucari
ee00417c6f Add white glow to libation_glass.svg (#701) 2023-08-07 11:31:03 -06:00
Robert McRackan
768afd8ecd incr ver 2023-08-04 11:57:15 -04:00
rmcrackan
32c3fa85ce Merge pull request #699 from Mbucari/master
Fix broken template editor (#698)
2023-08-04 11:55:30 -04:00
Mbucari
6986c8f018 Fix broken template editor (#698) 2023-08-04 09:26:51 -06:00
Robert McRackan
f69c2b1cfc incr ver 2023-08-02 21:55:19 -04:00
rmcrackan
b11675c36a Merge pull request #696 from Mbucari/master
Bug fixes
2023-08-02 21:52:57 -04:00
Mbucari
379c2ed62d Fix account nickname retrieval (#629) 2023-08-02 13:54:49 -06:00
Mbucari
7c8489b52f Fix walkthrough causing freeze (#695) 2023-08-02 13:15:58 -06:00
rmcrackan
c61a863edd Merge pull request #694 from Mbucari/master
Fix DPI scaling bug (#692)
2023-08-01 15:03:21 -04:00
Mbucari
1d54f32ef3 Fix DPI scaling bug (#692) 2023-08-01 11:55:23 -06:00
rmcrackan
fabe4afd94 Merge pull request #691 from Mbucari/master
Fix #686 and enable nullable in FileManager and LibationFileManager
2023-07-30 20:22:55 -04:00
MBucari
61efa3c0c1 Update dependencies 2023-07-30 14:00:12 -06:00
MBucari
fe70daf0bc Update avalonia to v11.0.1 2023-07-30 13:54:44 -06:00
MBucari
34033e7947 Enable Nullable 2023-07-30 13:31:57 -06:00
MBucari
e8c63e9a6e Fix UI control overlapping label (#686) 2023-07-30 13:15:43 -06:00
Robert McRackan
9315165f80 update dependencies 2023-07-21 21:21:48 -04:00
Robert McRackan
ce624399ba incr ver 2023-07-19 07:48:11 -04:00
rmcrackan
63e9700c4a Merge pull request #682 from Mbucari/master
Fix #680 and Add category ladders
2023-07-19 07:46:05 -04:00
Mbucari
914e574bf8 Improve GridView
- Remove LongDescription
- Description has the full description
- Better MyRating updating
2023-07-18 16:18:01 -06:00
Mbucari
b94f9bbc15 Fix grid update bug 2023-07-18 16:11:22 -06:00
Mbucari
4e34834c35 Fix category search indexing 2023-07-18 16:00:06 -06:00
Mbucari
3211b2dc85 Improved Category Ladders 2023-07-18 15:51:02 -06:00
Mbucari
ea6adeb58f Add category ladders 2023-07-17 16:50:45 -06:00
Mbucari
90eccbf2f6 Fix FilePathCache NRE (#680) 2023-07-17 08:55:55 -06:00
rmcrackan
668cd7dba8 Merge pull request #679 from Mbucari/master
Mp3 embedded cuesheet and raw metadata
2023-07-16 14:32:49 -04:00
MBucari
c08b2b575c UI Tweak 2023-07-16 10:57:17 -06:00
MBucari
07eaa48e10 Save raw json metadata 2023-07-16 10:54:05 -06:00
MBucari
3cf5fc1d99 Add mp3 embedded cuesheet (#677) 2023-07-15 10:44:31 -06:00
Robert McRackan
15ad753fa1 update dependencies 2023-07-14 20:58:26 -04:00
rmcrackan
75b984bdb2 Merge pull request #678 from Mbucari/master
Fix quick filter not being applied on startup
2023-07-14 20:53:57 -04:00
Mbucari
f586d1d59f Fix quick filter not being applied on startup 2023-07-13 11:00:05 -06:00
Mbucari
cb91a591f0 inc ver 2023-07-13 09:58:45 -06:00
Mbucari
0c0c556c6a Merge pull request #674 from Mbucari/master
Fix #673
2023-07-13 09:31:28 -06:00
MBucari
ff63b73c09 Fix #673 2023-07-13 09:30:02 -06:00
Mbucari
c1d56adbd2 Add groupbox title 2023-07-12 21:29:00 -06:00
rmcrackan
bcd99fd208 Merge pull request #670 from Mbucari/master
Add products grid scaling setting
2023-07-12 21:10:51 -04:00
Mbucari
d1df10d060 Add products grid scaling setting
- Add Grid Scaling Settings
- Add WinForms DPI migration to remove stored form sizes
- Add textbox clear button
2023-07-12 15:32:37 -06:00
Mbucari
1fa415628f Update ProductsGrid.cs 2023-07-10 11:39:33 -06:00
rmcrackan
a83fe9e532 Merge pull request #667 from Mbucari/master
Fix setting Panel2MinSize min width bug (#666)
2023-07-10 11:19:45 -04:00
Mbucari
f85462ffec Fix setting Panel2MinSize min width bug (#666) 2023-07-10 09:11:38 -06:00
Robert McRackan
156349c293 incr ver 2023-07-10 09:26:26 -04:00
rmcrackan
5976706e40 Merge pull request #664 from Mbucari/startup-2
New settings, context menu, and performance improvements
2023-07-10 09:25:13 -04:00
Mbucari
1e40180f0c Fix unit test 2023-07-09 16:42:08 -06:00
Mbucari
7d09728e6b Add Re-download context menu item 2023-07-09 16:26:58 -06:00
Mbucari
4899ef3007 Add new settings and settings dialog help tips
Add CombineNestedChapterTitles setting (#663)
Add SaveMetadataToFile setting
Add extended setting descriptions for select options
2023-07-09 16:07:13 -06:00
Mbucari
296c2b43eb Remove extra library load and move comments to Main 2023-07-09 10:10:00 -06:00
Mbucari
932472cb91 Add full context menu to call columns 2023-07-09 09:53:28 -06:00
Mbucari
1bf86b05ec Download high quality cover art 2023-07-09 09:35:40 -06:00
Mbucari
5d5e3a6671 improve startup time 2023-07-09 09:23:58 -06:00
Robert McRackan
9720a573c7 incr ver 2023-07-07 20:27:57 -04:00
rmcrackan
1cf01aa92a Merge pull request #660 from Mbucari/master
Crash logging to chardonnay
2023-07-07 20:27:09 -04:00
Mbucari
4df9e5abbf Add unhandled error handling and crash logging to chardonnay 2023-07-07 14:14:12 -06:00
Mbucari
9243aa47e7 Upgrade Avalonia to v11.0.0 2023-07-07 14:13:54 -06:00
rmcrackan
c69f41a2a6 Merge pull request #659 from Mbucari/master
Fix classic scaling on high dpi displays
2023-07-07 08:06:22 -04:00
Mbucari
27c74e52ca Fix classic scaling on high dpi displays 2023-07-06 21:34:29 -06:00
177 changed files with 5610 additions and 3235 deletions

View File

@@ -1,28 +1,39 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<path id="glass" d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
z" />
<path id="wine-level" d=
"M146,128
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
z"/>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524">
<defs>
<g id="glass">
<path fill-rule="evenodd" d=
"M262,8
h-117
a 192,200 0 0 0 -36,82
a 222,334 41 0 0 138,236
v158
h-81
a 16,16 0 0 0 0,32
h192
a 16 16 0 0 0 0,-32
h-81
v-158
a 222,334 -41 0 0 138,-236
a 192,200 0 0 0 -36,-82
h-117
m-99,30
a 192,200 0 0 0 -26,95
a 187.5,334 35 0 0 125,159
a 187.5,334 -35 0 0 125,-159
a 192,200 0 0 0 -26,-95
h-198
z"/>
</g>
<g id="wine-level">
<path d=
"M158,136
a 168,305 35 0 0 104,136
a 168,305 -35 0 0 104,-136
z"/>
</g>
</defs>
<use href="#glass" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#wine-level" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#glass" fill="Black" />
<use href="#wine-level" fill="Black" />
</svg>

Before

Width:  |  Height:  |  Size: 585 B

After

Width:  |  Height:  |  Size: 968 B

View File

@@ -81,14 +81,7 @@ namespace AaxDecrypter
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
}
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
@@ -98,5 +91,7 @@ namespace AaxDecrypter
return !IsCanceled;
}
protected virtual void OnInitialized() { }
}
}

View File

@@ -21,6 +21,18 @@ namespace AaxDecrypter
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
chapters: null);
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489

View File

@@ -3,7 +3,6 @@ using AAXClean.Codecs;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
@@ -26,6 +25,18 @@ namespace AaxDecrypter
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
DownloadOptions.ChapterInfo);
}
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
FileUtility.SaferDelete(OutputFileName);

View File

@@ -118,11 +118,7 @@ namespace AaxDecrypter
public abstract Task CancelAsync();
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
if (coverArt is not null)
OnRetrievedCoverArt(coverArt);
}
public virtual void SetCoverArt(byte[] coverArt) { }
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);

View File

@@ -8,7 +8,12 @@ namespace AaxDecrypter
public static class MpegUtil
{
private const string TagDomain = "com.pilabor.tone";
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
public static void ConfigureLameOptions(
Mp4File mp4File,
LameConfig lameConfig,
bool downsample,
bool matchSourceBitrate,
ChapterInfo chapters)
{
double bitrateMultiple = 1;
@@ -53,6 +58,12 @@ namespace AaxDecrypter
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
lameConfig.ID3.UserDefinedText.Add("PART", part);
if (chapters?.Count > 0)
{
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
}
}
}
}

View File

@@ -127,6 +127,8 @@ namespace AaxDecrypter
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
throw new ArgumentException($"New uri to the same file must have the same file name.");
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (DownloadTask is not null)

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>10.5.1.1</Version>
<Version>11.0.0.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="6.0.0" />
<PackageReference Include="Octokit" Version="7.1.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="1.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -6,7 +6,7 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="NPOI" Version="2.6.0" />
<PackageReference Include="NPOI" Version="2.6.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -141,7 +141,7 @@ namespace ApplicationServices
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,

View File

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

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
namespace DataLayer.Configurations
{
internal class BookCategoryConfig : IEntityTypeConfiguration<BookCategory>
{
public void Configure(EntityTypeBuilder<BookCategory> entity)
{
entity.HasKey(bc => new { bc.BookId, bc.CategoryLadderId });
entity.HasIndex(bc => bc.BookId);
entity.HasIndex(bc => bc.CategoryLadderId);
entity
.HasOne(bc => bc.Book)
.WithMany(b => b.CategoriesLink)
.HasForeignKey(bc => bc.BookId);
entity
.HasOne(bc => bc.CategoryLadder)
.WithMany(c => c.BooksLink)
.HasForeignKey(bc => bc.CategoryLadderId);
}
}
}

View File

@@ -21,10 +21,7 @@ namespace DataLayer.Configurations
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
entity.Ignore(nameof(Book.TitleWithSubtitle));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
entity.Ignore(b => b.Categories);
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
@@ -58,23 +55,6 @@ namespace DataLayer.Configurations
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);
});
entity
.Metadata
.FindNavigation(nameof(Book.ContributorsLink))
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
.SetPropertyAccessMode(PropertyAccessMode.Field);
entity
.Metadata
.FindNavigation(nameof(Book.SeriesLink))
// PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
.SetPropertyAccessMode(PropertyAccessMode.Field);
entity
.HasOne(b => b.Category)
.WithMany()
.HasForeignKey(b => b.CategoryId);
}
}
}

View File

@@ -10,8 +10,11 @@ namespace DataLayer.Configurations
entity.HasKey(c => c.CategoryId);
entity.HasIndex(c => c.AudibleCategoryId);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
entity.HasData(Category.GetEmpty());
entity.Ignore(c => c.CategoryLadders);
entity
.HasMany(e => e._categoryLadders)
.WithMany(e => e._categories);
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
namespace DataLayer.Configurations
{
internal class CategoryLadderConfig : IEntityTypeConfiguration<CategoryLadder>
{
public void Configure(EntityTypeBuilder<CategoryLadder> entity)
{
entity.HasKey(cl => cl.CategoryLadderId);
entity.Ignore(cl => cl.Categories);
entity
.HasMany(cl => cl._categories)
.WithMany(c => c._categoryLadders);
entity
.Metadata
.FindNavigation(nameof(CategoryLadder.BooksLink))
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
}

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.5">
<PackageReference Include="Dinah.Core" Version="7.3.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.3.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ namespace DataLayer
public string Subtitle { get; private set; }
private string _titleWithSubtitle;
public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}";
public string Description { get; private set; }
public string Description { get; private set; }
public int LengthInMinutes { get; private set; }
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
@@ -55,10 +56,6 @@ namespace DataLayer
public DateTime? DatePublished { get; private set; }
public string Language { get; private set; }
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
public Category Category { get; private set; }
// is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; }
@@ -77,9 +74,8 @@ namespace DataLayer
string description,
int lengthInMinutes,
ContentType contentType,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
Category category,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
string localeName)
{
// validate
@@ -87,7 +83,7 @@ namespace DataLayer
var productId = audibleProductId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
// assign as soon as possible. stuff below relies on this
// assign as soon as possible. stuff below relies on this
AudibleProductId = productId;
Locale = localeName;
@@ -95,43 +91,38 @@ namespace DataLayer
// non-ef-ctor init.s
UserDefinedItem = new UserDefinedItem(this);
_contributorsLink = new HashSet<BookContributor>();
ContributorsLink = new HashSet<BookContributor>();
CategoriesLink = new HashSet<BookCategory>();
_seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>();
Category = category;
// simple assigns
UpdateTitle(title, subtitle);
Description = description?.Trim() ?? "";
Description = description?.Trim() ?? "";
LengthInMinutes = lengthInMinutes;
ContentType = contentType;
// assigns with biz logic
ReplaceAuthors(authors);
ReplaceNarrators(narrators);
}
public void UpdateTitle(string title, string subtitle)
}
public void UpdateTitle(string title, string subtitle)
{
Title = title?.Trim() ?? "";
Subtitle = subtitle?.Trim() ?? "";
_titleWithSubtitle = null;
}
}
public void UpdateLengthInMinutes(int lengthInMinutes)
=> LengthInMinutes = lengthInMinutes;
#region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded
private HashSet<BookContributor> _contributorsLink;
// i'd like this to be internal but migration throws this exception when i try:
// Value cannot be null.
// Parameter name: property
public IEnumerable<BookContributor> ContributorsLink
=> _contributorsLink?
.OrderBy(bc => bc.Order)
.ToList();
internal HashSet<BookContributor> ContributorsLink { get; private set; }
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public IEnumerable<Contributor> Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList();
public IEnumerable<Contributor> Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
=> replaceContributors(authors, Role.Author, context);
@@ -144,47 +135,70 @@ namespace DataLayer
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
// the edge cases of doing local-loaded vs remote-only got weird. just load it
if (_contributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load();
if (ContributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load();
var isIdentical
= ContributorsLink
.ByRole(role)
.Select(c => c.Contributor)
.SequenceEqual(newContributors);
var roleContributions = getContributions(role);
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
if (isIdentical)
return;
_contributorsLink.RemoveWhere(bc => bc.Role == role);
ContributorsLink.RemoveWhere(bc => bc.Role == role);
addNewContributors(newContributors, role);
}
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
{
byte order = 0;
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
_contributorsLink.UnionWith(newContributions);
ContributorsLink.UnionWith(newContributions);
}
private List<BookContributor> getContributions(Role role)
=> ContributorsLink
.Where(a => a.Role == role)
.OrderBy(a => a.Order)
.ToList();
#endregion
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
}
#region categories
internal HashSet<BookCategory> CategoriesLink { get; private set; }
private ReadOnlyCollection<BookCategory> _categoriesReadOnly;
public ReadOnlyCollection<BookCategory> Categories
{
get
{
if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true)
_categoriesReadOnly = CategoriesLink.ToList().AsReadOnly();
return _categoriesReadOnly;
}
}
public void SetCategoryLadders(IEnumerable<CategoryLadder> ladders)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
ArgumentValidator.EnsureNotNull(ladders, nameof(ladders));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
//Replace all existing category ladders.
//Some books make have duplocate ladders
CategoriesLink.Clear();
CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l)));
}
#endregion
#region series
private HashSet<SeriesBook> _seriesLink;
#region series
private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
public void UpsertSeries(Series series, string order, DbContext context = null)
@@ -234,15 +248,6 @@ namespace DataLayer
Language = language?.FirstCharToUpper() ?? Language;
}
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category is null)
getEntry(context).Reference(s => s.Category).Load();
Category = category;
}
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
}
}

View File

@@ -0,0 +1,20 @@
using Dinah.Core;
namespace DataLayer
{
public class BookCategory
{
internal int BookId { get; private set; }
internal int CategoryLadderId { get; private set; }
public Book Book { get; private set; }
public CategoryLadder CategoryLadder { get; private set; }
private BookCategory() { }
internal BookCategory(Book book, CategoryLadder categoriesList)
{
Book = ArgumentValidator.EnsureNotNull(book, nameof(book));
CategoryLadder = ArgumentValidator.EnsureNotNull(categoriesList, nameof(categoriesList));
}
}
}

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
#nullable enable
namespace DataLayer
{
public class AudibleCategoryId
@@ -15,20 +15,29 @@ namespace DataLayer
Id = id;
}
}
public class Category
{
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }
public string? AudibleCategoryId { get; private set; }
public string Name { get; private set; }
public Category ParentCategory { get; private set; }
public string? Name { get; internal set; }
private Category() { }
internal List<CategoryLadder> _categoryLadders = new();
private ReadOnlyCollection<CategoryLadder>? _categoryLaddersReadOnly;
public ReadOnlyCollection<CategoryLadder> CategoryLadders
{
get
{
if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true)
_categoryLaddersReadOnly = _categoryLadders.AsReadOnly();
return _categoryLaddersReadOnly;
}
}
private Category() { }
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null)
public Category(AudibleCategoryId audibleSeriesId, string name)
{
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
var id = audibleSeriesId.Id;
@@ -37,15 +46,6 @@ namespace DataLayer
AudibleCategoryId = id;
Name = name;
UpdateParentCategory(parentCategory);
}
public void UpdateParentCategory(Category parentCategory)
{
// don't overwrite with null but not an error
if (parentCategory is not null)
ParentCategory = parentCategory;
}
public override string ToString() => $"[{AudibleCategoryId}] {Name}";

View File

@@ -0,0 +1,58 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
#nullable enable
namespace DataLayer
{
public class CategoryLadder : IEquatable<CategoryLadder>
{
internal int CategoryLadderId { get; private set; }
internal List<Category> _categories;
private ReadOnlyCollection<Category>? _categoriesReadOnly;
public ReadOnlyCollection<Category> Categories
{
get
{
if (_categoriesReadOnly?.SequenceEqual(_categories) is not true)
_categoriesReadOnly = _categories.AsReadOnly();
return _categoriesReadOnly;
}
}
private HashSet<BookCategory>? _booksLink;
public IEnumerable<BookCategory>? BooksLink => _booksLink?.ToList();
private CategoryLadder() { _categories = new(); }
public CategoryLadder(List<Category> categories)
{
ArgumentValidator.EnsureNotNull(categories, nameof(categories));
ArgumentValidator.EnsureGreaterThan(categories.Count, nameof(categories), 0);
_booksLink = new HashSet<BookCategory>();
_categories = categories;
}
public override int GetHashCode()
{
HashCode hashCode = default;
foreach (var category in _categories)
hashCode.Add(category.AudibleCategoryId);
return hashCode.ToHashCode();
}
public bool Equals(CategoryLadder? other)
=> other?._categories is not null
&& Equals(other._categories.Select(c => c.AudibleCategoryId));
public bool Equals(IEnumerable<string?>? categoryIds)
=> categoryIds is not null
&& _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds);
public override bool Equals(object? obj)
=> obj is CategoryLadder other && Equals(other);
public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name));
}
}

View File

@@ -7,8 +7,13 @@ using System.Threading.Tasks;
namespace DataLayer
{
public static class EntityExtensions
{
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
{
public static IEnumerable<BookContributor> ByRole(this IEnumerable<BookContributor> contributors, Role role)
=> contributors
.Where(a => a.Role == role)
.OrderBy(a => a.Order);
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
@@ -46,14 +51,31 @@ namespace DataLayer
? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name;
}
public static string[] CategoriesNames(this Book book)
=> book.Category is null ? new string[0]
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
: new[] { book.Category.ParentCategory.Name, book.Category.Name };
public static string[] CategoriesIds(this Book book)
=> book.Category is null ? null
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId }
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId };
public static string[] LowestCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
.Where(c => c is not null)
.Distinct()
.ToArray();
public static string[] AllCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public static string[] AllCategoryIds(this Book book)
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
{

View File

@@ -23,6 +23,7 @@ namespace DataLayer
public DbSet<Contributor> Contributors { get; private set; }
public DbSet<Series> Series { get; private set; }
public DbSet<Category> Categories { get; private set; }
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
public static LibationContext Create(string connectionString)
{
@@ -39,13 +40,15 @@ namespace DataLayer
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new BookConfig());
modelBuilder.ApplyConfiguration(new BookConfig());
modelBuilder.ApplyConfiguration(new ContributorConfig());
modelBuilder.ApplyConfiguration(new BookContributorConfig());
modelBuilder.ApplyConfiguration(new LibraryBookConfig());
modelBuilder.ApplyConfiguration(new SeriesConfig());
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig());
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types

View File

@@ -0,0 +1,465 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20230718214617_AddCategoryLadder")]
partial class AddCategoryLadder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,174 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddCategoryLadder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Books_Categories_CategoryId",
table: "Books");
migrationBuilder.DropForeignKey(
name: "FK_Categories_Categories_ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropIndex(
name: "IX_Categories_ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropIndex(
name: "IX_Books_CategoryId",
table: "Books");
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "CategoryId",
keyValue: -1);
migrationBuilder.DropColumn(
name: "ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "Books");
migrationBuilder.CreateTable(
name: "CategoryLadders",
columns: table => new
{
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
});
migrationBuilder.CreateTable(
name: "BookCategory",
columns: table => new
{
BookId = table.Column<int>(type: "INTEGER", nullable: false),
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
table.ForeignKey(
name: "FK_BookCategory_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
column: x => x.CategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CategoryCategoryLadder",
columns: table => new
{
_categoriesCategoryId = table.Column<int>(type: "INTEGER", nullable: false),
_categoryLaddersCategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
table.ForeignKey(
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
column: x => x._categoriesCategoryId,
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCategoryLadderId",
column: x => x._categoryLaddersCategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BookCategory_BookId",
table: "BookCategory",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookCategory_CategoryLadderId",
table: "BookCategory",
column: "CategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
table: "CategoryCategoryLadder",
column: "_categoryLaddersCategoryLadderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookCategory");
migrationBuilder.DropTable(
name: "CategoryCategoryLadder");
migrationBuilder.DropTable(
name: "CategoryLadders");
migrationBuilder.AddColumn<int>(
name: "ParentCategoryCategoryId",
table: "Categories",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "Books",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
values: new object[] { -1, "", "", null });
migrationBuilder.CreateIndex(
name: "IX_Categories_ParentCategoryCategoryId",
table: "Categories",
column: "ParentCategoryCategoryId");
migrationBuilder.CreateIndex(
name: "IX_Books_CategoryId",
table: "Books",
column: "CategoryId");
migrationBuilder.AddForeignKey(
name: "FK_Books_Categories_CategoryId",
table: "Books",
column: "CategoryId",
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Categories_Categories_ParentCategoryCategoryId",
table: "Categories",
column: "ParentCategoryCategoryId",
principalTable: "Categories",
principalColumn: "CategoryId");
}
}
}

View File

@@ -17,6 +17,21 @@ namespace DataLayer.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
@@ -26,9 +41,6 @@ namespace DataLayer.Migrations
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
@@ -69,11 +81,26 @@ namespace DataLayer.Migrations
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
@@ -109,24 +136,22 @@ namespace DataLayer.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
});
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
@@ -216,14 +241,23 @@ namespace DataLayer.Migrations
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", "Category")
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("CategoryId")
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
@@ -324,8 +358,6 @@ namespace DataLayer.Migrations
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
@@ -333,6 +365,25 @@ namespace DataLayer.Migrations
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
@@ -352,15 +403,6 @@ namespace DataLayer.Migrations
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
@@ -393,11 +435,18 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");

View File

@@ -34,7 +34,7 @@ namespace DataLayer
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
.Include(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
public static bool IsProduct(this Book book)
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace DataLayer
{
public static class CategoryQueries
{
public static IQueryable<CategoryLadder> GetCategoryLadders(this LibationContext context)
=> context.CategoryLadders.Include(c => c._categories);
}
}

View File

@@ -55,7 +55,7 @@ namespace DataLayer
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
.Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);

View File

@@ -99,20 +99,6 @@ namespace DtoImporterService
.Select(n => contributorImporter.Cache[n.Name])
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is also possible
// CATEGORY HACK: only use the 1st 2 categories
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var lastCategory
= item.Categories.Length == 0 ? ""
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
// 2+
: item.Categories[1].CategoryId;
var category = categoryImporter.Cache[lastCategory];
Book book;
try
{
@@ -125,7 +111,6 @@ namespace DtoImporterService
contentType,
authors,
narrators,
category,
importItem.LocaleName)
).Entity;
Cache.Add(book.AudibleProductId, book);
@@ -140,7 +125,6 @@ namespace DtoImporterService
contentType,
QtyAuthors = authors?.Count,
QtyNarrators = narrators?.Count,
Category = category?.Name,
importItem.LocaleName
});
throw;
@@ -165,6 +149,8 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
book.UpdateLengthInMinutes(item.LengthInMinutes);
// Update the book titles, since formatting can change
book.UpdateTitle(item.Title, item.Subtitle);
@@ -201,6 +187,19 @@ namespace DtoImporterService
book.UpsertSeries(series, seriesEntry.Sequence);
}
}
if (item.CategoryLadders is not null)
{
var ladders = new List<DataLayer.CategoryLadder>();
foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0))
{
var categoryIds = ladder.Select(l => l.CategoryId).ToList();
ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)));
}
//Set all ladders at once so ladders that have been
//removed by audible can be removed from the DB
book.SetCategoryLadders(ladders);
}
}
private static DataLayer.ContentType GetContentType(Item item)

View File

@@ -12,76 +12,84 @@ namespace DtoImporterService
{
protected override IValidator Validator => new CategoryValidator();
public Dictionary<string, Category> Cache { get; private set; } = new();
private Dictionary<string, Category> CategoryCache { get; set; } = new();
public HashSet<DataLayer.CategoryLadder> LadderCache { get; private set; } = new();
public CategoryImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// get distinct
var categoryIds = importItems
.Select(i => i.DtoItem)
.GetCategoriesDistinct()
.Select(c => c.CategoryId)
.Distinct()
.ToList();
// load db existing => .Local
loadLocal_categories(categoryIds);
loadLocal_categories();
// upsert
var categoryPairs = importItems
.Select(i => i.DtoItem)
.GetCategoryPairsDistinct()
var categoryLadders = importItems
.SelectMany(i => i.DtoItem.CategoryLadders)
.Select(cl => cl.Ladder)
.Where(l => l?.Length > 0)
.ToList();
var qtyNew = upsertCategories(categoryPairs);
var qtyNew = upsertCategories(categoryLadders);
return qtyNew;
}
private void loadLocal_categories(List<string> categoryIds)
private void loadLocal_categories()
{
// must include default/empty/missing
categoryIds.Add(Category.GetEmpty().AudibleCategoryId);
// load existing => local
Cache = DbContext.Categories
.Where(c => categoryIds.Contains(c.AudibleCategoryId))
.ToDictionarySafe(c => c.AudibleCategoryId);
LadderCache = DbContext.GetCategoryLadders().ToHashSet();
CategoryCache = LadderCache.SelectMany(cl => cl.Categories).ToDictionarySafe(c => c.AudibleCategoryId);
}
// only use after loading contributors => local
private int upsertCategories(List<Ladder[]> categoryPairs)
private int upsertCategories(List<Ladder[]> ladders)
{
var qtyNew = 0;
foreach (var pair in categoryPairs)
foreach (var ladder in ladders)
{
for (var i = 0; i < pair.Length; i++)
var categories = new List<Category>(ladder.Length);
for (var i = 0; i < ladder.Length; i++)
{
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
if (i > 1)
break;
var id = ladder[i].CategoryId;
var name = ladder[i].CategoryName;
var id = pair[i].CategoryId;
var name = pair[i].CategoryName;
Category parentCategory = null;
if (i == 1)
Cache.TryGetValue(pair[0].CategoryId, out parentCategory);
if (!Cache.TryGetValue(id, out var category))
if (!CategoryCache.TryGetValue(id, out var category))
{
category = addCategory(id, name);
qtyNew++;
}
category.UpdateParentCategory(parentCategory);
categories.Add(category);
}
var categoryLadder = new DataLayer.CategoryLadder(categories);
if (!LadderCache.Contains(categoryLadder))
{
addCategoryLadder(categoryLadder);
qtyNew++;
}
}
return qtyNew;
}
private DataLayer.CategoryLadder addCategoryLadder(DataLayer.CategoryLadder categoryList)
{
try
{
var entityEntry = DbContext.CategoryLadders.Add(categoryList);
var entity = entityEntry.Entity;
LadderCache.Add(entity);
return entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList);
throw;
}
}
private Category addCategory(string id, string name)
{
try
@@ -91,7 +99,7 @@ namespace DtoImporterService
var entityEntry = DbContext.Categories.Add(category);
var entity = entityEntry.Entity;
Cache.Add(entity.AudibleCategoryId, entity);
CategoryCache.Add(entity.AudibleCategoryId, entity);
return entity;
}
catch (Exception ex)

View File

@@ -21,7 +21,8 @@ namespace FileLiberator
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
using var context = ApplicationServices.DbContexts.GetContext();
var seriesParent = context.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
if (seriesParent is not null)
{

View File

@@ -51,18 +51,19 @@ namespace FileLiberator
var config = Configuration.Instance;
var lameConfig = GetLameOptions(config);
var chapters = m4bBook.GetChaptersFromMetadata();
//Finishing configuring lame encoder.
AaxDecrypter.MpegUtil.ConfigureLameOptions(
m4bBook,
lameConfig,
config.LameDownsampleMono,
config.LameMatchSourceBR);
config.LameMatchSourceBR,
chapters);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
try
{
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
await Mp4Operation;

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
using AudibleApi.Common;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -124,12 +125,12 @@ namespace FileLiberator
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
if (contentLic.DrmType != DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
else
{
@@ -139,7 +140,7 @@ namespace FileLiberator
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
abDownloader = converter;
}
@@ -152,16 +153,29 @@ namespace FileLiberator
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
if (success && config.SaveMetadataToFile)
{
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile);
}
return success;
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
{
//If DrmType != Adrm the delivered file is an unencrypted mp3.
var outputFormat
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
? OutputFormat.Mp3
: OutputFormat.M4b;
@@ -183,7 +197,11 @@ namespace FileLiberator
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
};
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs)
.ToList();
if (config.MergeOpeningAndEndCredits)
combineCredits(chapters);
@@ -280,14 +298,19 @@ namespace FileLiberator
*/
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
{
List<AudibleApi.Common.Chapter> chaps = new();
List<Chapter> chaps = new();
foreach (var c in chapters)
{
if (c.Chapters is null)
chaps.Add(c);
else if (titleConcat is null)
{
chaps.Add(c);
chaps.AddRange(flattenChapters(c.Chapters));
}
else
{
if (c.LengthMs < 10000)
@@ -305,13 +328,12 @@ namespace FileLiberator
child.Title = $"{c.Title}{titleConcat}{child.Title}";
chaps.AddRange(children);
c.Chapters = null;
}
}
return chaps;
}
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
public static void combineCredits(IList<Chapter> chapters)
{
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
{
@@ -351,11 +373,15 @@ namespace FileLiberator
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
{
if (Configuration.Instance.AllowLibationFixup)
{
e = OnRequestCoverArt();
abDownloader.SetCoverArt(e);
}
if (e is not null)
OnCoverImageDiscovered(e);
else if (Configuration.Instance.AllowLibationFixup)
abDownloader.SetCoverArt(OnRequestCoverArt());
}
}
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>

View File

@@ -30,7 +30,7 @@ namespace FileLiberator
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var nickname
= persister.AccountsSettings.Accounts
.FirstOrDefault(a => a.AccountId == libraryBook.Account)
.FirstOrDefault(a => a.AccountId == libraryBook.Account && a.Locale.Name == libraryBook.Book.Locale)
?.AccountName;
return new()

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace FileManager
{
/// <summary>
@@ -16,9 +17,9 @@ namespace FileManager
public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; }
private FileSystemWatcher fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
private Task backgroundScanner { get; set; }
private FileSystemWatcher? fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs>? directoryChangesEvents { get; set; }
private Task? backgroundScanner { get; set; }
private object fsCacheLocker { get; } = new();
private List<LongPath> fsCache { get; } = new();
@@ -32,7 +33,7 @@ namespace FileManager
Init();
}
public LongPath FindFile(System.Text.RegularExpressions.Regex regex)
public LongPath? FindFile(System.Text.RegularExpressions.Regex regex)
{
lock (fsCacheLocker)
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
@@ -105,13 +106,13 @@ namespace FileManager
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
{
directoryChangesEvents.Add(e);
directoryChangesEvents?.Add(e);
}
#region Background Thread
private void BackgroundScanner()
{
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
{
lock (fsCacheLocker)
UpdateLocalCache(change);

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Dinah.Core" Version="7.3.0.1" />
<PackageReference Include="Polly" Version="7.2.4" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@@ -7,20 +8,20 @@ using Dinah.Core;
using Polly;
using Polly.Retry;
#nullable enable
namespace FileManager
{
public static class FileUtility
{
/// <summary>
/// "txt" => ".txt"
/// <br />".txt" => ".txt"
/// <br />null or whitespace => ""
/// </summary>
public static string GetStandardizedExtension(string extension)
[return: NotNull]
public static string GetStandardizedExtension(string? extension)
=> string.IsNullOrWhiteSpace(extension)
? (extension ?? "")?.Trim()
? string.Empty
: '.' + extension.Trim().Trim('.');
/// <summary>
@@ -48,18 +49,18 @@ namespace FileManager
/// <br/>- ensure uniqueness
/// <br/>- enforce max file length
/// </summary>
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false)
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string? fileExtension, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
fileExtension = GetStandardizedExtension(fileExtension);
// remove invalid chars
path = GetSafePath(path, replacements);
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path);
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var dir = Path.GetDirectoryName(path)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var fileName = Path.GetFileName(path);
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
@@ -84,6 +85,7 @@ namespace FileManager
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
var pathNoPrefix = path.PathWithoutPrefix;
@@ -159,7 +161,7 @@ namespace FileManager
LongPath source,
LongPath destination,
ReplacementCharacters replacements,
string extension = null,
string? extension = null,
bool overwrite = false)
{
extension ??= Path.GetExtension(source);
@@ -213,6 +215,9 @@ namespace FileManager
SaferDelete(destination);
var dir = Path.GetDirectoryName(destination);
if (dir is null)
throw new DirectoryNotFoundException();
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir);

View File

@@ -8,9 +8,10 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
#nullable enable
namespace FileManager
{
public sealed class LogArchiver : IAsyncDisposable
public sealed class LogArchiver : IAsyncDisposable, IDisposable
{
public Encoding Encoding { get; set; }
public string FileName { get; }
@@ -39,28 +40,28 @@ namespace FileManager
e.Delete();
}
public async Task AddFileAsync(string name, JObject contents, string comment = null)
public async Task AddFileAsync(string name, JObject contents, string? comment = null)
{
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment);
}
public async Task AddFileAsync(string name, string contents, string comment = null)
public async Task AddFileAsync(string name, string contents, string? comment = null)
{
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents), comment);
}
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string comment = null)
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string? comment = null)
{
ArgumentValidator.EnsureNotNull(name, nameof(name));
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
return Task.Run(() => AddfileInternal(name, contents.Span, comment));
return Task.Run(() => AddFileInternal(name, contents.Span, comment));
}
private readonly object lockObj = new();
private void AddfileInternal(string name, ReadOnlySpan<byte> contents, string comment)
private void AddFileInternal(string name, ReadOnlySpan<byte> contents, string? comment)
{
lock (lockObj)
{
@@ -73,5 +74,7 @@ namespace FileManager
}
public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose);
public void Dispose() => archive.Dispose();
}
}

View File

@@ -1,9 +1,11 @@
using Newtonsoft.Json;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
#nullable enable
namespace FileManager
{
public class LongPath
@@ -15,9 +17,9 @@ namespace FileManager
public static readonly int MaxPathLength;
private const int WIN_MAX_PATH = 260;
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
internal static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
internal static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
internal static readonly bool IsOSX = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public string Path { get; }
@@ -60,7 +62,8 @@ namespace FileManager
=> IsWindows ? filename.Length
: Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path)
[return: NotNullIfNotNull(nameof(path))]
public static implicit operator LongPath?(string? path)
{
if (path is null) return null;
@@ -93,7 +96,8 @@ namespace FileManager
}
}
public static implicit operator string(LongPath path) => path?.Path;
[return: NotNullIfNotNull(nameof(path))]
public static implicit operator string?(LongPath? path) => path?.Path;
[JsonIgnore]
public string ShortPathName
@@ -127,8 +131,6 @@ namespace FileManager
//for newly-created entries in ther file system. Existing entries made while
//8dot3 names were disabled will not be reachable by short paths.
if (Path is null) return null;
StringBuilder shortPathBuffer = new(MaxPathLength);
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
return shortPathBuffer.ToString();
@@ -141,7 +143,6 @@ namespace FileManager
get
{
if (!IsWindows) return Path;
if (Path is null) return null;
StringBuilder longPathBuffer = new(MaxPathLength);
GetLongPathName(Path, longPathBuffer, MaxPathLength);
@@ -156,17 +157,18 @@ namespace FileManager
{
if (!IsWindows) return Path;
return
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
:Path;
Path.StartsWith(WIN_LONG_PATH_PREFIX)
? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
: Path;
}
}
public override string ToString() => Path;
public override int GetHashCode() => Path.GetHashCode();
public override bool Equals(object obj) => obj is LongPath other && Path == other.Path;
public static bool operator ==(LongPath path1, LongPath path2) => path1.Equals(path2);
public static bool operator !=(LongPath path1, LongPath path2) => !path1.Equals(path2);
public override bool Equals(object? obj) => obj is LongPath other && Path == other.Path;
public static bool operator ==(LongPath? path1, LongPath? path2) => path1?.Equals(path2) is true;
public static bool operator !=(LongPath? path1, LongPath? path2) => path1 is null || path2 is null || !path1.Equals(path2);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]

View File

@@ -1,7 +1,9 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
internal interface IClosingPropertyTag : IPropertyTag
@@ -17,7 +19,7 @@ internal interface IClosingPropertyTag : IPropertyTag
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
}
public class ConditionalTagCollection<TClass> : TagCollection
@@ -37,6 +39,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
private class ConditionalTag : TagBase, IClosingPropertyTag
{
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
@@ -46,7 +49,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
}
public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag)
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
{
var match = NameCloseMatcher.Match(templateString);
if (match.Success)

View File

@@ -1,19 +1,21 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
#nullable enable
namespace FileManager.NamingTemplate;
public class NamingTemplate
{
public string TemplateText { get; private set; }
public string TemplateText { get; private set; } = string.Empty;
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
public IEnumerable<string> Warnings => errors.Concat(warnings);
public IEnumerable<string> Errors => errors;
private Delegate templateToString;
private Delegate? templateToString;
private readonly List<string> warnings = new();
private readonly List<string> errors = new();
private readonly IEnumerable<TagCollection> TagCollections;
@@ -30,6 +32,9 @@ public class NamingTemplate
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
public TemplatePart Evaluate(params object[] propertyClasses)
{
if (templateToString is null)
throw new InvalidOperationException();
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
// First parameter is "this", so ignore it.
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
@@ -39,7 +44,7 @@ public class NamingTemplate
if (args.Length != delegateArgTypes.Count())
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
return ((TemplatePart)templateToString.DynamicInvoke(args)).FirstPart;
return (templateToString.DynamicInvoke(args) as TemplatePart)!.FirstPart;
}
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
@@ -69,7 +74,7 @@ public class NamingTemplate
}
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
private static Expression GetExpressionTree(BinaryNode node)
private static Expression GetExpressionTree(BinaryNode? node)
{
if (node is null) return TemplatePart.Blank;
else if (node.IsValue) return node.Expression;
@@ -81,10 +86,10 @@ public class NamingTemplate
}
/// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary>
private BinaryNode IntermediateParse(string templateString)
private BinaryNode IntermediateParse(string? templateString)
{
if (templateString is null)
throw new NullReferenceException(ERROR_NULL_IS_INVALID);
throw new ArgumentException(ERROR_NULL_IS_INVALID);
else if (string.IsNullOrEmpty(templateString))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(templateString))
@@ -93,12 +98,12 @@ public class NamingTemplate
TemplateText = templateString;
BinaryNode topNode = BinaryNode.CreateRoot();
BinaryNode currentNode = topNode;
BinaryNode? currentNode = topNode;
List<char> literalChars = new();
while (templateString.Length > 0)
{
if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression))
if (StartsWith(templateString, out var exactPropertyName, out var propertyTag, out var valueExpression))
{
checkAndAddLiterals();
@@ -116,7 +121,7 @@ public class NamingTemplate
{
checkAndAddLiterals();
BinaryNode lastParenth = currentNode;
BinaryNode? lastParenth = currentNode;
while (lastParenth?.IsConditional is false)
lastParenth = lastParenth.Parent;
@@ -168,7 +173,7 @@ public class NamingTemplate
}
}
private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression)
private bool StartsWith(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? valueExpression)
{
foreach (var pc in TagCollections)
{
@@ -182,7 +187,7 @@ public class NamingTemplate
return false;
}
private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag)
private bool StartsWithClosing(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
{
foreach (var pc in TagCollections)
{
@@ -198,36 +203,36 @@ public class NamingTemplate
private class BinaryNode
{
public string Name { get; }
public BinaryNode Parent { get; private set; }
public BinaryNode RightChild { get; private set; }
public BinaryNode LeftChild { get; private set; }
public Expression Expression { get; private init; }
public BinaryNode? Parent { get; private set; }
public BinaryNode? RightChild { get; private set; }
public BinaryNode? LeftChild { get; private set; }
public Expression Expression { get; }
public bool IsConditional { get; private init; } = false;
public bool IsValue { get; private init; } = false;
public static BinaryNode CreateRoot() => new("Root");
public static BinaryNode CreateRoot() => new("Root", Expression.Empty());
public static BinaryNode CreateValue(string literal) => new("Literal")
{
IsValue = true,
Expression = TemplatePart.CreateLiteral(literal)
};
public static BinaryNode CreateValue(string literal)
=> new("Literal", TemplatePart.CreateLiteral(literal))
{
IsValue = true
};
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
{
IsValue = true,
Expression = TemplatePart.CreateProperty(templateTag, property)
};
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property)
=> new(templateTag.TagName, TemplatePart.CreateProperty(templateTag, property))
{
IsValue = true
};
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
{
IsConditional = true,
Expression = property
};
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property)
=> new(templateTag.TagName, property)
{
IsConditional = true
};
private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right)
{
var newNode = new BinaryNode("Concatenation")
var newNode = new BinaryNode("Concatenation", Expression.Empty())
{
LeftChild = left,
RightChild = right
@@ -237,7 +242,12 @@ public class NamingTemplate
return newNode;
}
private BinaryNode(string name) => Name = name;
private BinaryNode(string name, Expression expression)
{
Name = name;
Expression = expression;
}
public override string ToString() => Name;
public BinaryNode AddNewNode(BinaryNode newNode)

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
@@ -37,7 +38,7 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty> formatter = null)
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
where TProperty : struct
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
@@ -59,7 +60,7 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty> formatter = null)
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
@@ -72,14 +73,15 @@ public class PropertyTagCollection<TClass> : TagCollection
=> RegisterWithToString(templateTag, propertyGetter, toString);
private void RegisterWithFormatter<TProperty, TPropertyValue>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue> formatter)
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue>? formatter)
{
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
formatter ??= GetDefaultFormatter<TPropertyValue>();
if ((formatter ??= GetDefaultFormatter<TPropertyValue>()) is null)
if (formatter is null)
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
else
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
@@ -97,7 +99,7 @@ public class PropertyTagCollection<TClass> : TagCollection
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
private PropertyFormatter<T> GetDefaultFormatter<T>()
private PropertyFormatter<T>? GetDefaultFormatter<T>()
{
try
{
@@ -109,6 +111,7 @@ public class PropertyTagCollection<TClass> : TagCollection
private class PropertyTag<TPropertyValue> : TagBase
{
public override Regex NameMatcher { get; }
private Func<Expression, string, Expression> CreateToStringExpression { get; }
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)

View File

@@ -1,7 +1,9 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
internal interface IPropertyTag
@@ -22,13 +24,13 @@ internal interface IPropertyTag
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWith(string templateString, out string exactName, out Expression propertyValue);
bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue);
}
internal abstract class TagBase : IPropertyTag
{
public ITemplateTag TemplateTag { get; }
public Regex NameMatcher { get; protected init; }
public abstract Regex NameMatcher { get; }
public Type ReturnType => ValueExpression.Type;
protected Expression ValueExpression { get; }
@@ -43,7 +45,7 @@ internal abstract class TagBase : IPropertyTag
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
protected abstract Expression GetTagExpression(string exactName, string formatter);
public bool StartsWith(string templateString, out string exactName, out Expression propertyValue)
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
{
var match = NameMatcher.Match(templateString);
if (match.Success)

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
@@ -32,7 +34,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
internal bool StartsWith(string templateString, out string exactName, out IPropertyTag propertyTag, out Expression propertyValue)
internal bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? propertyValue)
{
foreach (var p in PropertyTags)
{
@@ -57,7 +59,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag)
internal bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
{
foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>())
{

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
#nullable enable
namespace FileManager.NamingTemplate;
/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary>
@@ -15,13 +16,13 @@ public class TemplatePart : IEnumerable<TemplatePart>
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
/// a registered property, otherwise <see cref="null"/> for string literals. </summary>
public ITemplateTag TemplateTag { get; }
public ITemplateTag? TemplateTag { get; }
/// <summary>The evaluated string.</summary>
public string Value { get; }
private TemplatePart previous;
private TemplatePart next;
private TemplatePart? previous;
private TemplatePart? next;
private TemplatePart(string name, string value)
{
TagName = name;
@@ -53,14 +54,33 @@ public class TemplatePart : IEnumerable<TemplatePart>
private static Expression CreateExpression(string name, Expression value)
=> Expression.New(constructorInfo, Expression.Constant(name), value);
private static readonly ConstructorInfo constructorInfo
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(string), typeof(string) });
private static readonly ConstructorInfo constructorInfo;
private static readonly ConstructorInfo tagTemplateConstructorInfo;
private static readonly MethodInfo addMethodInfo;
static TemplatePart()
{
var type = typeof(TemplatePart);
private static readonly ConstructorInfo tagTemplateConstructorInfo
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(ITemplateTag), typeof(string) });
if (type.GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
new Type[] { typeof(string), typeof(string) }) is not ConstructorInfo c1)
throw new MissingMethodException(nameof(TemplatePart));
private static readonly MethodInfo addMethodInfo
= typeof(TemplatePart).GetMethod(nameof(Concatenate), BindingFlags.NonPublic | BindingFlags.Static, new Type[] { typeof(TemplatePart), typeof(TemplatePart) });
if (type.GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
new Type[] { typeof(ITemplateTag), typeof(string) }) is not ConstructorInfo c2)
throw new MissingMethodException(nameof(TemplatePart));
if (type.GetMethod(
nameof(Concatenate),
BindingFlags.NonPublic | BindingFlags.Static,
new Type[] { typeof(TemplatePart), typeof(TemplatePart) }) is not MethodInfo m1)
throw new MissingMethodException(nameof(Concatenate));
constructorInfo = c1;
tagTemplateConstructorInfo = c2;
addMethodInfo = m1;
}
public IEnumerator<TemplatePart> GetEnumerator()
{

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#nullable enable
namespace FileManager
{
public class PersistentDictionary
@@ -13,19 +15,19 @@ namespace FileManager
public bool IsReadOnly { get; }
// optimize for strings. expectation is most settings will be strings and a rare exception will be something else
private Dictionary<string, string> stringCache { get; } = new Dictionary<string, string>();
private Dictionary<string, object> objectCache { get; } = new Dictionary<string, object>();
private Dictionary<string, string?> stringCache { get; } = new();
private Dictionary<string, object?> objectCache { get; } = new();
public PersistentDictionary(string filepath, bool isReadOnly = false)
{
Filepath = filepath;
IsReadOnly = isReadOnly;
if (File.Exists(Filepath))
if (File.Exists(Filepath) || Path.GetDirectoryName(Filepath) is not string dirName)
return;
// will create any missing directories, incl subdirectories. if all already exist: no action
Directory.CreateDirectory(Path.GetDirectoryName(filepath));
Directory.CreateDirectory(dirName);
if (IsReadOnly)
return;
@@ -33,13 +35,14 @@ namespace FileManager
createNewFile();
}
public string GetString(string propertyName, string defaultValue = null)
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string propertyName, string? defaultValue = null)
{
if (!stringCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (jObject.ContainsKey(propertyName))
stringCache[propertyName] = jObject[propertyName].Value<string>();
stringCache[propertyName] = jObject[propertyName]?.Value<string>();
else
stringCache[propertyName] = defaultValue;
}
@@ -47,7 +50,8 @@ namespace FileManager
return stringCache[propertyName];
}
public T GetNonString<T>(string propertyName, T defaultValue = default)
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
{
var obj = GetObject(propertyName);
@@ -72,21 +76,21 @@ namespace FileManager
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
public object GetObject(string propertyName)
public object? GetObject(string propertyName)
{
if (!objectCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return null;
objectCache[propertyName] = jObject[propertyName].Value<object>();
objectCache[propertyName] = jObject[propertyName]?.Value<object>();
}
return objectCache[propertyName];
}
public string GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string GetStringFromJsonPath(string jsonPath)
public string? GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string? GetStringFromJsonPath(string jsonPath)
{
if (!stringCache.ContainsKey(jsonPath))
{
@@ -96,7 +100,7 @@ namespace FileManager
var token = jObject.SelectToken(jsonPath);
if (token is null)
return null;
stringCache[jsonPath] = (string)token;
stringCache[jsonPath] = token.Value<string>();
}
catch
{
@@ -110,7 +114,7 @@ namespace FileManager
public bool Exists(string propertyName) => readFile().ContainsKey(propertyName);
private object locker { get; } = new object();
public void SetString(string propertyName, string newValue)
public void SetString(string propertyName, string? newValue)
{
// only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
@@ -122,7 +126,7 @@ namespace FileManager
writeFile(propertyName, newValue);
}
public void SetNonString(string propertyName, object newValue)
public void SetNonString(string propertyName, object? newValue)
{
// set cache
objectCache[propertyName] = newValue;
@@ -131,7 +135,36 @@ namespace FileManager
writeFile(propertyName, parsedNewValue);
}
private void writeFile(string propertyName, JToken newValue)
public bool RemoveProperty(string propertyName)
{
if (IsReadOnly)
return false;
var success = false;
try
{
lock (locker)
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return false;
jObject.Remove(propertyName);
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
File.WriteAllText(Filepath, endContents);
success = true;
}
Serilog.Log.Logger.Information("Removed property. {@DebugInfo}", propertyName);
}
catch { }
return success;
}
private void writeFile(string propertyName, JToken? newValue)
{
if (IsReadOnly)
return;
@@ -161,7 +194,7 @@ namespace FileManager
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
/// <returns>Value was changed</returns>
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{
if (IsReadOnly)
return false;
@@ -213,7 +246,7 @@ namespace FileManager
return true;
}
private static string formatValueForLog(string value)
private static string formatValueForLog(string? value)
=> value is null ? "[null]"
: string.IsNullOrEmpty(value) ? "[empty]"
: string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]"
@@ -254,7 +287,6 @@ namespace FileManager
private void createNewFile()
{
File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
}
}
}

View File

@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
#nullable enable
namespace FileManager
{
public record Replacement
@@ -59,7 +60,7 @@ namespace FileManager
[JsonConverter(typeof(ReplacementCharactersConverter))]
public class ReplacementCharacters
{
public override bool Equals(object obj)
public override bool Equals(object? obj)
{
if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count)
{
@@ -173,7 +174,7 @@ namespace FileManager
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray();
public IReadOnlyList<Replacement> Replacements { get; init; }
required public IReadOnlyList<Replacement> Replacements { get; init; }
private string DefaultReplacement => Replacements[0].ReplacementString;
private Replacement ForwardSlash => Replacements[1];
private Replacement BackSlash => Replacements[2];
@@ -298,12 +299,14 @@ namespace FileManager
public override bool CanConvert(Type objectType)
=> objectType == typeof(ReplacementCharacters);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var replaceArr = jObj[nameof(Replacement)];
IReadOnlyList<Replacement> dict = replaceArr
.ToObject<Replacement[]>().ToList();
var dict
= replaceArr?.ToObject<Replacement[]>()?.ToList()
?? ReplacementCharacters.Default.Replacements;
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
//If not, reset to default.
@@ -325,9 +328,10 @@ namespace FileManager
return new ReplacementCharacters { Replacements = dict };
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
ReplacementCharacters replacements = (ReplacementCharacters)value;
if (value is not ReplacementCharacters replacements)
return;
var propertyNames = replacements.Replacements
.Select(JObject.FromObject).ToList();

View File

@@ -67,13 +67,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia" Version="11.0.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.3" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc2.2" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.3" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@@ -219,8 +219,8 @@
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Controls.Add(this.tabControl1);
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));

View File

@@ -44,9 +44,9 @@
<SolidColorBrush x:Key="CancelRed" Color="#802727" />
<SolidColorBrush x:Key="IconFill" Color="#DCE0DF" />
<SolidColorBrush x:Key="StoplightRed" Color="#5F0707" />
<SolidColorBrush x:Key="StoplightYellow" Color="#5F5B1A" />
<SolidColorBrush x:Key="StoplightGreen" Color="#174E15" />
<SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" />
<SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" />
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />

View File

@@ -8,9 +8,6 @@
<Panel Background="Transparent" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid Name="ratingsGrid" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="3,0,0,0" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
<Grid.Styles>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="11" />
</Style>
<Style Selector="StackPanel > TextBlock">
<Setter Property="Padding" Value="0,0,-2,0" />
</Style>

View File

@@ -73,7 +73,15 @@
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<CheckBox
ToolTip.Tip="{CompiledBinding CombineNestedChapterTitlesTip}"
IsChecked="{CompiledBinding CombineNestedChapterTitles, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CombineNestedChapterTitlesText}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{CompiledBinding AllowLibationFixupTip}"
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
</CheckBox>
</StackPanel>

View File

@@ -165,18 +165,32 @@
</StackPanel>
</controls:GroupBox>
<CheckBox
<StackPanel
Grid.Row="3"
Margin="5"
VerticalAlignment="Top"
IsVisible="{CompiledBinding !Config.IsLinux}"
IsChecked="{CompiledBinding UseCoverAsFolderIcon, Mode=TwoWay}">
Orientation="Horizontal">
<TextBlock
TextWrapping="Wrap"
Text="{CompiledBinding UseCoverAsFolderIconText}" />
<CheckBox
Margin="5"
VerticalAlignment="Top"
IsVisible="{CompiledBinding !Config.IsLinux}"
IsChecked="{CompiledBinding UseCoverAsFolderIcon, Mode=TwoWay}">
</CheckBox>
<TextBlock
TextWrapping="Wrap"
Text="{CompiledBinding UseCoverAsFolderIconText}" />
</CheckBox>
<CheckBox
Margin="5"
VerticalAlignment="Top"
IsChecked="{CompiledBinding SaveMetadataToFile, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="{CompiledBinding SaveMetadataToFileText}" />
</CheckBox>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -2,18 +2,18 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="600"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:ImportantSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Important">
<Grid RowDefinitions="Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<controls:GroupBox
Grid.Row="0"
Margin="5"
Label="Books Location">
<StackPanel>
<TextBlock
Margin="5"
@@ -35,7 +35,7 @@
<Grid
RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,*">
<TextBlock
VerticalAlignment="Center"
Margin="0,0,10,0"
@@ -95,8 +95,63 @@
</StackPanel>
<Grid
<controls:GroupBox
Grid.Row="2"
Margin="5"
Label="Display Settings">
<Grid
RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,Auto,*">
<TextBlock
Margin="0,0,10,0"
VerticalAlignment="Center"
Text="{CompiledBinding GridScaleFactorText}"/>
<Slider
Grid.Column="1"
Width="200"
Value="{CompiledBinding GridScaleFactor, Mode=TwoWay}"
VerticalAlignment="Center"
Minimum="-100"
Maximum="100"
IsSnapToTickEnabled="False"
TickFrequency="25"
TickPlacement="BottomRight">
</Slider>
<TextBlock
Margin="0,0,10,0"
Grid.Row="1"
VerticalAlignment="Center"
Text="{CompiledBinding GridFontScaleFactorText}"/>
<Slider
Grid.Column="1"
Grid.Row="1"
Width="200"
Value="{CompiledBinding GridFontScaleFactor, Mode=TwoWay}"
VerticalAlignment="Center"
Minimum="-100"
Maximum="100"
IsSnapToTickEnabled="False"
TickFrequency="25"
TickPlacement="BottomRight">
</Slider>
<Button
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Right"
Margin="0,5"
Padding="20,0"
VerticalAlignment="Stretch"
Content="Apply Display Settings"
Command="{CompiledBinding ApplyDisplaySettings}"/>
</Grid>
</controls:GroupBox>
<Grid
Grid.Row="3"
ColumnDefinitions="Auto,Auto,*"
Margin="10"
VerticalAlignment="Bottom">
@@ -111,7 +166,7 @@
MinWidth="80"
SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}"
ItemsSource="{CompiledBinding Themes}"/>
<TextBlock
Grid.Column="2"
FontSize="16"

View File

@@ -115,7 +115,7 @@ Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(" > ", Book.CategoriesNames())}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language}
Audible ID: {Book.AudibleProductId}

View File

@@ -70,13 +70,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-rc2.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc2.2" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.3" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.3" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.3" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.3" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.3" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.3" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -177,9 +177,12 @@ Libation.
tbx.MinWidth = vm.TextBlockMinWidth;
tbx.Text = message;
var thisScreen = owner.Screens.ScreenFromVisual(owner);
var thisScreen = owner.Screens?.ScreenFromVisual(owner);
var maxSize = new Size(0.20 * thisScreen.Bounds.Width, 0.9 * thisScreen.Bounds.Height - 55);
var maxSize
= thisScreen is null ? owner.ClientSize
: new Size(0.20 * thisScreen.Bounds.Width, 0.9 * thisScreen.Bounds.Height - 55);
var desiredMax = new Size(maxSize.Width, maxSize.Height);

View File

@@ -1,7 +1,6 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AppScaffolding;
@@ -35,30 +34,45 @@ namespace LibationAvalonia
$"\"{Configuration.ProcessDirectory}\"");
return;
}
AppDomain.CurrentDomain.UnhandledException += (o, e) => LogError(e.ExceptionObject);
bool loggingEnabled = false;
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
// Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration
var config = LibationScaffolding.RunPreConfigMigrations();
//Start as much work in parallel as possible.
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
if (config.LibationSettingsAreValid)
try
{
if (!RunDbMigrations(config))
return;
var config = LibationScaffolding.RunPreConfigMigrations();
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
//Start as much work in parallel as possible.
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
if (config.LibationSettingsAreValid)
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
loggingEnabled = true;
//Start loading the library before loading the main form
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
}
appBuilderTask.GetAwaiter().GetResult().SetupWithLifetime(classicLifetimeTask.GetAwaiter().GetResult());
classicLifetimeTask.Result.Start(null);
}
catch (Exception ex)
{
if (loggingEnabled)
Serilog.Log.Logger.Error(ex, "CRASH");
else
LogError(ex);
}
appBuilderTask.GetAwaiter().GetResult().SetupWithLifetime(classicLifetimeTask.GetAwaiter().GetResult());
classicLifetimeTask.Result.Start(null);
}
public static AppBuilder BuildAvaloniaApp()
@@ -67,20 +81,35 @@ namespace LibationAvalonia
.LogToTrace()
.UseReactiveUI();
public static bool RunDbMigrations(Configuration config)
private static void LogError(object exceptionObject)
{
try
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
var logError = $"""
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
return true;
}
catch (Exception exDebug)
var crashLog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log");
using var sw = new StreamWriter(crashLog, true);
sw.WriteLine(logError);
static string getConfigValue(Func<Configuration, string> selector)
{
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
return false;
try
{
return selector(Configuration.Instance);
}
catch (Exception ex)
{
return ex.ToString();
}
}
}
}

View File

@@ -1,7 +1,10 @@
using ApplicationServices;
using Avalonia.Threading;
using DataLayer;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
@@ -41,16 +44,17 @@ namespace LibationAvalonia.ViewModels
private void Configure_BackupCounts()
{
MainWindow.Loaded += setBackupCounts;
LibraryCommands.LibrarySizeChanged += setBackupCounts;
LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts;
MainWindow.LibraryLoaded += (_, e) => setBackupCounts(e.Where(l => !l.Book.IsEpisodeParent()));
LibraryCommands.LibrarySizeChanged += (_,_) => setBackupCounts();
LibraryCommands.BookUserDefinedItemCommitted += (_, _) => setBackupCounts();
}
private async void setBackupCounts(object _, object __)
private async void setBackupCounts(IEnumerable<LibraryBook> libraryBooks = null)
{
if (updateCountsTask?.IsCompleted ?? true)
{
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts());
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts(libraryBooks));
var stats = await updateCountsTask;
await Dispatcher.UIThread.InvokeAsync(() => LibraryStats = stats);

View File

@@ -264,9 +264,12 @@ namespace LibationAvalonia.ViewModels
private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e)
{
byte[] coverData = PictureStorage
.GetPictureSynchronously(
new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500));
var quality
= Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High
? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native)
: new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500);
byte[] coverData = PictureStorage.GetPictureSynchronously(quality);
AudioDecodable_CoverImageDiscovered(this, coverData);
return coverData;

View File

@@ -1,15 +1,18 @@
using ApplicationServices;
using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using LibationAvalonia.Dialogs.Login;
using LibationFileManager;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
@@ -27,8 +30,8 @@ namespace LibationAvalonia.ViewModels
public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; private set; }
private bool _removeColumnVisivle;
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
private bool _removeColumnVisible;
public bool RemoveColumnVisible { get => _removeColumnVisible; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisible, value); }
public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries?
@@ -91,37 +94,21 @@ namespace LibationAvalonia.ViewModels
#region Display Functions
internal void BindToGrid(List<LibraryBook> dbBooks)
internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{
GridEntries = new(SOURCE) { Filter = CollectionFilter };
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry<AvaloniaEntryStatus>(b))
.ToList<IGridEntry>();
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks);
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()).ToList();
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
foreach (var parent in seriesBooks)
{
var seriesEpisodes = episodes.FindChildren(parent);
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry<AvaloniaEntryStatus>(parent, seriesEpisodes);
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
}
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = geList.Union(geList.OfType<ISeriesEntry>().SelectMany(s => s.Children)).FilterEntries(FilterString);
SOURCE.AddRange(geList.OrderDescending(new RowComparer(null)));
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
//Adding entries to the Source list will invoke CollectionFilter
SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
//Add all children beneath their parent
foreach (var series in SOURCE.OfType<ISeriesEntry>().ToList())
foreach (var series in seriesEntries)
{
var seriesIndex = SOURCE.IndexOf(series);
foreach (var child in series.Children)
@@ -337,7 +324,7 @@ namespace LibationAvalonia.ViewModels
{
foreach (var item in SOURCE)
item.PropertyChanged -= GridEntry_PropertyChanged;
RemoveColumnVisivle = false;
RemoveColumnVisible = false;
}
public async Task RemoveCheckedBooksAsync()
@@ -392,7 +379,7 @@ namespace LibationAvalonia.ViewModels
item.PropertyChanged += GridEntry_PropertyChanged;
}
RemoveColumnVisivle = true;
RemoveColumnVisible = true;
RemovableCountChanged?.Invoke(this, 0);
try
@@ -437,5 +424,44 @@ namespace LibationAvalonia.ViewModels
}
#endregion
#region Column Widths
public DataGridLength TitleWidth { get => getColumnWidth("Title", 200); set => setColumnWidth("Title", value); }
public DataGridLength AuthorsWidth { get => getColumnWidth("Authors", 100); set => setColumnWidth("Authors", value); }
public DataGridLength NarratorsWidth { get => getColumnWidth("Narrators", 100); set => setColumnWidth("Narrators", value); }
public DataGridLength LengthWidth { get => getColumnWidth("Length", 80); set => setColumnWidth("Length", value); }
public DataGridLength SeriesWidth { get => getColumnWidth("Series", 100); set => setColumnWidth("Series", value); }
public DataGridLength SeriesOrderWidth { get => getColumnWidth("SeriesOrder", 60); set => setColumnWidth("SeriesOrder", value); }
public DataGridLength DescriptionWidth { get => getColumnWidth("Description", 100); set => setColumnWidth("Description", value); }
public DataGridLength CategoryWidth { get => getColumnWidth("Category", 100); set => setColumnWidth("Category", value); }
public DataGridLength ProductRatingWidth { get => getColumnWidth("ProductRating", 95); set => setColumnWidth("ProductRating", value); }
public DataGridLength PurchaseDateWidth { get => getColumnWidth("PurchaseDate", 75); set => setColumnWidth("PurchaseDate", value); }
public DataGridLength MyRatingWidth { get => getColumnWidth("MyRating", 95); set => setColumnWidth("MyRating", value); }
public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); }
public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); }
public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); }
private static DataGridLength getColumnWidth(string columnName, double defaultWidth)
=> Configuration.Instance.GridColumnsWidths.TryGetValue(columnName, out var val)
? new DataGridLength(val)
: new DataGridLength(defaultWidth);
private void setColumnWidth(string columnName, DataGridLength width, [CallerMemberName] string propertyName = "")
{
var dictionary = Configuration.Instance.GridColumnsWidths;
var newValue = (int)width.DisplayValue;
var valueSame = dictionary.TryGetValue(columnName, out var val) && val == newValue;
dictionary[columnName] = newValue;
if (!valueSame)
{
Configuration.Instance.GridColumnsWidths = dictionary;
this.RaisePropertyChanged(propertyName);
}
}
#endregion
}
}

View File

@@ -44,6 +44,7 @@ namespace LibationAvalonia.ViewModels.Settings
public void LoadSettings(Configuration config)
{
CreateCueSheet = config.CreateCueSheet;
CombineNestedChapterTitles = config.CombineNestedChapterTitles;
AllowLibationFixup = config.AllowLibationFixup;
DownloadCoverArt = config.DownloadCoverArt;
RetainAaxFile = config.RetainAaxFile;
@@ -71,6 +72,7 @@ namespace LibationAvalonia.ViewModels.Settings
public void SaveSettings(Configuration config)
{
config.CreateCueSheet = CreateCueSheet;
config.CombineNestedChapterTitles = CombineNestedChapterTitles;
config.AllowLibationFixup = AllowLibationFixup;
config.DownloadCoverArt = DownloadCoverArt;
config.RetainAaxFile = RetainAaxFile;
@@ -99,7 +101,10 @@ namespace LibationAvalonia.ViewModels.Settings
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality));
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
public string CombineNestedChapterTitlesText { get; } = Configuration.GetDescription(nameof(Configuration.CombineNestedChapterTitles));
public string CombineNestedChapterTitlesTip => Configuration.GetHelpText(nameof(CombineNestedChapterTitles));
public string AllowLibationFixupText { get; } = Configuration.GetDescription(nameof(Configuration.AllowLibationFixup));
public string AllowLibationFixupTip => Configuration.GetHelpText(nameof(AllowLibationFixup));
public string DownloadCoverArtText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadCoverArt));
public string RetainAaxFileText { get; } = Configuration.GetDescription(nameof(Configuration.RetainAaxFile));
public string SplitFilesByChapterText { get; } = Configuration.GetDescription(nameof(Configuration.SplitFilesByChapter));
@@ -110,6 +115,7 @@ namespace LibationAvalonia.ViewModels.Settings
public string MoveMoovToBeginningText { get; } = Configuration.GetDescription(nameof(Configuration.MoveMoovToBeginning));
public bool CreateCueSheet { get; set; }
public bool CombineNestedChapterTitles { get; set; }
public bool DownloadCoverArt { get; set; }
public bool RetainAaxFile { get; set; }
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }

View File

@@ -38,6 +38,7 @@ namespace LibationAvalonia.ViewModels.Settings
ChapterFileTemplate = config.ChapterFileTemplate;
InProgressDirectory = config.InProgress;
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
SaveMetadataToFile = config.SaveMetadataToFile;
}
public void SaveSettings(Configuration config)
@@ -54,9 +55,11 @@ namespace LibationAvalonia.ViewModels.Settings
config.InProgress = InProgressDirectory;
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
config.SaveMetadataToFile = SaveMetadataToFile;
}
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));
public string SaveMetadataToFileText { get; } = Configuration.GetDescription(nameof(Configuration.SaveMetadataToFile));
public string BadBookGroupboxText { get; } = Configuration.GetDescription(nameof(Configuration.BadBook));
public string BadBookAskText { get; } = Configuration.BadBookAction.Ask.GetDescription();
public string BadBookAbortText { get; } = Configuration.BadBookAction.Abort.GetDescription();
@@ -72,6 +75,7 @@ namespace LibationAvalonia.ViewModels.Settings
public string FileTemplate { get => _fileTemplate; set { this.RaiseAndSetIfChanged(ref _fileTemplate, value); } }
public string ChapterFileTemplate { get => _chapterFileTemplate; set { this.RaiseAndSetIfChanged(ref _chapterFileTemplate, value); } }
public bool UseCoverAsFolderIcon { get; set; }
public bool SaveMetadataToFile { get; set; }
public bool BadBookAsk { get; set; }
public bool BadBookAbort { get; set; }

View File

@@ -13,9 +13,11 @@ namespace LibationAvalonia.ViewModels.Settings
{
private string themeVariant;
private string initialThemeVariant;
private readonly Configuration config;
public ImportantSettingsVM(Configuration config)
{
this.config = config;
LoadSettings(config);
}
@@ -27,6 +29,8 @@ namespace LibationAvalonia.ViewModels.Settings
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
LastWriteTime = DateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? DateTimeSources[0];
LoggingLevel = config.LogLevel;
GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor);
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
ThemeVariant = initialThemeVariant
= Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) is nameof(Avalonia.Styling.ThemeVariant.Dark)
? nameof(Avalonia.Styling.ThemeVariant.Dark)
@@ -47,6 +51,16 @@ namespace LibationAvalonia.ViewModels.Settings
Configuration.Instance.SetString(ThemeVariant, nameof(ThemeVariant));
}
private static float scaleFactorToLinearRange(float scaleFactor)
=> float.Round(100 * MathF.Log2(scaleFactor));
private static float linearRangeToScaleFactor(float value)
=> MathF.Pow(2, value / 100f);
public void ApplyDisplaySettings()
{
config.GridFontScaleFactor = linearRangeToScaleFactor(GridFontScaleFactor);
config.GridScaleFactor = linearRangeToScaleFactor(GridScaleFactor);
}
public void OpenLogFolderButton() => Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
@@ -66,12 +80,16 @@ namespace LibationAvalonia.ViewModels.Settings
.Select(v => new EnumDiaplay<Configuration.DateTimeSource>(v))
.ToArray();
public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues<Serilog.Events.LogEventLevel>();
public string GridScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridScaleFactor));
public string GridFontScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridFontScaleFactor));
public string BetaOptInText { get; } = Configuration.GetDescription(nameof(Configuration.BetaOptIn));
public string[] Themes { get; } = { nameof(Avalonia.Styling.ThemeVariant.Light), nameof(Avalonia.Styling.ThemeVariant.Dark) };
public string BooksDirectory { get; set; }
public bool SavePodcastsToParentFolder { get; set; }
public bool OverwriteExisting { get; set; }
public float GridScaleFactor { get; set; }
public float GridFontScaleFactor { get; set; }
public EnumDiaplay<Configuration.DateTimeSource> CreationTime { get; set; }
public EnumDiaplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }

View File

@@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels"
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="200" MinWidth="64" MinHeight="64"
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="200" MinWidth="37" MinHeight="40"
Background="Transparent"
x:DataType="vm:LiberateStatusButtonViewModel"
x:Class="LibationAvalonia.Views.LiberateStatusButton">
@@ -35,48 +35,51 @@
Name="button"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsEnabled="{CompiledBinding IsButtonEnabled}" Padding="0" Click="Button_Click" >
<Panel>
<Panel
Width="64" Height="64"
IsVisible="{CompiledBinding !IsError}">
Padding="0"
IsEnabled="{CompiledBinding IsButtonEnabled}" Click="Button_Click" >
<Panel IsVisible="{CompiledBinding IsSeries}">
<Path IsVisible="{CompiledBinding Expanded}" Data="{StaticResource CollapseIcon}" />
<Path IsVisible="{CompiledBinding !Expanded}" Data="{StaticResource ExpandIcon}" />
<Grid RowDefinitions="*,8*,*">
<Viewbox
Grid.Row="1"
Stretch="Uniform"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Panel>
<Panel IsVisible="{CompiledBinding !IsError}">
<Panel IsVisible="{CompiledBinding IsSeries}">
<Path IsVisible="{CompiledBinding Expanded}" Data="{StaticResource CollapseIcon}" />
<Path IsVisible="{CompiledBinding !Expanded}" Data="{StaticResource ExpandIcon}" />
</Panel>
<Grid
IsVisible="{CompiledBinding !IsSeries}"
HorizontalAlignment="Center"
ColumnDefinitions="Auto,Auto">
<Canvas Width="29.44" Height="64">
<Rectangle Canvas.Left="5" Canvas.Top="5" IsVisible="{CompiledBinding RedVisible}" Fill="{DynamicResource StoplightRed}" />
<Rectangle Canvas.Left="5" Canvas.Top="23" IsVisible="{CompiledBinding YellowVisible}" Fill="{DynamicResource StoplightYellow}" />
<Rectangle Canvas.Left="5" Canvas.Top="42" IsVisible="{CompiledBinding GreenVisible}" Fill="{DynamicResource StoplightGreen}" />
<Path Height="64" Stretch="Uniform" Data="{StaticResource StoplightBodyIcon}"/>
</Canvas>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfDownloadedVisible}" Data="{StaticResource PdfDownloadedIcon}"/>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfNotDownloadedVisible}" Data="{StaticResource PdfNotDownloadedIcon}"/>
</Grid>
</Panel>
<Path
Stretch="None" Width="64"
IsVisible="{CompiledBinding IsError}"
Fill="{DynamicResource CancelRed}"
Data="{StaticResource BookErrorIcon}" />
<Path
Stretch="Fill"
IsVisible="{CompiledBinding !IsButtonEnabled}"
Fill="{DynamicResource DisabledGrayBrush}"
Data="M0,0 H1 V1 H0" />
</Panel>
<Grid
IsVisible="{CompiledBinding !IsSeries}"
HorizontalAlignment="Center"
ColumnDefinitions="Auto,Auto">
<Canvas Width="29.44" Height="64">
<Rectangle Canvas.Left="5" Canvas.Top="5" IsVisible="{CompiledBinding RedVisible}" Fill="{DynamicResource StoplightRed}" />
<Rectangle Canvas.Left="5" Canvas.Top="23" IsVisible="{CompiledBinding YellowVisible}" Fill="{DynamicResource StoplightYellow}" />
<Rectangle Canvas.Left="5" Canvas.Top="42" IsVisible="{CompiledBinding GreenVisible}" Fill="{DynamicResource StoplightGreen}" />
<Path Height="64" Stretch="Uniform" Data="{StaticResource StoplightBodyIcon}"/>
</Canvas>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfDownloadedVisible}" Data="{StaticResource PdfDownloadedIcon}"/>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfNotDownloadedVisible}" Data="{StaticResource PdfNotDownloadedIcon}"/>
</Grid>
</Panel>
<Path
Stretch="None" Width="64"
IsVisible="{CompiledBinding IsError}"
Fill="{DynamicResource CancelRed}"
Data="{StaticResource BookErrorIcon}" />
<Path
Stretch="Fill"
IsVisible="{CompiledBinding !IsButtonEnabled}"
Fill="{DynamicResource DisabledGrayBrush}"
Data="M0,0 H1 V1 H0" />
</Panel>
</Viewbox>
</Grid>
</Button>
</UserControl>

View File

@@ -21,7 +21,7 @@ namespace LibationAvalonia.Views
InitializeComponent();
Configure_Upgrade();
Loaded += MainWindow_Loaded;
Opened += MainWindow_Opened;
Closing += MainWindow_Closing;
LibraryLoaded += MainWindow_LibraryLoaded;
@@ -35,7 +35,7 @@ namespace LibationAvalonia.Views
}
}
private async void MainWindow_Loaded(object sender, EventArgs e)
private async void MainWindow_Opened(object sender, EventArgs e)
{
if (Configuration.Instance.FirstLaunch)
{
@@ -61,7 +61,7 @@ namespace LibationAvalonia.Views
if (QuickFilters.UseDefault)
await ViewModel.PerformFilter(QuickFilters.Filters.FirstOrDefault());
ViewModel.ProductsDisplay.BindToGrid(dbBooks);
await ViewModel.ProductsDisplay.BindToGridAsync(dbBooks);
}
private void selectAndFocusSearchBox()

View File

@@ -17,12 +17,10 @@
AutoGenerateColumns="False"
ItemsSource="{Binding GridEntries}"
CanUserSortColumns="True" BorderThickness="3"
CanUserResizeColumns="True"
CanUserReorderColumns="True">
<DataGrid.Styles>
<Style Selector="DataGridCell">
<Setter Property="Height" Value="80"/>
</Style>
<Style Selector="DataGridCell > Panel">
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>
@@ -31,7 +29,6 @@
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="DataGridCell Path">
<Setter Property="Stretch" Value="Uniform" />
@@ -49,10 +46,11 @@
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTemplateColumn
CanUserSort="True"
IsVisible="{Binding RemoveColumnVisivle}"
CanUserResize="False"
IsVisible="{Binding RemoveColumnVisible}"
PropertyChanged="RemoveColumn_PropertyChanged"
Header="Remove"
IsReadOnly="False"
@@ -69,7 +67,7 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="75" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<views:LiberateStatusButton
@@ -84,25 +82,25 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<DataGridTemplateColumn CanUserSort="False" Width="80" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Height="80" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
<TextBlock FontSize="14" Text="{CompiledBinding Title}" />
<TextBlock Classes="h1" Text="{CompiledBinding Title}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -112,7 +110,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -122,7 +120,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -132,7 +130,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -142,7 +140,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="Auto" Header="Series&#xA;Order" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}">
<controls:DataGridTemplateColumnExt Header="Series&#xA;Order" MinWidth="10" Width="{Binding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -152,17 +150,17 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}">
<controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<TextBlock Text="{CompiledBinding Description}" FontSize="11" VerticalAlignment="Top" />
<TextBlock Text="{CompiledBinding Description}" VerticalAlignment="Top" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -176,14 +174,14 @@
x:DataType="uibase:IGridEntry"
Header="Product&#xA;Rating"
IsReadOnly="true"
Width="115"
MinWidth="10" Width="{Binding ProductRatingWidth, Mode=TwoWay}"
SortMemberPath="ProductRating" CanUserSort="True"
OpacityBinding="{CompiledBinding Liberate.Opacity}"
BackgroundBinding="{CompiledBinding Liberate.BackgroundBrush}"
ClipboardContentBinding="{CompiledBinding ProductRating}"
Binding="{CompiledBinding ProductRating}" />
<controls:DataGridTemplateColumnExt Width="90" Header="Purchase&#xA;Date" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<controls:DataGridTemplateColumnExt Header="Purchase&#xA;Date" MinWidth="10" Width="{Binding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -195,45 +193,62 @@
<controls:DataGridMyRatingColumn
x:DataType="uibase:IGridEntry"
Header="My Rating"
Header="My Rating"
IsReadOnly="false"
Width="115"
MinWidth="10" Width="{Binding MyRatingWidth, Mode=TwoWay}"
SortMemberPath="MyRating" CanUserSort="True"
OpacityBinding="{CompiledBinding Liberate.Opacity}"
BackgroundBinding="{CompiledBinding Liberate.BackgroundBrush}"
ClipboardContentBinding="{CompiledBinding MyRating}"
Binding="{CompiledBinding MyRating, Mode=TwoWay}" />
<controls:DataGridTemplateColumnExt Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{Binding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
<TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" FontSize="10" />
<TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="102" Header="Last&#xA;Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{Binding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
<TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" FontSize="10" />
<TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Button IsVisible="{CompiledBinding !Liberate.IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<Panel Width="24" Height="24" IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Path Stretch="Uniform" Fill="{DynamicResource IconFill}" Data="{StaticResource EditTagsIcon}" />
</Panel>
<TextBlock IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" FontSize="12" TextWrapping="WrapWithOverflow" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{CompiledBinding BookTags}"/>
</Panel>
<Button
IsVisible="{CompiledBinding !Liberate.IsSeries}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Click="OnTagsButtonClick"
ToolTip.Tip="Click to edit tags">
<Grid
RowDefinitions="*,*,*"
Opacity="{CompiledBinding Liberate.Opacity}">
<Viewbox
Grid.Row="1"
Stretch="Uniform"
IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Path Fill="{DynamicResource IconFill}" Data="{StaticResource EditTagsIcon}" />
</Viewbox>
<TextBlock
Classes="h2"
Grid.RowSpan="3"
IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" TextWrapping="WrapWithOverflow" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{CompiledBinding BookTags}"/>
</Grid>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>

View File

@@ -2,13 +2,16 @@ using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationAvalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.GridView;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -30,6 +33,25 @@ namespace LibationAvalonia.Views
InitializeComponent();
DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded;
var cellSelector = Selectors.Is<DataGridCell>(null);
rowHeightStyle = new Style(_ => cellSelector);
rowHeightStyle.Setters.Add(rowHeightSetter);
var tboxSelector = cellSelector.Descendant().Is<TextBlock>();
fontSizeStyle = new Style(_ => tboxSelector);
fontSizeStyle.Setters.Add(fontSizeSetter);
var tboxH1Selector = cellSelector.Child().Is<Panel>().Child().Is<TextBlock>().Class("h1");
fontSizeH1Style = new Style(_ => tboxH1Selector);
fontSizeH1Style.Setters.Add(fontSizeH1Setter);
var tboxH2Selector = cellSelector.Child().Is<Panel>().Child().Is<TextBlock>().Class("h2");
fontSizeH2Style = new Style(_ => tboxH2Selector);
fontSizeH2Style.Setters.Add(fontSizeH2Setter);
Configuration.Instance.PropertyChanged += Configuration_GridScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontChanged;
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
@@ -51,12 +73,16 @@ namespace LibationAvalonia.Views
catch { sampleEntries = new(); }
var pdvm = new ProductsDisplayViewModel();
pdvm.BindToGrid(sampleEntries);
_ = pdvm.BindToGridAsync(sampleEntries);
DataContext = pdvm;
setGridScale(1);
setFontScale(1);
return;
}
setGridScale(Configuration.Instance.GridScaleFactor);
setFontScale(Configuration.Instance.GridFontScaleFactor);
Configure_ColumnCustomization();
foreach (var column in productsGrid.Columns)
@@ -67,91 +93,150 @@ namespace LibationAvalonia.Views
private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
{
if (sender is DataGridColumn col && e.Property.Name == nameof(DataGridColumn.IsVisible))
if (sender is DataGridColumn col && e.Property == DataGridColumn.IsVisibleProperty)
{
col.DisplayIndex = 0;
col.CanUserReorder = false;
}
}
#region Scaling
[PropertyChangeFilter(nameof(Configuration.GridScaleFactor))]
private void Configuration_GridScaleChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e)
{
setGridScale((float)e.NewValue);
}
[PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))]
private void Configuration_FontChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e)
{
setFontScale((float)e.NewValue);
}
private readonly Style rowHeightStyle;
private readonly Setter rowHeightSetter = new() { Property = DataGridCell.HeightProperty };
private readonly Style fontSizeStyle;
private readonly Setter fontSizeSetter = new() { Property = TextBlock.FontSizeProperty };
private readonly Style fontSizeH1Style;
private readonly Setter fontSizeH1Setter = new() { Property = TextBlock.FontSizeProperty };
private readonly Style fontSizeH2Style;
private readonly Setter fontSizeH2Setter = new() { Property = TextBlock.FontSizeProperty };
private void setFontScale(double scaleFactor)
{
const double TextBlockFontSize = 11;
const double H1FontSize = 14;
const double H2FontSize = 12;
fontSizeSetter.Value = TextBlockFontSize * scaleFactor;
fontSizeH1Setter.Value = H1FontSize * scaleFactor;
fontSizeH2Setter.Value = H2FontSize * scaleFactor;
productsGrid.Styles.Remove(fontSizeStyle);
productsGrid.Styles.Remove(fontSizeH1Style);
productsGrid.Styles.Remove(fontSizeH2Style);
productsGrid.Styles.Add(fontSizeStyle);
productsGrid.Styles.Add(fontSizeH1Style);
productsGrid.Styles.Add(fontSizeH2Style);
}
private void setGridScale(double scaleFactor)
{
const float BaseRowHeight = 80;
const float BaseLiberateWidth = 75;
const float BaseCoverWidth = 80;
foreach (var column in productsGrid.Columns)
{
switch (column.SortMemberPath)
{
case nameof(IGridEntry.Liberate):
column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor);
break;
case nameof(IGridEntry.Cover):
column.Width = new DataGridLength(BaseCoverWidth * scaleFactor);
break;
}
}
rowHeightSetter.Value = BaseRowHeight * scaleFactor;
productsGrid.Styles.Remove(rowHeightStyle);
productsGrid.Styles.Add(rowHeightStyle);
}
#endregion
#region Cell Context Menu
public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args)
{
// stop light
if (args.Column.SortMemberPath == "Liberate")
var entry = args.GridEntry;
var ctx = new GridContextMenu(entry, '_');
if (args.Column.SortMemberPath is not "Liberate" and not "Cover")
{
var entry = args.GridEntry;
#region Liberate all Episodes
if (entry.Liberate.IsSeries)
args.ContextMenuItems.Add(new MenuItem
{
var liberateEpisodesMenuItem = new MenuItem()
{
Header = "_Liberate All Episodes",
IsEnabled = ((ISeriesEntry)entry).Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
};
Header = ctx.CopyCellText,
Command = ReactiveCommand.CreateFromTask(() => App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents))
});
args.ContextMenuItems.Add(new Separator());
}
args.ContextMenuItems.Add(liberateEpisodesMenuItem);
#region Liberate all Episodes
liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, ((ISeriesEntry)entry));
}
#endregion
#region Set Download status to Downloaded
var setDownloadMenuItem = new MenuItem()
if (entry.Liberate.IsSeries)
{
args.ContextMenuItems.Add(new MenuItem()
{
Header = "Set Download status to '_Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || entry.Liberate.IsSeries
};
Header = ctx.LiberateEpisodesText,
IsEnabled = ctx.LiberateEpisodesEnabled,
Command = ReactiveCommand.Create(() => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry))
});
}
args.ContextMenuItems.Add(setDownloadMenuItem);
#endregion
#region Set Download status to Downloaded
if (entry.Liberate.IsSeries)
setDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated);
else
setDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated);
args.ContextMenuItems.Add(new MenuItem()
{
Header = ctx.SetDownloadedText,
IsEnabled = ctx.SetDownloadedEnabled,
Command = ReactiveCommand.Create(ctx.SetDownloaded)
});
#endregion
#region Set Download status to Not Downloaded
#endregion
#region Set Download status to Not Downloaded
var setNotDownloadMenuItem = new MenuItem()
args.ContextMenuItems.Add(new MenuItem()
{
Header = ctx.SetNotDownloadedText,
IsEnabled = ctx.SetNotDownloadedEnabled,
Command = ReactiveCommand.Create(ctx.SetNotDownloaded)
});
#endregion
#region Remove from library
args.ContextMenuItems.Add(new MenuItem
{
Header = ctx.RemoveText,
Command = ReactiveCommand.CreateFromTask(ctx.RemoveAsync)
});
#endregion
if (!entry.Liberate.IsSeries)
{
#region Locate file
args.ContextMenuItems.Add(new MenuItem
{
Header = "Set Download status to '_Not Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || entry.Liberate.IsSeries
};
args.ContextMenuItems.Add(setNotDownloadMenuItem);
if (entry.Liberate.IsSeries)
setNotDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated);
else
setNotDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated);
#endregion
#region Remove from library
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
args.ContextMenuItems.Add(removeMenuItem);
if (entry.Liberate.IsSeries)
removeMenuItem.Click += async (_, __) => await ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).RemoveBooksAsync();
else
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
#endregion
if (!entry.Liberate.IsSeries)
{
#region Locate file
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
args.ContextMenuItems.Add(locateFileMenuItem);
locateFileMenuItem.Click += async (_, __) =>
Header = ctx.LocateFileText,
Command = ReactiveCommand.CreateFromTask(async () =>
{
try
{
@@ -159,12 +244,12 @@ namespace LibationAvalonia.Views
var openFileDialogOptions = new FilePickerOpenOptions
{
Title = $"Locate the audio file for '{entry.Book.TitleWithSubtitle}'",
Title = ctx.LocateFileDialogTitle,
AllowMultiple = false,
SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix),
FileTypeFilter = new FilePickerFileType[]
{
new("All files (*.*)") { Patterns = new[] { "*" } },
new("All files (*.*)") { Patterns = new[] { "*" } },
}
};
@@ -176,65 +261,107 @@ namespace LibationAvalonia.Views
}
catch (Exception ex)
{
var msg = "Error saving book's location";
await MessageBox.ShowAdminAlert(null, msg, msg, ex);
await MessageBox.ShowAdminAlert(null, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex);
}
};
#endregion
#region Convert to Mp3
var convertToMp3MenuItem = new MenuItem
{
Header = "_Convert to Mp3",
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
args.ContextMenuItems.Add(convertToMp3MenuItem);
convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook);
#endregion
}
args.ContextMenuItems.Add(new Separator());
#region View Bookmarks/Clips
if (!entry.Liberate.IsSeries)
{
var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" };
args.ContextMenuItems.Add(bookRecordMenuItem);
bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window);
}
})
});
#endregion
#region View All Series
if (entry.Book.SeriesLink.Any())
#region Convert to Mp3
args.ContextMenuItems.Add(new MenuItem
{
var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
var viewSeriesMenuItem = new MenuItem { Header = header };
args.ContextMenuItems.Add(viewSeriesMenuItem);
viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show();
}
Header = ctx.ConvertToMp3Text,
IsEnabled = ctx.ConvertToMp3Enabled,
Command = ReactiveCommand.Create(() => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook))
});
#endregion
}
else
#region Force Re-Download
if (!entry.Liberate.IsSeries)
{
// any non-stop light column
// (except for the Cover column which does not have a context menu)
var menuItem = new MenuItem { Header = "_Copy Cell Contents" };
menuItem.Click += async (s, e)
=> await App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents);
args.ContextMenuItems.Add(menuItem);
args.ContextMenuItems.Add(new MenuItem()
{
Header = ctx.ReDownloadText,
IsEnabled = ctx.ReDownloadEnabled,
Command = ReactiveCommand.Create(() =>
{
//No need to persist this change. It only needs to last long for the file to start downloading
entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated;
LiberateClicked?.Invoke(this, entry.LibraryBook);
})
});
}
#endregion
args.ContextMenuItems.Add(new Separator());
#region Edit Templates
async Task editTemplate<T>(LibraryBook libraryBook, string existingTemplate, Action<string> setNewTemplate)
where T : Templates, LibationFileManager.ITemplate, new()
{
var template = ctx.CreateTemplateEditor<T>(libraryBook, existingTemplate);
var form = new EditTemplateDialog(template);
if (await form.ShowDialog<DialogResult>(this.GetParentWindow()) == DialogResult.OK)
{
setNewTemplate(template.EditingTemplate.TemplateText);
}
}
if (!entry.Liberate.IsSeries)
{
args.ContextMenuItems.Add(new MenuItem
{
Header = ctx.EditTemplatesText,
ItemsSource = new[]
{
new MenuItem
{
Header = ctx.FolderTemplateText,
Command = ReactiveCommand.CreateFromTask(() => editTemplate<Templates.FolderTemplate>(entry.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t))
},
new MenuItem
{
Header = ctx.FileTemplateText,
Command = ReactiveCommand.CreateFromTask(() => editTemplate<Templates.FileTemplate>(entry.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t))
},
new MenuItem
{
Header = ctx.MultipartTemplateText,
Command = ReactiveCommand.CreateFromTask(() => editTemplate<Templates.ChapterFileTemplate>(entry.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t))
}
}
});
args.ContextMenuItems.Add(new Separator());
}
#endregion
#region View Bookmarks/Clips
if (!entry.Liberate.IsSeries)
{
args.ContextMenuItems.Add(new MenuItem
{
Header = ctx.ViewBookmarksText,
Command = ReactiveCommand.CreateFromTask(() => new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window))
});
}
#endregion
#region View All Series
if (entry.Book.SeriesLink.Any())
{
args.ContextMenuItems.Add(new MenuItem
{
Header = ctx.ViewSeriesText,
Command = ReactiveCommand.Create(() => new SeriesViewDialog(entry.LibraryBook).Show())
});
}
#endregion
}
#endregion
@@ -428,7 +555,7 @@ namespace LibationAvalonia.Views
var displayWindow = new DescriptionDisplayDialog
{
SpawnLocation = new Point(pt.X, pt.Y),
DescriptionText = gEntry.LongDescription,
DescriptionText = gEntry.Description,
};
void CloseWindow(object o, DataGridRowEventArgs e)

View File

@@ -3,7 +3,6 @@ using AudibleUtilities;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Dinah.Core.StepRunner;
@@ -13,7 +12,6 @@ using LibationFileManager;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using static Avalonia.Threading.Dispatcher;
namespace LibationAvalonia
{
@@ -36,12 +34,13 @@ namespace LibationAvalonia
AutoScan = Configuration.Instance.AutoScan;
Configuration.Instance.AutoScan = false;
MainForm = mainForm;
sequence[nameof(ShowAccountDialog)] = () => UIThread.InvokeAsync(ShowAccountDialog);
sequence[nameof(ShowSettingsDialog)] = () => UIThread.InvokeAsync(ShowSettingsDialog);
sequence[nameof(ShowAccountScanning)] = () => UIThread.InvokeAsync(ShowAccountScanning);
sequence[nameof(ShowSearching)] = () => UIThread.InvokeAsync(ShowSearching);
sequence[nameof(ShowQuickFilters)] = () => UIThread.InvokeAsync(ShowQuickFilters);
sequence[nameof(ShowTourComplete)] = () => UIThread.InvokeAsync(ShowTourComplete);
var uiDispatcher = Avalonia.Threading.Dispatcher.UIThread;
sequence[nameof(ShowAccountDialog)] = () => uiDispatcher.InvokeAsync(ShowAccountDialog);
sequence[nameof(ShowSettingsDialog)] = () => uiDispatcher.InvokeAsync(ShowSettingsDialog);
sequence[nameof(ShowAccountScanning)] = () => uiDispatcher.InvokeAsync(ShowAccountScanning);
sequence[nameof(ShowSearching)] = () => uiDispatcher.InvokeAsync(ShowSearching);
sequence[nameof(ShowQuickFilters)] = () => uiDispatcher.InvokeAsync(ShowQuickFilters);
sequence[nameof(ShowTourComplete)] = () => uiDispatcher.InvokeAsync(ShowTourComplete);
}
public async Task RunAsync()
@@ -60,7 +59,7 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.accountsToolStripMenuItem);
var accountSettings = new AccountsDialog();
accountSettings.Loaded += async (_, _) => await MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account");
accountSettings.Opened += async (_, _) => await MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account");
await accountSettings.ShowDialog(MainForm);
return true;
}
@@ -74,14 +73,14 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.settingsToolStripMenuItem);
await displayControlAsync(MainForm.basicSettingsToolStripMenuItem);
var settingsDialog = await UIThread.InvokeAsync(() => new SettingsDialog());
var settingsDialog = new SettingsDialog();
var tabsToVisit = settingsDialog.tabControl.Items.OfType<TabItem>().ToList();
foreach (var tab in tabsToVisit)
tab.PropertyChanged += TabControl_PropertyChanged;
settingsDialog.Loaded += SettingsDialog_Loaded;
settingsDialog.Opened += SettingsDialog_Opened;
settingsDialog.Closing += SettingsDialog_FormClosing;
settingsDialog.saveBtn.Content = "Next Tab";
@@ -103,7 +102,7 @@ namespace LibationAvalonia
settingTabMessages.Remove(header.Text);
}
async void SettingsDialog_Loaded(object sender, RoutedEventArgs e)
async void SettingsDialog_Opened(object sender, System.EventArgs e)
{
await ShowTabPageMessageBoxAsync(tabsToVisit[0]);
}
@@ -227,7 +226,7 @@ namespace LibationAvalonia
await displayControlAsync(editQuickFiltersToolStripMenuItem);
var editQuickFilters = new EditQuickFilters();
editQuickFilters.Loaded += async (_, _) => await MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters");
editQuickFilters.Opened += async (_, _) => await MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters");
await editQuickFilters.ShowDialog(MainForm);
return true;
@@ -247,12 +246,12 @@ namespace LibationAvalonia
private async Task displayControlAsync(TemplatedControl control)
{
await UIThread.InvokeAsync(() => control.IsEnabled = false);
await UIThread.InvokeAsync(() => MainForm.productsDisplay.Focus());
await UIThread.InvokeAsync(() => flashControlAsync(control));
if (control is MenuItem menuItem) await UIThread.InvokeAsync(menuItem.Open);
control.IsEnabled = false;
MainForm.productsDisplay.Focus();
await flashControlAsync(control);
if (control is MenuItem menuItem) menuItem.Open();
await Task.Delay(500);
await UIThread.InvokeAsync(() => control.IsEnabled = true);
control.IsEnabled = true;
}
private static async Task flashControlAsync(TemplatedControl control, int flashCount = 3)

View File

@@ -9,11 +9,12 @@ using System.Threading.Tasks;
using System.Threading;
using FileManager;
#nullable enable
namespace LibationFileManager
{
public abstract class AudibleFileStorage
{
protected abstract LongPath GetFilePathCustom(string productId);
protected abstract LongPath? GetFilePathCustom(string productId);
protected abstract List<LongPath> GetFilePathsCustom(string productId);
#region static
@@ -57,7 +58,7 @@ namespace LibationFileManager
regexTemplate = $@"{{0}}.*?\.({extAggr})$";
}
protected LongPath GetFilePath(string productId)
protected LongPath? GetFilePath(string productId)
{
// primary lookup
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
@@ -87,7 +88,7 @@ namespace LibationFileManager
{
internal AaxcFileStorage() : base(FileType.AAXC) { }
protected override LongPath GetFilePathCustom(string productId)
protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
@@ -104,9 +105,9 @@ namespace LibationFileManager
public class AudioFileStorage : AudibleFileStorage
{
internal AudioFileStorage() : base(FileType.Audio)
=> BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
=> BookDirectoryFiles ??= newBookDirectoryFiles();
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
private static BackgroundFileSystem? BookDirectoryFiles { get; set; }
private static object bookDirectoryFilesLocker { get; } = new();
private static EnumerationOptions enumerationOptions { get; } = new()
{
@@ -115,17 +116,20 @@ namespace LibationFileManager
MatchCasing = MatchCasing.CaseInsensitive
};
protected override LongPath GetFilePathCustom(string productId)
protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
private static BackgroundFileSystem newBookDirectoryFiles()
=> new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
protected override List<LongPath> GetFilePathsCustom(string productId)
{
// If user changed the BooksDirectory: reinitialize
lock (bookDirectoryFilesLocker)
if (BooksDirectory != BookDirectoryFiles.RootDirectory)
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
if (BooksDirectory != BookDirectoryFiles?.RootDirectory)
BookDirectoryFiles = newBookDirectoryFiles();
var regex = GetBookSearchRegex(productId);
var regex = GetBookSearchRegex(productId);
//Find all extant files matching the productId
//using both the file system and the file path cache
@@ -138,9 +142,16 @@ namespace LibationFileManager
.ToList();
}
public void Refresh() => BookDirectoryFiles.RefreshFiles();
public void Refresh()
{
if (BookDirectoryFiles is null)
lock (bookDirectoryFilesLocker)
BookDirectoryFiles = newBookDirectoryFiles();
else
BookDirectoryFiles?.RefreshFiles();
}
public LongPath GetPath(string productId) => GetFilePath(productId);
public LongPath? GetPath(string productId) => GetFilePath(productId);
public static async IAsyncEnumerable<FilePathCache.CacheEntry> FindAudiobooksAsync(LongPath searchDirectory, [EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -151,7 +162,7 @@ namespace LibationFileManager
if (cancellationToken.IsCancellationRequested)
yield break;
FilePathCache.CacheEntry audioFile = default;
FilePathCache.CacheEntry? audioFile = default;
try
{

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
#nullable enable
namespace LibationFileManager
{
[Flags]
@@ -20,7 +21,7 @@ namespace LibationFileManager
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
public static Version LibationVersion { get; private set; }
public static Version? LibationVersion { get; private set; }
public static void SetLibationVersion(Version version) => LibationVersion = version;
public static OS OS { get; }

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
public static ReadOnlyDictionary<string, string> HelpText { get; } = new Dictionary<string, string>
{
{ nameof(CombineNestedChapterTitles),"""
If the book has nested chapters, e.g. a chapter named "Part 1"
that contains chapters "Chapter 1" and "Chapter 2", then combine
the chapter titles like the following example:
Part 1: Chapter 1
Part 1: Chapter 2
"""},
{nameof(AllowLibationFixup), """
In addition to the options that are enabled if you allow
"fixing up" the audiobook, it does the following:
* Sets the ©gen metadata tag for the genres.
* Adds the TCOM (@wrt in M4B files) metadata tag for the narrators.
* Unescapes the copyright symbol (replace &#169; with ©)
* Replaces the recording copyright (P) string with
* Adds various other metadata tags recognized by AudiobookShelf
* Sets the embedded cover art image with cover art retrieved from Audible
""" },
}
.AsReadOnly();
public static string? GetHelpText(string settingName)
=> HelpText.TryGetValue(settingName, out var value) ? value : null;
}
}

View File

@@ -5,11 +5,12 @@ using System.IO;
using System.Linq;
using Dinah.Core;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
public static string ProcessDirectory { get; } = Path.GetDirectoryName(Exe.FileLocationOnDisk);
public static string ProcessDirectory { get; } = Path.GetDirectoryName(Exe.FileLocationOnDisk)!;
public static string AppDir_Relative => $@".{Path.PathSeparator}{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
@@ -36,7 +37,7 @@ namespace LibationFileManager
LibationFiles = 5
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
private static List<(KnownDirectories directory, Func<string?> getPathFunc)> directoryOptionsPaths { get; } = new()
{
(KnownDirectories.None, () => null),
(KnownDirectories.UserProfile, () => UserProfile),
@@ -47,7 +48,7 @@ namespace LibationFileManager
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
};
public static string GetKnownDirectoryPath(KnownDirectories directory)
public static string? GetKnownDirectoryPath(KnownDirectories directory)
{
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
return dirFunc == default ? null : dirFunc.getPathFunc();

View File

@@ -7,6 +7,7 @@ using Newtonsoft.Json;
using Serilog;
using Dinah.Core.Logging;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
@@ -44,7 +45,7 @@ namespace LibationFileManager
}
}
private static string libationFilesPathCache { get; set; }
private static string? libationFilesPathCache { get; set; }
/// <summary>
/// Try to find appsettings.json in the following locations:
@@ -124,7 +125,10 @@ namespace LibationFileManager
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
// verify from live file. no try/catch. want failures to be visible
var jObjFinal = JObject.Parse(File.ReadAllText(AppsettingsJsonFile));
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
if (jObjFinal[LIBATION_FILES_KEY]?.Value<string>() is not string valueFinal)
throw new InvalidDataException($"{LIBATION_FILES_KEY} not found in {AppsettingsJsonFile}");
return valueFinal;
}

View File

@@ -1,19 +1,18 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Dinah.Core;
using Dinah.Core.Logging;
using FileManager;
using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Events;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
private IConfigurationRoot configuration;
private IConfigurationRoot? configuration;
public void ConfigureLogging()
{
@@ -31,20 +30,20 @@ namespace LibationFileManager
{
get
{
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
var logLevelStr = Settings.GetStringFromJsonPath("Serilog", "MinimumLevel");
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
}
set
{
OnPropertyChanging(nameof(LogLevel), LogLevel, value);
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
var valueWasChanged = Settings.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Debug("LogLevel.set attempt. No change");
return;
}
configuration.Reload();
configuration?.Reload();
OnPropertyChanged(nameof(LogLevel), value);

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -8,6 +9,7 @@ using FileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
@@ -18,32 +20,52 @@ namespace LibationFileManager
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
private PersistentDictionary persistentDictionary;
private PersistentDictionary? persistentDictionary;
public T GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "") => persistentDictionary.GetNonString(propertyName, defaultValue);
public object GetObject([CallerMemberName] string propertyName = "") => persistentDictionary.GetObject(propertyName);
public string GetString(string defaultValue = null, [CallerMemberName] string propertyName = "") => persistentDictionary.GetString(propertyName, defaultValue);
public void SetNonString(object newValue, [CallerMemberName] string propertyName = "")
private PersistentDictionary Settings
{
get
{
if (persistentDictionary is null)
throw new InvalidOperationException($"{nameof(persistentDictionary)} must first be set by accessing {nameof(LibationFiles)} or calling {nameof(SettingsFileIsValid)}");
return persistentDictionary;
}
}
public bool RemoveProperty(string propertyName) => Settings.RemoveProperty(propertyName);
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "")
=> Settings.GetNonString(propertyName, defaultValue);
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string? defaultValue = null, [CallerMemberName] string propertyName = "")
=> Settings.GetString(propertyName, defaultValue);
public object? GetObject([CallerMemberName] string propertyName = "") => Settings.GetObject(propertyName);
public void SetNonString(object? newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
if (existing?.Equals(newValue) is true) return;
OnPropertyChanging(propertyName, existing, newValue);
persistentDictionary.SetNonString(propertyName, newValue);
Settings.SetNonString(propertyName, newValue);
OnPropertyChanged(propertyName, newValue);
}
public void SetString(string newValue, [CallerMemberName] string propertyName = "")
public void SetString(string? newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
if (existing?.Equals(newValue) is true) return;
OnPropertyChanging(propertyName, existing, newValue);
persistentDictionary.SetString(propertyName, newValue);
Settings.SetString(propertyName, newValue);
OnPropertyChanged(propertyName, newValue);
}
private object getExistingValue(string propertyName)
private object? getExistingValue(string propertyName)
{
var property = GetType().GetProperty(propertyName);
if (property is not null) return property.GetValue(this);
@@ -51,16 +73,16 @@ namespace LibationFileManager
}
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
public void SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
var settingWasChanged = Settings.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public static string GetDescription(string propertyName)
public static string? GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
@@ -71,16 +93,25 @@ namespace LibationFileManager
return attribute?.Description;
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
public bool Exists(string propertyName) => Settings.Exists(propertyName);
[Description("Set cover art as the folder's icon. (Windows and macOS only)")]
[Description("Set cover art as the folder's icon.")]
public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Save audiobook metadata to metadata.json")]
public bool SaveMetadataToFile { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Book display grid size")]
public float GridScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); }
[Description("Book display font size")]
public float GridFontScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); }
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")]
public LongPath Books { get => GetString(); set => SetString(value); }
public LongPath? Books { get => GetString(); set => SetString(value); }
[Description("Overwrite existing files if they already exist?")]
public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); }
@@ -164,6 +195,9 @@ namespace LibationFileManager
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Combine nested chapter titles")]
public bool CombineNestedChapterTitles { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Download clips and bookmarks?")]
public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); }

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
@@ -9,17 +10,17 @@ namespace LibationFileManager
* and be sure to clone it before returning. This allows Configuration to
* accurately detect if any of the Dictionary's elements have changed.
*/
private class EquatableDictionary<TKey, TValue> : Dictionary<TKey, TValue>
private class EquatableDictionary<TKey, TValue> : Dictionary<TKey, TValue> where TKey : notnull
{
public EquatableDictionary() { }
public EquatableDictionary(IEnumerable<KeyValuePair<TKey, TValue>> keyValuePairs) : base(keyValuePairs) { }
public EquatableDictionary<TKey, TValue> Clone() => new(this);
public override bool Equals(object obj)
public override bool Equals(object? obj)
{
if (obj is Dictionary<TKey, TValue> dic && Count == dic.Count)
{
foreach (var pair in this)
if (!dic.TryGetValue(pair.Key, out var value) || !pair.Value.Equals(value))
if (!dic.TryGetValue(pair.Key, out var value) || pair.Value?.Equals(value) is not true)
return false;
return true;

View File

@@ -4,7 +4,7 @@ using System.Linq;
using Dinah.Core;
using FileManager;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
@@ -24,9 +24,12 @@ namespace LibationFileManager
if (!Directory.Exists(booksDir))
{
if (Path.GetDirectoryName(settingsFile) is not string dir)
throw new DirectoryNotFoundException(settingsFile);
//"Books" is not null, so setup has already been run.
//Since Books can't be found, try to create it in Libation settings folder
booksDir = Path.Combine(Path.GetDirectoryName(settingsFile), nameof(Books));
booksDir = Path.Combine(dir, nameof(Books));
try
{
Directory.CreateDirectory(booksDir);

View File

@@ -6,6 +6,7 @@ using Dinah.Core.Collections.Immutable;
using FileManager;
using Newtonsoft.Json;
#nullable enable
namespace LibationFileManager
{
public static class FilePathCache
@@ -14,8 +15,8 @@ namespace LibationFileManager
private const string FILENAME = "FileLocations.json";
public static event EventHandler<CacheEntry> Inserted;
public static event EventHandler<CacheEntry> Removed;
public static event EventHandler<CacheEntry>? Inserted;
public static event EventHandler<CacheEntry>? Removed;
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
@@ -51,16 +52,16 @@ namespace LibationFileManager
.Select(entry => (entry.FileType, entry.Path))
.ToList();
public static LongPath GetFirstPath(string id, FileType type)
public static LongPath? GetFirstPath(string id, FileType type)
=> getEntries(entry => entry.Id == id && entry.FileType == type)
?.FirstOrDefault()
?.Path;
private static List<CacheEntry> getEntries(Func<CacheEntry, bool> predicate)
private static IEnumerable<CacheEntry> getEntries(Func<CacheEntry, bool> predicate)
{
var entries = cache.Where(predicate).ToList();
if (entries is null || !entries.Any())
return null;
return Enumerable.Empty<CacheEntry>();
remove(entries.Where(e => !File.Exists(e.Path)).ToList());

View File

@@ -2,9 +2,9 @@
using System.Diagnostics;
using System.Threading.Tasks;
#nullable enable
namespace LibationFileManager
{
#nullable enable
public interface IInteropFunctions
{
/// <summary>

View File

@@ -5,11 +5,12 @@ using System.Linq;
using System.Reflection;
using Dinah.Core;
#nullable enable
namespace LibationFileManager
{
public static class InteropFactory
{
public static Type InteropFunctionsType { get; }
public static Type? InteropFunctionsType { get; }
public static IInteropFunctions Create() => _create();
@@ -17,13 +18,17 @@ namespace LibationFileManager
//public static IInteropFunctions Create(string str, int i) => _create(str, i);
//public static IInteropFunctions Create(params object[] values) => _create(values);
private static IInteropFunctions instance { get; set; }
private static IInteropFunctions? instance { get; set; }
private static IInteropFunctions _create(params object[] values)
{
instance ??=
InteropFunctionsType is null
? new NullInteropFunctions()
: Activator.CreateInstance(InteropFunctionsType, values) as IInteropFunctions;
if (instance is null)
throw new TypeLoadException();
return instance;
}
@@ -66,7 +71,7 @@ namespace LibationFileManager
.GetTypes()
.FirstOrDefault(type.IsAssignableFrom);
}
private static string getOSConfigApp()
private static string? getOSConfigApp()
{
// find '*ConfigApp.dll' files
var appName =
@@ -76,8 +81,8 @@ namespace LibationFileManager
return appName;
}
private static Dictionary<string, Assembly> lowEffortCache { get; } = new();
private static Assembly CurrentDomain_AssemblyResolve(object sender, ResolveEventArgs args)
private static Dictionary<string, Assembly?> lowEffortCache { get; } = new();
private static Assembly? CurrentDomain_AssemblyResolve(object? sender, ResolveEventArgs args)
{
var asmName = new AssemblyName(args.Name);
var here = Configuration.ProcessDirectory;
@@ -97,7 +102,7 @@ namespace LibationFileManager
return assembly;
}
private static Assembly CurrentDomain_AssemblyResolve_internal(AssemblyName asmName, string here)
private static Assembly? CurrentDomain_AssemblyResolve_internal(AssemblyName asmName, string here)
{
/*
* Find the requested assembly in the program files directory.

View File

@@ -2,26 +2,27 @@
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationFileManager
{
public class BookDto
{
public string AudibleProductId { get; set; }
public string Title { get; set; }
public string Subtitle { get; set; }
public string TitleWithSubtitle { get; set; }
public string Locale { get; set; }
public string? AudibleProductId { get; set; }
public string? Title { get; set; }
public string? Subtitle { get; set; }
public string? TitleWithSubtitle { get; set; }
public string? Locale { get; set; }
public int? YearPublished { get; set; }
public IEnumerable<string> Authors { get; set; }
public string AuthorNames => string.Join(", ", Authors);
public string FirstAuthor => Authors.FirstOrDefault();
public IEnumerable<string>? Authors { get; set; }
public string? AuthorNames => Authors is null ? null : string.Join(", ", Authors);
public string? FirstAuthor => Authors?.FirstOrDefault();
public IEnumerable<string> Narrators { get; set; }
public string NarratorNames => string.Join(", ", Narrators);
public string FirstNarrator => Narrators.FirstOrDefault();
public IEnumerable<string>? Narrators { get; set; }
public string? NarratorNames => Narrators is null? null: string.Join(", ", Narrators);
public string? FirstNarrator => Narrators?.FirstOrDefault();
public string SeriesName { get; set; }
public string? SeriesName { get; set; }
public float? SeriesNumber { get; set; }
public bool IsSeries => !string.IsNullOrEmpty(SeriesName);
public bool IsPodcastParent { get; set; }
@@ -32,13 +33,13 @@ namespace LibationFileManager
public int Channels { get; set; }
public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; }
public string Language { get; set; }
public string? Language { get; set; }
}
public class LibraryBookDto : BookDto
{
public DateTime? DateAdded { get; set; }
public string Account { get; set; }
public string AccountNickname { get; set; }
public string? Account { get; set; }
public string? AccountNickname { get; set; }
}
}

View File

@@ -5,13 +5,16 @@ using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
#nullable enable
namespace LibationFileManager
{
internal partial class NameListFormat
{
public static string Formatter(ITemplateTag _, IEnumerable<string> names, string formatString)
public static string Formatter(ITemplateTag _, IEnumerable<string>? names, string formatString)
{
var humanNames = names.Select(n => new HumanName(RemoveSuffix(n), Prefer.FirstOverPrefix));
if (names is null) return "";
var humanNames = names.Select(n => new HumanName(RemoveSuffix(n), Prefer.FirstOverPrefix));
var sortedNames = Sort(humanNames, formatString);
var nameFormatString = Format(formatString, defaultValue: "{T} {F} {M} {L} {S}");

View File

@@ -2,7 +2,6 @@
using System.Diagnostics;
#nullable enable
namespace LibationFileManager
{
public class NullInteropFunctions : IInteropFunctions

View File

@@ -6,18 +6,25 @@ using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
#nullable enable
namespace LibationFileManager
{
public enum PictureSize { Native, _80x80 = 80, _300x300 = 300, _500x500 = 500 }
public class PictureCachedEventArgs : EventArgs
{
public PictureDefinition Definition { get; internal set; }
public byte[] Picture { get; internal set; }
public PictureDefinition Definition { get; }
public byte[] Picture { get; }
internal PictureCachedEventArgs(PictureDefinition definition, byte[] picture)
{
Definition = definition;
Picture = picture;
}
}
public struct PictureDefinition : IEquatable<PictureDefinition>
{
public string PictureId { get; }
public PictureSize Size { get; }
public string PictureId { get; init; }
public PictureSize Size { get; init; }
public PictureDefinition(string pictureId, PictureSize pictureSize)
{
@@ -45,7 +52,7 @@ namespace LibationFileManager
.Start();
}
public static event EventHandler<PictureCachedEventArgs> PictureCached;
public static event EventHandler<PictureCachedEventArgs>? PictureCached;
private static BlockingCollection<PictureDefinition> DownloadQueue { get; } = new BlockingCollection<PictureDefinition>();
private static object cacheLocker { get; } = new object();
@@ -112,7 +119,7 @@ namespace LibationFileManager
lock (cacheLocker)
cache[def] = bytes;
PictureCached?.Invoke(nameof(PictureStorage), new PictureCachedEventArgs { Definition = def, Picture = bytes });
PictureCached?.Invoke(nameof(PictureStorage), new PictureCachedEventArgs(def, bytes));
}
}

View File

@@ -5,30 +5,30 @@ using System.Linq;
using Dinah.Core.Collections.Generic;
using Newtonsoft.Json;
#nullable enable
namespace LibationFileManager
{
public static class QuickFilters
{
public static event EventHandler Updated;
public static event EventHandler? Updated;
internal class FilterState
public static event EventHandler? UseDefaultChanged;
internal class FilterState
{
public bool UseDefault { get; set; }
public List<string> Filters { get; set; } = new List<string>();
}
static FilterState inMemoryState { get; } = new FilterState();
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "QuickFilters.json");
static QuickFilters()
{
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (File.Exists(JsonFile))
inMemoryState = JsonConvert.DeserializeObject<FilterState>(File.ReadAllText(JsonFile));
}
public static event EventHandler UseDefaultChanged;
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
static FilterState inMemoryState { get; }
= File.Exists(JsonFile) && JsonConvert.DeserializeObject<FilterState>(File.ReadAllText(JsonFile)) is FilterState inMemState
? inMemState
: new FilterState();
public static bool UseDefault
{
get => inMemoryState.UseDefault;
@@ -43,7 +43,7 @@ namespace LibationFileManager
save(false);
}
UseDefaultChanged?.Invoke(null, null);
UseDefaultChanged?.Invoke(null, EventArgs.Empty);
}
}
@@ -121,7 +121,7 @@ namespace LibationFileManager
}
if (invokeUpdatedEvent)
Updated?.Invoke(null, null);
Updated?.Invoke(null, EventArgs.Empty);
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System;
using System.IO;
#nullable enable
namespace LibationFileManager
{
public interface ITemplateEditor
@@ -14,14 +15,11 @@ namespace LibationFileManager
string DefaultTemplate { get; }
string TemplateName { get; }
string TemplateDescription { get; }
Templates Folder { get; }
Templates File { get; }
Templates Name { get; }
Templates EditingTemplate { get; }
void SetTemplateText(string templateText);
string GetFolderName();
string GetFileName();
string GetName();
bool SetTemplateText(string templateText);
string? GetFolderName();
string? GetFileName();
string? GetName();
}
public class TemplateEditor<T> : ITemplateEditor where T : Templates, ITemplate, new()
@@ -32,9 +30,9 @@ namespace LibationFileManager
public string DefaultTemplate { get; private init; }
public string TemplateName { get; private init; }
public string TemplateDescription { get; private init; }
public Templates Folder { get; private set; }
public Templates File { get; private set; }
public Templates Name { get; private set; }
private Templates? Folder { get; set; }
private Templates? File { get; set; }
private Templates? Name { get; set; }
public Templates EditingTemplate
{
get => _editingTemplate;
@@ -43,13 +41,20 @@ namespace LibationFileManager
private Templates _editingTemplate;
public void SetTemplateText(string templateText)
public bool SetTemplateText(string templateText)
{
Templates.TryGetTemplate<T>(templateText, out var template);
EditingTemplate = template;
if (Templates.TryGetTemplate<T>(templateText, out var template))
{
EditingTemplate = template;
return true;
}
return false;
}
private static readonly LibraryBookDto libraryBookDto
public LibraryBookDto FolderBook { get; }
public LibraryBookDto LibraryBook { get; }
private static readonly LibraryBookDto DefaultLibraryBook
= new()
{
Account = "myaccount@example.co",
@@ -58,6 +63,7 @@ namespace LibationFileManager
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
AudibleProductId = "123456789",
Title = "A Study in Scarlet",
TitleWithSubtitle = "A Study in Scarlet: A Sherlock Holmes Novel",
Subtitle = "A Sherlock Holmes Novel",
Locale = "us",
YearPublished = 2017,
@@ -71,7 +77,7 @@ namespace LibationFileManager
Language = "English"
};
private static readonly MultiConvertFileProperties partFileProperties
private static readonly MultiConvertFileProperties DefaultMultipartProperties
= new()
{
OutputFileName = "",
@@ -80,7 +86,7 @@ namespace LibationFileManager
Title = "A Flight for Life"
};
public string GetFolderName()
public string? GetFolderName()
{
/*
* Path must be rooted for windows to allow long file paths. This is
@@ -88,49 +94,58 @@ namespace LibationFileManager
* subdirectories. Without rooting, we won't be allowed to create a
* relative path longer than MAX_PATH.
*/
var dir = Folder.GetFilename(libraryBookDto, BaseDirectory, "");
var dir = Folder?.GetFilename(FolderBook, BaseDirectory, "");
if (dir is null) return null;
return Path.GetRelativePath(BaseDirectory, dir);
}
public string GetFileName()
=> File.GetFilename(libraryBookDto, partFileProperties, "", "");
public string GetName()
=> Name.GetName(libraryBookDto, partFileProperties);
public string? GetFileName()
=> File?.GetFilename(LibraryBook, DefaultMultipartProperties, "", "");
public string? GetName()
=> Name?.GetName(LibraryBook, DefaultMultipartProperties);
public static ITemplateEditor CreateFilenameEditor(LongPath baseDir, string templateText)
private TemplateEditor(
LibraryBookDto? folderDto,
LibraryBookDto? fileDto,
Templates editingTemplate,
LongPath baseDirectory,
string defaultTemplate,
string templateName,
string templateDescription)
{
Templates.TryGetTemplate<T>(templateText, out var template);
FolderBook = folderDto ?? DefaultLibraryBook;
LibraryBook = fileDto ?? DefaultLibraryBook;
_editingTemplate = editingTemplate;
BaseDirectory = baseDirectory;
DefaultTemplate = defaultTemplate;
TemplateName = templateName;
TemplateDescription = templateDescription;
}
var templateEditor = new TemplateEditor<T>
{
_editingTemplate = template,
BaseDirectory = baseDir,
DefaultTemplate = T.DefaultTemplate,
TemplateName = T.Name,
TemplateDescription = T.Description
};
public static ITemplateEditor CreateFilenameEditor(LongPath baseDir, string templateText, LibraryBookDto? folderDto = null, LibraryBookDto? fileDto = null)
{
if (!Templates.TryGetTemplate<T>(templateText, out var template))
throw new ArgumentException($"Failed to parse {nameof(templateText)}");
var templateEditor = new TemplateEditor<T>(folderDto, fileDto, template, baseDir, T.DefaultTemplate, T.Name, T.Description);
if (!templateEditor.IsFolder && !templateEditor.IsFilePath)
throw new InvalidOperationException($"This method is only for File and Folder templates. Use {nameof(CreateNameEditor)} for name templates");
templateEditor.Folder = templateEditor.IsFolder ? template : Templates.Folder;
templateEditor.File = templateEditor.IsFolder ? Templates.File : template;
if (templateEditor.IsFolder)
templateEditor.File = Templates.File;
else
templateEditor.Folder = Templates.Folder;
return templateEditor;
}
public static ITemplateEditor CreateNameEditor(string templateText)
public static ITemplateEditor CreateNameEditor(string templateText, LibraryBookDto? libraryBookDto = null)
{
Templates.TryGetTemplate<T>(templateText, out var nameTemplate);
if (!Templates.TryGetTemplate<T>(templateText, out var nameTemplate))
throw new ArgumentException($"Failed to parse {nameof(templateText)}");
var templateEditor = new TemplateEditor<T>
{
_editingTemplate = nameTemplate,
DefaultTemplate = T.DefaultTemplate,
TemplateName = T.Name,
TemplateDescription = T.Description
};
var templateEditor = new TemplateEditor<T>(null, libraryBookDto, nameTemplate, "", T.DefaultTemplate, T.Name, T.Description);
if (templateEditor.IsFolder || templateEditor.IsFilePath)
throw new InvalidOperationException($"This method is only for name templates. Use {nameof(CreateFilenameEditor)} for file templates");

View File

@@ -1,5 +1,6 @@
using FileManager.NamingTemplate;
#nullable enable
namespace LibationFileManager
{
public sealed class TemplateTags : ITemplateTag
@@ -10,7 +11,7 @@ namespace LibationFileManager
public string Description { get; }
public string Display { get; }
private TemplateTags(string tagName, string description, string defaultValue = null, string display = null)
private TemplateTags(string tagName, string description, string? defaultValue = null, string? display = null)
{
TagName = tagName;
Description = description;

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using AaxDecrypter;
@@ -8,6 +9,7 @@ using FileManager;
using FileManager.NamingTemplate;
using NameParser;
#nullable enable
namespace LibationFileManager
{
public interface ITemplate
@@ -26,10 +28,10 @@ namespace LibationFileManager
//Assigning the properties in the static constructor will require all
//Templates users to have a valid configuration file. To allow tests
//to work without access to Configuration, only load templates on demand.
private static FolderTemplate _folder;
private static FileTemplate _file;
private static ChapterFileTemplate _chapterFile;
private static ChapterTitleTemplate _chapterTitle;
private static FolderTemplate? _folder;
private static FileTemplate? _file;
private static ChapterFileTemplate? _chapterFile;
private static ChapterTitleTemplate? _chapterTitle;
public static FolderTemplate Folder => _folder ??= GetTemplate<FolderTemplate>(Configuration.Instance.FolderTemplate);
public static FileTemplate File => _file ??= GetTemplate<FileTemplate>(Configuration.Instance.FileTemplate);
@@ -38,10 +40,10 @@ namespace LibationFileManager
#region Template Parsing
public static T GetTemplate<T>(string templateText) where T : Templates, ITemplate, new()
=> TryGetTemplate<T>(templateText, out var template) ? template : GetDefaultTemplate<T>();
public static T GetTemplate<T>(string? templateText) where T : Templates, ITemplate, new()
=> TryGetTemplate<T>(templateText ?? "", out var template) ? template : GetDefaultTemplate<T>();
public static bool TryGetTemplate<T>(string templateText, out T template) where T : Templates, ITemplate, new()
public static bool TryGetTemplate<T>(string templateText, [NotNullWhen(true)] out T? template) where T : Templates, ITemplate, new()
{
var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections);
@@ -56,19 +58,19 @@ namespace LibationFileManager
{
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.FolderTemplate))]
(_,e) => _folder = GetTemplate<FolderTemplate>((string)e.NewValue);
(_,e) => _folder = GetTemplate<FolderTemplate>(e.NewValue as string);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.FileTemplate))]
(_, e) => _file = GetTemplate<FileTemplate>((string)e.NewValue);
(_, e) => _file = GetTemplate<FileTemplate>(e.NewValue as string);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterFileTemplate))]
(_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>((string)e.NewValue);
(_, e) => _chapterFile = GetTemplate<ChapterFileTemplate>(e.NewValue as string);
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))]
(_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue);
(_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>(e.NewValue as string);
HumanName.Suffixes.Add("ret");
HumanName.Titles.Add("professor");
@@ -78,10 +80,18 @@ namespace LibationFileManager
#region Template Properties
public IEnumerable<TemplateTags> TagsRegistered => NamingTemplate.TagsRegistered.Cast<TemplateTags>();
public IEnumerable<TemplateTags> TagsInUse => NamingTemplate.TagsInUse.Cast<TemplateTags>();
public string TemplateText => NamingTemplate.TemplateText;
protected NamingTemplate NamingTemplate { get; private set; }
public IEnumerable<TemplateTags> TagsRegistered
=> NamingTemplate?.TagsRegistered.Cast<TemplateTags>() ?? Enumerable.Empty<TemplateTags>();
public IEnumerable<TemplateTags> TagsInUse
=> NamingTemplate?.TagsInUse.Cast<TemplateTags>() ?? Enumerable.Empty<TemplateTags>();
public string TemplateText => NamingTemplate?.TemplateText ?? "";
private readonly NamingTemplate? _namingTemplate;
protected NamingTemplate NamingTemplate
{
get => _namingTemplate ?? throw new NullReferenceException(nameof(_namingTemplate));
private init => _namingTemplate = value;
}
#endregion
@@ -104,7 +114,7 @@ namespace LibationFileManager
return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps).Select(p => p.Value));
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters replacements = null, bool returnFirstExisting = false)
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
@@ -114,7 +124,7 @@ namespace LibationFileManager
return GetFilename(baseDir, fileExtension,replacements, returnFirstExisting, libraryBookDto);
}
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters replacements = null, bool returnFirstExisting = false)
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
@@ -246,7 +256,7 @@ namespace LibationFileManager
new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter, FloatFormatter)
{
//Don't allow formatting of Id
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v },
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v ?? "" },
{ TemplateTags.Title, lb => lb.TitleWithSubtitle },
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
{ TemplateTags.AudibleTitle, lb => lb.Title },
@@ -308,13 +318,13 @@ namespace LibationFileManager
#region Tag Formatters
private static string getTitleShort(string title)
private static string? getTitleShort(string? title)
=> title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title;
private static string getLanguageShort(string language)
private static string getLanguageShort(string? language)
{
if (language is null)
return null;
return "";
language = language.Trim();
if (language.Length <= 3)
@@ -324,8 +334,9 @@ namespace LibationFileManager
private static string StringFormatter(ITemplateTag templateTag, string value, string formatString)
{
if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value?.ToUpper();
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value?.ToLower();
if (value is null) return "";
else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper();
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower();
else return value;
}
@@ -358,7 +369,7 @@ namespace LibationFileManager
public class FolderTemplate : Templates, ITemplate
{
public static string Name { get; }= "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections
=> new TagCollection[] { filePropertyTags, conditionalTags, folderConditionalTags };
@@ -378,7 +389,7 @@ namespace LibationFileManager
public class FileTemplate : Templates, ITemplate
{
public static string Name { get; } = "File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate));
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = new TagCollection[] { filePropertyTags, conditionalTags };
}
@@ -386,7 +397,7 @@ namespace LibationFileManager
public class ChapterFileTemplate : Templates, ITemplate
{
public static string Name { get; } = "Chapter File Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(filePropertyTags).Append(conditionalTags);
@@ -399,7 +410,7 @@ namespace LibationFileManager
public class ChapterTitleTemplate : Templates, ITemplate
{
public static string Name { get; } = "Chapter Title Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)) ?? "";
public static string DefaultTemplate => "<ch#> - <title short>: <ch title>";
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags);

View File

@@ -41,7 +41,8 @@ namespace LibationSearchEngine
{ FieldType.String, lb => lb.Book.Publisher, nameof(Book.Publisher) },
{ FieldType.String, lb => lb.Book.SeriesNames(), "SeriesNames", "Narrator", "Series" },
{ FieldType.String, lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), "SeriesId" },
{ FieldType.String, lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()), nameof(Book.Category), "Categories", "CategoriesId", "CategoryId", "CategoriesNames" },
{ FieldType.String, lb => lb.Book.AllCategoryIds() is null ? null : string.Join(", ", lb.Book.AllCategoryIds()), "CategoriesId", "CategoryId" },
{ FieldType.String, lb => lb.Book.AllCategoryNames() is null ? null : string.Join(", ", lb.Book.AllCategoryNames()), "Category", "Categories", "CategoriesNames" },
{ FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() },
{ FieldType.String, lb => lb.Book.Locale, "Locale", "Region" },
{ FieldType.String, lb => lb.Account, "Account", "Email" },

View File

@@ -0,0 +1,114 @@
using ApplicationServices;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using System;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibationUiBase.GridView;
public class GridContextMenu
{
public string CopyCellText => $"{Accelerator}Copy Cell Contents";
public string LiberateEpisodesText => $"{Accelerator}Liberate All Episodes";
public string SetDownloadedText => $"Set Download status to '{Accelerator}Downloaded'";
public string SetNotDownloadedText => $"Set Download status to '{Accelerator}Not Downloaded'";
public string RemoveText => $"{Accelerator}Remove from library";
public string LocateFileText => $"{Accelerator}Locate file...";
public string LocateFileDialogTitle => $"Locate the audio file for '{GridEntry.Book.TitleWithSubtitle}'";
public string LocateFileErrorMessage => "Error saving book's location";
public string ConvertToMp3Text => $"{Accelerator}Convert to Mp3";
public string ReDownloadText => "Re-download this audiobook";
public string EditTemplatesText => "Edit Templates";
public string FolderTemplateText => "Folder Template";
public string FileTemplateText => "File Template";
public string MultipartTemplateText => "Multipart File Template";
public string ViewBookmarksText => "View _Bookmarks/Clips";
public string ViewSeriesText => GridEntry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
public bool LiberateEpisodesEnabled => GridEntry is ISeriesEntry sEntry && sEntry.Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
public bool SetDownloadedEnabled => GridEntry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || GridEntry.Liberate.IsSeries;
public bool SetNotDownloadedEnabled => GridEntry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || GridEntry.Liberate.IsSeries;
public bool ConvertToMp3Enabled => GridEntry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated;
public bool ReDownloadEnabled => GridEntry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated;
public IGridEntry GridEntry { get; }
public char Accelerator { get; }
public GridContextMenu(IGridEntry gridEntry, char accelerator)
{
GridEntry = gridEntry;
Accelerator = accelerator;
}
public void SetDownloaded()
{
if (GridEntry is ISeriesEntry series)
{
series.Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated);
}
else
{
GridEntry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated);
}
}
public void SetNotDownloaded()
{
if (GridEntry is ISeriesEntry series)
{
series.Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated);
}
else
{
GridEntry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated);
}
}
public async Task RemoveAsync()
{
if (GridEntry is ISeriesEntry series)
{
await series.Children.Select(c => c.LibraryBook).RemoveBooksAsync();
}
else
{
await Task.Run(GridEntry.LibraryBook.RemoveBook);
}
}
public ITemplateEditor CreateTemplateEditor<T>(LibraryBook libraryBook, string existingTemplate)
where T : Templates, ITemplate, new()
{
LibraryBookDto fileDto = libraryBook.ToDto(), folderDto = fileDto;
if (libraryBook.Book.IsEpisodeChild() &&
Configuration.Instance.SavePodcastsToParentFolder &&
libraryBook.Book.SeriesLink.SingleOrDefault() is SeriesBook series)
{
using var context = DbContexts.GetContext();
var seriesParent = context.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
folderDto = seriesParent?.ToDto() ?? fileDto;
}
return TemplateEditor<T>.CreateFilenameEditor(Configuration.Instance.Books, existingTemplate, folderDto, fileDto);
}
}
class Command : ICommand
{
public event EventHandler CanExecuteChanged;
public bool CanExecute(object parameter)
{
throw new NotImplementedException();
}
public void Execute(object parameter)
{
throw new NotImplementedException();
}
}

View File

@@ -27,7 +27,6 @@ namespace LibationUiBase.GridView
[Browsable(false)] public string AudibleProductId => Book.AudibleProductId;
[Browsable(false)] public LibraryBook LibraryBook { get; protected set; }
[Browsable(false)] public float SeriesIndex { get; protected set; }
[Browsable(false)] public string LongDescription { get; protected set; }
[Browsable(false)] public abstract DateTime DateAdded { get; }
[Browsable(false)] public Book Book => LibraryBook.Book;
@@ -38,7 +37,7 @@ namespace LibationUiBase.GridView
private string _purchasedate;
private string _length;
private LastDownloadStatus _lastDownload;
private object _cover;
private Lazy<object> _lazyCover;
private string _series;
private SeriesOrder _seriesOrder;
private string _title;
@@ -56,7 +55,7 @@ namespace LibationUiBase.GridView
public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); }
public string Length { get => _length; protected set => RaiseAndSetIfChanged(ref _length, value); }
public LastDownloadStatus LastDownload { get => _lastDownload; protected set => RaiseAndSetIfChanged(ref _lastDownload, value); }
public object Cover { get => _cover; private set => RaiseAndSetIfChanged(ref _cover, value); }
public object Cover { get => _lazyCover.Value; }
public string Series { get => _series; private set => RaiseAndSetIfChanged(ref _series, value); }
public SeriesOrder SeriesOrder { get => _seriesOrder; private set => RaiseAndSetIfChanged(ref _seriesOrder, value); }
public string Title { get => _title; private set => RaiseAndSetIfChanged(ref _title, value); }
@@ -109,23 +108,18 @@ namespace LibationUiBase.GridView
Series = Book.SeriesNames(includeIndex: true);
SeriesOrder = new SeriesOrder(Book.SeriesLink);
Length = GetBookLengthString();
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Clone the rating so that it updates within Avalonia properly.
_myRating = new Rating(Book.UserDefinedItem.Rating.OverallRating, Book.UserDefinedItem.Rating.PerformanceRating, Book.UserDefinedItem.Rating.StoryRating);
RaiseAndSetIfChanged(ref _myRating, Book.UserDefinedItem.Rating, nameof(MyRating));
PurchaseDate = GetPurchaseDateString();
ProductRating = Book.Rating ?? new Rating(0, 0, 0);
Authors = Book.AuthorNames();
Narrators = Book.NarratorNames();
Category = string.Join(" > ", Book.CategoriesNames());
Category = string.Join(", ", Book.LowestCategoryNames());
Misc = GetMiscDisplay(libraryBook);
LastDownload = new(Book.UserDefinedItem);
LongDescription = GetDescriptionDisplay(Book);
Description = TrimTextToWord(LongDescription, 62);
Description = GetDescriptionDisplay(Book);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
BookTags = GetBookTags();
RaisePropertyChanged(nameof(MyRating));
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
@@ -138,7 +132,7 @@ namespace LibationUiBase.GridView
int bookLenMins = GetLengthInMinutes();
return bookLenMins == 0 ? "" : $"{bookLenMins / 60} hr {bookLenMins % 60} min";
}
#endregion
#region detect changes to the model, update the view.
@@ -182,6 +176,8 @@ namespace LibationUiBase.GridView
break;
case nameof(udi.Rating):
_myRating = udi.Rating;
//Ratings are changed using Update(), which is a problem for Avalonia data bindings because
//the reference doesn't change. Must call RaisePropertyChanged instead of RaiseAndSetIfChanged
RaisePropertyChanged(nameof(MyRating));
break;
}
@@ -204,36 +200,31 @@ namespace LibationUiBase.GridView
#region Sorting
public GridEntry()
public object GetMemberValue(string memberName) => memberName switch
{
memberValues = new()
{
{ nameof(Remove), () => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved },
{ nameof(Title), () => Book.TitleSortable() },
{ nameof(Series), () => Book.SeriesSortable() },
{ nameof(SeriesOrder), () => SeriesOrder },
{ nameof(Length), () => GetLengthInMinutes() },
{ nameof(MyRating), () => Book.UserDefinedItem.Rating },
{ nameof(PurchaseDate), () => GetPurchaseDate() },
{ nameof(ProductRating), () => Book.Rating },
{ nameof(Authors), () => Authors },
{ nameof(Narrators), () => Narrators },
{ nameof(Description), () => Description },
{ nameof(Category), () => Category },
{ nameof(Misc), () => Misc },
{ nameof(LastDownload), () => LastDownload },
{ nameof(BookTags), () => BookTags ?? string.Empty },
{ nameof(Liberate), () => Liberate },
{ nameof(DateAdded), () => DateAdded },
};
}
nameof(Remove) => Remove.HasValue ? Remove.Value ? RemoveStatus.Removed : RemoveStatus.NotRemoved : RemoveStatus.SomeRemoved,
nameof(Title) => Book.TitleSortable(),
nameof(Series) => Book.SeriesSortable(),
nameof(SeriesOrder) => SeriesOrder,
nameof(Length) => GetLengthInMinutes(),
nameof(MyRating) => Book.UserDefinedItem.Rating,
nameof(PurchaseDate) => GetPurchaseDate(),
nameof(ProductRating) => Book.Rating,
nameof(Authors) => Authors,
nameof(Narrators) => Narrators,
nameof(Description) => Description,
nameof(Category) => Category,
nameof(Misc) => Misc,
nameof(LastDownload) => LastDownload,
nameof(BookTags) => BookTags ?? string.Empty,
nameof(Liberate) => Liberate,
nameof(DateAdded) => DateAdded,
_ => null
};
public object GetMemberValue(string memberName) => memberValues[memberName]();
public IComparer GetMemberComparer(Type memberType)
=> memberTypeComparers.TryGetValue(memberType, out IComparer value) ? value : memberTypeComparers[memberType.BaseType];
private readonly Dictionary<string, Func<object>> memberValues;
// Instantiate comparers for every exposed member object type.
private static readonly Dictionary<Type, IComparer> memberTypeComparers = new()
{
@@ -262,7 +253,7 @@ namespace LibationUiBase.GridView
PictureStorage.PictureCached += PictureStorage_PictureCached;
// Mutable property. Set the field so PropertyChanged isn't fired.
_cover = Liberate.LoadImage(picture);
_lazyCover = new Lazy<object>(() => Liberate.LoadImage(picture));
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
@@ -276,7 +267,8 @@ namespace LibationUiBase.GridView
// logic validation
if (e.Definition.PictureId == Book.PictureId)
{
Cover = Liberate.LoadImage(e.Picture);
_lazyCover = new Lazy<object>(() => Liberate.LoadImage(e.Picture));
RaisePropertyChanged(nameof(Cover));
PictureStorage.PictureCached -= PictureStorage_PictureCached;
}
}

View File

@@ -10,7 +10,6 @@ namespace LibationUiBase.GridView
EntryStatus Liberate { get; }
float SeriesIndex { get; }
string AudibleProductId { get; }
string LongDescription { get; }
LibraryBook LibraryBook { get; }
Book Book { get; }
DateTime DateAdded { get; }

View File

@@ -1,6 +1,10 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Threading.Tasks;
using System.Threading;
using System.Linq;
namespace LibationUiBase.GridView
{
@@ -29,6 +33,41 @@ namespace LibationUiBase.GridView
LoadCover();
}
public static async Task<List<IGridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks)
{
var products = libraryBooks.Where(lb => lb.Book.IsProduct()).ToArray();
int parallelism = int.Max(1, Environment.ProcessorCount - 1);
(int numPer, int rem) = int.DivRem(products.Length, parallelism);
if (rem != 0) numPer++;
var tasks = new Task<IGridEntry[]>[parallelism];
var syncContext = SynchronizationContext.Current;
for (int i = 0; i < parallelism; i++)
{
int start = i * numPer;
tasks[i] = Task.Run(() =>
{
SynchronizationContext.SetSynchronizationContext(syncContext);
int length = int.Min(numPer, products.Length - start);
if (length < 1) return Array.Empty<IGridEntry>();
var result = new IGridEntry[length];
for (int j = 0; j < length; j++)
result[j] = new LibraryBookEntry<TStatus>(products[start + j]);
return result;
});
}
return (await Task.WhenAll(tasks)).SelectMany(a => a).ToList();
}
protected override string GetBookTags() => string.Join("\r\n", Book.UserDefinedItem.TagsEnumerated);
}
}

View File

@@ -1,7 +1,10 @@
using DataLayer;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LibationUiBase.GridView
{
@@ -54,6 +57,60 @@ namespace LibationUiBase.GridView
LoadCover();
}
public static async Task<List<ISeriesEntry>> GetAllSeriesEntriesAsync(IEnumerable<LibraryBook> libraryBooks)
{
var seriesBooks = libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).ToArray();
var allEpisodes = libraryBooks.Where(lb => lb.Book.IsEpisodeChild()).ToArray();
int parallelism = int.Max(1, Environment.ProcessorCount - 1);
var tasks = new Task[parallelism];
var syncContext = SynchronizationContext.Current;
var q = new BlockingCollection<(int, LibraryBook episode)>();
var seriesEntries = new ISeriesEntry[seriesBooks.Length];
var seriesEpisodes = new ConcurrentBag<ILibraryBookEntry>[seriesBooks.Length];
for (int i = 0; i < parallelism; i++)
{
tasks[i] = Task.Run(() =>
{
SynchronizationContext.SetSynchronizationContext(syncContext);
while (q.TryTake(out var entry, -1))
{
var parent = seriesEntries[entry.Item1];
var episodeBag = seriesEpisodes[entry.Item1];
episodeBag.Add(new LibraryBookEntry<TStatus>(entry.episode, parent));
}
});
}
for (int i = 0; i <seriesBooks.Length; i++)
{
var series = seriesBooks[i];
seriesEntries[i] = new SeriesEntry<TStatus>(series, Enumerable.Empty<LibraryBook>());
seriesEpisodes[i] = new ConcurrentBag<ILibraryBookEntry>();
foreach (var ep in allEpisodes.FindChildren(series))
q.Add((i, ep));
}
q.CompleteAdding();
await Task.WhenAll(tasks);
for (int i = 0; i < seriesBooks.Length; i++)
{
var series = seriesEntries[i];
series.Children.AddRange(seriesEpisodes[i].OrderByDescending(c => c.SeriesOrder));
series.UpdateLibraryBook(series.LibraryBook);
}
return seriesEntries.Where(s => s.Children.Count != 0).ToList();
}
public void RemoveChild(ILibraryBookEntry lbe)
{
Children.Remove(lbe);

View File

@@ -0,0 +1,73 @@
namespace LibationWinForms
{
partial class ClearableTextBox
{
/// <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()
{
textBox1 = new System.Windows.Forms.TextBox();
button1 = new System.Windows.Forms.Button();
SuspendLayout();
//
// textBox1
//
textBox1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
textBox1.Location = new System.Drawing.Point(0, 0);
textBox1.Margin = new System.Windows.Forms.Padding(0);
textBox1.Name = "textBox1";
textBox1.Size = new System.Drawing.Size(625, 23);
textBox1.TabIndex = 0;
//
// button1
//
button1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
button1.Location = new System.Drawing.Point(623, 0);
button1.Margin = new System.Windows.Forms.Padding(0);
button1.Name = "button1";
button1.Size = new System.Drawing.Size(20, 20);
button1.TabIndex = 1;
button1.Text = "X";
button1.UseVisualStyleBackColor = true;
button1.Click += button1_Click;
//
// ClearableTextBox
//
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
Controls.Add(button1);
Controls.Add(textBox1);
Name = "ClearableTextBox";
Size = new System.Drawing.Size(642, 20);
ResumeLayout(false);
PerformLayout();
}
#endregion
private System.Windows.Forms.TextBox textBox1;
private System.Windows.Forms.Button button1;
}
}

View File

@@ -0,0 +1,44 @@
using System;
using System.Drawing;
using System.Windows.Forms;
namespace LibationWinForms
{
public partial class ClearableTextBox : UserControl
{
public event EventHandler TextCleared;
public override string Text { get => textBox1.Text; set => textBox1.Text = value; }
public override Font Font
{
get => textBox1.Font;
set
{
base.Font = textBox1.Font = button1.Font = value;
OnSizeChanged(EventArgs.Empty);
}
}
public ClearableTextBox()
{
InitializeComponent();
textBox1.KeyDown += (_, e) => OnKeyDown(e);
textBox1.KeyUp += (_, e) => OnKeyUp(e);
textBox1.KeyPress += (_, e) => OnKeyPress(e);
textBox1.TextChanged += (_, e) => OnTextChanged(e);
}
protected override void OnSizeChanged(EventArgs e)
{
base.OnSizeChanged(e);
Height = button1.Width = button1.Height = textBox1.Height;
textBox1.Width = Width - button1.Width;
button1.Location = new Point(textBox1.Width, 0);
}
private void button1_Click(object sender, System.EventArgs e)
{
textBox1.Clear();
TextCleared?.Invoke(this, EventArgs.Empty);
}
}
}

View File

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing"">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -286,8 +286,8 @@
//
// AboutDialog
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
ClientSize = new System.Drawing.Size(434, 491);
Controls.Add(groupBox1);
Controls.Add(getLibationLbl);

View File

@@ -143,8 +143,8 @@
// AccountsDialog
//
this.AcceptButton = this.saveBtn;
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.CancelButton = this.cancelBtn;
this.ClientSize = new System.Drawing.Size(933, 519);
this.Controls.Add(this.dataGridView1);

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