Compare commits

...

17 Commits

Author SHA1 Message Date
Robert
6df47d4d9c incr ver 2026-01-12 16:32:16 -05:00
rmcrackan
0103c879f2 Merge pull request #1551 from Mbucari/master
Update dependencies
2026-01-12 14:17:05 -05:00
Michael Bucari-Tovo
79e2bca8fc Update dependencies 2026-01-12 10:42:04 -07:00
rmcrackan
7bbc681767 Merge pull request #1548 from Mbucari/master
Improved trash bin, new book properties, and bug fixes
2026-01-11 17:54:53 -05:00
Mbucari
c95dccd246 Add confirmation dialog when removing books from Audible 2026-01-11 15:45:56 -07:00
Mbucari
94cf665be7 Fix books not being marked absent on large imports 2026-01-11 15:40:11 -07:00
Mbucari
7d38874257 Merge branch 'rmcrackan:master' into master 2026-01-09 16:49:09 -07:00
Michael Bucari-Tovo
659f793eb8 Improve TrashBinDialog functionality
- Use the main display grid control to display deleted books
- Added search functionality for deleted books. This required creating a temporary search index in the `InProgress` folder. The products grid control now uses an instance of `ISearchEngine` to filter its grid entries.  The main grid uses a singleton instance of `MainSearchEngine`, which merely wraps `SearchEngineCommands.Search()`.  The TrashBinDialogs use `TempSearchEngine`.
- Users can now batch select `Everyting` as well as `Audible Plus Books`

Avalonia:
  - Refactor main grid context menus to no longer require reflection
2026-01-09 16:47:37 -07:00
MBucari
6f7cd4d5b5 Fix narrators with null ASINs not importing (#1545 ) 2026-01-08 19:57:47 -07:00
Michael Bucari-Tovo
068f37319f Add option to adjust minimum file duration
when splitting audiobooks into multiple files by chapter
2026-01-08 18:36:57 -07:00
Michael Bucari-Tovo
dc58a101af Add cli export option to specify Asins 2026-01-08 16:54:43 -07:00
Michael Bucari-Tovo
7b68415b02 Add more properties to search engine and library export
- Add `IsAudiblePlus` to search engine
- Add `IsAudiblePlus` and `AbsentFromLastScan` properties to library export
- Refactor library export ToXlsx method
  - Make nullable
  - Improve readability and extensability
  - Use same column header names as CSV
  - Extend export methods to accept optional list of books (future use)
2026-01-08 15:14:20 -07:00
Michael Bucari-Tovo
1514de54da Add menu option to remove Plus books from Audible 2026-01-08 13:00:47 -07:00
Michael Bucari-Tovo
804bac5c4c Add LibraryBook.IsAudiblePlus property 2026-01-07 15:50:23 -07:00
Michael Bucari-Tovo
3fa805d51f Verify correct Serilog parameters used (#1536 ) 2026-01-07 15:10:54 -07:00
rmcrackan
1eff725125 Merge pull request #1541 from rmcrackan/dependabot/npm_and_yarn/preact-10.28.2
Bump preact from 10.28.0 to 10.28.2
2026-01-07 14:35:44 -05:00
dependabot[bot]
af1b1a70ae Bump preact from 10.28.0 to 10.28.2
Bumps [preact](https://github.com/preactjs/preact) from 10.28.0 to 10.28.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.28.0...10.28.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.28.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-07 19:29:23 +00:00
79 changed files with 2808 additions and 1081 deletions

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.1.2.1" />
<PackageReference Include="AAXClean.Codecs" Version="2.1.3.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -40,58 +40,14 @@ namespace AaxDecrypter
}
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
*/
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
if (AaxFile is null) return false;
var chapters = DownloadOptions.ChapterInfo.Chapters;
// Ensure split files are at least minChapterLength in duration.
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
var runningTotal = TimeSpan.Zero;
string title = "";
for (int i = 0; i < chapters.Count; i++)
{
if (runningTotal == TimeSpan.Zero)
title = chapters[i].Title;
runningTotal += chapters[i].Duration;
if (runningTotal >= minChapterLength)
{
splitChapters.AddChapter(title, runningTotal);
runningTotal = TimeSpan.Zero;
}
}
try
{
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo));
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>13.1.1.1</Version>
<Version>13.1.2.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -121,7 +121,7 @@ namespace AppScaffolding
}
catch(Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {@WalFile}", walFile);
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile);
}
}
if (File.Exists(shmFile))
@@ -132,7 +132,7 @@ namespace AppScaffolding
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {@ShmFile}", shmFile);
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {ShmFile}", shmFile);
}
}
}

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" PrivateAssets="all" />
<PackageReference Include="ClosedXML" Version="0.105.0" PrivateAssets="all" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,142 @@
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Linq;
#nullable enable
namespace ApplicationServices;
internal class ExportDto(LibraryBook libBook)
{
[Name("Account")]
public string Account { get; } = libBook.Account;
[Name("Date Added to library")]
public DateTime DateAdded { get; } = libBook.DateAdded;
[Name("Is Audible Plus?")]
public bool IsAudiblePlus { get; } = libBook.IsAudiblePlus;
[Name("Absent from last scan?")]
public bool AbsentFromLastScan { get; } = libBook.AbsentFromLastScan;
[Name("Audible Product Id")]
public string AudibleProductId { get; } = libBook.Book.AudibleProductId;
[Name("Locale")]
public string Locale { get; } = libBook.Book.Locale;
[Name("Title")]
public string Title { get; } = libBook.Book.Title;
[Name("Subtitle")]
public string Subtitle { get; } = libBook.Book.Subtitle;
[Name("Authors")]
public string AuthorNames { get; } = libBook.Book.AuthorNames;
[Name("Narrators")]
public string NarratorNames { get; } = libBook.Book.NarratorNames;
[Name("Length In Minutes")]
public int LengthInMinutes { get; } = libBook.Book.LengthInMinutes;
[Name("Description")]
public string Description { get; } = libBook.Book.Description;
[Name("Publisher")]
public string Publisher { get; } = libBook.Book.Publisher;
[Name("Has PDF")]
public bool HasPdf { get; } = libBook.Book.HasPdf;
[Name("Series Names")]
public string SeriesNames { get; } = libBook.Book.SeriesNames();
[Name("Series Order")]
public string SeriesOrder { get; } = libBook.Book.SeriesLink?.Any() is true ? string.Join(", ", libBook.Book.SeriesLink.Select(sl => $"{sl.Order} : {sl.Series.Name}")) : "";
[Name("Community Rating: Overall")]
public float? CommunityRatingOverall { get; } = ZeroIsNull(libBook.Book.Rating?.OverallRating);
[Name("Community Rating: Performance")]
public float? CommunityRatingPerformance { get; } = ZeroIsNull(libBook.Book.Rating?.PerformanceRating);
[Name("Community Rating: Story")]
public float? CommunityRatingStory { get; } = ZeroIsNull(libBook.Book.Rating?.StoryRating);
[Name("Cover Id")]
public string PictureId { get; } = libBook.Book.PictureId;
[Name("Cover Id Large")]
public string PictureLarge { get; } = libBook.Book.PictureLarge;
[Name("Is Abridged?")]
public bool IsAbridged { get; } = libBook.Book.IsAbridged;
[Name("Date Published")]
public DateTime? DatePublished { get; } = libBook.Book.DatePublished;
[Name("Categories")]
public string CategoriesNames { get; } = string.Join("; ", libBook.Book.LowestCategoryNames());
[Name("My Rating: Overall")]
public float? MyRatingOverall { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.OverallRating);
[Name("My Rating: Performance")]
public float? MyRatingPerformance { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.PerformanceRating);
[Name("My Rating: Story")]
public float? MyRatingStory { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.StoryRating);
[Name("My Libation Tags")]
public string MyLibationTags { get; } = libBook.Book.UserDefinedItem.Tags;
[Name("Book Liberated Status")]
public string BookStatus { get; } = libBook.Book.UserDefinedItem.BookStatus.ToString();
[Name("PDF Liberated Status")]
public string? PdfStatus { get; } = libBook.Book.UserDefinedItem.PdfStatus.ToString();
[Name("Content Type")]
public string ContentType { get; } = libBook.Book.ContentType.ToString();
[Name("Language")]
public string Language { get; } = libBook.Book.Language;
[Name("Last Downloaded")]
public DateTime? LastDownloaded { get; } = libBook.Book.UserDefinedItem.LastDownloaded;
[Name("Last Downloaded Version")]
public string? LastDownloadedVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString();
[Name("Is Finished?")]
public bool IsFinished { get; } = libBook.Book.UserDefinedItem.IsFinished;
[Name("Is Spatial?")]
public bool IsSpatial { get; } = libBook.Book.IsSpatial;
[Name("Included Until")]
public DateTime? IncludedUntil { get; } = libBook.IncludedUntil;
[Name("Last Downloaded File Version")]
public string? LastDownloadedFileVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedFileVersion;
[Ignore /* csv ignore */]
public AudioFormat? LastDownloadedFormat { get; } = libBook.Book.UserDefinedItem.LastDownloadedFormat;
[Name("Last Downloaded Codec"), JsonIgnore]
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
[Name("Last Downloaded Sample rate"), JsonIgnore]
public int? SampleRate => LastDownloadedFormat?.SampleRate;
[Name("Last Downloaded Audio Channels"), JsonIgnore]
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
[Name("Last Downloaded Bitrate"), JsonIgnore]
public int? BitRate => LastDownloadedFormat?.BitRate;
private static float? ZeroIsNull(float? value) => value is 0 ? null : value;
}

View File

@@ -0,0 +1,9 @@
using LibationSearchEngine;
#nullable enable
namespace ApplicationServices;
public interface ISearchEngine
{
SearchResultSet? GetSearchResultSet(string? searchString);
}

View File

@@ -220,6 +220,8 @@ namespace ApplicationServices
{
book.AbsentFromLastScan = false;
}
book.SetIncludedUntil(importItem.DtoItem.GetExpirationDate());
book.SetIsAudiblePlus(importItem.DtoItem.IsAyce is true);
});
}

View File

@@ -3,328 +3,79 @@ using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
namespace ApplicationServices
#nullable enable
namespace ApplicationServices;
public static class LibraryExporter
{
public class ExportDto
public static void ToCsv(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
{
public static string GetName(string fieldName)
{
var property = typeof(ExportDto).GetProperty(fieldName);
var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0];
var description = (NameAttribute)attribute;
var text = description.Names;
return text[0];
}
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var dtos = libraryBooks.ToDtos();
if (dtos.Count == 0)
return;
[Name("Account")]
public string Account { get; set; }
using var csv = new CsvWriter(new System.IO.StreamWriter(saveFilePath), CultureInfo.CurrentCulture);
csv.WriteHeader(typeof(ExportDto));
csv.NextRecord();
csv.WriteRecords(dtos); }
[Name("Date Added to library")]
public DateTime DateAdded { get; set; }
[Name("Audible Product Id")]
public string AudibleProductId { get; set; }
[Name("Locale")]
public string Locale { get; set; }
[Name("Title")]
public string Title { get; set; }
[Name("Subtitle")]
public string Subtitle { get; set; }
[Name("Authors")]
public string AuthorNames { get; set; }
[Name("Narrators")]
public string NarratorNames { get; set; }
[Name("Length In Minutes")]
public int LengthInMinutes { get; set; }
[Name("Description")]
public string Description { get; set; }
[Name("Publisher")]
public string Publisher { get; set; }
[Name("Has PDF")]
public bool HasPdf { get; set; }
[Name("Series Names")]
public string SeriesNames { get; set; }
[Name("Series Order")]
public string SeriesOrder { get; set; }
[Name("Community Rating: Overall")]
public float? CommunityRatingOverall { get; set; }
[Name("Community Rating: Performance")]
public float? CommunityRatingPerformance { get; set; }
[Name("Community Rating: Story")]
public float? CommunityRatingStory { get; set; }
[Name("Cover Id")]
public string PictureId { get; set; }
[Name("Is Abridged?")]
public bool IsAbridged { get; set; }
[Name("Date Published")]
public DateTime? DatePublished { get; set; }
[Name("Categories")]
public string CategoriesNames { get; set; }
[Name("My Rating: Overall")]
public float? MyRatingOverall { get; set; }
[Name("My Rating: Performance")]
public float? MyRatingPerformance { get; set; }
[Name("My Rating: Story")]
public float? MyRatingStory { get; set; }
[Name("My Libation Tags")]
public string MyLibationTags { get; set; }
[Name("Book Liberated Status")]
public string BookStatus { get; set; }
[Name("PDF Liberated Status")]
public string PdfStatus { get; set; }
[Name("Content Type")]
public string ContentType { get; set; }
[Name("Language")]
public string Language { get; set; }
[Name("Last Downloaded")]
public DateTime? LastDownloaded { get; set; }
[Name("Last Downloaded Version")]
public string LastDownloadedVersion { get; set; }
[Name("Is Finished?")]
public bool IsFinished { get; set; }
[Name("Is Spatial?")]
public bool IsSpatial { get; set; }
[Name("Included Until")]
public DateTime? IncludedUntil { get; set; }
[Name("Last Downloaded File Version")]
public string LastDownloadedFileVersion { get; set; }
[Ignore /* csv ignore */]
public AudioFormat LastDownloadedFormat { get; set; }
[Name("Last Downloaded Codec"), JsonIgnore]
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
[Name("Last Downloaded Sample rate"), JsonIgnore]
public int? SampleRate => LastDownloadedFormat?.SampleRate;
[Name("Last Downloaded Audio Channels"), JsonIgnore]
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
[Name("Last Downloaded Bitrate"), JsonIgnore]
public int? BitRate => LastDownloadedFormat?.BitRate;
public static void ToJson(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var dtos = libraryBooks.ToDtos();
var serializer = new JsonSerializer();
using var writer = new JsonTextWriter(new System.IO.StreamWriter(saveFilePath)) { Formatting = Formatting.Indented };
serializer.Serialize(writer, dtos);
}
public static class LibToDtos
public static void ToXlsx(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
{
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
=> library.Select(a => new ExportDto
{
Account = a.Account,
DateAdded = a.DateAdded,
AudibleProductId = a.Book.AudibleProductId,
Locale = a.Book.Locale,
Title = a.Book.Title,
Subtitle = a.Book.Subtitle,
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
IsFinished = a.Book.UserDefinedItem.IsFinished,
IsSpatial = a.Book.IsSpatial,
IncludedUntil = a.IncludedUntil,
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
}).ToList();
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var dtos = libraryBooks.ToDtos();
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
}
public static class LibraryExporter
{
public static void ToCsv(string saveFilePath)
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Library");
var columns = typeof(ExportDto).GetProperties().Where(p => p.GetCustomAttribute<NameAttribute>() is not null).ToArray();
// headers
var currentRow = sheet.FirstRow();
var currentCell = currentRow.FirstCell();
foreach (var column in columns)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
csv.WriteHeader(typeof(ExportDto));
csv.NextRecord();
csv.WriteRecords(dtos);
currentCell.Value = GetColumnName(column);
currentCell.Style.Font.Bold = true;
currentCell = currentCell.CellRight();
}
public static void ToJson(string saveFilePath)
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var dto in dtos)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var json = JsonConvert.SerializeObject(dtos, Formatting.Indented);
System.IO.File.WriteAllText(saveFilePath, json);
}
currentRow = currentRow.RowBelow();
currentCell = currentRow.FirstCell();
public static void ToXlsx(string saveFilePath)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Library");
// headers
var columns = new[] {
nameof(ExportDto.Account),
nameof(ExportDto.DateAdded),
nameof(ExportDto.AudibleProductId),
nameof(ExportDto.Locale),
nameof(ExportDto.Title),
nameof(ExportDto.Subtitle),
nameof(ExportDto.AuthorNames),
nameof(ExportDto.NarratorNames),
nameof(ExportDto.LengthInMinutes),
nameof(ExportDto.Description),
nameof(ExportDto.Publisher),
nameof(ExportDto.HasPdf),
nameof(ExportDto.SeriesNames),
nameof(ExportDto.SeriesOrder),
nameof(ExportDto.CommunityRatingOverall),
nameof(ExportDto.CommunityRatingPerformance),
nameof(ExportDto.CommunityRatingStory),
nameof(ExportDto.PictureId),
nameof(ExportDto.IsAbridged),
nameof(ExportDto.DatePublished),
nameof(ExportDto.CategoriesNames),
nameof(ExportDto.MyRatingOverall),
nameof(ExportDto.MyRatingPerformance),
nameof(ExportDto.MyRatingStory),
nameof(ExportDto.MyLibationTags),
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
nameof(ExportDto.IsFinished),
nameof(ExportDto.IsSpatial),
nameof(ExportDto.IncludedUntil),
nameof(ExportDto.LastDownloadedFileVersion),
nameof(ExportDto.CodecString),
nameof(ExportDto.SampleRate),
nameof(ExportDto.ChannelCount),
nameof(ExportDto.BitRate)
};
int rowIndex = 1, col = 1;
var headerRow = sheet.Row(rowIndex++);
foreach (var c in columns)
foreach (var column in columns)
{
var headerCell = headerRow.Cell(col++);
headerCell.Value = ExportDto.GetName(c);
headerCell.Style.Font.Bold = true;
var value = column.GetValue(dto);
currentCell.Value = XLCellValue.FromObject(value);
currentCell.Style.DateFormat.Format = currentCell.DataType is XLDataType.DateTime ? dateFormat : string.Empty;
currentCell = currentCell.CellRight();
}
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var dto in dtos)
{
col = 1;
var row = sheet.Row(rowIndex++);
row.Cell(col++).Value = dto.Account;
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
row.Cell(col++).Value = dto.AudibleProductId;
row.Cell(col++).Value = dto.Locale;
row.Cell(col++).Value = dto.Title;
row.Cell(col++).Value = dto.Subtitle;
row.Cell(col++).Value = dto.AuthorNames;
row.Cell(col++).Value = dto.NarratorNames;
row.Cell(col++).Value = dto.LengthInMinutes;
row.Cell(col++).Value = dto.Description;
row.Cell(col++).Value = dto.Publisher;
row.Cell(col++).Value = dto.HasPdf;
row.Cell(col++).Value = dto.SeriesNames;
row.Cell(col++).Value = dto.SeriesOrder;
row.Cell(col++).Value = dto.CommunityRatingOverall;
row.Cell(col++).Value = dto.CommunityRatingPerformance;
row.Cell(col++).Value = dto.CommunityRatingStory;
row.Cell(col++).Value = dto.PictureId;
row.Cell(col++).Value = dto.IsAbridged;
row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
row.Cell(col++).Value = dto.CategoriesNames;
row.Cell(col++).Value = dto.MyRatingOverall;
row.Cell(col++).Value = dto.MyRatingPerformance;
row.Cell(col++).Value = dto.MyRatingStory;
row.Cell(col++).Value = dto.MyLibationTags;
row.Cell(col++).Value = dto.BookStatus;
row.Cell(col++).Value = dto.PdfStatus;
row.Cell(col++).Value = dto.ContentType;
row.Cell(col++).Value = dto.Language;
row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
row.Cell(col++).Value = dto.LastDownloadedVersion;
row.Cell(col++).Value = dto.IsFinished;
row.Cell(col++).Value = dto.IsSpatial;
row.Cell(col++).Value = dto.IncludedUntil;
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.CodecString;
row.Cell(col++).Value = dto.SampleRate;
row.Cell(col++).Value = dto.ChannelCount;
row.Cell(col++).Value = dto.BitRate;
}
workbook.SaveAs(saveFilePath);
}
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
{
cell.Value = value;
cell.Style.DateFormat.Format = dateFormat;
}
workbook.SaveAs(saveFilePath);
}
private static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
=> library.Select(a => new ExportDto(a)).ToList();
private static string GetColumnName(PropertyInfo property)
=> property.GetCustomAttribute<NameAttribute>()?.Names?.FirstOrDefault() ?? property.Name;
}

View File

@@ -0,0 +1,16 @@
using LibationSearchEngine;
#nullable enable
namespace ApplicationServices;
/// <summary>
/// The main search engine used Libation.
/// Acts as an adapter to SearchEngineCommands.Search()
/// </summary>
public class MainSearchEngine : ISearchEngine
{
public static MainSearchEngine Instance { get; } = new MainSearchEngine();
private MainSearchEngine() { }
public SearchResultSet? GetSearchResultSet(string? searchString)
=> string.IsNullOrEmpty(searchString) ? null : SearchEngineCommands.Search(searchString);
}

View File

@@ -0,0 +1,45 @@
using DataLayer;
using LibationFileManager;
using LibationSearchEngine;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices;
/// <summary>
/// A temporary search engine created in InProgress/TempSearchEngine
/// Used for Trash Bin searches to avoid interfering with the main search engine
/// </summary>
public class TempSearchEngine : ISearchEngine
{
public static string SearchEnginePath { get; }
= System.IO.Path.Combine(Configuration.Instance.InProgress, nameof(TempSearchEngine));
private SearchEngine SearchEngine { get; } = new SearchEngine(SearchEnginePath);
public bool ReindexSearchEngine(IEnumerable<LibraryBook> books)
{
try
{
SearchEngine.CreateNewIndex(books, overwrite: true);
return true;
}
catch
{
return false;
}
}
public SearchResultSet? GetSearchResultSet(string? searchString)
{
if (string.IsNullOrEmpty(searchString))
return null;
try
{
return SearchEngine.Search(searchString);
}
catch
{
return null;
}
}
}

View File

@@ -28,6 +28,6 @@ public class ContributorSanitizer : ISanitizer
private static Person[]? SanitizePersonArray(Person?[]? contributors)
=> contributors
?.OfType<Person>()
.Where(c => !string.IsNullOrWhiteSpace(c.Asin) && !string.IsNullOrWhiteSpace(c.Name))
.Where(c => !string.IsNullOrWhiteSpace(c.Name))
.ToArray();
}

View File

@@ -6,7 +6,7 @@
<ItemGroup>
<PackageReference Include="AudibleApi" Version="10.1.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
<PackageReference Include="Google.Protobuf" Version="3.33.3" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,24 @@
using AudibleApi.Common;
using System;
using System.Linq;
namespace AudibleUtilities;
public static class Extensions
{
extension(Item item)
{
/// <summary>
/// Determines when your audible plus or free book will expire from your library
/// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used.
/// In some cases current date is later than end date so exclude.
/// </summary>
/// <returns>The DateTime that this title will become unavailable, otherwise null</returns>
public DateTime? GetExpirationDate()
=> item.Plans
?.Where(p => p.IsAyce)
.Select(p => p.EndDate)
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
?.DateTime;
}
}

View File

@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -0,0 +1,499 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20260107224301_AddIsAudiblePlus")]
partial class AddIsAudiblePlus
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
b.Property<string>("AudibleProductId")
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsAbridged")
.HasColumnType("boolean");
b.Property<bool>("IsSpatial")
.HasColumnType("boolean");
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.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("smallint");
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
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("boolean");
b.Property<string>("Account")
.HasColumnType("text");
b.Property<DateTime>("DateAdded")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
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<bool>("IsFinished")
.HasColumnType("boolean");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("timestamp without time zone");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("text");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("bigint");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("text");
b1.Property<int?>("PdfStatus")
.HasColumnType("integer");
b1.Property<string>("Tags")
.IsRequired()
.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")
.IsRequired();
});
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,101 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddIsAudiblePlus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "real",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "real",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "real",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsAudiblePlus",
table: "LibraryBooks",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAudiblePlus",
table: "LibraryBooks");
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "real",
nullable: true,
oldClrType: typeof(float),
oldType: "real");
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "real",
nullable: true,
oldClrType: typeof(float),
oldType: "real");
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "real",
nullable: true,
oldClrType: typeof(float),
oldType: "real");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -210,6 +210,9 @@ namespace DataLayer.Postgres.Migrations
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
@@ -351,6 +354,7 @@ namespace DataLayer.Postgres.Migrations
.HasColumnType("integer");
b1.Property<string>("Tags")
.IsRequired()
.HasColumnType("text");
b1.HasKey("BookId");
@@ -384,7 +388,8 @@ namespace DataLayer.Postgres.Migrations
b1.Navigation("Book");
b1.Navigation("Rating");
b1.Navigation("Rating")
.IsRequired();
});
b.Navigation("Rating");

View File

@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -0,0 +1,482 @@
// <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("20260107224303_AddIsAudiblePlus")]
partial class AddIsAudiblePlus
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
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<bool>("IsSpatial")
.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.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<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("INTEGER");
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<bool>("IsFinished")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.IsRequired()
.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")
.IsRequired();
});
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,101 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddIsAudiblePlus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "TEXT",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "REAL",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "REAL",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "REAL",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsAudiblePlus",
table: "LibraryBooks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAudiblePlus",
table: "LibraryBooks");
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT");
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "REAL",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "REAL",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "REAL",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -197,6 +197,9 @@ namespace DataLayer.Migrations
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
@@ -334,6 +337,7 @@ namespace DataLayer.Migrations
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.IsRequired()
.HasColumnType("TEXT");
b1.HasKey("BookId");
@@ -367,7 +371,8 @@ namespace DataLayer.Migrations
b1.Navigation("Book");
b1.Navigation("Rating");
b1.Navigation("Rating")
.IsRequired();
});
b.Navigation("Rating");

View File

@@ -13,12 +13,12 @@
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -15,6 +15,7 @@ namespace DataLayer
public bool AbsentFromLastScan { get; set; }
public DateTime? IncludedUntil { get; private set; }
public bool IsAudiblePlus { get; set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{
@@ -28,6 +29,7 @@ namespace DataLayer
public void SetAccount(string account) => Account = account;
public void SetIncludedUntil(DateTime? includedUntil) => IncludedUntil = includedUntil;
public void SetIsAudiblePlus(bool isAudiblePlus) => IsAudiblePlus = isAudiblePlus;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

View File

@@ -7,10 +7,11 @@ using System.Text;
namespace DataLayer;
public class MockLibraryBook : LibraryBook
{
protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil)
protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil, bool isAudiblePlus)
: base(book, dateAdded, account)
{
SetIncludedUntil(includedUntil);
SetIsAudiblePlus(isAudiblePlus);
}
public MockLibraryBook AddSeries(string seriesName, int order)
@@ -76,6 +77,7 @@ public class MockLibraryBook : LibraryBook
DateTime? dateAdded = null,
DateTime? datePublished = null,
DateTime? includedUntil = null,
bool isAudiblePlus = false,
string title = "Mock Book Title",
string subtitle = "Mock Book Subtitle",
string description = "This is a mock book description.",
@@ -115,7 +117,8 @@ public class MockLibraryBook : LibraryBook
book,
dateAdded ?? DateTime.Now,
account,
includedUntil)
includedUntil,
isAudiblePlus)
{
AbsentFromLastScan = absetFromLastScan
};

View File

@@ -47,7 +47,8 @@ namespace DataLayer
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.Where(lb => lb.IsDeleted)
//Return all parents so the trash bin grid can show podcasts beneath their parents
.Where(lb => lb.IsDeleted || lb.Book.ContentType == ContentType.Parent)
.getLibrary()
.ToList();

View File

@@ -18,6 +18,14 @@ namespace DtoImporterService
private SeriesImporter seriesImporter { get; }
private CategoryImporter categoryImporter { get; }
/// <summary>
/// Indicates whether <see cref="BookImporter"/> loaded every Book from the <seealso cref="LibationContext"/> during import.
/// If true, the DbContext was queried for all Books, rather than just those being imported.
/// If means that all <see cref="LibraryBook"/> objects in the DbContext will have their <see cref="LibraryBook.Book"/> property populated.
/// If false, only those Books being imported were loaded, and some <see cref="LibraryBook"/> objects will have a null <see cref="LibraryBook.Book"/> property for books not included in the import set.
/// </summary>
internal bool LoadedEntireLibrary {get; private set; }
public BookImporter(LibationContext context) : base(context)
{
contributorImporter = new ContributorImporter(DbContext);
@@ -56,6 +64,7 @@ namespace DtoImporterService
.ToArray()
.Where(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
LoadedEntireLibrary = true;
}
else
{
@@ -69,16 +78,16 @@ namespace DtoImporterService
{
var qtyNew = 0;
foreach (var item in importItems)
{
if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book))
foreach (var item in importItems)
{
book = createNewBook(item);
qtyNew++;
}
if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book))
{
book = createNewBook(item);
qtyNew++;
}
updateBook(item, book);
}
updateBook(item, book);
}
return qtyNew;
}
@@ -160,6 +169,14 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
// Replacing narrators only became necessary to correct a bug introduced in 13.1.0
// which would no import narrators with null ASINs. Thus, affected books had the
// author listed as the narrators. This can probably be removed in the future.
// Bug went live in 13.1.0 on 2026/01/02. Today is 2026/01/08.
var narrators = item.Narrators?.DistinctBy(a => a.Name).Select(n => contributorImporter.Cache[n.Name]).ToArray();
if (narrators is not null && narrators.Length > 0)
book.ReplaceNarrators(narrators);
book.UpdateLengthInMinutes(item.LengthInMinutes);
// Update the book titles, since formatting can change

View File

@@ -87,7 +87,7 @@ namespace DtoImporterService
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList);
Serilog.Log.Logger.Error(ex, "Error adding category ladder.");
throw;
}
}

View File

@@ -6,138 +6,135 @@ using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
namespace DtoImporterService
namespace DtoImporterService;
public class LibraryBookImporter : ItemsImporterBase
{
public class LibraryBookImporter : ItemsImporterBase
protected override IValidator Validator => new LibraryValidator();
private BookImporter bookImporter { get; }
public LibraryBookImporter(LibationContext context) : base(context)
{
protected override IValidator Validator => new LibraryValidator();
private BookImporter bookImporter { get; }
public LibraryBookImporter(LibationContext context) : base(context)
{
bookImporter = new BookImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
int qtyNew = 0;
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
existing = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
try
{
DbContext.LibraryBooks.Add(existing);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account });
}
}
existing.SetIncludedUntil(GetExpirationDate(item));
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
return qtyNew;
}
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
/// <summary>
/// Determines when your audible plus or free book will expire from your library
/// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used.
/// In some cases current date is later than end date so exclude.
/// </summary>
/// <returns>The DateTime that this title will become unavailable, otherwise null</returns>
private static DateTime? GetExpirationDate(ImportItem item)
=> item.DtoItem.Plans
?.Where(p => p.IsAyce)
.Select(p => p.EndDate)
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
?.DateTime;
bookImporter = new BookImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
int qtyNew = 0;
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
existing = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
try
{
DbContext.LibraryBooks.Add(existing);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account });
}
}
existing.SetIncludedUntil(item.DtoItem.GetExpirationDate());
existing.SetIsAudiblePlus(item.DtoItem.IsAyce is true);
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToHashSet();
var allInScannedAccounts = DbContext.LibraryBooks.Where(lb => scannedAccounts.Contains(lb.Account)).ToArray();
if (bookImporter.LoadedEntireLibrary)
{
//If the entire library was loaded, we can be sure that all existing LibraryBooks have their Book property populated.
//Find LibraryBooks which have a Book but weren't found in the import, and mark them as absent.
foreach (var absentBook in allInScannedAccounts.Where(lb => !uniqueImportItems.ContainsKey(lb.Book.AudibleProductId)))
absentBook.AbsentFromLastScan = true;
}
else
{
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in allInScannedAccounts.Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
}
return qtyNew;
}
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
}

View File

@@ -67,7 +67,7 @@ public static class AudioFormatDecoder
var mpegSize = mp3File.Length - mp3File.Position;
if (mpegSize < 64)
{
Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename);
Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {File}", mp3Filename);
return AudioFormat.Default;
}
@@ -80,7 +80,7 @@ public static class AudioFormatDecoder
if (layerDesc is not Layer.Layer_3)
{
Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString());
Serilog.Log.Logger.Warning("Could not read mp3 data from {layerVersion} file.", layerDesc);
return AudioFormat.Default;
}

View File

@@ -427,7 +427,7 @@ namespace FileLiberator
{
//Failure to download cover art should not be considered a failure to download the book
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook.LogFriendly(), coverPath);
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {coverPath}.", options.LibraryBook.LogFriendly(), coverPath);
throw;
}
}
@@ -476,7 +476,7 @@ namespace FileLiberator
{
//Failure to download records should not be considered a failure to download the book
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {@recordsPath}.", options.LibraryBook.LogFriendly(), recordsPath);
Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {recordsPath}.", options.LibraryBook.LogFriendly(), recordsPath);
throw;
}
}
@@ -512,7 +512,7 @@ namespace FileLiberator
{
//Failure to download metadata should not be considered a failure to download the book
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading metadata of {@Book} to {@metadataFile}.", options.LibraryBook.LogFriendly(), metadataPath);
Serilog.Log.Logger.Error(ex, "Error downloading metadata of {@Book} to {metadataFile}.", options.LibraryBook.LogFriendly(), metadataPath);
throw;
}
}
@@ -523,12 +523,12 @@ namespace FileLiberator
{
Serilog.Log.Verbose("Getting destination directory for {@Book}", libraryBook.LogFriendly());
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
Serilog.Log.Verbose("Got destination directory for {@Book}. {@Directory}", libraryBook.LogFriendly(), destinationDir);
Serilog.Log.Verbose("Got destination directory for {@Book}. {Directory}", libraryBook.LogFriendly(), destinationDir);
if (!Directory.Exists(destinationDir))
{
Serilog.Log.Verbose("Creating destination {@Directory}", destinationDir);
Serilog.Log.Verbose("Creating destination {Directory}", destinationDir);
Directory.CreateDirectory(destinationDir);
Serilog.Log.Verbose("Created destination {@Directory}", destinationDir);
Serilog.Log.Verbose("Created destination {Directory}", destinationDir);
}
return destinationDir;
}

View File

@@ -56,7 +56,7 @@ public partial class DownloadOptions
}
else if (metadata.ContentReference != license.ContentMetadata.ContentReference)
{
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {@DrmType}. {@Metadata}. {@License} ",
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ",
license.DrmType,
metadata.ContentReference,
license.ContentMetadata.ContentReference);
@@ -111,7 +111,7 @@ public partial class DownloadOptions
if (canUseWidevine)
Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM.");
else
Serilog.Log.Logger.Warning("Account {@account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
}
token.ThrowIfCancellationRequested();
@@ -170,17 +170,6 @@ public partial class DownloadOptions
/// </summary>
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
= config.StripAudibleBrandAudio
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
{
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
@@ -190,18 +179,22 @@ public partial class DownloadOptions
if (config.MergeOpeningAndEndCredits)
combineCredits(chapters);
if (config.StripAudibleBrandAudio)
stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
if (config.SplitFilesByChapter)
combineShortChapters(chapters, config.MinimumFileDuration * 1000);
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
{
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)),
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
//Build AAXClean.ChapterInfo
for (int i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= chapterStartMs;
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
dlOptions.ChapterInfo.AddChapter(chapters[i].Title, TimeSpan.FromMilliseconds(chapters[i].LengthMs));
}
return dlOptions;
@@ -349,6 +342,50 @@ public partial class DownloadOptions
return chaps;
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than minChapterLength but still has some audio frames, those
frames are combined with the following chapter and not split into a new file.
When 2 or more consecutive chapters are combined, chapter titles are concatenated
with a apace in between. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1 Chapter 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
*/
public static void combineShortChapters(List<Chapter> chapters, long minChapterLengthMs)
{
for (int i = 0; i < chapters.Count; i++)
{
while (chapters[i].LengthMs < minChapterLengthMs && chapters.Count > i + 1)
{
chapters[i].Title += " " + chapters[i + 1].Title;
chapters[i].LengthMs += chapters[i + 1].LengthMs;
chapters.RemoveAt(i + 1);
}
}
}
public static void stripBranding(List<Chapter> chapters, long introMs, long outroMs)
{
chapters[0].LengthMs -= introMs;
chapters[0].StartOffsetMs += introMs;
chapters[^1].LengthMs -= outroMs;
}
public static void combineCredits(IList<Chapter> chapters)
{
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")

View File

@@ -46,7 +46,7 @@ namespace FileManager
if (!Directory.Exists(directoryName))
return false;
Serilog.Log.Logger.Debug("Testing write permissions for directory: {@DirectoryName}", directoryName);
Serilog.Log.Logger.Debug("Testing write permissions for directory: {DirectoryName}", directoryName);
var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString());
return CanWriteFile(testFilePath);
}
@@ -55,9 +55,9 @@ namespace FileManager
{
try
{
Serilog.Log.Logger.Debug("Testing ability to write filename: {@filename}", filename);
Serilog.Log.Logger.Debug("Testing ability to write filename: {filename}", filename);
File.WriteAllBytes(filename, []);
Serilog.Log.Logger.Debug("Deleting test file after successful write: {@filename}", filename);
Serilog.Log.Logger.Debug("Deleting test file after successful write: {filename}", filename);
try
{
FileUtility.SaferDelete(filename);
@@ -65,13 +65,13 @@ namespace FileManager
catch (Exception ex)
{
//An error deleting the file doesn't constitute a write failure.
Serilog.Log.Logger.Debug(ex, "Error deleting test file: {@filename}", filename);
Serilog.Log.Logger.Debug(ex, "Error deleting test file: {filename}", filename);
}
return true;
}
catch (Exception ex)
{
Serilog.Log.Logger.Debug(ex, "Error writing test file: {@filename}", filename);
Serilog.Log.Logger.Debug(ex, "Error writing test file: {filename}", filename);
return false;
}
}

View File

@@ -142,7 +142,7 @@ namespace FileManager
File.WriteAllText(Filepath, endContents);
success = true;
}
Serilog.Log.Logger.Information("Removed property. {@DebugInfo}", propertyName);
Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName);
}
catch { }

View File

@@ -70,11 +70,11 @@
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.9" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.10" />
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@@ -97,7 +97,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public void Reload()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
var deletedBooks = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.Book.ContentType is not ContentType.Parent);
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));

View File

@@ -54,7 +54,7 @@ namespace HangoverWinForms
deletedCbl.Items.Clear();
List<LibraryBook> deletedBooks = DbContexts.GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
foreach (var lb in deletedBooks.Where(lb => lb.Book.ContentType is not ContentType.Parent))
deletedCbl.Items.Add(lb);
setLabel();

View File

@@ -101,6 +101,29 @@
<Setter Property="BorderThickness" Value="2" />
</Style>
</Style>
<Style Selector="NumericUpDown.SmallNumericUpDown">
<Setter Property="Height" Value="{DynamicResource TextControlThemeMinHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Style Selector="^ /template/ ButtonSpinner#PART_Spinner">
<Style Selector="^ RepeatButton#PART_IncreaseButton">
<Setter Property="MinWidth" Value="24" />
<Style Selector="^ > PathIcon">
<Setter Property="Data">
M0,0 l8,-10 l8,10 Z
</Setter>
</Style>
</Style>
<Style Selector="^ RepeatButton#PART_DecreaseButton">
<Setter Property="MinWidth" Value="24" />
<Style Selector="^ > PathIcon">
<Setter Property="Data">
M0,0 l8,10 l8,-10 Z
</Setter>
</Style>
</Style>
</Style>
</Style>
</Application.Styles>
<NativeMenu.Menu>

View File

@@ -1,119 +1,121 @@
using Avalonia.Collections;
using Avalonia.Controls;
using LibationUiBase.GridView;
using Avalonia.Input;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace LibationAvalonia.Controls
namespace LibationAvalonia.Controls;
public class DataGridCellContextMenu<TContext> where TContext : class
{
internal static class DataGridContextMenus
public static DataGridCellContextMenu<TContext>? Create(ContextMenu? contextMenu)
{
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs>? CellContextMenuStripNeeded;
private static readonly ContextMenu ContextMenu = new();
public static readonly AvaloniaList<Control> MenuItems = new();
private static readonly PropertyInfo OwningColumnProperty;
private static readonly PropertyInfo OwningGridProperty;
static DataGridContextMenus()
DataGrid? grid = null;
DataGridCell? cell = null;
var parent = contextMenu?.Parent;
while (parent is not null && grid is null)
{
ContextMenu.ItemsSource = MenuItems;
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Could not find OwningColumn property on DataGridCell");
OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Could not find OwningGrid property on DataGridColumn");
grid ??= parent as DataGrid;
cell ??= parent as DataGridCell;
parent = parent.Parent;
}
public static void AttachContextMenu(this DataGridCell cell)
if (grid is null || cell is null || cell.Tag is not DataGridColumn column || contextMenu!.DataContext is not TContext clickedEntry)
return null;
var allSelected = grid.SelectedItems.OfType<TContext>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
{
if (cell is not null && cell.ContextMenu is null)
{
cell.ContextRequested += Cell_ContextRequested;
cell.ContextMenu = ContextMenu;
}
//User didn't right-click on a selected cell
grid.SelectedItem = clickedEntry;
allSelected = [clickedEntry];
}
else if (clickedIndex > 0)
{
//Ensure the clicked entry is first in the list
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
}
private static void Cell_ContextRequested(object? sender, ContextRequestedEventArgs e)
return new DataGridCellContextMenu<TContext>(contextMenu, grid, column, allSelected);
}
public string CellClipboardContents
{
get
{
if (sender is DataGridCell cell &&
cell.DataContext is GridEntry clickedEntry &&
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
OwningGridProperty.GetValue(column) is DataGrid grid)
var lines = GetClipboardLines(getClickedCell: true);
return lines.Count >= 1 ? lines[0] : string.Empty;
}
}
public string GetRowClipboardContents() => string.Join(Environment.NewLine, GetClipboardLines(false));
public ContextMenu ContextMenu { get; }
public DataGrid Grid { get; }
public DataGridColumn Column { get; }
public TContext[] RowItems { get; }
public AvaloniaList<Control> ContextMenuItems { get; }
private DataGridCellContextMenu(ContextMenu contextMenu, DataGrid grid, DataGridColumn column, TContext[] rowItems)
{
Grid = grid;
Column = column;
RowItems = rowItems;
ContextMenu = contextMenu;
ContextMenuItems = contextMenu.ItemsSource as AvaloniaList<Control> ?? new();
contextMenu.ItemsSource = ContextMenuItems;
ContextMenuItems.Clear();
}
private List<string> GetClipboardLines(bool getClickedCell)
{
if (RowItems is null || RowItems.Length == 0)
return [];
List<string> lines = [];
Grid.CopyingRowClipboardContent += Grid_CopyingRowClipboardContent;
Grid.RaiseEvent(GetCopyEventArgs());
Grid.CopyingRowClipboardContent -= Grid_CopyingRowClipboardContent;
return lines;
void Grid_CopyingRowClipboardContent(object? sender, DataGridRowClipboardEventArgs e)
{
if (getClickedCell)
{
var allSelected = grid.SelectedItems.OfType<GridEntry>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
if (e.IsColumnHeadersRow)
return;
var cellContent = e.ClipboardRowContent.FirstOrDefault(c => c.Column == Column);
if (cellContent.Column is not null)
{
//User didn't right-click on a selected cell
grid.SelectedItem = clickedEntry;
allSelected = [clickedEntry];
lines.Add(cellContent.Content?.ToString() ?? string.Empty);
}
else if (clickedIndex > 0)
{
//Ensure the clicked entry is first in the list
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
}
var args = new DataGridCellContextMenuStripNeededEventArgs
{
Column = column,
Grid = grid,
GridEntries = allSelected,
ContextMenu = ContextMenu
};
args.ContextMenuItems.Clear();
CellContextMenuStripNeeded?.Invoke(sender, args);
e.Handled = args.ContextMenuItems.Count == 0;
}
else if (e.Item == RowItems[0])
lines.Insert(1, FormatClipboardRowContent(e));
else
e.Handled = true;
lines.Add(FormatClipboardRowContent(e));
//Clear so that the DataGrid copy implementation doesn't set the clipboard
e.ClipboardRowContent.Clear();
}
}
public class DataGridCellContextMenuStripNeededEventArgs
private static KeyEventArgs GetCopyEventArgs() => new()
{
private static readonly MethodInfo GetCellValueMethod;
static DataGridCellContextMenuStripNeededEventArgs()
{
GetCellValueMethod = typeof(DataGridColumn).GetMethod("GetCellValue", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Could not find GetCellValue method on DataGridColumn");
}
Key = Key.C,
KeyModifiers = KeyModifiers.Control,
Route = Avalonia.Interactivity.RoutingStrategies.Bubble,
PhysicalKey = PhysicalKey.C,
KeySymbol = "c",
KeyDeviceType = KeyDeviceType.Keyboard,
RoutedEvent = InputElement.KeyDownEvent
};
private static string GetCellValue(DataGridColumn column, object item)
=> GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? "";
private string FormatClipboardRowContent(DataGridRowClipboardEventArgs e)
=> string.Join("\t", e.ClipboardRowContent.Select(c => RemoveLineBreaks(c.Content?.ToString())));
private static string RemoveLineBreaks(string? text)
=> text?.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ') ?? "";
public string CellClipboardContents => GetCellValue(Column, GridEntries[0]);
public string GetRowClipboardContents()
{
if (GridEntries is null || GridEntries.Length == 0)
return string.Empty;
else if (GridEntries.Length == 1)
return HeaderNames + Environment.NewLine + GetRowClipboardContents(GridEntries[0]);
else
return string.Join(Environment.NewLine, GridEntries.Select(GetRowClipboardContents).Prepend(HeaderNames));
}
private string HeaderNames
=> string.Join("\t",
Grid.Columns
.Where(c => c.IsVisible)
.OrderBy(c => c.DisplayIndex)
.Select(c => RemoveLineBreaks(c.Header.ToString() ?? "")));
private static string RemoveLineBreaks(string text)
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
private string GetRowClipboardContents(GridEntry gridEntry)
{
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
return string.Join("\t", contents);
}
public required DataGrid Grid { get; init; }
public required DataGridColumn Column { get; init; }
public required GridEntry[] GridEntries { get; init; }
public required ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems => DataGridContextMenus.MenuItems;
}
}

View File

@@ -25,7 +25,7 @@ namespace LibationAvalonia.Controls
IsEditingMode = false
};
cell?.AttachContextMenu();
cell.Tag = this;
if (!IsReadOnly)
ToolTip.SetTip(myRatingElement, "Click to change ratings");

View File

@@ -6,7 +6,7 @@ namespace LibationAvalonia.Controls
{
protected override Control GenerateElement(DataGridCell cell, object dataItem)
{
cell?.AttachContextMenu();
cell.Tag = this;
return base.GenerateElement(cell, dataItem);
}
}

View File

@@ -6,6 +6,7 @@
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudioSettingsVM"
x:CompileBindings="True"
x:Class="LibationAvalonia.Controls.Settings.Audio">
<Grid
@@ -38,43 +39,43 @@
<Grid ColumnDefinitions="*,Auto">
<TextBlock
VerticalAlignment="Center"
Text="{CompiledBinding FileDownloadQualityText}" />
Text="{Binding FileDownloadQualityText}" />
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
ItemsSource="{CompiledBinding DownloadQualities}"
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
ItemsSource="{Binding DownloadQualities}"
SelectedItem="{Binding FileDownloadQuality}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<CheckBox
ToolTip.Tip="{CompiledBinding UseWidevineTip}"
ToolTip.Tip="{Binding UseWidevineTip}"
IsCheckedChanged="UseWidevine_IsCheckedChanged"
IsChecked="{CompiledBinding UseWidevine, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding UseWidevineText}" />
IsChecked="{Binding UseWidevine, Mode=TwoWay}">
<TextBlock Text="{Binding UseWidevineText}" />
</CheckBox>
<CheckBox
Grid.Column="1"
ToolTip.Tip="{CompiledBinding Request_xHE_AACTip}"
IsEnabled="{CompiledBinding UseWidevine}"
IsChecked="{CompiledBinding Request_xHE_AAC, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding Request_xHE_AACText}" />
ToolTip.Tip="{Binding Request_xHE_AACTip}"
IsEnabled="{Binding UseWidevine}"
IsChecked="{Binding Request_xHE_AAC, Mode=TwoWay}">
<TextBlock Text="{Binding Request_xHE_AACText}" />
</CheckBox>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<CheckBox
ToolTip.Tip="{CompiledBinding RequestSpatialTip}"
IsEnabled="{CompiledBinding UseWidevine}"
IsChecked="{CompiledBinding RequestSpatial, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding RequestSpatialText}" />
ToolTip.Tip="{Binding RequestSpatialTip}"
IsEnabled="{Binding UseWidevine}"
IsChecked="{Binding RequestSpatial, Mode=TwoWay}">
<TextBlock Text="{Binding RequestSpatialText}" />
</CheckBox>
<Grid
Grid.Column="1"
ColumnDefinitions="Auto,Auto"
VerticalAlignment="Top"
ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}">
ToolTip.Tip="{Binding SpatialAudioCodecTip}">
<Grid.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<MultiBinding.Bindings>
@@ -92,78 +93,100 @@
Margin="5,0,0,0"
Grid.Column="1"
VerticalAlignment="Center"
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
ItemsSource="{Binding SpatialAudioCodecs}"
SelectedItem="{Binding SpatialAudioCodec}"/>
</Grid>
</Grid>
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
<CheckBox IsChecked="{Binding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{Binding CreateCueSheetText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding DownloadCoverArt, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding DownloadCoverArtText}" />
<CheckBox IsChecked="{Binding DownloadCoverArt, Mode=TwoWay}">
<TextBlock Text="{Binding DownloadCoverArtText}" />
</CheckBox>
<Grid ColumnDefinitions="*,Auto">
<CheckBox IsChecked="{CompiledBinding DownloadClipsBookmarks, Mode=TwoWay}">
<CheckBox IsChecked="{Binding DownloadClipsBookmarks, Mode=TwoWay}">
<TextBlock Text="Download Clips, Notes and Bookmarks as" />
</CheckBox>
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
IsEnabled="{CompiledBinding DownloadClipsBookmarks}"
ItemsSource="{CompiledBinding ClipBookmarkFormats}"
SelectedItem="{CompiledBinding ClipBookmarkFormat}"/>
IsEnabled="{Binding DownloadClipsBookmarks}"
ItemsSource="{Binding ClipBookmarkFormats}"
SelectedItem="{Binding ClipBookmarkFormat}"/>
</Grid>
<CheckBox
IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding RetainAaxFileTip}">
<TextBlock Text="{CompiledBinding RetainAaxFileText}" />
IsChecked="{Binding RetainAaxFile, Mode=TwoWay}"
ToolTip.Tip="{Binding RetainAaxFileTip}">
<TextBlock Text="{Binding RetainAaxFileText}" />
</CheckBox>
<CheckBox
IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MergeOpeningAndEndCreditsTip}">
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
IsChecked="{Binding MergeOpeningAndEndCredits, Mode=TwoWay}"
ToolTip.Tip="{Binding MergeOpeningAndEndCreditsTip}">
<TextBlock Text="{Binding MergeOpeningEndCreditsText}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{CompiledBinding CombineNestedChapterTitlesTip}"
IsChecked="{CompiledBinding CombineNestedChapterTitles, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CombineNestedChapterTitlesText}" />
ToolTip.Tip="{Binding CombineNestedChapterTitlesTip}"
IsChecked="{Binding CombineNestedChapterTitles, Mode=TwoWay}">
<TextBlock Text="{Binding CombineNestedChapterTitlesText}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{CompiledBinding AllowLibationFixupTip}"
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
ToolTip.Tip="{Binding AllowLibationFixupTip}"
IsChecked="{Binding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{Binding AllowLibationFixupText}" />
</CheckBox>
<controls:GroupBox
Grid.Row="1"
Label="Audiobook Fix-ups"
IsEnabled="{CompiledBinding AllowLibationFixup}">
IsEnabled="{Binding AllowLibationFixup}">
<StackPanel Orientation="Vertical">
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
<Grid
Margin="0,0,0,5"
Grid.ColumnDefinitions="Auto,Auto"
Grid.RowDefinitions="Auto,Auto">
<CheckBox Margin="0" Grid.ColumnSpan="2" IsChecked="{Binding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Grid.ColumnSpan="2" Text="{Binding SplitFilesByChapterText}" />
</CheckBox>
<TextBlock Grid.Row="1" Margin="15,0" VerticalAlignment="Center" ToolTip.Tip="{Binding MinimumFileDurationTip}" Text="{Binding MinimumFileDurationText}" />
<NumericUpDown
Classes="SmallNumericUpDown"
Grid.Column="1"
Grid.Row="1"
ToolTip.Tip="{Binding MinimumFileDurationTip}"
MinWidth="100"
Minimum="0"
Maximum="120"
Increment="1"
FormatString="N0"
ParsingNumberStyle="Integer"
IsEnabled="{Binding SplitFilesByChapter}"
Value="{Binding MinimumFileDuration, Mode=TwoWay}"/>
</Grid>
<CheckBox
IsChecked="{Binding StripAudibleBrandAudio, Mode=TwoWay}"
ToolTip.Tip="{Binding StripAudibleBrandAudioTip}">
<TextBlock Text="{Binding StripAudibleBrandingText}" />
</CheckBox>
<CheckBox
IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripAudibleBrandAudioTip}">
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
</CheckBox>
<CheckBox
IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripUnabridgedTip}">
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
IsChecked="{Binding StripUnabridged, Mode=TwoWay}"
ToolTip.Tip="{Binding StripUnabridgedTip}">
<TextBlock Text="{Binding StripUnabridgedText}" />
</CheckBox>
</StackPanel>
</controls:GroupBox>
@@ -178,24 +201,24 @@
Margin="10,0,0,0">
<RadioButton
IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
IsChecked="{Binding !DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{Binding DecryptToLossyTip}">
<StackPanel VerticalAlignment="Center">
<TextBlock
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
IsEnabled="{CompiledBinding !DecryptToLossy}"
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MoveMoovToBeginningTip}">
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
IsEnabled="{Binding !DecryptToLossy}"
IsChecked="{Binding MoveMoovToBeginning, Mode=TwoWay}"
ToolTip.Tip="{Binding MoveMoovToBeginningTip}">
<TextBlock Text="{Binding MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<RadioButton
IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
IsChecked="{Binding DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{Binding DecryptToLossyTip}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books as .MP3 files (transcode if necessary)" />
@@ -203,7 +226,7 @@
<controls:GroupBox
Grid.Column="1"
IsEnabled="{CompiledBinding DecryptToLossy}"
IsEnabled="{Binding DecryptToLossy}"
Label="Mp3 Encoding Options">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
@@ -220,21 +243,21 @@
<RadioButton
Margin="5"
Content="Bitrate"
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
IsChecked="{Binding LameTargetBitrate, Mode=TwoWay}"/>
<RadioButton
Grid.Column="1"
Margin="5"
Content="Quality"
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
IsChecked="{Binding !LameTargetBitrate, Mode=TwoWay}"/>
</Grid>
</controls:GroupBox>
<CheckBox
HorizontalAlignment="Right"
Grid.Column="1"
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding LameDownsampleMonoTip}">
IsChecked="{Binding LameDownsampleMono, Mode=TwoWay}"
ToolTip.Tip="{Binding LameDownsampleMonoTip}">
<TextBlock
TextWrapping="Wrap"
@@ -249,8 +272,8 @@
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding SampleRates}"
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
ItemsSource="{Binding SampleRates}"
SelectedItem="{Binding SelectedSampleRate, Mode=TwoWay}"/>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
@@ -258,23 +281,23 @@
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding EncoderQualities}"
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
ItemsSource="{Binding EncoderQualities}"
SelectedItem="{Binding SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<controls:GroupBox
Grid.Row="2"
Margin="0,5"
Label="Bitrate"
IsEnabled="{CompiledBinding LameTargetBitrate}" >
IsEnabled="{Binding LameTargetBitrate}" >
<StackPanel>
<Grid ColumnDefinitions="*,25,Auto">
<Slider
Grid.Column="0"
IsEnabled="{CompiledBinding !LameMatchSource}"
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
IsEnabled="{Binding !LameMatchSource}"
Value="{Binding LameBitrate, Mode=TwoWay}"
Minimum="16"
Maximum="320"
IsSnapToTickEnabled="True" TickFrequency="16"
@@ -283,7 +306,7 @@
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Tip" Value="{Binding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
@@ -294,7 +317,7 @@
<TextBlock
Grid.Column="1"
HorizontalAlignment="Right"
Text="{CompiledBinding LameBitrate}" />
Text="{Binding LameBitrate}" />
<TextBlock
Grid.Column="2"
@@ -306,7 +329,7 @@
<CheckBox
Grid.Column="0"
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
IsChecked="{Binding LameConstantBitrate, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
@@ -317,7 +340,7 @@
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
IsChecked="{Binding LameMatchSource, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
@@ -332,7 +355,7 @@
Grid.Row="3"
Margin="0,5"
Label="Quality"
IsEnabled="{CompiledBinding !LameTargetBitrate}">
IsEnabled="{Binding !LameTargetBitrate}">
<Grid
ColumnDefinitions="*,Auto,25"
@@ -341,7 +364,7 @@
<Slider
Grid.Column="0"
Grid.ColumnSpan="2"
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
Value="{Binding LameVBRQuality, Mode=TwoWay}"
Minimum="0"
Maximum="9"
IsSnapToTickEnabled="True" TickFrequency="1"
@@ -349,7 +372,7 @@
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Tip" Value="{Binding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
@@ -363,7 +386,7 @@
Orientation="Horizontal">
<TextBlock Text="V" />
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
<TextBlock Text="{Binding LameVBRQuality}" />
</StackPanel>
@@ -397,8 +420,8 @@
Grid.Row="2"
Grid.ColumnSpan="2"
Margin="0,10,0,0"
IsEnabled="{CompiledBinding SplitFilesByChapter}"
Label="{CompiledBinding ChapterTitleTemplateText}">
IsEnabled="{Binding SplitFilesByChapter}"
Label="{Binding ChapterTitleTemplateText}">
<Grid ColumnDefinitions="*,Auto" Margin="0,8" >
@@ -406,7 +429,7 @@
Grid.Column="0"
FontSize="14"
IsReadOnly="True"
Text="{CompiledBinding ChapterTitleTemplate}" />
Text="{Binding ChapterTitleTemplate}" />
<Button
Grid.Column="1"

View File

@@ -4,49 +4,72 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="630" d:DesignHeight="480"
x:Class="LibationAvalonia.Dialogs.TrashBinDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
MinWidth="630" MinHeight="480"
Width="630" Height="480"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
xmlns:views="clr-namespace:LibationAvalonia.Views"
x:DataType="dialogs:TrashBinViewModel"
x:CompileBindings="True"
MinWidth="680" MinHeight="480"
Width="680" Height="480"
Title="Trash Bin"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
<Grid
RowDefinitions="Auto,*,Auto">
<Grid Margin="5"
RowDefinitions="Auto,Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="Check books you want to permanently delete from or restore to Libation" />
<TextBlock Text="Check books you want to permanently delete from or restore to Libation"/>
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid Margin="0,5" Grid.Row="1" Grid.ColumnDefinitions="Auto,*,Auto">
<TextBlock VerticalAlignment="Center" Text="Search Deleted Books:"/>
<TextBox Name="searchTb" Margin="5,0" Grid.Column="1" Text="{Binding FilterString}" IsEnabled="{Binding ControlsEnabled}">
<TextBox.KeyBindings>
<KeyBinding Command="{Binding FilterBtnAsync}" Gesture="Enter" />
</TextBox.KeyBindings>
</TextBox>
<Button Classes="SaveButton" Grid.Column="2" Command="{Binding FilterBtnAsync}" IsEnabled="{Binding ControlsEnabled}" VerticalAlignment="Stretch" Content="Filter" />
</Grid>
<views:ProductsDisplay
Grid.Row="2"
DisableContextMenu="True"
DisableColumnCustomization="True"
IsEnabled="{Binding $parent.((dialogs:TrashBinViewModel)DataContext).ControlsEnabled}"
DataContext="{Binding ProductsDisplay}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,*,Auto">
Margin="0,5,0,0"
Grid.Row="3"
ColumnDefinitions="Auto,Auto,Auto,Auto,*,Auto">
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
Margin="0,0,14,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
Margin="0,0,14,0"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<CheckBox
Grid.Column="2"
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,15,0"
IsChecked="{Binding AudiblePlusChecked}"
Content="Audible Plus Books" />
<TextBlock
Grid.Column="3"
VerticalAlignment="Center"
Text="{Binding AudiblePlusCheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Grid.Column="4"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
@@ -56,7 +79,7 @@
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Grid.Column="5"
Command="{Binding PermanentlyDeleteCheckedAsync}" >
<TextBlock
TextAlignment="Center"

View File

@@ -1,140 +1,159 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using DataLayer;
using LibationAvalonia.Controls;
using Dinah.Core.Collections.Generic;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
namespace LibationAvalonia.Dialogs;
public partial class TrashBinDialog : DialogWindow
{
public partial class TrashBinDialog : DialogWindow
private TrashBinViewModel VM { get; }
public TrashBinDialog()
{
public TrashBinDialog()
{
InitializeComponent();
DataContext = new TrashBinViewModel();
InitializeComponent();
SaveOnEnter = false;
ControlToFocusOnShow = searchTb;
DataContext = VM = new TrashBinViewModel();
this.Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
KeyBindings.Add(new Avalonia.Input.KeyBinding
{
Gesture = new Avalonia.Input.KeyGesture(Avalonia.Input.Key.Escape),
Command = ReactiveCommand.Create(Close)
});
}
}
public class TrashBinViewModel : ViewModelBase, IDisposable
{
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
public bool ControlsEnabled { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = true;
private bool? everythingChecked = false;
public bool? EverythingChecked
{
get => everythingChecked;
set
{
everythingChecked = value ?? false;
if (everythingChecked is true)
CheckAll();
else if (everythingChecked is false)
UncheckAll();
}
}
private int _totalBooksCount = 0;
private int _checkedBooksCount = -1;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
everythingChecked
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
: _checkedBooksCount == _totalBooksCount ? true
: null;
this.RaisePropertyChanged(nameof(EverythingChecked));
}
}
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
public TrashBinViewModel()
{
DeletedBooks = new()
{
ResetBehavior = ResetBehavior.Remove
};
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
Reload();
}
public void CheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = true;
}
public void UncheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = false;
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
private void Reload()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
_totalBooksCount = DeletedBooks.Count;
CheckedBooksCount = 0;
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object?, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
}
public void Dispose() => tracker?.Dispose();
Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
Loaded += async (_, _) => await VM.InitAsync();
}
}
public class TrashBinViewModel : ViewModelBase
{
private TempSearchEngine SearchEngine { get; } = new();
public ProductsDisplayViewModel ProductsDisplay { get; }
public string? CheckedCountText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? AudiblePlusCheckedCountText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public bool ControlsEnabled { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? FilterString { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
private bool? m_everythingChecked = false;
private bool? m_audiblePlusChecked = false;
public bool? EverythingChecked
{
get => m_everythingChecked;
set
{
m_everythingChecked = value ?? false;
SetVisibleChecked(_ => true, m_everythingChecked.Value);
}
}
public bool? AudiblePlusChecked
{
get => m_audiblePlusChecked;
set
{
m_audiblePlusChecked = value ?? false;
SetVisibleChecked(e => e.IsAudiblePlus, m_audiblePlusChecked.Value);
}
}
public TrashBinViewModel()
{
ProductsDisplay = new() { SearchEngine = SearchEngine };
ProductsDisplay.RemovableCountChanged += (_, _) => UpdateCounts();
ProductsDisplay.VisibleCountChanged += (_, _) => UpdateCounts();
}
public async Task InitAsync()
{
var deletedBooks = GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
await ProductsDisplay.BindToGridAsync(deletedBooks);
await ProductsDisplay.ScanAndRemoveBooksAsync();
ControlsEnabled = true;
}
private async Task ReloadAsync()
{
var deletedBooks = GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
await ProductsDisplay.UpdateGridAsync(deletedBooks);
}
public void SetVisibleChecked(Func<LibraryBook, bool> predicate, bool isChecked)
{
ProductsDisplay.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked);
}
private IEnumerable<LibraryBook> GetCheckedBooks() => ProductsDisplay.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook);
private void UpdateCounts()
{
var visible = ProductsDisplay.GetVisibleGridEntries().ToArray();
var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus);
var checkedCount = visible.Count(e => e.Remove is true);
var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true);
CheckedCountText = $"Checked: {checkedCount} of {visible.Length}";
AudiblePlusCheckedCountText = $"Checked: {plusCheckedCount} of {plusVisibleCount}";
bool? everythingChecked = checkedCount == 0 || visible.Length == 0 ? false
: checkedCount == visible.Length ? true
: null;
bool? audiblePlusChecked = plusCheckedCount == 0 || plusVisibleCount == 0 ? false
: plusCheckedCount == plusVisibleCount ? true
: null;
this.RaiseAndSetIfChanged(ref m_everythingChecked, everythingChecked, nameof(EverythingChecked));
this.RaiseAndSetIfChanged(ref m_audiblePlusChecked, audiblePlusChecked, nameof(AudiblePlusChecked));
}
public async Task FilterBtnAsync()
{
var lastGood = ProductsDisplay.FilterString;
try
{
await ProductsDisplay.Filter(FilterString);
}
catch
{
await ProductsDisplay.Filter(lastGood);
FilterString = lastGood;
}
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await GetCheckedBooks().RestoreBooksAsync();
if (qtyChanges > 0)
await ReloadAsync();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
await ReloadAsync();
ControlsEnabled = true;
}
private static List<LibraryBook> GetDeletedLibraryBooks()
{
#if DEBUG
if (Avalonia.Controls.Design.IsDesignMode)
{
return [
MockLibraryBook.CreateBook(title: "Mock Audible Plus Library Book 4", isAudiblePlus: true),
MockLibraryBook.CreateBook(title: "Mock Audible Plus Library Book 3", isAudiblePlus: true),
MockLibraryBook.CreateBook(title: "Mock Library Book 2"),
MockLibraryBook.CreateBook(title: "Mock Library Book 1"),
];
}
#endif
return DbContexts.GetDeletedLibraryBooks();
}
}

View File

@@ -73,12 +73,12 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.9" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.9" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.9" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
<PackageReference Include="Avalonia.Controls.ColorPicker" Version="11.3.10" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.10" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.3.10" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.10" />
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.10" />
<PackageReference Include="WebViewControlAvaloniaFree" Version="11.3.15" />
</ItemGroup>

View File

@@ -13,7 +13,7 @@ namespace LibationAvalonia.ViewModels
{
public Task? BindToGridTask { get; set; }
public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel();
public ProductsDisplayViewModel ProductsDisplay { get; } = new ProductsDisplayViewModel();
public ProductsDisplayViewModel ProductsDisplay { get; } = new() { SearchEngine = MainSearchEngine.Instance };
public double? DownloadProgress { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }

View File

@@ -34,18 +34,12 @@ namespace LibationAvalonia.ViewModels
public bool RemoveColumnVisible { get => field; private set => this.RaiseAndSetIfChanged(ref field, value); }
public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries?
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList()
?? SOURCE
.OfType<LibraryBookEntry>()
.Select(lbe => lbe.LibraryBook)
.ToList();
=> GetVisibleGridEntries().Select(lbe => lbe.LibraryBook).ToList();
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> SOURCE
.BookEntries();
public IEnumerable<LibraryBookEntry> GetVisibleGridEntries()
=> (FilteredInGridEntries as IEnumerable<GridEntry> ?? SOURCE).OfType<LibraryBookEntry>();
private IEnumerable<LibraryBookEntry> GetAllBookEntries() => SOURCE.BookEntries();
public ProductsDisplayViewModel()
{
@@ -53,6 +47,8 @@ namespace LibationAvalonia.ViewModels
VisibleCountChanged?.Invoke(this, 0);
}
public ISearchEngine? SearchEngine { get; set; }
private static readonly System.Reflection.MethodInfo? SetFlagsMethod;
/// <summary>
@@ -120,7 +116,8 @@ namespace LibationAvalonia.ViewModels
}
//Create the filtered-in list before adding entries to GridEntries to avoid a refresh or UI action
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(searchResultSet);
// Adding SOURCE to the DataGridViewCollection _after_ building the SOURCE list
//Saves ~500 ms on a library of ~4500 books.
@@ -315,7 +312,8 @@ namespace LibationAvalonia.ViewModels
if (SOURCE.Count == 0)
return;
FilteredInGridEntries = SOURCE.FilterEntries(searchString);
var results = SearchEngine?.GetSearchResultSet(searchString);
FilteredInGridEntries = SOURCE.FilterEntries(results);
await refreshGrid();
}
@@ -334,7 +332,9 @@ namespace LibationAvalonia.ViewModels
private async void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs? e)
{
var filterResults = SOURCE.FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
var filterResults = SOURCE.FilterEntries(searchResultSet);
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
@@ -454,7 +454,7 @@ namespace LibationAvalonia.ViewModels
#endregion
#region Column Widths
public bool DisablePersistColumnWidths { get; set; }
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); }
@@ -480,6 +480,7 @@ namespace LibationAvalonia.ViewModels
private void setColumnWidth(string columnName, DataGridLength width, [CallerMemberName] string propertyName = "")
{
if (DisablePersistColumnWidths) return;
var dictionary = Configuration.Instance.GridColumnsWidths;
var newValue = (int)width.DisplayValue;

View File

@@ -39,6 +39,7 @@ namespace LibationAvalonia.ViewModels.Settings
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
SplitFilesByChapter = config.SplitFilesByChapter;
MinimumFileDuration = config.MinimumFileDuration;
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
StripAudibleBrandAudio = config.StripAudibleBrandAudio;
StripUnabridged = config.StripUnabridged;
@@ -71,6 +72,7 @@ namespace LibationAvalonia.ViewModels.Settings
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
config.SplitFilesByChapter = SplitFilesByChapter;
config.MinimumFileDuration = MinimumFileDuration;
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
config.StripAudibleBrandAudio = StripAudibleBrandAudio;
config.StripUnabridged = StripUnabridged;
@@ -118,6 +120,7 @@ namespace LibationAvalonia.ViewModels.Settings
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));
public string MinimumFileDurationText { get; } = Configuration.GetDescription(nameof(Configuration.MinimumFileDuration));
public string MergeOpeningEndCreditsText { get; } = Configuration.GetDescription(nameof(Configuration.MergeOpeningAndEndCredits));
public string StripAudibleBrandingText { get; } = Configuration.GetDescription(nameof(Configuration.StripAudibleBrandAudio));
public string StripUnabridgedText { get; } = Configuration.GetDescription(nameof(Configuration.StripUnabridged));
@@ -146,6 +149,7 @@ namespace LibationAvalonia.ViewModels.Settings
public string StripUnabridgedTip => Configuration.GetHelpText(nameof(StripUnabridged));
public bool DecryptToLossy { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string DecryptToLossyTip => Configuration.GetHelpText(nameof(DecryptToLossy));
public string MinimumFileDurationTip => Configuration.GetHelpText(nameof(MinimumFileDuration));
public bool MoveMoovToBeginning { get; set; }
public bool LameDownsampleMono { get; set; } = Design.IsDesignMode;
@@ -153,6 +157,7 @@ namespace LibationAvalonia.ViewModels.Settings
public bool LameConstantBitrate { get; set; } = Design.IsDesignMode;
public bool SplitFilesByChapter { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } }
public int MinimumFileDuration { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } }
public bool LameTargetBitrate { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } }
public bool LameMatchSource { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } }
public int LameBitrate { get => field; set { this.RaiseAndSetIfChanged(ref field, value); } }

View File

@@ -58,7 +58,10 @@
</StackPanel.Styles>
<TextBlock Margin="0,0,6,0" FontSize="11" Text="DL&#xA;Limit" VerticalAlignment="Center" />
<NumericUpDown
Classes="SmallNumericUpDown"
MinWidth="100"
FontSize="12"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
TextConverter="{StaticResource myConverter}"
Height="{Binding #cancelAllBtn.DesiredSize.Height}"

View File

@@ -26,7 +26,7 @@
<DataGrid.Styles>
<Style Selector="DataGridColumnHeader">
<Setter Property="ContextMenu">
<ContextMenu Name="GridHeaderContextMenu" Opening="ContextMenu_ContextMenuOpening" Closed="ContextMenu_MenuClosed">
<ContextMenu Name="GridHeaderContextMenu" Opening="GridHeaderContextMenu_Opening" Closed="GridHeaderContextMenu_Closed">
<ContextMenu.Styles>
<Style Selector="MenuItem">
<Setter Property="Padding" Value="10,0,-10,0" />
@@ -51,6 +51,11 @@
</ContextMenu>
</Setter>
</Style>
<Style Selector="DataGridCell">
<Setter Property="ContextMenu">
<ContextMenu Opening="GridCellContextMenu_Opening" Opened="GridCellContextMenu_Opened"/>
</Setter>
</Style>
<Style Selector="DataGridCell > Panel">
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>

View File

@@ -1,8 +1,6 @@
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input.Platform;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using DataLayer;
@@ -30,13 +28,31 @@ namespace LibationAvalonia.Views
public event EventHandler<LibraryBook[]>? ConvertToMp3Clicked;
public event EventHandler<LibraryBook>? TagsButtonClicked;
public static readonly StyledProperty<bool> DisableContextMenuProperty =
AvaloniaProperty.Register<GroupBox, bool>(nameof(DisableContextMenu));
public static readonly StyledProperty<bool> DisableColumnCustomizationProperty =
AvaloniaProperty.Register<GroupBox, bool>(nameof(DisableColumnCustomization));
public bool DisableContextMenu
{
get { return GetValue(DisableContextMenuProperty); }
set { SetValue(DisableContextMenuProperty, value); }
}
public bool DisableColumnCustomization
{
get { return GetValue(DisableColumnCustomizationProperty); }
set { SetValue(DisableColumnCustomizationProperty, value); }
}
private ProductsDisplayViewModel? _viewModel => DataContext as ProductsDisplayViewModel;
ImageDisplayDialog? imageDisplayDialog;
public ProductsDisplay()
{
InitializeComponent();
DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded;
var cellSelector = Selectors.Is<DataGridCell>(null);
rowHeightStyle = new Style(_ => cellSelector);
@@ -91,6 +107,18 @@ namespace LibationAvalonia.Views
}
}
protected override void OnApplyTemplate(Avalonia.Controls.Primitives.TemplateAppliedEventArgs e)
{
ApplyDisableColumnCustimaziton();
base.OnApplyTemplate(e);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property == DisableColumnCustomizationProperty)
ApplyDisableColumnCustimaziton();
base.OnPropertyChanged(change);
}
private void ProductsDisplay_LoadingRow(object sender, DataGridRowEventArgs e)
{
if (e.Row.DataContext is LibraryBookEntry entry && entry.Liberate?.IsEpisode is true)
@@ -180,10 +208,19 @@ namespace LibationAvalonia.Views
#endregion
#region Cell Context Menu
public void ProductsGrid_CellContextMenuStripNeeded(object? sender, DataGridCellContextMenuStripNeededEventArgs args)
public void GridCellContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
{
var entries = args.GridEntries;
e.Cancel = DisableContextMenu;
}
//Use Opened instead of opening because the parent is not set yet in Opening
public void GridCellContextMenu_Opened(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is not ContextMenu contextMenu ||
DataGridCellContextMenu<GridEntry>.Create(contextMenu) is not { } args)
return;
var entries = args.RowItems;
var ctx = new GridContextMenu(entries, '_');
if (App.MainWindow?.Clipboard is IClipboard clipboard)
@@ -206,8 +243,8 @@ namespace LibationAvalonia.Views
});
args.ContextMenuItems.Add(new Separator());
}
}
#region Liberate all Episodes (Single series only)
@@ -351,6 +388,20 @@ namespace LibationAvalonia.Views
})
});
}
#endregion
#region Remove Audible Plus Books from Audible Library
if (entries.Length != 1 || ctx.RemoveFromAudibleEnabled)
{
args.ContextMenuItems.Add(new Separator());
args.ContextMenuItems.Add(new MenuItem
{
Header = ctx.RemoveFromAudibleText,
IsEnabled = ctx.RemoveFromAudibleEnabled,
Command = ReactiveCommand.CreateFromTask(ctx.RemoveFromAudibleAsync)
});
}
#endregion
if (entries.Length > 1)
@@ -440,13 +491,13 @@ namespace LibationAvalonia.Views
var itemName = column.SortMemberPath;
if (itemName == nameof(GridEntry.Remove))
continue;
GridHeaderContextMenu.Items.Add(new MenuItem
{
Header = new CheckBox { Content = new TextBlock { Text = ((string)column.Header).Replace('\n', ' ') } },
Tag = column,
});
column.IsVisible = Configuration.Instance.GetColumnVisibility(itemName);
}
@@ -464,10 +515,19 @@ namespace LibationAvalonia.Views
}
}
public void ContextMenu_ContextMenuOpening(object? sender, System.ComponentModel.CancelEventArgs e)
private void ApplyDisableColumnCustimaziton()
{
if (sender is not ContextMenu contextMenu)
_viewModel?.DisablePersistColumnWidths = DisableColumnCustomization;
productsGrid.CanUserReorderColumns = !DisableColumnCustomization;
}
public void GridHeaderContextMenu_Opening(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (DisableContextMenu || sender is not ContextMenu contextMenu)
{
e.Cancel = true;
return;
}
foreach (var mi in contextMenu.Items.OfType<MenuItem>())
{
if (mi.Tag is DataGridColumn column && mi.Header is CheckBox cbox)
@@ -477,7 +537,7 @@ namespace LibationAvalonia.Views
}
}
public void ContextMenu_MenuClosed(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
public void GridHeaderContextMenu_Closed(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is not ContextMenu contextMenu)
return;
@@ -504,6 +564,7 @@ namespace LibationAvalonia.Views
private void ProductsGrid_ColumnDisplayIndexChanged(object? sender, DataGridColumnEventArgs e)
{
if (DisableColumnCustomization) return;
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;

View File

@@ -1,16 +1,20 @@
using ApplicationServices;
using CommandLine;
using DataLayer;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationCli
{
[Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json")]
public class ExportOptions : OptionsBase
{
[Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")]
public string FilePath { get; set; }
public string? FilePath { get; set; }
#region explanation of mutually exclusive options
/*
@@ -36,9 +40,12 @@ namespace LibationCli
[Option(shortName: 'j', longName: "json", HelpText = "JavaScript Object Notation", SetName = "json")]
public bool json { get; set; }
[Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books to process.")]
public IEnumerable<string>? Asins { get; set; }
protected override Task ProcessAsync()
{
Action<string> exporter
Action<string, IEnumerable<LibraryBook>?>? exporter
= csv ? LibraryExporter.ToCsv
: json ? LibraryExporter.ToJson
: xlsx ? LibraryExporter.ToXlsx
@@ -54,9 +61,18 @@ namespace LibationCli
{
PrintVerbUsage($"Undefined export format for file type \"{Path.GetExtension(FilePath)}\"");
}
else if (FilePath is null)
{
PrintVerbUsage($"Undefined export file name");
}
else
{
exporter(FilePath);
IEnumerable<LibraryBook>? booksToScan = null;
if (Asins?.Any() is true)
{
booksToScan = DbContexts.GetLibrary_Flat_NoTracking().IntersectBy(Asins, l => l.Book.AudibleProductId);
}
exporter(FilePath, booksToScan);
Console.WriteLine($"Library exported to: {FilePath}");
}
return Task.CompletedTask;

View File

@@ -87,7 +87,7 @@ namespace LibationCli
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to read license file: {@LicenseFile}", licFile);
Serilog.Log.Error(ex, "Failed to read license file: {LicenseFile}", licFile);
Console.Error.WriteLine("Error: Failed to read license file. Please ensure the file is a valid license file in JSON format.");
}
return null;

View File

@@ -1,5 +1,4 @@
using CommandLine;
using CsvHelper.TypeConversion;
using Dinah.Core;
using FileManager;
using LibationFileManager;

View File

@@ -37,7 +37,7 @@ namespace LibationFileManager
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error creating subdirectory in {@InProgress}", Configuration.Instance.InProgress);
Serilog.Log.Error(ex, "Error creating subdirectory in {InProgress}", Configuration.Instance.InProgress);
lastInProgressFail = DateTime.UtcNow;
return null;
}
@@ -86,7 +86,7 @@ namespace LibationFileManager
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error creating Books directory: {@BooksDirectory}", Configuration.Instance.Books);
Serilog.Log.Error(ex, "Error creating Books directory: {BooksDirectory}", Configuration.Instance.Books);
return null;
}
}
@@ -272,7 +272,7 @@ namespace LibationFileManager
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error checking for asin in {@file}", path);
Serilog.Log.Error(ex, "Error checking for asin in {file}", path);
}
finally
{

View File

@@ -82,6 +82,13 @@ namespace LibationFileManager
from the decrypted audiobook. This does not require
re-encoding.
""" },
{nameof(MinimumFileDuration), """
The minimum duration (in minutes) for an chapter to
be split into its own file. Chapters shorter than
this duration will be merged with the following
chapter. Merged chapter titles will be joined with
a space between them.
""" },
{nameof(SpatialAudioCodec), """
The Dolby Digital Plus (E-AC-3) codec is more widely
supported than the AC-4 codec, but E-AC-3 files are

View File

@@ -169,6 +169,9 @@ namespace LibationFileManager
[Description("Split my books into multiple files by chapter")]
public bool SplitFilesByChapter { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Minimum file duration (seconds)")]
public int MinimumFileDuration { get => Math.Max(0, GetNonString(defaultValue: 3)); set => SetNonString(value); }
[Description("Merge Opening/End Credits into the following/preceding chapters")]
public bool MergeOpeningAndEndCredits { get => GetNonString(defaultValue: false); set => SetNonString(value); }

View File

@@ -76,7 +76,7 @@ namespace LibationFileManager
catch (Exception e)
{
//None of the interop functions are strictly necessary for Libation to run.
Serilog.Log.Logger.Error(e, "Unable to load types from assembly {@configApp}", configApp);
Serilog.Log.Logger.Error(e, "Unable to load types from assembly {configApp}", configApp);
}
}
private static string? getOSConfigApp()

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.1" />
<PackageReference Include="NameParserSharp" Version="1.5.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
</ItemGroup>

View File

@@ -114,24 +114,24 @@ public class LibationFiles
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Failed to load settings file: {@SettingsFile}", settingsFile);
Log.Logger.Error(ex, "Failed to load settings file: {SettingsFile}", settingsFile);
try
{
Log.Logger.Information("Deleting invalid settings file: {@SettingsFile}", settingsFile);
Log.Logger.Information("Deleting invalid settings file: {SettingsFile}", settingsFile);
FileUtility.SaferDelete(settingsFile);
Log.Logger.Information("Creating a new, empty setting file: {@SettingsFile}", settingsFile);
Log.Logger.Information("Creating a new, empty setting file: {SettingsFile}", settingsFile);
try
{
File.WriteAllText(settingsFile, "{}");
}
catch (Exception createEx)
{
Log.Logger.Error(createEx, "Failed to create new settings file: {@SettingsFile}", settingsFile);
Log.Logger.Error(createEx, "Failed to create new settings file: {SettingsFile}", settingsFile);
}
}
catch (Exception deleteEx)
{
Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {@SettingsFile}", settingsFile);
Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {SettingsFile}", settingsFile);
}
return false;

View File

@@ -59,6 +59,7 @@ namespace LibationSearchEngine
{ FieldType.Bool, lb => lb.AbsentFromLastScan.ToString(), "AbsentFromLastScan", "Absent" },
{ FieldType.Bool, lb => (!string.IsNullOrWhiteSpace(lb.Book.SeriesNames())).ToString(), "IsInSeries", "InSeries" },
{ FieldType.Bool, lb => lb.Book.UserDefinedItem.IsFinished.ToString(), nameof(UserDefinedItem.IsFinished), "Finished", "IsFinished" },
{ FieldType.Bool, lb => lb.IsAudiblePlus.ToString(), nameof(LibraryBook.IsAudiblePlus), "AudiblePlus", "Plus" },
// all numbers are padded to 8 char.s
// This will allow a single method to auto-pad numbers. The method will match these as well as date: yyyymmdd
{ FieldType.Number, lb => lb.Book.LengthInMinutes.ToLuceneString(), nameof(Book.LengthInMinutes), "Length", "Minutes" },
@@ -92,6 +93,12 @@ namespace LibationSearchEngine
}
}
public SearchEngine(string directory = null)
{
SearchEngineDirectory = directory
?? new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName;
}
/// <summary>Long running. Use await Task.Run(() => UpdateBook(productId))</summary>
public void UpdateBook(LibationContext context, string productId)
{
@@ -130,7 +137,7 @@ namespace LibationSearchEngine
public void UpdateTags(string productId, string tags) => updateAnalyzedField(productId, TAGS, tags);
// all fields are case-specific
private static void updateAnalyzedField(string productId, string fieldName, string newValue)
private void updateAnalyzedField(string productId, string fieldName, string newValue)
=> updateDocument(
productId,
d =>
@@ -169,7 +176,7 @@ namespace LibationSearchEngine
d.AddIndexRule(rating, book);
});
private static void updateDocument(string productId, Action<Document> action)
private void updateDocument(string productId, Action<Document> action)
{
var productTerm = new Term(_ID_, productId);
@@ -276,10 +283,10 @@ namespace LibationSearchEngine
}
#endregion
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
private Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles.Location).CreateSubdirectoryEx("SearchEngine").FullName;
//Defaults to "LibationFiles/SearchEngine, but can be overridden
//in constructor for use in TrashBinDialog search
private string SearchEngineDirectory { get; }
}
}

View File

@@ -150,7 +150,7 @@ public class FindBetterQualityBooksViewModel : ReactiveObject
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error checking for better quality for {@Asin}", b.Asin);
Serilog.Log.Logger.Error(ex, "Error checking for better quality for {@Asin}", new { b.Asin });
b.FoundFile = $"Error: {ex.Message}";
b.ScanStatus = ProcessBookStatus.Failed;
}

View File

@@ -47,8 +47,8 @@ namespace LibationUiBase.GridView
public bool IsBook => !IsSeries && !IsEpisode;
public bool IsUnavailable
=> !IsSeries
& isAbsent
& (
&& isAbsent
&& (
BookStatus is not LiberatedStatus.Liberated
|| PdfStatus is not null and not LiberatedStatus.Liberated
);

View File

@@ -1,9 +1,12 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using LibationFileManager.Templates;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -17,6 +20,7 @@ public class GridContextMenu
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 RemoveFromAudibleText => $"Remove Plus {(GridEntries.Count(e => e.LibraryBook.IsAudiblePlus) == 1 ? "Book" : "Books")} from Audible Library";
public string LocateFileText => $"{Accelerator}Locate file...";
public string LocateFileDialogTitle => $"Locate the audio file for '{GridEntries[0].Book?.TitleWithSubtitle ?? "[null]"}'";
public string LocateFileErrorMessage => "Error saving book's location";
@@ -37,6 +41,7 @@ public class GridContextMenu
public bool ConvertToMp3Enabled => LibraryBookEntries.Any(ge => ge.Book?.UserDefinedItem.BookStatus is LiberatedStatus.Liberated);
public bool DownloadAsChaptersEnabled => LibraryBookEntries.Any(ge => ge.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Error);
public bool ReDownloadEnabled => LibraryBookEntries.Any(ge => ge.Book?.UserDefinedItem.BookStatus is LiberatedStatus.Liberated);
public bool RemoveFromAudibleEnabled => LibraryBookEntries.Any(ge => ge.LibraryBook.IsAudiblePlus);
private GridEntry[] GridEntries { get; }
public LibraryBookEntry[] LibraryBookEntries { get; }
@@ -84,6 +89,85 @@ public class GridContextMenu
await LibraryBookEntries.Select(e => e.LibraryBook).RemoveBooksAsync();
}
public async Task RemoveFromAudibleAsync()
{
LibraryBook[] toRemove = LibraryBookEntries.Select(l => l.LibraryBook).Where(lb => lb.IsAudiblePlus).ToArray();
if (toRemove.Length == 0)
return;
string bookStr = "book".PluralizeWithCount(toRemove.Length), itsThem = toRemove.Length == 1 ? "it" : "them";
string confirmMessage = $"""
Libation is about to remove {bookStr} from your Audible account. The only way to get {itsThem} back
is to re-add {itsThem} to your Audible Library through the Audible website or app.
Are you sure you want to remove the following {bookStr}?
{toRemove.AggregateTitles()}
""";
DialogResult result = await MessageBoxBase.Show(confirmMessage, "Confirm Remove from Audible Library", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
if (result != DialogResult.Yes)
return;
List<LibraryBook> removedFromAudible = [];
List<LibraryBook> failedToRemove = [];
//Getting the API loads AccountsSettings every time and es expensive
//cache Api to improve perfomanc on large batches of deletions
Dictionary<string, AudibleApi.Api> apis = [];
foreach (var entry in toRemove)
{
try
{
if (!apis.TryGetValue(entry.Account, out var api))
{
apis[entry.Account] = api = await entry.GetApiAsync();
}
bool success = await api.RemoveItemFromLibraryAsync(entry.Book.AudibleProductId);
if (success)
{
removedFromAudible.Add(entry);
}
else
{
failedToRemove.Add(entry);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to remove book from audible account. {@Book}", entry.LogFriendly());
failedToRemove.Add(entry);
}
}
if (failedToRemove.Count > 0)
{
string booksStr = "book".PluralizeWithCount(failedToRemove.Count);
string message = $"""
Failed to remove {booksStr} from Audible.
{failedToRemove.AggregateTitles()}
""";
await MessageBoxBase.Show(message, $"Failed to Remove {booksStr} from Audible");
}
try
{
await removedFromAudible.PermanentlyDeleteBooksAsync();
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to delete locally removed from Audible books.");
string booksStr = "book".PluralizeWithCount(removedFromAudible.Count);
string message = $"""
Failed to delete {booksStr} from Libation.
{removedFromAudible.AggregateTitles()}
""";
await MessageBoxBase.Show(message, $"Failed to Delete {booksStr} from Libation");
}
}
public ITemplateEditor CreateTemplateEditor<T>(LibraryBook libraryBook, string existingTemplate)
where T : Templates, ITemplate, new()
{

View File

@@ -146,7 +146,10 @@ namespace LibationUiBase.GridView
{
//If UserDefinedItem was changed on a different Book instance (such as when batch liberating via menus),
//Liberate.Book and LibraryBook.Book instances will not have the current DB state.
Invoke(() => UpdateLibraryBook(new LibraryBook(udi.Book, LibraryBook.DateAdded, LibraryBook.Account)));
var newLB = new LibraryBook(udi.Book, LibraryBook.DateAdded, LibraryBook.Account);
newLB.SetIncludedUntil(LibraryBook.IncludedUntil);
newLB.SetIsAudiblePlus(LibraryBook.IsAudiblePlus);
Invoke(() => UpdateLibraryBook(newLB));
return;
}

View File

@@ -45,16 +45,13 @@ namespace LibationUiBase.GridView
=> searchSet is null != otherSet is null ||
(searchSet is not null &&
otherSet is not null &&
searchSet.Intersect(otherSet).Count() != searchSet.Count);
searchSet.Intersect(otherSet).Count() != searchSet.Count);
[return: NotNullIfNotNull(nameof(searchString))]
public static HashSet<GridEntry>? FilterEntries(this IEnumerable<GridEntry> entries, string? searchString)
[return: NotNullIfNotNull(nameof(searchResultSet))]
public static HashSet<GridEntry>? FilterEntries(this IEnumerable<GridEntry> entries, LibationSearchEngine.SearchResultSet? searchResultSet)
{
if (string.IsNullOrEmpty(searchString))
if (searchResultSet is null)
return null;
var searchResultSet = SearchEngineCommands.Search(searchString);
var booksFilteredIn = entries.IntersectBy(searchResultSet.Docs.Select(d => d.ProductId), l => l.AudibleProductId);
//Find all series containing children that match the search criteria

View File

@@ -168,7 +168,7 @@ public class ProcessQueueViewModel : ReactiveObject
private bool IsBooksDirectoryValid(Configuration config)
{
if (string.IsNullOrWhiteSpace(config.Books))
if (string.IsNullOrWhiteSpace(config.Books?.Path))
{
Serilog.Log.Logger.Error("Books location is not set in configuration.");
MessageBoxBase.Show(
@@ -180,7 +180,7 @@ public class ProcessQueueViewModel : ReactiveObject
}
else if (AudibleFileStorage.BooksDirectory is null)
{
Serilog.Log.Logger.Error("Failed to create books directory: {@booksDir}", config.Books);
Serilog.Log.Logger.Error("Failed to create books directory: {booksDir}", config.Books?.Path);
MessageBoxBase.Show(
$"Libation was unable to create the \"Books location\" folder at:\n{config.Books}\n\nPlease change the Books location in the settings menu.",
"Failed to Create Books Directory",
@@ -190,7 +190,7 @@ public class ProcessQueueViewModel : ReactiveObject
}
else if (AudibleFileStorage.DownloadsInProgressDirectory is null)
{
Serilog.Log.Logger.Error("Failed to create DownloadsInProgressDirectory in {@InProgress}", config.InProgress);
Serilog.Log.Logger.Error("Failed to create DownloadsInProgressDirectory in {InProgress}", config.InProgress);
MessageBoxBase.Show(
$"Libation was unable to create the \"Downloads In Progress\" folder in:\n{config.InProgress}\n\nPlease change the In Progress location in the settings menu.",
"Failed to Create Downloads In Progress Directory",
@@ -200,7 +200,7 @@ public class ProcessQueueViewModel : ReactiveObject
}
else if (AudibleFileStorage.DecryptInProgressDirectory is null)
{
Serilog.Log.Logger.Error("Failed to create DecryptInProgressDirectory in {@InProgress}", config.InProgress);
Serilog.Log.Logger.Error("Failed to create DecryptInProgressDirectory in {InProgress}", config.InProgress);
MessageBoxBase.Show(
$"Libation was unable to create the \"Decrypt In Progress\" folder in:\n{config.InProgress}\n\nPlease change the In Progress location in the settings menu.",
"Failed to Create Decrypt In Progress Directory",

View File

@@ -19,6 +19,7 @@ namespace LibationWinForms.Dialogs
this.retainAaxFileCbox.Text = desc(nameof(config.RetainAaxFile));
this.combineNestedChapterTitlesCbox.Text = desc(nameof(config.CombineNestedChapterTitles));
this.splitFilesByChapterCbox.Text = desc(nameof(config.SplitFilesByChapter));
this.minFileDurationLbl.Text = desc(nameof(config.MinimumFileDuration));
this.mergeOpeningEndCreditsCbox.Text = desc(nameof(config.MergeOpeningAndEndCredits));
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
@@ -40,6 +41,8 @@ namespace LibationWinForms.Dialogs
toolTip.SetToolTip(requestSpatialCbox, Configuration.GetHelpText(nameof(config.RequestSpatial)));
toolTip.SetToolTip(request_xHE_AAC_Cbox, Configuration.GetHelpText(nameof(config.Request_xHE_AAC)));
toolTip.SetToolTip(spatialAudioCodecCb, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
toolTip.SetToolTip(minFileDurationLbl, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
toolTip.SetToolTip(minFileDurationNud, Configuration.GetHelpText(nameof(config.SpatialAudioCodec)));
fileDownloadQualityCb.Items.AddRange(
[
@@ -87,6 +90,7 @@ namespace LibationWinForms.Dialogs
retainAaxFileCbox.Checked = config.RetainAaxFile;
combineNestedChapterTitlesCbox.Checked = config.CombineNestedChapterTitles;
splitFilesByChapterCbox.Checked = config.SplitFilesByChapter;
minFileDurationNud.Value = config.MinimumFileDuration;
mergeOpeningEndCreditsCbox.Checked = config.MergeOpeningAndEndCredits;
stripUnabridgedCbox.Checked = config.StripUnabridged;
stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio;
@@ -132,6 +136,7 @@ namespace LibationWinForms.Dialogs
config.RetainAaxFile = retainAaxFileCbox.Checked;
config.CombineNestedChapterTitles = combineNestedChapterTitlesCbox.Checked;
config.SplitFilesByChapter = splitFilesByChapterCbox.Checked;
config.MinimumFileDuration = (int)minFileDurationNud.Value;
config.MergeOpeningAndEndCredits = mergeOpeningEndCreditsCbox.Checked;
config.StripUnabridged = stripUnabridgedCbox.Checked;
config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked;
@@ -167,7 +172,7 @@ namespace LibationWinForms.Dialogs
private void splitFilesByChapterCbox_CheckedChanged(object sender, EventArgs e)
{
chapterTitleTemplateGb.Enabled = splitFilesByChapterCbox.Checked;
chapterTitleTemplateGb.Enabled = minFileDurationNud.Enabled = minFileDurationLbl.Enabled = splitFilesByChapterCbox.Checked;
}
private void chapterTitleTemplateBtn_Click(object sender, EventArgs e)

View File

@@ -99,6 +99,8 @@
clipsBookmarksFormatCb = new System.Windows.Forms.ComboBox();
downloadClipsBookmarksCbox = new System.Windows.Forms.CheckBox();
audiobookFixupsGb = new System.Windows.Forms.GroupBox();
minFileDurationLbl = new System.Windows.Forms.Label();
minFileDurationNud = new System.Windows.Forms.NumericUpDown();
stripUnabridgedCbox = new System.Windows.Forms.CheckBox();
chapterTitleTemplateGb = new System.Windows.Forms.GroupBox();
chapterTitleTemplateBtn = new System.Windows.Forms.Button();
@@ -154,6 +156,7 @@
customFileNamingGb.SuspendLayout();
tab4AudioFileOptions.SuspendLayout();
audiobookFixupsGb.SuspendLayout();
((System.ComponentModel.ISupportInitialize)minFileDurationNud).BeginInit();
chapterTitleTemplateGb.SuspendLayout();
lameOptionsGb.SuspendLayout();
lameBitrateGb.SuspendLayout();
@@ -289,7 +292,7 @@
// stripAudibleBrandingCbox
//
stripAudibleBrandingCbox.AutoSize = true;
stripAudibleBrandingCbox.Location = new System.Drawing.Point(13, 70);
stripAudibleBrandingCbox.Location = new System.Drawing.Point(13, 88);
stripAudibleBrandingCbox.Name = "stripAudibleBrandingCbox";
stripAudibleBrandingCbox.Size = new System.Drawing.Size(143, 34);
stripAudibleBrandingCbox.TabIndex = 16;
@@ -960,20 +963,41 @@
//
// audiobookFixupsGb
//
audiobookFixupsGb.Controls.Add(minFileDurationLbl);
audiobookFixupsGb.Controls.Add(minFileDurationNud);
audiobookFixupsGb.Controls.Add(splitFilesByChapterCbox);
audiobookFixupsGb.Controls.Add(stripUnabridgedCbox);
audiobookFixupsGb.Controls.Add(stripAudibleBrandingCbox);
audiobookFixupsGb.Location = new System.Drawing.Point(6, 254);
audiobookFixupsGb.Name = "audiobookFixupsGb";
audiobookFixupsGb.Size = new System.Drawing.Size(416, 114);
audiobookFixupsGb.Size = new System.Drawing.Size(416, 128);
audiobookFixupsGb.TabIndex = 14;
audiobookFixupsGb.TabStop = false;
audiobookFixupsGb.Text = "Audiobook Fix-ups";
//
// minFileDurationLbl
//
minFileDurationLbl.AutoSize = true;
minFileDurationLbl.Location = new System.Drawing.Point(34, 44);
minFileDurationLbl.Margin = new System.Windows.Forms.Padding(0, 0, 2, 0);
minFileDurationLbl.Name = "minFileDurationLbl";
minFileDurationLbl.Size = new System.Drawing.Size(159, 15);
minFileDurationLbl.TabIndex = 29;
minFileDurationLbl.Text = "[MinimumFileDuration desc]";
//
// minFileDurationNud
//
minFileDurationNud.Location = new System.Drawing.Point(243, 41);
minFileDurationNud.Maximum = new decimal(new int[] { 120, 0, 0, 0 });
minFileDurationNud.Name = "minFileDurationNud";
minFileDurationNud.Size = new System.Drawing.Size(51, 23);
minFileDurationNud.TabIndex = 17;
minFileDurationNud.TextAlign = System.Windows.Forms.HorizontalAlignment.Center;
//
// stripUnabridgedCbox
//
stripUnabridgedCbox.AutoSize = true;
stripUnabridgedCbox.Location = new System.Drawing.Point(13, 46);
stripUnabridgedCbox.Location = new System.Drawing.Point(13, 63);
stripUnabridgedCbox.Name = "stripUnabridgedCbox";
stripUnabridgedCbox.Size = new System.Drawing.Size(147, 19);
stripUnabridgedCbox.TabIndex = 15;
@@ -1498,6 +1522,7 @@
tab4AudioFileOptions.PerformLayout();
audiobookFixupsGb.ResumeLayout(false);
audiobookFixupsGb.PerformLayout();
((System.ComponentModel.ISupportInitialize)minFileDurationNud).EndInit();
chapterTitleTemplateGb.ResumeLayout(false);
chapterTitleTemplateGb.PerformLayout();
lameOptionsGb.ResumeLayout(false);
@@ -1627,5 +1652,7 @@
private System.Windows.Forms.ComboBox themeCb;
private System.Windows.Forms.Label themeLbl;
private System.Windows.Forms.CheckBox importPlusTitlesCb;
private System.Windows.Forms.Label minFileDurationLbl;
private System.Windows.Forms.NumericUpDown minFileDurationNud;
}
}

View File

@@ -1,6 +1,4 @@
using Dinah.Core;
using DocumentFormat.OpenXml.Drawing;
using DocumentFormat.OpenXml.Office2013.Theme;
using FileManager;
using LibationFileManager;
using LibationUiBase;

View File

@@ -28,40 +28,35 @@
/// </summary>
private void InitializeComponent()
{
deletedCbl = new System.Windows.Forms.CheckedListBox();
label1 = new System.Windows.Forms.Label();
restoreBtn = new System.Windows.Forms.Button();
permanentlyDeleteBtn = new System.Windows.Forms.Button();
everythingCb = new System.Windows.Forms.CheckBox();
deletedCheckedLbl = new System.Windows.Forms.Label();
productsGrid1 = new LibationWinForms.GridView.ProductsGrid();
label2 = new System.Windows.Forms.Label();
textBox1 = new System.Windows.Forms.TextBox();
button1 = new System.Windows.Forms.Button();
audiblePlusCb = new System.Windows.Forms.CheckBox();
plusBookcSheckedLbl = new System.Windows.Forms.Label();
SuspendLayout();
//
// deletedCbl
//
deletedCbl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
deletedCbl.FormattingEnabled = true;
deletedCbl.Location = new System.Drawing.Point(12, 27);
deletedCbl.Name = "deletedCbl";
deletedCbl.Size = new System.Drawing.Size(776, 364);
deletedCbl.TabIndex = 3;
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
//
// label1
//
label1.AutoSize = true;
label1.Location = new System.Drawing.Point(12, 9);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(388, 15);
label1.TabIndex = 4;
label1.TabIndex = 0;
label1.Text = "Check books you want to permanently delete from or restore to Libation";
//
// restoreBtn
//
restoreBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
restoreBtn.Location = new System.Drawing.Point(572, 398);
restoreBtn.Location = new System.Drawing.Point(572, 450);
restoreBtn.Name = "restoreBtn";
restoreBtn.Size = new System.Drawing.Size(75, 40);
restoreBtn.TabIndex = 5;
restoreBtn.TabIndex = 6;
restoreBtn.Text = "Restore";
restoreBtn.UseVisualStyleBackColor = true;
restoreBtn.Click += restoreBtn_Click;
@@ -69,10 +64,10 @@
// permanentlyDeleteBtn
//
permanentlyDeleteBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 398);
permanentlyDeleteBtn.Location = new System.Drawing.Point(653, 450);
permanentlyDeleteBtn.Name = "permanentlyDeleteBtn";
permanentlyDeleteBtn.Size = new System.Drawing.Size(135, 40);
permanentlyDeleteBtn.TabIndex = 5;
permanentlyDeleteBtn.TabIndex = 7;
permanentlyDeleteBtn.Text = "Permanently Remove\r\nfrom Libation";
permanentlyDeleteBtn.UseVisualStyleBackColor = true;
permanentlyDeleteBtn.Click += permanentlyDeleteBtn_Click;
@@ -81,10 +76,11 @@
//
everythingCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
everythingCb.AutoSize = true;
everythingCb.Location = new System.Drawing.Point(12, 410);
everythingCb.Location = new System.Drawing.Point(12, 462);
everythingCb.Margin = new System.Windows.Forms.Padding(10, 3, 3, 3);
everythingCb.Name = "everythingCb";
everythingCb.Size = new System.Drawing.Size(82, 19);
everythingCb.TabIndex = 6;
everythingCb.TabIndex = 4;
everythingCb.Text = "Everything";
everythingCb.ThreeState = true;
everythingCb.UseVisualStyleBackColor = true;
@@ -94,23 +90,93 @@
//
deletedCheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
deletedCheckedLbl.AutoSize = true;
deletedCheckedLbl.Location = new System.Drawing.Point(126, 411);
deletedCheckedLbl.Location = new System.Drawing.Point(100, 463);
deletedCheckedLbl.Name = "deletedCheckedLbl";
deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
deletedCheckedLbl.TabIndex = 7;
deletedCheckedLbl.TabIndex = 0;
deletedCheckedLbl.Text = "Checked: {0} of {1}";
//
// productsGrid1
//
productsGrid1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
productsGrid1.AutoScroll = true;
productsGrid1.DisableColumnCustomization = true;
productsGrid1.DisableContextMenu = true;
productsGrid1.Location = new System.Drawing.Point(12, 62);
productsGrid1.Name = "productsGrid1";
productsGrid1.SearchEngine = null;
productsGrid1.Size = new System.Drawing.Size(776, 382);
productsGrid1.TabIndex = 3;
//
// label2
//
label2.AutoSize = true;
label2.Location = new System.Drawing.Point(12, 36);
label2.Name = "label2";
label2.Size = new System.Drawing.Size(123, 15);
label2.TabIndex = 0;
label2.Text = "Search Deleted Books:";
//
// textBox1
//
textBox1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
textBox1.Location = new System.Drawing.Point(141, 33);
textBox1.Name = "textBox1";
textBox1.Size = new System.Drawing.Size(574, 23);
textBox1.TabIndex = 1;
textBox1.KeyDown += textBox1_KeyDown;
//
// button1
//
button1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
button1.Location = new System.Drawing.Point(721, 33);
button1.Name = "button1";
button1.Size = new System.Drawing.Size(67, 23);
button1.TabIndex = 2;
button1.Text = "Filter";
button1.UseVisualStyleBackColor = true;
button1.Click += searchBtn_Click;
//
// audiblePlusCb
//
audiblePlusCb.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
audiblePlusCb.AutoSize = true;
audiblePlusCb.Location = new System.Drawing.Point(247, 462);
audiblePlusCb.Margin = new System.Windows.Forms.Padding(10, 3, 3, 3);
audiblePlusCb.Name = "audiblePlusCb";
audiblePlusCb.Size = new System.Drawing.Size(127, 19);
audiblePlusCb.TabIndex = 5;
audiblePlusCb.Text = "Audible Plus Books";
audiblePlusCb.ThreeState = true;
audiblePlusCb.UseVisualStyleBackColor = true;
audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged;
//
// plusBookcSheckedLbl
//
plusBookcSheckedLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
plusBookcSheckedLbl.AutoSize = true;
plusBookcSheckedLbl.Location = new System.Drawing.Point(380, 463);
plusBookcSheckedLbl.Name = "plusBookcSheckedLbl";
plusBookcSheckedLbl.Size = new System.Drawing.Size(104, 15);
plusBookcSheckedLbl.TabIndex = 0;
plusBookcSheckedLbl.Text = "Checked: {0} of {1}";
//
// TrashBinDialog
//
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
ClientSize = new System.Drawing.Size(800, 450);
ClientSize = new System.Drawing.Size(800, 502);
Controls.Add(plusBookcSheckedLbl);
Controls.Add(button1);
Controls.Add(textBox1);
Controls.Add(label2);
Controls.Add(productsGrid1);
Controls.Add(deletedCheckedLbl);
Controls.Add(audiblePlusCb);
Controls.Add(everythingCb);
Controls.Add(permanentlyDeleteBtn);
Controls.Add(restoreBtn);
Controls.Add(label1);
Controls.Add(deletedCbl);
Name = "TrashBinDialog";
Text = "Trash Bin";
ResumeLayout(false);
@@ -118,12 +184,16 @@
}
#endregion
private System.Windows.Forms.CheckedListBox deletedCbl;
private System.Windows.Forms.Label label1;
private System.Windows.Forms.Button restoreBtn;
private System.Windows.Forms.Button permanentlyDeleteBtn;
private System.Windows.Forms.CheckBox everythingCb;
private System.Windows.Forms.Label deletedCheckedLbl;
private GridView.ProductsGrid productsGrid1;
private System.Windows.Forms.Label label2;
private System.Windows.Forms.TextBox textBox1;
private System.Windows.Forms.Button button1;
private System.Windows.Forms.CheckBox audiblePlusCb;
private System.Windows.Forms.Label plusBookcSheckedLbl;
}
}

View File

@@ -1,18 +1,21 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core.Collections.Generic;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using DataLayer;
using LibationFileManager;
using System.Collections;
#nullable enable
namespace LibationWinForms.Dialogs
{
public partial class TrashBinDialog : Form
{
private readonly string deletedCheckedTemplate;
private string lastGoodFilter = "";
private TempSearchEngine SearchEngine { get; } = new TempSearchEngine();
public TrashBinDialog()
{
InitializeComponent();
@@ -21,29 +24,67 @@ namespace LibationWinForms.Dialogs
this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
deletedCheckedTemplate = deletedCheckedLbl.Text;
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
deletedCbl.Items.Add(lb);
setLabel();
deletedCheckedLbl.Text = "";
plusBookcSheckedLbl.Text = "";
productsGrid1.SearchEngine = SearchEngine;
productsGrid1.RemovableCountChanged += (_, _) => UpdateCounts();
productsGrid1.VisibleCountChanged += (_, _) => UpdateCounts();
Load += TrashBinDialog_Load;
}
private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e)
private IEnumerable<LibraryBook> GetCheckedBooks() => productsGrid1.GetVisibleGridEntries().Where(i => i.Remove is true).Select(i => i.LibraryBook);
private async void TrashBinDialog_Load(object? sender, EventArgs e)
{
// CheckedItems.Count is not updated until after the event fires
setLabel(e.NewValue);
productsGrid1.RemoveColumnVisible = true;
await InitAsync();
}
private void UpdateCounts()
{
var visible = productsGrid1.GetVisibleGridEntries().ToArray();
var plusVisibleCount = visible.Count(e => e.LibraryBook.IsAudiblePlus);
var checkedCount = visible.Count(e => e.Remove is true);
var plusCheckedCount = visible.Count(e => e.LibraryBook.IsAudiblePlus && e.Remove is true);
deletedCheckedLbl.Text = $"Checked: {checkedCount} of {visible.Length}";
plusBookcSheckedLbl.Text = $"Checked: {plusCheckedCount} of {plusVisibleCount}";
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
everythingCb.CheckState = checkedCount == 0 || visible.Length == 0 ? CheckState.Unchecked
: checkedCount == visible.Length ? CheckState.Checked
: CheckState.Indeterminate;
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
audiblePlusCb.CheckStateChanged -= audiblePlusCb_CheckStateChanged;
audiblePlusCb.CheckState = plusCheckedCount == 0 || plusVisibleCount == 0 ? CheckState.Unchecked
: plusCheckedCount == plusVisibleCount ? CheckState.Checked
: CheckState.Indeterminate;
audiblePlusCb.CheckStateChanged += audiblePlusCb_CheckStateChanged;
}
private async Task InitAsync()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
await productsGrid1.BindToGridAsync(deletedBooks);
}
private void Reload()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
SearchEngine.ReindexSearchEngine(deletedBooks);
productsGrid1.UpdateGrid(deletedBooks);
}
private async void permanentlyDeleteBtn_Click(object sender, EventArgs e)
{
setControlsEnabled(false);
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
removeFromCheckList(removed);
await removed.PermanentlyDeleteBooksAsync();
var qtyChanges = await GetCheckedBooks().PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
Reload();
setControlsEnabled(true);
}
@@ -52,65 +93,70 @@ namespace LibationWinForms.Dialogs
{
setControlsEnabled(false);
var removed = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
removeFromCheckList(removed);
await removed.RestoreBooksAsync();
var qtyChanges = await GetCheckedBooks().RestoreBooksAsync();
if (qtyChanges > 0)
Reload();
setControlsEnabled(true);
}
private void removeFromCheckList(IEnumerable objects)
{
foreach (var o in objects)
deletedCbl.Items.Remove(o);
deletedCbl.Refresh();
setLabel();
}
private void setControlsEnabled(bool enabled)
=> restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = deletedCbl.Enabled = everythingCb.Enabled = enabled;
=> Invoke(() => productsGrid1.Enabled = restoreBtn.Enabled = permanentlyDeleteBtn.Enabled = everythingCb.Enabled = enabled);
private void everythingCb_CheckStateChanged(object sender, EventArgs e)
private void textBox1_KeyDown(object sender, KeyEventArgs e)
{
if (everythingCb.CheckState is CheckState.Indeterminate)
{
everythingCb.CheckState = CheckState.Unchecked;
return;
}
deletedCbl.ItemCheck -= deletedCbl_ItemCheck;
for (var i = 0; i < deletedCbl.Items.Count; i++)
deletedCbl.SetItemChecked(i, everythingCb.CheckState is CheckState.Checked);
setLabel();
deletedCbl.ItemCheck += deletedCbl_ItemCheck;
if (e.KeyCode == Keys.Enter)
searchBtn_Click(sender, e);
}
private void setLabel(CheckState? checkedState = null)
private void searchBtn_Click(object sender, EventArgs e)
{
var pre = deletedCbl.CheckedItems.Count;
int count = checkedState switch
try
{
CheckState.Checked => pre + 1,
CheckState.Unchecked => pre - 1,
_ => pre,
};
productsGrid1.Filter(textBox1.Text);
lastGoodFilter = textBox1.Text;
}
catch
{
productsGrid1.Filter(lastGoodFilter);
}
}
everythingCb.CheckStateChanged -= everythingCb_CheckStateChanged;
private void audiblePlusCb_CheckStateChanged(object? sender, EventArgs e)
{
switch (audiblePlusCb.CheckState)
{
case CheckState.Checked:
SetVisibleChecked(e => e.IsAudiblePlus, isChecked: true);
break;
case CheckState.Unchecked:
SetVisibleChecked(e => e.IsAudiblePlus, isChecked: false);
break;
default:
audiblePlusCb.CheckState = CheckState.Unchecked;
break;
}
}
private void everythingCb_CheckStateChanged(object? sender, EventArgs e)
{
switch (everythingCb.CheckState)
{
case CheckState.Checked:
SetVisibleChecked(_ => true, isChecked: true);
break;
case CheckState.Unchecked:
SetVisibleChecked(_ => true, isChecked: false);
break;
default:
everythingCb.CheckState = CheckState.Unchecked;
break;
}
}
everythingCb.CheckState
= count > 0 && count == deletedCbl.Items.Count ? CheckState.Checked
: count == 0 ? CheckState.Unchecked
: CheckState.Indeterminate;
everythingCb.CheckStateChanged += everythingCb_CheckStateChanged;
deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count);
public void SetVisibleChecked(Func<LibraryBook, bool> predicate, bool isChecked)
{
productsGrid1.GetVisibleGridEntries().Where(e => predicate(e.LibraryBook)).ForEach(i => i.Remove = isChecked);
UpdateCounts();
}
}
}

View File

@@ -1,4 +1,64 @@
<root>
<?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">

View File

@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
#nullable enable
namespace LibationWinForms.GridView
{
/*
@@ -43,8 +44,10 @@ namespace LibationWinForms.GridView
.OfType<LibraryBookEntry>()
.Union(Items.OfType<LibraryBookEntry>());
public ISearchEngine? SearchEngine { get; set; }
public bool SupportsFiltering => true;
public string Filter
public string? Filter
{
get => FilterString;
set
@@ -54,7 +57,8 @@ namespace LibationWinForms.GridView
if (Items.Count + FilterRemoved.Count == 0)
return;
FilteredInGridEntries = AllItems().FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
FilteredInGridEntries = AllItems().FilterEntries(searchResultSet);
refreshEntries();
}
}
@@ -63,16 +67,16 @@ namespace LibationWinForms.GridView
protected override bool SupportsSortingCore => true;
protected override bool SupportsSearchingCore => true;
protected override bool IsSortedCore => isSorted;
protected override PropertyDescriptor SortPropertyCore => propertyDescriptor;
protected override PropertyDescriptor? SortPropertyCore => propertyDescriptor;
protected override ListSortDirection SortDirectionCore => Comparer.SortOrder;
/// <summary> Items that were removed from the base list due to filtering </summary>
private readonly List<GridEntry> FilterRemoved = new();
private string FilterString;
private string? FilterString;
private bool isSorted;
private PropertyDescriptor propertyDescriptor;
private PropertyDescriptor? propertyDescriptor;
/// <summary> All GridEntries present in the current filter set. If null, no filter is applied and all entries are filtered in.(This was a performance choice)</summary>
private HashSet<GridEntry> FilteredInGridEntries;
private HashSet<GridEntry>? FilteredInGridEntries;
#region Unused - Advanced Filtering
public bool SupportsAdvancedSorting => false;
@@ -128,7 +132,7 @@ namespace LibationWinForms.GridView
//(except for episodes that are collapsed)
foreach (var addBack in addBackEntries)
{
if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && !se.Liberate.Expanded)
if (addBack is LibraryBookEntry lbe && lbe.Parent is SeriesEntry se && se.Liberate?.Expanded is not true)
continue;
FilterRemoved.Remove(addBack);
@@ -137,9 +141,10 @@ namespace LibationWinForms.GridView
}
}
private void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e)
private void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs e)
{
var filterResults = AllItems().FilterEntries(FilterString);
var searchResultSet = SearchEngine?.GetSearchResultSet(FilterString);
var filterResults = AllItems().FilterEntries(searchResultSet);
if (FilteredInGridEntries.SearchSetsDiffer(filterResults))
{
@@ -168,7 +173,7 @@ namespace LibationWinForms.GridView
base.Remove(episode);
}
sEntry.Liberate.Expanded = false;
sEntry.Liberate?.Expanded = false;
}
public void ExpandItem(SeriesEntry sEntry)
@@ -183,7 +188,7 @@ namespace LibationWinForms.GridView
InsertItem(++sindex, episode);
}
}
sEntry.Liberate.Expanded = true;
sEntry.Liberate?.Expanded = true;
}
public void RemoveFilter()
@@ -216,7 +221,7 @@ namespace LibationWinForms.GridView
itemsList.AddRange(sortedItems);
}
private void GridEntryBindingList_ListChanged(object sender, ListChangedEventArgs e)
private void GridEntryBindingList_ListChanged(object? sender, ListChangedEventArgs e)
{
if (e.ListChangedType == ListChangedType.ItemChanged && IsSortedCore && e.PropertyDescriptor == SortPropertyCore)
refreshEntries();

View File

@@ -32,6 +32,7 @@ namespace LibationWinForms.GridView
public ProductsDisplay()
{
InitializeComponent();
productsGrid.SearchEngine = MainSearchEngine.Instance;
}
#region Button controls
@@ -262,7 +263,17 @@ namespace LibationWinForms.GridView
}
#endregion
#region Remove Audible Plus Books from Audible Library
if (entries.Length != 1 || ctx.RemoveFromAudibleEnabled)
{
ctxMenu.Items.Add(new ToolStripSeparator());
var removeFromAudibleMenuItem = new ToolStripMenuItem() { Text = ctx.RemoveFromAudibleText, Enabled = ctx.RemoveFromAudibleEnabled };
removeFromAudibleMenuItem.Click += async (_, _) => await ctx.RemoveFromAudibleAsync();
ctxMenu.Items.Add(removeFromAudibleMenuItem);
}
#endregion
if (entries.Length > 1)
return;
@@ -422,7 +433,7 @@ namespace LibationWinForms.GridView
#endregion
internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBooks().ToList();
internal List<LibraryBook> GetVisible() => productsGrid.GetVisibleBookEntries().ToList();
private void productsGrid_VisibleCountChanged(object sender, int count)
{

View File

@@ -1,4 +1,5 @@
using DataLayer;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
using Dinah.Core.WindowsDesktop.Forms;
@@ -6,6 +7,7 @@ using LibationFileManager;
using LibationUiBase.GridView;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
@@ -22,6 +24,23 @@ namespace LibationWinForms.GridView
public partial class ProductsGrid : UserControl
{
[DefaultValue(false)]
[Category("Behavior")]
[Description("Disable the grid context menu")]
public bool DisableContextMenu { get; set; }
[DefaultValue(false)]
[Category("Behavior")]
[Description("Disable grid column reordering and don't persist width changes")]
public bool DisableColumnCustomization
{
get => field;
set
{
field = value;
gridEntryDataGridView.AllowUserToOrderColumns = !value;
}
}
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int>? VisibleCountChanged;
public event LibraryBookEntryClickedEventHandler? LiberateClicked;
@@ -33,13 +52,17 @@ namespace LibationWinForms.GridView
public event ProductsGridCellContextMenuStripNeededEventHandler? LiberateContextMenuStripNeeded;
private GridEntryBindingList? bindingList;
internal IEnumerable<LibraryBook> GetVisibleBooks()
=> bindingList
?.GetFilteredInItems()
.Select(lbe => lbe.LibraryBook) ?? Enumerable.Empty<LibraryBook>();
internal IEnumerable<LibraryBook> GetVisibleBookEntries()
=> GetVisibleGridEntries().Select(lbe => lbe.LibraryBook);
public IEnumerable<LibraryBookEntry> GetVisibleGridEntries()
=> bindingList?.GetFilteredInItems().OfType<LibraryBookEntry>() ?? [];
internal IEnumerable<LibraryBookEntry> GetAllBookEntries()
=> bindingList?.AllItems().BookEntries() ?? Enumerable.Empty<LibraryBookEntry>();
public ISearchEngine? SearchEngine { get => field; set { field = value; bindingList?.SearchEngine = value; } }
public ProductsGrid()
{
InitializeComponent();
@@ -47,19 +70,7 @@ namespace LibationWinForms.GridView
gridEntryDataGridView.Scroll += (_, s) => Scroll?.Invoke(this, s);
gridEntryDataGridView.CellContextMenuStripNeeded += GridEntryDataGridView_CellContextMenuStripNeeded;
removeGVColumn.Frozen = false;
defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font;
setGridFontScale(Configuration.Instance.GridFontScaleFactor);
setGridScale(Configuration.Instance.GridScaleFactor);
Configuration.Instance.PropertyChanged += Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged;
gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled;
gridEntryDataGridView.Disposed += (_, _) =>
{
Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged;
};
defaultFont = gridEntryDataGridView.DefaultCellStyle.Font ?? gridEntryDataGridView.Font;
}
#region Scaling
@@ -120,7 +131,7 @@ namespace LibationWinForms.GridView
private void GridEntryDataGridView_CellContextMenuStripNeeded(object? sender, DataGridViewCellContextMenuStripNeededEventArgs e)
{
// header
if (e.RowIndex < 0 || sender is not DataGridView dgv)
if (DisableContextMenu || e.RowIndex < 0 || sender is not DataGridView dgv)
return;
e.ContextMenuStrip = new ContextMenuStrip();
@@ -313,7 +324,7 @@ namespace LibationWinForms.GridView
}
System.Threading.SynchronizationContext.SetSynchronizationContext(null);
bindingList = new GridEntryBindingList(geList);
bindingList = new GridEntryBindingList(geList) { SearchEngine = SearchEngine };
bindingList.CollapseAll();
//The syncBindingSource ensures that the IGridEntry list is added on the UI thread
@@ -381,7 +392,8 @@ namespace LibationWinForms.GridView
RemoveBooks(removedBooks);
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;
if (topRow >= 0 && topRow < gridEntryDataGridView.RowCount)
gridEntryDataGridView.FirstDisplayedScrollingRowIndex = topRow;
}
public void RemoveBooks(IEnumerable<LibraryBookEntry> removedBooks)
@@ -505,8 +517,21 @@ namespace LibationWinForms.GridView
private void ProductsGrid_Load(object sender, EventArgs e)
{
//https://stackoverflow.com/a/4498512/3335599
if (System.ComponentModel.LicenseManager.UsageMode == System.ComponentModel.LicenseUsageMode.Designtime) return;
//DesignMode is not set in constructor
if (DesignMode)
return;
setGridFontScale(Configuration.Instance.GridFontScaleFactor);
setGridScale(Configuration.Instance.GridScaleFactor);
Configuration.Instance.PropertyChanged += Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontScaleChanged;
gridEntryDataGridView.EnableHeadersVisualStyles = !Application.IsDarkModeEnabled;
gridEntryDataGridView.Disposed += (_, _) =>
{
Configuration.Instance.PropertyChanged -= Configuration_ScaleChanged;
Configuration.Instance.PropertyChanged -= Configuration_FontScaleChanged;
};
gridEntryDataGridView.ColumnWidthChanged += gridEntryDataGridView_ColumnWidthChanged;
gridEntryDataGridView.ColumnDisplayIndexChanged += gridEntryDataGridView_ColumnDisplayIndexChanged;
@@ -523,6 +548,8 @@ namespace LibationWinForms.GridView
foreach (DataGridViewColumn column in gridEntryDataGridView.Columns)
{
if (column == removeGVColumn)
continue;
var itemName = column.DataPropertyName;
var visible = config.GetColumnVisibility(itemName);
@@ -596,6 +623,7 @@ namespace LibationWinForms.GridView
private void gridEntryDataGridView_ColumnDisplayIndexChanged(object? sender, DataGridViewColumnEventArgs e)
{
if (DisableColumnCustomization) return;
var config = Configuration.Instance;
var dictionary = config.GridColumnsDisplayIndices;
@@ -613,6 +641,7 @@ namespace LibationWinForms.GridView
private void gridEntryDataGridView_ColumnWidthChanged(object? sender, DataGridViewColumnEventArgs e)
{
if (DisableColumnCustomization) return;
var config = Configuration.Instance;
var dictionary = config.GridColumnsWidths;

View File

@@ -41,7 +41,7 @@
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="10.0.0.1" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3595.46" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3650.58" />
</ItemGroup>
<ItemGroup>

View File

@@ -3,7 +3,7 @@
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "9.0.10",
"version": "10.0.1",
"commands": [
"dotnet-ef"
],

6
package-lock.json generated
View File

@@ -2027,9 +2027,9 @@
}
},
"node_modules/preact": {
"version": "10.28.0",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.0.tgz",
"integrity": "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA==",
"version": "10.28.2",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
"dev": true,
"license": "MIT",
"funding": {