mirror of
https://github.com/Kareadita/Kavita.git
synced 2026-04-02 07:14:30 -04:00
CBL Import Polish 4 (#4576)
This commit is contained in:
parent
f6796583bc
commit
c8bcbc3d58
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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; }
|
||||
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace Kavita.Models.Entities.Enums.ReadingList;
|
||||
|
||||
public enum CblRemapRuleKind
|
||||
{
|
||||
Series = 0,
|
||||
Volume = 1,
|
||||
Chapter = 2
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
namespace Kavita.Models.Entities.Enums;
|
||||
using Kavita.Models.Entities.Enums;
|
||||
|
||||
namespace Kavita.Models.Extensions;
|
||||
|
||||
public static class PdfRenderResolutionExtensions
|
||||
{
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
{
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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"] }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[];
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
export enum CblRemapRuleKind {
|
||||
Series = 0,
|
||||
Volume = 1,
|
||||
Chapter = 2
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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/'
|
||||
}
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user