CBL Import Polish 4 (#4576)

This commit is contained in:
Joe Milazzo 2026-03-31 15:23:01 -05:00 committed by GitHub
parent f6796583bc
commit c8bcbc3d58
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 979 additions and 420 deletions

View File

@ -21,6 +21,10 @@ public interface IReadingListRemapRuleRepository
/// Admin-only: returns all rules across all users, with user names.
/// </summary>
Task<IList<ReadingListRemapRule>> GetAllRulesAsync(CancellationToken ct = default);
/// <summary>
/// Finds an existing rule for the same user with the same CBL matching key (normalized name + volume + number).
/// </summary>
Task<ReadingListRemapRule?> GetExactRuleAsync(string normalizedCblSeriesName, string? cblVolume, string? cblNumber, int userId, CancellationToken ct = default);
void Add(ReadingListRemapRule rule);
void Remove(ReadingListRemapRule rule);
}

View File

@ -125,8 +125,6 @@ public interface ISeriesRepository
Task<Series?> GetSeriesThatContainsLowestFolderPath(string path, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default);
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default);
Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, IList<int>? libraryIds, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default);
Task<Series?> GetFullSeriesByAnyName(string seriesName, string localizedName, int libraryId, MangaFormat format, bool withFullIncludes = true, CancellationToken ct = default);
Task<Series?> GetSeriesByAnyName(IList<string> names, IList<MangaFormat> formats,
int userId, int? aniListId = null, SeriesIncludes includes = SeriesIncludes.None, CancellationToken ct = default);

View File

@ -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<CblImportSummaryDto> ValidateList(int userId, string filePath, CblImportOptions options);
Task<CblImportSummaryDto> ValidateList(int userId, string filePath);
/// <summary>
/// Creates a new RL or updates an existing
/// </summary>
Task<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions);
Task<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportDecisions decisions);
/// <summary>
/// Checks for updates against upstream ReadingList files and attempts to Update reading list.
/// </summary>

View File

@ -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<ReadingListRemapRule?> 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);

View File

@ -1557,24 +1557,6 @@ public class SeriesRepository(DataContext context, IMapper mapper) : ISeriesRepo
.ToListAsync(ct);
}
public async Task<IEnumerable<Series>> GetAllSeriesByNameAsync(IList<string> normalizedNames,
int userId, IList<int>? 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);
}
/// <summary>

View File

@ -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<AppUserRating, UserReviewExtendedDto>()
.ForMember(dest => dest.Body,

View File

@ -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
{
/// <summary>
/// Weighs ComicVine Matching higher
/// </summary>
public bool PreferComicVineMatching { get; set; }
/// <summary>
/// Libraries to search against. If empty, will include all
/// </summary>
public IList<int> ApplicableLibraries { get; set; }
}

View File

@ -9,7 +9,7 @@ public sealed record CreateRemapRuleDto
public string CblSeriesName { get; set; } = string.Empty;
public int SeriesId { get; set; }
/// <summary>
/// Optional: CBL volume string for issue-level rules
/// Optional: CBL volume string for issue/volume-level rules
/// </summary>
public string? CblVolume { get; set; }
/// <summary>

View File

@ -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; }

View File

@ -0,0 +1,8 @@
namespace Kavita.Models.Entities.Enums.ReadingList;
public enum CblRemapRuleKind
{
Series = 0,
Volume = 1,
Chapter = 2
}

View File

@ -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;
}

View File

@ -1,4 +1,6 @@
namespace Kavita.Models.Entities.Enums;
using Kavita.Models.Entities.Enums;
namespace Kavita.Models.Extensions;
public static class PdfRenderResolutionExtensions
{

View File

@ -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
}
/// <summary>
/// Admin-only: returns all rules across all users.
/// Returns all rules across all users
/// </summary>
[Authorize(Policy = PolicyGroups.AdminPolicy)]
[HttpGet("remap-rules/all")]
@ -237,34 +237,72 @@ public class CblController(IReadingListService readingListService, IDirectorySer
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("remap-rules")]
[DisallowRole(PolicyConstants.ReadOnlyRole)]
public async Task<ActionResult<RemapRuleDto>> 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<RemapRuleDto>(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<RemapRuleDto>(existing));
}
/// <summary>

View File

@ -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<CblExternalId>? externalIds = null)
string year = "", List<CblExternalId>? 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;

View File

@ -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
{

View File

@ -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<int, CblItemDecision>(),
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<int, CblItemDecision>(),
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);
}
/// <summary>
/// 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.
/// </summary>
[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
/// <summary>
/// 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.
/// </summary>
[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);
}
/// <summary>
/// Even with the loose-leaf fallback, if the chapter doesn't exist in the
/// loose-leaf volume either, the result should still report failure.
/// </summary>
[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);
}
/// <summary>
/// When the series-level remap targets a Comic series where the volume DOES exist,
/// the fallback should NOT activate — the volume should resolve directly.
/// </summary>
[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);
}
/// <summary>
/// Series-level remap with multiple CBL entries: some volumes exist in the target,
/// some fall back to loose-leaf.
/// </summary>
[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
/// <summary>
/// Tier 3: Comic naming pattern — "Batman" with Volume="2014" should match series "Batman (2014)"
/// </summary>
[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);
}
/// <summary>
/// Tier 4: Article-stripped — "The Fables" should match series "Fables" with articles removed
/// </summary>
[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);
}
/// <summary>
/// Tier 5: Reprint-stripped — "Fables Deluxe Edition" should match "Fables" with suffix removed
/// </summary>
[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
/// <summary>
/// When two series share the same name, the CBL Year field should disambiguate.
/// </summary>
[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
/// <summary>
/// An issue-only remap (CblNumber set, CblVolume empty) should match any CBL entry
/// with that issue number regardless of volume.
/// </summary>
[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
/// <summary>
/// When no chapter number is specified in the CBL entry, should default to first chapter in volume.
/// </summary>
[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);
}
/// <summary>
/// When no volume is specified, chapters should be searched across all volumes.
/// </summary>
[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
}

View File

@ -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"] }
]
}
]
}

View File

@ -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;

View File

@ -35,6 +35,18 @@ public interface ICblExportService
public partial class CblExportService(IUnitOfWork unitOfWork, IDirectoryService directoryService, ILogger<CblExportService> 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,
};
/// <inheritdoc />
public async Task<string?> 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<CblV2Issue>();
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<CblBookDatabase> GetV1Databases(Chapter chapter, string seriesName)
private static List<CblBookDatabase> GetV1Databases(Chapter chapter, Series series)
{
var results = new List<CblBookDatabase>();
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<CblV2ExternalId> GetExternalIds(Chapter chapter, string seriesName)
private static List<CblV2ExternalId> GetExternalIds(Chapter chapter, Series series)
{
var results = new List<CblV2ExternalId>();
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);
}

View File

@ -21,7 +21,7 @@ namespace Kavita.Services.ReadingLists;
public class CblImportService(IUnitOfWork unitOfWork, ICblGithubService cblGithubService,
IDirectoryService directoryService, ILogger<CblImportService> logger) : ICblImportService
{
public async Task<CblImportSummaryDto> ValidateList(int userId, string filePath, CblImportOptions options)
public async Task<CblImportSummaryDto> 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<CblImportSummaryDto> UpsertReadingList(int userId, string filePath, CblImportOptions options, CblImportDecisions decisions)
public async Task<CblImportSummaryDto> 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<Dictionary<int, (MatchedItem? Match, CblBookResult Result)>> 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,

View File

@ -42,23 +42,21 @@ internal static class CblSeriesMatcher
public static Dictionary<string, (string OriginalName, CblMatchTier Tier)> GenerateAllNameVariants(IList<ParsedCblItem> items)
{
var variants = new Dictionary<string, (string, CblMatchTier)>();
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<ReadingListRemapRule> remapRules,
IList<Chapter> externalIdChapters,
IList<Series> matchedSeries,
IList<Chapter> alternateSeriesChapters,
CblImportOptions options)
IList<Chapter> alternateSeriesChapters)
{
var results = new Dictionary<int, (MatchedItem? Match, CblBookResult Result)>();
@ -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<string, (string OriginalName, CblMatchTier Tier)> nameVariants,
Dictionary<string, List<Series>> 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<Series> candidates, ParsedCblItem item, CblImportOptions options)
private static Series? DisambiguateSeries(List<Series> 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;

View File

@ -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[];

View File

@ -0,0 +1,5 @@
export enum CblRemapRuleKind {
Series = 0,
Volume = 1,
Chapter = 2
}

View File

@ -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;

View File

@ -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/'
}

View File

@ -24,7 +24,7 @@ export class BrowseCblRepoModalComponent implements OnInit {
private readonly cblService = inject(CblService);
items = signal<CblRepoItem[]>([]);
selectedItems = signal<Set<string>>(new Set());
selectedItems = signal<CblRepoItem[]>([]);
loading = signal(false);
rateLimit = signal<GithubRateLimit | null>(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() {

View File

@ -19,6 +19,7 @@
<p class="mt-2 text-muted">{{t('processing')}}</p>
</div>
} @else if (currentSummary(); as summary) {
<p>{{t('description')}} <a [href]="WikiLink.CblImportModal" rel="noopener noreferrer">{{t('wiki')}}</a></p>
<!-- File name -->
<h5 class="mb-3">
{{summary.cblName || currentFile().name}}
@ -31,15 +32,20 @@
<!-- Summary bar -->
<div class="d-flex gap-3 mb-3 align-items-center">
<span class="badge bg-success fs-6">{{t('matched-count', {count: matchedCount()})}}</span>
@if (issueCount() > 0) {
<span class="badge text-bg-warning fs-6">{{t('issues-count', {count: issueCount()})}}</span>
}
<button class="btn btn-sm" [class.btn-outline-secondary]="showSuccessful()"
[class.btn-secondary]="!showSuccessful()"
(click)="showSuccessful.update(v => !v)">
<i class="fa" [class.fa-eye]="showSuccessful()" [class.fa-eye-slash]="!showSuccessful()" aria-hidden="true"></i>
{{showSuccessful() ? t('hide-matched') : t('show-matched')}}
<div class="btn-group" role="group">
<input type="checkbox" class="btn-check" id="matched-btn" autocomplete="off" [checked]="showMatched()" (change)="toggleRowFilter('matched')">
<label class="btn btn-outline-success" for="matched-btn">{{t('matched-count', {count: matchedCount()})}}</label>
<input type="checkbox" class="btn-check" id="warnings-btn" autocomplete="off" [checked]="showIssues()" (change)="toggleRowFilter('issues')">
<label class="btn btn-outline-warning" for="warnings-btn">{{t('issues-count', {count: issueCount()})}}</label>
<input type="checkbox" class="btn-check" id="errors-btn" autocomplete="off" [checked]="showUnmatched()" (change)="toggleRowFilter('unmatched')">
<label class="btn btn-outline-danger" for="errors-btn">{{t('unmatched-count', {count: unmatchedCount()})}}</label>
</div>
<button class="btn btn-sm btn-outline-secondary" (click)="validateCurrentFile()">
<i class="fa fa-refresh me-1" aria-hidden="true"></i>
{{t('refresh')}}
</button>
</div>
@ -90,15 +96,15 @@
</ngx-datatable-column>
<!-- Status / Reason -->
<ngx-datatable-column [width]="110" [sortable]="false" [draggable]="false" [resizeable]="false">
<ngx-datatable-column [width]="100" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template ngx-datatable-header-template>{{t('col-status')}}</ng-template>
<ng-template let-row="row" ngx-datatable-cell-template>
@if (row.result.reason === CblImportReason.Success && row.result.matchTier === 0) {
<span class="badge tier-badge tier-0" [ngbTooltip]="getRemapRuleTooltip(row)" container="body">
@if (row.result.reason === CblImportReason.Success && row.result.matchTier === CblMatchTier.RemapRule) {
<span class="badge tier-badge text-bg-{{getMatchBadgeClass(row.result.matchTier)}}" [ngbTooltip]="getRemapRuleTooltip(row)" container="body">
{{row.result.matchTier | cblMatchTier}}
</span>
} @else if (row.result.reason === CblImportReason.Success) {
<span class="badge tier-badge tier-{{row.result.matchTier}}">
<span class="badge tier-badge text-bg-{{getMatchBadgeClass(row.result.matchTier)}}" >
{{row.result.matchTier | cblMatchTier}}
</span>
} @else {
@ -118,10 +124,10 @@
<div class="flex-grow-1">
<app-typeahead [settings]="activeSeriesTypeahead()!"
(selectedData)="onSeriesTypeaheadSelected(row, $event)">
<ng-template #badgeItem let-item let-position="idx">
<ng-template #badgeItem let-item>
{{item.name}} ({{libraryNames()[item.libraryId]}})
</ng-template>
<ng-template #optionItem let-item let-position="idx" let-value="value">
<ng-template #optionItem let-item let-value="value">
<div class="d-flex align-items-center gap-2">
<app-image [imageUrl]="imageService.getSeriesCoverImage(item.seriesId)"
height="40px" width="30px" />
@ -226,7 +232,7 @@
</ngx-datatable-column>
<!-- Action Column -->
<ngx-datatable-column [width]="30" [sortable]="false" [draggable]="false" [resizeable]="false">
<ngx-datatable-column [width]="60" [sortable]="false" [draggable]="false" [resizeable]="false">
<ng-template ngx-datatable-header-template>{{t('col-action')}}</ng-template>
<ng-template let-row="row" ngx-datatable-cell-template>
@if (row.result.reason !== CblImportReason.Success) {
@ -238,7 +244,7 @@
</button>
}
<button class="btn btn-sm btn-outline-secondary" (click)="toggleSkip(row)"
[attr.aria-label]="t('skip-item')">
[attr.aria-label]="t('skip-item')" [ngbTooltip]="t('skip-item')">
@if (row.skipped) {
<i class="fa fa-undo" aria-hidden="true"></i>
} @else {

View File

@ -4,14 +4,6 @@
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;
}

View File

@ -12,14 +12,12 @@ import {CblSeriesCandidate} from '../../../_models/reading-list/cbl/cbl-series-c
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';
@ -37,6 +35,7 @@ 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";
import {WikiLink} from "../../../_models/wiki";
export interface CblIssueRow {
result: CblBookResult;
@ -71,15 +70,11 @@ export class ImportCblModalComponent implements OnInit {
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<CblSavedFile[]>();
currentFileIndex = signal(0);
@ -90,18 +85,34 @@ export class ImportCblModalComponent implements OnInit {
/** All rows (matched + issues) for the unified table */
allRows = signal<CblIssueRow[]>([]);
classifiedRows = computed(() =>
this.allRows().map(r => ({
...r,
category: this.classifyRow(r)
}))
);
libraryNames = signal<Record<number, string>>({});
showSuccessful = signal(true);
showMatched = signal(true);
showIssues = signal(true);
showUnmatched = signal(true);
visibleRows = computed(() => {
const rows = this.allRows();
return this.showSuccessful() ? rows : rows.filter(r => r.result.reason !== CblImportReason.Success);
const active = new Set<string>();
if (this.showMatched()) active.add('matched');
if (this.showIssues()) active.add('issue');
if (this.showUnmatched()) active.add('unmatched');
if (active.size === 0) return [];
if (active.size === 3) return this.classifiedRows();
return this.classifiedRows().filter(r => active.has(r.category));
});
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);
matchedCount = computed(() => this.classifiedRows().filter(r => r.category === 'matched').length);
issueCount = computed(() => this.classifiedRows().filter(r => r.category === 'issue').length);
unmatchedCount = computed(() => this.classifiedRows().filter(r => r.category === 'unmatched').length);
/** Lazy typeahead state — only one row can be resolving at a time */
/** Lazy typeahead state, only one row can be resolving at a time */
activeRow = signal<CblIssueRow | null>(null);
activeSeriesTypeahead = signal<TypeaheadSettings<SearchResult> | null>(null);
activeChapterTypeahead = signal<TypeaheadSettings<Chapter> | null>(null);
@ -109,12 +120,37 @@ export class ImportCblModalComponent implements OnInit {
/** Track the CBL series name of the row being resolved, so we can auto-continue after re-validation */
private pendingAutoEditSeries: string | null = null;
private classifyRow(r: CblIssueRow): 'matched' | 'issue' | 'unmatched' {
if (r.result.reason === CblImportReason.Success) return 'matched';
if (r.skipped) return 'matched';
if (r.result.matchTier === CblMatchTier.Unmatched) return 'unmatched';
return 'issue';
}
getRowClass = (row: CblIssueRow) => {
if (row.skipped) return 'skipped-row';
if (row.result.reason === CblImportReason.Success) return 'matched-row';
return 'issue-row';
};
getMatchBadgeClass(matchTier: CblMatchTier) {
switch (matchTier) {
case CblMatchTier.RemapRule:
case CblMatchTier.ExternalId:
case CblMatchTier.ExactName:
case CblMatchTier.ComicVineNaming:
case CblMatchTier.ArticleStripped:
case CblMatchTier.ReprintStripped:
case CblMatchTier.AlternateSeries:
return 'success';
case CblMatchTier.UserDecision:
return 'warning';
case CblMatchTier.Unmatched:
return 'danger';
}
}
ngOnInit() {
this.cblService.getRemapRules().subscribe(rules => {
this.remapRules.set(rules);
@ -190,24 +226,14 @@ export class ImportCblModalComponent implements OnInit {
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;
return this.activeRow()?.result.order === row.result.order && 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;
return this.activeRow()?.result.order === row.result.order && this.activeChapterTypeahead() !== null;
}
/** Build a minimal Chapter stub for entity-title rendering */
@ -269,7 +295,7 @@ export class ImportCblModalComponent implements OnInit {
}
onCandidateSelected(row: CblIssueRow, candidate: CblSeriesCandidate) {
this.handleSeriesSelection(row, candidate.seriesId, candidate.seriesName);
this.handleSeriesSelection(row, candidate.seriesId);
}
onSeriesTypeaheadSelected(row: CblIssueRow, event: SearchResult[]) {
@ -282,7 +308,7 @@ export class ImportCblModalComponent implements OnInit {
return;
}
this.handleSeriesSelection(row, selected.seriesId, selected.name);
this.handleSeriesSelection(row, selected.seriesId);
}
onChapterTypeaheadSelected(row: CblIssueRow, event: Chapter[]) {
@ -309,12 +335,22 @@ export class ImportCblModalComponent implements OnInit {
this.allRows.set([...this.allRows()]);
}
toggleRowFilter(category: 'matched' | 'issues' | 'unmatched') {
switch (category) {
case 'matched': this.showMatched.update(v => !v); break;
case 'issues': this.showIssues.update(v => !v); break;
case 'unmatched': this.showUnmatched.update(v => !v); break;
}
}
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.remapRules.set([...rules]);
this.validateCurrentFile();
});
}
@ -369,20 +405,21 @@ export class ImportCblModalComponent implements OnInit {
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;
private async handleSeriesSelection(row: CblIssueRow, seriesId: number) {
// Remember this series for auto-continue after re-validation
this.pendingAutoEditSeries = row.result.series;
this.cblService.createRemapRule(row.result.series, seriesId).subscribe(rule => {
this.cblService.createRemapRule(row.result.series, seriesId, {
cblVolume: row.result.volume || undefined, // Pass the volume if it's available to ensure volume-level mapping works
}).subscribe(rule => {
row.remapRuleId = rule.id;
this.remapRules.set([...this.remapRules(), rule]);
this.cancelResolve();
this.validateCurrentFile();
// The backend might have updated the ruleset, so refresh them
this.cblService.getRemapRules().subscribe(rules => {
this.remapRules.set([...rules]);
this.cancelResolve();
this.validateCurrentFile();
});
});
}
@ -394,9 +431,12 @@ export class ImportCblModalComponent implements OnInit {
chapterId: chapter.id,
}).subscribe(rule => {
row.remapRuleId = rule.id;
this.remapRules.set([...this.remapRules(), rule]);
this.cancelResolve();
this.validateCurrentFile();
this.cblService.getRemapRules().subscribe(rules => {
this.remapRules.set([...rules]);
this.cancelResolve();
this.validateCurrentFile();
});
});
}
@ -460,4 +500,8 @@ export class ImportCblModalComponent implements OnInit {
return settings;
}
protected readonly CblImportReason = CblImportReason;
protected readonly CblMatchTier = CblMatchTier;
protected readonly WikiLink = WikiLink;
}

View File

@ -15,20 +15,30 @@
<span>{{rule.seriesNameAtMapping}}</span>
</div>
<small class="text-muted">
@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) {
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
<app-entity-title [libraryType]="rule.libraryType"
[prioritizeTitleName]="false"
[entity]="buildChapterStub(rule)" />
@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})}}
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
{{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})}}
}
<i class="fa fa-arrow-right mx-1" aria-hidden="true"></i>
<app-entity-title [libraryType]="rule.libraryType"
[prioritizeTitleName]="false"
[entity]="buildChapterStub(rule)" />
}
}
</small>
</div>
@ -36,7 +46,7 @@
@if (rule.isGlobal) {
<span class="badge bg-info">{{t('global')}}</span>
}
@if (rule.appUserId === currentUserId()) {
@if (rule.appUserId === currentUserId() && !accountService.hasReadOnlyRole()) {
<button class="btn btn-sm btn-outline-danger" (click)="deleteRule(rule)">
<i class="fa fa-trash" aria-hidden="true"></i>
<span class="visually-hidden">{{t('delete-rule')}}</span>

View File

@ -1,11 +1,13 @@
import {ChangeDetectionStrategy, Component, computed, inject, OnInit, signal} from '@angular/core';
import {NgbActiveModal} from '@ng-bootstrap/ng-bootstrap';
import {TranslocoDirective} from '@jsverse/transloco';
import {translate, 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 {CblRemapRuleKind} from '../../../_models/reading-list/cbl/cbl-remap-rule-kind.enum';
import {Chapter} from '../../../_models/chapter';
import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.component';
import {ConfirmService} from "../../../shared/confirm.service";
@Component({
selector: 'app-manage-remap-rules-modal',
@ -20,7 +22,9 @@ import {EntityTitleComponent} from '../../../cards/entity-title/entity-title.com
export class ManageRemapRulesModalComponent implements OnInit {
private readonly modal = inject(NgbActiveModal);
private readonly cblService = inject(CblService);
private readonly accountService = inject(AccountService);
protected readonly accountService = inject(AccountService);
private readonly confirmService = inject(ConfirmService);
protected readonly CblRemapRuleKind = CblRemapRuleKind;
rules = signal<RemapRule[]>([]);
hasModifications = false;
@ -47,7 +51,9 @@ export class ManageRemapRulesModalComponent implements OnInit {
this.cblService.getRemapRules().subscribe(rules => this.rules.set(rules));
}
deleteRule(rule: RemapRule) {
async deleteRule(rule: RemapRule) {
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-cbl-remap-rule'))) return;
this.cblService.deleteRemapRule(rule.id).subscribe(() => {
this.rules.set(this.rules().filter(r => r.id !== rule.id));
this.hasModifications = true;

View File

@ -3,7 +3,7 @@
<div class="position-relative">
@if (!accountService.hasReadOnlyRole()) {
<div class="position-absolute custom-position d-flex gap-2">
<button class="btn btn-outline-primary" (click)="selectedList.set(undefined)" [title]="t('add')">
<button class="btn btn-outline-primary" [disabled]="selectedList() === undefined" (click)="selectedList.set(undefined)" [title]="t('add')">
<i class="fa fa-plus" aria-hidden="true"></i><span class="phone-hidden ms-1">{{t('add')}}</span>
</button>
<button class="btn btn-outline-primary" (click)="openBrowseModal()" [title]="t('browse-repo')">
@ -149,7 +149,7 @@
</div>
@if (selectedItem.canSync) {
<span class="pill p-1 me-1 provider">{{selectedItem.provider | readingListProvider}}</span>
<span class="pill p-1 me-1 provider">{{t('can-sync')}}</span>
}
<div class="text-muted mt-1" style="font-size: 0.9rem;">
@ -160,7 +160,7 @@
@if (selectedItem.ageRating) {
<span> · </span>
}
<span>{{selectedItem.startingMonth}}/{{selectedItem.startingYear}} {{selectedItem.endingMonth}}/{{selectedItem.endingYear}}</span>
<span>{{selectedItem.startingYear}} {{selectedItem.endingYear}}</span>
}
@if (selectedItem.ageRating || selectedItem.startingYear > 0) {
<span> · </span>
@ -192,7 +192,7 @@
}
} @else {
<p>You lack the ability to interact with this content.</p>
<p>{{t('not-authorized')}}</p>
}
</div>
</form>

View File

@ -32,7 +32,7 @@ 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 {editModal} from "../../_models/modal/modal-options";
import {ModalResult} from "../../_models/modal/modal-result";
@Component({
@ -194,7 +194,7 @@ export class CblManagerComponent implements OnInit {
}
private openImportModal(savedFiles: CblSavedFile[]) {
const ref = this.modalService.open(ImportCblModalComponent, fullscreenModal());
const ref = this.modalService.open(ImportCblModalComponent, editModal());
ref.setInput('savedFiles', savedFiles);
ref.closed.subscribe((res: ModalResult) => {
this.refreshLists();

View File

@ -113,34 +113,35 @@ export class ManageRemapRulesComponent implements OnInit {
const selectedSeries = this.selectedSeries();
if (!cblSeriesName?.trim() || !selectedSeries) return;
const issueDetail = cblVolume?.trim() ? { cblVolume: cblVolume.trim() } : undefined;
this.cblService.createRemapRule(cblSeriesName.trim(), selectedSeries.seriesId, issueDetail).subscribe(rule => {
this.rules.update(rules => [...rules, rule]);
this.showCreateForm.set(false);
this.resetCreateForm();
this.toastr.success(translate('manage-remap-rules.rule-created'));
this.toastr.success(translate('toasts.cbl-remap-rule-created'));
});
}
async deleteRule(rule: RemapRule) {
if (!await this.confirmService.confirm(translate('manage-remap-rules.confirm-delete'))) return;
if (!await this.confirmService.confirm(translate('toasts.confirm-delete-cbl-remap-rule'))) 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'));
this.toastr.success(translate('toasts.cbl-remap-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'));
this.toastr.success(translate('toasts.cbl-remap-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'));
this.toastr.success(translate('toasts.cbl-remap-rule-demoted'));
});
}
}

View File

@ -2099,34 +2099,37 @@
"filter-all": "All",
"filter-local": "Local",
"no-results": "No matching reading lists",
"add": "Add",
"add": "{{common.add}}",
"sync": "Sync",
"delete": "Delete",
"delete": "{{common.delete}}",
"view-list": "View Reading List",
"last-synced": "Last synced {{date}}",
"source": "Source"
"source": "Source",
"can-sync": "Can Sync",
"not-authorized": "You lack the ability to interact with this content"
},
"import-cbl-modal": {
"title": "CBL Importer",
"description": "On this screen you can build remap rules to automatically map series or issues to Kavita entities. Remap rules will apply to this and future lists.",
"close": "{{common.close}}",
"matched-count": "{{count}} matched",
"issues-count": "{{count}} issues",
"unmatched-count": "{{count}} missing",
"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...",
"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",
"delete-rule": "{{common.delete}}",
"manage-rules": "Manage Remap Rules",
"col-series": "Requested Series",
"col-vol-issue": "Requested Vol / Issue",
@ -2135,30 +2138,31 @@
"col-matched-issue": "Matched Issue",
"col-action": "Action",
"resolve": "Resolve",
"select-chapter": "Select the matching chapter...",
"hide-matched": "Hide Matched",
"show-matched": "Show Matched",
"select-chapter": "Select the matching chapter",
"edit-match": "Edit match",
"cancel": "Cancel",
"cancel": "{{common.cancel}}",
"undo": "Undo",
"remap-rule-used": "Matched via remap rule",
"import-success": "Successfully imported {{count}} reading list(s)",
"processing": "Processing...",
"processing": "Processing",
"new-list": "New",
"update-list": "Update",
"volume-num": "{{common.volume-num-shorthand}}",
"issue-num": "{{common.issue-num-shorthand}}"
"issue-num": "{{common.issue-num-shorthand}}",
"refresh": "Refresh",
"wiki": "Wiki"
},
"manage-remap-rules-modal": {
"title": "Manage Remap Rules",
"close": "{{common.close}}",
"no-rules": "No remap rules saved",
"delete-rule": "Delete",
"delete-rule": "{{common.delete}}",
"volume-num": "{{common.volume-num-shorthand}}",
"issue-num": "{{common.issue-num-shorthand}}",
"global": "Global",
"series-level": "Series-level"
"series-level": "Series-level",
"volume-target": "{{common.volume-num-shorthand}}"
},
"manage-remap-rules": {
@ -2184,11 +2188,6 @@
"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}}",
"cbl-volume-label": "CBL Volume",
@ -2213,7 +2212,8 @@
"cancel": "{{common.cancel}}",
"served-from-cache": "Served from Cache",
"download": "Download",
"owned-label": "Owned"
"owned-label": "Owned",
"close": "{{common.close}}"
},
@ -3606,7 +3606,12 @@
"font-in-use": "Cannot delete as the font is in use by one or more users.",
"k+-resend-welcome-email-success": "An email was sent to your Kavita+ email",
"profile-unauthorized": "This user is not sharing their profile",
"confirm-delete-client-device": "Are you sure you want to delete this device?"
"confirm-delete-client-device": "Are you sure you want to delete this device?",
"confirm-delete-cbl-remap-rule": "Are you sure you want to delete this remap rule?",
"cbl-remap-rule-created": "Remap rule created",
"cbl-remap-rule-deleted": "Remap rule deleted",
"cbl-remap-rule-promoted": "Rule promoted to global",
"cbl-remap-rule-demoted": "Rule demoted to user scope"
},
"read-time-pipe": {