diff --git a/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs b/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs index d14802752..2364838be 100644 --- a/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs +++ b/Kavita.API/Repositories/IReadingListRemapRuleRepository.cs @@ -21,6 +21,10 @@ public interface IReadingListRemapRuleRepository /// Admin-only: returns all rules across all users, with user names. /// Task> GetAllRulesAsync(CancellationToken ct = default); + /// + /// Finds an existing rule for the same user with the same CBL matching key (normalized name + volume + number). + /// + Task GetExactRuleAsync(string normalizedCblSeriesName, string? cblVolume, string? cblNumber, int userId, CancellationToken ct = default); void Add(ReadingListRemapRule rule); void Remove(ReadingListRemapRule rule); } diff --git a/Kavita.API/Repositories/ISeriesRepository.cs b/Kavita.API/Repositories/ISeriesRepository.cs index 586398be3..3816935ac 100644 --- a/Kavita.API/Repositories/ISeriesRepository.cs +++ b/Kavita.API/Repositories/ISeriesRepository.cs @@ -125,8 +125,6 @@ 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/ICblImportService.cs b/Kavita.API/Services/ReadingLists/ICblImportService.cs index 6035462d8..0b7ab2513 100644 --- a/Kavita.API/Services/ReadingLists/ICblImportService.cs +++ b/Kavita.API/Services/ReadingLists/ICblImportService.cs @@ -1,4 +1,4 @@ -using System.Threading.Tasks; +using System.Threading.Tasks; using Kavita.Models.DTOs.ReadingLists.CBL; using Kavita.Models.DTOs.ReadingLists.CBL.Import; @@ -7,11 +7,11 @@ namespace Kavita.API.Services.ReadingLists; public interface ICblImportService { - Task ValidateList(int userId, string filePath, CblImportOptions options); + Task ValidateList(int userId, string filePath); /// /// Creates a new RL or updates an existing /// - Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions); + Task UpsertReadingList(int userId, string filePath, CblImportDecisions decisions); /// /// Checks for updates against upstream ReadingList files and attempts to Update reading list. /// diff --git a/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs b/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs index 222787675..219a0f3b3 100644 --- a/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs +++ b/Kavita.Database/Repositories/ReadingListRemapRuleRepository.cs @@ -28,6 +28,7 @@ public class ReadingListRemapRuleRepository(DataContext context, IMapper mapper) return await context.ReadingListRemapRule .Include(r => r.AppUser) .Include(r => r.Chapter) + .Include(r => r.Volume) .Include(r => r.Series).ThenInclude(s => s.Library) .Where(r => r.AppUserId == userId || r.IsGlobal) .OrderByDescending(r => r.AppUserId == userId) @@ -60,6 +61,16 @@ public class ReadingListRemapRuleRepository(DataContext context, IMapper mapper) .ToListAsync(ct); } + public async Task GetExactRuleAsync(string normalizedCblSeriesName, string? cblVolume, string? cblNumber, int userId, CancellationToken ct = default) + { + return await context.ReadingListRemapRule + .FirstOrDefaultAsync(r => + r.NormalizedCblSeriesName == normalizedCblSeriesName + && r.CblVolume == cblVolume + && r.CblNumber == cblNumber + && r.AppUserId == userId, ct); + } + public void Add(ReadingListRemapRule rule) { context.ReadingListRemapRule.Add(rule); diff --git a/Kavita.Database/Repositories/SeriesRepository.cs b/Kavita.Database/Repositories/SeriesRepository.cs index d667abaa5..0d0b480f0 100644 --- a/Kavita.Database/Repositories/SeriesRepository.cs +++ b/Kavita.Database/Repositories/SeriesRepository.cs @@ -1557,24 +1557,6 @@ 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); - } /// diff --git a/Kavita.Models/AutoMapper/AutoMapperProfiles.cs b/Kavita.Models/AutoMapper/AutoMapperProfiles.cs index 1b4c9011e..56a293f2f 100644 --- a/Kavita.Models/AutoMapper/AutoMapperProfiles.cs +++ b/Kavita.Models/AutoMapper/AutoMapperProfiles.cs @@ -350,7 +350,11 @@ public class AutoMapperProfiles : Profile .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)); + opt => opt.MapFrom(src => src.Series.Library != null ? src.Series.Library.Type : LibraryType.Comic)) + .ForMember(dest => dest.VolumeNumber, + opt => opt.MapFrom(src => src.Volume != null ? src.Volume.Name : string.Empty)) + .ForMember(dest => dest.Kind, + opt => opt.MapFrom(src => src.GetKind())); CreateMap() .ForMember(dest => dest.Body, diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs b/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs deleted file mode 100644 index 05996ee2d..000000000 --- a/Kavita.Models/DTOs/ReadingLists/CBL/CblImportOptions.cs +++ /dev/null @@ -1,17 +0,0 @@ -using System.Collections.Generic; - -namespace Kavita.Models.DTOs.ReadingLists.CBL; - -// TODO: Validate if we want to keep this. From testing, it doesn't seem necessary -public sealed record CblImportOptions -{ - /// - /// Weighs ComicVine Matching higher - /// - public bool PreferComicVineMatching { get; set; } - /// - /// Libraries to search against. If empty, will include all - /// - public IList ApplicableLibraries { get; set; } - -} diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs index 2ff4a5fde..330f891bf 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/CreateRemapRuleDto.cs @@ -9,7 +9,7 @@ public sealed record CreateRemapRuleDto public string CblSeriesName { get; set; } = string.Empty; public int SeriesId { get; set; } /// - /// Optional: CBL volume string for issue-level rules + /// Optional: CBL volume string for issue/volume-level rules /// public string? CblVolume { get; set; } /// diff --git a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs index 0ae6d2913..d65e8cf70 100644 --- a/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs +++ b/Kavita.Models/DTOs/ReadingLists/CBL/RemapRules/RemapRuleDto.cs @@ -1,5 +1,6 @@ using System; using Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums.ReadingList; namespace Kavita.Models.DTOs.ReadingLists.CBL.RemapRules; #nullable enable @@ -13,7 +14,9 @@ public sealed record RemapRuleDto public string? CblNumber { get; set; } public int SeriesId { get; set; } public int? VolumeId { get; set; } + public string VolumeNumber { get; set; } = string.Empty; public int? ChapterId { get; set; } + public CblRemapRuleKind Kind { get; set; } public string ChapterRange { get; set; } = string.Empty; public string ChapterTitleName { get; set; } = string.Empty; public bool ChapterIsSpecial { get; set; } diff --git a/Kavita.Models/Entities/Enums/ReadingList/CblRemapRuleKind.cs b/Kavita.Models/Entities/Enums/ReadingList/CblRemapRuleKind.cs new file mode 100644 index 000000000..51a5d2cdc --- /dev/null +++ b/Kavita.Models/Entities/Enums/ReadingList/CblRemapRuleKind.cs @@ -0,0 +1,8 @@ +namespace Kavita.Models.Entities.Enums.ReadingList; + +public enum CblRemapRuleKind +{ + Series = 0, + Volume = 1, + Chapter = 2 +} diff --git a/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs b/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs index 3641bf329..12e3a7284 100644 --- a/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs +++ b/Kavita.Models/Entities/ReadingLists/ReadingListRemapRule.cs @@ -1,4 +1,5 @@ using System; +using Kavita.Models.Entities.Enums.ReadingList; using Kavita.Models.Entities.User; namespace Kavita.Models.Entities.ReadingLists; @@ -68,4 +69,9 @@ public class ReadingListRemapRule public AppUser AppUser { get; set; } = null!; public DateTime CreatedUtc { get; set; } + + public CblRemapRuleKind GetKind() => + ChapterId != null ? CblRemapRuleKind.Chapter : + VolumeId != null ? CblRemapRuleKind.Volume : + CblRemapRuleKind.Series; } diff --git a/Kavita.Models/Entities/Enums/PdfRenderResolutionExtensions.cs b/Kavita.Models/Extensions/PdfRenderResolutionExtensions.cs similarity index 83% rename from Kavita.Models/Entities/Enums/PdfRenderResolutionExtensions.cs rename to Kavita.Models/Extensions/PdfRenderResolutionExtensions.cs index 4d721cc1c..b3b30ea3a 100644 --- a/Kavita.Models/Entities/Enums/PdfRenderResolutionExtensions.cs +++ b/Kavita.Models/Extensions/PdfRenderResolutionExtensions.cs @@ -1,4 +1,6 @@ -namespace Kavita.Models.Entities.Enums; +using Kavita.Models.Entities.Enums; + +namespace Kavita.Models.Extensions; public static class PdfRenderResolutionExtensions { diff --git a/Kavita.Server/Controllers/CBLController.cs b/Kavita.Server/Controllers/CBLController.cs index 596360739..164f4dd1c 100644 --- a/Kavita.Server/Controllers/CBLController.cs +++ b/Kavita.Server/Controllers/CBLController.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -149,7 +149,7 @@ public class CblController(IReadingListService readingListService, IDirectorySer return BadRequest("File not found on server"); } - var summary = await cblImporterService.ValidateList(userId, fullPath, new CblImportOptions()); + var summary = await cblImporterService.ValidateList(userId, fullPath); summary.FileName = dto.FileName; return Ok(summary); } @@ -174,7 +174,7 @@ public class CblController(IReadingListService readingListService, IDirectorySer try { var summary = await cblImporterService.UpsertReadingList( - userId, fullPath, new CblImportOptions(), dto.Decisions); + userId, fullPath, dto.Decisions); summary.FileName = dto.FileName; // Set provider and sync tracking fields @@ -226,7 +226,7 @@ public class CblController(IReadingListService readingListService, IDirectorySer } /// - /// Admin-only: returns all rules across all users. + /// Returns all rules across all users /// [Authorize(Policy = PolicyGroups.AdminPolicy)] [HttpGet("remap-rules/all")] @@ -237,34 +237,72 @@ public class CblController(IReadingListService readingListService, IDirectorySer } /// - /// Creates a new series-level remap rule. + /// Creates a new remap rule, or updates an existing one if a rule with the same + /// CBL matching key (normalized name + volume + number) already exists for this user. + /// When no explicit VolumeId is provided, attempts to auto-resolve a matching volume + /// on the target series from the CBL volume string. /// [HttpPost("remap-rules")] [DisallowRole(PolicyConstants.ReadOnlyRole)] public async Task> CreateRemapRule([FromBody] CreateRemapRuleDto dto) { - var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, ct: HttpContext.RequestAborted); + var ct = HttpContext.RequestAborted; + var series = await unitOfWork.SeriesRepository.GetSeriesByIdAsync(dto.SeriesId, ct: ct); if (series == null) return BadRequest(await localizationService.Translate(UserId, "series-doesnt-exist")); - var rule = new ReadingListRemapRule + var normalizedName = dto.CblSeriesName.ToNormalized(); + + // Auto-resolve VolumeId when the caller didn't provide one and there's a CBL volume string + var volumeId = dto.VolumeId; + if (volumeId == null && dto.ChapterId == null && !string.IsNullOrEmpty(dto.CblVolume) && series.Volumes != null) { - 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 - }; + var realVolumes = series.Volumes + .Where(v => v.MinNumber is not (ParserConstants.LooseLeafVolumeNumber or ParserConstants.SpecialVolumeNumber)) + .ToList(); - unitOfWork.RemapRuleRepository.Add(rule); - await unitOfWork.CommitAsync(); + if (realVolumes.Count > 0) + { + var matched = realVolumes.FirstOrDefault(v => + v.Name.Equals(dto.CblVolume, StringComparison.OrdinalIgnoreCase) + || v.LookupName.Equals(dto.CblVolume, StringComparison.OrdinalIgnoreCase)); + volumeId = matched?.Id; + } + } - return Ok(mapper.Map(rule)); + // Check for an existing rule with the same CBL matching key for this user + var existing = await unitOfWork.RemapRuleRepository.GetExactRuleAsync(normalizedName, dto.CblVolume, dto.CblNumber, UserId, ct); + + if (existing != null) + { + existing.SeriesId = dto.SeriesId; + existing.VolumeId = volumeId; + existing.ChapterId = dto.ChapterId; + existing.CblSeriesName = dto.CblSeriesName; + existing.SeriesNameAtMapping = series.Name; + existing.CreatedUtc = DateTime.UtcNow; + } + else + { + existing = new ReadingListRemapRule + { + NormalizedCblSeriesName = normalizedName, + CblSeriesName = dto.CblSeriesName, + SeriesId = dto.SeriesId, + CblVolume = dto.CblVolume, + CblNumber = dto.CblNumber, + VolumeId = volumeId, + ChapterId = dto.ChapterId, + SeriesNameAtMapping = series.Name, + AppUserId = UserId, + IsGlobal = false, + CreatedUtc = DateTime.UtcNow + }; + unitOfWork.RemapRuleRepository.Add(existing); + } + + await unitOfWork.CommitAsync(ct); + + return Ok(mapper.Map(existing)); } /// diff --git a/Kavita.Services.Tests/Helpers/CblFileBuilder.cs b/Kavita.Services.Tests/Helpers/CblFileBuilder.cs index 2d559fc65..28ee077fd 100644 --- a/Kavita.Services.Tests/Helpers/CblFileBuilder.cs +++ b/Kavita.Services.Tests/Helpers/CblFileBuilder.cs @@ -20,7 +20,7 @@ public class CblFileBuilder public static CblFileBuilder Create(string name) => new(name); public CblFileBuilder AddBook(string series, string volume = "", string number = "", - List? externalIds = null) + string year = "", List? externalIds = null) { _items.Add(new ParsedCblItem { @@ -28,6 +28,7 @@ public class CblFileBuilder SeriesName = series, Volume = volume, Number = number, + Year = year, ExternalIds = externalIds ?? [] }); return this; diff --git a/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs b/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs index 96bafa366..0ea3a0395 100644 --- a/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs +++ b/Kavita.Services.Tests/ReadingLists/CblExportServiceTests.cs @@ -185,7 +185,7 @@ public class CblExportServiceTests 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.Null(first.Databases[0].Series); // Series is the series id and not the Series name Assert.Equal("cv-12345", first.Databases[0].Issue); } @@ -248,11 +248,9 @@ public class CblExportServiceTests 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 { diff --git a/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs b/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs index 337698f0c..63c302ca4 100644 --- a/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs +++ b/Kavita.Services.Tests/ReadingLists/CblImportServiceTests.cs @@ -33,7 +33,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Equal(3, summary.SuccessfulInserts.Count); @@ -56,7 +56,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Partial, summary.Success); Assert.Equal(2, summary.SuccessfulInserts.Count); @@ -77,7 +77,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Fail, summary.Success); Assert.Contains(summary.Results, r => r.Reason == CblImportReason.SeriesMissing); @@ -94,7 +94,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Fail, summary.Success); Assert.Contains(summary.Results, r => r.Reason == CblImportReason.EmptyFile); @@ -119,7 +119,7 @@ public class CblImportServiceTests : AbstractDbTest ItemResolutions = new Dictionary(), SaveAsRemapRules = false }; - var summary = await svc.UpsertReadingList(seed.User.Id, filePath, new CblImportOptions(), decisions); + var summary = await svc.UpsertReadingList(seed.User.Id, filePath, decisions); Assert.False(summary.IsUpdate); @@ -155,7 +155,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.True(summary.IsUpdate); } @@ -173,7 +173,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.False(summary.IsUpdate); } @@ -209,7 +209,7 @@ public class CblImportServiceTests : AbstractDbTest ItemResolutions = new Dictionary(), SaveAsRemapRules = false }; - var summary = await svc.UpsertReadingList(seed.User.Id, filePath, new CblImportOptions(), decisions); + var summary = await svc.UpsertReadingList(seed.User.Id, filePath, decisions); Assert.True(summary.IsUpdate); @@ -249,7 +249,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -286,7 +286,7 @@ public class CblImportServiceTests : AbstractDbTest // 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()); + var summary1 = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary1.Success); // User2 should NOT match (no remap, and "Fable" doesn't match "Fables" exactly) @@ -294,7 +294,7 @@ public class CblImportServiceTests : AbstractDbTest .AddBook("Fable", volume: "1", number: "1") .Build(); var filePath2 = helper.WriteCblToDisk(cbl2); - var summary2 = await svc.ValidateList(user2.Id, filePath2, new CblImportOptions()); + var summary2 = await svc.ValidateList(user2.Id, filePath2); Assert.Contains(summary2.Results, r => r.Reason == CblImportReason.SeriesMissing); } @@ -326,7 +326,7 @@ public class CblImportServiceTests : AbstractDbTest }, SaveAsRemapRules = true }; - await svc.UpsertReadingList(seed.User.Id, filePath, new CblImportOptions(), decisions); + await svc.UpsertReadingList(seed.User.Id, filePath, decisions); // Verify remap rule was persisted var rules = await unitOfWork.RemapRuleRepository.GetRulesForUserAsync(seed.User.Id); @@ -336,18 +336,8 @@ public class CblImportServiceTests : AbstractDbTest r.SeriesId == fablesIds.SeriesId); } - /// - /// A series-only remap (CblVolume=null) must not cause false positives when the CBL - /// contains multiple entries that share the same series name but differ by volume. - /// In Comic libraries the volume year is appended to the series name, so - /// "Batman" Vol 2014 -> "Batman (2014)" and "Batman" Vol 1994 -> "Batman (1994)". - /// A remap rule mapping "Batman" -> "Batman (2014)" should only succeed when - /// the volume actually resolves within "Batman (2014)". When the CBL entry has - /// Volume="1994", the remap should fall through and let Tier 3 (ComicVine naming) - /// resolve it to "Batman (1994)" instead. - /// [Fact] - public async Task ValidateList_SeriesRemap_FallsThroughWhenVolumeDoesNotResolve() + public async Task ValidateList_SeriesVolumeRemap() { var (unitOfWork, context, _) = await CreateDatabase(); using var helper = new CblTestHelper(unitOfWork); @@ -361,6 +351,7 @@ public class CblImportServiceTests : AbstractDbTest { NormalizedCblSeriesName = "Batman".ToNormalized(), CblSeriesName = "Batman", + CblVolume = "2014", SeriesId = batman2014Ids.SeriesId, SeriesNameAtMapping = "Batman (2014)", AppUserId = seed.User.Id, @@ -375,7 +366,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Equal(2, summary.SuccessfulInserts.Count); @@ -426,7 +417,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -466,7 +457,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -517,7 +508,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -547,7 +538,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(teenUser.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(teenUser.Id, filePath); Assert.Equal(CblImportResult.Partial, summary.Success); // Batman (Teen) should succeed, Fables (Mature) should be missing @@ -570,7 +561,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Equal(2, summary.SuccessfulInserts.Count); @@ -614,7 +605,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(teenUser.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(teenUser.Id, filePath); Assert.Contains(summary.Results, r => r.Reason == CblImportReason.SeriesMissing); } @@ -657,7 +648,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(teenUser.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(teenUser.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -682,7 +673,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -704,7 +695,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -726,7 +717,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -749,7 +740,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -788,7 +779,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(user2.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(user2.Id, filePath); Assert.Equal(CblImportResult.Fail, summary.Success); } @@ -834,64 +825,13 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(user.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(user.Id, filePath); 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 @@ -932,7 +872,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(user.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(user.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -971,7 +911,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -1009,7 +949,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -1046,7 +986,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -1099,7 +1039,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -1148,7 +1088,7 @@ public class CblImportServiceTests : AbstractDbTest var filePath = helper.WriteCblToDisk(cbl); var svc = helper.CreateImportService(); - var summary = await svc.ValidateList(seed.User.Id, filePath, new CblImportOptions()); + var summary = await svc.ValidateList(seed.User.Id, filePath); Assert.Equal(CblImportResult.Success, summary.Success); Assert.Single(summary.SuccessfulInserts); @@ -1158,4 +1098,502 @@ public class CblImportServiceTests : AbstractDbTest } #endregion + + #region Group 10: Series Remap — Volume Fallback to Loose-Leaf + + /// + /// When a series-level remap targets a manga series with only loose-leaf issues, + /// the CBL volume (e.g. "2005") doesn't exist in the target series. The matcher + /// should fall back to loose-leaf volume and resolve the chapter there. + /// + [Fact] + public async Task ValidateList_SeriesRemap_FallsBackToLooseLeafWhenVolumeMissing() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("manga-loose-leaf.json"); + + var adventureTimeIds = seed.Lookup[("Adventure Time", "-100000", "1")]; + + // Series-level remap: "Zombie Tales" -> Adventure Time (manga, loose-leaf only) + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Zombie Tales".ToNormalized(), + CblSeriesName = "Zombie Tales", + SeriesId = adventureTimeIds.SeriesId, + SeriesNameAtMapping = "Adventure Time", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + // CBL has Volume="2005" which doesn't exist in Adventure Time + var cbl = CblFileBuilder.Create("Loose Leaf Fallback Test") + .AddBook("Zombie Tales", volume: "2005", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.RemapRule, summary.SuccessfulInserts.First().MatchTier); + Assert.Equal(adventureTimeIds.SeriesId, summary.SuccessfulInserts.First().SeriesId); + } + + /// + /// Even with the loose-leaf fallback, if the chapter doesn't exist in the + /// loose-leaf volume either, the result should still report failure. + /// + [Fact] + public async Task ValidateList_SeriesRemap_LooseLeafFallback_ChapterMissing() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("manga-loose-leaf.json"); + + var adventureTimeIds = seed.Lookup[("Adventure Time", "-100000", "1")]; + + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Zombie Tales".ToNormalized(), + CblSeriesName = "Zombie Tales", + SeriesId = adventureTimeIds.SeriesId, + SeriesNameAtMapping = "Adventure Time", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + // Verify the rule was actually persisted + var rules = await unitOfWork.RemapRuleRepository.GetRulesForUserAsync(seed.User.Id); + Assert.NotEmpty(rules); + + // Chapter 99 doesn't exist anywhere in Adventure Time + var cbl = CblFileBuilder.Create("Missing Chapter Test") + .AddBook("Zombie Tales", volume: "2005", number: "99") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Fail, summary.Success); + Assert.Contains(summary.Results, r => r.Reason == CblImportReason.ChapterMissing); + } + + /// + /// When the series-level remap targets a Comic series where the volume DOES exist, + /// the fallback should NOT activate — the volume should resolve directly. + /// + [Fact] + public async Task ValidateList_SeriesRemap_VolumeExistsInTarget_NoFallback() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("comic-multi-volume-series.json"); + + var batman2014Ids = seed.Lookup[("Batman (2014)", "2014", "1")]; + + // Series-level remap: "Dark Knight" -> Batman (2014) + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Dark Knight".ToNormalized(), + CblSeriesName = "Dark Knight", + SeriesId = batman2014Ids.SeriesId, + SeriesNameAtMapping = "Batman (2014)", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + // Volume 2014 exists in Batman (2014) — should resolve directly, no fallback needed + var cbl = CblFileBuilder.Create("Direct Volume Match Test") + .AddBook("Dark Knight", volume: "2014", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.RemapRule, summary.SuccessfulInserts.First().MatchTier); + Assert.Equal(batman2014Ids.SeriesId, summary.SuccessfulInserts.First().SeriesId); + Assert.Equal(batman2014Ids.ChapterId, summary.SuccessfulInserts.First().ChapterId); + } + + /// + /// Series-level remap with multiple CBL entries: some volumes exist in the target, + /// some fall back to loose-leaf. + /// + [Fact] + public async Task ValidateList_SeriesRemap_MultipleEntries_MixedResolution() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("manga-loose-leaf.json"); + + var ch1Ids = seed.Lookup[("Adventure Time", "-100000", "1")]; + var ch3Ids = seed.Lookup[("Adventure Time", "-100000", "3")]; + + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Zombie Tales".ToNormalized(), + CblSeriesName = "Zombie Tales", + SeriesId = ch1Ids.SeriesId, + SeriesNameAtMapping = "Adventure Time", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Mixed Resolution Test") + .AddBook("Zombie Tales", volume: "2005", number: "1") + .AddBook("Zombie Tales", volume: "2005", number: "3") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Equal(2, summary.SuccessfulInserts.Count); + Assert.All(summary.SuccessfulInserts, r => Assert.Equal(CblMatchTier.RemapRule, r.MatchTier)); + } + + #endregion + + #region Group 11: Name Matching Tiers + + /// + /// Tier 3: Comic naming pattern — "Batman" with Volume="2014" should match series "Batman (2014)" + /// + [Fact] + public async Task ValidateList_ComicNamingPattern_MatchesTier3() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("comic-multi-volume-series.json"); + + var batman2014Ids = seed.Lookup[("Batman (2014)", "2014", "1")]; + + var cbl = CblFileBuilder.Create("Comic Naming Test") + .AddBook("Batman", volume: "2014", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.ComicVineNaming, summary.SuccessfulInserts.First().MatchTier); + Assert.Equal(batman2014Ids.SeriesId, summary.SuccessfulInserts.First().SeriesId); + } + + /// + /// Tier 4: Article-stripped — "The Fables" should match series "Fables" with articles removed + /// + [Fact] + public async Task ValidateList_ArticleStripped_MatchesTier4() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Article Stripped Test") + .AddBook("The Fables", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.ArticleStripped, summary.SuccessfulInserts.First().MatchTier); + } + + /// + /// Tier 5: Reprint-stripped — "Fables Deluxe Edition" should match "Fables" with suffix removed + /// + [Fact] + public async Task ValidateList_ReprintStripped_MatchesTier5() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var cbl = CblFileBuilder.Create("Reprint Stripped Test") + .AddBook("Fables Deluxe Edition", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(CblMatchTier.ReprintStripped, summary.SuccessfulInserts.First().MatchTier); + } + + #endregion + + #region Group 12: Global Remap Rules + + [Fact] + public async Task ValidateList_GlobalRemap_AppliesForAnyUser() + { + 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 global remap rule (created by user1, visible to all) + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fable".ToNormalized(), + CblSeriesName = "Fable", + SeriesId = fablesIds.SeriesId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + IsGlobal = true, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + var cbl = CblFileBuilder.Create("Global Remap Test") + .AddBook("Fable", volume: "1", number: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + + // Both users should match via global remap + var summary1 = await svc.ValidateList(seed.User.Id, filePath); + Assert.Equal(CblImportResult.Success, summary1.Success); + Assert.Equal(CblMatchTier.RemapRule, summary1.SuccessfulInserts.First().MatchTier); + + var cbl2 = CblFileBuilder.Create("Global Remap Test 2") + .AddBook("Fable", volume: "1", number: "1") + .Build(); + var filePath2 = helper.WriteCblToDisk(cbl2); + var summary2 = await svc.ValidateList(user2.Id, filePath2); + Assert.Equal(CblImportResult.Success, summary2.Success); + Assert.Equal(CblMatchTier.RemapRule, summary2.SuccessfulInserts.First().MatchTier); + } + + [Fact] + public async Task ValidateList_UserRemap_TakesPrecedenceOverGlobal() + { + 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")]; + var batmanIds = seed.Lookup[("Batman", "2016", "1")]; + + // Global remap created by user2: "Fable" -> Batman + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fable".ToNormalized(), + CblSeriesName = "Fable", + SeriesId = batmanIds.SeriesId, + SeriesNameAtMapping = "Batman", + AppUserId = user2.Id, + IsGlobal = true, + CreatedUtc = DateTime.UtcNow + }); + + // User-specific remap for user1: "Fable" -> Fables (should win) + 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("User vs Global Precedence") + .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); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + // User-specific rule should win — matched to Fables, not Batman + Assert.Equal(fablesIds.SeriesId, summary.SuccessfulInserts.First().SeriesId); + } + + #endregion + + #region Group 13: Series Disambiguation + + /// + /// When two series share the same name, the CBL Year field should disambiguate. + /// + [Fact] + public async Task ValidateList_SeriesDisambiguation_ByYear() + { + var (unitOfWork, context, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + + var library = new LibraryBuilder("Comics", LibraryType.Comic) + .WithFolderPath(new FolderPathBuilder("/data/comics").Build()) + .Build(); + + var series2000 = new SeriesBuilder("Fables") + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2000).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + + var series2020 = new SeriesBuilder("Fables") + .WithMetadata(new SeriesMetadataBuilder().WithReleaseYear(2020).Build()) + .WithVolume(new VolumeBuilder("1") + .WithChapter(new ChapterBuilder("1").Build()) + .Build()) + .Build(); + + library.Series = [series2000, series2020]; + + var user = new AppUserBuilder("disambiguser", "disambig@test.com") + .WithLibrary(library) + .Build(); + context.AppUser.Add(user); + await context.SaveChangesAsync(); + context.ChangeTracker.Clear(); + + // CBL entry with Year="2020" should match the 2020 series + var cbl = CblFileBuilder.Create("Disambiguation Test") + .AddBook("Fables", volume: "1", number: "1", year: "2020") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(user.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(series2020.Id, summary.SuccessfulInserts.First().SeriesId); + } + + #endregion + + #region Group 14: Issue-Only Remap Rules + + /// + /// An issue-only remap (CblNumber set, CblVolume empty) should match any CBL entry + /// with that issue number regardless of volume. + /// + [Fact] + public async Task ValidateList_IssueOnlyRemap_MatchesRegardlessOfVolume() + { + 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")]; + + // Issue-only remap: Fables #2 (any volume) + unitOfWork.RemapRuleRepository.Add(new ReadingListRemapRule + { + NormalizedCblSeriesName = "Fables".ToNormalized(), + CblSeriesName = "Fables", + CblNumber = "2", + SeriesId = ch2Ids.SeriesId, + VolumeId = ch2Ids.VolumeId, + ChapterId = ch2Ids.ChapterId, + SeriesNameAtMapping = "Fables", + AppUserId = seed.User.Id, + CreatedUtc = DateTime.UtcNow + }); + await unitOfWork.CommitAsync(); + + // CBL with volume="5" (doesn't exist) but issue #2 should still match via issue-only remap + var cbl = CblFileBuilder.Create("Issue Only Remap Test") + .AddBook("Fables", volume: "5", number: "2") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + 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); + } + + #endregion + + #region Group 15: Chapter Resolution Edge Cases + + /// + /// When no chapter number is specified in the CBL entry, should default to first chapter in volume. + /// + [Fact] + public async Task ValidateList_NoChapterNumber_DefaultsToFirstChapter() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("simple-comic.json"); + + var ch1Ids = seed.Lookup[("Fables", "1", "1")]; + + var cbl = CblFileBuilder.Create("No Chapter Number Test") + .AddBook("Fables", volume: "1") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(ch1Ids.ChapterId, summary.SuccessfulInserts.First().ChapterId); + } + + /// + /// When no volume is specified, chapters should be searched across all volumes. + /// + [Fact] + public async Task ValidateList_NoVolume_SearchesAcrossAllVolumes() + { + var (unitOfWork, _, _) = await CreateDatabase(); + using var helper = new CblTestHelper(unitOfWork); + var seed = await helper.SeedLibrary("manga-loose-leaf.json"); + + // One Piece has Volume 1 (chapters 1-3) and Volume 2 (chapters 4-6) + var ch5Ids = seed.Lookup[("One Piece", "2", "5")]; + + // No volume specified, chapter 5 is in volume 2 + var cbl = CblFileBuilder.Create("Cross Volume Search Test") + .AddBook("One Piece", number: "5") + .Build(); + + var filePath = helper.WriteCblToDisk(cbl); + var svc = helper.CreateImportService(); + var summary = await svc.ValidateList(seed.User.Id, filePath); + + Assert.Equal(CblImportResult.Success, summary.Success); + Assert.Single(summary.SuccessfulInserts); + Assert.Equal(ch5Ids.ChapterId, summary.SuccessfulInserts.First().ChapterId); + } + + #endregion } diff --git a/Kavita.Services.Tests/Test Data/CblImportService/manga-loose-leaf.json b/Kavita.Services.Tests/Test Data/CblImportService/manga-loose-leaf.json new file mode 100644 index 000000000..47057235a --- /dev/null +++ b/Kavita.Services.Tests/Test Data/CblImportService/manga-loose-leaf.json @@ -0,0 +1,19 @@ +{ + "libraryName": "Manga", + "libraryType": "Manga", + "series": [ + { + "name": "Adventure Time", + "volumes": [ + { "number": "-100000", "chapters": ["1", "2", "3", "4", "5"] } + ] + }, + { + "name": "One Piece", + "volumes": [ + { "number": "1", "chapters": ["1", "2", "3"] }, + { "number": "2", "chapters": ["4", "5", "6"] } + ] + } + ] +} diff --git a/Kavita.Services/BookService.cs b/Kavita.Services/BookService.cs index d5800614b..9e19d55ae 100644 --- a/Kavita.Services/BookService.cs +++ b/Kavita.Services/BookService.cs @@ -22,6 +22,7 @@ using Kavita.Models.DTOs.Reader; using Kavita.Models.Entities; using Kavita.Models.Entities.Enums; using Kavita.Models.Entities.Enums.User; +using Kavita.Models.Extensions; using Kavita.Models.Metadata; using Kavita.Models.Parser; using Kavita.Services.Extensions; diff --git a/Kavita.Services/ReadingLists/CblExportService.cs b/Kavita.Services/ReadingLists/CblExportService.cs index 3bda82595..30b6788e0 100644 --- a/Kavita.Services/ReadingLists/CblExportService.cs +++ b/Kavita.Services/ReadingLists/CblExportService.cs @@ -35,6 +35,18 @@ public interface ICblExportService public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger logger) : ICblExportService { + private static readonly XmlWriterSettings CblV1XmlOptions = new XmlWriterSettings + { + Indent = true, + Encoding = System.Text.Encoding.UTF8, + }; + + private static readonly JsonSerializerOptions CblV2JsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, + }; + /// public async Task ExportReadingList(int readingListId, int userId, bool asV2 = false) { @@ -98,24 +110,17 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService ? 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; - } - + var seriesName = GetSeriesAndYearFromName(item, ref year); books.Add(new CblBook { 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 + Volume = item.Volume.Name, // NOTE: 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.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), - Databases = GetV1Databases(item.Chapter, seriesName), + Databases = GetV1Databases(item.Chapter, item.Series), }); } @@ -131,17 +136,25 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService }; } + private static string GetSeriesAndYearFromName(ReadingListItem item, ref string year) + { + var seriesName = item.Series.Name; + var group = SeriesAndYearRegex().Matches(item.Series.Name); + if (group.Count > 1) + { + seriesName = group[0].Groups["Series"].Value.Trim(); + year = group[0].Groups["Year"].Value.Trim(); + } + + return seriesName; + } + public static void SerializeV1(CblReadingList cbl, string filePath) { var serializer = new XmlSerializer(typeof(CblReadingList)); - var settings = new XmlWriterSettings - { - Indent = true, - Encoding = System.Text.Encoding.UTF8, - }; using var stream = File.Create(filePath); - using var writer = XmlWriter.Create(stream, settings); + using var writer = XmlWriter.Create(stream, CblV1XmlOptions); serializer.Serialize(writer, cbl); } @@ -153,25 +166,26 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService var issues = new List(); foreach (var item in items) { + var year = string.Empty; + var seriesName = GetSeriesAndYearFromName(item, ref year); + var coverDate = item.Chapter.ReleaseDate != DateTime.MinValue ? item.Chapter.ReleaseDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture) : string.Empty; var seriesStartYear = item.Series.Metadata?.ReleaseYear is > 0 ? item.Series.Metadata.ReleaseYear - : (int?)null; + : int.TryParse(year, out var parsedYear) ? parsedYear : (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, + SeriesName = seriesName, SeriesStartYear = seriesStartYear, IssueNumber = item.Chapter.Range, IssueCoverDate = coverDate, IssueType = string.Empty, - Id = GetExternalIds(item.Chapter, seriesName) + Id = GetExternalIds(item.Chapter, item.Series) }); } @@ -201,29 +215,39 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService }; } - private static List GetV1Databases(Chapter chapter, string seriesName) + private static List GetV1Databases(Chapter chapter, Series series) { var results = new List(); if (!string.IsNullOrEmpty(chapter.ComicVineId)) - results.Add(new CblBookDatabase { Name = "cv", Series = seriesName, Issue = chapter.ComicVineId }); + { + if (!string.IsNullOrEmpty(series.ComicVineId)) + { + results.Add(new CblBookDatabase { Name = "cv", Series = series.ComicVineId, Issue = chapter.ComicVineId }); + } + else + { + results.Add(new CblBookDatabase { Name = "cv", Issue = chapter.ComicVineId }); + } + } + if (chapter.MetronId > 0) - results.Add(new CblBookDatabase { Name = "metron", Series = seriesName, Issue = chapter.MetronId.ToString() }); + results.Add(new CblBookDatabase { Name = "metron", Issue = chapter.MetronId.ToString() }); if (chapter.AniListId > 0) - results.Add(new CblBookDatabase { Name = "anilist", Series = seriesName, Issue = chapter.AniListId.ToString() }); + results.Add(new CblBookDatabase { Name = "anilist", Series = chapter.AniListId.ToString(), Issue = chapter.AniListId.ToString() }); if (chapter.MalId > 0) - results.Add(new CblBookDatabase { Name = "malist", Series = seriesName, Issue = chapter.MalId.ToString() }); + results.Add(new CblBookDatabase { Name = "malist", Series = chapter.MalId.ToString(), Issue = chapter.MalId.ToString() }); if (chapter.HardcoverId > 0) - results.Add(new CblBookDatabase { Name = "hardcover", Series = seriesName, Issue = chapter.HardcoverId.ToString() }); + results.Add(new CblBookDatabase { Name = "hardcover", Issue = chapter.HardcoverId.ToString() }); return results; } - private static List GetExternalIds(Chapter chapter, string seriesName) + private static List GetExternalIds(Chapter chapter, Series series) { var results = new List(); if (chapter.AniListId > 0) @@ -232,7 +256,7 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService { Issue = chapter.AniListId.ToString(), Name = "anilist", - Series = seriesName + Series = chapter.AniListId.ToString() }); } @@ -242,7 +266,7 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService { Issue = chapter.MalId.ToString(), Name = "malist", - Series = seriesName + Series = chapter.MalId.ToString() }); } @@ -252,7 +276,7 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService { Issue = chapter.ComicVineId, Name = "cv", - Series = seriesName + Series = series.ComicVineId }); } @@ -262,7 +286,7 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService { Issue = chapter.MetronId.ToString(), Name = "metron", - Series = seriesName + Series = series.MetronId.ToString() }); } @@ -272,7 +296,7 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService { Issue = chapter.HardcoverId.ToString(), Name = "hardcover", - Series = seriesName + Series = series.HardcoverId.ToString() }); } @@ -281,13 +305,8 @@ public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService public static void SerializeV2(CblV2Root root, string filePath) { - var options = new JsonSerializerOptions - { - WriteIndented = true, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - }; - var json = JsonSerializer.Serialize(root, options); + var json = JsonSerializer.Serialize(root, CblV2JsonOptions); File.WriteAllText(filePath, json); } diff --git a/Kavita.Services/ReadingLists/CblImportService.cs b/Kavita.Services/ReadingLists/CblImportService.cs index 59ff3a846..996613539 100644 --- a/Kavita.Services/ReadingLists/CblImportService.cs +++ b/Kavita.Services/ReadingLists/CblImportService.cs @@ -21,7 +21,7 @@ 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) + public async Task ValidateList(int userId, string filePath) { ParsedCblReadingList cbl; try @@ -53,7 +53,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu }; } - var matchResults = await RunMatchingPipeline(userId, cbl, options); + var matchResults = await RunMatchingPipeline(userId, cbl); var summary = BuildSummary(cbl, filePath, matchResults); var existingList = await unitOfWork.ReadingListRepository @@ -63,7 +63,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu return summary; } - public async Task UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions) + public async Task UpsertReadingList(int userId, string filePath, CblImportDecisions decisions) { ParsedCblReadingList cbl; try @@ -95,7 +95,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu }; } - var matchResults = await RunMatchingPipeline(userId, cbl, options); + var matchResults = await RunMatchingPipeline(userId, cbl); // Override with user decisions foreach (var (order, decision) in decisions.ItemResolutions) @@ -222,8 +222,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu var cbl = CblParser.Parse(tempFile); if (cbl.Items.Count == 0) return; - var options = new CblImportOptions(); - var matchResults = await RunMatchingPipeline(userId, cbl, options); + var matchResults = await RunMatchingPipeline(userId, cbl); // Clear existing items and re-add readingList.Items.Clear(); @@ -258,7 +257,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu } private async Task> RunMatchingPipeline( - int userId, ParsedCblReadingList cbl, CblImportOptions options) + int userId, ParsedCblReadingList cbl) { // Collect all unique normalized names + variants var nameVariants = CblSeriesMatcher.GenerateAllNameVariants(cbl.Items); @@ -293,12 +292,6 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu // 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); @@ -307,7 +300,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu .GetChaptersByExternalIdsAsync(comicVineIds, metronIds, userLibraryIds); var matchedSeries = (await unitOfWork.SeriesRepository - .GetAllSeriesByNameAsync(allNormalizedNames, userId, options.ApplicableLibraries, + .GetAllSeriesByNameAsync(allNormalizedNames, userId, SeriesIncludes.Chapters | SeriesIncludes.Metadata)).ToList(); // Also fetch series referenced by remap rules that weren't caught by name matching @@ -330,7 +323,7 @@ public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithu .GetChaptersByAlternateSeriesAsync(directNormalizedNames, userLibraryIds); return CblSeriesMatcher.ResolveAll(cbl.Items, remapRules, externalIdChapters, - matchedSeries, alternateSeriesChapters, options); + matchedSeries, alternateSeriesChapters); } private static CblImportSummaryDto BuildSummary(ParsedCblReadingList cbl, string filePath, diff --git a/Kavita.Services/ReadingLists/CblSeriesMatcher.cs b/Kavita.Services/ReadingLists/CblSeriesMatcher.cs index 9724aa8c2..7189da173 100644 --- a/Kavita.Services/ReadingLists/CblSeriesMatcher.cs +++ b/Kavita.Services/ReadingLists/CblSeriesMatcher.cs @@ -42,23 +42,21 @@ internal static class CblSeriesMatcher public static Dictionary GenerateAllNameVariants(IList items) { var variants = new Dictionary(); - var uniqueNames = items.DistinctBy(i => i.SeriesName).ToList(); + var uniqueNames = items.Select(i => i.SeriesName).Distinct().ToList(); - foreach (var item in uniqueNames) + foreach (var name in uniqueNames) { - var name = item.SeriesName; - - // Tier 2: Exact normalized + // Exact normalized AddVariants(variants, name, CblMatchTier.ExactName, name); - // Tier 4: Article stripped + // Article stripped var sortTitle = BookSortTitlePrefixHelper.GetSortTitle(name); if (!string.Equals(sortTitle, name, StringComparison.OrdinalIgnoreCase)) { AddVariants(variants, sortTitle, CblMatchTier.ArticleStripped, name); } - // Tier 5: Reprint stripped + // Reprint stripped var stripped = StripReprintSuffix(name); if (!string.Equals(stripped, name, StringComparison.OrdinalIgnoreCase)) { @@ -94,8 +92,7 @@ internal static class CblSeriesMatcher IList remapRules, IList externalIdChapters, IList matchedSeries, - IList alternateSeriesChapters, - CblImportOptions options) + IList alternateSeriesChapters) { var results = new Dictionary(); @@ -162,7 +159,7 @@ internal static class CblSeriesMatcher } // Tiers 2-4: Name matching - if (TryMatchByName(item, nameVariants, seriesByNormalizedName, options, out var seriesMatch, out var tier)) + if (TryMatchByName(item, nameVariants, seriesByNormalizedName, out var seriesMatch, out var tier)) { // Series resolved, now resolve chapter results[item.Order] = ResolveChapter(item, seriesMatch, tier); @@ -218,6 +215,7 @@ internal static class CblSeriesMatcher var libraryId = 0; var libraryType = LibraryType.Comic; var ruleSeries = matchedSeries.FirstOrDefault(s => s.Id == rule.SeriesId); + if (ruleSeries != null) { libraryId = ruleSeries.LibraryId; @@ -250,38 +248,51 @@ internal static class CblSeriesMatcher return true; } - // Volume-only remap with target VolumeId — resolve chapters within the override volume + // Volume-only remap with target VolumeId - resolve chapters within the override volume if (rule is {VolumeId: not null, ChapterId: null}) { var volSeries = matchedSeries.FirstOrDefault(s => s.Id == rule.SeriesId); - if (volSeries != null) - { - var targetVolume = volSeries.Volumes?.FirstOrDefault(v => v.Id == rule.VolumeId.Value); - if (targetVolume != null) - { - var resolved = ResolveChapter(item, volSeries, CblMatchTier.RemapRule, targetVolume); - if (resolved.Result.Reason is CblImportReason.ChapterMissing) - { - return false; - } + var targetVolume = volSeries?.Volumes?.FirstOrDefault(v => v.Id == rule.VolumeId.Value); - resolvedResult = resolved; - return true; - } + if (targetVolume == null) return false; + var resolved = ResolveChapter(item, volSeries!, CblMatchTier.RemapRule, targetVolume); + if (resolved.Result.Reason is CblImportReason.ChapterMissing) + { + return false; } - return false; + resolvedResult = resolved; + return true; } // Rule only mapped to series — resolve chapter within the mapped series. - // If volume/chapter resolution fails, fall through to lower tiers so the - // original CBL data can match via name variants (e.g. Comic naming "Series (Volume)"). - // This prevents false positives where a series-only remap for "Batman" -> "Batman (2014)" - // would incorrectly capture a CBL entry with Volume="1994". + // The user has explicitly declared this mapping, so we should resolve within + // the target series rather than falling through to lower tiers (which can never + // match a remapped name like "Zombie Tales" -> "Adventure Time"). var series = matchedSeries.FirstOrDefault(s => s.Id == rule.SeriesId); if (series != null) { var resolved = ResolveChapter(item, series, CblMatchTier.RemapRule); + + // If the CBL volume doesn't exist in the target series, retry without + // the volume so resolution falls back to the loose-leaf volume. + // This handles cases like a manga series with only loose issues being + // targeted by a remap from a Comic CBL entry that carries a year-volume. + if (resolved.Result.Reason is CblImportReason.VolumeMissing + && !string.IsNullOrEmpty(item.Volume)) + { + resolved = ResolveChapter(item with { Volume = string.Empty }, series, CblMatchTier.RemapRule); + + // Restore the original volume so the result shows what the CBL requested + resolved.Result.Volume = item.Volume; + + // After the retry the remap is authoritative — return the result + // (success or failure) rather than falling through to lower tiers + // which would report SeriesMissing for a remapped name. + resolvedResult = resolved; + return true; + } + if (resolved.Result.Reason is CblImportReason.VolumeMissing or CblImportReason.ChapterMissing) { return false; @@ -333,7 +344,6 @@ internal static class CblSeriesMatcher private static bool TryMatchByName(ParsedCblItem item, Dictionary nameVariants, Dictionary> seriesByNormalizedName, - CblImportOptions options, out Series series, out CblMatchTier tier) { // Try each tier in order @@ -370,7 +380,7 @@ internal static class CblSeriesMatcher } // Disambiguate - var disambiguated = DisambiguateSeries(candidates, item, options); + var disambiguated = DisambiguateSeries(candidates, item); if (disambiguated != null) { series = disambiguated; @@ -388,39 +398,18 @@ internal static class CblSeriesMatcher return false; } - private static Series? DisambiguateSeries(List candidates, ParsedCblItem item, CblImportOptions options) + private static Series? DisambiguateSeries(List candidates, ParsedCblItem item) { - 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 => + var yearFiltered = candidates.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; + return candidates.Count == 1 ? candidates[0] : null; } private static (MatchedItem? Match, CblBookResult Result) ResolveChapter( @@ -463,7 +452,7 @@ internal static class CblSeriesMatcher targetVolume ??= volumes.FirstOrDefault(v => string.Equals(v.Name, item.Volume, StringComparison.OrdinalIgnoreCase)); - // Volume was explicitly requested but not found — report as VolumeMissing + // Volume was explicitly requested but not found, report as VolumeMissing if (targetVolume == null) { return (null, new CblBookResult(item) @@ -479,7 +468,7 @@ internal static class CblSeriesMatcher } else { - // No volume specified — use loose leaf + // No volume specified, use loose-leaf targetVolume = volumes.GetLooseLeafVolumeOrDefault(); } @@ -640,12 +629,9 @@ internal static class CblSeriesMatcher 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; - } + if (!trimmed.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) continue; + var stripped = trimmed[..^suffix.Length].TrimEnd(' ', '-', ':'); + if (!string.IsNullOrWhiteSpace(stripped)) return stripped; } return name; 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 index 2457c93d5..49198d85a 100644 --- 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 @@ -9,3 +9,7 @@ export enum CblMatchTier { UserDecision = 7, Unmatched = -1 } + +export const allCblMatchTiers = Object.keys(CblMatchTier) + .filter(key => !isNaN(Number(key)) && parseInt(key, 10) >= 0) + .map(key => parseInt(key, 10)) as CblMatchTier[]; diff --git a/UI/Web/src/app/_models/reading-list/cbl/cbl-remap-rule-kind.enum.ts b/UI/Web/src/app/_models/reading-list/cbl/cbl-remap-rule-kind.enum.ts new file mode 100644 index 000000000..ea8fb9531 --- /dev/null +++ b/UI/Web/src/app/_models/reading-list/cbl/cbl-remap-rule-kind.enum.ts @@ -0,0 +1,5 @@ +export enum CblRemapRuleKind { + Series = 0, + Volume = 1, + Chapter = 2 +} 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 index 3eb9fea08..a7fd01745 100644 --- a/UI/Web/src/app/_models/reading-list/cbl/remap-rule.ts +++ b/UI/Web/src/app/_models/reading-list/cbl/remap-rule.ts @@ -1,4 +1,5 @@ import {LibraryType} from '../../library/library'; +import {CblRemapRuleKind} from './cbl-remap-rule-kind.enum'; export interface RemapRule { id: number; @@ -8,7 +9,9 @@ export interface RemapRule { cblNumber: string | null; seriesId: number; volumeId: number | null; + volumeNumber: string; chapterId: number | null; + kind: CblRemapRuleKind; chapterRange: string; chapterTitleName: string; chapterIsSpecial: boolean; diff --git a/UI/Web/src/app/_models/wiki.ts b/UI/Web/src/app/_models/wiki.ts index b4860426f..a7ac3e0b5 100644 --- a/UI/Web/src/app/_models/wiki.ts +++ b/UI/Web/src/app/_models/wiki.ts @@ -23,4 +23,5 @@ export enum WikiLink { Guides = 'https://wiki.kavitareader.com/guides', ReadingProfiles = "https://wiki.kavitareader.com/guides/user-settings/reading-profiles/", EpubFontManager = "https://wiki.kavitareader.com/guides/epub-fonts/", + CblImportModal = 'https://wiki.kavitareader.com/guides/features/cbl-import/' } 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 index f45352fc9..b580c7706 100644 --- 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 @@ -24,7 +24,7 @@ export class BrowseCblRepoModalComponent implements OnInit { private readonly cblService = inject(CblService); items = signal([]); - selectedItems = signal>(new Set()); + selectedItems = signal([]); loading = signal(false); rateLimit = signal(null); fromCache = signal(false); @@ -38,14 +38,14 @@ export class BrowseCblRepoModalComponent implements OnInit { 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); + hasSelection = computed(() => this.selectedItems().length > 0); + selectionCount = computed(() => this.selectedItems().length); allFilesSelected = computed(() => { const f = this.files(); if (f.length === 0) return false; const sel = this.selectedItems(); - return f.every(file => sel.has(file.path)); + return f.every(file => sel.some(s => s.path === file.path)); }); ngOnInit() { @@ -74,44 +74,32 @@ export class BrowseCblRepoModalComponent implements OnInit { 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); + if (current.some(s => s.path === file.path)) { + return current.filter(s => s.path !== file.path); } - return next; + return [...current, file]; }); } 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; - }); + const paths = new Set(files.map(f => f.path)); + this.selectedItems.update(current => current.filter(s => !paths.has(s.path))); } else { this.selectedItems.update(current => { - const next = new Set(current); - for (const file of files) { - next.add(file.path); - } - return next; + const existing = new Set(current.map(s => s.path)); + return [...current, ...files.filter(f => !existing.has(f.path))]; }); } } isSelected(file: CblRepoItem): boolean { - return this.selectedItems().has(file.path); + return this.selectedItems().some(s => s.path === file.path); } download() { - const selected = this.items().filter(i => this.selectedItems().has(i.path)); - this.modal.close(selected); + this.modal.close(this.selectedItems()); } close() { 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 index de9d98e88..1937d4388 100644 --- 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 @@ -19,6 +19,7 @@

{{t('processing')}}

} @else if (currentSummary(); as summary) { +

{{t('description')}} {{t('wiki')}}

{{summary.cblName || currentFile().name}} @@ -31,15 +32,20 @@
- {{t('matched-count', {count: matchedCount()})}} - @if (issueCount() > 0) { - {{t('issues-count', {count: issueCount()})}} - } -
@@ -90,15 +96,15 @@ - + {{t('col-status')}} - @if (row.result.reason === CblImportReason.Success && row.result.matchTier === 0) { - + @if (row.result.reason === CblImportReason.Success && row.result.matchTier === CblMatchTier.RemapRule) { + {{row.result.matchTier | cblMatchTier}} } @else if (row.result.reason === CblImportReason.Success) { - + {{row.result.matchTier | cblMatchTier}} } @else { @@ -118,10 +124,10 @@
- + {{item.name}} ({{libraryNames()[item.libraryId]}}) - +
@@ -226,7 +232,7 @@ - + {{t('col-action')}} @if (row.result.reason !== CblImportReason.Success) { @@ -238,7 +244,7 @@ }
- @if (rule.cblVolume) { - {{t('volume-num', {num: rule.cblVolume})}} - } - @if (rule.cblNumber) { - {{t('issue-num', {num: rule.cblNumber})}} - } - @if (!rule.cblVolume && !rule.cblNumber) { - {{t('series-level')}} - } - @if (rule.chapterId) { - - + @switch (rule.kind) { + @case (CblRemapRuleKind.Series) { + {{t('series-level')}} + @if (rule.cblVolume) { + {{t('volume-num', {num: rule.cblVolume})}} + } + } + @case (CblRemapRuleKind.Volume) { + {{t('volume-num', {num: rule.cblVolume})}} + + {{t('volume-target', {num: rule.volumeNumber})}} + } + @case (CblRemapRuleKind.Chapter) { + @if (rule.cblVolume) { + {{t('volume-num', {num: rule.cblVolume})}} + } + @if (rule.cblNumber) { + {{t('issue-num', {num: rule.cblNumber})}} + } + + + } }
@@ -36,7 +46,7 @@ @if (rule.isGlobal) { {{t('global')}} } - @if (rule.appUserId === currentUserId()) { + @if (rule.appUserId === currentUserId() && !accountService.hasReadOnlyRole()) {