diff --git a/Jiten.Api/Controllers/AdminController.WebNovels.cs b/Jiten.Api/Controllers/AdminController.WebNovels.cs index dccbbace..1da88023 100644 --- a/Jiten.Api/Controllers/AdminController.WebNovels.cs +++ b/Jiten.Api/Controllers/AdminController.WebNovels.cs @@ -70,6 +70,22 @@ public async Task PreviewWebNovel([FromQuery] string url, } } + /// + /// Re-checks the InsertDeck dedupe key after the admin edits the title on the add page. + /// + [HttpGet("webnovel-title-conflict")] + public async Task CheckWebNovelTitleConflict([FromQuery] string? title) + { + var normalised = title?.Trim(); + if (string.IsNullOrEmpty(normalised)) + return Ok(new { Conflict = false }); + + var conflict = await dbContext.Decks + .AnyAsync(d => d.OriginalTitle == normalised && d.MediaType == MediaType.WebNovel); + + return Ok(new { Conflict = conflict }); + } + [HttpPost("add-webnovel-deck")] [Consumes("multipart/form-data")] public async Task AddWebNovelDeck([FromForm] AddWebNovelDeckRequest model, @@ -98,9 +114,16 @@ public async Task AddWebNovelDeck([FromForm] AddWebNovelDeckReque await model.CoverImage.CopyToAsync(stream); } + var titles = new WebNovelTitles + { + OriginalTitle = model.OriginalTitle, + RomajiTitle = model.RomajiTitle, + EnglishTitle = model.EnglishTitle + }; + // Fetching a long novel is 20+ minutes of polite requests, so it can never run in-request var jobId = backgroundJobs.Enqueue( - job => job.Import(provider, sourceId, coverPath, model.ChunkCharBudget)); + job => job.Import(provider, sourceId, coverPath, model.ChunkCharBudget, titles)); logger.LogInformation("Admin queued webnovel import for {Provider}/{SourceId} (job {JobId})", provider, sourceId, jobId); diff --git a/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs b/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs index 1c5390d7..98a45bae 100644 --- a/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs +++ b/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs @@ -7,6 +7,18 @@ public class AddWebNovelDeckRequest /// public required string Url { get; set; } + /// + /// Optional override of the title the source reports + /// + public string? OriginalTitle { get; set; } + + /// + /// Optional: the source only carries the Japanese title, so romaji is typed or auto-romanised at add time + /// + public string? RomajiTitle { get; set; } + + public string? EnglishTitle { get; set; } + /// /// Optional: uploaded or generated in the browser. Syosetu works have no cover art of their own. /// diff --git a/Jiten.Api/Jobs/WebNovelImportJob.cs b/Jiten.Api/Jobs/WebNovelImportJob.cs index 7602e3b0..110e7a7f 100644 --- a/Jiten.Api/Jobs/WebNovelImportJob.cs +++ b/Jiten.Api/Jobs/WebNovelImportJob.cs @@ -23,7 +23,8 @@ public class WebNovelImportJob( /// [Queue(WebNovelQueues.Syosetu)] [AutomaticRetry(Attempts = 1)] - public async Task Import(WebNovelProvider provider, string sourceId, string? coverPath, int? chunkCharBudget) + public async Task Import(WebNovelProvider provider, string sourceId, string? coverPath, int? chunkCharBudget, + WebNovelTitles? titles = null) { await using var context = await contextFactory.CreateDbContextAsync(); @@ -37,12 +38,14 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov var info = await source.GetInfoAsync(sourceId); + var originalTitle = Normalise(titles?.OriginalTitle) ?? info.Title; + // InsertDeck dedupes new decks on (OriginalTitle, MediaType) and silently keeps the existing one, which // would leave us with a ledger pointing at someone else's deck. Catch it before the long fetch. - if (await context.Decks.AnyAsync(d => d.OriginalTitle == info.Title && d.MediaType == MediaType.WebNovel)) + if (await context.Decks.AnyAsync(d => d.OriginalTitle == originalTitle && d.MediaType == MediaType.WebNovel)) { throw new InvalidOperationException( - $"A webnovel deck titled '{info.Title}' already exists. Rename or remove it before importing {sourceId}."); + $"A webnovel deck titled '{originalTitle}' already exists. Rename or remove it before importing {sourceId}."); } var toc = await source.GetTocAsync(sourceId); @@ -51,7 +54,7 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov throw new InvalidOperationException($"{provider}/{sourceId} has no episodes."); logger.LogInformation("WebNovelImport: {Title} ({SourceId}) — {Episodes} episodes, ~{Chars} chars", - info.Title, sourceId, toc.Count, info.TotalCharacters); + originalTitle, sourceId, toc.Count, info.TotalCharacters); var budget = chunkCharBudget ?? SubdeckChunker.DefaultCharBudget; @@ -74,6 +77,9 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov var textByEpisode = episodes.ToDictionary(e => e.Chunk.Number, e => e.Text); var metadata = info.ToMetadata(); + metadata.OriginalTitle = originalTitle; + metadata.RomajiTitle = Normalise(titles?.RomajiTitle); + metadata.EnglishTitle = Normalise(titles?.EnglishTitle); foreach (var plan in plans) { @@ -98,7 +104,7 @@ public async Task Import(WebNovelProvider provider, string sourceId, string? cov DeleteCoverDirectory(coverPath); logger.LogInformation("WebNovelImport: created deck {DeckId} with {Subdecks} subdecks for {Title}", - parentDeckId, plans.Count, info.Title); + parentDeckId, plans.Count, originalTitle); } finally { @@ -130,6 +136,8 @@ private void DeleteCoverDirectory(string? coverPath) } } + private static string? Normalise(string? title) => string.IsNullOrWhiteSpace(title) ? null : title.Trim(); + private static string JoinEpisodes(ChunkPlan plan, Dictionary textByEpisode) => string.Join("\n\n", plan.EpisodesToAppend.Select(e => textByEpisode[e.Number])); diff --git a/Jiten.Core/WebNovel/WebNovelModels.cs b/Jiten.Core/WebNovel/WebNovelModels.cs index 3196c734..d95da945 100644 --- a/Jiten.Core/WebNovel/WebNovelModels.cs +++ b/Jiten.Core/WebNovel/WebNovelModels.cs @@ -39,6 +39,17 @@ public class WebNovelInfo public bool IsR15 { get; set; } } +/// +/// Titles chosen by the admin at add time. A source only reports the Japanese title, so romaji and English +/// are typed in (or auto-romanised) on the add page and carried into the import. +/// +public class WebNovelTitles +{ + public string? OriginalTitle { get; set; } + public string? RomajiTitle { get; set; } + public string? EnglishTitle { get; set; } +} + /// /// One entry in the table of contents. /// diff --git a/Jiten.Web/app/pages/dashboard/add-webnovel.vue b/Jiten.Web/app/pages/dashboard/add-webnovel.vue index 2e4a8d0e..1599c97f 100644 --- a/Jiten.Web/app/pages/dashboard/add-webnovel.vue +++ b/Jiten.Web/app/pages/dashboard/add-webnovel.vue @@ -1,5 +1,5 @@