diff --git a/.gitignore b/.gitignore index 362296e8e..6e35b5d8e 100644 --- a/.gitignore +++ b/.gitignore @@ -543,4 +543,5 @@ Kavita.Services.Tests/Test Data/ImageService/**/*_baseline* Kavita.Services.Tests/Test Data/ImageService/**/*.html Kavita.Services.Tests/Test Data/ScannerService/ScanTests/**/* + Kavita.Server/config/appsettings.*.json diff --git a/Kavita.API/Database/IDataContext.cs b/Kavita.API/Database/IDataContext.cs index 455e3211e..e50e9b1ec 100644 --- a/Kavita.API/Database/IDataContext.cs +++ b/Kavita.API/Database/IDataContext.cs @@ -7,6 +7,7 @@ using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.MetadataMatching; using Kavita.Models.Entities.Person; using Kavita.Models.Entities.Progress; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.Scrobble; using Kavita.Models.Entities.User; using Microsoft.EntityFrameworkCore; diff --git a/Kavita.API/Database/IUnitOfWork.cs b/Kavita.API/Database/IUnitOfWork.cs index 6c352282d..3a3e75112 100644 --- a/Kavita.API/Database/IUnitOfWork.cs +++ b/Kavita.API/Database/IUnitOfWork.cs @@ -35,6 +35,7 @@ public interface IUnitOfWork IEpubFontRepository EpubFontRepository { get; } IReadingSessionRepository ReadingSessionRepository { get; } IClientDeviceRepository ClientDeviceRepository { get; } + IReadingListRemapRuleRepository RemapRuleRepository { get; } bool Commit(); Task CommitAsync(CancellationToken ct = default); bool HasChanges(); diff --git a/Kavita.API/Repositories/IChapterRepository.cs b/Kavita.API/Repositories/IChapterRepository.cs index 9ebe7a556..2051587ff 100644 --- a/Kavita.API/Repositories/IChapterRepository.cs +++ b/Kavita.API/Repositories/IChapterRepository.cs @@ -60,4 +60,14 @@ public interface IChapterRepository Task GetFirstChapterForVolumeAsync(int volumeId, int userId, CancellationToken ct = default); Task> GetChapterDtosAsync(IEnumerable chapterIds, int userId, CancellationToken ct = default); Task GetSeriesIdForChapter(int chapterId, CancellationToken ct = default); + + /// + /// Fetches chapters matching by ComicVineId or MetronId, with Volume and Series included + /// + Task> GetChaptersByExternalIdsAsync(IList comicVineIds, IList metronIds, IList libraryIds, CancellationToken ct = default); + + /// + /// Fetches chapters that have a non-empty AlternateSeries field from the specified libraries + /// + Task> GetChaptersByAlternateSeriesAsync(IList normalizedNames, IList libraryIds, CancellationToken ct = default); } diff --git a/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs b/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs new file mode 100644 index 000000000..d14802752 --- /dev/null +++ b/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs @@ -0,0 +1,26 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; +using Kavita.Models.Entities; +using Kavita.Models.Entities.ReadingLists; + +namespace Kavita.API.Repositories; + +public interface IReadingListRemapRuleRepository +{ + /// + /// Returns all remap rules matching the given normalized CBL series names, + /// ordered with user-specific rules before global rules. + /// + Task> GetRulesForNamesAsync(IList normalizedNames, int userId, CancellationToken ct = default); + Task> GetRulesForUserAsync(int userId, CancellationToken ct = default); + Task GetByIdAsync(int id, CancellationToken ct = default); + Task GetDtoByIdAsync(int id, CancellationToken ct = default); + /// + /// Admin-only: returns all rules across all users, with user names. + /// + Task> GetAllRulesAsync(CancellationToken ct = default); + void Add(ReadingListRemapRule rule); + void Remove(ReadingListRemapRule rule); +} diff --git a/Kavita.API/Repositories/IReadingListRepository.cs b/Kavita.API/Repositories/IReadingListRepository.cs index 2ab0aecc0..f71a21245 100644 --- a/Kavita.API/Repositories/IReadingListRepository.cs +++ b/Kavita.API/Repositories/IReadingListRepository.cs @@ -7,6 +7,7 @@ using Kavita.Models.DTOs.Person; using Kavita.Models.DTOs.ReadingLists; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; namespace Kavita.API.Repositories; diff --git a/Kavita.API/Repositories/ISeriesRepository.cs b/Kavita.API/Repositories/ISeriesRepository.cs index 3816935ac..586398be3 100644 --- a/Kavita.API/Repositories/ISeriesRepository.cs +++ b/Kavita.API/Repositories/ISeriesRepository.cs @@ -125,6 +125,8 @@ public interface ISeriesRepository Task GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default); Task> GetAllSeriesByNameAsync(IList normalizedNames, int userId, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default); + Task> GetAllSeriesByNameAsync(IList normalizedNames, + int userId, IList? libraryIds, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default); Task GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true, CancellationToken ct = default); Task GetSeriesByAnyName(IList names, IList formats, int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default); diff --git a/Kavita.API/Services/ReadingLists/ICblGithubService.cs b/Kavita.API/Services/ReadingLists/ICblGithubService.cs new file mode 100644 index 000000000..0f32fd6a4 --- /dev/null +++ b/Kavita.API/Services/ReadingLists/ICblGithubService.cs @@ -0,0 +1,22 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using Kavita.Models.DTOs.ReadingLists.CBL; + +namespace Kavita.API.Services.ReadingLists; + +public interface ICblGithubService +{ + /// + /// Browse a directory in the CBL repo. Returns folders and .cbl files only. + /// Results are cached per-directory with TTL. Pass forceRefresh to bypass cache. + /// + Task BrowseRepo(string path = "", bool forceRefresh = false); + /// + /// Downloads the raw content of a .cbl file by its repo path. + /// + Task GetFileContent(string filePath); + /// + /// Invalidates all cached directory listings, forcing fresh fetches on next browse. + /// + void InvalidateCache(); +} diff --git a/Kavita.API/Services/ReadingLists/ICblImportService.cs b/Kavita.API/Services/ReadingLists/ICblImportService.cs index 332785e36..6035462d8 100644 --- a/Kavita.API/Services/ReadingLists/ICblImportService.cs +++ b/Kavita.API/Services/ReadingLists/ICblImportService.cs @@ -1,26 +1,20 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Import; namespace Kavita.API.Services.ReadingLists; public interface ICblImportService { - Task ValidateList(int userId, string filePath, CblImportOptions options); + Task ValidateList(int userId, string filePath, CblImportOptions options); /// /// Creates a new RL or updates an existing /// - /// - /// - /// - /// - Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions); + Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions); /// /// Checks for updates against upstream ReadingList files and attempts to Update reading list. /// /// Does not prompt for validation, makes best guess - /// - /// - /// Task SyncReadingList(int userId, int readingListId); } diff --git a/Kavita.API/Services/ReadingLists/IReadingListService.cs b/Kavita.API/Services/ReadingLists/IReadingListService.cs index 93d960228..e11bcda32 100644 --- a/Kavita.API/Services/ReadingLists/IReadingListService.cs +++ b/Kavita.API/Services/ReadingLists/IReadingListService.cs @@ -2,10 +2,9 @@ using System.Collections.Generic; using System.Threading.Tasks; using Kavita.Common.Helpers; using Kavita.Models.DTOs.ReadingLists; -using Kavita.Models.DTOs.ReadingLists.CBL; -using Kavita.Models.DTOs.ReadingLists.CBL.V1; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; namespace Kavita.API.Services.ReadingLists; @@ -22,8 +21,6 @@ public interface IReadingListService Task CalculateReadingListAgeRating(ReadingList readingList); Task AddChaptersToReadingList(int seriesId, IList chapterIds, ReadingList readingList); - Task ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false); - Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false); Task CalculateStartAndEndDates(ReadingList readingListWithItems); /// /// This is expected to be called from ProcessSeries and has the Full Series present. Will generate on the default admin user. @@ -32,8 +29,6 @@ public interface IReadingListService /// /// Task CreateReadingListsFromSeries(Series series, Library library); - - Task CreateReadingListsFromSeries(int libraryId, int seriesId); Task GenerateReadingListCoverImage(int readingListId); /// /// Check, and update if needed, all reading lists' AgeRating who contain the passed series @@ -43,7 +38,5 @@ public interface IReadingListService /// /// This method does not commit changes Task UpdateReadingListAgeRatingForSeries(int seriesId, AgeRating ageRating); - Task> GetReadingListItems(int readingListId, int userId, UserParams? userParams = null); - Task GetContinueReadingPoint(int readingListId, int userId); } diff --git a/Kavita.Common/Kavita.Common.csproj b/Kavita.Common/Kavita.Common.csproj index 17e8c2203..7cb6c32cd 100644 --- a/Kavita.Common/Kavita.Common.csproj +++ b/Kavita.Common/Kavita.Common.csproj @@ -28,4 +28,4 @@ - \ No newline at end of file + diff --git a/Kavita.Database.Tests/Extensions/QueryableExtensionsTests.cs b/Kavita.Database.Tests/Extensions/QueryableExtensionsTests.cs index 9c112585c..0f6728e1b 100644 --- a/Kavita.Database.Tests/Extensions/QueryableExtensionsTests.cs +++ b/Kavita.Database.Tests/Extensions/QueryableExtensionsTests.cs @@ -7,6 +7,7 @@ using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Enums.UserPreferences; using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.Person; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Xunit; diff --git a/Kavita.Database/DataContext.cs b/Kavita.Database/DataContext.cs index 08046ab67..8430a219a 100644 --- a/Kavita.Database/DataContext.cs +++ b/Kavita.Database/DataContext.cs @@ -8,6 +8,7 @@ using Kavita.Database.Extensions; using Kavita.Models.DTOs.Progress; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.ReadingList; using Kavita.Models.Entities.Enums.User; using Kavita.Models.Entities.Enums.UserPreferences; using Kavita.Models.Entities.History; @@ -16,6 +17,7 @@ using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.MetadataMatching; using Kavita.Models.Entities.Person; using Kavita.Models.Entities.Progress; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.Scrobble; using Kavita.Models.Entities.User; using Microsoft.AspNetCore.DataProtection.EntityFrameworkCore; @@ -97,6 +99,8 @@ public sealed class DataContext : IdentityDbContext ClientDeviceHistory { get; set; } = null!; public DbSet AppUserAuthKey { get; set; } = null!; + public DbSet ReadingListRemapRule { get; set; } = null!; + public DbSet DataProtectionKeys { get; set; } = null!; @@ -129,6 +133,39 @@ public sealed class DataContext : IdentityDbContext b.AgeRating) .HasDefaultValue(AgeRating.Unknown); + #region Reading List + builder.Entity() + .Property(b => b.Provider) + .HasDefaultValue(ReadingListProvider.None); + + builder.Entity(entity => + { + entity.HasOne(r => r.Series) + .WithMany() + .HasForeignKey(r => r.SeriesId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasOne(r => r.Volume) + .WithMany() + .HasForeignKey(r => r.VolumeId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(r => r.Chapter) + .WithMany() + .HasForeignKey(r => r.ChapterId) + .OnDelete(DeleteBehavior.SetNull); + + entity.HasOne(r => r.AppUser) + .WithMany() + .HasForeignKey(r => r.AppUserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(r => new { r.NormalizedCblSeriesName, r.IsGlobal, r.AppUserId }) + .HasDatabaseName("IX_ReadingListRemapRule_NormalizedCblSeriesName_IsGlobal_AppUserId"); + }); + #endregion + + #region Library builder.Entity() @@ -304,7 +341,6 @@ public sealed class DataContext : IdentityDbContext() .Property(b => b.IsActive) diff --git a/Kavita.Database/Extensions/IncludesExtensions.cs b/Kavita.Database/Extensions/IncludesExtensions.cs index c0354d949..a094fdbe1 100644 --- a/Kavita.Database/Extensions/IncludesExtensions.cs +++ b/Kavita.Database/Extensions/IncludesExtensions.cs @@ -2,6 +2,7 @@ using System.Linq; using Kavita.API.Repositories; using Kavita.Models.Entities; using Kavita.Models.Entities.Person; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Microsoft.EntityFrameworkCore; diff --git a/Kavita.Database/Extensions/RestrictByAgeExtensions.cs b/Kavita.Database/Extensions/RestrictByAgeExtensions.cs index 0951c2325..667a0d8cf 100644 --- a/Kavita.Database/Extensions/RestrictByAgeExtensions.cs +++ b/Kavita.Database/Extensions/RestrictByAgeExtensions.cs @@ -3,6 +3,7 @@ using System.Linq; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Person; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; namespace Kavita.Database.Extensions; diff --git a/Kavita.Database/Extensions/SearchQueryableExtensions.cs b/Kavita.Database/Extensions/SearchQueryableExtensions.cs index e8aff4e32..b9776a5cd 100644 --- a/Kavita.Database/Extensions/SearchQueryableExtensions.cs +++ b/Kavita.Database/Extensions/SearchQueryableExtensions.cs @@ -4,6 +4,7 @@ using Kavita.API.Repositories; using Kavita.Models.Entities; using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.Person; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Microsoft.EntityFrameworkCore; diff --git a/Kavita.Database/Migrations/20260321155910_ReadingListSyncAndRemapRules.Designer.cs b/Kavita.Database/Migrations/20260321155910_ReadingListSyncAndRemapRules.Designer.cs new file mode 100644 index 000000000..9628a3434 --- /dev/null +++ b/Kavita.Database/Migrations/20260321155910_ReadingListSyncAndRemapRules.Designer.cs @@ -0,0 +1,4649 @@ +// +using System; +using System.Collections.Generic; +using Kavita.Database; +using Kavita.Models.Entities.MetadataMatching; +using Kavita.Models.Entities.Progress; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Kavita.Database.Migrations +{ + [DbContext(typeof(DataContext))] + [Migration("20260321155910_ReadingListSyncAndRemapRules")] + partial class ReadingListSyncAndRemapRules + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.5"); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.Property("CollectionsId") + .HasColumnType("INTEGER"); + + b.Property("ItemsId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionsId", "ItemsId"); + + b.HasIndex("ItemsId"); + + b.ToTable("AppUserCollectionSeries"); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.Property("AppUsersId") + .HasColumnType("INTEGER"); + + b.Property("LibrariesId") + .HasColumnType("INTEGER"); + + b.HasKey("AppUsersId", "LibrariesId"); + + b.HasIndex("LibrariesId"); + + b.ToTable("AppUserLibrary"); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "GenresId"); + + b.HasIndex("GenresId"); + + b.ToTable("ChapterGenre"); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTag"); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.Property("CollectionTagsId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("CollectionTagsId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("CollectionTagSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.Property("ExternalRatingsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRatingsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRatingExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.Property("ExternalRecommendationsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalRecommendationsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalRecommendationExternalSeriesMetadata"); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.Property("ExternalReviewsId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("ExternalReviewsId", "ExternalSeriesMetadatasId"); + + b.HasIndex("ExternalSeriesMetadatasId"); + + b.ToTable("ExternalReviewExternalSeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("AlternateCount") + .HasColumnType("INTEGER"); + + b.Property("AlternateNumber") + .HasColumnType("TEXT"); + + b.Property("AlternateSeries") + .HasColumnType("TEXT"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("REAL"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("ComicVineId") + .HasColumnType("TEXT"); + + b.Property("Count") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("HardcoverId") + .HasColumnType("INTEGER"); + + b.Property("ISBN") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("ISBNLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("IsSpecial") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("MangaBakaId") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MetronId") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Number") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("Range") + .HasColumnType("TEXT"); + + b.Property("ReleaseDate") + .HasColumnType("TEXT"); + + b.Property("ReleaseDateLocked") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesGroup") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("REAL"); + + b.Property("SortOrderLocked") + .HasColumnType("INTEGER"); + + b.Property("StoryArc") + .HasColumnType("TEXT"); + + b.Property("StoryArcNumber") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TitleName") + .HasColumnType("TEXT"); + + b.Property("TitleNameLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TitleName") + .HasDatabaseName("IX_Chapter_TitleName"); + + b.HasIndex("VolumeId"); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.CollectionTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Id", "Promoted") + .IsUnique(); + + b.ToTable("CollectionTag"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Device", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EmailAddress") + .HasColumnType("TEXT"); + + b.Property("IpAddress") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastUsed") + .HasColumnType("TEXT"); + + b.Property("LastUsedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Platform") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("Device"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.EmailHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DeliveryStatus") + .HasColumnType("TEXT"); + + b.Property("EmailTemplate") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SendDate") + .HasColumnType("TEXT"); + + b.Property("Sent") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("Sent", "AppUserId", "EmailTemplate", "SendDate"); + + b.ToTable("EmailHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.EpubFont", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("EpubFont"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.FolderPath", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Path") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("FolderPath"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Genre"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.History.ManualMigrationHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("ProductVersion") + .HasColumnType("TEXT"); + + b.Property("RanAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ManualMigrationHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowMetadataMatching") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AllowScrobbling") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DefaultLanguage") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("EnableMetadata") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FolderWatching") + .HasColumnType("INTEGER"); + + b.Property("IncludeInDashboard") + .HasColumnType("INTEGER"); + + b.Property("IncludeInRecommended") + .HasColumnType("INTEGER"); + + b.Property("IncludeInSearch") + .HasColumnType("INTEGER"); + + b.Property("InheritWebLinksFromFirstChapter") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastScanned") + .HasColumnType("TEXT"); + + b.Property("ManageCollections") + .HasColumnType("INTEGER"); + + b.Property("ManageReadingLists") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RemovePrefixForSortName") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.LibraryExcludePattern", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Pattern") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryExcludePattern"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.LibraryFileTypeGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileTypeGroup") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId"); + + b.ToTable("LibraryFileTypeGroup"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MangaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Bytes") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("KoreaderHash") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysis") + .HasColumnType("TEXT"); + + b.Property("LastFileAnalysisUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("FilePath") + .HasDatabaseName("IX_MangaFile_FilePath"); + + b.ToTable("MangaFile"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MediaError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("Extension") + .HasColumnType("TEXT"); + + b.Property("FilePath") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MediaError"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("AverageScore") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("FavoriteCount") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ProviderUrl") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalRating"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalRecommendation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("CoverUrl") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("ExternalRecommendation"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Authority") + .HasColumnType("INTEGER"); + + b.Property("Body") + .HasColumnType("TEXT"); + + b.Property("BodyJustText") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("RawBody") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SiteUrl") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasColumnType("TEXT"); + + b.Property("TotalVotes") + .HasColumnType("INTEGER"); + + b.Property("Username") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("ExternalReview"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AverageExternalRating") + .HasColumnType("INTEGER"); + + b.Property("CbrId") + .HasColumnType("INTEGER"); + + b.Property("GoogleBooksId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("ValidUntilUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.ToTable("ExternalSeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.GenreSeriesMetadata", b => + { + b.Property("GenresId") + .HasColumnType("INTEGER"); + + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.HasKey("GenresId", "SeriesMetadatasId"); + + b.HasIndex("SeriesMetadatasId"); + + b.ToTable("GenreSeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesBlacklist", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastChecked") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("SeriesBlacklist"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadata", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AgeRatingLocked") + .HasColumnType("INTEGER"); + + b.Property("CharacterLocked") + .HasColumnType("INTEGER"); + + b.Property("ColoristLocked") + .HasColumnType("INTEGER"); + + b.Property("CoverArtistLocked") + .HasColumnType("INTEGER"); + + b.Property("EditorLocked") + .HasColumnType("INTEGER"); + + b.Property("GenresLocked") + .HasColumnType("INTEGER"); + + b.Property("ImprintLocked") + .HasColumnType("INTEGER"); + + b.Property("InkerLocked") + .HasColumnType("INTEGER"); + + b.Property("KPlusOverrides") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Language") + .HasColumnType("TEXT"); + + b.Property("LanguageLocked") + .HasColumnType("INTEGER"); + + b.Property("LettererLocked") + .HasColumnType("INTEGER"); + + b.Property("LocationLocked") + .HasColumnType("INTEGER"); + + b.Property("MaxCount") + .HasColumnType("INTEGER"); + + b.Property("PencillerLocked") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("PublicationStatusLocked") + .HasColumnType("INTEGER"); + + b.Property("PublisherLocked") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYear") + .HasColumnType("INTEGER"); + + b.Property("ReleaseYearLocked") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("SummaryLocked") + .HasColumnType("INTEGER"); + + b.Property("TagsLocked") + .HasColumnType("INTEGER"); + + b.Property("TeamLocked") + .HasColumnType("INTEGER"); + + b.Property("TotalCount") + .HasColumnType("INTEGER"); + + b.Property("TranslatorLocked") + .HasColumnType("INTEGER"); + + b.Property("WebLinks") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue(""); + + b.Property("WriterLocked") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AgeRating") + .HasDatabaseName("IX_SeriesMetadata_AgeRating"); + + b.HasIndex("SeriesId") + .IsUnique(); + + b.HasIndex("Id", "SeriesId") + .IsUnique(); + + b.HasIndex("SeriesId", "AgeRating") + .HasDatabaseName("IX_SeriesMetadata_SeriesId_AgeRating"); + + b.ToTable("SeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadataTag", b => + { + b.Property("SeriesMetadatasId") + .HasColumnType("INTEGER"); + + b.Property("TagsId") + .HasColumnType("INTEGER"); + + b.HasKey("SeriesMetadatasId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("SeriesMetadataTag"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesRelation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("RelationKind") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TargetSeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.HasIndex("TargetSeriesId"); + + b.ToTable("SeriesRelation"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataFieldMapping", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("DestinationType") + .HasColumnType("INTEGER"); + + b.Property("DestinationValue") + .HasColumnType("TEXT"); + + b.Property("ExcludeFromSource") + .HasColumnType("INTEGER"); + + b.Property("MetadataSettingsId") + .HasColumnType("INTEGER"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("SourceValue") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MetadataSettingsId"); + + b.ToTable("MetadataFieldMapping"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataMatching.MetadataSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRatingMappings") + .HasColumnType("TEXT"); + + b.Property("Blacklist") + .HasColumnType("TEXT"); + + b.Property("EnableChapterCoverImage") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterPublisher") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterReleaseDate") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableChapterTitle") + .HasColumnType("INTEGER"); + + b.Property("EnableCoverImage") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("EnableExtendedMetadataProcessing") + .HasColumnType("INTEGER"); + + b.Property("EnableGenres") + .HasColumnType("INTEGER"); + + b.Property("EnableLocalizedName") + .HasColumnType("INTEGER"); + + b.Property("EnablePeople") + .HasColumnType("INTEGER"); + + b.Property("EnablePublicationStatus") + .HasColumnType("INTEGER"); + + b.Property("EnableRelationships") + .HasColumnType("INTEGER"); + + b.Property("EnableStartDate") + .HasColumnType("INTEGER"); + + b.Property("EnableSummary") + .HasColumnType("INTEGER"); + + b.Property("EnableTags") + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("FirstLastPeopleNaming") + .HasColumnType("INTEGER"); + + b.Property("Overrides") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("PersonRoles") + .HasColumnType("TEXT"); + + b.Property("Whitelist") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MetadataSettings"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.ChapterPeople", b => + { + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .HasColumnType("INTEGER"); + + b.HasKey("ChapterId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("ChapterPeople"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("Asin") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.PersonAlias", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Alias") + .HasColumnType("TEXT"); + + b.Property("NormalizedAlias") + .HasColumnType("TEXT"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PersonId"); + + b.ToTable("PersonAlias"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.SeriesMetadataPeople", b => + { + b.Property("SeriesMetadataId") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("INTEGER"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("KavitaPlusConnection") + .HasColumnType("INTEGER"); + + b.Property("OrderWeight") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("SeriesMetadataId", "PersonId", "Role"); + + b.HasIndex("PersonId"); + + b.ToTable("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserProgress", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("TotalReads") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserProgresses"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ClientInfoUsed") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Data") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"TotalMinutesRead\":0,\"TotalPagesRead\":0,\"TotalWordsRead\":0,\"LongestSessionMinutes\":0,\"Activities\":[],\"SeriesIds\":null,\"ChapterIds\":null}"); + + b.Property("DateUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("DateUtc") + .IsUnique(); + + b.ToTable("AppUserReadingHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("IsActive") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("IsGenerated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("IsActive"); + + b.HasIndex("AppUserId", "IsActive") + .HasDatabaseName("IX_AppUserReadingSession_AppUserId_IsActive"); + + b.HasIndex("IsActive", "LastModifiedUtc") + .HasDatabaseName("IX_AppUserReadingSession_IsActive_LastModifiedUtc"); + + b.ToTable("AppUserReadingSession"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserReadingSessionId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("DeviceIds") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("EndBookScrollId") + .HasColumnType("TEXT"); + + b.Property("EndPage") + .HasColumnType("INTEGER"); + + b.Property("EndTime") + .HasColumnType("TEXT"); + + b.Property("EndTimeUtc") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PagesRead") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("StartBookScrollId") + .HasColumnType("TEXT"); + + b.Property("StartPage") + .HasColumnType("INTEGER"); + + b.Property("StartTime") + .HasColumnType("TEXT"); + + b.Property("StartTimeUtc") + .HasColumnType("TEXT"); + + b.Property("TotalPages") + .HasColumnType("INTEGER"); + + b.Property("TotalWords") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("WordsRead") + .HasColumnType("INTEGER"); + + b.ComplexProperty(typeof(Dictionary), "ClientInfo", "Kavita.Models.Entities.Progress.AppUserReadingSessionActivityData.ClientInfo#ClientInfoData", b1 => + { + b1.Property("AppVersion"); + + b1.Property("AuthType"); + + b1.Property("Browser"); + + b1.Property("BrowserVersion"); + + b1.Property("CapturedAt"); + + b1.Property("ClientType"); + + b1.Property("DeviceType"); + + b1.Property("IpAddress") + .IsRequired(); + + b1.Property("Orientation"); + + b1.Property("Platform"); + + b1.Property("ScreenHeight"); + + b1.Property("ScreenWidth"); + + b1.Property("UserAgent") + .IsRequired(); + + b1 + .ToJson("ClientInfo") + .HasColumnType("TEXT"); + }); + + b.HasKey("Id"); + + b.HasIndex("AppUserReadingSessionId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("StartTimeUtc", "LibraryId") + .HasDatabaseName("IX_ActivityData_StartTimeUtc_LibraryId"); + + b.ToTable("AppUserReadingSessionActivityData"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DownloadUrl") + .HasColumnType("TEXT"); + + b.Property("EndingMonth") + .HasColumnType("INTEGER"); + + b.Property("EndingYear") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncCheckUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncedUtc") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + + b.Property("StartingMonth") + .HasColumnType("INTEGER"); + + b.Property("StartingYear") + .HasColumnType("INTEGER"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ReadingList"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("ReadingListId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("ReadingListId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.ToTable("ReadingListItem"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListRemapRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CblNumber") + .HasColumnType("TEXT"); + + b.Property("CblSeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CblVolume") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsGlobal") + .HasColumnType("INTEGER"); + + b.Property("NormalizedCblSeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SeriesNameAtMapping") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("NormalizedCblSeriesName", "IsGlobal", "AppUserId") + .HasDatabaseName("IX_ReadingListRemapRule_NormalizedCblSeriesName_IsGlobal_AppUserId"); + + b.ToTable("ReadingListRemapRule"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleError", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Details") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId") + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventId1") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ScrobbleEventId1"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleError"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterNumber") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorDetails") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("IsErrored") + .HasColumnType("INTEGER"); + + b.Property("IsProcessed") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("ProcessDateUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("ReviewBody") + .HasColumnType("TEXT"); + + b.Property("ReviewTitle") + .HasColumnType("TEXT"); + + b.Property("ScrobbleEventType") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeNumber") + .HasColumnType("REAL"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleEvent"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleHold", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("ScrobbleHold"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("ComicVineId") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("DontMatch") + .HasColumnType("INTEGER"); + + b.Property("FolderPath") + .HasColumnType("TEXT"); + + b.Property("Format") + .HasColumnType("INTEGER"); + + b.Property("HardcoverId") + .HasColumnType("INTEGER"); + + b.Property("IsBlacklisted") + .HasColumnType("INTEGER"); + + b.Property("LastChapterAdded") + .HasColumnType("TEXT"); + + b.Property("LastChapterAddedUtc") + .HasColumnType("TEXT"); + + b.Property("LastFolderScanned") + .HasColumnType("TEXT"); + + b.Property("LastFolderScannedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("LocalizedName") + .HasColumnType("TEXT"); + + b.Property("LocalizedNameLocked") + .HasColumnType("INTEGER"); + + b.Property("LowestFolderPath") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("MangaBakaId") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MetronId") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedLocalizedName") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("OriginalName") + .HasColumnType("TEXT"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SortName") + .HasColumnType("TEXT"); + + b.Property("SortNameLocked") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId") + .HasDatabaseName("IX_Series_LibraryId"); + + b.HasIndex("NormalizedName") + .HasDatabaseName("IX_Series_NormalizedName"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ServerSetting", b => + { + b.Property("Key") + .HasColumnType("INTEGER"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("ServerSetting"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ServerStatistics", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ChapterCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("GenreCount") + .HasColumnType("INTEGER"); + + b.Property("PersonCount") + .HasColumnType("INTEGER"); + + b.Property("SeriesCount") + .HasColumnType("INTEGER"); + + b.Property("TagCount") + .HasColumnType("INTEGER"); + + b.Property("UserCount") + .HasColumnType("INTEGER"); + + b.Property("VolumeCount") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("ServerStatistics"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.SiteTheme", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CompatibleVersion") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("GitHubPath") + .HasColumnType("TEXT"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PreviewUrls") + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasColumnType("INTEGER"); + + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SiteTheme"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedTitle") + .IsUnique(); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AgeRestriction") + .HasColumnType("INTEGER"); + + b.Property("AgeRestrictionIncludeUnknowns") + .HasColumnType("INTEGER"); + + b.Property("AniListAccessToken") + .HasColumnType("TEXT"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("ConfirmationToken") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("HasRunScrobbleEventGeneration") + .HasColumnType("INTEGER"); + + b.Property("IdentityProvider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LastActive") + .HasColumnType("TEXT"); + + b.Property("LastActiveUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MalAccessToken") + .HasColumnType("TEXT"); + + b.Property("MalUserName") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("OidcId") + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property("ScrobbleEventGenerationRan") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAnnotation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Comment") + .HasColumnType("TEXT"); + + b.Property("CommentHtml") + .HasColumnType("TEXT"); + + b.Property("CommentPlainText") + .HasColumnType("TEXT"); + + b.Property("ContainsSpoiler") + .HasColumnType("INTEGER"); + + b.Property("Context") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("EndingXPath") + .HasColumnType("TEXT"); + + b.Property("HighlightCount") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Likes") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedSlotIndex") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("LibraryId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserAnnotation"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAuthKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("Key") + .HasColumnType("TEXT"); + + b.Property("LastAccessedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ExpiresAtUtc"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("AppUserAuthKey"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserBookmark", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("FileName") + .HasColumnType("TEXT"); + + b.Property("ImageOffset") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Page") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.Property("XPath") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("AppUserId", "SeriesId") + .HasDatabaseName("IX_AppUserBookmark_AppUserId_SeriesId"); + + b.ToTable("AppUserBookmark"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserChapterRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserChapterRating"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AgeRating") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncUtc") + .HasColumnType("TEXT"); + + b.Property("MissingSeriesFromSource") + .HasColumnType("TEXT"); + + b.Property("NormalizedTitle") + .HasColumnType("TEXT"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("Promoted") + .HasColumnType("INTEGER"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("INTEGER"); + + b.Property("SourceUrl") + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("TotalSourceCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserCollection"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserDashboardStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(4); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserDashboardStream"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserExternalSource", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ApiKey") + .HasColumnType("TEXT"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Host") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserExternalSource"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserOnDeckRemoval", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserOnDeckRemoval"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserPreferences", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AniListScrobblingEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BlurUnreadSummaries") + .HasColumnType("INTEGER"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderHighlightSlots") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.Property("CollapseSeriesRelationships") + .HasColumnType("INTEGER"); + + b.Property("ColorScapeEnabled") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("CustomKeyBinds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{}"); + + b.Property("DataSaver") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("GlobalPageLayoutMode") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.Property("Locale") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("en"); + + b.Property("NoTransitions") + .HasColumnType("INTEGER"); + + b.Property("OpdsPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"EmbedProgressIndicator\":true,\"IncludeContinueFrom\":true}"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("PromptForDownloadSize") + .HasColumnType("INTEGER"); + + b.Property("PromptForRereadsAfter") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(30); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.Property("ShareReviews") + .HasColumnType("INTEGER"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SocialPreferences") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"ShareReviews\":false,\"ShareAnnotations\":false,\"ViewOtherAnnotations\":false,\"SocialLibraries\":[],\"SocialMaxAgeRating\":-1,\"SocialIncludeUnknowns\":true,\"ShareProfile\":false}"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("ThemeId") + .HasColumnType("INTEGER"); + + b.Property("WantToReadSync") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.HasKey("Id"); + + b.HasIndex("AppUserId") + .IsUnique(); + + b.HasIndex("ThemeId"); + + b.ToTable("AppUserPreferences"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRating", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HasBeenRated") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("Review") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserRating"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserReadingProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AllowAutomaticWebtoonReaderDetection") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(true); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("AutoCloseMenu") + .HasColumnType("INTEGER"); + + b.Property("BackgroundColor") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("#000000"); + + b.Property("BookReaderFontFamily") + .HasColumnType("TEXT"); + + b.Property("BookReaderFontSize") + .HasColumnType("INTEGER"); + + b.Property("BookReaderImmersiveMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLayoutMode") + .HasColumnType("INTEGER"); + + b.Property("BookReaderLineSpacing") + .HasColumnType("INTEGER"); + + b.Property("BookReaderMargin") + .HasColumnType("INTEGER"); + + b.Property("BookReaderReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("BookReaderTapToPaginate") + .HasColumnType("INTEGER"); + + b.Property("BookReaderWritingStyle") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("BookThemeName") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("Dark"); + + b.PrimitiveCollection("DeviceIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("DisableWidthOverride") + .HasColumnType("INTEGER"); + + b.Property("EmulateBook") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .HasColumnType("INTEGER"); + + b.Property("LayoutMode") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("LibraryIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasColumnType("TEXT"); + + b.Property("PageSplitOption") + .HasColumnType("INTEGER"); + + b.Property("PdfScrollMode") + .HasColumnType("INTEGER"); + + b.Property("PdfSpreadMode") + .HasColumnType("INTEGER"); + + b.Property("PdfTheme") + .HasColumnType("INTEGER"); + + b.Property("ReaderMode") + .HasColumnType("INTEGER"); + + b.Property("ReadingDirection") + .HasColumnType("INTEGER"); + + b.Property("ScalingOption") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("SeriesIds") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("[]"); + + b.Property("ShowScreenHints") + .HasColumnType("INTEGER"); + + b.Property("SwipeToPaginate") + .HasColumnType("INTEGER"); + + b.Property("WidthOverride") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserReadingProfiles"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserSideNavStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("ExternalSourceId") + .HasColumnType("INTEGER"); + + b.Property("IsProvided") + .HasColumnType("INTEGER"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("SmartFilterId") + .HasColumnType("INTEGER"); + + b.Property("StreamType") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(5); + + b.Property("Visible") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SmartFilterId"); + + b.HasIndex("Visible"); + + b.ToTable("AppUserSideNavStream"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserSmartFilter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("Filter") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("AppUserSmartFilter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserTableOfContent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("BookScrollId") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("ChapterTitle") + .HasColumnType("TEXT"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("INTEGER"); + + b.Property("PageNumber") + .HasColumnType("INTEGER"); + + b.Property("SelectedText") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserTableOfContent"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserWantToRead", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("SeriesId"); + + b.ToTable("AppUserWantToRead"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CurrentClientInfo") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceFingerprint") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FirstSeenUtc") + .HasColumnType("TEXT"); + + b.Property("FriendlyName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsActive") + .HasColumnType("INTEGER"); + + b.Property("LastSeenUtc") + .HasColumnType("TEXT"); + + b.Property("UiFingerprint") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.ToTable("ClientDevice"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDeviceHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CapturedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ClientInfo") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasDefaultValue("{\"UserAgent\":\"\",\"IpAddress\":\"\",\"AuthType\":0,\"ClientType\":0,\"AppVersion\":null,\"Browser\":null,\"BrowserVersion\":null,\"Platform\":0,\"DeviceType\":null,\"ScreenWidth\":null,\"ScreenHeight\":null,\"Orientation\":null,\"CapturedAt\":\"0001-01-01T00:00:00\"}"); + + b.Property("DeviceId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("DeviceId"); + + b.ToTable("ClientDeviceHistory"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AniListId") + .HasColumnType("INTEGER"); + + b.Property("AvgHoursToRead") + .HasColumnType("REAL"); + + b.Property("ComicVineId") + .HasColumnType("TEXT"); + + b.Property("CoverImage") + .HasColumnType("TEXT"); + + b.Property("CoverImageLocked") + .HasColumnType("INTEGER"); + + b.Property("Created") + .HasColumnType("TEXT"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("HardcoverId") + .HasColumnType("INTEGER"); + + b.Property("LastModified") + .HasColumnType("TEXT"); + + b.Property("LastModifiedUtc") + .HasColumnType("TEXT"); + + b.Property("LookupName") + .HasColumnType("TEXT"); + + b.Property("MalId") + .HasColumnType("INTEGER"); + + b.Property("MangaBakaId") + .HasColumnType("INTEGER"); + + b.Property("MaxHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MaxNumber") + .HasColumnType("REAL"); + + b.Property("MetronId") + .HasColumnType("INTEGER"); + + b.Property("MinHoursToRead") + .HasColumnType("INTEGER"); + + b.Property("MinNumber") + .HasColumnType("REAL"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Pages") + .HasColumnType("INTEGER"); + + b.Property("PrimaryColor") + .HasColumnType("TEXT"); + + b.Property("SecondaryColor") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId"); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FriendlyName") + .HasColumnType("TEXT"); + + b.Property("Xml") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("AppUserCollectionSeries", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUserCollection", null) + .WithMany() + .HasForeignKey("CollectionsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", null) + .WithMany() + .HasForeignKey("ItemsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("AppUserLibrary", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("AppUsersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", null) + .WithMany() + .HasForeignKey("LibrariesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterGenre", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CollectionTagSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.CollectionTag", null) + .WithMany() + .HasForeignKey("CollectionTagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRatingExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.ExternalRating", null) + .WithMany() + .HasForeignKey("ExternalRatingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalRecommendationExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.ExternalRecommendation", null) + .WithMany() + .HasForeignKey("ExternalRecommendationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ExternalReviewExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.ExternalReview", null) + .WithMany() + .HasForeignKey("ExternalReviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", null) + .WithMany() + .HasForeignKey("ExternalSeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Chapter", b => + { + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Device", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Devices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.EmailHistory", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.FolderPath", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("Folders") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.LibraryExcludePattern", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("LibraryExcludePatterns") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.LibraryFileTypeGroup", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("LibraryFileTypes") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MangaFile", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany("Files") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalRating", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany("ExternalRatings") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalReview", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany("ExternalReviews") + .HasForeignKey("ChapterId"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithOne("ExternalSeriesMetadata") + .HasForeignKey("Kavita.Models.Entities.Metadata.ExternalSeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.GenreSeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Genre", null) + .WithMany() + .HasForeignKey("GenresId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesBlacklist", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadata", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithOne("Metadata") + .HasForeignKey("Kavita.Models.Entities.Metadata.SeriesMetadata", "SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadataTag", b => + { + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", null) + .WithMany() + .HasForeignKey("SeriesMetadatasId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesRelation", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany("Relations") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "TargetSeries") + .WithMany("RelationOf") + .HasForeignKey("TargetSeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + + b.Navigation("TargetSeries"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataFieldMapping", b => + { + b.HasOne("Kavita.Models.Entities.MetadataMatching.MetadataSettings", "MetadataSettings") + .WithMany("FieldMappings") + .HasForeignKey("MetadataSettingsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MetadataSettings"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.ChapterPeople", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany("People") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Person.Person", "Person") + .WithMany("ChapterPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.PersonAlias", b => + { + b.HasOne("Kavita.Models.Entities.Person.Person", "Person") + .WithMany("Aliases") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.SeriesMetadataPeople", b => + { + b.HasOne("Kavita.Models.Entities.Person.Person", "Person") + .WithMany("SeriesMetadataPeople") + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Metadata.SeriesMetadata", "SeriesMetadata") + .WithMany("People") + .HasForeignKey("SeriesMetadataId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Person"); + + b.Navigation("SeriesMetadata"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserProgress", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Progresses") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", null) + .WithMany("UserProgress") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", null) + .WithMany("Progress") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingHistory", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingHistory") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSession", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingSessions") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSessionActivityData", b => + { + b.HasOne("Kavita.Models.Entities.Progress.AppUserReadingSession", "ReadingSession") + .WithMany("ActivityData") + .HasForeignKey("AppUserReadingSessionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("ReadingSession"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingLists") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListItem", b => + { + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.ReadingLists.ReadingList", "ReadingList") + .WithMany("Items") + .HasForeignKey("ReadingListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("ReadingList"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListRemapRule", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleError", b => + { + b.HasOne("Kavita.Models.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") + .WithMany() + .HasForeignKey("ScrobbleEventId1"); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("ScrobbleEvent"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleEvent", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleHold", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ScrobbleHolds") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Series", b => + { + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany("Series") + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Library"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAnnotation", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Annotations") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Library", "Library") + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Library"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserAuthKey", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("AuthKeys") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserBookmark", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Bookmarks") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserChapterRating", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ChapterRatings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany("Ratings") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserCollection", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Collections") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserDashboardStream", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("DashboardStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.User.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserExternalSource", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ExternalSources") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserOnDeckRemoval", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserPreferences", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithOne("UserPreferences") + .HasForeignKey("Kavita.Models.Entities.User.AppUserPreferences", "AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.SiteTheme", "Theme") + .WithMany() + .HasForeignKey("ThemeId"); + + b.Navigation("AppUser"); + + b.Navigation("Theme"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRating", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("Ratings") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany("Ratings") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserReadingProfile", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ReadingProfiles") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserRole", b => + { + b.HasOne("Kavita.Models.Entities.User.AppRole", "Role") + .WithMany("UserRoles") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.User.AppUser", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Role"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserSideNavStream", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("SideNavStreams") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.User.AppUserSmartFilter", "SmartFilter") + .WithMany() + .HasForeignKey("SmartFilterId"); + + b.Navigation("AppUser"); + + b.Navigation("SmartFilter"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserSmartFilter", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("SmartFilters") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserTableOfContent", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("TableOfContents") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUserWantToRead", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("WantToRead") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDevice", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany("ClientDevices") + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("AppUser"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDeviceHistory", b => + { + b.HasOne("Kavita.Models.Entities.User.ClientDevice", "Device") + .WithMany("History") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Volume", b => + { + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany("Volumes") + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Kavita.Models.Entities.User.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Chapter", b => + { + b.Navigation("ExternalRatings"); + + b.Navigation("ExternalReviews"); + + b.Navigation("Files"); + + b.Navigation("People"); + + b.Navigation("Ratings"); + + b.Navigation("UserProgress"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Library", b => + { + b.Navigation("Folders"); + + b.Navigation("LibraryExcludePatterns"); + + b.Navigation("LibraryFileTypes"); + + b.Navigation("Series"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Metadata.SeriesMetadata", b => + { + b.Navigation("People"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.MetadataMatching.MetadataSettings", b => + { + b.Navigation("FieldMappings"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Person.Person", b => + { + b.Navigation("Aliases"); + + b.Navigation("ChapterPeople"); + + b.Navigation("SeriesMetadataPeople"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Progress.AppUserReadingSession", b => + { + b.Navigation("ActivityData"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Series", b => + { + b.Navigation("ExternalSeriesMetadata"); + + b.Navigation("Metadata"); + + b.Navigation("Progress"); + + b.Navigation("Ratings"); + + b.Navigation("RelationOf"); + + b.Navigation("Relations"); + + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppRole", b => + { + b.Navigation("UserRoles"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.AppUser", b => + { + b.Navigation("Annotations"); + + b.Navigation("AuthKeys"); + + b.Navigation("Bookmarks"); + + b.Navigation("ChapterRatings"); + + b.Navigation("ClientDevices"); + + b.Navigation("Collections"); + + b.Navigation("DashboardStreams"); + + b.Navigation("Devices"); + + b.Navigation("ExternalSources"); + + b.Navigation("Progresses"); + + b.Navigation("Ratings"); + + b.Navigation("ReadingHistory"); + + b.Navigation("ReadingLists"); + + b.Navigation("ReadingProfiles"); + + b.Navigation("ReadingSessions"); + + b.Navigation("ScrobbleHolds"); + + b.Navigation("SideNavStreams"); + + b.Navigation("SmartFilters"); + + b.Navigation("TableOfContents"); + + b.Navigation("UserPreferences") + .IsRequired(); + + b.Navigation("UserRoles"); + + b.Navigation("WantToRead"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.User.ClientDevice", b => + { + b.Navigation("History"); + }); + + modelBuilder.Entity("Kavita.Models.Entities.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Kavita.Database/Migrations/20260321155910_ReadingListSyncAndRemapRules.cs b/Kavita.Database/Migrations/20260321155910_ReadingListSyncAndRemapRules.cs new file mode 100644 index 000000000..695fd507d --- /dev/null +++ b/Kavita.Database/Migrations/20260321155910_ReadingListSyncAndRemapRules.cs @@ -0,0 +1,155 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Kavita.Database.Migrations +{ + /// + public partial class ReadingListSyncAndRemapRules : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "DownloadUrl", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastSyncCheckUtc", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "LastSyncedUtc", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Provider", + table: "ReadingList", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.AddColumn( + name: "ShaHash", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SourcePath", + table: "ReadingList", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "ReadingListRemapRule", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + NormalizedCblSeriesName = table.Column(type: "TEXT", nullable: false), + CblVolume = table.Column(type: "TEXT", nullable: true), + CblNumber = table.Column(type: "TEXT", nullable: true), + SeriesId = table.Column(type: "INTEGER", nullable: false), + VolumeId = table.Column(type: "INTEGER", nullable: true), + ChapterId = table.Column(type: "INTEGER", nullable: true), + CblSeriesName = table.Column(type: "TEXT", nullable: false), + SeriesNameAtMapping = table.Column(type: "TEXT", nullable: false), + IsGlobal = table.Column(type: "INTEGER", nullable: false), + AppUserId = table.Column(type: "INTEGER", nullable: false), + CreatedUtc = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_ReadingListRemapRule", x => x.Id); + table.ForeignKey( + name: "FK_ReadingListRemapRule_AspNetUsers_AppUserId", + column: x => x.AppUserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ReadingListRemapRule_Chapter_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapter", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_ReadingListRemapRule_Series_SeriesId", + column: x => x.SeriesId, + principalTable: "Series", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_ReadingListRemapRule_Volume_VolumeId", + column: x => x.VolumeId, + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListRemapRule_AppUserId", + table: "ReadingListRemapRule", + column: "AppUserId"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListRemapRule_ChapterId", + table: "ReadingListRemapRule", + column: "ChapterId"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListRemapRule_NormalizedCblSeriesName_IsGlobal_AppUserId", + table: "ReadingListRemapRule", + columns: new[] { "NormalizedCblSeriesName", "IsGlobal", "AppUserId" }); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListRemapRule_SeriesId", + table: "ReadingListRemapRule", + column: "SeriesId"); + + migrationBuilder.CreateIndex( + name: "IX_ReadingListRemapRule_VolumeId", + table: "ReadingListRemapRule", + column: "VolumeId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ReadingListRemapRule"); + + migrationBuilder.DropColumn( + name: "DownloadUrl", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "LastSyncCheckUtc", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "LastSyncedUtc", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "Provider", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "ShaHash", + table: "ReadingList"); + + migrationBuilder.DropColumn( + name: "SourcePath", + table: "ReadingList"); + } + } +} diff --git a/Kavita.Database/Migrations/DataContextModelSnapshot.cs b/Kavita.Database/Migrations/DataContextModelSnapshot.cs index caf10d9cf..d631b81f9 100644 --- a/Kavita.Database/Migrations/DataContextModelSnapshot.cs +++ b/Kavita.Database/Migrations/DataContextModelSnapshot.cs @@ -1641,7 +1641,7 @@ namespace Kavita.Database.Migrations b.ToTable("AppUserReadingSessionActivityData"); }); - modelBuilder.Entity("Kavita.Models.Entities.ReadingList", b => + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1665,6 +1665,9 @@ namespace Kavita.Database.Migrations b.Property("CreatedUtc") .HasColumnType("TEXT"); + b.Property("DownloadUrl") + .HasColumnType("TEXT"); + b.Property("EndingMonth") .HasColumnType("INTEGER"); @@ -1677,6 +1680,12 @@ namespace Kavita.Database.Migrations b.Property("LastModifiedUtc") .HasColumnType("TEXT"); + b.Property("LastSyncCheckUtc") + .HasColumnType("TEXT"); + + b.Property("LastSyncedUtc") + .HasColumnType("TEXT"); + b.Property("NormalizedTitle") .IsRequired() .HasColumnType("TEXT"); @@ -1687,9 +1696,20 @@ namespace Kavita.Database.Migrations b.Property("Promoted") .HasColumnType("INTEGER"); + b.Property("Provider") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + b.Property("SecondaryColor") .HasColumnType("TEXT"); + b.Property("ShaHash") + .HasColumnType("TEXT"); + + b.Property("SourcePath") + .HasColumnType("TEXT"); + b.Property("StartingMonth") .HasColumnType("INTEGER"); @@ -1710,7 +1730,7 @@ namespace Kavita.Database.Migrations b.ToTable("ReadingList"); }); - modelBuilder.Entity("Kavita.Models.Entities.ReadingListItem", b => + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListItem", b => { b.Property("Id") .ValueGeneratedOnAdd() @@ -1744,6 +1764,64 @@ namespace Kavita.Database.Migrations b.ToTable("ReadingListItem"); }); + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListRemapRule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppUserId") + .HasColumnType("INTEGER"); + + b.Property("CblNumber") + .HasColumnType("TEXT"); + + b.Property("CblSeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CblVolume") + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("INTEGER"); + + b.Property("CreatedUtc") + .HasColumnType("TEXT"); + + b.Property("IsGlobal") + .HasColumnType("INTEGER"); + + b.Property("NormalizedCblSeriesName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("INTEGER"); + + b.Property("SeriesNameAtMapping") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("VolumeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AppUserId"); + + b.HasIndex("ChapterId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("VolumeId"); + + b.HasIndex("NormalizedCblSeriesName", "IsGlobal", "AppUserId") + .HasDatabaseName("IX_ReadingListRemapRule_NormalizedCblSeriesName_IsGlobal_AppUserId"); + + b.ToTable("ReadingListRemapRule"); + }); + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleError", b => { b.Property("Id") @@ -3900,7 +3978,7 @@ namespace Kavita.Database.Migrations b.Navigation("Volume"); }); - modelBuilder.Entity("Kavita.Models.Entities.ReadingList", b => + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => { b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") .WithMany("ReadingLists") @@ -3911,7 +3989,7 @@ namespace Kavita.Database.Migrations b.Navigation("AppUser"); }); - modelBuilder.Entity("Kavita.Models.Entities.ReadingListItem", b => + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListItem", b => { b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") .WithMany() @@ -3919,7 +3997,7 @@ namespace Kavita.Database.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Kavita.Models.Entities.ReadingList", "ReadingList") + b.HasOne("Kavita.Models.Entities.ReadingLists.ReadingList", "ReadingList") .WithMany("Items") .HasForeignKey("ReadingListId") .OnDelete(DeleteBehavior.Cascade) @@ -3946,6 +4024,39 @@ namespace Kavita.Database.Migrations b.Navigation("Volume"); }); + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingListRemapRule", b => + { + b.HasOne("Kavita.Models.Entities.User.AppUser", "AppUser") + .WithMany() + .HasForeignKey("AppUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Kavita.Models.Entities.Series", "Series") + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Kavita.Models.Entities.Volume", "Volume") + .WithMany() + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("AppUser"); + + b.Navigation("Chapter"); + + b.Navigation("Series"); + + b.Navigation("Volume"); + }); + modelBuilder.Entity("Kavita.Models.Entities.Scrobble.ScrobbleError", b => { b.HasOne("Kavita.Models.Entities.Scrobble.ScrobbleEvent", "ScrobbleEvent") @@ -4445,7 +4556,7 @@ namespace Kavita.Database.Migrations b.Navigation("ActivityData"); }); - modelBuilder.Entity("Kavita.Models.Entities.ReadingList", b => + modelBuilder.Entity("Kavita.Models.Entities.ReadingLists.ReadingList", b => { b.Navigation("Items"); }); diff --git a/Kavita.Database/Repositories/ChapterRepository.cs b/Kavita.Database/Repositories/ChapterRepository.cs index b880fe51b..c871861bd 100644 --- a/Kavita.Database/Repositories/ChapterRepository.cs +++ b/Kavita.Database/Repositories/ChapterRepository.cs @@ -5,6 +5,7 @@ using System.Threading.Tasks; using AutoMapper; using AutoMapper.QueryableExtensions; using Kavita.API.Repositories; +using Kavita.Common.Extensions; using Kavita.Database.Extensions; using Kavita.Models.Constants; using Kavita.Models.DTOs; @@ -436,4 +437,49 @@ public class ChapterRepository(DataContext context, IMapper mapper) : IChapterRe .Select(chp => chp.Volume.SeriesId) .FirstOrDefaultAsync(ct); } + + public async Task> GetChaptersByExternalIdsAsync(IList comicVineIds, IList metronIds, IList libraryIds, CancellationToken ct = default) + { + if (comicVineIds.Count == 0 && metronIds.Count == 0) return []; + + var query = context.Chapter + .Include(c => c.Volume) + .ThenInclude(v => v.Series) + .Where(c => libraryIds.Contains(c.Volume.Series.LibraryId)); + + if (comicVineIds.Count > 0 && metronIds.Count > 0) + { + query = query.Where(c => + (c.ComicVineId != null && comicVineIds.Contains(c.ComicVineId)) || + (c.MetronId > 0 && metronIds.Contains(c.MetronId))); + } + else if (comicVineIds.Count > 0) + { + query = query.Where(c => c.ComicVineId != null && comicVineIds.Contains(c.ComicVineId)); + } + else + { + query = query.Where(c => c.MetronId > 0 && metronIds.Contains(c.MetronId)); + } + + return await query.ToListAsync(ct); + } + + public async Task> GetChaptersByAlternateSeriesAsync(IList normalizedNames, IList libraryIds, CancellationToken ct = default) + { + if (normalizedNames.Count == 0) return []; + + // AlternateSeries is rare and not normalized in the DB, so fetch all non-empty ones and filter in-memory + var chapters = await context.Chapter + .Include(c => c.Volume) + .ThenInclude(v => v.Series) + .Where(c => libraryIds.Contains(c.Volume.Series.LibraryId)) + .Where(c => c.AlternateSeries != null && c.AlternateSeries != string.Empty) + .ToListAsync(ct); + + var normalizedSet = new HashSet(normalizedNames); + return chapters + .Where(c => normalizedSet.Contains(c.AlternateSeries.ToNormalized())) + .ToList(); + } } diff --git a/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs b/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs new file mode 100644 index 000000000..222787675 --- /dev/null +++ b/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using AutoMapper; +using AutoMapper.QueryableExtensions; +using Kavita.API.Repositories; +using Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; +using Kavita.Models.Entities; +using Kavita.Models.Entities.ReadingLists; +using Microsoft.EntityFrameworkCore; + +namespace Kavita.Database.Repositories; + +public class ReadingListRemapRuleRepository(DataContext context, IMapper mapper) : IReadingListRemapRuleRepository +{ + public async Task> GetRulesForNamesAsync(IList normalizedNames, int userId, CancellationToken ct = default) + { + return await context.ReadingListRemapRule + .Where(r => normalizedNames.Contains(r.NormalizedCblSeriesName) + && (r.AppUserId == userId || r.IsGlobal)) + .OrderByDescending(r => r.AppUserId == userId) // user-specific first + .ToListAsync(ct); + } + + public async Task> GetRulesForUserAsync(int userId, CancellationToken ct = default) + { + return await context.ReadingListRemapRule + .Include(r => r.AppUser) + .Include(r => r.Chapter) + .Include(r => r.Series).ThenInclude(s => s.Library) + .Where(r => r.AppUserId == userId || r.IsGlobal) + .OrderByDescending(r => r.AppUserId == userId) + .ThenByDescending(r => r.CreatedUtc) + .ToListAsync(ct); + } + + public async Task GetByIdAsync(int id, CancellationToken ct = default) + { + return await context.ReadingListRemapRule + .Include(r => r.AppUser) + .FirstOrDefaultAsync(r => r.Id == id, ct); + } + + public async Task GetDtoByIdAsync(int id, CancellationToken ct = default) + { + return await context.ReadingListRemapRule + .Include(r => r.AppUser) + .Where(r => r.Id == id) + .ProjectTo(mapper.ConfigurationProvider) + .FirstOrDefaultAsync(ct); + } + + public async Task> GetAllRulesAsync(CancellationToken ct = default) + { + return await context.ReadingListRemapRule + .Include(r => r.AppUser) + .OrderByDescending(r => r.IsGlobal) + .ThenBy(r => r.NormalizedCblSeriesName) + .ToListAsync(ct); + } + + public void Add(ReadingListRemapRule rule) + { + context.ReadingListRemapRule.Add(rule); + } + + public void Remove(ReadingListRemapRule rule) + { + context.ReadingListRemapRule.Remove(rule); + } +} diff --git a/Kavita.Database/Repositories/ReadingListRepository.cs b/Kavita.Database/Repositories/ReadingListRepository.cs index 10aba99d8..a9e992e98 100644 --- a/Kavita.Database/Repositories/ReadingListRepository.cs +++ b/Kavita.Database/Repositories/ReadingListRepository.cs @@ -13,6 +13,7 @@ using Kavita.Models.DTOs.Person; using Kavita.Models.DTOs.ReadingLists; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Extensions; using Microsoft.EntityFrameworkCore; diff --git a/Kavita.Database/Repositories/SeriesRepository.cs b/Kavita.Database/Repositories/SeriesRepository.cs index 0a197ea16..57ff21ef6 100644 --- a/Kavita.Database/Repositories/SeriesRepository.cs +++ b/Kavita.Database/Repositories/SeriesRepository.cs @@ -1557,6 +1557,25 @@ public class SeriesRepository(DataContext context, IMapper mapper) : ISeriesRepo .ToListAsync(ct); } + public async Task> GetAllSeriesByNameAsync(IList normalizedNames, + int userId, IList? libraryIds, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default) + { + var userLibraryIds = await context.Library.GetUserLibraries(userId).ToListAsync(ct); + if (libraryIds is { Count: > 0 }) + { + userLibraryIds = userLibraryIds.Where(libraryIds.Contains).ToList(); + } + var userRating = await context.AppUser.GetUserAgeRestriction(userId, ct: ct); + + return await context.Series + .Where(s => normalizedNames.Contains(s.NormalizedName) || + normalizedNames.Contains(s.NormalizedLocalizedName)) + .Where(s => userLibraryIds.Contains(s.LibraryId)) + .RestrictAgainstAgeRestriction(userRating) + .Includes(includes) + .ToListAsync(ct); + } + /// /// Finds a series by series name or localized name for a given library. diff --git a/Kavita.Database/UnitOfWork.cs b/Kavita.Database/UnitOfWork.cs index fee29fd91..5e01391ad 100644 --- a/Kavita.Database/UnitOfWork.cs +++ b/Kavita.Database/UnitOfWork.cs @@ -51,6 +51,7 @@ public class UnitOfWork : IUnitOfWork EpubFontRepository = new EpubFontRepository(_context, _mapper); ReadingSessionRepository = new ReadingSessionRepository(_context, _mapper); ClientDeviceRepository = new ClientDeviceRepository(_context, _mapper); + RemapRuleRepository = new ReadingListRemapRuleRepository(_context, _mapper); } /// @@ -85,6 +86,7 @@ public class UnitOfWork : IUnitOfWork public IEpubFontRepository EpubFontRepository { get; } public IReadingSessionRepository ReadingSessionRepository { get; } public IClientDeviceRepository ClientDeviceRepository { get; } + public IReadingListRemapRuleRepository RemapRuleRepository { get; } /// /// Commits changes to the DB. Completes the open transaction. diff --git a/Kavita.Models/AutoMapper/AutoMapperProfiles.cs b/Kavita.Models/AutoMapper/AutoMapperProfiles.cs index 881430c67..1b4c9011e 100644 --- a/Kavita.Models/AutoMapper/AutoMapperProfiles.cs +++ b/Kavita.Models/AutoMapper/AutoMapperProfiles.cs @@ -20,6 +20,7 @@ using Kavita.Models.DTOs.Person; using Kavita.Models.DTOs.Progress; using Kavita.Models.DTOs.Reader; using Kavita.Models.DTOs.ReadingLists; +using Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; using Kavita.Models.DTOs.Recommendation; using Kavita.Models.DTOs.Scrobbling; using Kavita.Models.DTOs.Search; @@ -34,6 +35,7 @@ using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.MetadataMatching; using Kavita.Models.Entities.Person; using Kavita.Models.Entities.Progress; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.Scrobble; using Kavita.Models.Entities.User; @@ -223,7 +225,13 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.SeriesId, opt => opt.MapFrom(src => src.Id)) .ForMember(dest => dest.LibraryName, - opt => opt.MapFrom(src => src.Library.Name)); + opt => opt.MapFrom(src => src.Library.Name)) + .ForMember(dest => dest.ReleaseYear, + opt => opt.MapFrom(src => src.Metadata.ReleaseYear)) + .ForMember(dest => dest.VolumeCount, + opt => opt.MapFrom(src => src.Volumes.Count)) + .ForMember(dest => dest.ChapterCount, + opt => opt.MapFrom(src => src.Volumes.SelectMany(v => v.Chapters).Count())); CreateMap(); CreateMap() @@ -332,6 +340,18 @@ public class AutoMapperProfiles : Profile .ForMember(dest => dest.OwnerUserId, opt => opt.MapFrom(src => src.AppUserId)) .ForMember(dest => dest.OwnerUsername, opt => opt.MapFrom(src => src.AppUser.UserName)); + CreateMap() + .ForMember(dest => dest.CreatedByUserName, + opt => opt.MapFrom(src => src.AppUser != null ? src.AppUser.UserName : string.Empty)) + .ForMember(dest => dest.ChapterRange, + opt => opt.MapFrom(src => src.Chapter != null ? src.Chapter.Range : string.Empty)) + .ForMember(dest => dest.ChapterTitleName, + opt => opt.MapFrom(src => src.Chapter != null ? src.Chapter.TitleName : string.Empty)) + .ForMember(dest => dest.ChapterIsSpecial, + opt => opt.MapFrom(src => src.Chapter != null && src.Chapter.IsSpecial)) + .ForMember(dest => dest.LibraryType, + opt => opt.MapFrom(src => src.Series.Library != null ? src.Series.Library.Type : LibraryType.Comic)); + CreateMap() .ForMember(dest => dest.Body, opt => opt.MapFrom(src => src.Review)) diff --git a/Kavita.Models/AutoMapper/AutoMapperReadingListProfile.cs b/Kavita.Models/AutoMapper/AutoMapperReadingListProfile.cs index f318796b1..ce42ea2db 100644 --- a/Kavita.Models/AutoMapper/AutoMapperReadingListProfile.cs +++ b/Kavita.Models/AutoMapper/AutoMapperReadingListProfile.cs @@ -3,6 +3,7 @@ using System.Linq; using AutoMapper; using Kavita.Models.DTOs.ReadingLists; using Kavita.Models.Entities; +using Kavita.Models.Entities.ReadingLists; namespace Kavita.Models.AutoMapper; diff --git a/Kavita.Models/Builders/ReadingListBuilder.cs b/Kavita.Models/Builders/ReadingListBuilder.cs index bcd057d67..d50b289a8 100644 --- a/Kavita.Models/Builders/ReadingListBuilder.cs +++ b/Kavita.Models/Builders/ReadingListBuilder.cs @@ -2,6 +2,7 @@ using Kavita.Common.Extensions; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; namespace Kavita.Models.Builders; diff --git a/Kavita.Models/Builders/ReadingListItemBuilder.cs b/Kavita.Models/Builders/ReadingListItemBuilder.cs index 5ef621353..210399aef 100644 --- a/Kavita.Models/Builders/ReadingListItemBuilder.cs +++ b/Kavita.Models/Builders/ReadingListItemBuilder.cs @@ -1,4 +1,5 @@ using Kavita.Models.Entities; +using Kavita.Models.Entities.ReadingLists; namespace Kavita.Models.Builders; diff --git a/Kavita.Models/DTOs/Misc/GithubRateLimit.cs b/Kavita.Models/DTOs/Misc/GithubRateLimit.cs new file mode 100644 index 000000000..a65dc5ba8 --- /dev/null +++ b/Kavita.Models/DTOs/Misc/GithubRateLimit.cs @@ -0,0 +1,16 @@ +using System; + +namespace Kavita.Models.DTOs.Misc; + +public record GithubRateLimitDto +{ + public int? Remaining { get; set; } + public int? Limit { get; set; } + public DateTime? ResetsAtUtc { get; set; } + + /// + /// Threshold below which we warn the user proactively + /// + public bool IsLow => Remaining is not null and <= 10; + public bool IsExhausted => Remaining is not null and <= 0; +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblConflictsDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblConflictsDto.cs deleted file mode 100644 index a90b52988..000000000 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblConflictsDto.cs +++ /dev/null @@ -1,10 +0,0 @@ -using System.Collections.Generic; - -namespace Kavita.Models.DTOs.ReadingLists.CBL; - - -public sealed record CblConflictQuestion -{ - public string SeriesName { get; set; } - public IList LibrariesIds { get; set; } -} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs new file mode 100644 index 000000000..d9874ae46 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblFinalizeRequestDto.cs @@ -0,0 +1,29 @@ +using Kavita.Models.Entities.Enums.ReadingList; + +namespace Kavita.Models.DTOs.ReadingLists.CBL; +#nullable enable + +/// +/// Request body for the finalize-import endpoint +/// +public sealed record CblFinalizeRequestDto +{ + public string FileName { get; set; } = string.Empty; + public CblImportDecisions Decisions { get; set; } = new(); + /// + /// Import source type (File, Url, or None) + /// + public ReadingListProvider Provider { get; set; } = ReadingListProvider.None; + /// + /// Optional repo-relative path for sync tracking + /// + public string? RepoPath { get; set; } + /// + /// Optional cached download URL for sync tracking + /// + public string? DownloadUrl { get; set; } + /// + /// Optional Git SHA for sync tracking + /// + public string? Sha { get; set; } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportDecisions.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblImportDecisions.cs index 2ea1a5023..3ca002ad0 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportDecisions.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblImportDecisions.cs @@ -1,9 +1,28 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +using System.Collections.Generic; + +namespace Kavita.Models.DTOs.ReadingLists.CBL; /// /// Represents a set of decisions against ambiguity in CBL Import /// -public record CblImportDecisions +public sealed record CblImportDecisions { - + /// + /// Per-item user resolutions keyed by the item's Order index + /// + public Dictionary ItemResolutions { get; set; } = new(); + /// + /// Whether to persist user decisions as remap rules for future imports + /// + public bool SaveAsRemapRules { get; set; } = true; +} + +/// +/// A user's explicit resolution for a single CBL item +/// +public sealed record CblItemDecision +{ + public int SeriesId { get; set; } + public int VolumeId { get; set; } + public int ChapterId { get; set; } } diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs index 9eaec347b..05996ee2d 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs @@ -2,7 +2,8 @@ namespace Kavita.Models.DTOs.ReadingLists.CBL; -public record CblImportOptions +// TODO: Validate if we want to keep this. From testing, it doesn't seem necessary +public sealed record CblImportOptions { /// /// Weighs ComicVine Matching higher diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportSummary.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblImportSummary.cs deleted file mode 100644 index 2d6cdd8d5..000000000 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportSummary.cs +++ /dev/null @@ -1,129 +0,0 @@ -using System.Collections.Generic; -using System.ComponentModel; -using Kavita.Models.DTOs.ReadingLists.CBL.V1; - -namespace Kavita.Models.DTOs.ReadingLists.CBL; - -public enum CblImportResult { - /// - /// There was an issue which prevented processing - /// - [Description("Fail")] - Fail = 0, - /// - /// Some items were added, but not all - /// - [Description("Partial")] - Partial = 1, - /// - /// Everything was imported correctly - /// - [Description("Success")] - Success = 2 -} - -public enum CblImportReason -{ - /// - /// The Chapter is not present in Kavita - /// - [Description("Chapter missing")] - ChapterMissing = 0, - /// - /// The Volume is not present in Kavita or no Volume field present in CBL and there is no chapter matching - /// - [Description("Volume missing")] - VolumeMissing = 1, - /// - /// The Series is not present in Kavita or the user does not have access to the Series due to some account restrictions - /// - [Description("Series missing")] - SeriesMissing = 2, - /// - /// The CBL Name conflicts with another Reading List in the system - /// - [Description("Name Conflict")] - NameConflict = 3, - /// - /// Every Series in the Reading list is missing from within Kavita or user has access restrictions to - /// - [Description("All Series Missing")] - AllSeriesMissing = 4, - /// - /// There are no Book entries in the CBL - /// - [Description("Empty File")] - EmptyFile = 5, - /// - /// Series Collides between Libraries - /// - [Description("Series Collision")] - SeriesCollision = 6, - /// - /// Every book chapter is missing or can't be matched - /// - [Description("All Chapters Missing")] - AllChapterMissing = 7, - /// - /// The Chapter was imported - /// - [Description("Success")] - Success = 8, - /// - /// The file does not match the XML spec - /// - [Description("Invalid File")] - InvalidFile = 9, -} - -public sealed record CblBookResult -{ - /// - /// Order in the CBL - /// - public int Order { get; set; } - public string Series { get; set; } - public string Volume { get; set; } - public string Number { get; set; } - /// - /// Used on Series conflict - /// - public int LibraryId { get; set; } - /// - /// Used on Series conflict - /// - public int SeriesId { get; set; } - /// - /// The name of the reading list - /// - public string ReadingListName { get; set; } - public CblImportReason Reason { get; set; } - - public CblBookResult(CblBook book) - { - Series = book.Series; - Volume = book.Volume; - Number = book.Number; - } - - public CblBookResult() - { - - } -} - -/// -/// Represents the summary from the Import of a given CBL -/// -public sealed record CblImportSummaryDto -{ - public string CblName { get; set; } - /// - /// Used only for Kavita's UI, the filename of the cbl - /// - public string FileName { get; set; } - public ICollection Results { get; set; } - public CblImportResult Success { get; set; } - public ICollection SuccessfulInserts { get; set; } - -} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblMatchTier.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblMatchTier.cs new file mode 100644 index 000000000..ed2e96b82 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblMatchTier.cs @@ -0,0 +1,45 @@ +namespace Kavita.Models.DTOs.ReadingLists.CBL; + +/// +/// Indicates which matching strategy resolved a CBL item to a Kavita entity. +/// Lower values indicate higher confidence. +/// +public enum CblMatchTier +{ + /// + /// Matched via a user or admin remap rule + /// + RemapRule = 0, + /// + /// Matched via external database ID (ComicVine, Metron, etc.) + /// + ExternalId = 1, + /// + /// Matched via exact normalized name + /// + ExactName = 2, + /// + /// Comic Vine naming pattern: Series (VolumeNumber) + /// + ComicVineNaming = 3, + /// + /// Matched after stripping leading articles (The, A, An, etc.) + /// + ArticleStripped = 4, + /// + /// Matched after stripping reprint/edition suffixes + /// + ReprintStripped = 5, + /// + /// Matched via the AlternateSeries field on a chapter + /// + AlternateSeries = 6, + /// + /// Resolved by explicit user decision + /// + UserDecision = 7, + /// + /// Could not be matched to any Kavita entity + /// + Unmatched = -1 +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblRepoBrowseResultDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblRepoBrowseResultDto.cs new file mode 100644 index 000000000..3a09bf56e --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblRepoBrowseResultDto.cs @@ -0,0 +1,14 @@ +using System.Collections.Generic; +using Kavita.Models.DTOs.Misc; + +namespace Kavita.Models.DTOs.ReadingLists.CBL; + +public sealed record CblRepoBrowseResultDto +{ + public IList Items { get; set; } = []; + public GithubRateLimitDto RateLimitDto { get; set; } = new(); + /// + /// True if this result was served from cache (no GitHub API call made) + /// + public bool FromCache { get; set; } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblRepoItemDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblRepoItemDto.cs new file mode 100644 index 000000000..35ee7880d --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/CblRepoItemDto.cs @@ -0,0 +1,15 @@ +namespace Kavita.Models.DTOs.ReadingLists.CBL; +#nullable enable + +public sealed record CblRepoItemDto +{ + public string Name { get; init; } = string.Empty; + public string Path { get; init; } = string.Empty; + public bool IsDirectory { get; init; } + public string Sha { get; init; } = string.Empty; + public long Size { get; set; } + public string? DownloadUrl { get; init; } + + public int? ExistingReadingListId { get; set; } + public bool AlreadySynced => ExistingReadingListId.HasValue; +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblBookResult.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblBookResult.cs new file mode 100644 index 000000000..a5f950bea --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblBookResult.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; +using Kavita.Models.Entities.Enums; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; +#nullable enable + +public sealed record CblBookResult +{ + /// + /// Order in the CBL + /// + public int Order { get; set; } + public string Series { get; set; } + public string Volume { get; set; } + public string Number { get; set; } + /// + /// Used on Series conflict + /// + public int LibraryId { get; set; } + /// + /// Used on Series conflict + /// + public int SeriesId { get; set; } + /// + /// The name of the reading list + /// + public string ReadingListName { get; set; } + public CblImportReason Reason { get; set; } + /// + /// Which matching tier resolved this item (null if not processed by new matcher) + /// + public CblMatchTier? MatchTier { get; set; } + /// + /// The matched chapter's ID (0 if not matched) + /// + public int ChapterId { get; set; } + /// + /// Display title of the matched chapter (e.g., range or title) + /// + public string ChapterTitle { get; set; } = string.Empty; + /// + /// The Kavita series name this item matched to (empty if unmatched) + /// + public string MatchedSeriesName { get; set; } = string.Empty; + /// + /// The library type of the matched series (for entity-title rendering) + /// + public LibraryType LibraryType { get; set; } + /// + /// The raw chapter range/number (e.g. "5", "10.5") — separate from ChapterTitle + /// + public string ChapterNumber { get; set; } = string.Empty; + /// + /// When a SeriesCollision occurs, the candidate series the user can choose from + /// + public IList Candidates { get; set; } + + public CblBookResult(ParsedCblItem item) + { + Series = item.SeriesName; + Volume = item.Volume; + Number = item.Number; + Order = item.Order; + } + + public CblBookResult() { } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportReason.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportReason.cs new file mode 100644 index 000000000..faa7287a3 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportReason.cs @@ -0,0 +1,57 @@ +using System.ComponentModel; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; + +public enum CblImportReason +{ + /// + /// The Chapter is not present in Kavita + /// + [Description("Chapter missing")] + ChapterMissing = 0, + /// + /// The Volume is not present in Kavita or no Volume field present in CBL and there is no chapter matching + /// + [Description("Volume missing")] + VolumeMissing = 1, + /// + /// The Series is not present in Kavita or the user does not have access to the Series due to some account restrictions + /// + [Description("Series missing")] + SeriesMissing = 2, + /// + /// The CBL Name conflicts with another Reading List in the system + /// + [Description("Name Conflict")] + NameConflict = 3, + /// + /// Every Series in the Reading list is missing from within Kavita or user has access restrictions to + /// + [Description("All Series Missing")] + AllSeriesMissing = 4, + /// + /// There are no Book entries in the CBL + /// + [Description("Empty File")] + EmptyFile = 5, + /// + /// Series Collides between Libraries + /// + [Description("Series Collision")] + SeriesCollision = 6, + /// + /// Every book chapter is missing or can't be matched + /// + [Description("All Chapters Missing")] + AllChapterMissing = 7, + /// + /// The Chapter was imported + /// + [Description("Success")] + Success = 8, + /// + /// The file does not match the XML spec + /// + [Description("Invalid File")] + InvalidFile = 9, +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportResult.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportResult.cs new file mode 100644 index 000000000..648634a27 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportResult.cs @@ -0,0 +1,21 @@ +using System.ComponentModel; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; + +public enum CblImportResult { + /// + /// There was an issue which prevented processing + /// + [Description("Fail")] + Fail = 0, + /// + /// Some items were added, but not all + /// + [Description("Partial")] + Partial = 1, + /// + /// Everything was imported correctly + /// + [Description("Success")] + Success = 2 +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs new file mode 100644 index 000000000..dbcfb13f7 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblImportSummaryDto.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; + +/// +/// Represents the summary from the Import of a given CBL +/// +public sealed record CblImportSummaryDto +{ + public string CblName { get; set; } + /// + /// Used only for Kavita's UI, the filename of the cbl + /// + public string FileName { get; set; } + public ICollection Results { get; set; } + public CblImportResult Success { get; set; } + public ICollection SuccessfulInserts { get; set; } + /// + /// Are we updating a pre-existing list or not + /// + public bool IsUpdate { get; set; } + +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblReValidateRequestDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblReValidateRequestDto.cs new file mode 100644 index 000000000..2607ab25d --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblReValidateRequestDto.cs @@ -0,0 +1,9 @@ +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; + +/// +/// Request body for the re-validate endpoint +/// +public record CblReValidateRequestDto +{ + public string FileName { get; set; } = string.Empty; +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblRepoImportRequestDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblRepoImportRequestDto.cs new file mode 100644 index 000000000..097f62084 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblRepoImportRequestDto.cs @@ -0,0 +1,8 @@ +using System.Collections.Generic; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; + +public class CblRepoImportRequestDto +{ + public IList Items { get; set; } = []; +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblSavedFileDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblSavedFileDto.cs new file mode 100644 index 000000000..a0566a04c --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblSavedFileDto.cs @@ -0,0 +1,35 @@ +using Kavita.Models.Entities.Enums.ReadingList; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; +#nullable enable + +/// +/// Response for save-only CBL upload endpoints +/// +public record CblSavedFileDto +{ + /// + /// Display name (filename for file/url, repo item name for repo) + /// + public string Name { get; set; } = string.Empty; + /// + /// Server-side filename in cbl-manager-download/ + /// + public string FileName { get; set; } = string.Empty; + /// + /// Import source type (File, Url, or None) + /// + public ReadingListProvider Provider { get; set; } = ReadingListProvider.None; + /// + /// Repo-relative path (null for file/URL sources) + /// + public string? RepoPath { get; set; } + /// + /// Cached download URL (null for file/URL sources) + /// + public string? DownloadUrl { get; set; } + /// + /// Git SHA for change detection (null for file/URL sources) + /// + public string? Sha { get; set; } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblSeriesCandidate.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblSeriesCandidate.cs new file mode 100644 index 000000000..89e084b57 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Import/CblSeriesCandidate.cs @@ -0,0 +1,6 @@ +namespace Kavita.Models.DTOs.ReadingLists.CBL.Import; + +/// +/// A candidate series for user disambiguation when multiple series match a CBL name +/// +public sealed record CblSeriesCandidate(int SeriesId, int LibraryId, string SeriesName); diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblExternalDbProvider.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblExternalDbProvider.cs similarity index 91% rename from Kavita.Models/DTOs/ReadingLists/CBL/CblExternalDbProvider.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblExternalDbProvider.cs index d97166a4a..baacf25cb 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblExternalDbProvider.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblExternalDbProvider.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// Known external comic database providers used for issue/series identification. diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblExternalId.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblExternalId.cs similarity index 92% rename from Kavita.Models/DTOs/ReadingLists/CBL/CblExternalId.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblExternalId.cs index 7d408acfc..390a3e22f 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblExternalId.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblExternalId.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// A resolved external-database reference for a series/issue pair. diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblIssueType.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblIssueType.cs similarity index 91% rename from Kavita.Models/DTOs/ReadingLists/CBL/CblIssueType.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblIssueType.cs index 941f1bbcf..0e4c65db6 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblIssueType.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblIssueType.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// Categorisation of an issue's role within a reading list (V2 only). diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblListType.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblListType.cs similarity index 94% rename from Kavita.Models/DTOs/ReadingLists/CBL/CblListType.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblListType.cs index e0113dcfb..e3c79f549 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblListType.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblListType.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// Classification of a CBL reading list, indicating its scope or purpose. diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblRelationship.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblRelationship.cs similarity index 92% rename from Kavita.Models/DTOs/ReadingLists/CBL/CblRelationship.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblRelationship.cs index d61ab3417..0a0e1c9c9 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblRelationship.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblRelationship.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// A link to a related reading list (e.g. prequel, sequel, companion) diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblSource.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblSource.cs similarity index 88% rename from Kavita.Models/DTOs/ReadingLists/CBL/CblSource.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblSource.cs index 4fe4fe24e..e70800f72 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblSource.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/CblSource.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// An external source from which a reading list was derived diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/ParsedCblItem.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/ParsedCblItem.cs similarity index 97% rename from Kavita.Models/DTOs/ReadingLists/CBL/ParsedCblItem.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/ParsedCblItem.cs index 55ac19445..343d5769c 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/ParsedCblItem.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/ParsedCblItem.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// A single issue/book entry in a unified (V1+V2) parsed reading list diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/ParsedCblReadingList.cs b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/ParsedCblReadingList.cs similarity index 98% rename from Kavita.Models/DTOs/ReadingLists/CBL/ParsedCblReadingList.cs rename to Kavita.Models/DTOs/ReadingLists/CBL/Internal/ParsedCblReadingList.cs index 2a63bd724..5743e9a07 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/ParsedCblReadingList.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/Internal/ParsedCblReadingList.cs @@ -1,6 +1,6 @@ using System.Collections.Generic; -namespace Kavita.Models.DTOs.ReadingLists.CBL; +namespace Kavita.Models.DTOs.ReadingLists.CBL.Internal; /// /// Unified reading list model produced by parsing either a V1 XML or V2 JSON CBL file diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs new file mode 100644 index 000000000..2ff4a5fde --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs @@ -0,0 +1,27 @@ +namespace Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; +#nullable enable + +public sealed record CreateRemapRuleDto +{ + /// + /// The CBL series name as it appears in the file, will be normalized server-side + /// + public string CblSeriesName { get; set; } = string.Empty; + public int SeriesId { get; set; } + /// + /// Optional: CBL volume string for issue-level rules + /// + public string? CblVolume { get; set; } + /// + /// Optional: CBL issue number string for issue-level rules + /// + public string? CblNumber { get; set; } + /// + /// Optional: Kavita Volume ID for issue-level rules + /// + public int? VolumeId { get; set; } + /// + /// Optional: Kavita Chapter ID for issue-level rules + /// + public int? ChapterId { get; set; } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs new file mode 100644 index 000000000..0ae6d2913 --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs @@ -0,0 +1,26 @@ +using System; +using Kavita.Models.Entities.Enums; + +namespace Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; +#nullable enable + +public sealed record RemapRuleDto +{ + public int Id { get; set; } + public string NormalizedCblSeriesName { get; set; } = string.Empty; + public string CblSeriesName { get; set; } = string.Empty; + public string? CblVolume { get; set; } + public string? CblNumber { get; set; } + public int SeriesId { get; set; } + public int? VolumeId { get; set; } + public int? ChapterId { get; set; } + public string ChapterRange { get; set; } = string.Empty; + public string ChapterTitleName { get; set; } = string.Empty; + public bool ChapterIsSpecial { get; set; } + public LibraryType LibraryType { get; set; } + public string SeriesNameAtMapping { get; set; } = string.Empty; + public int AppUserId { get; set; } + public bool IsGlobal { get; set; } + public string CreatedByUserName { get; set; } = string.Empty; + public DateTime CreatedUtc { get; set; } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/UpdateRemapRuleDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/UpdateRemapRuleDto.cs new file mode 100644 index 000000000..d2f2043aa --- /dev/null +++ b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/UpdateRemapRuleDto.cs @@ -0,0 +1,10 @@ +namespace Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; +#nullable enable + +public sealed record UpdateRemapRuleDto +{ + public int? VolumeId { get; set; } + public int? ChapterId { get; set; } + public string? CblVolume { get; set; } + public string? CblNumber { get; set; } +} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/V1/CblBook.cs b/Kavita.Models/DTOs/ReadingLists/CBL/V1/CblBook.cs index 34d5bda12..d2ddfa2f3 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/V1/CblBook.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/V1/CblBook.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Xml.Serialization; namespace Kavita.Models.DTOs.ReadingLists.CBL.V1; @@ -59,8 +60,8 @@ public sealed record CblBook [XmlAttribute("FileType")] public string FileType { get; set; } /// - /// External database reference (e.g. ComicVine) + /// External database references (e.g. ComicVine, Metron) /// [XmlElement("Database")] - public CblBookDatabase Database { get; set; } + public List Databases { get; set; } = []; } diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/V2/CblV2ListDetails.cs b/Kavita.Models/DTOs/ReadingLists/CBL/V2/CblV2ListDetails.cs index 2341cebb2..b45d985a6 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/V2/CblV2ListDetails.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/V2/CblV2ListDetails.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Text.Json.Serialization; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; namespace Kavita.Models.DTOs.ReadingLists.CBL.V2; diff --git a/Kavita.Models/DTOs/ReadingLists/ReadingListDto.cs b/Kavita.Models/DTOs/ReadingLists/ReadingListDto.cs index d5a274399..370365af8 100644 --- a/Kavita.Models/DTOs/ReadingLists/ReadingListDto.cs +++ b/Kavita.Models/DTOs/ReadingLists/ReadingListDto.cs @@ -1,4 +1,6 @@ -using Kavita.Models.Entities.Enums; +using System; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.ReadingList; using Kavita.Models.Entities.Interfaces; namespace Kavita.Models.DTOs.ReadingLists; @@ -53,6 +55,46 @@ public sealed record ReadingListDto : IHasCoverImage /// public string OwnerUserName { get; set; } + + /// + /// The repo-relative path used as the stable sync key (e.g. "Marvel/Spider-Man.cbl"). + /// This is the primary identifier for re-fetching — more stable than DownloadUrl. + /// + public string? SourcePath { get; set; } + + /// + /// Cached raw download URL for convenience. Reconstructable from SourcePath if needed. + /// + public string? DownloadUrl { get; set; } + + /// + /// Git SHA of the file content at last sync. Used for change detection only — if the + /// remote SHA differs from this value, the file has changed upstream. + /// + public string? ShaHash { get; set; } + /// + /// Determines how the list was created and if it's syncable. + /// + public ReadingListProvider Provider { get; set; } = ReadingListProvider.None; + /// + /// When we last checked the remote for changes (compared SHA). This can happen + /// without downloading — a metadata-only check via the Contents API. + /// + public DateTime? LastSyncCheckUtc { get; set; } + /// + /// When we last actually downloaded and applied the CBL content. + /// Only updated when ShaHash changes and we pull new content. + /// + public DateTime? LastSyncedUtc { get; set; } + + public bool CanSync => Provider == ReadingListProvider.Url + && !string.IsNullOrEmpty(SourcePath); + /// + /// Checks if the remote SHA differs from our stored hash. + /// + public bool HasRemoteChange(string remoteSha) + => !string.Equals(ShaHash, remoteSha, StringComparison.Ordinal); + public void ResetColorScape() { PrimaryColor = string.Empty; diff --git a/Kavita.Models/DTOs/Search/SearchResultDto.cs b/Kavita.Models/DTOs/Search/SearchResultDto.cs index 40837fa33..b95e97d9e 100644 --- a/Kavita.Models/DTOs/Search/SearchResultDto.cs +++ b/Kavita.Models/DTOs/Search/SearchResultDto.cs @@ -14,4 +14,8 @@ public sealed record SearchResultDto // Grouping information public string LibraryName { get; set; } = default!; public int LibraryId { get; set; } + + public int ReleaseYear { get; init; } + public int VolumeCount { get; init; } + public int ChapterCount { get; init; } } diff --git a/Kavita.Models/DTOs/Uploads/UploadFileDto.cs b/Kavita.Models/DTOs/Uploads/UploadFileDto.cs index 29608fbd0..c07a59e05 100644 --- a/Kavita.Models/DTOs/Uploads/UploadFileDto.cs +++ b/Kavita.Models/DTOs/Uploads/UploadFileDto.cs @@ -1,6 +1,6 @@ namespace Kavita.Models.DTOs.Uploads; -public sealed record UploadFileDto +public sealed record UploadCoverFileDto { /// /// Id of the Entity diff --git a/Kavita.Models/Entities/Enums/ReadingList/ReadingListProvider.cs b/Kavita.Models/Entities/Enums/ReadingList/ReadingListProvider.cs new file mode 100644 index 000000000..4b47d2eb8 --- /dev/null +++ b/Kavita.Models/Entities/Enums/ReadingList/ReadingListProvider.cs @@ -0,0 +1,22 @@ +using System.ComponentModel; + +namespace Kavita.Models.Entities.Enums.ReadingList; + +public enum ReadingListProvider +{ + /// + /// Default, List created within Kavita. No Sync capabilities + /// + [Description("File")] + None = 0, + /// + /// Created by File upload. No Sync capabilities + /// + [Description("File")] + File = 1, + /// + /// Downloaded via CBL Manager or direct Url feed + /// + [Description("Url")] + Url = 2, +} diff --git a/Kavita.Models/Entities/ReadingList.cs b/Kavita.Models/Entities/ReadingLists/ReadingList.cs similarity index 52% rename from Kavita.Models/Entities/ReadingList.cs rename to Kavita.Models/Entities/ReadingLists/ReadingList.cs index c9064ea15..57f8a2ea4 100644 --- a/Kavita.Models/Entities/ReadingList.cs +++ b/Kavita.Models/Entities/ReadingLists/ReadingList.cs @@ -1,10 +1,11 @@ using System; using System.Collections.Generic; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.ReadingList; using Kavita.Models.Entities.Interfaces; using Kavita.Models.Entities.User; -namespace Kavita.Models.Entities; +namespace Kavita.Models.Entities.ReadingLists; #nullable enable @@ -28,6 +29,55 @@ public class ReadingList : IEntityDate, IHasCoverImage public string? SecondaryColor { get; set; } public bool CoverImageLocked { get; set; } + /// + /// A list of tags associtated with the RL + /// + /// Can be populated via API/UI or from CBLv2 + //public ICollection Tags { get; set; } + + + /// + /// Determines how the list was created and if it's syncable. + /// + public ReadingListProvider Provider { get; set; } = ReadingListProvider.None; + + /// + /// The repo-relative path used as the stable sync key (e.g. "Marvel/Spider-Man.cbl"). + /// This is the primary identifier for re-fetching — more stable than DownloadUrl. + /// + public string? SourcePath { get; set; } + + /// + /// Cached raw download URL for convenience. Reconstructable from SourcePath if needed. + /// + public string? DownloadUrl { get; set; } + + /// + /// Git SHA of the file content at last sync. Used for change detection only — if the + /// remote SHA differs from this value, the file has changed upstream. + /// + public string? ShaHash { get; set; } + + /// + /// When we last checked the remote for changes (compared SHA). This can happen + /// without downloading — a metadata-only check via the Contents API. + /// + public DateTime? LastSyncCheckUtc { get; set; } + + /// + /// When we last actually downloaded and applied the CBL content. + /// Only updated when ShaHash changes and we pull new content. + /// + public DateTime? LastSyncedUtc { get; set; } + + public bool CanSync => Provider == ReadingListProvider.Url + && !string.IsNullOrEmpty(SourcePath); + + /// + /// Checks if the remote SHA differs from our stored hash. + /// + public bool HasRemoteChange(string remoteSha) + => !string.Equals(ShaHash, remoteSha, StringComparison.Ordinal); public ICollection Items { get; set; } = null!; public DateTime Created { get; set; } diff --git a/Kavita.Models/Entities/ReadingListItem.cs b/Kavita.Models/Entities/ReadingLists/ReadingListItem.cs similarity index 92% rename from Kavita.Models/Entities/ReadingListItem.cs rename to Kavita.Models/Entities/ReadingLists/ReadingListItem.cs index 2932c8913..b9bbf3af0 100644 --- a/Kavita.Models/Entities/ReadingListItem.cs +++ b/Kavita.Models/Entities/ReadingLists/ReadingListItem.cs @@ -1,4 +1,4 @@ -namespace Kavita.Models.Entities; +namespace Kavita.Models.Entities.ReadingLists; public class ReadingListItem { diff --git a/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs b/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs new file mode 100644 index 000000000..3641bf329 --- /dev/null +++ b/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs @@ -0,0 +1,71 @@ +using System; +using Kavita.Models.Entities.User; + +namespace Kavita.Models.Entities.ReadingLists; +#nullable enable + +/// +/// Persists a user's (or admin's) decision mapping a CBL series/issue name to a Kavita entity. +/// Used as Tier 0 in the CBL matching pipeline. +/// +public class ReadingListRemapRule +{ + public int Id { get; set; } + + /// + /// The normalized CBL series name that this rule matches against + /// + public required string NormalizedCblSeriesName { get; set; } + + /// + /// Optional CBL volume to narrow matching (null = any volume) + /// + public string? CblVolume { get; set; } + + /// + /// Optional CBL issue number to narrow matching (null = any issue) + /// + public string? CblNumber { get; set; } + + /// + /// The Kavita Series this rule maps to + /// + public int SeriesId { get; set; } + public Series Series { get; set; } = null!; + + /// + /// Optional: specific Volume within the Series + /// + public int? VolumeId { get; set; } + public Volume? Volume { get; set; } + + /// + /// Optional: specific Chapter within the Volume + /// + public int? ChapterId { get; set; } + public Chapter? Chapter { get; set; } + + /// + /// The original CBL series name as it appeared in the file (for display) + /// + public string CblSeriesName { get; set; } = string.Empty; + + /// + /// Snapshot of the series name at time of mapping creation (for auditing) + /// + public string SeriesNameAtMapping { get; set; } = string.Empty; + + /// + /// When true, this rule is visible to all users (admin-promoted). + /// AppUserId still tracks the original creator. + /// + public bool IsGlobal { get; set; } + + /// + /// The user who created this rule. Always required. + /// + public int AppUserId { get; set; } + public AppUser AppUser { get; set; } = null!; + + public DateTime CreatedUtc { get; set; } +} diff --git a/Kavita.Models/Entities/User/AppUser.cs b/Kavita.Models/Entities/User/AppUser.cs index cb59f1916..a48fbdd1b 100644 --- a/Kavita.Models/Entities/User/AppUser.cs +++ b/Kavita.Models/Entities/User/AppUser.cs @@ -7,6 +7,7 @@ using Kavita.Common.Helpers; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Interfaces; using Kavita.Models.Entities.Progress; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.Scrobble; using Microsoft.AspNetCore.Identity; diff --git a/Kavita.Models/Helpers/OrderableHelper.cs b/Kavita.Models/Helpers/OrderableHelper.cs index 9561fd2dc..8ff1c02dd 100644 --- a/Kavita.Models/Helpers/OrderableHelper.cs +++ b/Kavita.Models/Helpers/OrderableHelper.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using Kavita.Models.Entities; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; namespace Kavita.Models.Helpers; diff --git a/Kavita.Server/Controllers/BaseApiController.cs b/Kavita.Server/Controllers/BaseApiController.cs index 624abbbe3..20a2ae535 100644 --- a/Kavita.Server/Controllers/BaseApiController.cs +++ b/Kavita.Server/Controllers/BaseApiController.cs @@ -10,8 +10,6 @@ using MimeTypes; namespace Kavita.Server.Controllers; -#nullable enable - [Authorize] [ApiController] [Route("api/[controller]")] @@ -113,4 +111,30 @@ public class BaseApiController : ControllerBase : File(content, contentType); } + /// + /// Ensures there is no malicious path in the fileName before use + /// + /// + /// + protected static bool ValidateFilename(string fileName) + { + if (string.IsNullOrWhiteSpace(fileName)) + { + return false; + } + + if (fileName.Contains("..", StringComparison.Ordinal)) + { + return false; + } + + if (fileName.IndexOf(Path.DirectorySeparatorChar) >= 0 || + fileName.IndexOf(Path.AltDirectorySeparatorChar) >= 0) + { + return false; + } + + return true; + } + } diff --git a/Kavita.Server/Controllers/CBLController.cs b/Kavita.Server/Controllers/CBLController.cs index 1097498aa..596360739 100644 --- a/Kavita.Server/Controllers/CBLController.cs +++ b/Kavita.Server/Controllers/CBLController.cs @@ -1,140 +1,396 @@ -using System; +using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Threading.Tasks; -using Kavita.API.Attributes; +using Kavita.API.Database; using Kavita.API.Services; -using Kavita.API.Services.Reading; using Kavita.API.Services.ReadingLists; +using Kavita.Common.Extensions; +using Kavita.Database; using Kavita.Models.Constants; using Kavita.Models.DTOs.ReadingLists.CBL; -using Kavita.Models.DTOs.ReadingLists.CBL.V1; +using Kavita.Models.Entities.Enums.ReadingList; +using Kavita.Models.Entities.ReadingLists; using Kavita.Server.Attributes; -using Kavita.Services.Reading; +using Flurl.Http; +using Kavita.Models.DTOs.ReadingLists.CBL.Import; +using Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; +using Kavita.Models.DTOs.Uploads; +using AutoMapper; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; -using Swashbuckle.AspNetCore.Annotations; +using Microsoft.EntityFrameworkCore; namespace Kavita.Server.Controllers; /// /// Responsible for the CBL import flow /// -public class CblController( IReadingListService readingListService, IDirectoryService directoryService) : BaseApiController +public class CblController(IReadingListService readingListService, IDirectoryService directoryService, + ICblGithubService cblGithubService, DataContext dataContext, ICblImportService cblImporterService, + IUnitOfWork unitOfWork, IMapper mapper, ILocalizationService localizationService) : BaseApiController { /// - /// The first step in a cbl import. This validates the cbl file that if an import occured, would it be successful. - /// If this returns errors, the cbl will always be rejected by Kavita. + /// Saves an uploaded CBL file to disk without importing. Returns the saved file info. /// - /// FormBody with parameter name of cbl - /// Use comic vine matching or not. Defaults to false - /// - [HttpPost("validate")] - [SwaggerIgnore] - public async Task> ValidateCbl(IFormFile cbl, [FromQuery] bool useComicVineMatching = false) + [HttpPost("file-import")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> SaveCblFromFile(IFormFile cblFile) { var userId = UserId; + var filename = cblFile.FileName; + + var ext = Path.GetExtension(filename); + if (!ext.Equals(".cbl", StringComparison.OrdinalIgnoreCase) + && !ext.Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Only .cbl and .json files are allowed"); + } + + if (filename.Contains(".exe", StringComparison.OrdinalIgnoreCase)) + { + return BadRequest("Invalid filename"); + } + + await SaveCblFile(cblFile, userId, filename); + + return Ok(new CblSavedFileDto + { + Name = filename, + FileName = filename, + Provider = ReadingListProvider.File + }); + } + + /// + /// Downloads a CBL file from a URL and saves it to disk without importing. + /// + [HttpPost("upload-cbl-file")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> SaveCblFromUrl(UploadUrlDto dto) + { + var dir = GetCblManagerFolder(UserId); + Directory.CreateDirectory(dir); + + string fullPath; + string filename; try { - var cblReadingList = await SaveAndLoadCblFile(cbl); - var importSummary = await readingListService.ValidateCblFile(userId, cblReadingList, useComicVineMatching); - importSummary.FileName = cbl.FileName; + fullPath = await dto.Url.DownloadFileAsync(dir); + filename = Path.GetFileName(fullPath); + } + catch (FlurlHttpException) + { + return BadRequest("Unable to download file from URL"); + } - return Ok(importSummary); - } - catch (ArgumentNullException) + var ext = Path.GetExtension(filename); + if (!ext.Equals(".cbl", StringComparison.OrdinalIgnoreCase) + && !ext.Equals(".json", StringComparison.OrdinalIgnoreCase)) { - return Ok(new CblImportSummaryDto - { - FileName = cbl.FileName, - Success = CblImportResult.Fail, - Results = - [ - new CblBookResult - { - Reason = CblImportReason.InvalidFile - } - ] - }); + if (System.IO.File.Exists(fullPath)) System.IO.File.Delete(fullPath); + return BadRequest("Only .cbl and .json files are allowed"); } - catch (InvalidOperationException) + + return Ok(new CblSavedFileDto { - return Ok(new CblImportSummaryDto - { - FileName = cbl.FileName, - Success = CblImportResult.Fail, - Results = - [ - new CblBookResult - { - Reason = CblImportReason.InvalidFile - } - ] - }); - } + Name = filename, + FileName = filename, + Provider = ReadingListProvider.Url + }); } /// - /// Performs the actual import (assuming dryRun = false) + /// Downloads selected CBL files from the GitHub repo and saves them to disk without importing. /// - /// FormBody with parameter name of cbl - /// If true, will only emulate the import but not perform. This should be done to preview what will happen - /// Use comic vine matching or not. Defaults to false - /// - [SwaggerIgnore] - [HttpPost("import")] + [HttpPost("repo-import")] [DisallowRole(PolicyConstants.ReadOnlyRole)] - public async Task> ImportCbl(IFormFile cbl, [FromQuery] bool dryRun = false, [FromQuery] bool useComicVineMatching = false) + public async Task>> SaveCblFromRepo([FromBody] CblRepoImportRequestDto request) { - try - { - var userId = UserId; - var cblReadingList = await SaveAndLoadCblFile(cbl); - var importSummary = await readingListService.CreateReadingListFromCbl(userId, cblReadingList, dryRun, useComicVineMatching); - importSummary.FileName = cbl.FileName; + var userId = UserId; + var savedFiles = new List(); - return Ok(importSummary); - } catch (ArgumentNullException) + foreach (var item in request.Items) { - return Ok(new CblImportSummaryDto + var content = await cblGithubService.GetFileContent(item.Path); + SaveCblFileFromContent(content, userId, item.Name); + + savedFiles.Add(new CblSavedFileDto { - FileName = cbl.FileName, - Success = CblImportResult.Fail, - Results = - [ - new CblBookResult - { - Reason = CblImportReason.InvalidFile - } - ] - }); - } - catch (InvalidOperationException) - { - return Ok(new CblImportSummaryDto - { - FileName = cbl.FileName, - Success = CblImportResult.Fail, - Results = - [ - new CblBookResult - { - Reason = CblImportReason.InvalidFile - } - ] + Name = item.Name, + FileName = item.Name, + Provider = ReadingListProvider.Url, + RepoPath = item.Path, + DownloadUrl = item.DownloadUrl, + Sha = item.Sha }); } + return Ok(savedFiles); } - private async Task SaveAndLoadCblFile(IFormFile file) + /// + /// Validates an already-saved CBL file on disk. Called by the import modal after remap rule changes. + /// + [HttpPost("re-validate")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> ReValidate([FromBody] CblReValidateRequestDto dto) { - var filename = Path.GetRandomFileName(); - var outputFile = Path.Join(directoryService.TempDirectory, filename); + if (!ValidateFilename(dto.FileName)) return BadRequest("Invalid filename"); + + var userId = UserId; + var fullPath = Path.Join(GetCblManagerFolder(userId), dto.FileName); + + if (!System.IO.File.Exists(fullPath)) + { + return BadRequest("File not found on server"); + } + + var summary = await cblImporterService.ValidateList(userId, fullPath, new CblImportOptions()); + summary.FileName = dto.FileName; + return Ok(summary); + } + + /// + /// Finalizes the import of a saved CBL file with user decisions + /// + [HttpPost("finalize-import")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> FinalizeImport([FromBody] CblFinalizeRequestDto dto) + { + if (!ValidateFilename(dto.FileName)) return BadRequest("Invalid filename"); + + var userId = UserId; + var fullPath = Path.Join(GetCblManagerFolder(userId), dto.FileName); + + if (!System.IO.File.Exists(fullPath)) + { + return BadRequest("File not found on server"); + } + + try + { + var summary = await cblImporterService.UpsertReadingList( + userId, fullPath, new CblImportOptions(), dto.Decisions); + summary.FileName = dto.FileName; + + // Set provider and sync tracking fields + if (summary.Success != CblImportResult.Fail && dto.Provider != ReadingListProvider.None) + { + var readingList = await unitOfWork.ReadingListRepository + .GetReadingListByTitleAsync(summary.CblName, userId); + + if (readingList != null) + { + readingList.Provider = dto.Provider; + + // Repo-specific sync tracking + if (!string.IsNullOrEmpty(dto.RepoPath)) + { + readingList.SourcePath = dto.RepoPath; + readingList.DownloadUrl = dto.DownloadUrl; + readingList.ShaHash = dto.Sha; + readingList.LastSyncedUtc = DateTime.UtcNow; + readingList.LastSyncCheckUtc = DateTime.UtcNow; + } + + await readingListService.CalculateReadingListAgeRating(readingList); + await readingListService.CalculateStartAndEndDates(readingList); + + await unitOfWork.CommitAsync(); + } + } + + return Ok(summary); + } + finally + { + if (System.IO.File.Exists(fullPath)) + { + System.IO.File.Delete(fullPath); + } + } + } + + /// + /// Returns all remap rules accessible to the current user (own rules + global/admin rules). + /// + [HttpGet("remap-rules")] + public async Task>> GetRemapRules() + { + var rules = await unitOfWork.RemapRuleRepository.GetRulesForUserAsync(UserId); + return Ok(mapper.Map>(rules)); + } + + /// + /// Admin-only: returns all rules across all users. + /// + [Authorize(Policy = PolicyGroups.AdminPolicy)] + [HttpGet("remap-rules/all")] + public async Task>> GetAllRemapRules() + { + var rules = await unitOfWork.RemapRuleRepository.GetAllRulesAsync(); + return Ok(mapper.Map>(rules)); + } + + /// + /// Creates a new series-level remap rule. + /// + [HttpPost("remap-rules")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> CreateRemapRule([FromBody] CreateRemapRuleDto dto) + { + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, ct: HttpContext.RequestAborted); + if (series == null) return BadRequest(await localizationService.Translate(UserId, "series-doesnt-exist")); + + var rule = new ReadingListRemapRule + { + NormalizedCblSeriesName = dto.CblSeriesName.ToNormalized(), + CblSeriesName = dto.CblSeriesName, + SeriesId = dto.SeriesId, + CblVolume = dto.CblVolume, + CblNumber = dto.CblNumber, + VolumeId = dto.VolumeId, + ChapterId = dto.ChapterId, + SeriesNameAtMapping = series.Name, + AppUserId = UserId, + IsGlobal = false, + CreatedUtc = DateTime.UtcNow + }; + + unitOfWork.RemapRuleRepository.Add(rule); + await unitOfWork.CommitAsync(); + + return Ok(mapper.Map(rule)); + } + + /// + /// Promotes a remap rule to global scope. Admin-only. + /// + [Authorize(Policy = PolicyGroups.AdminPolicy)] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + [HttpPost("remap-rules/{id}/promote")] + public async Task> PromoteRemapRule(int id) + { + var rule = await unitOfWork.RemapRuleRepository.GetByIdAsync(id, HttpContext.RequestAborted); + if (rule == null) return NotFound(); + rule.IsGlobal = true; + await unitOfWork.CommitAsync(); + return Ok(mapper.Map(rule)); + } + + /// + /// Demotes a global remap rule back to user-scoped. Admin-only. + /// + [Authorize(Policy = PolicyGroups.AdminPolicy)] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + [HttpPost("remap-rules/{id}/demote")] + public async Task> DemoteRemapRule(int id) + { + var rule = await unitOfWork.RemapRuleRepository.GetByIdAsync(id, HttpContext.RequestAborted); + if (rule == null) return NotFound(); + + rule.IsGlobal = false; + await unitOfWork.CommitAsync(); + + return Ok(mapper.Map(rule)); + } + + /// + /// Updates a remap rule with issue-level detail (volume/chapter). + /// + [HttpPut("remap-rules/{id}")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> UpdateRemapRule(int id, [FromBody] UpdateRemapRuleDto dto) + { + var rule = await unitOfWork.RemapRuleRepository.GetByIdAsync(id); + if (rule == null) return NotFound(); + if (rule.AppUserId != UserId) return Forbid(); + + rule.VolumeId = dto.VolumeId; + rule.ChapterId = dto.ChapterId; + rule.CblVolume = dto.CblVolume; + rule.CblNumber = dto.CblNumber; + + await unitOfWork.CommitAsync(); + + return Ok(mapper.Map(rule)); + } + + /// + /// Deletes a remap rule. Users can only delete their own rules. + /// + [HttpDelete("remap-rules/{id}")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task DeleteRemapRule(int id) + { + var rule = await unitOfWork.RemapRuleRepository.GetByIdAsync(id); + if (rule == null) return NotFound(); + if (rule.AppUserId != UserId) return Forbid(); + + unitOfWork.RemapRuleRepository.Remove(rule); + await unitOfWork.CommitAsync(); + + return Ok(); + } + + /// + /// Provides the browse CBL Repo interface. Requires Download role. + /// + /// + /// + [HttpGet("browse")] + [DisallowRole(PolicyConstants.ReadOnlyRole)] + public async Task> BrowseCblRepo([FromQuery] string path = "") + { + if (path.Contains("..") || path.Contains("http://")) return BadRequest(); + + var result = await cblGithubService.BrowseRepo(path); + + // TODO: Refactor into CblService - Update Browse Results with sync details from what's on disk + var syncedPaths = await dataContext.ReadingList + .Where(rl => rl.AppUserId == UserId + && rl.Provider == ReadingListProvider.Url + && rl.SourcePath != null) + .Select(rl => new { rl.SourcePath, rl.Id }) + .ToDictionaryAsync(x => x.SourcePath!, x => x.Id); + + foreach (var item in result.Items.Where(i => !i.IsDirectory)) + { + if (syncedPaths.TryGetValue(item.Path, out var readingListId)) + { + item.ExistingReadingListId = readingListId; + } + } + + return Ok(result); + } + + private async Task SaveCblFile(IFormFile file, int userId, string filename) + { + var dir = GetCblManagerFolder(userId); + Directory.CreateDirectory(dir); + var outputFile = Path.Join(dir, filename); await using var stream = System.IO.File.Create(outputFile); await file.CopyToAsync(stream); stream.Close(); - return ReadingListService.LoadCblFromPath(outputFile); + return outputFile; + } + + private string SaveCblFileFromContent(string content, int userId, string filename) + { + var dir = GetCblManagerFolder(userId); + Directory.CreateDirectory(dir); + var outputFile = Path.Join(dir, filename); + System.IO.File.WriteAllText(outputFile, content); + return outputFile; + } + + private string GetCblManagerFolder(int userId) + { + return Path.Join(directoryService.TempDirectory, $"{userId}", "cbl-manager-download"); } } diff --git a/Kavita.Server/Controllers/DeprecatedController.cs b/Kavita.Server/Controllers/DeprecatedController.cs index e340013a8..97dba74de 100644 --- a/Kavita.Server/Controllers/DeprecatedController.cs +++ b/Kavita.Server/Controllers/DeprecatedController.cs @@ -129,16 +129,16 @@ public class DeprecatedController( /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// - /// Does not use Url property + /// Does not use Url property /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [HttpPost("upload/reset-chapter-lock")] [Obsolete("Use LockCover in UploadFileDto, will be removed in v0.9.0")] - public async Task ResetChapterLock(UploadFileDto uploadFileDto) + public async Task ResetChapterLock(UploadCoverFileDto uploadCoverFileDto) { try { - var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); + var chapter = await unitOfWork.ChapterRepository.GetChapterAsync(uploadCoverFileDto.Id); if (chapter == null) return BadRequest(await localizationService.Translate(UserId, "chapter-doesnt-exist")); var originalFile = chapter.CoverImage; @@ -163,7 +163,7 @@ public class DeprecatedController( } catch (Exception e) { - logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadFileDto.Id); + logger.LogError(e, "There was an issue resetting cover lock for Chapter {Id}", uploadCoverFileDto.Id); await unitOfWork.RollbackAsync(); } diff --git a/Kavita.Server/Controllers/SearchController.cs b/Kavita.Server/Controllers/SearchController.cs index 670f1a756..bfcad252f 100644 --- a/Kavita.Server/Controllers/SearchController.cs +++ b/Kavita.Server/Controllers/SearchController.cs @@ -1,4 +1,6 @@ -using System.Threading.Tasks; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; using Kavita.API.Database; using Kavita.API.Repositories; using Kavita.API.Services; @@ -14,8 +16,8 @@ namespace Kavita.Server.Controllers; /// /// Responsible for the Search interface from the UI /// -public class SearchController(IUnitOfWork unitOfWork, ILocalizationService localizationService) - : BaseApiController +public class SearchController(IUnitOfWork unitOfWork, ILocalizationService localizationService, + IEntityNamingService namingService) : BaseApiController { /// /// Returns the series for the MangaFile id. If the user does not have access (shouldn't happen by the UI), @@ -69,4 +71,30 @@ public class SearchController(IUnitOfWork unitOfWork, ILocalizationService local return Ok(series); } + + /// + /// Returns all chapters for a given series with localized titles. Used for CBL chapter-level matching. + /// + [HttpGet("chapters-by-series")] + public async Task>> GetChaptersBySeries([FromQuery] int seriesId) + { + if (!await unitOfWork.UserRepository.HasAccessToSeries(UserId, seriesId)) + return Unauthorized(); + + var libraryType = await unitOfWork.LibraryRepository.GetLibraryTypeBySeriesIdAsync(seriesId); + var volumes = await unitOfWork.VolumeRepository.GetVolumesDtoAsync(seriesId, UserId); + var namingContext = await LocalizedNamingContext.CreateAsync(namingService, localizationService, UserId, libraryType); + + var chapters = volumes + .SelectMany(v => v.Chapters.Select(c => + { + c.VolumeTitle = namingContext.FormatVolumeName(v) ?? v.Name; + c.Title = namingContext.FormatChapterTitle(c); + return c; + })) + .OrderBy(c => c.SortOrder) + .ToList(); + + return Ok(chapters); + } } diff --git a/Kavita.Server/Controllers/UploadController.cs b/Kavita.Server/Controllers/UploadController.cs index edf8ec785..e6a8c009f 100644 --- a/Kavita.Server/Controllers/UploadController.cs +++ b/Kavita.Server/Controllers/UploadController.cs @@ -95,27 +95,27 @@ public class UploadController : BaseApiController /// /// Replaces series cover image and locks it with a base64 encoded image /// - /// + /// /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("series")] - public async Task UploadSeriesCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadSeriesCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system try { - var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadFileDto.Id); + var series = await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(uploadCoverFileDto.Id); if (series == null) return BadRequest(await _localizationService.Translate(UserId, "series-doesnt-exist")); var filePath = string.Empty; var lockState = false; - if (!string.IsNullOrEmpty(uploadFileDto.Url)) + if (!string.IsNullOrEmpty(uploadCoverFileDto.Url)) { - filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetSeriesFormat(uploadFileDto.Id)}"); - lockState = uploadFileDto.LockCover; + filePath = await CreateThumbnail(uploadCoverFileDto, $"{ImageService.GetSeriesFormat(uploadCoverFileDto.Id)}"); + lockState = uploadCoverFileDto.LockCover; } series.CoverImage = filePath; @@ -128,7 +128,7 @@ public class UploadController : BaseApiController if (_unitOfWork.HasChanges()) { // Refresh covers - if (string.IsNullOrEmpty(uploadFileDto.Url)) + if (string.IsNullOrEmpty(uploadCoverFileDto.Url)) { await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); } @@ -142,7 +142,7 @@ public class UploadController : BaseApiController } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Series {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } @@ -152,17 +152,17 @@ public class UploadController : BaseApiController /// /// Replaces collection tag cover image and locks it with a base64 encoded image /// - /// + /// /// [HttpPost("collection")] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] - public async Task UploadCollectionCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadCollectionCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system try { - var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadFileDto.Id); + var tag = await _unitOfWork.CollectionTagRepository.GetCollectionAsync(uploadCoverFileDto.Id); if (tag == null) return BadRequest(await _localizationService.Translate(UserId, "collection-doesnt-exist")); if (!User.IsInRole(PolicyConstants.AdminRole) && tag.AppUserId != UserId) @@ -170,10 +170,10 @@ public class UploadController : BaseApiController var filePath = string.Empty; var lockState = false; - if (!string.IsNullOrEmpty(uploadFileDto.Url)) + if (!string.IsNullOrEmpty(uploadCoverFileDto.Url)) { - filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetCollectionTagFormat(uploadFileDto.Id)}"); - lockState = uploadFileDto.LockCover; + filePath = await CreateThumbnail(uploadCoverFileDto, $"{ImageService.GetCollectionTagFormat(uploadCoverFileDto.Id)}"); + lockState = uploadCoverFileDto.LockCover; } tag.CoverImage = filePath; @@ -192,7 +192,7 @@ public class UploadController : BaseApiController } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Collection Tag {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } @@ -203,29 +203,29 @@ public class UploadController : BaseApiController /// Replaces reading list cover image and locks it with a base64 encoded image /// /// This is the only API that can be called by non-admins, but the authenticated user must have a readinglist permission - /// + /// /// [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("reading-list")] - public async Task UploadReadingListCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadReadingListCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { // Check if Url is non-empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (await _readingListService.UserHasReadingListAccess(uploadFileDto.Id, Username!) == null) + if (await _readingListService.UserHasReadingListAccess(uploadCoverFileDto.Id, Username!) == null) return Unauthorized(await _localizationService.Translate(UserId, "access-denied")); try { - var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadFileDto.Id); + var readingList = await _unitOfWork.ReadingListRepository.GetReadingListByIdAsync(uploadCoverFileDto.Id); if (readingList == null) return BadRequest(await _localizationService.Translate(UserId, "reading-list-doesnt-exist")); var filePath = string.Empty; var lockState = false; - if (!string.IsNullOrEmpty(uploadFileDto.Url)) + if (!string.IsNullOrEmpty(uploadCoverFileDto.Url)) { - filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetReadingListFormat(uploadFileDto.Id)}"); - lockState = uploadFileDto.LockCover; + filePath = await CreateThumbnail(uploadCoverFileDto, $"{ImageService.GetReadingListFormat(uploadCoverFileDto.Id)}"); + lockState = uploadCoverFileDto.LockCover; } @@ -245,46 +245,46 @@ public class UploadController : BaseApiController } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Reading List {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } return BadRequest(await _localizationService.Translate(UserId, "generic-cover-reading-list-save")); } - private async Task CreateThumbnail(UploadFileDto uploadFileDto, string filename) + private async Task CreateThumbnail(UploadCoverFileDto uploadCoverFileDto, string filename) { var settings = await _unitOfWork.SettingsRepository.GetSettingsDtoAsync(); var encodeFormat = settings.EncodeMediaAs; var coverImageSize = settings.CoverImageSize; - return _imageService.CreateThumbnailFromBase64(uploadFileDto.Url, + return _imageService.CreateThumbnailFromBase64(uploadCoverFileDto.Url, filename, encodeFormat, coverImageSize.GetDimensions().Width); } /// /// Replaces chapter cover image and locks it with a base64 encoded image. This will update the parent volume's cover image. /// - /// + /// /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("chapter")] - public async Task UploadChapterCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadChapterCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system try { - var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadFileDto.Id); + var chapter = await _unitOfWork.ChapterRepository.GetChapterAsync(uploadCoverFileDto.Id); if (chapter == null) return BadRequest(await _localizationService.Translate(UserId, "chapter-doesnt-exist")); var filePath = string.Empty; var lockState = false; - if (!string.IsNullOrEmpty(uploadFileDto.Url)) + if (!string.IsNullOrEmpty(uploadCoverFileDto.Url)) { - filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetChapterFormat(uploadFileDto.Id, chapter.VolumeId)}"); - lockState = uploadFileDto.LockCover; + filePath = await CreateThumbnail(uploadCoverFileDto, $"{ImageService.GetChapterFormat(uploadCoverFileDto.Id, chapter.VolumeId)}"); + lockState = uploadCoverFileDto.LockCover; } chapter.CoverImage = filePath; @@ -304,7 +304,7 @@ public class UploadController : BaseApiController await _unitOfWork.CommitAsync(); // Refresh covers - if (string.IsNullOrEmpty(uploadFileDto.Url)) + if (string.IsNullOrEmpty(uploadCoverFileDto.Url)) { var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume!.SeriesId))!; await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); @@ -321,7 +321,7 @@ public class UploadController : BaseApiController } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Chapter {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } @@ -332,26 +332,26 @@ public class UploadController : BaseApiController /// Replaces volume cover image and locks it with a base64 encoded image. /// /// This will not update the underlying chapter - /// + /// /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("volume")] - public async Task UploadVolumeCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadVolumeCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system try { - var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(uploadFileDto.Id, VolumeIncludes.Chapters); + var volume = await _unitOfWork.VolumeRepository.GetVolumeByIdAsync(uploadCoverFileDto.Id, VolumeIncludes.Chapters); if (volume == null) return BadRequest(await _localizationService.Translate(UserId, "volume-doesnt-exist")); var filePath = string.Empty; var lockState = false; - if (!string.IsNullOrEmpty(uploadFileDto.Url)) + if (!string.IsNullOrEmpty(uploadCoverFileDto.Url)) { - filePath = await CreateThumbnail(uploadFileDto, $"{ImageService.GetVolumeFormat(uploadFileDto.Id)}"); - lockState = uploadFileDto.LockCover; + filePath = await CreateThumbnail(uploadCoverFileDto, $"{ImageService.GetVolumeFormat(uploadCoverFileDto.Id)}"); + lockState = uploadCoverFileDto.LockCover; } volume.CoverImage = filePath; @@ -364,7 +364,7 @@ public class UploadController : BaseApiController await _unitOfWork.CommitAsync(); // Refresh covers - if (string.IsNullOrEmpty(uploadFileDto.Url)) + if (string.IsNullOrEmpty(uploadCoverFileDto.Url)) { var series = (await _unitOfWork.SeriesRepository.GetSeriesByIdAsync(volume.SeriesId))!; await _taskScheduler.RefreshSeriesMetadata(series.LibraryId, series.Id, true); @@ -372,7 +372,7 @@ public class UploadController : BaseApiController await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, - MessageFactory.CoverUpdateEvent(uploadFileDto.Id, MessageFactoryEntityTypes.Volume), false); + MessageFactory.CoverUpdateEvent(uploadCoverFileDto.Id, MessageFactoryEntityTypes.Volume), false); await _eventHub.SendMessageAsync(MessageFactory.CoverUpdate, MessageFactory.CoverUpdateEvent(volume.Id, MessageFactoryEntityTypes.Chapter), false); return Ok(); @@ -381,7 +381,7 @@ public class UploadController : BaseApiController } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Volume {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Volume {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } @@ -392,19 +392,19 @@ public class UploadController : BaseApiController /// /// Replaces library cover image with a base64 encoded image. If empty string passed, will reset to null. /// - /// + /// /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("library")] - public async Task UploadLibraryCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadLibraryCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { - var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(uploadFileDto.Id); + var library = await _unitOfWork.LibraryRepository.GetLibraryForIdAsync(uploadCoverFileDto.Id); if (library == null) return BadRequest("This library does not exist"); // Check if Url is non empty, request the image and place in temp, then ask image service to handle it. // See if we can do this all in memory without touching underlying system - if (string.IsNullOrEmpty(uploadFileDto.Url)) + if (string.IsNullOrEmpty(uploadCoverFileDto.Url)) { library.CoverImage = null; library.ResetColorScape(); @@ -421,8 +421,8 @@ public class UploadController : BaseApiController try { - var filePath = await CreateThumbnail(uploadFileDto, - $"{ImageService.GetLibraryFormat(uploadFileDto.Id)}"); + var filePath = await CreateThumbnail(uploadCoverFileDto, + $"{ImageService.GetLibraryFormat(uploadCoverFileDto.Id)}"); if (!string.IsNullOrEmpty(filePath)) { @@ -442,7 +442,7 @@ public class UploadController : BaseApiController } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Library {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Library {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } @@ -453,24 +453,24 @@ public class UploadController : BaseApiController /// /// Replaces person tag cover image and locks it with a base64 encoded image /// - /// + /// /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] [HttpPost("person")] - public async Task UploadPersonCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadPersonCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { try { - var person = await _unitOfWork.PersonRepository.GetPersonById(uploadFileDto.Id); + var person = await _unitOfWork.PersonRepository.GetPersonById(uploadCoverFileDto.Id); if (person == null) return BadRequest(await _localizationService.Translate(UserId, "person-doesnt-exist")); - await _coverDbService.SetPersonCoverByUrl(person, uploadFileDto.Url, chooseBetterImage: false); + await _coverDbService.SetPersonCoverByUrl(person, uploadCoverFileDto.Url, chooseBetterImage: false); return Ok(); } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for Person {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for Person {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } @@ -482,26 +482,26 @@ public class UploadController : BaseApiController /// Replaces user cover image and locks it with a base64 encoded image /// /// You MUST be the user in question - /// + /// /// [HttpPost("user")] [DisallowRole(PolicyConstants.ReadOnlyRole)] [RequestSizeLimit(ControllerConstants.MaxUploadSizeBytes)] - public async Task UploadUserCoverImageFromUrl(UploadFileDto uploadFileDto) + public async Task UploadUserCoverImageFromUrl(UploadCoverFileDto uploadCoverFileDto) { try { - if (uploadFileDto.Id != UserId) return NotFound(); + if (uploadCoverFileDto.Id != UserId) return NotFound(); - var user = await _unitOfWork.UserRepository.GetUserByIdAsync(uploadFileDto.Id); + var user = await _unitOfWork.UserRepository.GetUserByIdAsync(uploadCoverFileDto.Id); if (user == null) return BadRequest(await _localizationService.Translate(UserId, "user-doesnt-exist")); - await _coverDbService.SetUserCoverByUrl(user, uploadFileDto.Url, chooseBetterImage: false); + await _coverDbService.SetUserCoverByUrl(user, uploadCoverFileDto.Url, chooseBetterImage: false); return Ok(); } catch (Exception e) { - _logger.LogError(e, "There was an issue uploading cover image for User {Id}", uploadFileDto.Id); + _logger.LogError(e, "There was an issue uploading cover image for User {Id}", uploadCoverFileDto.Id); await _unitOfWork.RollbackAsync(); } diff --git a/Kavita.Services.Tests/CleanupServiceTests.cs b/Kavita.Services.Tests/CleanupServiceTests.cs index 8f0def39f..10f792c9b 100644 --- a/Kavita.Services.Tests/CleanupServiceTests.cs +++ b/Kavita.Services.Tests/CleanupServiceTests.cs @@ -15,6 +15,7 @@ using Kavita.Models.DTOs.Filtering; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Progress; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Kavita.Services.Builders; using Kavita.Services.Reading; diff --git a/Kavita.Services.Tests/Helpers/CblFileBuilder.cs b/Kavita.Services.Tests/Helpers/CblFileBuilder.cs new file mode 100644 index 000000000..2d559fc65 --- /dev/null +++ b/Kavita.Services.Tests/Helpers/CblFileBuilder.cs @@ -0,0 +1,45 @@ +using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; + +namespace Kavita.Services.Tests.Helpers; + +/// +/// Fluent builder for constructing objects in tests +/// +public class CblFileBuilder +{ + private readonly string _name; + private readonly List _items = []; + private int _nextOrder; + + private CblFileBuilder(string name) + { + _name = name; + } + + public static CblFileBuilder Create(string name) => new(name); + + public CblFileBuilder AddBook(string series, string volume = "", string number = "", + List? externalIds = null) + { + _items.Add(new ParsedCblItem + { + Order = _nextOrder++, + SeriesName = series, + Volume = volume, + Number = number, + ExternalIds = externalIds ?? [] + }); + return this; + } + + public ParsedCblReadingList Build() + { + return new ParsedCblReadingList + { + SchemaVersion = 1, + Name = _name, + Items = new List(_items) + }; + } +} diff --git a/Kavita.Services.Tests/Helpers/CblTestHelper.cs b/Kavita.Services.Tests/Helpers/CblTestHelper.cs new file mode 100644 index 000000000..787555040 --- /dev/null +++ b/Kavita.Services.Tests/Helpers/CblTestHelper.cs @@ -0,0 +1,237 @@ +using System.Text.Json; +using System.Xml.Serialization; +using Kavita.API.Database; +using Kavita.API.Services; +using Kavita.API.Services.ReadingLists; +using Kavita.Models.Builders; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; +using Kavita.Models.DTOs.ReadingLists.CBL.V1; +using Kavita.Models.Entities; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.User; +using Kavita.Services.Builders; +using Kavita.Services.ReadingLists; +using Microsoft.Extensions.Logging; +using NSubstitute; + +namespace Kavita.Services.Tests.Helpers; + +#region Seed Models + +internal record SeedLibrary(string LibraryName, string LibraryType, List Series); +internal record SeedSeries(string Name, string? LocalizedName, string? AgeRating, List Volumes); +internal record SeedVolume(string Number, List Chapters); +internal record SeedChapter(string Number, string? ComicVineId, long? MetronId); + +#endregion + +public record SeedResult( + Library Library, + AppUser User, + Dictionary<(string Series, string Volume, string Chapter), (int SeriesId, int VolumeId, int ChapterId)> Lookup +); + +public class CblTestHelper : IDisposable +{ + private readonly IUnitOfWork _unitOfWork; + private readonly List _tempFiles = []; + + private static readonly string TestDataDir = Path.Join( + Directory.GetCurrentDirectory(), "../../../Test Data/CblImportService"); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public CblTestHelper(IUnitOfWork unitOfWork) + { + _unitOfWork = unitOfWork; + } + + public async Task SeedLibrary(string testCaseFile, string? username = null) + { + var json = await File.ReadAllTextAsync(Path.Join(TestDataDir, testCaseFile)); + var seed = JsonSerializer.Deserialize(json, JsonOptions)!; + + var libraryType = Enum.Parse(seed.LibraryType); + var library = new LibraryBuilder(seed.LibraryName, libraryType) + .WithFolderPath(new FolderPathBuilder("/data/" + seed.LibraryName.ToLower()).Build()) + .Build(); + + foreach (var seedSeries in seed.Series) + { + var seriesMetadataBuilder = new SeriesMetadataBuilder(); + if (!string.IsNullOrEmpty(seedSeries.AgeRating)) + { + seriesMetadataBuilder.WithAgeRating(Enum.Parse(seedSeries.AgeRating)); + } + + var seriesBuilder = new SeriesBuilder(seedSeries.Name) + .WithMetadata(seriesMetadataBuilder.Build()); + + if (!string.IsNullOrEmpty(seedSeries.LocalizedName)) + { + seriesBuilder.WithLocalizedName(seedSeries.LocalizedName); + } + + foreach (var seedVolume in seedSeries.Volumes) + { + var volumeBuilder = new VolumeBuilder(seedVolume.Number); + + foreach (var chapterElement in seedVolume.Chapters) + { + var seedChapter = ParseChapterElement(chapterElement); + var chapter = new ChapterBuilder(seedChapter.Number).Build(); + + if (!string.IsNullOrEmpty(seedChapter.ComicVineId)) + { + chapter.ComicVineId = seedChapter.ComicVineId; + } + if (seedChapter.MetronId.HasValue) + { + chapter.MetronId = seedChapter.MetronId.Value; + } + + volumeBuilder.WithChapter(chapter); + } + + seriesBuilder.WithVolume(volumeBuilder.Build()); + } + + library.Series ??= []; + library.Series.Add(seriesBuilder.Build()); + } + + var user = new AppUserBuilder(username ?? "testuser", $"{username ?? "testuser"}@test.com") + .WithLibrary(library) + .Build(); + + _unitOfWork.UserRepository.Add(user); + await _unitOfWork.CommitAsync(); + + // Build lookup map + var lookup = new Dictionary<(string, string, string), (int, int, int)>(); + foreach (var series in library.Series!) + { + foreach (var volume in series.Volumes) + { + foreach (var chapter in volume.Chapters) + { + // Use the range (which matches the input number) for lookup + lookup[(series.Name, volume.Name, chapter.Range)] = (series.Id, volume.Id, chapter.Id); + } + } + } + + return new SeedResult(library, user, lookup); + } + + public async Task AddUser(string username, Library library, AgeRating? restriction = null, bool includeUnknowns = false) + { + var user = new AppUserBuilder(username, $"{username}@test.com") + .WithLibrary(library) + .Build(); + + if (restriction.HasValue) + { + user.AgeRestriction = restriction.Value; + user.AgeRestrictionIncludeUnknowns = includeUnknowns; + } + + _unitOfWork.UserRepository.Add(user); + await _unitOfWork.CommitAsync(); + return user; + } + + public CblImportService CreateImportService() + { + return new CblImportService( + _unitOfWork, + Substitute.For(), + Substitute.For(), + Substitute.For>() + ); + } + + public string WriteCblToDisk(ParsedCblReadingList cbl) + { + var v1 = new CblReadingList + { + Name = cbl.Name, + Summary = cbl.Summary, + StartYear = cbl.StartYear, + StartMonth = cbl.StartMonth, + EndYear = cbl.EndYear, + EndMonth = cbl.EndMonth, + Books = new CblBooks + { + Book = cbl.Items.Select(item => + { + var book = new CblBook + { + Series = item.SeriesName, + Number = item.Number, + Volume = item.Volume, + Year = item.Year, + }; + + book.Databases = item.ExternalIds.Select(extId => new CblBookDatabase + { + Name = extId.Provider switch + { + CblExternalDbProvider.ComicVine => "cv", + CblExternalDbProvider.Metron => "metron", + CblExternalDbProvider.GrandComicsDatabase => "gcd", + _ => "unknown" + }, + Series = extId.SeriesId, + Issue = extId.IssueId + }).ToList(); + + return book; + }).ToList() + } + }; + + var tempPath = Path.Join(Path.GetTempPath(), $"kavita-test-{Guid.NewGuid()}.cbl"); + _tempFiles.Add(tempPath); + + var serializer = new XmlSerializer(typeof(CblReadingList)); + using var stream = File.Create(tempPath); + serializer.Serialize(stream, v1); + + return tempPath; + } + + private static SeedChapter ParseChapterElement(JsonElement element) + { + if (element.ValueKind == JsonValueKind.String) + { + return new SeedChapter(element.GetString()!, null, null); + } + + var number = element.GetProperty("number").GetString()!; + string? comicVineId = null; + long? metronId = null; + + if (element.TryGetProperty("comicVineId", out var cvProp)) + { + comicVineId = cvProp.GetString(); + } + if (element.TryGetProperty("metronId", out var metronProp)) + { + metronId = metronProp.GetInt64(); + } + + return new SeedChapter(number, comicVineId, metronId); + } + + public void Dispose() + { + foreach (var file in _tempFiles) + { + try { File.Delete(file); } catch { /* best effort */ } + } + } +} diff --git a/Kavita.Services.Tests/Helpers/OrderableHelperTests.cs b/Kavita.Services.Tests/Helpers/OrderableHelperTests.cs index 5cbeb730e..905747e2f 100644 --- a/Kavita.Services.Tests/Helpers/OrderableHelperTests.cs +++ b/Kavita.Services.Tests/Helpers/OrderableHelperTests.cs @@ -1,4 +1,5 @@ using Kavita.Models.Entities; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Kavita.Models.Helpers; diff --git a/Kavita.Services.Tests/OpdsServiceTests.cs b/Kavita.Services.Tests/OpdsServiceTests.cs index cb646600c..424abc28f 100644 --- a/Kavita.Services.Tests/OpdsServiceTests.cs +++ b/Kavita.Services.Tests/OpdsServiceTests.cs @@ -20,9 +20,11 @@ using Kavita.Models.DTOs.OPDS.Requests; using Kavita.Models.DTOs.Progress; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Kavita.Services.Builders; using Kavita.Services.Reading; +using Kavita.Services.ReadingLists; using Kavita.Services.Scanner; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; diff --git a/Kavita.Services.Tests/ReadingListServiceTests.cs b/Kavita.Services.Tests/ReadingListServiceTests.cs index de59f7f36..15fc3136d 100644 --- a/Kavita.Services.Tests/ReadingListServiceTests.cs +++ b/Kavita.Services.Tests/ReadingListServiceTests.cs @@ -15,9 +15,11 @@ using Kavita.Models.DTOs.ReadingLists.CBL; using Kavita.Models.DTOs.ReadingLists.CBL.V1; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Kavita.Services.Builders; using Kavita.Services.Reading; +using Kavita.Services.ReadingLists; using Kavita.Services.Scanner; using Microsoft.Extensions.Logging; using NSubstitute; @@ -725,68 +727,6 @@ public class ReadingListServiceTests(ITestOutputHelper outputHelper): AbstractDb #endregion - #region FormatTitle - - [Fact] - public void FormatTitle_ShouldFormatCorrectly() - { - // Manga Library & Archive - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1"))); - Assert.Equal("Chapter 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1", "1"))); - Assert.Equal("Chapter 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1", "1", "The Title"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, "1", chapterTitleName: "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Manga, chapterTitleName: "The Title"))); - - // Comic Library & Archive - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1"))); - Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1"))); - Assert.Equal("Issue #1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", "1", "The Title"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, "1", chapterTitleName: "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterTitleName: "The Title"))); - var dto = CreateListItemDto(MangaFormat.Archive, LibraryType.Comic, chapterNumber: "The Special Title"); - dto.IsSpecial = true; - Assert.Equal("The Special Title", ReadingListService.FormatTitle(dto)); - - // Book Library & Archive - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1"))); - Assert.Equal("Book 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1", "1"))); - Assert.Equal("Book 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1", "1", "The Title"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, "1", chapterTitleName: "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Archive, LibraryType.Book, chapterTitleName: "The Title"))); - - // Manga Library & EPUB - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1", "1"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1", "1", "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, "1", chapterTitleName: "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Manga, chapterTitleName: "The Title"))); - - // Book Library & EPUB - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1", "1"))); - Assert.Equal("Volume 1", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1", "1", "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, "1", chapterTitleName: "The Title"))); - Assert.Equal("The Title", ReadingListService.FormatTitle(CreateListItemDto(MangaFormat.Epub, LibraryType.Book, chapterTitleName: "The Title"))); - - } - - private static ReadingListItemDto CreateListItemDto(MangaFormat seriesFormat, LibraryType libraryType, - string volumeNumber = Parser.LooseLeafVolume, - string chapterNumber =Parser.DefaultChapter, - string chapterTitleName = "") - { - return new ReadingListItemDto() - { - SeriesFormat = seriesFormat, - LibraryType = libraryType, - VolumeNumber = volumeNumber, - ChapterNumber = chapterNumber, - ChapterTitleName = chapterTitleName - }; - } - - #endregion - #region CreateReadingList private async Task CreateReadingList_SetupBaseData(IUnitOfWork unitOfWork, DataContext context) @@ -988,354 +928,354 @@ public class ReadingListServiceTests(ITestOutputHelper outputHelper): AbstractDb } #endregion - #region ValidateCBL - - [Fact] - public async Task ValidateCblFile_ShouldFail_UserHasAccessToNoSeries() - { - var (unitOfWork, context, mapper) = await CreateDatabase(); - var (readingListService, _) = Setup(unitOfWork, context, mapper); - var cblReadingList = LoadCblFromPath("Fables.cbl"); - - // Mock up our series - var fablesSeries = new SeriesBuilder("Fables").Build(); - var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build(); - - fablesSeries.Volumes.Add(new VolumeBuilder("1") - .WithMinNumber(1) - .WithName("2002") - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build() - ); - fables2Series.Volumes.Add(new VolumeBuilder("1") - .WithMinNumber(1) - .WithName("2003") - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build() - ); - - context.AppUser.Add(new AppUserBuilder("majora2007", string.Empty).Build()); - - context.Library.Add(new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .WithSeries(fables2Series) - .Build() - ); - - await unitOfWork.CommitAsync(); - - var importSummary = await readingListService.ValidateCblFile(1, cblReadingList); - - Assert.Equal(CblImportResult.Fail, importSummary.Success); - Assert.NotEmpty(importSummary.Results); - } - - [Fact] - public async Task ValidateCblFile_ShouldFail_ServerHasNoSeries() - { - var (unitOfWork, context, mapper) = await CreateDatabase(); - var (readingListService, _) = Setup(unitOfWork, context, mapper); - var cblReadingList = LoadCblFromPath("Fables.cbl"); - - // Mock up our series - var fablesSeries = new SeriesBuilder("Fablesa").Build(); - var fables2Series = new SeriesBuilder("Fablesa: The Last Castle").Build(); - - fablesSeries.Volumes.Add(new VolumeBuilder("2002") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()); - fables2Series.Volumes.Add(new VolumeBuilder("2003") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()); - - context.AppUser.Add(new AppUser() - { - UserName = "majora2007", - ReadingLists = new List(), - Libraries = new List(), - }); - - context.Library.Add(new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .WithSeries(fables2Series) - .Build()); - - await unitOfWork.CommitAsync(); - - var importSummary = await readingListService.ValidateCblFile(1, cblReadingList); - - Assert.Equal(CblImportResult.Fail, importSummary.Success); - Assert.NotEmpty(importSummary.Results); - } - - #endregion - - #region CreateReadingListFromCBL - - private static CblReadingList LoadCblFromPath(string path) - { - var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Test Data/ReadingListService/"); - - var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList)); - using var file = new StreamReader(Path.Join(testDirectory, path)); - var cblReadingList = (CblReadingList) reader.Deserialize(file); - file.Close(); - return cblReadingList; - } - - [Fact] - public async Task CreateReadingListFromCBL_ShouldCreateList() - { - var (unitOfWork, context, mapper) = await CreateDatabase(); - var (readingListService, _) = Setup(unitOfWork, context, mapper); - var cblReadingList = LoadCblFromPath("Fables.cbl"); - - // Mock up our series - var fablesSeries = new SeriesBuilder("Fables") - .WithVolume(new VolumeBuilder("2002") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()) - .Build(); - - var fables2Series = new SeriesBuilder("Fables: The Last Castle") - .WithVolume(new VolumeBuilder("2003") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()) - .Build(); - - context.AppUser.Add(new AppUser() - { - UserName = "majora2007", - ReadingLists = new List(), - Libraries = new List() - { - new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .WithSeries(fables2Series) - .Build() - }, - }); - await unitOfWork.CommitAsync(); - - var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); - - Assert.Equal(CblImportResult.Partial, importSummary.Success); - Assert.NotEmpty(importSummary.Results); - - var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - - Assert.NotNull(createdList); - Assert.Equal("Fables", createdList.Title); - - Assert.Equal(4, createdList.Items.Count); - Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); - Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); - Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); - } - - [Fact] - public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo() - { - var (unitOfWork, context, mapper) = await CreateDatabase(); - var (readingListService, _) = Setup(unitOfWork, context, mapper); - var cblReadingList = LoadCblFromPath("Fables.cbl"); - - // Mock up our series - var fablesSeries = new SeriesBuilder("Fables").Build(); - var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build(); - - fablesSeries.Volumes.Add(new VolumeBuilder("2002") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()); - fables2Series.Volumes.Add(new VolumeBuilder("2003") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()); - - context.AppUser.Add(new AppUser() - { - UserName = "majora2007", - ReadingLists = new List(), - Libraries = new List() - { - new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .Build() - }, - }); - - context.Library.Add(new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fables2Series) - .Build()); - - await unitOfWork.CommitAsync(); - - var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); - - Assert.Equal(CblImportResult.Partial, importSummary.Success); - Assert.NotEmpty(importSummary.Results); - - var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - - Assert.NotNull(createdList); - Assert.Equal("Fables", createdList.Title); - - Assert.Equal(3, createdList.Items.Count); - Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); - Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); - Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle" - && r.Reason == CblImportReason.SeriesMissing)); - } - - [Fact] - public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList() - { - var (unitOfWork, context, mapper) = await CreateDatabase(); - var (readingListService, _) = Setup(unitOfWork, context, mapper); - var cblReadingList = LoadCblFromPath("Fables.cbl"); - - // Mock up our series - var fablesSeries = new SeriesBuilder("Fables").Build(); - var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build(); - - fablesSeries.Volumes.Add(new VolumeBuilder("2002") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()); - fables2Series.Volumes.Add(new VolumeBuilder("2003") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()); - - context.AppUser.Add(new AppUser() - { - UserName = "majora2007", - ReadingLists = new List(), - Libraries = new List() - { - new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .WithSeries(fables2Series) - .Build() - }, - }); - - await unitOfWork.CommitAsync(); - - // Create a reading list named Fables and add 2 chapters to it - var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists); - Assert.NotNull(user); - var readingList = await readingListService.CreateReadingListForUser(user, "Fables"); - Assert.True(await readingListService.AddChaptersToReadingList(1, new List() {1, 3}, readingList)); - Assert.Equal(2, readingList.Items.Count); - - // Attempt to import a Cbl with same reading list name - var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); - - Assert.Equal(CblImportResult.Partial, importSummary.Success); - Assert.NotEmpty(importSummary.Results); - - var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - - Assert.NotNull(createdList); - Assert.Equal("Fables", createdList.Title); - - Assert.Equal(4, createdList.Items.Count); - Assert.Equal(4, importSummary.SuccessfulInserts.Count); - - Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - Assert.Equal(3, createdList.Items.First(item => item.Order == 1).ChapterId); // we inserted 3 first - Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId); - Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); - } - - /// - /// This test is about ensuring Annuals that are a separate series can be linked up properly (ComicVine) - /// - //[Fact] - public async Task CreateReadingListFromCBL_ShouldCreateList_WithAnnuals() - { - // TODO: Implement this correctly - var (unitOfWork, context, mapper) = await CreateDatabase(); - var (readingListService, _) = Setup(unitOfWork, context, mapper); - var cblReadingList = LoadCblFromPath("Annual.cbl"); - - // Mock up our series - var fablesSeries = new SeriesBuilder("Fables") - .WithVolume(new VolumeBuilder("2002") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .WithChapter(new ChapterBuilder("2").Build()) - .WithChapter(new ChapterBuilder("3").Build()) - .Build()) - .Build(); - - var fables2Series = new SeriesBuilder("Fables Annual") - .WithVolume(new VolumeBuilder("2003") - .WithMinNumber(1) - .WithChapter(new ChapterBuilder("1").Build()) - .Build()) - .Build(); - - context.AppUser.Add(new AppUser() - { - UserName = "majora2007", - ReadingLists = new List(), - Libraries = new List() - { - new LibraryBuilder("Test LIb 2", LibraryType.Book) - .WithSeries(fablesSeries) - .WithSeries(fables2Series) - .Build() - }, - }); - await unitOfWork.CommitAsync(); - - var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); - - Assert.Equal(CblImportResult.Success, importSummary.Success); - Assert.NotEmpty(importSummary.Results); - - var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); - - Assert.NotNull(createdList); - Assert.Equal("Annual", createdList.Title); - - Assert.Equal(4, createdList.Items.Count); - Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); - Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); - Assert.Equal(4, createdList.Items.First(item => item.Order == 2).ChapterId); - Assert.Equal(3, createdList.Items.First(item => item.Order == 3).ChapterId); - } - - #endregion + // #region ValidateCBL + // + // [Fact] + // public async Task ValidateCblFile_ShouldFail_UserHasAccessToNoSeries() + // { + // var (unitOfWork, context, mapper) = await CreateDatabase(); + // var (readingListService, _) = Setup(unitOfWork, context, mapper); + // var cblReadingList = LoadCblFromPath("Fables.cbl"); + // + // // Mock up our series + // var fablesSeries = new SeriesBuilder("Fables").Build(); + // var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build(); + // + // fablesSeries.Volumes.Add(new VolumeBuilder("1") + // .WithMinNumber(1) + // .WithName("2002") + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build() + // ); + // fables2Series.Volumes.Add(new VolumeBuilder("1") + // .WithMinNumber(1) + // .WithName("2003") + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build() + // ); + // + // context.AppUser.Add(new AppUserBuilder("majora2007", string.Empty).Build()); + // + // context.Library.Add(new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fablesSeries) + // .WithSeries(fables2Series) + // .Build() + // ); + // + // await unitOfWork.CommitAsync(); + // + // var importSummary = await readingListService.ValidateCblFile(1, cblReadingList); + // + // Assert.Equal(CblImportResult.Fail, importSummary.Success); + // Assert.NotEmpty(importSummary.Results); + // } + // + // [Fact] + // public async Task ValidateCblFile_ShouldFail_ServerHasNoSeries() + // { + // var (unitOfWork, context, mapper) = await CreateDatabase(); + // var (readingListService, _) = Setup(unitOfWork, context, mapper); + // var cblReadingList = LoadCblFromPath("Fables.cbl"); + // + // // Mock up our series + // var fablesSeries = new SeriesBuilder("Fablesa").Build(); + // var fables2Series = new SeriesBuilder("Fablesa: The Last Castle").Build(); + // + // fablesSeries.Volumes.Add(new VolumeBuilder("2002") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()); + // fables2Series.Volumes.Add(new VolumeBuilder("2003") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()); + // + // context.AppUser.Add(new AppUser() + // { + // UserName = "majora2007", + // ReadingLists = new List(), + // Libraries = new List(), + // }); + // + // context.Library.Add(new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fablesSeries) + // .WithSeries(fables2Series) + // .Build()); + // + // await unitOfWork.CommitAsync(); + // + // var importSummary = await readingListService.ValidateCblFile(1, cblReadingList); + // + // Assert.Equal(CblImportResult.Fail, importSummary.Success); + // Assert.NotEmpty(importSummary.Results); + // } + // + // #endregion + // + // #region CreateReadingListFromCBL + // + // private static CblReadingList LoadCblFromPath(string path) + // { + // var testDirectory = Path.Join(Directory.GetCurrentDirectory(), "../../../Test Data/ReadingListService/"); + // + // var reader = new System.Xml.Serialization.XmlSerializer(typeof(CblReadingList)); + // using var file = new StreamReader(Path.Join(testDirectory, path)); + // var cblReadingList = (CblReadingList) reader.Deserialize(file); + // file.Close(); + // return cblReadingList; + // } + // + // [Fact] + // public async Task CreateReadingListFromCBL_ShouldCreateList() + // { + // var (unitOfWork, context, mapper) = await CreateDatabase(); + // var (readingListService, _) = Setup(unitOfWork, context, mapper); + // var cblReadingList = LoadCblFromPath("Fables.cbl"); + // + // // Mock up our series + // var fablesSeries = new SeriesBuilder("Fables") + // .WithVolume(new VolumeBuilder("2002") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()) + // .Build(); + // + // var fables2Series = new SeriesBuilder("Fables: The Last Castle") + // .WithVolume(new VolumeBuilder("2003") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()) + // .Build(); + // + // context.AppUser.Add(new AppUser() + // { + // UserName = "majora2007", + // ReadingLists = new List(), + // Libraries = new List() + // { + // new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fablesSeries) + // .WithSeries(fables2Series) + // .Build() + // }, + // }); + // await unitOfWork.CommitAsync(); + // + // var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); + // + // Assert.Equal(CblImportResult.Partial, importSummary.Success); + // Assert.NotEmpty(importSummary.Results); + // + // var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + // + // Assert.NotNull(createdList); + // Assert.Equal("Fables", createdList.Title); + // + // Assert.Equal(4, createdList.Items.Count); + // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + // Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); + // Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); + // Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); + // } + // + // [Fact] + // public async Task CreateReadingListFromCBL_ShouldCreateList_ButOnlyIncludeSeriesThatUserHasAccessTo() + // { + // var (unitOfWork, context, mapper) = await CreateDatabase(); + // var (readingListService, _) = Setup(unitOfWork, context, mapper); + // var cblReadingList = LoadCblFromPath("Fables.cbl"); + // + // // Mock up our series + // var fablesSeries = new SeriesBuilder("Fables").Build(); + // var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build(); + // + // fablesSeries.Volumes.Add(new VolumeBuilder("2002") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()); + // fables2Series.Volumes.Add(new VolumeBuilder("2003") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()); + // + // context.AppUser.Add(new AppUser() + // { + // UserName = "majora2007", + // ReadingLists = new List(), + // Libraries = new List() + // { + // new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fablesSeries) + // .Build() + // }, + // }); + // + // context.Library.Add(new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fables2Series) + // .Build()); + // + // await unitOfWork.CommitAsync(); + // + // var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); + // + // Assert.Equal(CblImportResult.Partial, importSummary.Success); + // Assert.NotEmpty(importSummary.Results); + // + // var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + // + // Assert.NotNull(createdList); + // Assert.Equal("Fables", createdList.Title); + // + // Assert.Equal(3, createdList.Items.Count); + // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + // Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); + // Assert.Equal(3, createdList.Items.First(item => item.Order == 2).ChapterId); + // Assert.NotNull(importSummary.Results.SingleOrDefault(r => r.Series == "Fables: The Last Castle" + // && r.Reason == CblImportReason.SeriesMissing)); + // } + // + // [Fact] + // public async Task CreateReadingListFromCBL_ShouldUpdateAnExistingList() + // { + // var (unitOfWork, context, mapper) = await CreateDatabase(); + // var (readingListService, _) = Setup(unitOfWork, context, mapper); + // var cblReadingList = LoadCblFromPath("Fables.cbl"); + // + // // Mock up our series + // var fablesSeries = new SeriesBuilder("Fables").Build(); + // var fables2Series = new SeriesBuilder("Fables: The Last Castle").Build(); + // + // fablesSeries.Volumes.Add(new VolumeBuilder("2002") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()); + // fables2Series.Volumes.Add(new VolumeBuilder("2003") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()); + // + // context.AppUser.Add(new AppUser() + // { + // UserName = "majora2007", + // ReadingLists = new List(), + // Libraries = new List() + // { + // new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fablesSeries) + // .WithSeries(fables2Series) + // .Build() + // }, + // }); + // + // await unitOfWork.CommitAsync(); + // + // // Create a reading list named Fables and add 2 chapters to it + // var user = await unitOfWork.UserRepository.GetUserByIdAsync(1, AppUserIncludes.ReadingLists); + // Assert.NotNull(user); + // var readingList = await readingListService.CreateReadingListForUser(user, "Fables"); + // Assert.True(await readingListService.AddChaptersToReadingList(1, new List() {1, 3}, readingList)); + // Assert.Equal(2, readingList.Items.Count); + // + // // Attempt to import a Cbl with same reading list name + // var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); + // + // Assert.Equal(CblImportResult.Partial, importSummary.Success); + // Assert.NotEmpty(importSummary.Results); + // + // var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + // + // Assert.NotNull(createdList); + // Assert.Equal("Fables", createdList.Title); + // + // Assert.Equal(4, createdList.Items.Count); + // Assert.Equal(4, importSummary.SuccessfulInserts.Count); + // + // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + // Assert.Equal(3, createdList.Items.First(item => item.Order == 1).ChapterId); // we inserted 3 first + // Assert.Equal(2, createdList.Items.First(item => item.Order == 2).ChapterId); + // Assert.Equal(4, createdList.Items.First(item => item.Order == 3).ChapterId); + // } + // + // /// + // /// This test is about ensuring Annuals that are a separate series can be linked up properly (ComicVine) + // /// + // //[Fact] + // public async Task CreateReadingListFromCBL_ShouldCreateList_WithAnnuals() + // { + // // TODO: Implement this correctly + // var (unitOfWork, context, mapper) = await CreateDatabase(); + // var (readingListService, _) = Setup(unitOfWork, context, mapper); + // var cblReadingList = LoadCblFromPath("Annual.cbl"); + // + // // Mock up our series + // var fablesSeries = new SeriesBuilder("Fables") + // .WithVolume(new VolumeBuilder("2002") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .WithChapter(new ChapterBuilder("2").Build()) + // .WithChapter(new ChapterBuilder("3").Build()) + // .Build()) + // .Build(); + // + // var fables2Series = new SeriesBuilder("Fables Annual") + // .WithVolume(new VolumeBuilder("2003") + // .WithMinNumber(1) + // .WithChapter(new ChapterBuilder("1").Build()) + // .Build()) + // .Build(); + // + // context.AppUser.Add(new AppUser() + // { + // UserName = "majora2007", + // ReadingLists = new List(), + // Libraries = new List() + // { + // new LibraryBuilder("Test LIb 2", LibraryType.Book) + // .WithSeries(fablesSeries) + // .WithSeries(fables2Series) + // .Build() + // }, + // }); + // await unitOfWork.CommitAsync(); + // + // var importSummary = await readingListService.CreateReadingListFromCbl(1, cblReadingList); + // + // Assert.Equal(CblImportResult.Success, importSummary.Success); + // Assert.NotEmpty(importSummary.Results); + // + // var createdList = await unitOfWork.ReadingListRepository.GetReadingListByIdAsync(1); + // + // Assert.NotNull(createdList); + // Assert.Equal("Annual", createdList.Title); + // + // Assert.Equal(4, createdList.Items.Count); + // Assert.Equal(1, createdList.Items.First(item => item.Order == 0).ChapterId); + // Assert.Equal(2, createdList.Items.First(item => item.Order == 1).ChapterId); + // Assert.Equal(4, createdList.Items.First(item => item.Order == 2).ChapterId); + // Assert.Equal(3, createdList.Items.First(item => item.Order == 3).ChapterId); + // } + // + // #endregion #region CreateReadingListsFromSeries diff --git a/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs b/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs index 3807905e0..96bafa366 100644 --- a/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs +++ b/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs @@ -1,7 +1,9 @@ +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Metadata; using Kavita.Models.Entities.Person; +using Kavita.Models.Entities.ReadingLists; using Kavita.Services.Helpers; using Kavita.Services.ReadingLists; @@ -30,7 +32,8 @@ public class CblExportServiceTests private static ReadingListItem CreateItem(int order, string seriesName, string chapterRange, string volumeName, DateTime? releaseDate = null, bool isSpecial = false, - MangaFormat format = MangaFormat.Archive) + MangaFormat format = MangaFormat.Archive, + string? comicVineId = null, long metronId = 0, int aniListId = 0, long malId = 0, int hardcoverId = 0) { var series = new Series { @@ -65,6 +68,11 @@ public class CblExportServiceTests Range = chapterRange, IsSpecial = isSpecial, ReleaseDate = releaseDate ?? DateTime.MinValue, + ComicVineId = comicVineId, + MetronId = metronId, + AniListId = aniListId, + MalId = malId, + HardcoverId = hardcoverId, }, }; } @@ -102,7 +110,7 @@ public class CblExportServiceTests Assert.Equal("2016", first.Year); Assert.Equal(string.Empty, first.Format); Assert.Equal("cbz", first.FileType); - Assert.Null(first.Database); + Assert.Empty(first.Databases); var last = result.Books.Book[2]; Assert.Equal("Superman", last.Series); @@ -163,6 +171,95 @@ public class CblExportServiceTests Assert.Equal(string.Empty, result.Books.Book[0].Year); } + [Fact] + public void ExportV1_ExternalIds_SingleProvider() + { + var readingList = CreateReadingList(); + var items = new List + { + CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15), comicVineId: "cv-12345"), + }; + + var result = CblExportService.BuildCblReadingList(readingList, items); + + var first = result.Books.Book[0]; + Assert.Single(first.Databases); + Assert.Equal("cv", first.Databases[0].Name); + Assert.Equal("Batman", first.Databases[0].Series); + Assert.Equal("cv-12345", first.Databases[0].Issue); + } + + [Fact] + public void ExportV1_ExternalIds_MultipleProviders() + { + var readingList = CreateReadingList(); + var items = new List + { + CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15), + comicVineId: "cv-12345", metronId: 67890), + }; + + var result = CblExportService.BuildCblReadingList(readingList, items); + + var first = result.Books.Book[0]; + Assert.Equal(2, first.Databases.Count); + Assert.Equal("cv", first.Databases[0].Name); + Assert.Equal("cv-12345", first.Databases[0].Issue); + Assert.Equal("metron", first.Databases[1].Name); + Assert.Equal("67890", first.Databases[1].Issue); + } + + [Fact] + public void ExportV1_ExternalIds_NoIds() + { + var readingList = CreateReadingList(); + var items = new List + { + CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15)), + }; + + var result = CblExportService.BuildCblReadingList(readingList, items); + + Assert.Empty(result.Books.Book[0].Databases); + } + + [Fact] + public void ExportV1_ExternalIds_RoundTrip() + { + var readingList = CreateReadingList(title: "External ID Round Trip"); + var items = new List + { + CreateItem(0, "Batman", "1", "2016", new DateTime(2016, 6, 15), + comicVineId: "cv-12345", metronId: 67890), + }; + + var cbl = CblExportService.BuildCblReadingList(readingList, items); + + var tempFile = Path.Combine(Path.GetTempPath(), $"cbl-extid-test-{Guid.NewGuid()}.cbl"); + try + { + CblExportService.SerializeV1(cbl, tempFile); + + var parsed = CblParser.ParseV1(tempFile); + + Assert.Single(parsed.Items); + var item = parsed.Items[0]; + Assert.Equal(2, item.ExternalIds.Count); + + var cv = item.ExternalIds.First(e => e.Provider == CblExternalDbProvider.ComicVine); + Assert.Equal("cv-12345", cv.IssueId); + Assert.Equal("Batman", cv.SeriesId); + + var metron = item.ExternalIds.First(e => e.Provider == CblExternalDbProvider.Metron); + Assert.Equal("67890", metron.IssueId); + Assert.Equal("Batman", metron.SeriesId); + } + finally + { + if (File.Exists(tempFile)) File.Delete(tempFile); + } + } + #endregion #region RoundTrip @@ -253,7 +350,7 @@ public class CblExportServiceTests Assert.Equal("1", first.IssueNumber); Assert.Equal(2016, first.SeriesStartYear); Assert.Equal("2016-06-15", first.IssueCoverDate); - Assert.Null(first.Id); + Assert.Empty(first.Id); var second = result.IssueList[1]; Assert.Equal("Superman", second.SeriesName); diff --git a/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs b/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs new file mode 100644 index 000000000..060a38a56 --- /dev/null +++ b/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs @@ -0,0 +1,888 @@ +using Kavita.Common.Extensions; +using Kavita.Database.Tests; +using Kavita.Models.Builders; +using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Import; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; +using Kavita.Services.Builders; +using Kavita.Services.Tests.Helpers; +using Xunit.Abstractions; + +namespace Kavita.Services.Tests.ReadingLists; + +public class CblImportServiceTests : AbstractDbTest +{ + public CblImportServiceTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + #region Group 1: Fresh Import + + [Fact] + public async Task ValidateList_AllMatched_ReturnsSuccess() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Test List") + .AddBook("Fables", volume: "1", number: "1") + .AddBook("Fables", volume: "1", number: "2") + .AddBook("Fables", volume: "1", number: "3") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Equal(3, summary.SuccessfulInserts.Count); + Assert.All(summary.SuccessfulInserts, r => Assert.Equal(CblImportReason.Success, r.Reason)); + Assert.Empty(summary.Results); + } + + [Fact] + public async Task ValidateList_PartialMatch_ReturnsPartial() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Test List") + .AddBook("Fables", volume: "1", number: "1") + .AddBook("Fables", volume: "1", number: "2") + .AddBook("Fables", volume: "1", number: "99") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Partial, summary.Success); + Assert.Equal(2, summary.SuccessfulInserts.Count); + Assert.Single(summary.Results); + Assert.Equal(CblImportReason.ChapterMissing, summary.Results.First().Reason); + } + + [Fact] + public async Task ValidateList_NoSeriesMatch_ReturnsFail() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Test List") + .AddBook("NonExistent", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Fail, summary.Success); + Assert.Contains(summary.Results, r => r.Reason == CblImportReason.SeriesMissing); + } + + [Fact] + public async Task ValidateList_EmptyCbl_ReturnsFail() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Empty List").Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Fail, summary.Success); + Assert.Contains(summary.Results, r => r.Reason == CblImportReason.EmptyFile); + } + + [Fact] + public async Task UpsertReadingList_CreatesNewList() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("My New List") + .AddBook("Fables", volume: "1", number: "1") + .AddBook("Fables", volume: "1", number: "2") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var decisions = new CblImportDecisions + { + ItemResolutions = new Dictionary(), + SaveAsRemapRules = false + }; + var summary = await svc.UpsertReadingList(seed.User.Id, filePath, new CblImportOptions(), decisions); + + Assert.False(summary.IsUpdate); + + // Verify reading list was created in DB + var rl = await unitOfWork.ReadingListRepository.GetReadingListByTitleAsync("My New List", seed.User.Id); + Assert.NotNull(rl); + Assert.Equal(2, rl!.Items.Count); + Assert.Equal(0, rl.Items.OrderBy(i => i.Order).First().Order); + Assert.Equal(1, rl.Items.OrderBy(i => i.Order).Last().Order); + } + + #endregion + + #region Group 2: Re-Import / Update + + [Fact] + public async Task ValidateList_ExistingList_IsUpdateTrue() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + // Pre-create reading list + var rl = new ReadingListBuilder("Test") + .WithAppUserId(seed.User.Id) + .Build(); + unitOfWork.ReadingListRepository.Add(rl); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Test") + .AddBook("Fables", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.True(summary.IsUpdate); + } + + [Fact] + public async Task ValidateList_NewList_IsUpdateFalse() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Brand New List") + .AddBook("Fables", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.False(summary.IsUpdate); + } + + [Fact] + public async Task UpsertReadingList_UpdatesExistingList_NoDuplicates() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var ids = seed.Lookup[("Fables", "1", "1")]; + + // Pre-create reading list with Fables #1 + var rl = new ReadingListBuilder("Update Test") + .WithAppUserId(seed.User.Id) + .WithItem(new ReadingListItemBuilder(0, ids.SeriesId, ids.VolumeId, ids.ChapterId).Build()) + .Build(); + unitOfWork.ReadingListRepository.Add(rl); + await unitOfWork.CommitAsync(); + + // Upsert with #1, #2, #3 + var cbl = CblFileBuilder.Create("Update Test") + .AddBook("Fables", volume: "1", number: "1") + .AddBook("Fables", volume: "1", number: "2") + .AddBook("Fables", volume: "1", number: "3") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var decisions = new CblImportDecisions + { + ItemResolutions = new Dictionary(), + SaveAsRemapRules = false + }; + var summary = await svc.UpsertReadingList(seed.User.Id, filePath, new CblImportOptions(), decisions); + + Assert.True(summary.IsUpdate); + + var updated = await unitOfWork.ReadingListRepository.GetReadingListByTitleAsync("Update Test", seed.User.Id); + Assert.NotNull(updated); + Assert.Equal(3, updated!.Items.Count); + } + + #endregion + + #region Group 3: Series-Level Remap Rules + + [Fact] + public async Task ValidateList_SeriesRemap_MatchesViaTier0() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var fablesIds = seed.Lookup[("Fables", "1", "1")]; + + // Add series-level remap rule: "Fable" → Fables series + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fable".ToNormalized(), + CblSeriesName = "Fable", + SeriesId = fablesIds.SeriesId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Remap Test") + .AddBook("Fable", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.RemapRule, summary.SuccessfulInserts.First().MatchTier); + } + + [Fact] + public async Task ValidateList_SeriesRemap_UserScoped() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json", "user1"); + + var user2 = await helper.AddUser("user2", seed.Library); + + var fablesIds = seed.Lookup[("Fables", "1", "1")]; + + // Add user-scoped remap rule for user1 only + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fable".ToNormalized(), + CblSeriesName = "Fable", + SeriesId = fablesIds.SeriesId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Scoped Test") + .AddBook("Fable", volume: "1", number: "1") + .Build(); + + // User1 should match via remap + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary1 = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + Assert.Equal(CblImportResult.Success, summary1.Success); + + // User2 should NOT match (no remap, and "Fable" doesn't match "Fables" exactly) + var cbl2 = CblFileBuilder.Create("Scoped Test 2") + .AddBook("Fable", volume: "1", number: "1") + .Build(); + var filePath2 = helper.WriteCblToDisk(cbl2); + var summary2 = await svc.ValidateList(user2.Id, filePath2, new CblImportOptions()); + Assert.Contains(summary2.Results, r => r.Reason == CblImportReason.SeriesMissing); + } + + [Fact] + public async Task UpsertReadingList_SavesRemapRule() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var fablesIds = seed.Lookup[("Fables", "1", "1")]; + + var cbl = CblFileBuilder.Create("Remap Save Test") + .AddBook("Fables", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var decisions = new CblImportDecisions + { + ItemResolutions = new Dictionary + { + [0] = new CblItemDecision + { + SeriesId = fablesIds.SeriesId, + VolumeId = fablesIds.VolumeId, + ChapterId = fablesIds.ChapterId + } + }, + SaveAsRemapRules = true + }; + await svc.UpsertReadingList(seed.User.Id, filePath, new CblImportOptions(), decisions); + + // Verify remap rule was persisted + var rules = await unitOfWork.RemapRuleRepository.GetRulesForUserAsync(seed.User.Id); + Assert.NotEmpty(rules); + Assert.Contains(rules, r => + r.NormalizedCblSeriesName == "Fables".ToNormalized() && + r.SeriesId == fablesIds.SeriesId); + } + + #endregion + + #region Group 4: Issue-Level Remap Rules + + [Fact] + public async Task ValidateList_IssueRemap_MatchesSpecificChapter() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var ch2Ids = seed.Lookup[("Fables", "1", "2")]; + + // Add issue-level remap rule: Fables vol=1 #2 → specific chapter + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fables".ToNormalized(), + CblSeriesName = "Fables", + CblVolume = "1", + CblNumber = "2", + SeriesId = ch2Ids.SeriesId, + VolumeId = ch2Ids.VolumeId, + ChapterId = ch2Ids.ChapterId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Issue Remap Test") + .AddBook("Fables", volume: "1", number: "2") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.RemapRule, summary.SuccessfulInserts.First().MatchTier); + Assert.Equal(ch2Ids.ChapterId, summary.SuccessfulInserts.First().ChapterId); + } + + [Fact] + public async Task ValidateList_IssueRemap_DoesNotMatchWrongIssue() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var ch2Ids = seed.Lookup[("Fables", "1", "2")]; + + // Same issue-level rule for #2 + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fables".ToNormalized(), + CblSeriesName = "Fables", + CblVolume = "1", + CblNumber = "2", + SeriesId = ch2Ids.SeriesId, + VolumeId = ch2Ids.VolumeId, + ChapterId = ch2Ids.ChapterId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + // Request #3 — should NOT use the #2 rule, should fall through to name matching + var cbl = CblFileBuilder.Create("Wrong Issue Test") + .AddBook("Fables", volume: "1", number: "3") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + // Should match via name, not remap rule + Assert.NotEqual(CblMatchTier.RemapRule, summary.SuccessfulInserts.First().MatchTier); + } + + [Fact] + public async Task ValidateList_IssueRemap_TakesPrecedenceOverSeriesRemap() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var ch2Ids = seed.Lookup[("Fables", "1", "2")]; + var fablesIds = seed.Lookup[("Fables", "1", "1")]; + + // Add series-level remap (less specific) + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fables".ToNormalized(), + CblSeriesName = "Fables", + SeriesId = fablesIds.SeriesId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + + // Add issue-level remap (more specific) + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fables".ToNormalized(), + CblSeriesName = "Fables", + CblVolume = "1", + CblNumber = "2", + SeriesId = ch2Ids.SeriesId, + VolumeId = ch2Ids.VolumeId, + ChapterId = ch2Ids.ChapterId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Precedence Test") + .AddBook("Fables", volume: "1", number: "2") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.RemapRule, summary.SuccessfulInserts.First().MatchTier); + // The issue-level rule should resolve to the exact chapter + Assert.Equal(ch2Ids.ChapterId, summary.SuccessfulInserts.First().ChapterId); + } + + #endregion + + #region Group 5: Age Rating Filtering + + [Fact] + public async Task ValidateList_AgeRestriction_FiltersMatureSeries() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("rated-library.json"); + + // Create a teen-restricted user + var teenUser = await helper.AddUser("teen", seed.Library, AgeRating.Teen); + + var cbl = CblFileBuilder.Create("Age Filter Test") + .AddBook("Fables", volume: "1", number: "1") + .AddBook("Batman", volume: "2016", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(teenUser.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Partial, summary.Success); + // Batman (Teen) should succeed, Fables (Mature) should be missing + Assert.Single(summary.SuccessfulInserts); + Assert.Equal("Batman", summary.SuccessfulInserts.First().Series); + Assert.Contains(summary.Results, r => r.Reason == CblImportReason.SeriesMissing && r.Series == "Fables"); + } + + [Fact] + public async Task ValidateList_AgeRestriction_UnrestrictedSeesAll() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("rated-library.json"); + + var cbl = CblFileBuilder.Create("Unrestricted Test") + .AddBook("Fables", volume: "1", number: "1") + .AddBook("Batman", volume: "2016", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Equal(2, summary.SuccessfulInserts.Count); + } + + [Fact] + public async Task ValidateList_AgeRestriction_UnknownExcluded() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + + // Inline seed: series with Unknown age rating + var library = new LibraryBuilder("TestLib", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/testlib").Build()) + .Build(); + + var series = new SeriesBuilder("Mystery Series") + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Unknown) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + + library.Series = [series]; + + // Teen user who does NOT include unknowns — single user with library access + var teenUser = new AppUserBuilder("teenuser", "teen@test.com") + .WithLibrary(library) + .Build(); + teenUser.AgeRestriction = AgeRating.Teen; + teenUser.AgeRestrictionIncludeUnknowns = false; + context.AppUser.Add(teenUser); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var cbl = CblFileBuilder.Create("Unknown Excluded Test") + .AddBook("Mystery Series", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(teenUser.Id, filePath, new CblImportOptions()); + + Assert.Contains(summary.Results, r => r.Reason == CblImportReason.SeriesMissing); + } + + [Fact] + public async Task ValidateList_AgeRestriction_UnknownIncluded() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + + // Inline seed: series with Unknown age rating + var library = new LibraryBuilder("TestLib", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/testlib").Build()) + .Build(); + + var series = new SeriesBuilder("Mystery Series") + .WithMetadata(new SeriesMetadataBuilder() + .WithAgeRating(AgeRating.Unknown) + .Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + + library.Series = [series]; + + // Teen user who DOES include unknowns — single user with library access + var teenUser = new AppUserBuilder("teenuser", "teen@test.com") + .WithLibrary(library) + .Build(); + teenUser.AgeRestriction = AgeRating.Teen; + teenUser.AgeRestrictionIncludeUnknowns = true; + context.AppUser.Add(teenUser); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var cbl = CblFileBuilder.Create("Unknown Included Test") + .AddBook("Mystery Series", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(teenUser.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + } + + #endregion + + #region Group 6: External ID Matching + + [Fact] + public async Task ValidateList_ExternalId_ComicVine_DirectMatch() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("rated-library.json"); + + // Fables #1 has comicVineId "cv-111" in rated-library.json + var cbl = CblFileBuilder.Create("CV Match Test") + .AddBook("WrongSeriesName", volume: "1", number: "1", + externalIds: [new CblExternalId { Provider = CblExternalDbProvider.ComicVine, IssueId = "cv-111" }]) + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.ExternalId, summary.SuccessfulInserts.First().MatchTier); + } + + [Fact] + public async Task ValidateList_ExternalId_Metron_DirectMatch() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("rated-library.json"); + + // Fables #2 has metronId 222 + var cbl = CblFileBuilder.Create("Metron Match Test") + .AddBook("WrongSeriesName", volume: "1", number: "2", + externalIds: [new CblExternalId { Provider = CblExternalDbProvider.Metron, IssueId = "222" }]) + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.ExternalId, summary.SuccessfulInserts.First().MatchTier); + } + + [Fact] + public async Task ValidateList_ExternalId_NoMatch_FallsThrough() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("rated-library.json"); + + // Nonexistent external ID but correct series name — should fall through to name match + var cbl = CblFileBuilder.Create("Fallthrough Test") + .AddBook("Fables", volume: "1", number: "1", + externalIds: [new CblExternalId { Provider = CblExternalDbProvider.ComicVine, IssueId = "nonexistent" }]) + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + // Should NOT be ExternalId tier since the ID doesn't exist + Assert.NotEqual(CblMatchTier.ExternalId, summary.SuccessfulInserts.First().MatchTier); + } + + [Fact] + public async Task ValidateList_ExternalId_WrongProvider_Ignored() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("rated-library.json"); + + // Fables #1 has ComicVine "cv-111", but we provide it as Metron — should not match Tier 1 + var cbl = CblFileBuilder.Create("Wrong Provider Test") + .AddBook("Fables", volume: "1", number: "1", + externalIds: [new CblExternalId { Provider = CblExternalDbProvider.Metron, IssueId = "cv-111" }]) + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + // Should fall through to name matching, not ExternalId + Assert.NotEqual(CblMatchTier.ExternalId, summary.SuccessfulInserts.First().MatchTier); + } + + #endregion + + #region Group 7: Library Access + + [Fact] + public async Task ValidateList_LibraryAccess_NoAccess_AllMissing() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + // Create a second library that user2 has access to (not the Comics library) + var otherLib = new LibraryBuilder("Other", LibraryType.Manga) + .WithFolderPath(new FolderPathBuilder("/data/other").Build()) + .Build(); + context.Library.Add(otherLib); + await context.SaveChangesAsync(); + + var user2 = new AppUserBuilder("user2", "user2@test.com") + .WithLibrary(otherLib) + .Build(); + context.AppUser.Add(user2); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var cbl = CblFileBuilder.Create("No Access Test") + .AddBook("Fables", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(user2.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Fail, summary.Success); + } + + [Fact] + public async Task ValidateList_LibraryAccess_PartialAccess() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + + // Seed two libraries inline + var libA = new LibraryBuilder("LibA", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/liba").Build()) + .Build(); + libA.Series = [new SeriesBuilder("SeriesA") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build()]; + + var libB = new LibraryBuilder("LibB", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/libb").Build()) + .Build(); + libB.Series = [new SeriesBuilder("SeriesB") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build()]; + + // User only has access to LibA + var user = new AppUserBuilder("partialuser", "partial@test.com") + .WithLibrary(libA) + .Build(); + context.Library.Add(libB); + context.AppUser.Add(user); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var cbl = CblFileBuilder.Create("Partial Access Test") + .AddBook("SeriesA", volume: "1", number: "1") + .AddBook("SeriesB", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(user.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Partial, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal("SeriesA", summary.SuccessfulInserts.First().Series); + } + + [Fact] + public async Task ValidateList_ApplicableLibraries_RestrictsSearch() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + + // Seed two libraries + var libA = new LibraryBuilder("LibA", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/liba").Build()) + .Build(); + libA.Series = [new SeriesBuilder("SeriesA") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build()]; + + var libB = new LibraryBuilder("LibB", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/libb").Build()) + .Build(); + libB.Series = [new SeriesBuilder("SeriesB") + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build()]; + + // User has access to both + var user = new AppUserBuilder("bothuser", "both@test.com") + .WithLibrary(libA) + .WithLibrary(libB) + .Build(); + context.AppUser.Add(user); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + var cbl = CblFileBuilder.Create("Applicable Libs Test") + .AddBook("SeriesA", volume: "1", number: "1") + .AddBook("SeriesB", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + + // Only search in LibB + var options = new CblImportOptions { ApplicableLibraries = [libB.Id] }; + var summary = await svc.ValidateList(user.Id, filePath, options); + + Assert.Equal(CblImportResult.Partial, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal("SeriesB", summary.SuccessfulInserts.First().Series); + } + + #endregion + + #region Group 8: AlternateSeries + + [Fact] + public async Task ValidateList_AlternateSeries_MatchesTier6() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + + // Inline seed: series "Fables Deluxe" with chapter having AlternateSeries = "Fables" + var library = new LibraryBuilder("Comics", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/comics").Build()) + .Build(); + + var chapter = new ChapterBuilder("1").Build(); + chapter.AlternateSeries = "Fables"; + + var series = new SeriesBuilder("Fables Deluxe") + .WithVolume(new VolumeBuilder("1") + .WithChapter(chapter) + .Build()) + .Build(); + + library.Series = [series]; + + var user = new AppUserBuilder("altuser", "alt@test.com") + .WithLibrary(library) + .Build(); + context.AppUser.Add(user); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // CBL asks for "Fables" — no direct series match for "Fables Deluxe", but AlternateSeries should match + var cbl = CblFileBuilder.Create("AlternateSeries Test") + .AddBook("Fables", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(user.Id, filePath, new CblImportOptions()); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.AlternateSeries, summary.SuccessfulInserts.First().MatchTier); + } + + #endregion +} diff --git a/Kavita.Services.Tests/ReadingLists/CblParserTests.cs b/Kavita.Services.Tests/ReadingLists/CblParserTests.cs index a68fa5070..dc79ed47c 100644 --- a/Kavita.Services.Tests/ReadingLists/CblParserTests.cs +++ b/Kavita.Services.Tests/ReadingLists/CblParserTests.cs @@ -1,4 +1,5 @@ using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; using Kavita.Services.Helpers; using Kavita.Services.ReadingLists; diff --git a/Kavita.Services.Tests/Test Data/CblImportService/rated-library.json b/Kavita.Services.Tests/Test Data/CblImportService/rated-library.json new file mode 100644 index 000000000..ecffef8ab --- /dev/null +++ b/Kavita.Services.Tests/Test Data/CblImportService/rated-library.json @@ -0,0 +1,32 @@ +{ + "libraryName": "Comics", + "libraryType": "Comic", + "series": [ + { + "name": "Fables", + "ageRating": "Mature", + "volumes": [ + { + "number": "1", + "chapters": [ + { "number": "1", "comicVineId": "cv-111" }, + { "number": "2", "metronId": 222 }, + "3" + ] + } + ] + }, + { + "name": "Batman", + "ageRating": "Teen", + "volumes": [ + { + "number": "2016", + "chapters": [ + { "number": "1", "comicVineId": "cv-999" } + ] + } + ] + } + ] +} diff --git a/Kavita.Services.Tests/Test Data/CblImportService/simple-comic.json b/Kavita.Services.Tests/Test Data/CblImportService/simple-comic.json new file mode 100644 index 000000000..d480a2fea --- /dev/null +++ b/Kavita.Services.Tests/Test Data/CblImportService/simple-comic.json @@ -0,0 +1,18 @@ +{ + "libraryName": "Comics", + "libraryType": "Comic", + "series": [ + { + "name": "Fables", + "volumes": [ + { "number": "1", "chapters": ["1", "2", "3"] } + ] + }, + { + "name": "Batman", + "volumes": [ + { "number": "2016", "chapters": ["1", "2"] } + ] + } + ] +} diff --git a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs index 899468a5e..fbebf8f3f 100644 --- a/Kavita.Services/Extensions/ApplicationServiceExtensions.cs +++ b/Kavita.Services/Extensions/ApplicationServiceExtensions.cs @@ -57,7 +57,10 @@ public static class ApplicationServiceExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/Kavita.Services/Extensions/FlurlGithubExtensions.cs b/Kavita.Services/Extensions/FlurlGithubExtensions.cs new file mode 100644 index 000000000..cb228fc59 --- /dev/null +++ b/Kavita.Services/Extensions/FlurlGithubExtensions.cs @@ -0,0 +1,38 @@ +using System; +using Flurl.Http; +using Kavita.Models.DTOs.Misc; + +namespace Kavita.Services.Extensions; + +public static class FlurlGithubExtensions +{ + public static IFlurlRequest WithGithubHeaders(this string url) + { + return url + .WithHeader("User-Agent", "Kavita") + .WithHeader("Accept", "application/vnd.github.v3+json"); + } + + /// + /// Extracts GitHub rate limit info from response headers. + /// + public static GithubRateLimitDto GetRateLimit(this IFlurlResponse response) + { + var remaining = response.Headers.FirstOrDefault("X-RateLimit-Remaining"); + var limit = response.Headers.FirstOrDefault("X-RateLimit-Limit"); + var resetEpoch = response.Headers.FirstOrDefault("X-RateLimit-Reset"); + + DateTime? resetsAt = null; + if (long.TryParse(resetEpoch, out var epoch)) + { + resetsAt = DateTimeOffset.FromUnixTimeSeconds(epoch).UtcDateTime; + } + + return new GithubRateLimitDto + { + Remaining = int.TryParse(remaining, out var r) ? r : null, + Limit = int.TryParse(limit, out var l) ? l : null, + ResetsAtUtc = resetsAt + }; + } +} diff --git a/Kavita.Services/Helpers/CblParser.cs b/Kavita.Services/Helpers/CblParser.cs index f7a088625..7b1b8b36e 100644 --- a/Kavita.Services/Helpers/CblParser.cs +++ b/Kavita.Services/Helpers/CblParser.cs @@ -4,6 +4,7 @@ using System.IO; using System.Text.Json; using System.Xml.Serialization; using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; using Kavita.Models.DTOs.ReadingLists.CBL.V1; using Kavita.Models.DTOs.ReadingLists.CBL.V2; @@ -70,14 +71,14 @@ public static class CblParser IssueType = CblIssueType.Unknown, }; - if (book.Database != null) + foreach (var db in book.Databases) { - var provider = MapProviderName(book.Database.Name); + var provider = MapProviderName(db.Name); item.ExternalIds.Add(new CblExternalId { Provider = provider, - SeriesId = book.Database.Series ?? string.Empty, - IssueId = book.Database.Issue ?? string.Empty, + SeriesId = db.Series ?? string.Empty, + IssueId = db.Issue ?? string.Empty, }); } diff --git a/Kavita.Services/ReadingLists/CblExportService.cs b/Kavita.Services/ReadingLists/CblExportService.cs index 2ff920a9d..3bda82595 100644 --- a/Kavita.Services/ReadingLists/CblExportService.cs +++ b/Kavita.Services/ReadingLists/CblExportService.cs @@ -5,6 +5,7 @@ using System.Linq; using System.Threading.Tasks; using System.Globalization; using System.Text.Json; +using System.Text.RegularExpressions; using System.Xml; using System.Xml.Serialization; using Kavita.API.Database; @@ -13,6 +14,7 @@ using Kavita.Models.DTOs.ReadingLists.CBL.V1; using Kavita.Models.DTOs.ReadingLists.CBL.V2; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -31,7 +33,7 @@ public interface ICblExportService Task ExportReadingList(int readingListId, int userId, bool asV2 = false); } -public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) : ICblExportService +public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) : ICblExportService { /// public async Task ExportReadingList(int readingListId, int userId, bool asV2 = false) @@ -96,15 +98,24 @@ public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService director ? item.Chapter.ReleaseDate.Year.ToString() : string.Empty; + var seriesName = item.Series.Name; + var group = SeriesAndYearRegex().Matches(item.Series.Name); + if (group.Count > 1) + { + seriesName = group[0].Groups["Series"].Value; + year = group[0].Groups["Year"].Value; + } + + books.Add(new CblBook { - Series = item.Series.Name, + Series = seriesName, Number = item.Chapter.Range, // Range can leak internal encodings. Need to understand how to map this. Volume = item.Volume.Name, // TODO: If the library is Comic type, we can try and parse from Kavita Series first. Need to test with real user files Year = year, - Format = item.Chapter.IsSpecial ? "Annual" : string.Empty, // TODO: Confirm with CBL Group on how to handle Format + Format = (item.Series.Name.Contains("Annual") || item.Chapter.Range.Contains("Annual")) ? "Annual" : string.Empty, // We will only write "Annual" when we detect it in the Series Name FileType = MapMangaFormatToFileType(item.Series.Format), - Database = null, // TODO: If we have ComicVine metadata id in Chapter, populate this + Databases = GetV1Databases(item.Chapter, seriesName), }); } @@ -150,6 +161,9 @@ public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService director ? item.Series.Metadata.ReleaseYear : (int?)null; + // TODO: If library type is Comics, we need to remove (YEAR/Vol) + var seriesName = item.Series.Name; + issues.Add(new CblV2Issue { SeriesName = item.Series.Name, @@ -157,7 +171,7 @@ public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService director IssueNumber = item.Chapter.Range, IssueCoverDate = coverDate, IssueType = string.Empty, - Id = null, // TODO: When we expand Chapter-level external metadata, create this + Id = GetExternalIds(item.Chapter, seriesName) }); } @@ -187,6 +201,84 @@ public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService director }; } + private static List GetV1Databases(Chapter chapter, string seriesName) + { + var results = new List(); + + if (!string.IsNullOrEmpty(chapter.ComicVineId)) + results.Add(new CblBookDatabase { Name = "cv", Series = seriesName, Issue = chapter.ComicVineId }); + + if (chapter.MetronId > 0) + results.Add(new CblBookDatabase { Name = "metron", Series = seriesName, Issue = chapter.MetronId.ToString() }); + + if (chapter.AniListId > 0) + results.Add(new CblBookDatabase { Name = "anilist", Series = seriesName, Issue = chapter.AniListId.ToString() }); + + if (chapter.MalId > 0) + results.Add(new CblBookDatabase { Name = "malist", Series = seriesName, Issue = chapter.MalId.ToString() }); + + if (chapter.HardcoverId > 0) + results.Add(new CblBookDatabase { Name = "hardcover", Series = seriesName, Issue = chapter.HardcoverId.ToString() }); + + return results; + } + + private static List GetExternalIds(Chapter chapter, string seriesName) + { + var results = new List(); + if (chapter.AniListId > 0) + { + results.Add(new CblV2ExternalId() + { + Issue = chapter.AniListId.ToString(), + Name = "anilist", + Series = seriesName + }); + } + + if (chapter.MalId > 0) + { + results.Add(new CblV2ExternalId() + { + Issue = chapter.MalId.ToString(), + Name = "malist", + Series = seriesName + }); + } + + if (!string.IsNullOrEmpty(chapter.ComicVineId)) + { + results.Add(new CblV2ExternalId() + { + Issue = chapter.ComicVineId, + Name = "cv", + Series = seriesName + }); + } + + if (chapter.MetronId > 0) + { + results.Add(new CblV2ExternalId() + { + Issue = chapter.MetronId.ToString(), + Name = "metron", + Series = seriesName + }); + } + + if (chapter.HardcoverId > 0) + { + results.Add(new CblV2ExternalId() + { + Issue = chapter.HardcoverId.ToString(), + Name = "hardcover", + Series = seriesName + }); + } + + return results; + } + public static void SerializeV2(CblV2Root root, string filePath) { var options = new JsonSerializerOptions @@ -228,4 +320,7 @@ public class CblExportService(IUnitOfWork unitOfWork, IDirectoryService director var invalid = Path.GetInvalidFileNameChars(); return string.Concat(name.Select(c => invalid.Contains(c) ? '_' : c)); } + + [GeneratedRegex(@"(?.+)\((?\d{4})\)$")] + private static partial Regex SeriesAndYearRegex(); } diff --git a/Kavita.Services/ReadingLists/CblGithubService.cs b/Kavita.Services/ReadingLists/CblGithubService.cs new file mode 100644 index 000000000..026335138 --- /dev/null +++ b/Kavita.Services/ReadingLists/CblGithubService.cs @@ -0,0 +1,253 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using Flurl.Http; +using Kavita.API.Services; +using Kavita.API.Services.ReadingLists; +using Kavita.Common; +using Kavita.Common.Extensions; +using Kavita.Common.Helpers; +using Kavita.Models.DTOs.Misc; +using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Services.Extensions; +using Microsoft.Extensions.Logging; + + +namespace Kavita.Services.ReadingLists; + +internal sealed record GithubContentItem +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + [JsonPropertyName("sha")] + public string Sha { get; set; } = string.Empty; + [JsonPropertyName("size")] + public long Size { get; set; } + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + [JsonPropertyName("download_url")] + public string? DownloadUrl { get; set; } +} + +/// +/// File-backed cache structure. Each directory path maps to its cached listing + fetch timestamp. +/// +public sealed record CblRepoCache +{ + public Dictionary Directories { get; init; } = new(); +} + +public sealed record CachedDirectory +{ + public DateTime FetchedAtUtc { get; init; } + public List Items { get; init; } = []; +} + + +public class CblGithubService : ICblGithubService +{ + private const string RepoOwner = "DieselTech"; + private const string RepoName = "CBL-ReadingLists"; + private const string ApiBase = $"https://api.github.com/repos/{RepoOwner}/{RepoName}/contents"; + private const string CblExtension = ".cbl"; + private const string Cblv2Extension = ".json"; + private const string CacheFileName = "cbl-repo-cache.json"; + private static readonly TimeSpan CacheTtl = TimeSpan.FromHours(4); + + private readonly IDirectoryService _directoryService; + private readonly ILogger _logger; + + public CblGithubService(IDirectoryService directoryService, ILogger logger) + { + _directoryService = directoryService; + _logger = logger; + + FlurlConfiguration.ConfigureClientForUrl(ApiBase); + } + + public async Task BrowseRepo(string path = "", bool forceRefresh = false) + { + var normalizedPath = NormalizePath(path); + var cache = forceRefresh ? new CblRepoCache() : LoadCache(); + + if (!forceRefresh && cache.Directories.TryGetValue(normalizedPath, out var cached)) + { + if (DateTime.UtcNow - cached.FetchedAtUtc < CacheTtl) + { + _logger.LogDebug("Cache hit for CBL repo path: {Path}", normalizedPath.Sanitize()); + return new CblRepoBrowseResultDto + { + Items = cached.Items, + FromCache = true + }; + } + + _logger.LogDebug("Cache expired for CBL repo path: {Path}", normalizedPath.Sanitize()); + } + + var (items, rateLimit) = await FetchDirectoryFromGithub(normalizedPath); + + cache.Directories[normalizedPath] = new CachedDirectory + { + FetchedAtUtc = DateTime.UtcNow, + Items = items + }; + + SaveCache(cache); + + return new CblRepoBrowseResultDto + { + Items = items, + RateLimitDto = rateLimit, + FromCache = false + }; + } + + + public void InvalidateCache() + { + var cachePath = GetCacheFilePath(); + if (_directoryService.FileSystem.File.Exists(cachePath)) + { + _directoryService.FileSystem.File.Delete(cachePath); + } + + _logger.LogInformation("CBL repo cache invalidated"); + } + + public async Task GetFileContent(string filePath) + { + var normalizedPath = NormalizePath(filePath); + + var item = await BuildApiUrl(normalizedPath) + .WithGithubHeaders() + .GetJsonAsync(); + + if (string.IsNullOrEmpty(item.DownloadUrl)) + { + throw new KavitaException($"No download URL available for {filePath}"); + } + + return await item.DownloadUrl + .WithGithubHeaders() + .GetStringAsync(); + } + + private async Task<(List Items, GithubRateLimitDto RateLimit)> FetchDirectoryFromGithub(string path) + { + _logger.LogDebug("Fetching CBL repo directory from GitHub: {Path}", path.Sanitize()); + + try + { + var response = await BuildApiUrl(path) + .WithGithubHeaders() + .GetAsync(); + + var rateLimit = response.GetRateLimit(); + + if (rateLimit is {IsLow: true, IsExhausted: false}) + { + _logger.LogWarning( + "GitHub API rate limit is low: {Remaining}/{Limit}, resets at {ResetsAt}", + rateLimit.Remaining, rateLimit.Limit, rateLimit.ResetsAtUtc); + } + + var items = await response.GetJsonAsync>(); + + var result = items + .Where(i => !i.Name.StartsWith('.')) // Insure .github or other meta files/directories are excluded + .Where(i => i.Type == "dir" || i.Name.EndsWith(CblExtension, StringComparison.OrdinalIgnoreCase) || i.Name.EndsWith(Cblv2Extension, StringComparison.OrdinalIgnoreCase)) + .Select(i => new CblRepoItemDto + { + Name = i.Name, + Path = i.Path, + IsDirectory = i.Type == "dir", + Sha = i.Sha, + Size = i.Size, + DownloadUrl = i.DownloadUrl + }) + .OrderBy(i => !i.IsDirectory) + .ThenBy(i => i.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + return (result, rateLimit); + } + catch (FlurlHttpException ex) when (ex.StatusCode == 403) + { + var rateLimit = ex.Call?.Response != null + ? ex.Call.Response.GetRateLimit() + : new GithubRateLimitDto(); + + if (rateLimit.IsExhausted) + { + var resetsIn = rateLimit.ResetsAtUtc.HasValue + ? $" Resets at {rateLimit.ResetsAtUtc.Value:HH:mm} UTC." + : string.Empty; + + _logger.LogWarning("GitHub API rate limit exhausted.{ResetsIn}", resetsIn); + throw new KavitaException( + $"GitHub API rate limit exhausted.{resetsIn} Cached data may still be available."); + } + + _logger.LogWarning(ex, "GitHub API returned 403 for CBL repo"); + throw new KavitaException("GitHub API access denied. Please try again later."); + } + catch (FlurlHttpException ex) when (ex.StatusCode == 404) + { + _logger.LogWarning("CBL repo path not found: {Path}", path.Sanitize()); + throw new KavitaException($"Path not found in CBL repository: {path}"); + } + } + + private CblRepoCache LoadCache() + { + var cachePath = GetCacheFilePath(); + if (!File.Exists(cachePath)) return new CblRepoCache(); + + try + { + var json = File.ReadAllText(cachePath); + return JsonSerializer.Deserialize(json) ?? new CblRepoCache(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to read CBL repo cache, starting fresh"); + return new CblRepoCache(); + } + } + + private void SaveCache(CblRepoCache cache) + { + try + { + var cachePath = GetCacheFilePath(); + var json = JsonSerializer.Serialize(cache, new JsonSerializerOptions { WriteIndented = false }); + File.WriteAllText(cachePath, json); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to write CBL repo cache"); + } + } + + private string GetCacheFilePath() + { + return Path.Combine(_directoryService.LongTermCacheDirectory, CacheFileName); + } + + private static string BuildApiUrl(string path) + { + return string.IsNullOrEmpty(path) ? ApiBase : $"{ApiBase}/{path}"; + } + + private static string NormalizePath(string path) + { + return path.Trim('/').Trim(); + } +} diff --git a/Kavita.Services/ReadingLists/CblImportService.cs b/Kavita.Services/ReadingLists/CblImportService.cs new file mode 100644 index 000000000..59ff3a846 --- /dev/null +++ b/Kavita.Services/ReadingLists/CblImportService.cs @@ -0,0 +1,393 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Kavita.API.Database; +using Kavita.API.Repositories; +using Kavita.API.Services; +using Kavita.API.Services.ReadingLists; +using Kavita.Common.Extensions; +using Kavita.Models.Builders; +using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Import; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; +using Kavita.Models.Entities.ReadingLists; +using Kavita.Services.Helpers; +using Microsoft.Extensions.Logging; + +namespace Kavita.Services.ReadingLists; + +public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithubService, + IDirectoryService directoryService, ILogger logger) : ICblImportService +{ + public async Task ValidateList(int userId, string filePath, CblImportOptions options) + { + ParsedCblReadingList cbl; + try + { + cbl = CblParser.Parse(filePath); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse CBL file: {FilePath}", filePath.Sanitize()); + return new CblImportSummaryDto + { + CblName = string.Empty, + FileName = Path.GetFileName(filePath), + Success = CblImportResult.Fail, + Results = [new CblBookResult { Reason = CblImportReason.InvalidFile }], + SuccessfulInserts = [] + }; + } + + if (cbl.Items.Count == 0) + { + return new CblImportSummaryDto + { + CblName = cbl.Name, + FileName = Path.GetFileName(filePath), + Success = CblImportResult.Fail, + Results = [new CblBookResult { Reason = CblImportReason.EmptyFile }], + SuccessfulInserts = [] + }; + } + + var matchResults = await RunMatchingPipeline(userId, cbl, options); + var summary = BuildSummary(cbl, filePath, matchResults); + + var existingList = await unitOfWork.ReadingListRepository + .GetReadingListByTitleAsync(cbl.Name, userId); + summary.IsUpdate = existingList != null; + + return summary; + } + + public async Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions) + { + ParsedCblReadingList cbl; + try + { + cbl = CblParser.Parse(filePath); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to parse CBL file: {FilePath}", filePath.Sanitize()); + return new CblImportSummaryDto + { + CblName = string.Empty, + FileName = Path.GetFileName(filePath), + Success = CblImportResult.Fail, + Results = [new CblBookResult { Reason = CblImportReason.InvalidFile }], + SuccessfulInserts = [] + }; + } + + if (cbl.Items.Count == 0) + { + return new CblImportSummaryDto + { + CblName = cbl.Name, + FileName = Path.GetFileName(filePath), + Success = CblImportResult.Fail, + Results = [new CblBookResult { Reason = CblImportReason.EmptyFile }], + SuccessfulInserts = [] + }; + } + + var matchResults = await RunMatchingPipeline(userId, cbl, options); + + // Override with user decisions + foreach (var (order, decision) in decisions.ItemResolutions) + { + if (matchResults.ContainsKey(order)) + { + var item = cbl.Items.FirstOrDefault(i => i.Order == order); + if (item != null) + { + matchResults[order] = ( + new MatchedItem(decision.SeriesId, decision.VolumeId, decision.ChapterId, CblMatchTier.UserDecision), + new CblBookResult(item) + { + Reason = CblImportReason.Success, + MatchTier = CblMatchTier.UserDecision, + SeriesId = decision.SeriesId, + ChapterId = decision.ChapterId + } + ); + } + } + } + + // Find or create reading list + var readingList = await unitOfWork.ReadingListRepository + .GetReadingListByTitleAsync(cbl.Name, userId); + var isUpdate = readingList != null; + + if (readingList == null) + { + readingList = new ReadingListBuilder(cbl.Name) + .WithSummary(cbl.Summary ?? string.Empty) + .WithAppUserId(userId) + .Build(); + + unitOfWork.ReadingListRepository.Add(readingList); + } + + // Set metadata from CBL + if (!string.IsNullOrEmpty(cbl.Summary)) + readingList.Summary = cbl.Summary; + if (cbl.StartYear > 0) + readingList.StartingYear = cbl.StartYear; + if (cbl.StartMonth > 0) + readingList.StartingMonth = cbl.StartMonth; + if (cbl.EndYear > 0) + readingList.EndingYear = cbl.EndYear; + if (cbl.EndMonth > 0) + readingList.EndingMonth = cbl.EndMonth; + + // Add resolved items + foreach (var (order, (match, _)) in matchResults.OrderBy(kv => kv.Key)) + { + if (match == null) continue; + ExistsOrAddReadingListItem(readingList, match.SeriesId, match.VolumeId, match.ChapterId, order); + } + + // Save remap rules from user decisions + if (decisions.SaveAsRemapRules && decisions.ItemResolutions.Count > 0) + { + foreach (var (order, decision) in decisions.ItemResolutions) + { + var item = cbl.Items.FirstOrDefault(i => i.Order == order); + if (item == null) continue; + + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(decision.SeriesId); + + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = item.SeriesName.ToNormalized(), + CblVolume = !string.IsNullOrEmpty(item.Volume) ? item.Volume : null, + CblNumber = !string.IsNullOrEmpty(item.Number) ? item.Number : null, + SeriesId = decision.SeriesId, + VolumeId = decision.VolumeId > 0 ? decision.VolumeId : null, + ChapterId = decision.ChapterId > 0 ? decision.ChapterId : null, + SeriesNameAtMapping = series?.Name ?? string.Empty, + AppUserId = userId, + IsGlobal = false, + CreatedUtc = DateTime.UtcNow + }); + } + } + + await unitOfWork.CommitAsync(); + + var summary = BuildSummary(cbl, filePath, matchResults); + summary.IsUpdate = isUpdate; + return summary; + } + + public async Task SyncReadingList(int userId, int readingListId) + { + var readingList = await unitOfWork.ReadingListRepository + .GetReadingListByIdAsync(readingListId, ReadingListIncludes.Items); + + if (readingList is not {CanSync: true} || readingList.AppUserId != userId) + { + logger.LogWarning("Cannot sync reading list {ReadingListId} — not found, not syncable, or wrong user", readingListId); + return; + } + + // Re-download from GitHub + string content; + try + { + content = await cblGithubService.GetFileContent(readingList.SourcePath!); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to download CBL content for sync: {SourcePath}", readingList.SourcePath); + readingList.LastSyncCheckUtc = DateTime.UtcNow; + await unitOfWork.CommitAsync(); + return; + } + + // Save to temp file for parsing + var tempDir = Path.Join(directoryService.TempDirectory, $"{userId}", "cbl-sync"); + Directory.CreateDirectory(tempDir); + var tempFile = Path.Join(tempDir, $"sync-{readingListId}{GetExtension(readingList.SourcePath!)}"); + await File.WriteAllTextAsync(tempFile, content); + + try + { + var cbl = CblParser.Parse(tempFile); + if (cbl.Items.Count == 0) return; + + var options = new CblImportOptions(); + var matchResults = await RunMatchingPipeline(userId, cbl, options); + + // Clear existing items and re-add + readingList.Items.Clear(); + + foreach (var (order, (match, _)) in matchResults.OrderBy(kv => kv.Key)) + { + if (match == null) continue; + ExistsOrAddReadingListItem(readingList, match.SeriesId, match.VolumeId, match.ChapterId, order); + } + + // Update metadata + if (!string.IsNullOrEmpty(cbl.Summary)) + readingList.Summary = cbl.Summary; + if (cbl.StartYear > 0) + readingList.StartingYear = cbl.StartYear; + if (cbl.EndYear > 0) + readingList.EndingYear = cbl.EndYear; + + readingList.LastSyncedUtc = DateTime.UtcNow; + readingList.LastSyncCheckUtc = DateTime.UtcNow; + + await unitOfWork.CommitAsync(); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed to sync reading list {ReadingListId} from {SourcePath}", readingListId, readingList.SourcePath); + } + finally + { + try { File.Delete(tempFile); } catch { /* best effort cleanup */ } + } + } + + private async Task> RunMatchingPipeline( + int userId, ParsedCblReadingList cbl, CblImportOptions options) + { + // Collect all unique normalized names + variants + var nameVariants = CblSeriesMatcher.GenerateAllNameVariants(cbl.Items); + var allNormalizedNames = nameVariants.Keys.ToList(); + + // Also include direct normalized names for remap rule lookup + var directNormalizedNames = cbl.Items + .Select(i => i.SeriesName.ToNormalized()) + .Distinct() + .ToList(); + + foreach (var n in directNormalizedNames) + { + if (!allNormalizedNames.Contains(n)) allNormalizedNames.Add(n); + } + + // Collect external IDs + var comicVineIds = cbl.Items + .SelectMany(i => i.ExternalIds) + .Where(e => e.Provider == CblExternalDbProvider.ComicVine && !string.IsNullOrEmpty(e.IssueId)) + .Select(e => e.IssueId) + .Distinct() + .ToList(); + + var metronIds = cbl.Items + .SelectMany(i => i.ExternalIds) + .Where(e => e.Provider == CblExternalDbProvider.Metron && long.TryParse(e.IssueId, out _)) + .Select(e => long.Parse(e.IssueId)) + .Distinct() + .ToList(); + + // Get user's accessible library IDs + var userLibraryIds = await unitOfWork.LibraryRepository.GetLibraryIdsForUserIdAsync(userId); + + // TODO: Figure out if I want to keep this in final design + if (options.ApplicableLibraries is { Count: > 0 }) + { + userLibraryIds = userLibraryIds.Where(options.ApplicableLibraries.Contains).ToList(); + } + + // Batch DB queries + var remapRules = await unitOfWork.RemapRuleRepository + .GetRulesForNamesAsync(directNormalizedNames, userId); + + var externalIdChapters = await unitOfWork.ChapterRepository + .GetChaptersByExternalIdsAsync(comicVineIds, metronIds, userLibraryIds); + + var matchedSeries = (await unitOfWork.SeriesRepository + .GetAllSeriesByNameAsync(allNormalizedNames, userId, options.ApplicableLibraries, + SeriesIncludes.Chapters | SeriesIncludes.Metadata)).ToList(); + + // Also fetch series referenced by remap rules that weren't caught by name matching + var remapSeriesIds = remapRules + .Where(r => !r.ChapterId.HasValue || !r.VolumeId.HasValue) + .Select(r => r.SeriesId) + .Where(id => matchedSeries.All(s => s.Id != id)) + .Distinct() + .ToList(); + + if (remapSeriesIds.Count > 0) + { + var remapSeries = await unitOfWork.SeriesRepository + .GetSeriesByIdsAsync(remapSeriesIds); + matchedSeries.AddRange(remapSeries); + } + + // We'll run AlternateSeries for all names, the matcher will only use it as fallback + var alternateSeriesChapters = await unitOfWork.ChapterRepository + .GetChaptersByAlternateSeriesAsync(directNormalizedNames, userLibraryIds); + + return CblSeriesMatcher.ResolveAll(cbl.Items, remapRules, externalIdChapters, + matchedSeries, alternateSeriesChapters, options); + } + + private static CblImportSummaryDto BuildSummary(ParsedCblReadingList cbl, string filePath, + Dictionary matchResults) + { + var results = new List(); + var successfulInserts = new List(); + + foreach (var (_, (match, result)) in matchResults.OrderBy(kv => kv.Key)) + { + if (match != null && result.Reason == CblImportReason.Success) + { + successfulInserts.Add(result); + } + else + { + results.Add(result); + } + } + + var success = CblImportResult.Success; + if (successfulInserts.Count == 0 && results.Count > 0) + { + success = CblImportResult.Fail; + } + else if (results.Count > 0) + { + success = CblImportResult.Partial; + } + + return new CblImportSummaryDto + { + CblName = cbl.Name, + FileName = Path.GetFileName(filePath), + Success = success, + Results = results, + SuccessfulInserts = successfulInserts + }; + } + + private static void ExistsOrAddReadingListItem(ReadingList readingList, int seriesId, int volumeId, int chapterId, int order) + { + var existing = readingList.Items.FirstOrDefault(item => + item.SeriesId == seriesId && item.ChapterId == chapterId); + if (existing != null) + { + existing.Order = order; + return; + } + + var newItem = new ReadingListItemBuilder(order, seriesId, volumeId, chapterId).Build(); + readingList.Items.Add(newItem); + } + + private static string GetExtension(string path) + { + var ext = Path.GetExtension(path); + return string.IsNullOrEmpty(ext) ? ".cbl" : ext; + } +} diff --git a/Kavita.Services/ReadingLists/CblImporterService.cs b/Kavita.Services/ReadingLists/CblImporterService.cs deleted file mode 100644 index 0f8e5868c..000000000 --- a/Kavita.Services/ReadingLists/CblImporterService.cs +++ /dev/null @@ -1,24 +0,0 @@ -using System.Threading.Tasks; -using Kavita.API.Services.ReadingLists; -using Kavita.Models.DTOs.ReadingLists.CBL; - -namespace Kavita.Services.ReadingLists; - -public class CblImporterService : ICblImportService -{ - public Task ValidateList(int userId, string filePath, CblImportOptions options) - { - - throw new System.NotImplementedException(); - } - - public Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions) - { - throw new System.NotImplementedException(); - } - - public Task SyncReadingList(int userId, int readingListId) - { - throw new System.NotImplementedException(); - } -} diff --git a/Kavita.Services/ReadingLists/CblSeriesMatcher.cs b/Kavita.Services/ReadingLists/CblSeriesMatcher.cs new file mode 100644 index 000000000..2fe6c94b2 --- /dev/null +++ b/Kavita.Services/ReadingLists/CblSeriesMatcher.cs @@ -0,0 +1,614 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using Kavita.Common.Extensions; +using Kavita.Models.DTOs.ReadingLists.CBL; +using Kavita.Models.DTOs.ReadingLists.CBL.Import; +using Kavita.Models.DTOs.ReadingLists.CBL.Internal; +using Kavita.Models.Entities; +using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; +using Kavita.Services.Extensions; +using Kavita.Services.Helpers; +using Kavita.Services.Scanner; + +namespace Kavita.Services.ReadingLists; + +/// +/// Result of matching a single CBL item to Kavita entities +/// +internal sealed record MatchedItem(int SeriesId, int VolumeId, int ChapterId, CblMatchTier SeriesTier); + +/// +/// Pure matching logic — takes pre-fetched data, returns per-item resolutions. No DB access. +/// +internal static class CblSeriesMatcher +{ + private static readonly string[] ReprintSuffixes = + [ + "director's cut", "directors cut", "deluxe edition", "deluxe", + "omnibus edition", "omnibus", "tpb", "trade paperback", + "hc", "hardcover", "complete edition", "absolute", + "new edition", "revised edition", "anniversary edition", + "collected edition", "compendium", "gallery edition", + "artist's edition", "artists edition" + ]; + + /// + /// Generates all normalized name variants for a set of CBL items, mapping each variant + /// back to the original series name and which tier generated it. + /// + public static Dictionary GenerateAllNameVariants(IList items) + { + var variants = new Dictionary(); + var uniqueItems = items.DistinctBy(i => i.SeriesName).ToList(); + + foreach (var item in uniqueItems) + { + var name = item.SeriesName; + var volumeNumber = item.Volume; + + // Tier 2: Exact normalized + AddVariants(variants, name, CblMatchTier.ExactName, name); + + // Tier 3: Comic Vine handling: Series (Volume) + var comicVineTitle = GetComicNamingPattern(name, volumeNumber); + if (!string.IsNullOrEmpty(volumeNumber) && !string.Equals(comicVineTitle, name, StringComparison.OrdinalIgnoreCase)) + { + AddVariants(variants, comicVineTitle, CblMatchTier.ComicVineNaming, name); + } + + // Tier 4: Article stripped + var sortTitle = BookSortTitlePrefixHelper.GetSortTitle(name); + if (!string.Equals(sortTitle, name, StringComparison.OrdinalIgnoreCase)) + { + AddVariants(variants, sortTitle, CblMatchTier.ArticleStripped, name); + } + + // Tier 5: Reprint stripped + var stripped = StripReprintSuffix(name); + if (!string.Equals(stripped, name, StringComparison.OrdinalIgnoreCase)) + { + AddVariants(variants, stripped, CblMatchTier.ReprintStripped, name); + } + } + + return variants; + } + + /// + /// Main matching entry point. Resolves all CBL items against pre-fetched data. + /// + public static Dictionary ResolveAll( + IList items, + IList remapRules, + IList externalIdChapters, + IList matchedSeries, + IList alternateSeriesChapters, + CblImportOptions options) + { + var results = new Dictionary(); + + // Build lookup structures + var rulesByName = remapRules + .GroupBy(r => r.NormalizedCblSeriesName) + .ToDictionary(g => g.Key, g => g.ToList()); + + var externalIdByComicVine = externalIdChapters + .Where(c => !string.IsNullOrEmpty(c.ComicVineId)) + .GroupBy(c => c.ComicVineId!) + .ToDictionary(g => g.Key, g => g.ToList()); + + var externalIdByMetron = externalIdChapters + .Where(c => c.MetronId > 0) + .GroupBy(c => c.MetronId) + .ToDictionary(g => g.Key, g => g.ToList()); + + var nameVariants = GenerateAllNameVariants(items); + + // Build series lookup: normalized name -> list of series + var seriesByNormalizedName = new Dictionary>(); + foreach (var series in matchedSeries) + { + AddToLookup(seriesByNormalizedName, series.NormalizedName, series); + if (!string.IsNullOrEmpty(series.NormalizedLocalizedName) && + series.NormalizedLocalizedName != series.NormalizedName) + { + AddToLookup(seriesByNormalizedName, series.NormalizedLocalizedName, series); + } + } + + var altSeriesByNormName = alternateSeriesChapters + .GroupBy(c => c.AlternateSeries.ToNormalized()) + .ToDictionary(g => g.Key, g => g.ToList()); + + foreach (var item in items) + { + var normalizedName = item.SeriesName.ToNormalized(); + + // Tier 0: Remap rules + if (TryMatchByRemapRule(item, normalizedName, rulesByName, matchedSeries, out var remapResult)) + { + results[item.Order] = remapResult!.Value; + continue; + } + + // Tier 1: External IDs + if (TryMatchByExternalId(item, externalIdByComicVine, externalIdByMetron, out var extMatch, out var extChapter)) + { + results[item.Order] = (extMatch, new CblBookResult(item) + { + Reason = CblImportReason.Success, + MatchTier = CblMatchTier.ExternalId, + SeriesId = extMatch.SeriesId, + LibraryId = extChapter.Volume.Series?.LibraryId ?? 0, + ChapterId = extChapter.Id, + ChapterTitle = !string.IsNullOrEmpty(extChapter.TitleName) ? extChapter.TitleName : extChapter.Range, + ChapterNumber = extChapter.Range, + MatchedSeriesName = extChapter.Volume.Series?.Name ?? string.Empty, + LibraryType = extChapter.Volume.Series?.Library?.Type ?? LibraryType.Comic + }); + continue; + } + + // Tiers 2-4: Name matching + if (TryMatchByName(item, nameVariants, seriesByNormalizedName, options, out var seriesMatch, out var tier)) + { + // Series resolved, now resolve chapter + results[item.Order] = ResolveChapter(item, seriesMatch, tier); + continue; + } + + // Tier 5: AlternateSeries + if (TryMatchByAlternateSeries(item, normalizedName, altSeriesByNormName, out var altMatch, out var altChapter)) + { + results[item.Order] = (altMatch, new CblBookResult(item) + { + Reason = CblImportReason.Success, + MatchTier = CblMatchTier.AlternateSeries, + SeriesId = altMatch.SeriesId, + LibraryId = altChapter.Volume.Series?.LibraryId ?? 0, + ChapterId = altChapter.Id, + ChapterTitle = !string.IsNullOrEmpty(altChapter.TitleName) ? altChapter.TitleName : altChapter.Range, + ChapterNumber = altChapter.Range, + MatchedSeriesName = altChapter.Volume.Series?.Name ?? string.Empty, + LibraryType = altChapter.Volume.Series?.Library?.Type ?? LibraryType.Comic + }); + continue; + } + + // Tier 6: Unmatched + results[item.Order] = (null, new CblBookResult(item) { Reason = CblImportReason.SeriesMissing, MatchTier = CblMatchTier.Unmatched }); + } + + return results; + } + + private static bool TryMatchByRemapRule(ParsedCblItem item, string normalizedName, + Dictionary> rulesByName, + IList matchedSeries, + out (MatchedItem? Match, CblBookResult Result)? resolvedResult) + { + resolvedResult = null; + if (!rulesByName.TryGetValue(normalizedName, out var rules)) return false; + + // Try most specific first (volume + number), then less specific + var rule = rules.FirstOrDefault(r => + !string.IsNullOrEmpty(r.CblVolume) && r.CblVolume == item.Volume && + !string.IsNullOrEmpty(r.CblNumber) && r.CblNumber == item.Number) + ?? rules.FirstOrDefault(r => + !string.IsNullOrEmpty(r.CblNumber) && r.CblNumber == item.Number && + string.IsNullOrEmpty(r.CblVolume)) + ?? rules.FirstOrDefault(r => + string.IsNullOrEmpty(r.CblVolume) && string.IsNullOrEmpty(r.CblNumber)); + + if (rule == null) return false; + + if (rule is {ChapterId: not null, VolumeId: not null}) + { + var chapterTitle = string.Empty; + var chapterNumber = string.Empty; + var libraryId = 0; + var libraryType = LibraryType.Comic; + var ruleSeries = matchedSeries.FirstOrDefault(s => s.Id == rule.SeriesId); + if (ruleSeries != null) + { + libraryId = ruleSeries.LibraryId; + libraryType = ruleSeries.Library?.Type ?? LibraryType.Comic; + var ch = ruleSeries.Volumes? + .SelectMany(v => v.Chapters ?? []) + .FirstOrDefault(c => c.Id == rule.ChapterId.Value); + if (ch != null) + { + chapterTitle = !string.IsNullOrEmpty(ch.TitleName) ? ch.TitleName : ch.Range; + chapterNumber = ch.Range; + } + } + + resolvedResult = ( + new MatchedItem(rule.SeriesId, rule.VolumeId.Value, rule.ChapterId.Value, CblMatchTier.RemapRule), + new CblBookResult(item) + { + Reason = CblImportReason.Success, + MatchTier = CblMatchTier.RemapRule, + SeriesId = rule.SeriesId, + LibraryId = libraryId, + ChapterId = rule.ChapterId.Value, + ChapterTitle = chapterTitle, + ChapterNumber = chapterNumber, + MatchedSeriesName = ruleSeries?.Name ?? string.Empty, + LibraryType = libraryType + } + ); + return true; + } + + // Rule only mapped to series — resolve chapter within the mapped series + var series = matchedSeries.FirstOrDefault(s => s.Id == rule.SeriesId); + if (series != null) + { + resolvedResult = ResolveChapter(item, series, CblMatchTier.RemapRule); + return true; + } + + // Series from the rule wasn't in our pre-fetched data — report as series matched but chapter unresolved + resolvedResult = (null, new CblBookResult(item) + { + Reason = CblImportReason.ChapterMissing, + MatchTier = CblMatchTier.RemapRule, + SeriesId = rule.SeriesId, + MatchedSeriesName = rule.SeriesNameAtMapping ?? string.Empty + }); + return true; + } + + private static bool TryMatchByExternalId(ParsedCblItem item, + Dictionary> byComicVine, + Dictionary> byMetron, + out MatchedItem match, out Chapter matchedChapter) + { + foreach (var extId in item.ExternalIds) + { + if (extId.Provider == CblExternalDbProvider.ComicVine && !string.IsNullOrEmpty(extId.IssueId)) + { + if (byComicVine.TryGetValue(extId.IssueId, out var chapters) && chapters.Count > 0) + { + var ch = chapters[0]; + match = new MatchedItem(ch.Volume.SeriesId, ch.VolumeId, ch.Id, CblMatchTier.ExternalId); + matchedChapter = ch; + return true; + } + } + + if (extId.Provider == CblExternalDbProvider.Metron && long.TryParse(extId.IssueId, out var metronId) && metronId > 0) + { + if (byMetron.TryGetValue(metronId, out var chapters) && chapters.Count > 0) + { + var ch = chapters[0]; + match = new MatchedItem(ch.Volume.SeriesId, ch.VolumeId, ch.Id, CblMatchTier.ExternalId); + matchedChapter = ch; + return true; + } + } + } + + match = null!; + matchedChapter = null!; + return false; + } + + private static bool TryMatchByName(ParsedCblItem item, + Dictionary nameVariants, + Dictionary> seriesByNormalizedName, + CblImportOptions options, + out Series series, out CblMatchTier tier) + { + // Try each tier in order + foreach (var candidateTier in new[] { CblMatchTier.ExactName, CblMatchTier.ComicVineNaming, CblMatchTier.ArticleStripped, CblMatchTier.ReprintStripped }) + { + var variantsForTier = nameVariants + .Where(kv => kv.Value.Tier == candidateTier && + string.Equals(kv.Value.OriginalName, item.SeriesName, StringComparison.OrdinalIgnoreCase)) + .Select(kv => kv.Key) + .ToList(); + + foreach (var variant in variantsForTier) + { + if (!seriesByNormalizedName.TryGetValue(variant, out var candidates) || candidates.Count == 0) + continue; + + tier = candidateTier; + + if (candidates.Count == 1) + { + series = candidates[0]; + return true; + } + + // Disambiguate + var disambiguated = DisambiguateSeries(candidates, item, options); + if (disambiguated != null) + { + series = disambiguated; + return true; + } + + // Still ambiguous - take first, collision handled by caller through chapter resolution + series = candidates[0]; + return true; + } + } + + series = null!; + tier = CblMatchTier.Unmatched; + return false; + } + + private static Series? DisambiguateSeries(List candidates, ParsedCblItem item, CblImportOptions options) + { + var filtered = candidates; + + // Filter by applicable libraries + if (options.ApplicableLibraries is { Count: > 0 }) + { + var libFiltered = filtered.Where(s => options.ApplicableLibraries.Contains(s.LibraryId)).ToList(); + if (libFiltered.Count > 0) filtered = libFiltered; + } + + if (filtered.Count == 1) return filtered[0]; + + // Prefer Comic library type if PreferComicVineMatching + if (options.PreferComicVineMatching) + { + var comicFiltered = filtered.Where(s => s.Library != null && + s.Library.Type is LibraryType.Comic or LibraryType.ComicVine).ToList(); + if (comicFiltered.Count > 0) filtered = comicFiltered; + } + + if (filtered.Count == 1) return filtered[0]; + + // Match by year if available + if (int.TryParse(item.Year, out var year) && year > 0) + { + var yearFiltered = filtered.Where(s => + s.Metadata != null && s.Metadata.ReleaseYear == year).ToList(); + if (yearFiltered.Count == 1) return yearFiltered[0]; + } + + // Still ambiguous + return filtered.Count == 1 ? filtered[0] : null; + } + + private static (MatchedItem? Match, CblBookResult Result) ResolveChapter(ParsedCblItem item, Series series, CblMatchTier tier) + { + var seriesLibraryType = series.Library?.Type ?? LibraryType.Comic; + + var volumes = series.Volumes; + if (volumes == null || volumes.Count == 0) + { + return (null, new CblBookResult(item) + { + Reason = CblImportReason.VolumeMissing, + MatchTier = tier, + SeriesId = series.Id, + LibraryId = series.LibraryId, + MatchedSeriesName = series.Name, + LibraryType = seriesLibraryType + }); + } + + // Find the target volume + Volume? targetVolume = null; + var volumeWasRequested = !string.IsNullOrEmpty(item.Volume); + + if (volumeWasRequested) + { + // Try to find by volume name/number + if (float.TryParse(item.Volume, NumberStyles.Any, CultureInfo.InvariantCulture, out var volNum)) + { + targetVolume = volumes.FirstOrDefault(v => + v.MinNumber <= volNum && v.MaxNumber >= volNum && !v.MinNumber.Is(Parser.SpecialVolumeNumber)); + } + + targetVolume ??= volumes.FirstOrDefault(v => + string.Equals(v.Name, item.Volume, StringComparison.OrdinalIgnoreCase)); + + // Volume was explicitly requested but not found — report as VolumeMissing + if (targetVolume == null) + { + return (null, new CblBookResult(item) + { + Reason = CblImportReason.VolumeMissing, + MatchTier = tier, + SeriesId = series.Id, + LibraryId = series.LibraryId, + MatchedSeriesName = series.Name, + LibraryType = seriesLibraryType + }); + } + } + else + { + // No volume specified — use loose leaf + targetVolume = volumes.GetLooseLeafVolumeOrDefault(); + } + + var fallbackVolume = volumes.GetSpecialVolumeOrDefault(); + + // Try to find chapter + Chapter? chapter = null; + + if (!string.IsNullOrEmpty(item.Number)) + { + // Exact range match in target volume + if (targetVolume?.Chapters != null) + { + chapter = targetVolume.Chapters.FirstOrDefault(c => + string.Equals(c.Range, item.Number, StringComparison.OrdinalIgnoreCase)); + + // Numeric match + if (chapter == null && float.TryParse(item.Number, NumberStyles.Any, CultureInfo.InvariantCulture, out var chNum)) + { + chapter = targetVolume.Chapters.FirstOrDefault(c => + c.MinNumber <= chNum && c.MaxNumber >= chNum); + } + } + + // Try fallback volume (specials) — only when no specific volume was requested + if (chapter == null && !volumeWasRequested && fallbackVolume?.Chapters != null && fallbackVolume != targetVolume) + { + chapter = fallbackVolume.Chapters.FirstOrDefault(c => + string.Equals(c.Range, item.Number, StringComparison.OrdinalIgnoreCase)); + + if (chapter == null && float.TryParse(item.Number, NumberStyles.Any, CultureInfo.InvariantCulture, out var chNum2)) + { + chapter = fallbackVolume.Chapters.FirstOrDefault(c => + c.MinNumber <= chNum2 && c.MaxNumber >= chNum2); + } + + if (chapter != null) targetVolume = fallbackVolume; + } + + // Search across all volumes as last resort — only when no specific volume was requested + if (chapter == null && !volumeWasRequested) + { + foreach (var vol in volumes.Where(v => v != targetVolume && v != fallbackVolume)) + { + if (vol.Chapters == null) continue; + chapter = vol.Chapters.FirstOrDefault(c => + string.Equals(c.Range, item.Number, StringComparison.OrdinalIgnoreCase)); + + if (chapter == null && float.TryParse(item.Number, NumberStyles.Any, CultureInfo.InvariantCulture, out var chNum3)) + { + chapter = vol.Chapters.FirstOrDefault(c => + c.MinNumber <= chNum3 && c.MaxNumber >= chNum3); + } + + if (chapter != null) + { + targetVolume = vol; + break; + } + } + } + } + else + { + // No issue number — default chapter in the volume + if (targetVolume?.Chapters is { Count: > 0 }) + { + chapter = targetVolume.Chapters.OrderBy(c => c.SortOrder).First(); + } + } + + if (chapter == null) + { + return (null, new CblBookResult(item) + { + Reason = CblImportReason.ChapterMissing, + MatchTier = tier, + SeriesId = series.Id, + LibraryId = series.LibraryId, + MatchedSeriesName = series.Name, + LibraryType = seriesLibraryType + }); + } + + return ( + new MatchedItem(series.Id, targetVolume!.Id, chapter.Id, tier), + new CblBookResult(item) + { + Reason = CblImportReason.Success, + MatchTier = tier, + SeriesId = series.Id, + LibraryId = series.LibraryId, + ChapterId = chapter.Id, + ChapterTitle = !string.IsNullOrEmpty(chapter.TitleName) ? chapter.TitleName : chapter.Range, + ChapterNumber = chapter.Range, + MatchedSeriesName = series.Name, + LibraryType = seriesLibraryType + } + ); + } + + private static bool TryMatchByAlternateSeries(ParsedCblItem item, string normalizedName, + Dictionary> altSeriesByNormName, out MatchedItem match, out Chapter matchedChapter) + { + match = null!; + matchedChapter = null!; + if (!altSeriesByNormName.TryGetValue(normalizedName, out var chapters) || chapters.Count == 0) return false; + + // Try to find matching chapter by number + if (!string.IsNullOrEmpty(item.Number)) + { + var found = chapters.FirstOrDefault(c => + string.Equals(c.Range, item.Number, StringComparison.OrdinalIgnoreCase)); + + if (found == null && float.TryParse(item.Number, NumberStyles.Any, CultureInfo.InvariantCulture, out var chNum)) + { + found = chapters.FirstOrDefault(c => c.MinNumber <= chNum && c.MaxNumber >= chNum); + } + + if (found != null) + { + match = new MatchedItem(found.Volume.SeriesId, found.VolumeId, found.Id, CblMatchTier.AlternateSeries); + matchedChapter = found; + return true; + } + } + + // Just take the first one if no number specified + if (string.IsNullOrEmpty(item.Number) && chapters.Count > 0) + { + var ch = chapters[0]; + match = new MatchedItem(ch.Volume.SeriesId, ch.VolumeId, ch.Id, CblMatchTier.AlternateSeries); + matchedChapter = ch; + return true; + } + + return false; + } + + private static void AddVariants(Dictionary variants, + string name, CblMatchTier tier, string originalName) + { + var normalized = name.ToNormalized(); + if (!string.IsNullOrEmpty(normalized)) + { + variants.TryAdd(normalized, (originalName, tier)); + } + } + + private static string GetComicNamingPattern(string name, string volumeName) + { + var trimmed = name.Trim(); + return $"{trimmed} ({volumeName})"; + } + + private static string StripReprintSuffix(string name) + { + var trimmed = name.Trim(); + foreach (var suffix in ReprintSuffixes) + { + if (trimmed.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + var stripped = trimmed[..^suffix.Length].TrimEnd(' ', '-', ':'); + if (!string.IsNullOrWhiteSpace(stripped)) + return stripped; + } + } + + return name; + } + + private static void AddToLookup(Dictionary> dict, TKey key, TValue value) where TKey : notnull + { + if (!dict.TryGetValue(key, out var list)) + { + list = []; + dict[key] = list; + } + list.Add(value); + } +} diff --git a/Kavita.Services/ReadingLists/ReadingListService.cs b/Kavita.Services/ReadingLists/ReadingListService.cs index a3f001d32..ec0042188 100644 --- a/Kavita.Services/ReadingLists/ReadingListService.cs +++ b/Kavita.Services/ReadingLists/ReadingListService.cs @@ -1,15 +1,12 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Text.RegularExpressions; using System.Threading.Tasks; -using System.Xml.Serialization; using Kavita.API.Database; using Kavita.API.Repositories; using Kavita.API.Services; -using Kavita.API.Services.Reading; using Kavita.API.Services.ReadingLists; using Kavita.API.Services.SignalR; using Kavita.Common; @@ -17,19 +14,18 @@ using Kavita.Common.Extensions; using Kavita.Common.Helpers; using Kavita.Models.Builders; using Kavita.Models.DTOs.ReadingLists; -using Kavita.Models.DTOs.ReadingLists.CBL; -using Kavita.Models.DTOs.ReadingLists.CBL.V1; using Kavita.Models.DTOs.SignalR; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.ReadingLists; using Kavita.Models.Entities.User; using Kavita.Models.Extensions; using Kavita.Models.Helpers; -using Kavita.Services.Extensions; +using Kavita.Services.Reading; using Kavita.Services.Scanner; using Microsoft.Extensions.Logging; -namespace Kavita.Services.Reading; +namespace Kavita.Services.ReadingLists; /// /// Methods responsible for management of Reading Lists @@ -44,74 +40,6 @@ public class ReadingListService( IEntityNamingService namingService) : IReadingListService { - private static readonly Regex JustNumbers = new Regex(@"^\d+$", RegexOptions.Compiled | RegexOptions.IgnoreCase, - Parser.RegexTimeout); - - public static string FormatTitle(ReadingListItemDto item) - { - var title = string.Empty; - if (Parser.IsDefaultChapter(item.ChapterNumber) && !Parser.IsLooseLeafVolume(item.VolumeNumber)) { - title = $"Volume {item.VolumeNumber}"; - } - - if (item.SeriesFormat == MangaFormat.Epub) { - var specialTitle = Parser.CleanSpecialTitle(item.ChapterNumber); - if (Parser.IsDefaultChapter(specialTitle)) - { - if (!string.IsNullOrEmpty(item.ChapterTitleName)) - { - title = item.ChapterTitleName; - } - else - { - title = $"Volume {Parser.CleanSpecialTitle(item.VolumeNumber)}"; - } - } - else if (item.VolumeNumber == Parser.SpecialVolume) - { - title = specialTitle; - } - else - { - title = $"Volume {specialTitle}"; - } - } - - var chapterNum = item.ChapterNumber; - if (!string.IsNullOrEmpty(chapterNum) && !JustNumbers.Match(item.ChapterNumber).Success) { - chapterNum = Parser.CleanSpecialTitle(item.ChapterNumber); - } - - if (title != string.Empty) return title; - - // item.ChapterNumber is Range - if (Parser.IsDefaultChapter(item.ChapterNumber) && - !string.IsNullOrEmpty(item.ChapterTitleName)) - { - title = item.ChapterTitleName; - } - else if (item.IsSpecial && - (!string.IsNullOrEmpty(item.ChapterTitleName) || !string.IsNullOrEmpty(chapterNum))) - { - if (!string.IsNullOrEmpty(item.ChapterTitleName)) - { - title = item.ChapterTitleName; - } - else - { - title = chapterNum; - } - - } - else - { - title = ReaderService.FormatChapterName(item.LibraryType, true, true) + chapterNum; - } - - return title; - } - - /// /// Creates a new Reading List for a User /// @@ -382,7 +310,7 @@ public class ReadingListService( { readingList.Items ??= new List(); var lastOrder = 0; - if (readingList.Items.Any()) + if (readingList.Items.Count != 0) { lastOrder = readingList.Items.DefaultIfEmpty().Max(rli => rli!.Order); } @@ -400,26 +328,11 @@ public class ReadingListService( index += 1; } - await CalculateReadingListAgeRating(readingList, new []{ seriesId }); + await CalculateReadingListAgeRating(readingList, [seriesId]); return index > lastOrder + 1; } - /// - /// Create Reading lists from a Series - /// - /// Execute this from Hangfire - /// - /// - public async Task CreateReadingListsFromSeries(int libraryId, int seriesId) - { - var series = await unitOfWork.SeriesRepository.GetFullSeriesForSeriesIdAsync(seriesId); - var library = await unitOfWork.LibraryRepository.GetLibraryForIdAsync(libraryId); - if (series == null || library == null) return; - - await CreateReadingListsFromSeries(series, library); - } - public async Task CreateReadingListsFromSeries(Series series, Library library) { if (!library.ManageReadingLists) return; @@ -527,266 +440,6 @@ public class ReadingListService( return data; } - /// - /// Check for File issues like: No entries, Reading List Name collision, Duplicate Series across Libraries - /// - /// - /// - /// When true, will force ComicVine library naming conventions: Series (Year) for Series name matching. - public async Task ValidateCblFile(int userId, CblReadingList cblReading, bool useComicLibraryMatching = false) - { - var importSummary = new CblImportSummaryDto - { - CblName = cblReading.Name, - Success = CblImportResult.Success, - Results = [], - SuccessfulInserts = [] - }; - - if (IsCblEmpty(cblReading, importSummary, out var readingListFromCbl)) return readingListFromCbl; - - // Is there another reading list with the same name on the user's account? - if (await unitOfWork.ReadingListRepository.ReadingListExistsForUser(cblReading.Name, userId)) - { - importSummary.Success = CblImportResult.Fail; - importSummary.Results.Add(new CblBookResult - { - Reason = CblImportReason.NameConflict, - ReadingListName = cblReading.Name - }); - } - - - var uniqueSeries = GetUniqueSeries(cblReading, useComicLibraryMatching); - var userSeries = - (await unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); - - if (userSeries.Count == 0) - { - // Report that no series exist in the reading list - importSummary.Results.Add(new CblBookResult - { - Reason = CblImportReason.AllSeriesMissing - }); - importSummary.Success = CblImportResult.Fail; - return importSummary; - } - - var conflicts = FindCblImportConflicts(userSeries); - if (!conflicts.Any()) return importSummary; - - importSummary.Success = CblImportResult.Fail; - foreach (var conflict in conflicts) - { - importSummary.Results.Add(new CblBookResult - { - Reason = CblImportReason.SeriesCollision, - Series = conflict.Name, - LibraryId = conflict.LibraryId, - SeriesId = conflict.Id, - }); - } - - return importSummary; - } - - private static string GetSeriesFormatting(CblBook book, bool useComicLibraryMatching) - { - return useComicLibraryMatching ? $"{book.Series} ({book.Volume})" : book.Series; - } - - private static List GetUniqueSeries(CblReadingList cblReading, bool useComicLibraryMatching) - { - return cblReading.Books.Book.Select(b => Parser.Normalize(GetSeriesFormatting(b, useComicLibraryMatching))).Distinct().ToList(); - } - - - /// - /// Imports (or pretends to) a cbl into a reading list. Call first! - /// - /// - /// - /// - /// When true, will force ComicVine library naming conventions: Series (Year) for Series name matching. - /// - public async Task CreateReadingListFromCbl(int userId, CblReadingList cblReading, bool dryRun = false, bool useComicLibraryMatching = false) - { - var user = await unitOfWork.UserRepository.GetUserByIdAsync(userId, AppUserIncludes.ReadingListsWithItems); - logger.LogDebug("Importing {ReadingListName} CBL for User {UserName}", cblReading.Name, user!.UserName); - var importSummary = new CblImportSummaryDto - { - CblName = cblReading.Name, - Success = CblImportResult.Success, - Results = new List(), - SuccessfulInserts = new List() - }; - - var uniqueSeries = GetUniqueSeries(cblReading, useComicLibraryMatching); - var userSeries = - (await unitOfWork.SeriesRepository.GetAllSeriesByNameAsync(uniqueSeries, userId, SeriesIncludes.Chapters)).ToList(); - var allSeries = userSeries.ToDictionary(s => s.NormalizedName); - var allSeriesLocalized = userSeries.ToDictionary(s => s.NormalizedLocalizedName); - - var readingListNameNormalized = Parser.Normalize(cblReading.Name); - - // Get all the user's reading lists - var allReadingLists = (user.ReadingLists).ToDictionary(s => s.NormalizedTitle); - if (!allReadingLists.TryGetValue(readingListNameNormalized, out var readingList)) - { - readingList = new ReadingListBuilder(cblReading.Name).WithSummary(cblReading.Summary).Build(); - user.ReadingLists.Add(readingList); - } - else - { - // Reading List exists, check if we own it - if (user.ReadingLists.All(l => l.NormalizedTitle != readingListNameNormalized)) - { - importSummary.Results.Add(new CblBookResult - { - Reason = CblImportReason.NameConflict - }); - importSummary.Success = CblImportResult.Fail; - return importSummary; - } - } - - readingList.Items ??= new List(); - foreach (var (book, i) in cblReading.Books.Book.Select((value, i) => ( value, i ))) - { - var normalizedSeries = Parser.Normalize(GetSeriesFormatting(book, useComicLibraryMatching)); - if (!allSeries.TryGetValue(normalizedSeries, out var bookSeries) && !allSeriesLocalized.TryGetValue(normalizedSeries, out bookSeries)) - { - importSummary.Results.Add(new CblBookResult(book) - { - Reason = CblImportReason.SeriesMissing, - Order = i - }); - continue; - } - // Prioritize lookup by Volume then Chapter, but allow fallback to just Chapter - var bookVolume = string.IsNullOrEmpty(book.Volume) - ? Parser.LooseLeafVolume - : book.Volume; - var matchingVolume = bookSeries.Volumes.Find(v => bookVolume == v.Name) - ?? bookSeries.Volumes.GetLooseLeafVolumeOrDefault() - ?? bookSeries.Volumes.GetSpecialVolumeOrDefault(); - if (matchingVolume == null) - { - importSummary.Results.Add(new CblBookResult(book) - { - Reason = CblImportReason.VolumeMissing, - LibraryId = bookSeries.LibraryId, - Order = i - }); - continue; - } - - // We need to handle default chapter or empty string when it's just a volume - var bookNumber = string.IsNullOrEmpty(book.Number) - ? Parser.DefaultChapter - : book.Number; - var chapter = matchingVolume.Chapters.FirstOrDefault(c => c.Range == bookNumber); - if (chapter == null) - { - importSummary.Results.Add(new CblBookResult(book) - { - Reason = CblImportReason.ChapterMissing, - LibraryId = bookSeries.LibraryId, - Order = i - }); - continue; - } - - // See if a matching item already exists - ExistsOrAddReadingListItem(readingList, bookSeries.Id, matchingVolume.Id, chapter.Id); - importSummary.SuccessfulInserts.Add(new CblBookResult(book) - { - Reason = CblImportReason.Success, - Order = i - }); - } - - if (importSummary.SuccessfulInserts.Count != cblReading.Books.Book.Count || importSummary.Results.Count > 0) - { - importSummary.Success = CblImportResult.Partial; - } - - if (importSummary.SuccessfulInserts.Count == 0 && importSummary.Results.Count == cblReading.Books.Book.Count) - { - importSummary.Success = CblImportResult.Fail; - } - - if (dryRun) return importSummary; - - await CalculateReadingListAgeRating(readingList); - await CalculateStartAndEndDates(readingList); - - // For CBL Import only we override pre-calculated dates - if (NumberHelper.IsValidMonth(cblReading.StartMonth)) readingList.StartingMonth = cblReading.StartMonth; - if (NumberHelper.IsValidYear(cblReading.StartYear)) readingList.StartingYear = cblReading.StartYear; - if (NumberHelper.IsValidMonth(cblReading.EndMonth)) readingList.EndingMonth = cblReading.EndMonth; - if (NumberHelper.IsValidYear(cblReading.EndYear)) readingList.EndingYear = cblReading.EndYear; - - if (!string.IsNullOrEmpty(readingList.Summary?.Trim())) - { - readingList.Summary = readingList.Summary?.Trim(); - } - - // If there are no items, don't create a blank list - if (!unitOfWork.HasChanges() || readingList.Items.Count == 0) return importSummary; - - - imageService.UpdateColorScape(readingList); - await unitOfWork.CommitAsync(); - - - return importSummary; - } - - private static IList FindCblImportConflicts(IEnumerable userSeries) - { - var dict = new HashSet(); - return userSeries.Where(series => !dict.Add(series.NormalizedName)).ToList(); - } - - private static bool IsCblEmpty(CblReadingList cblReading, CblImportSummaryDto importSummary, - out CblImportSummaryDto readingListFromCbl) - { - readingListFromCbl = new CblImportSummaryDto(); - if (cblReading.Books == null || cblReading.Books.Book.Count == 0) - { - importSummary.Results.Add(new CblBookResult - { - Reason = CblImportReason.EmptyFile - }); - importSummary.Success = CblImportResult.Fail; - readingListFromCbl = importSummary; - return true; - } - - return false; - } - - private static void ExistsOrAddReadingListItem(ReadingList readingList, int seriesId, int volumeId, int chapterId) - { - var readingListItem = - readingList.Items.FirstOrDefault(item => - item.SeriesId == seriesId && item.ChapterId == chapterId); - if (readingListItem != null) return; - - readingListItem = new ReadingListItemBuilder(readingList.Items.Count, seriesId, - volumeId, chapterId).Build(); - readingList.Items.Add(readingListItem); - } - - public static CblReadingList LoadCblFromPath(string path) - { - var reader = new XmlSerializer(typeof(CblReadingList)); - using var file = new StreamReader(path); - var cblReadingList = (CblReadingList) reader.Deserialize(file); - file.Close(); - return cblReadingList; - } public async Task GenerateReadingListCoverImage(int readingListId) { @@ -847,12 +500,4 @@ public class ReadingListService( return items; } - - public async Task GetContinueReadingPoint(int readingListId, int userId) - { - var item = await unitOfWork.ReadingListRepository.GetContinueReadingPoint(readingListId, userId); - item?.Title = namingService.FormatReadingListItemTitle(item); - - return item; - } } diff --git a/Kavita.Services/SeriesService.cs b/Kavita.Services/SeriesService.cs index 5a95c14ec..96d40e54d 100644 --- a/Kavita.Services/SeriesService.cs +++ b/Kavita.Services/SeriesService.cs @@ -537,7 +537,6 @@ public class SeriesService( } var specials = new List(); - // Why isn't this doing a check if chapter is not special as it wont get included var chapters = volumes .SelectMany(v => v.Chapters .Select(c => diff --git a/Kavita.Services/VersionUpdaterService.cs b/Kavita.Services/VersionUpdaterService.cs index df5af69b1..cee21e681 100644 --- a/Kavita.Services/VersionUpdaterService.cs +++ b/Kavita.Services/VersionUpdaterService.cs @@ -201,8 +201,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService try { var prInfo = await $"{GithubPullsUrl}{prNumber}" - .WithHeader(HeaderNames.Accept, "application/json") - .WithHeader(HeaderNames.UserAgent, "Kavita") + .WithGithubHeaders() .GetJsonAsync(); // Cache the result @@ -233,8 +232,7 @@ public partial class VersionUpdaterService : IVersionUpdaterService var nightlyReleases = new List(); var commits = await GithubBranchCommitsUrl - .WithHeader(HeaderNames.Accept, "application/json") - .WithHeader(HeaderNames.UserAgent, "Kavita") + .WithGithubHeaders() .GetJsonAsync>(); var commitList = commits.ToList(); diff --git a/UI/Web/src/app/_models/common/github-rate-limit.ts b/UI/Web/src/app/_models/common/github-rate-limit.ts new file mode 100644 index 000000000..343dc0aff --- /dev/null +++ b/UI/Web/src/app/_models/common/github-rate-limit.ts @@ -0,0 +1,7 @@ +export interface GithubRateLimit { + remaining: number | null; + limit: number | null; + resetsAtUtc: string | null; + isLow: boolean; + isExhausted: boolean; +} diff --git a/UI/Web/src/app/_models/modal/modal-options.ts b/UI/Web/src/app/_models/modal/modal-options.ts index 69e778980..c3887430a 100644 --- a/UI/Web/src/app/_models/modal/modal-options.ts +++ b/UI/Web/src/app/_models/modal/modal-options.ts @@ -24,6 +24,11 @@ export function addToModal(): Partial { return {...DefaultModalOptions, size: 'md', fullscreen: 'sm'}; } +/** Fullscreen from the get-go*/ +export function fullscreenModal(): Partial { + return {...DefaultModalOptions, size: 'xl', fullscreen: true}; +} + /** Non-dismissible - for refresh-required modals only */ export function versionRefreshModal(): Partial { return { diff --git a/UI/Web/src/app/_models/reading-list.ts b/UI/Web/src/app/_models/reading-list.ts index dc46e3ca9..af7115309 100644 --- a/UI/Web/src/app/_models/reading-list.ts +++ b/UI/Web/src/app/_models/reading-list.ts @@ -25,6 +25,15 @@ export interface ReadingListItem { summary?: string; } +export enum ReadingListProvider { + /** Default, List created within Kavita. No Sync capabilities */ + None = 0, + /** Created by File upload. No Sync capabilities */ + File = 1, + /** Downloaded via CBL Manager or direct Url feed */ + Url = 2 +} + export interface ReadingList extends IHasCover { id: number; title: string; @@ -44,6 +53,15 @@ export interface ReadingList extends IHasCover { endingMonth: number; itemCount: number; ageRating: AgeRating; + + sourcePath: string | null; + downloadUrl: string | null; + shareUrl: string | null; + provider: ReadingListProvider; + lastSyncCheckUtc: Date | null; + lastSyncedDate: Date | null; + canSync: boolean; + hasRemoteChange: boolean; } export interface ReadingListInfo extends IHasReadingTime, IHasReadingTime { diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts index e5ac4b298..bcda2f6c4 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-book-result.ts @@ -1,18 +1,28 @@ -import { CblImportReason } from "./cbl-import-reason.enum"; +import {CblImportReason} from './cbl-import-reason.enum'; +import {CblMatchTier} from './cbl-match-tier'; +import {CblSeriesCandidate} from './cbl-series-candidate'; +import {LibraryType} from '../../library/library'; export interface CblBookResult { - order: number; - series: string; - volume: string; - number: string; - /** - * For SeriesCollision - */ - libraryId: number; - /** - * For SeriesCollision - */ - seriesId: number; - readingListName: string; - reason: CblImportReason; -} \ No newline at end of file + order: number; + series: string; + volume: string; + number: string; + /** + * For SeriesCollision + */ + libraryId: number; + /** + * For SeriesCollision + */ + seriesId: number; + readingListName: string; + reason: CblImportReason; + matchTier: CblMatchTier | null; + chapterId: number; + chapterTitle: string; + matchedSeriesName: string; + libraryType: LibraryType; + chapterNumber: string; + candidates: CblSeriesCandidate[]; +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-decisions.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-decisions.ts new file mode 100644 index 000000000..56b91d121 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-decisions.ts @@ -0,0 +1,10 @@ +export interface CblItemDecision { + seriesId: number; + volumeId: number; + chapterId: number; +} + +export interface CblImportDecisions { + itemResolutions: Record; + saveAsRemapRules: boolean; +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts index 476adb7ed..fd9f20c9c 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-import-summary.ts @@ -1,15 +1,11 @@ -import { CblBookResult } from "./cbl-book-result"; -import { CblImportResult } from "./cbl-import-result.enum"; - -export interface CblConflictQuestion { - seriesName: string; - librariesIds: Array; -} +import {CblBookResult} from "./cbl-book-result"; +import {CblImportResult} from "./cbl-import-result.enum"; export interface CblImportSummary { cblName: string; fileName: string; results: Array; success: CblImportResult; + isUpdate: boolean; successfulInserts: Array; } diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-match-tier.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-match-tier.ts new file mode 100644 index 000000000..2457c93d5 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-match-tier.ts @@ -0,0 +1,11 @@ +export enum CblMatchTier { + RemapRule = 0, + ExternalId = 1, + ExactName = 2, + ComicVineNaming = 3, + ArticleStripped = 4, + ReprintStripped = 5, + AlternateSeries = 6, + UserDecision = 7, + Unmatched = -1 +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-repo-browse-result.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-repo-browse-result.ts new file mode 100644 index 000000000..c5aa9b462 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-repo-browse-result.ts @@ -0,0 +1,8 @@ +import {CblRepoItem} from "./cbl-repo-item"; +import {GithubRateLimit} from "../../common/github-rate-limit"; + +export interface CblRepoBrowseResult { + items: CblRepoItem[]; + rateLimit: GithubRateLimit; + fromCache: boolean; +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-repo-item.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-repo-item.ts new file mode 100644 index 000000000..9ae514d47 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-repo-item.ts @@ -0,0 +1,10 @@ +export interface CblRepoItem { + name: string; + path: string; + isDirectory: boolean; + sha: string; + size: number; + downloadUrl: string | null; + existingReadingListId: number | null; + alreadySynced: boolean; +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-saved-file.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-saved-file.ts new file mode 100644 index 000000000..b7fbef2d3 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-saved-file.ts @@ -0,0 +1,10 @@ +import {ReadingListProvider} from '../../reading-list'; + +export interface CblSavedFile { + name: string; + fileName: string; + provider: ReadingListProvider; + repoPath?: string; + downloadUrl?: string; + sha?: string; +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-series-candidate.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-series-candidate.ts new file mode 100644 index 000000000..088515bff --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-series-candidate.ts @@ -0,0 +1,5 @@ +export interface CblSeriesCandidate { + seriesId: number; + libraryId: number; + seriesName: string; +} diff --git a/UI/Web/src/app/_models/reading-list/cbl/remap-rule.ts b/UI/Web/src/app/_models/reading-list/cbl/remap-rule.ts new file mode 100644 index 000000000..3eb9fea08 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/remap-rule.ts @@ -0,0 +1,21 @@ +import {LibraryType} from '../../library/library'; + +export interface RemapRule { + id: number; + normalizedCblSeriesName: string; + cblSeriesName: string; + cblVolume: string | null; + cblNumber: string | null; + seriesId: number; + volumeId: number | null; + chapterId: number | null; + chapterRange: string; + chapterTitleName: string; + chapterIsSpecial: boolean; + libraryType: LibraryType; + seriesNameAtMapping: string; + appUserId: number; + isGlobal: boolean; + createdByUserName: string; + createdUtc: string; +} diff --git a/UI/Web/src/app/_models/search/search-result.ts b/UI/Web/src/app/_models/search/search-result.ts index f7025a72a..da9c3147b 100644 --- a/UI/Web/src/app/_models/search/search-result.ts +++ b/UI/Web/src/app/_models/search/search-result.ts @@ -9,4 +9,7 @@ export interface SearchResult { localizedName: string; sortName: string; format: MangaFormat; + releaseYear?: number; + volumeCount?: number; + chapterCount?: number; } diff --git a/UI/Web/src/app/_pipes/cbl-import-reason.pipe.ts b/UI/Web/src/app/_pipes/cbl-import-reason.pipe.ts new file mode 100644 index 000000000..c91bcc06c --- /dev/null +++ b/UI/Web/src/app/_pipes/cbl-import-reason.pipe.ts @@ -0,0 +1,39 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {CblImportReason} from '../_models/reading-list/cbl/cbl-import-reason.enum'; +import {TranslocoService} from '@jsverse/transloco'; + +@Pipe({ + name: 'cblImportReason', + standalone: true +}) +export class CblImportReasonPipe implements PipeTransform { + + translocoService = inject(TranslocoService); + + transform(reason: CblImportReason): string { + switch (reason) { + case CblImportReason.ChapterMissing: + return this.translocoService.translate('cbl-import-reason-pipe.chapter-missing'); + case CblImportReason.VolumeMissing: + return this.translocoService.translate('cbl-import-reason-pipe.volume-missing'); + case CblImportReason.SeriesMissing: + return this.translocoService.translate('cbl-import-reason-pipe.series-missing'); + case CblImportReason.NameConflict: + return this.translocoService.translate('cbl-import-reason-pipe.name-conflict'); + case CblImportReason.AllSeriesMissing: + return this.translocoService.translate('cbl-import-reason-pipe.all-series-missing'); + case CblImportReason.EmptyFile: + return this.translocoService.translate('cbl-import-reason-pipe.empty-file'); + case CblImportReason.SeriesCollision: + return this.translocoService.translate('cbl-import-reason-pipe.series-collision'); + case CblImportReason.AllChapterMissing: + return this.translocoService.translate('cbl-import-reason-pipe.all-chapter-missing'); + case CblImportReason.Success: + return this.translocoService.translate('cbl-import-reason-pipe.success'); + case CblImportReason.InvalidFile: + return this.translocoService.translate('cbl-import-reason-pipe.invalid-file'); + default: + return ''; + } + } +} diff --git a/UI/Web/src/app/_pipes/cbl-match-tier.pipe.ts b/UI/Web/src/app/_pipes/cbl-match-tier.pipe.ts new file mode 100644 index 000000000..9102d5785 --- /dev/null +++ b/UI/Web/src/app/_pipes/cbl-match-tier.pipe.ts @@ -0,0 +1,38 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {CblMatchTier} from '../_models/reading-list/cbl/cbl-match-tier'; +import {TranslocoService} from '@jsverse/transloco'; + +@Pipe({ + name: 'cblMatchTier', + standalone: true +}) +export class CblMatchTierPipe implements PipeTransform { + + translocoService = inject(TranslocoService); + + transform(tier: CblMatchTier | null): string { + if (tier === null || tier === undefined) return ''; + switch (tier) { + case CblMatchTier.RemapRule: + return this.translocoService.translate('cbl-match-tier-pipe.remap-rule'); + case CblMatchTier.ExternalId: + return this.translocoService.translate('cbl-match-tier-pipe.external-id'); + case CblMatchTier.ExactName: + return this.translocoService.translate('cbl-match-tier-pipe.exact-name'); + case CblMatchTier.ComicVineNaming: + return this.translocoService.translate('cbl-match-tier-pipe.comicvine-naming'); + case CblMatchTier.ArticleStripped: + return this.translocoService.translate('cbl-match-tier-pipe.article-stripped'); + case CblMatchTier.ReprintStripped: + return this.translocoService.translate('cbl-match-tier-pipe.reprint-stripped'); + case CblMatchTier.AlternateSeries: + return this.translocoService.translate('cbl-match-tier-pipe.alternate-series'); + case CblMatchTier.UserDecision: + return this.translocoService.translate('cbl-match-tier-pipe.user-decision'); + case CblMatchTier.Unmatched: + return this.translocoService.translate('cbl-match-tier-pipe.unmatched'); + default: + return ''; + } + } +} diff --git a/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts b/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts new file mode 100644 index 000000000..e43ccc870 --- /dev/null +++ b/UI/Web/src/app/_pipes/reading-list-provider.pipe.ts @@ -0,0 +1,26 @@ +import {inject, Pipe, PipeTransform} from '@angular/core'; +import {ReadingListProvider} from "../_models/reading-list"; +import {TranslocoService} from "@jsverse/transloco"; + +@Pipe({ + name: 'readingListProvider', + standalone: true, + pure: true +}) +export class ReadingListProviderPipe implements PipeTransform { + + private readonly translocoService = inject(TranslocoService); + + transform(value: ReadingListProvider): string { + switch (value) { + case ReadingListProvider.None: + return this.translocoService.translate('reading-list-provider-pipe.none'); + case ReadingListProvider.File: + return this.translocoService.translate('reading-list-provider-pipe.file'); + case ReadingListProvider.Url: + return this.translocoService.translate('reading-list-provider-pipe.url'); + + } + } + +} diff --git a/UI/Web/src/app/_services/cbl.service.ts b/UI/Web/src/app/_services/cbl.service.ts new file mode 100644 index 000000000..daf52405b --- /dev/null +++ b/UI/Web/src/app/_services/cbl.service.ts @@ -0,0 +1,87 @@ +import {inject, Injectable} from '@angular/core'; +import {HttpClient, HttpParams} from '@angular/common/http'; +import {environment} from '../../environments/environment'; +import {CblRepoBrowseResult} from '../_models/reading-list/cbl/cbl-repo-browse-result'; +import {CblRepoItem} from '../_models/reading-list/cbl/cbl-repo-item'; +import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; +import {CblSavedFile} from '../_models/reading-list/cbl/cbl-saved-file'; +import {CblImportDecisions} from '../_models/reading-list/cbl/cbl-import-decisions'; +import {ReadingListProvider} from '../_models/reading-list'; +import {RemapRule} from '../_models/reading-list/cbl/remap-rule'; +import {NgxFileDropEntry} from 'ngx-file-drop'; + +@Injectable({ + providedIn: 'root', +}) +export class CblService { + private readonly httpClient = inject(HttpClient); + private readonly baseUrl = environment.apiUrl; + + browseRepo(path: string = '') { + let params = new HttpParams(); + if (path !== '') { + params = params.append('path', path); + } + return this.httpClient.get(this.baseUrl + 'cbl/browse', {params: params}); + } + + importFromRepo(items: CblRepoItem[]) { + return this.httpClient.post(this.baseUrl + 'cbl/repo-import', {items}); + } + + importFromUrl(url: string) { + return this.httpClient.post(this.baseUrl + 'cbl/upload-cbl-file', {url}); + } + + importFromFile(file: File, fileEntry: NgxFileDropEntry) { + const formData = new FormData(); + formData.append('cblFile', file, fileEntry.relativePath); + return this.httpClient.post(this.baseUrl + 'cbl/file-import', formData); + } + + reValidate(fileName: string) { + return this.httpClient.post(this.baseUrl + 'cbl/re-validate', {fileName}); + } + + finalizeImport(fileName: string, decisions: CblImportDecisions, provider: ReadingListProvider, + repoMeta?: { repoPath: string; downloadUrl: string; sha: string }) { + return this.httpClient.post(this.baseUrl + 'cbl/finalize-import', { + fileName, + decisions, + provider, + ...repoMeta + }); + } + + getRemapRules() { + return this.httpClient.get(this.baseUrl + 'cbl/remap-rules'); + } + + createRemapRule(cblSeriesName: string, seriesId: number, issueDetail?: { + cblVolume?: string; cblNumber?: string; volumeId?: number; chapterId?: number; + }) { + return this.httpClient.post(this.baseUrl + 'cbl/remap-rules', { + cblSeriesName, seriesId, ...issueDetail + }); + } + + updateRemapRule(id: number, update: { volumeId?: number; chapterId?: number; cblVolume?: string; cblNumber?: string }) { + return this.httpClient.put(this.baseUrl + 'cbl/remap-rules/' + id, update); + } + + deleteRemapRule(id: number) { + return this.httpClient.delete(this.baseUrl + 'cbl/remap-rules/' + id); + } + + getAllRemapRules() { + return this.httpClient.get(this.baseUrl + 'cbl/remap-rules/all'); + } + + promoteRule(id: number) { + return this.httpClient.post(this.baseUrl + 'cbl/remap-rules/' + id + '/promote', {}); + } + + demoteRule(id: number) { + return this.httpClient.post(this.baseUrl + 'cbl/remap-rules/' + id + '/demote', {}); + } +} diff --git a/UI/Web/src/app/_services/reading-list.service.ts b/UI/Web/src/app/_services/reading-list.service.ts index 728d4ae9c..72d07af62 100644 --- a/UI/Web/src/app/_services/reading-list.service.ts +++ b/UI/Web/src/app/_services/reading-list.service.ts @@ -6,7 +6,6 @@ import {UtilityService} from '../shared/_services/utility.service'; import {Person, PersonRole} from '../_models/metadata/person'; import {PaginatedResult} from '../_models/pagination'; import {ReadingList, ReadingListCast, ReadingListInfo, ReadingListItem} from '../_models/reading-list'; -import {CblImportSummary} from '../_models/reading-list/cbl/cbl-import-summary'; import {TextResonse} from '../_types/text-response'; import {ActionItem} from "../_models/actionables/action-item"; import {Action} from "../_models/actionables/action"; @@ -105,14 +104,6 @@ export class ReadingListService { return this.httpClient.get(this.baseUrl + 'readinglist/name-exists?name=' + name); } - validateCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) { - return this.httpClient.post(this.baseUrl + `cbl/validate?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); - } - - importCbl(form: FormData, dryRun: boolean, useComicVineMatching: boolean) { - return this.httpClient.post(this.baseUrl + `cbl/import?dryRun=${dryRun}&useComicVineMatching=${useComicVineMatching}`, form); - } - getPeople(readingListId: number, role: PersonRole) { return this.httpClient.get>(this.baseUrl + `readinglist/people?readingListId=${readingListId}&role=${role}`); } diff --git a/UI/Web/src/app/_services/search.service.ts b/UI/Web/src/app/_services/search.service.ts index 48115bcdb..57d7aac59 100644 --- a/UI/Web/src/app/_services/search.service.ts +++ b/UI/Web/src/app/_services/search.service.ts @@ -4,6 +4,7 @@ import { of } from 'rxjs'; import { environment } from 'src/environments/environment'; import { SearchResultGroup } from '../_models/search/search-result-group'; import { Series } from '../_models/series'; +import { Chapter } from '../_models/chapter'; @Injectable({ providedIn: 'root' @@ -11,7 +12,6 @@ import { Series } from '../_models/series'; export class SearchService { private httpClient = inject(HttpClient); - baseUrl = environment.apiUrl; search(term: string, includeChapterAndFiles: boolean = false) { @@ -28,4 +28,8 @@ export class SearchService { getSeriesForChapter(chapterId: number) { return this.httpClient.get(this.baseUrl + 'search/series-for-chapter?chapterId=' + chapterId); } + + getChaptersBySeries(seriesId: number) { + return this.httpClient.get(this.baseUrl + 'search/chapters-by-series?seriesId=' + seriesId); + } } diff --git a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html deleted file mode 100644 index 8a726b9c7..000000000 --- a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.html +++ /dev/null @@ -1,183 +0,0 @@ - - -
- -
- -
- @switch (currentStepIndex) { - @case (Step.Import) { -
-

{{t('import-description')}}

-

- -
- - -
- } - @case (Step.Validate) { -

{{t('validate-description')}}

-
-
- @for(fileToProcess of filesToProcess; track fileToProcess.fileName) { - @if (fileToProcess.validateSummary; as summary) { -
-
- -
-
-
- -
-
-
- } - } -
-
- } - @case (Step.DryRun) { -
-

{{t('dry-run-description')}}

- -
- @for(fileToProcess of filesToProcess; track fileToProcess.fileName) { - @if (fileToProcess.dryRunSummary; as summary) { -
-
- -
-
-
- -
-
-
- } - } -
-
- } - @case (Step.Finalize) { -
-
- @for(fileToProcess of filesToProcess; track fileToProcess.fileName) { - @if (fileToProcess.finalizeSummary; as summary) { -
-
- -
-
-
- -
-
-
- } - } -
-
- } - } -
- - - @if (summary.results.length > 0) { -
-
-
- -
-
- {{t('validate-warning')}} -
-
-
-
    - @for(result of summary.results; track result) { -
  1. -
  2. - } - - -
- } - @else { -
-
-
- -
-
- {{t('validate-no-issue-description')}} -
-
-
- } -
- - -
    - @for(result of summary.results; track result.order) { -
  • - } -
-
- - - @switch (summary.success) { - @case (CblImportResult.Success) { - {{summary.success | cblImportResult}} - } - @case (CblImportResult.Fail) { - {{summary.success | cblImportResult}} - } - @case (CblImportResult.Partial) { - {{summary.success | cblImportResult}} - } - } - {{filename}} - @if(summary.cblName) { - : ({{summary.cblName}}) - } - - - - - - - -
diff --git a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.scss b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.scss deleted file mode 100644 index 246544ad0..000000000 --- a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.scss +++ /dev/null @@ -1,46 +0,0 @@ -.file-input { - display: none; -} - -.heading-badge { - color: var(--bs-badge-color); -} - -::ng-deep .file-info { - width: 83%; - float: left; -} - -::ng-deep .file-buttons { - float: right; -} - -file-upload { - background: none; - height: auto; -} - -::ng-deep .upload-input { - color: var(--input-text-color) !important; -} - -::ng-deep file-upload-list-item { - color: var(--input-text-color) !important; -} - -::ng-deep .remove-btn { - background: #C0392B; - border-radius: 0.1875rem; - color: var(--input-text-color) !important; - font-weight: bold; - padding: 0.1875rem 0.3125rem; -} - -::ng-deep .reading-list-success--item { - color: var(--primary-color); -} - -::ng-deep .reading-list-fail--item { - color: var(--error-color); -} - diff --git a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts b/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts deleted file mode 100644 index 492b256ce..000000000 --- a/UI/Web/src/app/reading-list/_components/import-cbl/import-cbl.component.ts +++ /dev/null @@ -1,266 +0,0 @@ -import {ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, inject, viewChild} from '@angular/core'; -import {CblConflictReasonPipe} from "../../../_pipes/cbl-conflict-reason.pipe"; -import {CblImportResultPipe} from "../../../_pipes/cbl-import-result.pipe"; -import {FileUploadComponent, FileUploadValidators} from "@iplab/ngx-file-upload"; -import {FormControl, FormGroup, FormsModule, ReactiveFormsModule} from "@angular/forms"; -import {NgTemplateOutlet} from "@angular/common"; -import { - NgbAccordionBody, - NgbAccordionButton, - NgbAccordionCollapse, - NgbAccordionDirective, - NgbAccordionHeader, - NgbAccordionItem, -} from "@ng-bootstrap/ng-bootstrap"; -import {SafeHtmlPipe} from "../../../_pipes/safe-html.pipe"; -import {StepTrackerComponent, TimelineStep} from "../step-tracker/step-tracker.component"; -import {translate, TranslocoDirective} from "@jsverse/transloco"; -import {ReadingListService} from "../../../_services/reading-list.service"; -import {UtilityService} from "../../../shared/_services/utility.service"; -import {ToastrService} from "ngx-toastr"; -import {forkJoin} from "rxjs"; -import {CblImportSummary} from "../../../_models/reading-list/cbl/cbl-import-summary"; -import { WikiLink } from 'src/app/_models/wiki'; -import { CblImportResult } from 'src/app/_models/reading-list/cbl/cbl-import-result.enum'; - - -interface FileStep { - fileName: string; - validateSummary: CblImportSummary | undefined; - dryRunSummary: CblImportSummary | undefined; - finalizeSummary: CblImportSummary | undefined; -} - -enum Step { - Import = 0, - Validate = 1, - DryRun = 2, - Finalize = 3 -} - -@Component({ - selector: 'app-import-cbl', - imports: [ - CblConflictReasonPipe, - CblImportResultPipe, - FileUploadComponent, - FormsModule, - NgbAccordionBody, - NgbAccordionButton, - NgbAccordionCollapse, - NgbAccordionDirective, - NgbAccordionHeader, - NgbAccordionItem, - ReactiveFormsModule, - SafeHtmlPipe, - StepTrackerComponent, - TranslocoDirective, - NgTemplateOutlet - ], - templateUrl: './import-cbl.component.html', - styleUrl: './import-cbl.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush -}) -export class ImportCblComponent { - private readonly readingListService = inject(ReadingListService); - private readonly toastr = inject(ToastrService); - private readonly cdRef = inject(ChangeDetectorRef); - protected readonly utilityService = inject(UtilityService); - - - protected readonly CblImportResult = CblImportResult; - protected readonly Step = Step; - protected readonly WikiLink = WikiLink; - - readonly fileUpload = viewChild.required>('fileUpload'); - - - fileUploadControl = new FormControl>(undefined, [ - FileUploadValidators.accept(['.cbl']) - ]); - - uploadForm = new FormGroup({ - files: this.fileUploadControl - }); - cblSettingsForm = new FormGroup({ - comicVineMatching: new FormControl(true, []) - }); - - isLoading: boolean = false; - - steps: Array = [ - {title: translate('import-cbl-modal.import-step'), index: Step.Import, active: true, icon: 'fa-solid fa-file-arrow-up'}, - {title: translate('import-cbl-modal.validate-cbl-step'), index: Step.Validate, active: false, icon: 'fa-solid fa-spell-check'}, - {title: translate('import-cbl-modal.dry-run-step'), index: Step.DryRun, active: false, icon: 'fa-solid fa-gears'}, - {title: translate('import-cbl-modal.final-import-step'), index: Step.Finalize, active: false, icon: 'fa-solid fa-floppy-disk'}, - ]; - currentStepIndex = this.steps[0].index; - - filesToProcess: Array = []; - failedFiles: Array = []; - - - get NextButtonLabel() { - switch(this.currentStepIndex) { - case Step.DryRun: - return 'import'; - case Step.Finalize: - return 'restart' - default: - return 'next'; - } - } - - - - nextStep() { - if (this.currentStepIndex === Step.Import && !this.isFileSelected()) return; - - this.isLoading = true; - switch (this.currentStepIndex) { - case Step.Import: - const files = this.uploadForm.get('files')?.value; - if (!files) { - this.toastr.error(translate('toasts.select-files-warning')); - return; - } - // Load each file into filesToProcess and group their data - const pages = []; - for (let i = 0; i < files.length; i++) { - const formData = new FormData(); - formData.append('cbl', files[i]); - formData.append('dryRun', 'true'); - formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + ''); - pages.push(this.readingListService.validateCbl(formData, true, this.cblSettingsForm.get('comicVineMatching')?.value as boolean)); - } - - forkJoin(pages).subscribe(results => { - this.filesToProcess = []; - results.forEach(cblImport => { - this.filesToProcess.push({ - fileName: cblImport.fileName, - validateSummary: cblImport, - dryRunSummary: undefined, - finalizeSummary: undefined - }); - }); - - this.filesToProcess = this.filesToProcess.sort((a, b) => b.validateSummary!.success - a.validateSummary!.success); - this.cdRef.markForCheck(); - - this.currentStepIndex++; - this.isLoading = false; - this.cdRef.markForCheck(); - }); - break; - case Step.Validate: - this.failedFiles = this.filesToProcess.filter(item => item.validateSummary?.success === CblImportResult.Fail); - this.filesToProcess = this.filesToProcess.filter(item => item.validateSummary?.success != CblImportResult.Fail); - this.dryRun(); - break; - case Step.DryRun: - this.failedFiles.push(...this.filesToProcess.filter(item => item.dryRunSummary?.success === CblImportResult.Fail)); - this.filesToProcess = this.filesToProcess.filter(item => item.dryRunSummary?.success != CblImportResult.Fail); - this.import(); - break; - case Step.Finalize: - // Clear the models and allow user to do another import - this.uploadForm.get('files')?.setValue(undefined); - this.currentStepIndex = Step.Import; - this.isLoading = false; - this.filesToProcess = []; - this.failedFiles = []; - this.cdRef.markForCheck(); - break; - } - } - - prevStep() { - if (this.currentStepIndex === Step.Import) return; - this.currentStepIndex--; - } - - canMoveToNextStep() { - switch (this.currentStepIndex) { - case Step.Import: - return this.isFileSelected(); - case Step.Validate: - return this.filesToProcess.filter(item => item.validateSummary?.success != CblImportResult.Fail).length > 0; - case Step.DryRun: - return this.filesToProcess.filter(item => item.dryRunSummary?.success != CblImportResult.Fail).length > 0; - case Step.Finalize: - return true; - default: - return false; - } - } - - canMoveToPrevStep() { - switch (this.currentStepIndex) { - case Step.Import: - case Step.Finalize: - return false; - default: - return true; - } - } - - - isFileSelected() { - const files = this.uploadForm.get('files')?.value; - if (files) return files.length > 0; - return false; - } - - - dryRun() { - const filenamesAllowedToProcess = this.filesToProcess.map(p => p.fileName); - const files = (this.uploadForm.get('files')?.value || []).filter(f => filenamesAllowedToProcess.includes(f.name)); - - const pages = []; - for (let i = 0; i < files.length; i++) { - const formData = new FormData(); - formData.append('cbl', files[i]); - formData.append('dryRun', 'true'); - formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + ''); - pages.push(this.readingListService.importCbl(formData, true, this.cblSettingsForm.get('comicVineMatching')?.value as boolean)); - } - forkJoin(pages).subscribe(results => { - results.forEach(cblImport => { - const index = this.filesToProcess.findIndex(p => p.fileName === cblImport.fileName); - this.filesToProcess[index].dryRunSummary = cblImport; - }); - this.filesToProcess = this.filesToProcess.sort((a, b) => b.dryRunSummary!.success - a.dryRunSummary!.success); - - this.isLoading = false; - this.currentStepIndex++; - this.cdRef.markForCheck(); - }); - } - - import() { - const filenamesAllowedToProcess = this.filesToProcess.map(p => p.fileName); - const files = (this.uploadForm.get('files')?.value || []).filter(f => filenamesAllowedToProcess.includes(f.name)); - - let pages = []; - for (let i = 0; i < files.length; i++) { - const formData = new FormData(); - formData.append('cbl', files[i]); - formData.append('dryRun', 'false'); - formData.append('comicVineMatching', this.cblSettingsForm.get('comicVineMatching')?.value + ''); - pages.push(this.readingListService.importCbl(formData, false, this.cblSettingsForm.get('comicVineMatching')?.value as boolean)); - } - - forkJoin(pages).subscribe(results => { - results.forEach(cblImport => { - const index = this.filesToProcess.findIndex(p => p.fileName === cblImport.fileName); - this.filesToProcess[index].finalizeSummary = cblImport; - }); - - this.isLoading = false; - this.currentStepIndex++; - this.toastr.success(translate('toasts.reading-list-imported')); - this.cdRef.markForCheck(); - }); - } -} diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.html b/UI/Web/src/app/settings/_components/settings/settings.component.html index 8a815cd46..6f09ffb6c 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.html +++ b/UI/Web/src/app/settings/_components/settings/settings.component.html @@ -92,6 +92,14 @@ } } + @case (SettingsTabId.RemapRules) { + @defer (prefetch on idle) { +
+ +
+ } + } + @case (SettingsTabId.ManageMetadata) { @if (accountService.hasAdminRole()) { @defer (prefetch on idle) { @@ -301,11 +309,12 @@ @case (SettingsTabId.CBLImport) { @defer (prefetch on idle) {
- +
} } + @case (SettingsTabId.ReadingProfiles) { @defer (prefetch on idle) {
diff --git a/UI/Web/src/app/settings/_components/settings/settings.component.ts b/UI/Web/src/app/settings/_components/settings/settings.component.ts index 8bfdc2d07..7f4c9605c 100644 --- a/UI/Web/src/app/settings/_components/settings/settings.component.ts +++ b/UI/Web/src/app/settings/_components/settings/settings.component.ts @@ -1,14 +1,6 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, DestroyRef, inject} from '@angular/core'; -import { - ChangeAgeRestrictionComponent -} from "../../../user-settings/change-age-restriction/change-age-restriction.component"; -import {ChangeEmailComponent} from "../../../user-settings/change-email/change-email.component"; -import {ChangePasswordComponent} from "../../../user-settings/change-password/change-password.component"; import {ManageDevicesComponent} from "../../../user-settings/manage-devices/manage-devices.component"; import {ManageAuthKeysComponent} from "../../../user-settings/manage-auth-keys/manage-auth-keys.component"; -import { - ManageScrobblingProvidersComponent -} from "../../../user-settings/manage-scrobbling-providers/manage-scrobbling-providers.component"; import { ManageUserPreferencesComponent } from "../../../user-settings/manga-user-preferences/manage-user-preferences.component"; @@ -41,7 +33,6 @@ import { import { ImportMalCollectionComponent } from "../../../collections/_components/import-mal-collection/import-mal-collection.component"; -import {ImportCblComponent} from "../../../reading-list/_components/import-cbl/import-cbl.component"; import {LicenseService} from "../../../_services/license.service"; import {ManageMatchedMetadataComponent} from "../../../admin/manage-matched-metadata/manage-matched-metadata.component"; import {ManageUserTokensComponent} from "../../../admin/manage-user-tokens/manage-user-tokens.component"; @@ -63,15 +54,13 @@ import {ServerActivityComponent} from "../../../admin/server-activity/server-act import {ServerDevicesComponent} from "../../../admin/server-devices/server-devices.component"; import {ManageCustomKeyBindsComponent} from "../../../user-settings/custom-key-binds/manage-custom-key-binds.component"; import {AccountSettingsComponent} from "src/app/user-settings/account-settings/account-settings.component"; +import {CblManagerComponent} from "../../../user-settings/cbl-manager/cbl-manager.component"; +import {ManageRemapRulesComponent} from "../../../user-settings/manage-remap-rules/manage-remap-rules.component"; @Component({ selector: 'app-settings', imports: [ - ChangeAgeRestrictionComponent, - ChangeEmailComponent, - ChangePasswordComponent, ManageDevicesComponent, - ManageScrobblingProvidersComponent, ManageUserPreferencesComponent, SideNavCompanionBarComponent, ThemeManagerComponent, @@ -90,7 +79,6 @@ import {AccountSettingsComponent} from "src/app/user-settings/account-settings/a ManageMediaIssuesComponent, ManageCustomizationComponent, ImportMalCollectionComponent, - ImportCblComponent, ManageMatchedMetadataComponent, ManageUserTokensComponent, EmailHistoryComponent, @@ -105,7 +93,9 @@ import {AccountSettingsComponent} from "src/app/user-settings/account-settings/a ServerDevicesComponent, ManageCustomKeyBindsComponent, ManageAuthKeysComponent, - AccountSettingsComponent + AccountSettingsComponent, + CblManagerComponent, + ManageRemapRulesComponent ], templateUrl: './settings.component.html', styleUrl: './settings.component.scss', diff --git a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts index 882f5a24e..dfb20dc4c 100644 --- a/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts +++ b/UI/Web/src/app/sidenav/preference-nav/preference-nav.component.ts @@ -68,7 +68,8 @@ export enum SettingsTabId { Scrobbling = 'scrobbling', ScrobblingHolds = 'scrobble-holds', Customize = 'customize', - CBLImport = 'cbl-import' + CBLImport = 'cbl-import', + RemapRules = 'remap-rules', } export enum SettingSectionId { @@ -240,6 +241,7 @@ export class PreferenceNavComponent implements AfterViewInit { new SideNavItem(SettingsTabId.Theme), new SideNavItem(SettingsTabId.Font), new SideNavItem(SettingsTabId.Devices), + new SideNavItem(SettingsTabId.RemapRules, [], undefined, [Role.ReadOnly]), ] }, { diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.html b/UI/Web/src/app/typeahead/_components/typeahead.component.html index 32ba8ab85..90d1edc98 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.html +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.html @@ -1,7 +1,7 @@ @if(settings) {
-
+
@if (settings.showLocked) { {{t('locked-field')}} @@ -39,35 +39,56 @@
- @if (filteredOptions | async; as options) { - @if (hasFocus) { - + + + + @if (!useOverlay) { + @if (filteredOptions | async; as options) { + @if (hasFocus) { + + } } } + + + + @if (filteredOptions | async; as options) { + + } + }
diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.scss b/UI/Web/src/app/typeahead/_components/typeahead.component.scss index adf9b9989..fc015de90 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.scss +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.scss @@ -127,6 +127,10 @@ input { overflow: auto; } +.dropdown-overlay { + position: static; +} + .slide-in-from-top { animation: slideDown 200ms ease-out; } diff --git a/UI/Web/src/app/typeahead/_components/typeahead.component.ts b/UI/Web/src/app/typeahead/_components/typeahead.component.ts index 687f23d12..07d052637 100644 --- a/UI/Web/src/app/typeahead/_components/typeahead.component.ts +++ b/UI/Web/src/app/typeahead/_components/typeahead.component.ts @@ -17,6 +17,7 @@ import { TemplateRef, viewChild } from '@angular/core'; +import {CdkConnectedOverlay, CdkOverlayOrigin, ConnectedPosition, ScrollStrategy, ScrollStrategyOptions} from '@angular/cdk/overlay'; import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; import {Observable, ReplaySubject} from 'rxjs'; import {auditTime, filter, map, shareReplay, switchMap, take, tap} from 'rxjs/operators'; @@ -29,7 +30,7 @@ import {SelectionModel} from "../_models/selection-model"; @Component({ selector: 'app-typeahead', - imports: [TagBadgeComponent, ReactiveFormsModule, TranslocoDirective, AsyncPipe, NgTemplateOutlet, NgClass], + imports: [TagBadgeComponent, ReactiveFormsModule, TranslocoDirective, AsyncPipe, NgTemplateOutlet, NgClass, CdkConnectedOverlay, CdkOverlayOrigin], templateUrl: './typeahead.component.html', styleUrls: ['./typeahead.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush @@ -69,9 +70,21 @@ export class TypeaheadComponent implements OnInit { readonly inputElem = viewChild.required>('input'); + readonly triggerEl = viewChild.required>('triggerEl'); readonly optionTemplate = contentChild.required>('optionItem'); readonly badgeTemplate = contentChild.required>('badgeItem'); + protected triggerWidth = 0; + protected readonly overlayPositions: ConnectedPosition[] = [ + { originX: 'start', originY: 'bottom', overlayX: 'start', overlayY: 'top' }, + { originX: 'start', originY: 'top', overlayX: 'start', overlayY: 'bottom' }, + ]; + protected readonly repositionScrollStrategy: ScrollStrategy = inject(ScrollStrategyOptions).reposition(); + + get useOverlay(): boolean { + return this.settings?.dropdownPosition === 'body'; + } + optionSelection!: SelectionModel; hasFocus = false; // Whether input has active focus @@ -335,6 +348,9 @@ export class TypeaheadComponent implements OnInit { setTimeout(() => { this.typeaheadControl.setValue(this.typeaheadControl.value); this.hasFocus = true; + if (this.useOverlay) { + this.triggerWidth = this.triggerEl().nativeElement.getBoundingClientRect().width; + } }); } @@ -356,6 +372,9 @@ export class TypeaheadComponent implements OnInit { inputElem.nativeElement.focus(); this.hasFocus = true; + if (this.useOverlay) { + this.triggerWidth = this.triggerEl().nativeElement.getBoundingClientRect().width; + } } diff --git a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts index f21468c7c..176c7fa4b 100644 --- a/UI/Web/src/app/typeahead/_models/typeahead-settings.ts +++ b/UI/Web/src/app/typeahead/_models/typeahead-settings.ts @@ -72,6 +72,11 @@ export class TypeaheadSettings { * An optional, but recommended trackby identity function to help Angular render the list better */ trackByIdentityFn!: (index: number, value: T) => string; + /** + * Where to render the dropdown. 'relative' (default) uses position: absolute within the form. + * 'body' renders via CDK overlay attached to the document body, avoiding overflow: hidden clipping. + */ + dropdownPosition: 'relative' | 'body' = 'relative'; } /** diff --git a/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.html b/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.html new file mode 100644 index 000000000..ccc0290ce --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.html @@ -0,0 +1,155 @@ + + + + + + + diff --git a/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.scss b/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.scss new file mode 100644 index 000000000..f5286836f --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.scss @@ -0,0 +1,3 @@ +.selected-row { + background-color: var(--primary-color-scrollbar) +} diff --git a/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.ts b/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.ts new file mode 100644 index 000000000..f45352fc9 --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component.ts @@ -0,0 +1,143 @@ +import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core'; +import {NgbActiveModal} from "@ng-bootstrap/ng-bootstrap"; +import {Stack} from "../../../shared/data-structures/stack"; +import {TranslocoDirective} from "@jsverse/transloco"; +import {CblService} from "../../../_services/cbl.service"; +import {LoadingComponent} from "../../../shared/loading/loading.component"; +import {CblRepoItem} from "../../../_models/reading-list/cbl/cbl-repo-item"; +import {GithubRateLimit} from "../../../_models/common/github-rate-limit"; +import {CblRepoBrowseResult} from "../../../_models/reading-list/cbl/cbl-repo-browse-result"; + + +@Component({ + selector: 'app-browse-cbl-repo-modal', + imports: [ + TranslocoDirective, + LoadingComponent + ], + templateUrl: './browse-cbl-repo-modal.component.html', + styleUrl: './browse-cbl-repo-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BrowseCblRepoModalComponent implements OnInit { + protected readonly modal = inject(NgbActiveModal); + private readonly cblService = inject(CblService); + + items = signal([]); + selectedItems = signal>(new Set()); + loading = signal(false); + rateLimit = signal(null); + fromCache = signal(false); + + routeStack = new Stack(); + routeItems = signal([]); + routeStackPeek = computed(() => { + const items = this.routeItems(); + return items.length > 0 ? items[items.length - 1] : undefined; + }); + + folders = computed(() => this.items().filter(i => i.isDirectory)); + files = computed(() => this.items().filter(i => !i.isDirectory)); + hasSelection = computed(() => this.selectedItems().size > 0); + selectionCount = computed(() => this.selectedItems().size); + + allFilesSelected = computed(() => { + const f = this.files(); + if (f.length === 0) return false; + const sel = this.selectedItems(); + return f.every(file => sel.has(file.path)); + }); + + ngOnInit() { + this.loadDirectory(''); + } + + navigateTo(index: number) { + while (this.routeStack.items.length - 1 > index) { + this.routeStack.pop(); + } + this.syncRouteItems(); + this.loadDirectory(this.routeStack.items.join('/')); + } + + openFolder(folder: CblRepoItem) { + this.routeStack.push(folder.name); + this.syncRouteItems(); + this.loadDirectory(folder.path); + } + + goBack() { + this.routeStack.pop(); + this.syncRouteItems(); + this.loadDirectory(this.routeStack.items.join('/')); + } + + toggleFileSelection(file: CblRepoItem) { + this.selectedItems.update(current => { + const next = new Set(current); + if (next.has(file.path)) { + next.delete(file.path); + } else { + next.add(file.path); + } + return next; + }); + } + + toggleAllFiles() { + const files = this.files(); + if (this.allFilesSelected()) { + this.selectedItems.update(current => { + const next = new Set(current); + for (const file of files) { + next.delete(file.path); + } + return next; + }); + } else { + this.selectedItems.update(current => { + const next = new Set(current); + for (const file of files) { + next.add(file.path); + } + return next; + }); + } + } + + isSelected(file: CblRepoItem): boolean { + return this.selectedItems().has(file.path); + } + + download() { + const selected = this.items().filter(i => this.selectedItems().has(i.path)); + this.modal.close(selected); + } + + close() { + this.modal.dismiss(); + } + + private loadDirectory(path: string) { + this.loading.set(true); + + this.cblService.browseRepo(path).subscribe({ + next: (result: CblRepoBrowseResult) => { + this.items.set(result.items); + this.rateLimit.set(result.rateLimit); + this.fromCache.set(result.fromCache); + this.loading.set(false); + }, + error: () => { + // Revert navigation on error + this.routeStack.pop(); + this.syncRouteItems(); + this.loading.set(false); + }, + }); + } + + private syncRouteItems() { + this.routeItems.set([...this.routeStack.items]); + } +} diff --git a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html new file mode 100644 index 000000000..7627d8418 --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.html @@ -0,0 +1,284 @@ + + + + + + + + diff --git a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.scss b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.scss new file mode 100644 index 000000000..51d594d11 --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.scss @@ -0,0 +1,26 @@ +.tier-badge { + font-size: 0.75rem; + font-weight: normal; + cursor: default; +} + +.tier-0 { background-color: var(--bs-info); color: white; cursor: help; } +.tier-1 { background-color: var(--bs-primary); color: white; } +.tier-2 { background-color: var(--bs-success); color: white; } +.tier-3 { background-color: #6f42c1; color: white; } +.tier-4 { background-color: #d63384; color: white; } +.tier-5 { background-color: #fd7e14; color: white; } +.tier-6 { background-color: var(--bs-secondary); color: white; } + +:host ::ng-deep .skipped-row { + color: var(--bs-secondary-color) !important; +} + +:host ::ng-deep .matched-row { + background-color: rgba(var(--bs-success-rgb), 0.05); +} + +.table-wrapper { + max-height: 60vh; + overflow-y: auto; +} diff --git a/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.ts b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.ts new file mode 100644 index 000000000..3696695ce --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/import-cbl-modal/import-cbl-modal.component.ts @@ -0,0 +1,463 @@ +import {ChangeDetectionStrategy, Component, computed, inject, input, OnInit, signal} from '@angular/core'; +import {NgbActiveModal, NgbModal, NgbTooltip} from '@ng-bootstrap/ng-bootstrap'; +import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {CblSavedFile} from '../../../_models/reading-list/cbl/cbl-saved-file'; +import {CblImportSummary} from '../../../_models/reading-list/cbl/cbl-import-summary'; +import {CblBookResult} from '../../../_models/reading-list/cbl/cbl-book-result'; +import {CblImportReason} from '../../../_models/reading-list/cbl/cbl-import-reason.enum'; +import {CblMatchTier} from '../../../_models/reading-list/cbl/cbl-match-tier'; +import {CblImportDecisions} from '../../../_models/reading-list/cbl/cbl-import-decisions'; +import {RemapRule} from '../../../_models/reading-list/cbl/remap-rule'; +import {CblSeriesCandidate} from '../../../_models/reading-list/cbl/cbl-series-candidate'; +import {Chapter} from '../../../_models/chapter'; +import {CblService} from '../../../_services/cbl.service'; +import {SearchService} from '../../../_services/search.service'; +import {ConfirmService} from '../../../shared/confirm.service'; +import {ToastrService} from 'ngx-toastr'; +import {TypeaheadSettings} from '../../../typeahead/_models/typeahead-settings'; +import {SearchResult} from '../../../_models/search/search-result'; +import {UtilityService} from '../../../shared/_services/utility.service'; +import {TypeaheadComponent} from '../../../typeahead/_components/typeahead.component'; +import {LoadingComponent} from '../../../shared/loading/loading.component'; +import {CblImportResult} from '../../../_models/reading-list/cbl/cbl-import-result.enum'; +import {CblMatchTierPipe} from '../../../_pipes/cbl-match-tier.pipe'; +import {CblImportReasonPipe} from '../../../_pipes/cbl-import-reason.pipe'; +import {ManageRemapRulesModalComponent} from '../manage-remap-rules-modal/manage-remap-rules-modal.component'; +import {ImageComponent} from '../../../shared/image/image.component'; +import {ImageService} from '../../../_services/image.service'; +import {map} from 'rxjs'; +import {LibraryService} from '../../../_services/library.service'; +import { + DataTableColumnCellDirective, + DataTableColumnDirective, + DataTableColumnHeaderDirective, + DatatableComponent, +} from '@siemens/ngx-datatable'; +import {CdkScrollable} from '@angular/cdk/scrolling'; +import {RouterLink} from '@angular/router'; +import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component'; +import {modalSaved} from "../../../_models/modal/modal-result"; + +export interface CblIssueRow { + result: CblBookResult; + remapRuleId: number | null; + skipped: boolean; +} + +@Component({ + selector: 'app-import-cbl-modal', + imports: [ + TranslocoDirective, + TypeaheadComponent, + LoadingComponent, + CblMatchTierPipe, + CblImportReasonPipe, + ImageComponent, + DatatableComponent, + DataTableColumnDirective, + DataTableColumnCellDirective, + DataTableColumnHeaderDirective, + NgbTooltip, + CdkScrollable, + RouterLink, + EntityTitleComponent, + ], + templateUrl: './import-cbl-modal.component.html', + styleUrl: './import-cbl-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ImportCblModalComponent implements OnInit { + private readonly modal = inject(NgbActiveModal); + private readonly modalService = inject(NgbModal); + private readonly cblService = inject(CblService); + private readonly searchService = inject(SearchService); + private readonly confirmService = inject(ConfirmService); + private readonly toastr = inject(ToastrService); + private readonly utilityService = inject(UtilityService); + private readonly libraryService = inject(LibraryService); + protected readonly imageService = inject(ImageService); + + protected readonly CblImportReason = CblImportReason; + protected readonly CblImportResult = CblImportResult; + + savedFiles = input.required(); + + currentFileIndex = signal(0); + currentFile = computed(() => this.savedFiles()[this.currentFileIndex()]); + currentSummary = signal(null); + isProcessing = signal(false); + remapRules = signal([]); + + /** All rows (matched + issues) for the unified table */ + allRows = signal([]); + libraryNames = signal>({}); + + showSuccessful = signal(true); + visibleRows = computed(() => { + const rows = this.allRows(); + return this.showSuccessful() ? rows : rows.filter(r => r.result.reason !== CblImportReason.Success); + }); + + matchedCount = computed(() => this.allRows().filter(r => r.result.reason === CblImportReason.Success).length); + issueCount = computed(() => this.allRows().filter(r => r.result.reason !== CblImportReason.Success && !r.skipped).length); + + /** Lazy typeahead state — only one row can be resolving at a time */ + activeRow = signal(null); + activeSeriesTypeahead = signal | null>(null); + activeChapterTypeahead = signal | null>(null); + + /** Track the CBL series name of the row being resolved, so we can auto-continue after re-validation */ + private pendingAutoEditSeries: string | null = null; + + getRowClass = (row: CblIssueRow) => { + if (row.skipped) return 'skipped-row'; + if (row.result.reason === CblImportReason.Success) return 'matched-row'; + return 'issue-row'; + }; + + ngOnInit() { + this.cblService.getRemapRules().subscribe(rules => { + this.remapRules.set(rules); + this.validateCurrentFile(); + }); + + this.libraryService.getLibraryNames().subscribe(names => { + this.libraryNames.set(names); + }); + } + + dismiss() { + this.modal.dismiss(); + } + + previousFile() { + if (this.currentFileIndex() > 0) { + this.currentFileIndex.set(this.currentFileIndex() - 1); + this.validateCurrentFile(); + } + } + + nextFile() { + if (this.currentFileIndex() < this.savedFiles().length - 1) { + this.currentFileIndex.set(this.currentFileIndex() + 1); + this.validateCurrentFile(); + } + } + + validateCurrentFile() { + const file = this.currentFile(); + if (!file) return; + + this.isProcessing.set(true); + this.cancelResolve(); + this.cblService.reValidate(file.fileName).subscribe({ + next: (summary) => { + this.currentSummary.set(summary); + this.buildAllRows(summary); + this.isProcessing.set(false); + + // Auto-continue: if a pending series was just resolved and is now chapter-missing, auto-edit + if (this.pendingAutoEditSeries) { + const seriesName = this.pendingAutoEditSeries; + this.pendingAutoEditSeries = null; + const row = this.allRows().find(r => + r.result.series === seriesName && this.isChapterMissing(r) + ); + if (row) { + this.startResolve(row); + } + } + }, + error: () => { + this.toastr.error(translate('toasts.failed-to-validate')); + this.isProcessing.set(false); + this.pendingAutoEditSeries = null; + } + }); + } + + isSeriesMissing(row: CblIssueRow): boolean { + return row.result.reason === CblImportReason.SeriesMissing || + row.result.reason === CblImportReason.AllSeriesMissing; + } + + isChapterMissing(row: CblIssueRow): boolean { + return row.result.reason === CblImportReason.ChapterMissing || + row.result.reason === CblImportReason.VolumeMissing; + } + + isSeriesCollision(row: CblIssueRow): boolean { + return row.result.reason === CblImportReason.SeriesCollision; + } + + needsAction(row: CblIssueRow): boolean { + return row.result.reason !== CblImportReason.Success && !row.skipped; + } + + /** Whether this row needs a series typeahead */ + needsSeriesTypeahead(row: CblIssueRow): boolean { + return this.isSeriesMissing(row) || + (this.isSeriesCollision(row) && (!row.result.candidates || row.result.candidates.length === 0)); + } + + /** Whether this row is the active editing row showing a series typeahead */ + isEditingSeries(row: CblIssueRow): boolean { + return this.activeRow() === row && this.activeSeriesTypeahead() !== null; + } + + /** Whether this row is the active editing row showing a chapter typeahead */ + isEditingChapter(row: CblIssueRow): boolean { + return this.activeRow() === row && this.activeChapterTypeahead() !== null; + } + + /** Build a minimal Chapter stub for entity-title rendering */ + buildChapterStub(result: CblBookResult): Chapter { + return { + volumeId: 0, + range: result.chapterNumber || result.chapterTitle, + titleName: result.chapterTitle !== result.chapterNumber ? result.chapterTitle : '', + isSpecial: false, + } as Chapter; + } + + /** Auto-detect which typeahead to open based on row reason */ + startResolve(row: CblIssueRow) { + if (this.isSeriesMissing(row) || this.isSeriesCollision(row) || row.result.reason === CblImportReason.Success) { + this.startResolveSeries(row); + } else if (this.isChapterMissing(row)) { + this.startResolveChapter(row); + } + } + + /** Explicitly open the series typeahead for a row */ + startResolveSeries(row: CblIssueRow) { + if (this.activeRow() === row && this.activeSeriesTypeahead() !== null) { + this.cancelResolve(); + return; + } + this.clearActiveState(); + this.activeRow.set(row); + this.activeSeriesTypeahead.set(this.createSeriesTypeahead(row.result)); + this.allRows.set([...this.allRows()]); + } + + /** Explicitly open the chapter typeahead for a row */ + startResolveChapter(row: CblIssueRow) { + if (this.activeRow() === row && this.activeChapterTypeahead() !== null) { + this.cancelResolve(); + return; + } + this.clearActiveState(); + this.activeRow.set(row); + this.activeChapterTypeahead.set( + row.result.seriesId > 0 ? this.createChapterTypeahead(row.result.seriesId) : null + ); + this.allRows.set([...this.allRows()]); + } + + private clearActiveState() { + this.activeRow.set(null); + this.activeSeriesTypeahead.set(null); + this.activeChapterTypeahead.set(null); + } + + cancelResolve() { + this.activeRow.set(null); + this.activeSeriesTypeahead.set(null); + this.activeChapterTypeahead.set(null); + this.allRows.set([...this.allRows()]); + } + + onCandidateSelected(row: CblIssueRow, candidate: CblSeriesCandidate) { + this.handleSeriesSelection(row, candidate.seriesId, candidate.seriesName); + } + + onSeriesTypeaheadSelected(row: CblIssueRow, event: SearchResult[]) { + if (!event || event.length === 0) return; + const selected = event[0]; + + // If editing a matched row and the user picked the same series, just cancel + if (row.result.reason === CblImportReason.Success && selected.seriesId === row.result.seriesId) { + this.cancelResolve(); + return; + } + + this.handleSeriesSelection(row, selected.seriesId, selected.name); + } + + onChapterTypeaheadSelected(row: CblIssueRow, event: Chapter[]) { + if (!event || event.length === 0) return; + const chapter = event[0]; + this.handleChapterSelection(row, chapter); + } + + getRemapRuleTooltip(row: CblIssueRow): string { + if (row.result.matchTier !== CblMatchTier.RemapRule) return ''; + const rule = this.remapRules().find(r => + r.normalizedCblSeriesName === row.result.series.toLowerCase().replace(/[^a-z0-9]/g, '') + || r.cblSeriesName === row.result.series + ); + if (!rule) return translate('import-cbl-modal.remap-rule-used'); + return `${rule.cblSeriesName || rule.normalizedCblSeriesName} → ${rule.seriesNameAtMapping}`; + } + + toggleSkip(row: CblIssueRow) { + if (this.activeRow() === row) { + this.cancelResolve(); + } + row.skipped = !row.skipped; + this.allRows.set([...this.allRows()]); + } + + openRemapRulesModal() { + const ref = this.modalService.open(ManageRemapRulesModalComponent, {size: 'lg'}); + ref.closed.subscribe((hasModifications: boolean) => { + if (hasModifications) { + this.cblService.getRemapRules().subscribe(rules => { + this.remapRules.set(rules); + this.validateCurrentFile(); + }); + } + }); + } + + async finalizeAll() { + this.isProcessing.set(true); + + for (let i = 0; i < this.savedFiles().length; i++) { + const file = this.savedFiles()[i]; + + if (i !== this.currentFileIndex()) { + this.currentFileIndex.set(i); + } + + const decisions: CblImportDecisions = { + itemResolutions: {}, + saveAsRemapRules: false + }; + + const repoMeta = file.repoPath ? { + repoPath: file.repoPath, + downloadUrl: file.downloadUrl!, + sha: file.sha! + } : undefined; + + try { + await this.cblService.finalizeImport(file.fileName, decisions, file.provider, repoMeta).toPromise(); + } catch { + this.toastr.error(translate('toasts.failed-to-import', {name: file.name})); + } + } + + this.isProcessing.set(false); + this.toastr.success(translate('toasts.import-complete')); + this.modal.close(modalSaved(true)); + } + + private buildAllRows(summary: CblImportSummary) { + const allResults = [ + ...(summary.successfulInserts || []), + ...(summary.results || []) + ].sort((a, b) => a.order - b.order); + + const rows: CblIssueRow[] = allResults.map(result => ({ + result, + remapRuleId: null, + skipped: false, + })); + + this.allRows.set(rows); + } + + private async handleSeriesSelection(row: CblIssueRow, seriesId: number, seriesName: string) { + const confirmed = await this.confirmService.confirm( + translate('toasts.save-remap-rule', {from: row.result.series, to: seriesName}) + ); + if (!confirmed) return; + + // Remember this series for auto-continue after re-validation + this.pendingAutoEditSeries = row.result.series; + + this.cblService.createRemapRule(row.result.series, seriesId).subscribe(rule => { + row.remapRuleId = rule.id; + this.remapRules.set([...this.remapRules(), rule]); + this.cancelResolve(); + this.validateCurrentFile(); + }); + } + + private handleChapterSelection(row: CblIssueRow, chapter: Chapter) { + this.cblService.createRemapRule(row.result.series, row.result.seriesId, { + cblVolume: row.result.volume || undefined, + cblNumber: row.result.number || undefined, + volumeId: chapter.volumeId, + chapterId: chapter.id, + }).subscribe(rule => { + row.remapRuleId = rule.id; + this.remapRules.set([...this.remapRules(), rule]); + this.cancelResolve(); + this.validateCurrentFile(); + }); + } + + private createSeriesTypeahead(result: CblBookResult): TypeaheadSettings { + const settings = new TypeaheadSettings(); + settings.minCharacters = 0; + settings.multiple = false; + settings.id = 'cbl-series-' + result.order; + settings.unique = true; + settings.addIfNonExisting = false; + settings.fetchFn = (searchFilter: string) => this.searchService.search(searchFilter).pipe( + map(group => group.series), + map(items => settings.compareFn(items, searchFilter)) + ); + settings.trackByIdentityFn = (idx, item) => item.seriesId + ''; + settings.compareFn = (options: SearchResult[], filter: string) => { + return options.filter(m => { + return this.utilityService.filter(m.name, filter) || this.utilityService.filter(m.localizedName, filter); + }); + }; + settings.selectionCompareFn = (a: SearchResult, b: SearchResult) => { + return a.seriesId === b.seriesId; + }; + settings.dropdownPosition = 'body'; + + return settings; + } + + private createChapterTypeahead(seriesId: number): TypeaheadSettings { + const settings = new TypeaheadSettings(); + settings.minCharacters = 0; + settings.multiple = false; + settings.id = 'cbl-chapter-' + seriesId; + settings.unique = true; + settings.addIfNonExisting = false; + settings.fetchFn = (searchFilter: string) => this.searchService.getChaptersBySeries(seriesId).pipe( + map(chapters => { + if (!searchFilter) return chapters; + const lower = searchFilter.toLowerCase().trim(); + return chapters.filter(c => + c.title?.toLowerCase().includes(lower) || + c.range?.toLowerCase().includes(lower) || + c.titleName?.toLowerCase().includes(lower) + ); + }) + ); + settings.trackByIdentityFn = (idx, item) => item.id + ''; + settings.compareFn = (options: Chapter[], filter: string) => { + if (!filter) return options; + const lower = filter.toLowerCase().trim(); + return options.filter(c => + c.title?.toLowerCase().includes(lower) || + c.range?.toLowerCase().includes(lower) || + c.titleName?.toLowerCase().includes(lower) + ); + }; + settings.selectionCompareFn = (a: Chapter, b: Chapter) => { + return a.id === b.id; + }; + settings.dropdownPosition = 'body'; + + return settings; + } +} diff --git a/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.html b/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.html new file mode 100644 index 000000000..15a8ffdbf --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.html @@ -0,0 +1,55 @@ + + + + + + + diff --git a/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.scss b/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.ts b/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.ts new file mode 100644 index 000000000..89deecfd7 --- /dev/null +++ b/UI/Web/src/app/user-settings/_modals/manage-remap-rules-modal/manage-remap-rules-modal.component.ts @@ -0,0 +1,69 @@ +import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core'; +import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap'; +import {TranslocoDirective} from '@jsverse/transloco'; +import {CblService} from '../../../_services/cbl.service'; +import {AccountService} from '../../../_services/account.service'; +import {RemapRule} from '../../../_models/reading-list/cbl/remap-rule'; +import {Chapter} from '../../../_models/chapter'; +import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component'; + +@Component({ + selector: 'app-manage-remap-rules-modal', + imports: [ + TranslocoDirective, + EntityTitleComponent, + ], + templateUrl: './manage-remap-rules-modal.component.html', + styleUrl: './manage-remap-rules-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ManageRemapRulesModalComponent implements OnInit { + private readonly modal = inject(NgbActiveModal); + private readonly cblService = inject(CblService); + private readonly accountService = inject(AccountService); + + rules = signal([]); + hasModifications = false; + currentUserId = computed(() => this.accountService.currentUser()?.id ?? 0); + + sortedRules = computed(() => { + const userId = this.currentUserId(); + return [...this.rules()].sort((a, b) => { + // User's own rules first, global last + const aIsOwn = a.appUserId === userId && !a.isGlobal; + const bIsOwn = b.appUserId === userId && !b.isGlobal; + if (aIsOwn !== bIsOwn) return aIsOwn ? -1 : 1; + + const aIsGlobal = a.isGlobal; + const bIsGlobal = b.isGlobal; + if (aIsGlobal !== bIsGlobal) return aIsGlobal ? 1 : -1; + + // Within same group, most recently created first + return new Date(b.createdUtc).getTime() - new Date(a.createdUtc).getTime(); + }); + }); + + ngOnInit() { + this.cblService.getRemapRules().subscribe(rules => this.rules.set(rules)); + } + + deleteRule(rule: RemapRule) { + this.cblService.deleteRemapRule(rule.id).subscribe(() => { + this.rules.set(this.rules().filter(r => r.id !== rule.id)); + this.hasModifications = true; + }); + } + + buildChapterStub(rule: RemapRule): Chapter { + return { + volumeId: 0, + range: rule.chapterRange, + titleName: rule.chapterTitleName !== rule.chapterRange ? rule.chapterTitleName : '', + isSpecial: rule.chapterIsSpecial, + } as Chapter; + } + + close() { + this.modal.close(this.hasModifications); + } +} diff --git a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.html b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.html new file mode 100644 index 000000000..dc99d1aa1 --- /dev/null +++ b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.html @@ -0,0 +1,226 @@ + + +
+ @if (!accountService.hasReadOnlyRole()) { +
+ + +
+ } +
+ +

{{t('description')}}

+ + +
+
+
+
+ +
+ +
+
+ + +
+ +
+ + + + +
+
+ +
{{t('downloaded')}}
+ +
    + @for (readingList of filteredLists(); track readingList.id) { + + } @empty { +
  • {{t('no-results')}}
  • + } +
+
+
+ +
+
+
+ + @let selectedItem = selectedList(); + @if (showUploadFlow()) { +
+
+
+
+ {{t('preview-default')}} +
+
+
+
+ + + @if (files && files.length > 0) { + + } @else if (!accountService.hasReadOnlyRole()) { + + + + @switch (uploadMode()) { + @case ('all') { + + } + @case ('url') { +
+
+ + + +
+ +
+ } + } +
+ +
+ } + } @else if(selectedItem) { + +
+ + +
+
+

{{selectedItem.title}}

+
+ @if (!accountService.hasReadOnlyRole()) { + + } + @if (selectedItem.canSync) { + + } +
+
+ + @if (selectedItem.canSync) { + {{selectedItem.provider | readingListProvider}} + } + +
+ @if (selectedItem.ageRating) { + {{selectedItem.ageRating | ageRating}} + } + @if (selectedItem.startingYear > 0) { + @if (selectedItem.ageRating) { + · + } + {{selectedItem.startingMonth}}/{{selectedItem.startingYear}} – {{selectedItem.endingMonth}}/{{selectedItem.endingYear}} + } + @if (selectedItem.ageRating || selectedItem.startingYear > 0) { + · + } + {{selectedItem.itemCount}} {{t('items-count')}} +
+
+
+ + @if (selectedItem.summary) { +
+ +
+ } + + + + @if (selectedItem.canSync && selectedItem.lastSyncedDate) { +
+ {{t('last-synced', {date: (selectedItem.lastSyncedDate | date)})}} + @if (selectedItem.downloadUrl) { + · {{t('source')}}: {{selectedItem.downloadUrl}} + } +
+ } + + } @else { +

You lack the ability to interact with this content.

+ } +
+
+
+
+ + + + @if (item !== undefined) { +
  • +
    +
    + + {{item.title}} +
    + {{item.itemCount}} {{t('items-count')}} + @if (item.canSync) { + {{item.provider | readingListProvider}} + } + @if (item.hasRemoteChange) { + {{t('update-available')}} + } +
    +
  • + } +
    + + +
    diff --git a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss new file mode 100644 index 000000000..e23e813d3 --- /dev/null +++ b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.scss @@ -0,0 +1,77 @@ + +ngx-file-drop ::ng-deep > div { + // styling for the outer drop box + width: 100%; + border: 0.125rem solid var(--primary-color); + border-radius: 0.3125rem; + height: 6.25rem; + margin: auto; + + > div { + // styling for the inner box (template) + width: 100%; + display: inline-block; + + } +} + +.custom-position { + right: 0.9375rem; + top: -2.625rem; +} + +.section-header { + color: var(--primary-color); +} + +.scroller { + max-height: calc(100dvh - 11.25rem); + overflow: hidden; + display: flex; + flex-direction: column; + + @media (max-width: 576px) { + max-height: calc(100dvh - 30rem); + } + + > div { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + } +} + +.list-scroll { + overflow-y: auto; + min-height: 0; + flex: 1 1 auto; +} + +.pill { + font-size: .8rem; + background-color: var(--card-bg-color); + border-radius: 0.375rem; + color: var(--badge-text-color); +} + +.update-available { + color: var(--primary-color); +} + +.btn-group .btn { + font-size: 0.8rem; +} + +.detail-cover { + width: 80px; + min-width: 80px; + height: 120px; + + ::ng-deep img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 0.25rem; + } +} diff --git a/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts new file mode 100644 index 000000000..d8c40b917 --- /dev/null +++ b/UI/Web/src/app/user-settings/cbl-manager/cbl-manager.component.ts @@ -0,0 +1,210 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + computed, + DestroyRef, + inject, + OnInit, + signal +} from '@angular/core'; +import {AccountService} from '../../_services/account.service'; +import {ToastrService} from 'ngx-toastr'; +import {ConfirmService} from '../../shared/confirm.service'; +import {ModalService} from '../../_services/modal.service'; +import {DatePipe, NgTemplateOutlet} from '@angular/common'; +import {FileSystemFileEntry, NgxFileDropEntry, NgxFileDropModule} from 'ngx-file-drop'; +import {ReadingListService} from '../../_services/reading-list.service'; +import {ReadingList, ReadingListProvider} from '../../_models/reading-list'; +import {LoadingComponent} from '../../shared/loading/loading.component'; +import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {BrowseCblRepoModalComponent} from '../_modals/browse-cbl-repo-modal/browse-cbl-repo-modal.component'; +import {ImportCblModalComponent} from '../_modals/import-cbl-modal/import-cbl-modal.component'; +import {CblService} from '../../_services/cbl.service'; +import {CblRepoItem} from '../../_models/reading-list/cbl/cbl-repo-item'; +import {CblSavedFile} from '../../_models/reading-list/cbl/cbl-saved-file'; +import {FormControl, FormGroup, ReactiveFormsModule} from '@angular/forms'; +import {PromotedIconComponent} from '../../shared/_components/promoted-icon/promoted-icon.component'; +import {ReadingListProviderPipe} from '../../_pipes/reading-list-provider.pipe'; +import {forkJoin} from 'rxjs'; +import {ImageService} from '../../_services/image.service'; +import {ReadMoreComponent} from '../../shared/read-more/read-more.component'; +import {ImageComponent} from '../../shared/image/image.component'; +import {AgeRatingPipe} from '../../_pipes/age-rating.pipe'; +import {RouterLink} from '@angular/router'; +import {fullscreenModal} from "../../_models/modal/modal-options"; +import {ModalResult} from "../../_models/modal/modal-result"; + +@Component({ + selector: 'app-cbl-manager', + imports: [ + NgTemplateOutlet, + NgxFileDropModule, + LoadingComponent, + TranslocoDirective, + ReactiveFormsModule, + PromotedIconComponent, + ReadingListProviderPipe, + ReadMoreComponent, + ImageComponent, + AgeRatingPipe, + RouterLink, + DatePipe + ], + templateUrl: './cbl-manager.component.html', + styleUrl: './cbl-manager.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CblManagerComponent implements OnInit { + private readonly destroyRef = inject(DestroyRef); + protected readonly ReadingListProvider = ReadingListProvider; + protected readonly accountService = inject(AccountService); + private readonly toastr = inject(ToastrService); + private readonly cdRef = inject(ChangeDetectorRef); + private readonly confirmService = inject(ConfirmService); + private readonly modalService = inject(ModalService); + private readonly readingListService = inject(ReadingListService); + private readonly cblService = inject(CblService); + protected readonly imageService = inject(ImageService); + + form = new FormGroup({ + cblUrl: new FormControl('', []) + }); + files: NgxFileDropEntry[] = []; + acceptableExtensions = ['.cbl', '.json'].join(','); + uploadMode = signal<'file' | 'url' | 'all'>('all'); + isUploadingCbl = signal(false); + allLists = signal([]); + + selectedList = signal(undefined); + showUploadFlow = computed(() => this.selectedList() === undefined); + + searchTerm = signal(''); + providerFilter = signal(null); + hasUpdateFilter = signal(false); + + filteredLists = computed(() => { + let lists = this.allLists(); + const term = this.searchTerm().toLowerCase().trim(); + const provider = this.providerFilter(); + const hasUpdate = this.hasUpdateFilter(); + + if (term) { + lists = lists.filter(l => l.title.toLowerCase().includes(term)); + } + if (provider !== null) { + lists = lists.filter(l => l.provider === provider); + } + if (hasUpdate) { + lists = lists.filter(l => l.hasRemoteChange); + } + return lists; + }); + + ngOnInit() { + this.readingListService.getReadingLists(false).subscribe(lists => { + this.allLists.set(lists.result); + }); + } + + openBrowseModal() { + this.selectedList.set(undefined); + const ref = this.modalService.open(BrowseCblRepoModalComponent); + ref.closed.subscribe((selected: CblRepoItem[]) => { + if (!selected || selected.length === 0) return; + this.isUploadingCbl.set(true); + this.cblService.importFromRepo(selected).subscribe({ + next: (savedFiles) => { + this.isUploadingCbl.set(false); + this.openImportModal(savedFiles); + }, + error: () => { + this.toastr.error('Failed to download from repo'); + this.isUploadingCbl.set(false); + } + }); + }); + } + + selectList(list: ReadingList | undefined) { + this.selectedList.set(list); + } + + public dropped(files: NgxFileDropEntry[]) { + this.files = files; + this.isUploadingCbl.set(true); + + const uploads$ = files + .filter(f => f.fileEntry.isFile) + .map(droppedFile => { + return new Promise>((resolve) => { + const fileEntry = droppedFile.fileEntry as FileSystemFileEntry; + fileEntry.file((file: File) => { + resolve(this.cblService.importFromFile(file, droppedFile)); + }); + }); + }); + + Promise.all(uploads$).then(observables => { + forkJoin(observables).subscribe({ + next: (savedFiles) => { + this.isUploadingCbl.set(false); + this.files = []; + this.openImportModal(savedFiles); + }, + error: () => { + this.toastr.error('Failed to upload CBL file(s)'); + this.isUploadingCbl.set(false); + this.files = []; + } + }); + }); + } + + uploadFromUrl() { + const url = this.form.get('cblUrl')?.value?.trim(); + if (!url) return; + + this.isUploadingCbl.set(true); + this.cblService.importFromUrl(url).subscribe({ + next: (savedFile) => { + this.form.get('cblUrl')!.setValue(''); + this.isUploadingCbl.set(false); + this.openImportModal([savedFile]); + }, + error: () => { + this.toastr.error('Failed to download CBL file'); + this.isUploadingCbl.set(false); + } + }); + } + + setProviderFilter(provider: ReadingListProvider | null) { + this.providerFilter.set(this.providerFilter() === provider ? null : provider); + } + + async deleteList(list: ReadingList) { + const confirmed = await this.confirmService.confirm(translate('toasts.confirm-delete-reading-list')); + if (!confirmed) return; + this.readingListService.delete(list.id).subscribe(() => { + this.selectedList.set(undefined); + this.refreshLists(); + this.toastr.success(translate('toasts.reading-list-deleted')); + }); + } + + private openImportModal(savedFiles: CblSavedFile[]) { + const ref = this.modalService.open(ImportCblModalComponent, fullscreenModal()); + ref.setInput('savedFiles', savedFiles); + ref.closed.subscribe((res: ModalResult) => { + this.refreshLists(); + this.selectedList.set(undefined); + }); + } + + private refreshLists() { + this.readingListService.getReadingLists(false).subscribe(lists => { + this.allLists.set([...lists.result]); + }); + } +} diff --git a/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.html b/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.html new file mode 100644 index 000000000..f5149ad50 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.html @@ -0,0 +1,347 @@ + + +
    + +
    + + + @if (showCreateForm()) { +
    +
    +
    {{t('new-rule-title')}}
    +
    +
    + + +
    +
    + + + + {{item.name}} + + + {{item.name}} + + +
    +
    + + +
    +
    +
    +
    + } + + +

    {{t('my-rules-header')}}

    + + + + + + + {{t('cbl-name-col')}} + + + {{item.cblSeriesName || item.normalizedCblSeriesName}} + + + + + + {{t('maps-to-col')}} + + + {{item.seriesNameAtMapping}} + + + + + + {{t('vol-issue-col')}} + + + @if (item.cblVolume) { + {{t('volume-num', {num: item.cblVolume})}} + } + @if (item.cblNumber) { + {{t('issue-num', {num: item.cblNumber})}} + } + + + + + + {{t('created-col')}} + + + {{item.createdUtc | date:'shortDate'}} + + + + + + +
    + + @if (isAdmin()) { + + } +
    +
    +
    +
    +
    + + +
    +
    +
    +
    {{item.cblSeriesName || item.normalizedCblSeriesName}}
    +
    + + @if (isAdmin()) { + + } +
    +
    +
    +
    +
    {{t('maps-to-col')}}
    +
    {{item.seriesNameAtMapping}}
    +
    + @if (item.cblVolume || item.cblNumber) { +
    +
    {{t('vol-issue-col')}}
    +
    + @if (item.cblVolume) { Vol. {{item.cblVolume}} } + @if (item.cblNumber) { #{{item.cblNumber}} } +
    +
    + } +
    +
    +
    +
    +
    + + +
    +

    {{t('global-rules-header')}}

    + + + + + + + {{t('cbl-name-col')}} + + + {{item.cblSeriesName || item.normalizedCblSeriesName}} + + + + + + {{t('maps-to-col')}} + + + {{item.seriesNameAtMapping}} + + + + + + {{t('vol-issue-col')}} + + + @if (item.cblVolume) { + {{t('volume-num', {num: item.cblVolume})}} + } + @if (item.cblNumber) { + {{t('issue-num', {num: item.cblNumber})}} + } + + + + + + {{t('created-by-col')}} + + + {{item.createdByUserName}} + + + + + + {{t('created-col')}} + + + {{item.createdUtc | date:'shortDate'}} + + + + + + + @if (isAdmin()) { +
    + +
    + } +
    +
    +
    +
    + + +
    +
    +
    +
    +
    {{item.cblSeriesName || item.normalizedCblSeriesName}}
    + {{t('by')}} {{item.createdByUserName}} +
    + @if (isAdmin()) { + + } +
    +
    +
    +
    {{t('maps-to-col')}}
    +
    {{item.seriesNameAtMapping}}
    +
    +
    +
    +
    +
    +
    + + + @if (isAdmin() && otherUserRules().length > 0) { +
    +

    {{t('other-users-rules-header')}}

    + + + + + + + {{t('cbl-name-col')}} + + + {{item.cblSeriesName || item.normalizedCblSeriesName}} + + + + + + {{t('maps-to-col')}} + + + {{item.seriesNameAtMapping}} + + + + + + {{t('user-col')}} + + + {{item.createdByUserName}} + + + + + + {{t('created-col')}} + + + {{item.createdUtc | date:'shortDate'}} + + + + + + +
    + +
    +
    +
    +
    +
    + + +
    +
    +
    +
    +
    {{item.cblSeriesName || item.normalizedCblSeriesName}}
    + {{item.createdByUserName}} +
    + +
    +
    +
    +
    {{t('maps-to-col')}}
    +
    {{item.seriesNameAtMapping}}
    +
    +
    +
    +
    +
    +
    + } + +
    diff --git a/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.scss b/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.scss new file mode 100644 index 000000000..82c15f632 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.scss @@ -0,0 +1,4 @@ +.custom-position { + right: 0.9375rem; + top: -2.625rem; +} diff --git a/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.ts b/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.ts new file mode 100644 index 000000000..9370737e8 --- /dev/null +++ b/UI/Web/src/app/user-settings/manage-remap-rules/manage-remap-rules.component.ts @@ -0,0 +1,140 @@ +import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core'; +import {CblService} from '../../_services/cbl.service'; +import {AccountService} from '../../_services/account.service'; +import {ConfirmService} from '../../shared/confirm.service'; +import {ToastrService} from 'ngx-toastr'; +import {SearchService} from '../../_services/search.service'; +import {UtilityService} from '../../shared/_services/utility.service'; +import {RemapRule} from '../../_models/reading-list/cbl/remap-rule'; +import {SearchResult} from '../../_models/search/search-result'; +import {TypeaheadSettings} from '../../typeahead/_models/typeahead-settings'; +import {map} from 'rxjs'; +import {translate, TranslocoDirective} from '@jsverse/transloco'; +import {NgxDatatableModule} from '@siemens/ngx-datatable'; +import {ResponsiveTableComponent} from '../../shared/_components/responsive-table/responsive-table.component'; +import {TypeaheadComponent} from '../../typeahead/_components/typeahead.component'; +import {FormsModule} from '@angular/forms'; +import {DatePipe} from '@angular/common'; + +@Component({ + selector: 'app-manage-remap-rules', + templateUrl: './manage-remap-rules.component.html', + styleUrls: ['./manage-remap-rules.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [TranslocoDirective, NgxDatatableModule, ResponsiveTableComponent, TypeaheadComponent, FormsModule, DatePipe] +}) +export class ManageRemapRulesComponent implements OnInit { + + private readonly cblService = inject(CblService); + private readonly accountService = inject(AccountService); + private readonly confirmService = inject(ConfirmService); + private readonly toastr = inject(ToastrService); + private readonly searchService = inject(SearchService); + private readonly utilityService = inject(UtilityService); + + rules = signal([]); + isAdmin = this.accountService.hasAdminRole; + isReadOnly = this.accountService.hasReadOnlyRole; + currentUserId = computed(() => this.accountService.currentUser()?.id ?? 0); + + showCreateForm = signal(false); + newCblSeriesName = ''; + selectedSeries: SearchResult | null = null; + + seriesSettings: TypeaheadSettings; + + myRules = computed(() => { + const userId = this.currentUserId(); + return this.rules().filter(r => r.appUserId === userId && !r.isGlobal); + }); + + globalRules = computed(() => this.rules().filter(r => r.isGlobal)); + + otherUserRules = computed(() => { + const userId = this.currentUserId(); + return this.rules().filter(r => r.appUserId !== userId && !r.isGlobal); + }); + + trackBy = (_idx: number, item: RemapRule) => item.id; + + constructor() { + this.seriesSettings = new TypeaheadSettings(); + this.seriesSettings.minCharacters = 2; + this.seriesSettings.multiple = false; + this.seriesSettings.id = 'remap-series'; + this.seriesSettings.unique = true; + this.seriesSettings.addIfNonExisting = false; + this.seriesSettings.fetchFn = (filter: string) => + this.searchService.search(filter).pipe( + map(group => group.series), + map(items => this.seriesSettings.compareFn(items, filter)), + ); + this.seriesSettings.trackByIdentityFn = (_idx, item) => item.seriesId + ''; + this.seriesSettings.compareFn = (options: SearchResult[], filter: string) => { + return options.filter(m => { + return this.utilityService.filter(m.name, filter) || this.utilityService.filter(m.localizedName, filter); + }); + }; + this.seriesSettings.selectionCompareFn = (a: SearchResult, b: SearchResult) => { + return a.seriesId === b.seriesId; + }; + } + + ngOnInit() { + this.loadRules(); + } + + loadRules() { + const obs = this.isAdmin() ? this.cblService.getAllRemapRules() : this.cblService.getRemapRules(); + obs.subscribe(rules => this.rules.set(rules)); + } + + onSeriesSelected(event: SearchResult[]) { + this.selectedSeries = event.length > 0 ? event[0] : null; + } + + toggleCreateForm() { + this.showCreateForm.update(v => !v); + if (!this.showCreateForm()) { + this.resetCreateForm(); + } + } + + resetCreateForm() { + this.newCblSeriesName = ''; + this.selectedSeries = null; + } + + createRule() { + if (!this.newCblSeriesName.trim() || !this.selectedSeries) return; + + this.cblService.createRemapRule(this.newCblSeriesName.trim(), this.selectedSeries.seriesId).subscribe(rule => { + this.rules.update(rules => [...rules, rule]); + this.showCreateForm.set(false); + this.resetCreateForm(); + this.toastr.success(translate('manage-remap-rules.rule-created')); + }); + } + + async deleteRule(rule: RemapRule) { + if (!await this.confirmService.confirm(translate('manage-remap-rules.confirm-delete'))) return; + this.cblService.deleteRemapRule(rule.id).subscribe(() => { + this.rules.update(rules => rules.filter(r => r.id !== rule.id)); + this.toastr.success(translate('manage-remap-rules.rule-deleted')); + }); + } + + promoteRule(rule: RemapRule) { + this.cblService.promoteRule(rule.id).subscribe(updated => { + this.rules.update(rules => rules.map(r => r.id === updated.id ? updated : r)); + this.toastr.success(translate('manage-remap-rules.rule-promoted')); + }); + } + + demoteRule(rule: RemapRule) { + this.cblService.demoteRule(rule.id).subscribe(updated => { + this.rules.update(rules => rules.map(r => r.id === updated.id ? updated : r)); + this.toastr.success(translate('manage-remap-rules.rule-demoted')); + }); + } +} diff --git a/UI/Web/src/assets/langs/en.json b/UI/Web/src/assets/langs/en.json index db5e1afe9..f95d524d0 100644 --- a/UI/Web/src/assets/langs/en.json +++ b/UI/Web/src/assets/langs/en.json @@ -615,6 +615,31 @@ "failure": "Failure" }, + "cbl-match-tier-pipe": { + "remap-rule": "Remap Rule", + "external-id": "External ID", + "exact-name": "Exact Name", + "comicvine-naming": "ComicVine Naming", + "article-stripped": "Article Stripped", + "reprint-stripped": "Reprint Stripped", + "alternate-series": "Alternate Series", + "user-decision": "User Decision", + "unmatched": "Unmatched" + }, + + "cbl-import-reason-pipe": { + "chapter-missing": "Chapter Missing", + "volume-missing": "Volume Missing", + "series-missing": "Series Not Found", + "name-conflict": "Name Conflict", + "all-series-missing": "All Series Missing", + "empty-file": "Empty File", + "series-collision": "Multiple Series Match", + "all-chapter-missing": "All Chapters Missing", + "success": "Success", + "invalid-file": "Invalid File" + }, + "cbl-conflict-reason-pipe": { "all-series-missing": "Your account is missing access to all series in the list or Kavita does not have anything present in the list.", "chapter-missing": "{{series}}: Chapter {{chapter}} is missing from Kavita. This item will be skipped.", @@ -1996,7 +2021,8 @@ "theme": "Theme", "font": "Epub Fonts", "customize": "Customize", - "cbl-import": "CBL Reading List", + "cbl-import": "Reading List Manager", + "remap-rules": "Remap Rules", "mal-stack-import": "MAL Stack", "admin-public-metadata": "Manage Metadata" }, @@ -2050,6 +2076,142 @@ "key-bind-tooltip-offset-double-page": "Offset pages for double page spread alignment" }, + "cbl-manager": { + "browse-repo": "Browse CBLs", + "description": "You can import Reading Lists from file, url, or browse the CBL Repo. Lists created from an online source will automatically update in the background or on demand.", + "url": "Url to CBL", + "preview-default": "Upload your own CBL v1/v2 file for import", + "enter-an-url-pre-title": "{{font-manager.enter-an-url-pre-title}}", + "drag-n-drop": "{{cover-image-chooser.drag-n-drop}}", + "upload": "{{cover-image-chooser.upload}}", + "upload-continued": "a cbl file", + "downloaded": "Downloaded", + "url-label": "Url", + "load": "Load", + "back": "Back", + "items-count": "items", + "provider-url": "URL", + "provider-file": "File", + "update-available": "Update available", + "search-placeholder": "Search reading lists...", + "filter-has-update": "Has update", + "filter-all": "All", + "filter-local": "Local", + "no-results": "No matching reading lists", + "add": "Add", + "sync": "Sync", + "delete": "Delete", + "view-list": "View Reading List", + "last-synced": "Last synced {{date}}", + "source": "Source" + }, + + "import-cbl-modal": { + "title": "CBL Importer", + "close": "{{common.close}}", + "matched-count": "{{count}} matched", + "issues-count": "{{count}} issues", + "matched-items-header": "Matched Items", + "issues-header": "Items Needing Attention", + "remap-rules-header": "Remap Rules", + "tier-label": "Matched via", + "skip-item": "Skip", + "select-series": "Search for series...", + "pick-candidate": "Select correct series", + "save-remap-prompt": "Save this mapping as a remap rule for future imports?", + "finalize": "Import All", + "finalize-single": "Import", + "previous-file": "Previous", + "next-file": "Next", + "file-counter": "File {{current}} of {{total}}", + "no-remap-rules": "No remap rules", + "delete-rule": "Delete", + "manage-rules": "Manage Remap Rules", + "col-series": "Requested Series", + "col-vol-issue": "Requested Vol / Issue", + "col-status": "Status", + "col-matched-series": "Matched Series", + "col-matched-issue": "Matched Issue", + "col-action": "Action", + "resolve": "Resolve", + "select-chapter": "Select the matching chapter...", + "hide-matched": "Hide Matched", + "show-matched": "Show Matched", + "edit-match": "Edit match", + "cancel": "Cancel", + "undo": "Undo", + "remap-rule-used": "Matched via remap rule", + "import-success": "Successfully imported {{count}} reading list(s)", + "processing": "Processing...", + "new-list": "New", + "update-list": "Update", + "volume-num": "{{common.volume-num-shorthand}}", + "issue-num": "{{common.issue-num-shorthand}}" + }, + + "manage-remap-rules-modal": { + "title": "Manage Remap Rules", + "close": "{{common.close}}", + "no-rules": "No remap rules saved", + "delete-rule": "Delete", + "volume-num": "{{common.volume-num-shorthand}}", + "issue-num": "{{common.issue-num-shorthand}}", + "global": "Global" + }, + + "manage-remap-rules": { + "create-rule": "Create Rule", + "new-rule-title": "New Remap Rule", + "cbl-series-name-label": "CBL Series Name", + "cbl-series-name-placeholder": "Enter CBL series name...", + "kavita-series-label": "Kavita Series", + "save": "{{common.save}}", + "cancel": "{{common.cancel}}", + "my-rules-header": "My Rules", + "global-rules-header": "Global Rules", + "other-users-rules-header": "Other Users' Rules", + "cbl-name-col": "CBL Name", + "maps-to-col": "Maps To", + "vol-issue-col": "Vol / Issue", + "created-col": "Created", + "created-by-col": "Created By", + "user-col": "User", + "delete": "{{common.delete}}", + "promote": "{{actionable.promote}}", + "promote-tooltip": "Promote to global rule", + "demote": "Demote", + "demote-tooltip": "Demote to user rule", + "by": "by", + "rule-created": "Remap rule created", + "rule-deleted": "Remap rule deleted", + "rule-promoted": "Rule promoted to global", + "rule-demoted": "Rule demoted to user scope", + "confirm-delete": "Are you sure you want to delete this remap rule?", + "volume-num": "{{common.volume-num-shorthand}}", + "issue-num": "{{common.issue-num-shorthand}}" + }, + + "browse-cbl-repo-modal": { + "title": "CBL Repo Browser", + "rate-limit-warning": "Kavita is coming close to hitting Rate Limit for GitHub! You have {{remaining}} calls left.", + "rate-limit-resets": "Rate Limit resets {{time}}", + "breadcrumb-alt-label": "Breadcrumbs", + "root-alt-label": "Root folder", + "instructions": "Browse for Reading Lists to download. Downloaded files will be kept in sync with your library.", + "file-browser-label": "CBL Repo Browser", + "type-header": "File Type", + "name-header": "Name", + "select-all-files": "Select All", + "loading": "{{common.loading}}", + "select-file-alt-label": "Select File: {{name}}", + "empty-directory": "Empty Directory", + "cancel": "{{common.cancel}}", + "served-from-cache": "Served from Cache", + "download": "Download", + "owned-label": "Owned" + + }, + "collection-detail": { "no-data": "There are no items. Try adding a series.", "no-data-filtered": "No items match your current filter.", @@ -2194,6 +2356,12 @@ "title": "{{download-queue-drawer.title}}" }, + "reading-list-provider-pipe": { + "none": "System", + "file": "File", + "url": "Url" + }, + "shortcuts-modal": { "title": "Keyboard Shortcuts", "close": "{{common.close}}", @@ -2300,7 +2468,7 @@ "month-label": "Month", "ending-title": "Ending", "starting-title": "Starting", - "promote-label": "Promote", + "promote-label": "{{actionable.promote}}", "promote-tooltip": "Promotion means that the collection can be seen server-wide, not just for you. All series within this collection will still have user-access restrictions placed on them." }, @@ -2319,9 +2487,9 @@ "configure-step": "Configure", "conflicts-step": "Resolve collisions", "finalize-step": "Finalize", - "prev": "{{import-cbl-modal.prev}}", - "import": "{{import-cbl-modal.import}}", - "next": "{{import-cbl-modal.next}}", + "prev": "{{import-cbl.prev}}", + "import": "{{import-cbl.import}}", + "next": "{{import-cbl.next}}", "save": "{{common.save}}", "import-description": "Upload a file you, or someone else, has exported to replace or merge with your current settings.", @@ -2353,7 +2521,7 @@ "finalize-title": "Preview of your settings, press save to finish your import" }, - "import-cbl-modal": { + "import-cbl": { "close": "{{common.close}}", "title": "CBL Import", "help-label": "{{common.help}}", @@ -3305,6 +3473,10 @@ "toasts": { + "save-remap-rule": "Save \"{{from}} → {{to}}\" as a remap rule for future imports?", + "import-complete": "Import complete", + "failed-to-validate": "Failed to validate file", + "failed-to-import": "Failed to import {{name}}", "no-pages": "There are no pages. Kavita was not able to read this archive.", "scan-queued": "Scan queued for {{name}}", "server-settings-updated": "Server settings updated", diff --git a/UI/Web/src/styles.scss b/UI/Web/src/styles.scss index c0cd83a2e..931d886cd 100644 --- a/UI/Web/src/styles.scss +++ b/UI/Web/src/styles.scss @@ -19,6 +19,8 @@ @use '../node_modules/@siemens/ngx-datatable/themes/bootstrap.css' as ngxDatatableBootstrap; @use '../node_modules/@siemens/ngx-datatable/assets/icons.css' as ngxDatatableIcons; +@use '@angular/cdk/overlay-prebuilt.css' as cdkOverlay; + // Import all the customized theme overrides @use './theme/components/input';