From e3f7990d3cf807924f69e9b68ecdf20df34a520f Mon Sep 17 00:00:00 2001
From: Sirus <5435333+Sirush@users.noreply.github.com>
Date: Mon, 13 Jul 2026 14:49:02 +0200
Subject: [PATCH] Webnovel download & sync system
---
.../Controllers/AdminController.WebNovels.cs | 213 ++
Jiten.Api/Controllers/AdminController.cs | 7 +
.../Dtos/Requests/AddWebNovelDeckRequest.cs | 19 +
Jiten.Api/Jobs/FetchMetadataJob.cs | 65 +-
Jiten.Api/Jobs/ParseJob.cs | 18 +-
Jiten.Api/Jobs/WebNovelFetchJob.cs | 334 +++
Jiten.Api/Jobs/WebNovelImportJob.cs | 202 ++
Jiten.Api/Jobs/WebNovelQueues.cs | 18 +
Jiten.Api/Jobs/WebNovelSyncSweepJob.cs | 138 +
Jiten.Api/Program.cs | 43 +
Jiten.Cli/CliOptions.cs | 8 +
Jiten.Cli/Commands/WebNovelCommands.cs | 118 +
Jiten.Cli/Program.cs | 7 +
Jiten.Core/Data/WebNovel/WebNovelChapter.cs | 37 +
Jiten.Core/Data/WebNovel/WebNovelProvider.cs | 9 +
Jiten.Core/Data/WebNovel/WebNovelSource.cs | 70 +
Jiten.Core/Extractors/EbookExtractor.cs | 28 +-
Jiten.Core/Extractors/RubyHtmlHelper.cs | 36 +
Jiten.Core/JitenDbContext.cs | 38 +
.../20260711232624_WebNovelDecks.Designer.cs | 2268 +++++++++++++++++
.../20260711232624_WebNovelDecks.cs | 102 +
.../Migrations/JitenDbContextModelSnapshot.cs | 116 +
Jiten.Core/WebNovel/IWebNovelSource.cs | 33 +
Jiten.Core/WebNovel/SubdeckChunker.cs | 227 ++
Jiten.Core/WebNovel/SyosetuSource.cs | 432 ++++
.../WebNovel/WebNovelMetadataExtensions.cs | 35 +
Jiten.Core/WebNovel/WebNovelModels.cs | 68 +
Jiten.Core/WebNovel/WebNovelSchedule.cs | 69 +
Jiten.Core/WebNovel/WebNovelSourceResolver.cs | 22 +
Jiten.Core/WebNovel/WebNovelUrlParser.cs | 61 +
Jiten.Tests/RubyHtmlHelperTests.cs | 48 +
Jiten.Tests/SubdeckChunkerTests.cs | 215 ++
Jiten.Tests/WebNovelScheduleTests.cs | 78 +
.../app/components/dashboard/CoverTools.vue | 23 +-
.../dashboard/WebNovelSyncPanel.vue | 174 ++
.../app/pages/dashboard/add-webnovel.vue | 227 ++
.../app/pages/dashboard/genre-mappings.vue | 8 +
Jiten.Web/app/pages/dashboard/index.vue | 10 +
Jiten.Web/app/pages/dashboard/media/[id].vue | 8 +
.../app/pages/dashboard/tag-mappings.vue | 8 +
Jiten.Web/app/utils/coverImage.ts | 31 +
Jiten.Web/app/utils/mediaTypeMapper.ts | 2 +-
42 files changed, 5634 insertions(+), 39 deletions(-)
create mode 100644 Jiten.Api/Controllers/AdminController.WebNovels.cs
create mode 100644 Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs
create mode 100644 Jiten.Api/Jobs/WebNovelFetchJob.cs
create mode 100644 Jiten.Api/Jobs/WebNovelImportJob.cs
create mode 100644 Jiten.Api/Jobs/WebNovelQueues.cs
create mode 100644 Jiten.Api/Jobs/WebNovelSyncSweepJob.cs
create mode 100644 Jiten.Cli/Commands/WebNovelCommands.cs
create mode 100644 Jiten.Core/Data/WebNovel/WebNovelChapter.cs
create mode 100644 Jiten.Core/Data/WebNovel/WebNovelProvider.cs
create mode 100644 Jiten.Core/Data/WebNovel/WebNovelSource.cs
create mode 100644 Jiten.Core/Extractors/RubyHtmlHelper.cs
create mode 100644 Jiten.Core/Migrations/20260711232624_WebNovelDecks.Designer.cs
create mode 100644 Jiten.Core/Migrations/20260711232624_WebNovelDecks.cs
create mode 100644 Jiten.Core/WebNovel/IWebNovelSource.cs
create mode 100644 Jiten.Core/WebNovel/SubdeckChunker.cs
create mode 100644 Jiten.Core/WebNovel/SyosetuSource.cs
create mode 100644 Jiten.Core/WebNovel/WebNovelMetadataExtensions.cs
create mode 100644 Jiten.Core/WebNovel/WebNovelModels.cs
create mode 100644 Jiten.Core/WebNovel/WebNovelSchedule.cs
create mode 100644 Jiten.Core/WebNovel/WebNovelSourceResolver.cs
create mode 100644 Jiten.Core/WebNovel/WebNovelUrlParser.cs
create mode 100644 Jiten.Tests/RubyHtmlHelperTests.cs
create mode 100644 Jiten.Tests/SubdeckChunkerTests.cs
create mode 100644 Jiten.Tests/WebNovelScheduleTests.cs
create mode 100644 Jiten.Web/app/components/dashboard/WebNovelSyncPanel.vue
create mode 100644 Jiten.Web/app/pages/dashboard/add-webnovel.vue
diff --git a/Jiten.Api/Controllers/AdminController.WebNovels.cs b/Jiten.Api/Controllers/AdminController.WebNovels.cs
new file mode 100644
index 00000000..dccbbace
--- /dev/null
+++ b/Jiten.Api/Controllers/AdminController.WebNovels.cs
@@ -0,0 +1,213 @@
+using Hangfire;
+using Jiten.Api.Dtos.Requests;
+using Jiten.Api.Jobs;
+using Jiten.Core.Data;
+using Jiten.Core.Data.WebNovel;
+using Jiten.Core.WebNovel;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jiten.Api.Controllers;
+
+public partial class AdminController
+{
+ ///
+ /// Work metadata straight from the provider, so the admin can review it (and generate a cover from the
+ /// title) before committing to an import.
+ ///
+ [HttpGet("webnovel-preview")]
+ public async Task PreviewWebNovel([FromQuery] string url,
+ [FromServices] IWebNovelSourceResolver sourceResolver)
+ {
+ if (!WebNovelUrlParser.TryParse(url, out var provider, out var sourceId))
+ return BadRequest(new { Message = "Not a supported webnovel URL." });
+
+ if (!sourceResolver.IsSupported(provider))
+ return BadRequest(new { Message = $"{provider} is not enabled yet." });
+
+ var existing = await dbContext.WebNovelSources
+ .FirstOrDefaultAsync(s => s.Provider == provider && s.SourceId == sourceId);
+
+ if (existing != null)
+ return Conflict(new { Message = "This novel is already tracked.", DeckId = existing.DeckId });
+
+ try
+ {
+ var info = await sourceResolver.Resolve(provider).GetInfoAsync(sourceId);
+
+ // A same-title webnovel deck would make InsertDeck skip the insert, so the import would fail
+ var titleConflict = await dbContext.Decks
+ .AnyAsync(d => d.OriginalTitle == info.Title &&
+ d.MediaType == MediaType.WebNovel);
+
+ return Ok(new
+ {
+ Provider = provider.ToString(),
+ info.SourceId,
+ info.Url,
+ info.Title,
+ TitleConflict = titleConflict,
+ info.Author,
+ info.Synopsis,
+ info.Genre,
+ info.Keywords,
+ info.EpisodeCount,
+ info.TotalCharacters,
+ info.IsCompleted,
+ info.IsOnHiatus,
+ info.IsOneShot,
+ info.IsR15,
+ FirstPublishedAt = info.FirstPublishedAt?.UtcDateTime,
+ LastUpdatedAt = info.LastUpdatedAt?.UtcDateTime,
+ EstimatedSubdecks = EstimateSubdecks(info.TotalCharacters)
+ });
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "Webnovel preview failed for {Url}", url);
+ return StatusCode(StatusCodes.Status502BadGateway,
+ new { Message = "Could not read this novel from the source.", Details = ex.Message });
+ }
+ }
+
+ [HttpPost("add-webnovel-deck")]
+ [Consumes("multipart/form-data")]
+ public async Task AddWebNovelDeck([FromForm] AddWebNovelDeckRequest model,
+ [FromServices] IWebNovelSourceResolver sourceResolver)
+ {
+ if (!ModelState.IsValid)
+ return BadRequest(ModelState);
+
+ if (!WebNovelUrlParser.TryParse(model.Url, out var provider, out var sourceId))
+ return BadRequest(new { Message = "Not a supported webnovel URL." });
+
+ if (!sourceResolver.IsSupported(provider))
+ return BadRequest(new { Message = $"{provider} is not enabled yet." });
+
+ if (await dbContext.WebNovelSources.AnyAsync(s => s.Provider == provider && s.SourceId == sourceId))
+ return Conflict(new { Message = "This novel is already tracked." });
+
+ string? coverPath = null;
+ if (model.CoverImage is { Length: > 0 })
+ {
+ var directory = Path.Join(config["StaticFilesPath"], "tmp", Guid.NewGuid().ToString());
+ Directory.CreateDirectory(directory);
+
+ coverPath = Path.Join(directory, "cover.jpg");
+ await using var stream = new FileStream(coverPath, FileMode.Create);
+ await model.CoverImage.CopyToAsync(stream);
+ }
+
+ // 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));
+
+ logger.LogInformation("Admin queued webnovel import for {Provider}/{SourceId} (job {JobId})",
+ provider, sourceId, jobId);
+
+ return Accepted(new { Message = "Import queued.", JobId = jobId, Provider = provider.ToString(), SourceId = sourceId });
+ }
+
+ [HttpGet("webnovel/{deckId:int}")]
+ public async Task GetWebNovelSource(int deckId)
+ {
+ var tracked = await dbContext.WebNovelSources
+ .AsNoTracking()
+ .FirstOrDefaultAsync(s => s.DeckId == deckId);
+
+ if (tracked == null)
+ return NotFound();
+
+ var subdecks = await dbContext.WebNovelChapters
+ .AsNoTracking()
+ .Where(c => c.DeckId == deckId)
+ .GroupBy(c => c.ChildDeckId)
+ .Select(g => new
+ {
+ ChildDeckId = g.Key,
+ StartEpisode = g.Min(c => c.EpisodeNumber),
+ EndEpisode = g.Max(c => c.EpisodeNumber),
+ EpisodeCount = g.Count(),
+ CharCount = g.Sum(c => c.CharCount)
+ })
+ .OrderBy(g => g.StartEpisode)
+ .ToListAsync();
+
+ return Ok(new
+ {
+ tracked.DeckId,
+ Provider = tracked.Provider.ToString(),
+ tracked.SourceId,
+ Url = WebNovelUrlParser.BuildWorkUrl(tracked.Provider, tracked.SourceId),
+ tracked.LastEpisodeCount,
+ tracked.LastSourceUpdate,
+ tracked.LastSyncedAt,
+ tracked.NextCheckAt,
+ tracked.SyncEnabled,
+ tracked.CompletedAtSource,
+ tracked.OnHiatusAtSource,
+ tracked.ConsecutiveFailures,
+ tracked.LastError,
+ tracked.ChunkCharBudget,
+ tracked.PendingRevisionCount,
+ Subdecks = subdecks
+ });
+ }
+
+ [HttpPost("webnovel/{deckId:int}/sync")]
+ public async Task SyncWebNovelNow(int deckId)
+ {
+ if (!await dbContext.WebNovelSources.AnyAsync(s => s.DeckId == deckId))
+ return NotFound();
+
+ var jobId = backgroundJobs.Enqueue(job => job.Sync(deckId));
+
+ return Accepted(new { Message = "Sync queued.", JobId = jobId });
+ }
+
+ ///
+ /// Re-fetches one subdeck's whole episode range, picking up revisions (改稿) to episodes already ingested.
+ ///
+ [HttpPost("webnovel/{deckId:int}/rebuild/{childDeckId:int}")]
+ public async Task RebuildWebNovelSubdeck(int deckId, int childDeckId)
+ {
+ if (!await dbContext.WebNovelChapters.AnyAsync(c => c.DeckId == deckId && c.ChildDeckId == childDeckId))
+ return NotFound();
+
+ var jobId = backgroundJobs.Enqueue(job => job.RebuildSubdeck(deckId, childDeckId));
+
+ return Accepted(new { Message = "Rebuild queued.", JobId = jobId });
+ }
+
+ [HttpPost("webnovel/{deckId:int}/sync-enabled")]
+ public async Task SetWebNovelSyncEnabled(int deckId, [FromBody] SetWebNovelSyncEnabledRequest model)
+ {
+ var tracked = await dbContext.WebNovelSources.FirstOrDefaultAsync(s => s.DeckId == deckId);
+ if (tracked == null)
+ return NotFound();
+
+ tracked.SyncEnabled = model.Enabled;
+
+ // Coming off a freeze shouldn't wait out a backoff window
+ if (model.Enabled)
+ {
+ tracked.NextCheckAt = DateTimeOffset.UtcNow;
+ tracked.ConsecutiveFailures = 0;
+ tracked.LastError = null;
+ }
+
+ await dbContext.SaveChangesAsync();
+
+ return Ok(new { tracked.DeckId, tracked.SyncEnabled });
+ }
+
+ private static int EstimateSubdecks(long totalCharacters) =>
+ totalCharacters <= 0
+ ? 1
+ : (int)Math.Max(1, Math.Ceiling(totalCharacters / (double)SubdeckChunker.DefaultCharBudget));
+}
+
+public class SetWebNovelSyncEnabledRequest
+{
+ public bool Enabled { get; set; }
+}
diff --git a/Jiten.Api/Controllers/AdminController.cs b/Jiten.Api/Controllers/AdminController.cs
index 889e72d8..6575098f 100644
--- a/Jiten.Api/Controllers/AdminController.cs
+++ b/Jiten.Api/Controllers/AdminController.cs
@@ -1155,6 +1155,9 @@ public async Task FetchMetadata(int deckId)
else
backgroundJobs.Enqueue(job => job.FetchGoogleBooksMissingMetadata(deckId));
break;
+ case MediaType.WebNovel:
+ backgroundJobs.Enqueue(job => job.FetchSyosetuMissingMetadata(deckId));
+ break;
default:
return NotFound("No fetch job for this media type.");
}
@@ -1204,6 +1207,10 @@ public async Task FetchAllMissingMetadata()
}
break;
+ case MediaType.WebNovel:
+ if (deck.Links.Any(l => l.LinkType == LinkType.Syosetsu))
+ backgroundJobs.Enqueue(job => job.FetchSyosetuMissingMetadata(deck.DeckId));
+ break;
default:
break;
}
diff --git a/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs b/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs
new file mode 100644
index 00000000..1c5390d7
--- /dev/null
+++ b/Jiten.Api/Dtos/Requests/AddWebNovelDeckRequest.cs
@@ -0,0 +1,19 @@
+namespace Jiten.Api.Dtos.Requests;
+
+public class AddWebNovelDeckRequest
+{
+ ///
+ /// Novel URL (https://ncode.syosetu.com/n9669bk/) or a bare ncode
+ ///
+ public required string Url { get; set; }
+
+ ///
+ /// Optional: uploaded or generated in the browser. Syosetu works have no cover art of their own.
+ ///
+ public IFormFile? CoverImage { get; set; }
+
+ ///
+ /// Optional per-novel override of the subdeck character budget
+ ///
+ public int? ChunkCharBudget { get; set; }
+}
diff --git a/Jiten.Api/Jobs/FetchMetadataJob.cs b/Jiten.Api/Jobs/FetchMetadataJob.cs
index 4f4176ca..c94ea252 100644
--- a/Jiten.Api/Jobs/FetchMetadataJob.cs
+++ b/Jiten.Api/Jobs/FetchMetadataJob.cs
@@ -2,11 +2,16 @@
using Jiten.Core;
using Jiten.Core.Data;
using Jiten.Core.Data.Providers;
+using Jiten.Core.Data.WebNovel;
+using Jiten.Core.WebNovel;
using Microsoft.EntityFrameworkCore;
namespace Jiten.Api.Jobs;
-public class FetchMetadataJob(IDbContextFactory contextFactory, IConfiguration configuration)
+public class FetchMetadataJob(
+ IDbContextFactory contextFactory,
+ IConfiguration configuration,
+ IWebNovelSourceResolver sourceResolver)
{
private const float ANILIST_DELAY = 2.2f;
private const float GOOGLE_BOOKS_DELAY = 3f;
@@ -366,6 +371,64 @@ public async Task FetchJikanMissingMetadata(int deckId)
}
}
+ ///
+ /// Refreshes a tracked webnovel from the source's metadata API
+ ///
+ [Queue(WebNovelQueues.SyosetuMetadata)]
+ public async Task FetchSyosetuMissingMetadata(int deckId)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+
+ var deck = await context.Decks
+ .Include(d => d.Links)
+ .Include(d => d.Titles)
+ .Include(d => d.DeckGenres)
+ .Include(d => d.DeckTags)
+ .FirstAsync(d => d.DeckId == deckId);
+
+ var (provider, sourceId) = await ResolveWebNovelWork(context, deck);
+
+ var info = await sourceResolver.Resolve(provider).GetInfoAsync(sourceId);
+ var metadata = info.ToMetadata();
+
+ if (deck.ReleaseDate == default && metadata.ReleaseDate != null)
+ deck.ReleaseDate = DateOnly.FromDateTime(metadata.ReleaseDate.Value);
+
+ if (string.IsNullOrEmpty(deck.Description))
+ deck.Description = metadata.Description?.Length > 2000 ? metadata.Description?[..2000] : metadata.Description;
+
+ if (deck.Links.All(l => l.LinkType != LinkType.Syosetsu))
+ deck.Links.Add(new Link { DeckId = deck.DeckId, LinkType = LinkType.Syosetsu, Url = info.Url });
+
+ await MetadataProviderHelper.ApplyGenreAndTagMappings(context, deck, metadata, LinkType.Syosetsu);
+
+ await context.SaveChangesAsync();
+ }
+
+ ///
+ /// The ledger is the authority on which work a deck came from; the link is the fallback for a webnovel
+ /// deck that was added by hand rather than imported.
+ ///
+ private static async Task<(WebNovelProvider Provider, string SourceId)> ResolveWebNovelWork(JitenDbContext context, Deck deck)
+ {
+ var tracked = await context.WebNovelSources
+ .AsNoTracking()
+ .FirstOrDefaultAsync(s => s.DeckId == deck.DeckId);
+
+ if (tracked != null)
+ return (tracked.Provider, tracked.SourceId);
+
+ var link = deck.Links.FirstOrDefault(l => l.LinkType == LinkType.Syosetsu);
+
+ if (link == null)
+ throw new Exception($"No Syosetsu link found for deck with ID {deck.DeckId}.");
+
+ if (!WebNovelUrlParser.TryParse(link.Url, out var provider, out var sourceId))
+ throw new Exception($"Syosetsu link '{link.Url}' on deck {deck.DeckId} is not a work URL.");
+
+ return (provider, sourceId);
+ }
+
private static void MergeDictionaryEntries(Deck deck, List entries)
{
foreach (var entry in entries)
diff --git a/Jiten.Api/Jobs/ParseJob.cs b/Jiten.Api/Jobs/ParseJob.cs
index 8690a11b..833ce2bf 100644
--- a/Jiten.Api/Jobs/ParseJob.cs
+++ b/Jiten.Api/Jobs/ParseJob.cs
@@ -18,6 +18,15 @@ public class ParseJob(
{
[Queue("parse")]
public async Task Parse(Metadata metadata, MediaType deckType, bool storeRawText = false)
+ {
+ await ParseAndGetDeckId(metadata, deckType, storeRawText);
+ }
+
+ ///
+ /// Same as , but hands back the created deck. Callers that need to attach their own
+ /// rows to the new deck tree (webnovel ledger) run it inline instead of enqueuing it.
+ ///
+ public async Task ParseAndGetDeckId(Metadata metadata, MediaType deckType, bool storeRawText = false)
{
Deck deck = new();
string filePath = metadata.FilePath!;
@@ -127,10 +136,13 @@ public async Task Parse(Metadata metadata, MediaType deckType, bool storeRawText
await MetadataProviderHelper.ApplyGenreAndTagMappings(context, deck, metadata, firstLink.LinkType);
}
- var coverImage = await File.ReadAllBytesAsync(metadata.Image ?? throw new Exception("No cover image found."));
+ // Sources with no cover art (webnovels) fall back to nocover.jpg, set above
+ var coverImage = !string.IsNullOrEmpty(metadata.Image) && File.Exists(metadata.Image)
+ ? await File.ReadAllBytesAsync(metadata.Image)
+ : [];
// Insert the deck into the database
- await JitenHelper.InsertDeck(contextFactory, deck, coverImage ?? [], false);
+ await JitenHelper.InsertDeck(contextFactory, deck, coverImage, false);
// Process relations from metadata
if (metadata.Relations.Count > 0)
@@ -154,6 +166,8 @@ public async Task Parse(Metadata metadata, MediaType deckType, bool storeRawText
// Notify IndexNow of the new (top-level) deck page so Bing can index it quickly.
await indexNow.SubmitDeckAsync(deck.DeckId);
+
+ return deck.DeckId;
}
///
diff --git a/Jiten.Api/Jobs/WebNovelFetchJob.cs b/Jiten.Api/Jobs/WebNovelFetchJob.cs
new file mode 100644
index 00000000..1f5b84f2
--- /dev/null
+++ b/Jiten.Api/Jobs/WebNovelFetchJob.cs
@@ -0,0 +1,334 @@
+using Hangfire;
+using Jiten.Core;
+using Jiten.Core.Data;
+using Jiten.Core.Data.WebNovel;
+using Jiten.Core.WebNovel;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jiten.Api.Jobs;
+
+public class WebNovelFetchJob(
+ IDbContextFactory contextFactory,
+ IWebNovelSourceResolver sourceResolver,
+ IBackgroundJobClient backgroundJobs,
+ ILogger logger)
+{
+ ///
+ /// Appends any new episodes to the novel's subdecks.
+ ///
+ /// Subdeck rows and their raw text are updated by DeckId. The InsertDeck(update: true) path must
+ /// never be used here: JitenHelper.CollectChildDeckUpdates matches children by OriginalTitle and
+ /// orphan-deletes the ones it can't match, and the open subdeck's title changes on every append — that
+ /// would delete the subdeck and every user's progress on it.
+ ///
+ [Queue(WebNovelQueues.Syosetu)]
+ [AutomaticRetry(Attempts = 1)]
+ public async Task Sync(int parentDeckId)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+
+ var tracked = await context.WebNovelSources
+ .Include(s => s.Chapters)
+ .FirstOrDefaultAsync(s => s.DeckId == parentDeckId);
+
+ if (tracked == null)
+ {
+ logger.LogWarning("WebNovelSync: deck {DeckId} is not a tracked webnovel", parentDeckId);
+ return;
+ }
+
+ try
+ {
+ var source = sourceResolver.Resolve(tracked.Provider);
+
+ var info = await source.GetInfoAsync(tracked.SourceId);
+ var toc = await source.GetTocAsync(tracked.SourceId);
+
+ var ledger = tracked.Chapters.ToDictionary(c => c.EpisodeNumber);
+
+ // Episode numbers at the source are positional: deleting one renumbers everything after it,
+ // so the ledger no longer lines up with the table of contents. Appending would attach the
+ // wrong text to the wrong episode — stop and surface it instead.
+ if (toc.Count < ledger.Count)
+ {
+ throw new InvalidOperationException(
+ $"The source lists {toc.Count} episodes but {ledger.Count} are ingested — episodes were " +
+ "deleted or renumbered at the source. Rebuild the affected subdecks or re-import the novel.");
+ }
+
+ var newEpisodes = toc.Where(e => !ledger.ContainsKey(e.Number)).OrderBy(e => e.Number).ToList();
+
+ // Episodes we already hold whose text changed at the source (改稿). Applying these means
+ // rebuilding the subdeck that holds them, so they are surfaced for a manual refresh instead.
+ var revisedCount = toc.Count(e => ledger.TryGetValue(e.Number, out var known) &&
+ e.UpdatedAt != null && known.SourceUpdatedAt != null &&
+ e.UpdatedAt > known.SourceUpdatedAt);
+
+ if (newEpisodes.Count == 0)
+ {
+ ApplySuccessState(tracked, info, revisedCount);
+ await context.SaveChangesAsync();
+ logger.LogInformation("WebNovelSync: deck {DeckId} has no new episodes ({Revised} revised)",
+ parentDeckId, revisedCount);
+ return;
+ }
+
+ logger.LogInformation("WebNovelSync: deck {DeckId} has {Count} new episodes", parentDeckId, newEpisodes.Count);
+
+ var fetched = new List<(WebNovelEpisodeRef Reference, ChunkEpisode Chunk, string Text)>();
+ foreach (var reference in newEpisodes)
+ {
+ var text = await source.GetEpisodeTextAsync(tracked.SourceId, reference);
+ fetched.Add((reference,
+ new ChunkEpisode(reference.Number, reference.Title, SubdeckChunker.CountCharacters(text)),
+ text));
+ }
+
+ var existingChunks = await BuildExistingChunksAsync(context, tracked);
+ var plans = SubdeckChunker.Plan(existingChunks,
+ fetched.Select(f => f.Chunk).ToList(),
+ tracked.ChunkCharBudget ?? SubdeckChunker.DefaultCharBudget);
+
+ var textByEpisode = fetched.ToDictionary(f => f.Chunk.Number, f => f.Text);
+ var referenceByEpisode = fetched.ToDictionary(f => f.Chunk.Number, f => f.Reference);
+
+ // One transaction around the whole apply: appended raw text must never be committed without the
+ // matching ledger rows and success state, or the subdeck's text and words silently diverge.
+ await using var transaction = await context.Database.BeginTransactionAsync();
+
+ var changedChildIds = await ApplyPlansAsync(context, tracked, plans, textByEpisode, referenceByEpisode);
+
+ tracked.LastEpisodeCount = tracked.Chapters.Count;
+ ApplySuccessState(tracked, info, revisedCount);
+ await context.SaveChangesAsync();
+ await transaction.CommitAsync();
+
+ // Reparses exactly the changed subdecks by DeckId, re-aggregates the parent and preserves progress
+ backgroundJobs.Enqueue(job => job.ParseNewSubdecks(parentDeckId, changedChildIds));
+
+ logger.LogInformation("WebNovelSync: deck {DeckId} appended {Episodes} episodes across {Subdecks} subdecks",
+ parentDeckId, newEpisodes.Count, changedChildIds.Count);
+ }
+ catch (Exception ex)
+ {
+ // This context's tracker may hold half-applied changes (or be the thing that threw), so the
+ // failure bookkeeping runs on its own context.
+ await RecordFailureAsync(parentDeckId, ex);
+ throw;
+ }
+ }
+
+ private static void ApplySuccessState(WebNovelSource tracked, WebNovelInfo info, int revisedCount)
+ {
+ tracked.CompletedAtSource = info.IsCompleted;
+ tracked.OnHiatusAtSource = info.IsOnHiatus;
+ tracked.PendingRevisionCount = revisedCount;
+ tracked.LastSourceUpdate = info.LastUpdatedAt;
+ tracked.LastSyncedAt = DateTimeOffset.UtcNow;
+ tracked.NextCheckAt = WebNovelSchedule.NextCheck(info.IsCompleted);
+ tracked.ConsecutiveFailures = 0;
+ tracked.LastError = null;
+ }
+
+ private async Task RecordFailureAsync(int parentDeckId, Exception ex)
+ {
+ try
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+ var tracked = await context.WebNovelSources.FirstOrDefaultAsync(s => s.DeckId == parentDeckId);
+ if (tracked == null)
+ return;
+
+ tracked.ConsecutiveFailures++;
+ tracked.LastError = WebNovelImportJob.Truncate(ex.Message, 1000);
+ tracked.NextCheckAt = WebNovelSchedule.NextCheckAfterFailure(tracked.ConsecutiveFailures);
+ await context.SaveChangesAsync();
+
+ if (tracked.ConsecutiveFailures >= 3)
+ {
+ logger.LogError(ex, "WebNovelSync: deck {DeckId} has failed {Count} times in a row — the site's markup " +
+ "has probably changed, re-check the narou.rb site definitions",
+ parentDeckId, tracked.ConsecutiveFailures);
+ }
+ else
+ {
+ logger.LogWarning(ex, "WebNovelSync: deck {DeckId} failed", parentDeckId);
+ }
+ }
+ catch (Exception saveEx)
+ {
+ logger.LogError(saveEx, "WebNovelSync: could not record the failure for deck {DeckId}", parentDeckId);
+ }
+ }
+
+ ///
+ /// Re-fetches every episode in one subdeck and replaces its text, picking up revisions (改稿) to episodes
+ /// that were already ingested. Manual, because a rebuild costs one request per episode in the range.
+ ///
+ [Queue(WebNovelQueues.Syosetu)]
+ [AutomaticRetry(Attempts = 1)]
+ public async Task RebuildSubdeck(int parentDeckId, int childDeckId)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+
+ var tracked = await context.WebNovelSources
+ .Include(s => s.Chapters)
+ .FirstOrDefaultAsync(s => s.DeckId == parentDeckId);
+
+ if (tracked == null)
+ return;
+
+ var chapters = tracked.Chapters.Where(c => c.ChildDeckId == childDeckId)
+ .OrderBy(c => c.EpisodeNumber)
+ .ToList();
+
+ if (chapters.Count == 0)
+ {
+ logger.LogWarning("WebNovelRebuild: subdeck {ChildDeckId} holds no episodes", childDeckId);
+ return;
+ }
+
+ var source = sourceResolver.Resolve(tracked.Provider);
+ var toc = (await source.GetTocAsync(tracked.SourceId)).ToDictionary(e => e.Number);
+
+ var texts = new List();
+ foreach (var chapter in chapters)
+ {
+ if (!toc.TryGetValue(chapter.EpisodeNumber, out var reference))
+ {
+ // The episode was deleted at the source; keep the range intact and skip it
+ logger.LogWarning("WebNovelRebuild: episode {Episode} no longer exists at the source",
+ chapter.EpisodeNumber);
+ continue;
+ }
+
+ var text = await source.GetEpisodeTextAsync(tracked.SourceId, reference);
+ texts.Add(text);
+
+ chapter.Title = WebNovelImportJob.Truncate(reference.Title, 500);
+ chapter.SourceUpdatedAt = reference.UpdatedAt;
+ chapter.CharCount = SubdeckChunker.CountCharacters(text);
+ }
+
+ var rawText = await context.DeckRawTexts.FirstOrDefaultAsync(t => t.DeckId == childDeckId);
+ if (rawText == null)
+ {
+ rawText = new DeckRawText { DeckId = childDeckId };
+ context.DeckRawTexts.Add(rawText);
+ }
+
+ rawText.RawText = string.Join("\n\n", texts);
+
+ // Recompute over the whole ledger: this rebuild refreshed its own chapters' SourceUpdatedAt, but
+ // other subdecks may still hold revised episodes awaiting their own rebuild.
+ tracked.PendingRevisionCount = tracked.Chapters.Count(c => toc.TryGetValue(c.EpisodeNumber, out var e) &&
+ e.UpdatedAt != null && c.SourceUpdatedAt != null &&
+ e.UpdatedAt > c.SourceUpdatedAt);
+ await context.SaveChangesAsync();
+
+ backgroundJobs.Enqueue(job => job.ParseNewSubdecks(parentDeckId, new List { childDeckId }));
+
+ logger.LogInformation("WebNovelRebuild: rebuilt subdeck {ChildDeckId} from {Count} episodes",
+ childDeckId, texts.Count);
+ }
+
+ private static async Task> BuildExistingChunksAsync(JitenDbContext context, WebNovelSource tracked)
+ {
+ if (tracked.Chapters.Count == 0)
+ return [];
+
+ var orderByChild = await context.Decks
+ .Where(d => d.ParentDeckId == tracked.DeckId)
+ .Select(d => new { d.DeckId, d.DeckOrder })
+ .ToDictionaryAsync(d => d.DeckId, d => d.DeckOrder);
+
+ return tracked.Chapters
+ .GroupBy(c => c.ChildDeckId)
+ .Select(g => new ExistingChunk(
+ ChunkIndex: orderByChild.GetValueOrDefault(g.Key),
+ ChildDeckId: g.Key,
+ StartEpisode: g.Min(c => c.EpisodeNumber),
+ EndEpisode: g.Max(c => c.EpisodeNumber),
+ EpisodeCount: g.Count(),
+ CharCount: g.Sum(c => c.CharCount)))
+ .OrderBy(c => c.ChunkIndex)
+ .ToList();
+ }
+
+ ///
+ /// Extends the open subdeck and opens new ones, all keyed by DeckId so existing subdecks keep their identity.
+ ///
+ private async Task> ApplyPlansAsync(JitenDbContext context,
+ WebNovelSource tracked,
+ List plans,
+ Dictionary textByEpisode,
+ Dictionary referenceByEpisode)
+ {
+ var parent = await context.Decks.FirstAsync(d => d.DeckId == tracked.DeckId);
+ var changedChildIds = new List();
+
+ foreach (var plan in plans)
+ {
+ var appendedText = string.Join("\n\n", plan.EpisodesToAppend.Select(e => textByEpisode[e.Number]));
+ int childDeckId;
+
+ if (plan.ChildDeckId is { } existingChildId)
+ {
+ var child = await context.Decks.FirstAsync(d => d.DeckId == existingChildId);
+ var rawText = await context.DeckRawTexts.FirstOrDefaultAsync(t => t.DeckId == existingChildId);
+
+ if (rawText == null)
+ {
+ // Raw-text storage was off when this subdeck was created; there is nothing to append to
+ logger.LogError("WebNovelSync: subdeck {ChildDeckId} has no stored raw text, cannot append",
+ existingChildId);
+ continue;
+ }
+
+ rawText.RawText = $"{rawText.RawText}\n\n{appendedText}";
+
+ // The open subdeck's episode range grew
+ child.OriginalTitle = plan.Title;
+ child.LastUpdate = DateTimeOffset.UtcNow;
+
+ childDeckId = existingChildId;
+ }
+ else
+ {
+ var child = new Deck
+ {
+ ParentDeckId = tracked.DeckId,
+ DeckOrder = plan.ChunkIndex,
+ MediaType = parent.MediaType,
+ OriginalTitle = plan.Title,
+ DifficultyOverride = -1,
+ CreationDate = DateTimeOffset.UtcNow,
+ LastUpdate = DateTimeOffset.UtcNow,
+ RawText = new DeckRawText(appendedText)
+ };
+
+ context.Decks.Add(child);
+ await context.SaveChangesAsync();
+
+ childDeckId = child.DeckId;
+ }
+
+ foreach (var episode in plan.EpisodesToAppend)
+ {
+ tracked.Chapters.Add(new WebNovelChapter
+ {
+ DeckId = tracked.DeckId,
+ EpisodeNumber = episode.Number,
+ ChildDeckId = childDeckId,
+ Title = WebNovelImportJob.Truncate(episode.Title, 500),
+ SourceUpdatedAt = referenceByEpisode[episode.Number].UpdatedAt,
+ CharCount = episode.CharCount
+ });
+ }
+
+ changedChildIds.Add(childDeckId);
+ }
+
+ return changedChildIds;
+ }
+}
diff --git a/Jiten.Api/Jobs/WebNovelImportJob.cs b/Jiten.Api/Jobs/WebNovelImportJob.cs
new file mode 100644
index 00000000..7602e3b0
--- /dev/null
+++ b/Jiten.Api/Jobs/WebNovelImportJob.cs
@@ -0,0 +1,202 @@
+using Hangfire;
+using Jiten.Core;
+using Jiten.Core.Data;
+using Jiten.Core.Data.Providers;
+using Jiten.Core.Data.WebNovel;
+using Jiten.Core.WebNovel;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jiten.Api.Jobs;
+
+public class WebNovelImportJob(
+ IDbContextFactory contextFactory,
+ IWebNovelSourceResolver sourceResolver,
+ ParseJob parseJob,
+ IConfiguration config,
+ ILogger logger)
+{
+ ///
+ /// Fetches a whole novel and creates the parent deck plus its chapter-range subdecks.
+ ///
+ /// Runs on the per-site queue: a 1,000-episode novel is ~20 minutes of polite fetching, so it must
+ /// never run in-request, and it must serialise with syncs so the site never sees two requests at once.
+ ///
+ [Queue(WebNovelQueues.Syosetu)]
+ [AutomaticRetry(Attempts = 1)]
+ public async Task Import(WebNovelProvider provider, string sourceId, string? coverPath, int? chunkCharBudget)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+
+ if (await context.WebNovelSources.AnyAsync(s => s.Provider == provider && s.SourceId == sourceId))
+ {
+ logger.LogWarning("WebNovelImport: {Provider}/{SourceId} is already tracked, skipping", provider, sourceId);
+ return;
+ }
+
+ var source = sourceResolver.Resolve(provider);
+
+ var info = await source.GetInfoAsync(sourceId);
+
+ // 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))
+ {
+ throw new InvalidOperationException(
+ $"A webnovel deck titled '{info.Title}' already exists. Rename or remove it before importing {sourceId}.");
+ }
+
+ var toc = await source.GetTocAsync(sourceId);
+
+ if (toc.Count == 0)
+ throw new InvalidOperationException($"{provider}/{sourceId} has no episodes.");
+
+ logger.LogInformation("WebNovelImport: {Title} ({SourceId}) — {Episodes} episodes, ~{Chars} chars",
+ info.Title, sourceId, toc.Count, info.TotalCharacters);
+
+ var budget = chunkCharBudget ?? SubdeckChunker.DefaultCharBudget;
+
+ var workingDirectory = Path.Join(config["StaticFilesPath"], "tmp", $"webnovel-{sourceId}-{Guid.NewGuid()}");
+ Directory.CreateDirectory(workingDirectory);
+
+ try
+ {
+ var episodes = new List<(WebNovelEpisodeRef Reference, ChunkEpisode Chunk, string Text)>();
+
+ foreach (var reference in toc)
+ {
+ var text = await source.GetEpisodeTextAsync(sourceId, reference);
+ episodes.Add((reference,
+ new ChunkEpisode(reference.Number, reference.Title, SubdeckChunker.CountCharacters(text)),
+ text));
+ }
+
+ var plans = SubdeckChunker.Plan([], episodes.Select(e => e.Chunk).ToList(), budget);
+ var textByEpisode = episodes.ToDictionary(e => e.Chunk.Number, e => e.Text);
+
+ var metadata = info.ToMetadata();
+
+ foreach (var plan in plans)
+ {
+ var chunkPath = Path.Join(workingDirectory, $"chunk-{plan.ChunkIndex:D4}.txt");
+ await File.WriteAllTextAsync(chunkPath, JoinEpisodes(plan, textByEpisode));
+
+ metadata.Children.Add(new Metadata
+ {
+ FilePath = chunkPath,
+ OriginalTitle = plan.Title
+ });
+ }
+
+ if (!string.IsNullOrEmpty(coverPath) && File.Exists(coverPath))
+ metadata.Image = coverPath;
+
+ var parentDeckId = await parseJob.ParseAndGetDeckId(metadata, MediaType.WebNovel, storeRawText: true);
+
+ await WriteLedgerAsync(parentDeckId, provider, sourceId, info, plans, episodes, chunkCharBudget);
+
+ // Only on success: a Hangfire retry of a failed import still needs the uploaded cover
+ DeleteCoverDirectory(coverPath);
+
+ logger.LogInformation("WebNovelImport: created deck {DeckId} with {Subdecks} subdecks for {Title}",
+ parentDeckId, plans.Count, info.Title);
+ }
+ finally
+ {
+ try
+ {
+ Directory.Delete(workingDirectory, recursive: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "WebNovelImport: could not clean up {Directory}", workingDirectory);
+ }
+ }
+ }
+
+ private void DeleteCoverDirectory(string? coverPath)
+ {
+ if (string.IsNullOrEmpty(coverPath))
+ return;
+
+ try
+ {
+ var directory = Path.GetDirectoryName(coverPath);
+ if (!string.IsNullOrEmpty(directory) && Directory.Exists(directory))
+ Directory.Delete(directory, recursive: true);
+ }
+ catch (Exception ex)
+ {
+ logger.LogWarning(ex, "WebNovelImport: could not clean up the cover at {CoverPath}", coverPath);
+ }
+ }
+
+ private static string JoinEpisodes(ChunkPlan plan, Dictionary textByEpisode) =>
+ string.Join("\n\n", plan.EpisodesToAppend.Select(e => textByEpisode[e.Number]));
+
+ private async Task WriteLedgerAsync(int parentDeckId, WebNovelProvider provider, string sourceId, WebNovelInfo info,
+ List plans,
+ List<(WebNovelEpisodeRef Reference, ChunkEpisode Chunk, string Text)> episodes,
+ int? chunkCharBudget)
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+
+ // Children were created in chunk order, so DeckOrder maps back to the chunk that produced them
+ var childIdsByOrder = await context.Decks
+ .Where(d => d.ParentDeckId == parentDeckId)
+ .OrderBy(d => d.DeckOrder)
+ .Select(d => new { d.DeckId, d.DeckOrder })
+ .ToDictionaryAsync(d => d.DeckOrder, d => d.DeckId);
+
+ // No subdecks means the deck wasn't actually created (a same-title deck already existed and the insert
+ // was skipped). Writing the ledger now would bind this novel to a deck it doesn't own.
+ if (childIdsByOrder.Count == 0)
+ {
+ throw new InvalidOperationException(
+ $"Deck {parentDeckId} has no subdecks after import — the insert was skipped, so {sourceId} was not tracked.");
+ }
+
+ context.WebNovelSources.Add(new WebNovelSource
+ {
+ DeckId = parentDeckId,
+ Provider = provider,
+ SourceId = sourceId,
+ LastEpisodeCount = episodes.Count,
+ LastSourceUpdate = info.LastUpdatedAt,
+ LastSyncedAt = DateTimeOffset.UtcNow,
+ NextCheckAt = WebNovelSchedule.NextCheck(info.IsCompleted),
+ CompletedAtSource = info.IsCompleted,
+ OnHiatusAtSource = info.IsOnHiatus,
+ ChunkCharBudget = chunkCharBudget
+ });
+
+ var referenceByNumber = episodes.ToDictionary(e => e.Chunk.Number, e => e.Reference);
+
+ foreach (var plan in plans)
+ {
+ if (!childIdsByOrder.TryGetValue(plan.ChunkIndex, out var childDeckId))
+ {
+ logger.LogError("WebNovelImport: no subdeck was created for chunk {Chunk} of deck {DeckId}",
+ plan.ChunkIndex, parentDeckId);
+ continue;
+ }
+
+ foreach (var episode in plan.EpisodesToAppend)
+ {
+ context.WebNovelChapters.Add(new WebNovelChapter
+ {
+ DeckId = parentDeckId,
+ EpisodeNumber = episode.Number,
+ ChildDeckId = childDeckId,
+ Title = Truncate(episode.Title, 500),
+ SourceUpdatedAt = referenceByNumber[episode.Number].UpdatedAt,
+ CharCount = episode.CharCount
+ });
+ }
+ }
+
+ await context.SaveChangesAsync();
+ }
+
+ internal static string Truncate(string value, int maxLength) =>
+ value.Length <= maxLength ? value : value[..maxLength];
+}
diff --git a/Jiten.Api/Jobs/WebNovelQueues.cs b/Jiten.Api/Jobs/WebNovelQueues.cs
new file mode 100644
index 00000000..25d385c2
--- /dev/null
+++ b/Jiten.Api/Jobs/WebNovelQueues.cs
@@ -0,0 +1,18 @@
+namespace Jiten.Api.Jobs;
+
+///
+/// One single-worker queue per site. Imports and syncs for a site serialise on the same queue, so the
+/// politeness rate holds however many novels are queued; different sites still run concurrently.
+///
+public static class WebNovelQueues
+{
+ public const string Syosetu = "webnovel-syosetu";
+
+ ///
+ /// Metadata refreshes are a single API call, so they get their own queue rather than waiting out the
+ /// hours-long import sitting in front of them
+ ///
+ public const string SyosetuMetadata = "webnovel-syosetu-metadata";
+
+ public static readonly string[] All = [Syosetu, SyosetuMetadata];
+}
diff --git a/Jiten.Api/Jobs/WebNovelSyncSweepJob.cs b/Jiten.Api/Jobs/WebNovelSyncSweepJob.cs
new file mode 100644
index 00000000..324d3d4a
--- /dev/null
+++ b/Jiten.Api/Jobs/WebNovelSyncSweepJob.cs
@@ -0,0 +1,138 @@
+using Hangfire;
+using Jiten.Core;
+using Jiten.Core.Data.WebNovel;
+using Jiten.Core.WebNovel;
+using Microsoft.EntityFrameworkCore;
+
+namespace Jiten.Api.Jobs;
+
+public class WebNovelSyncSweepJob(
+ IDbContextFactory contextFactory,
+ IWebNovelSourceResolver sourceResolver,
+ IBackgroundJobClient backgroundJobs,
+ ILogger logger)
+{
+ ///
+ /// Polls every tracked novel's update state and enqueues a fetch for the ones worth syncing.
+ ///
+ /// For Syosetu this is two API calls per 1,000 novels — the metadata API reports episode count and last
+ /// update, so no episode or table-of-contents page is touched until there is something to fetch.
+ ///
+ /// Polling is daily but syncs are batched (): a sync reparses
+ /// the subdeck and rewrites the parent's aggregated words however few episodes it ingests, so a
+ /// daily-updating serial is left to accumulate ~15 episodes (or two weeks) between syncs.
+ ///
+ [Queue("default")]
+ public async Task Sweep()
+ {
+ await using var context = await contextFactory.CreateDbContextAsync();
+
+ var now = DateTimeOffset.UtcNow;
+ var due = await context.WebNovelSources
+ .Where(s => s.SyncEnabled && s.NextCheckAt <= now)
+ .ToListAsync();
+
+ if (due.Count == 0)
+ {
+ logger.LogInformation("WebNovelSweep: nothing due");
+ return;
+ }
+
+ var dirtyCount = 0;
+
+ foreach (var group in due.GroupBy(s => s.Provider))
+ {
+ if (!sourceResolver.IsSupported(group.Key))
+ {
+ logger.LogWarning("WebNovelSweep: provider {Provider} is not enabled, skipping {Count} novels",
+ group.Key, group.Count());
+ continue;
+ }
+
+ try
+ {
+ dirtyCount += await SweepProviderAsync(context, sourceResolver.Resolve(group.Key), group.ToList());
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "WebNovelSweep: polling {Provider} failed", group.Key);
+ }
+ }
+
+ await context.SaveChangesAsync();
+
+ logger.LogInformation("WebNovelSweep: polled {Due} novels, {Dirty} due a sync", due.Count, dirtyCount);
+ }
+
+ private async Task SweepProviderAsync(JitenDbContext context, IWebNovelSource source, List tracked)
+ {
+ var polled = source is IBatchPollableSource batch
+ ? await batch.BatchPollAsync(tracked.Select(s => s.SourceId))
+ : await PollIndividuallyAsync(source, tracked);
+
+ var dirty = 0;
+
+ foreach (var novel in tracked)
+ {
+ if (!polled.TryGetValue(novel.SourceId, out var info))
+ {
+ // The work was deleted or made private at the source
+ novel.ConsecutiveFailures++;
+ novel.LastError = "Not found at the source.";
+ novel.NextCheckAt = WebNovelSchedule.NextCheckAfterFailure(novel.ConsecutiveFailures);
+ logger.LogWarning("WebNovelSweep: {SourceId} was not returned by the source", novel.SourceId);
+ continue;
+ }
+
+ novel.CompletedAtSource = info.IsCompleted;
+ novel.OnHiatusAtSource = info.IsOnHiatus;
+
+ // Fewer episodes than ingested means deletions/renumbering at the source: every later episode
+ // shifted position, so a sync would append the wrong text. Alert instead of syncing.
+ if (info.EpisodeCount < novel.LastEpisodeCount)
+ {
+ novel.ConsecutiveFailures++;
+ novel.LastError = $"The source lists {info.EpisodeCount} episodes but {novel.LastEpisodeCount} are " +
+ "ingested — episodes were deleted or renumbered. Rebuild the affected subdecks or re-import.";
+ novel.NextCheckAt = WebNovelSchedule.NextCheckAfterFailure(novel.ConsecutiveFailures);
+ logger.LogWarning("WebNovelSweep: {SourceId} episode count dropped from {Known} to {Polled}",
+ novel.SourceId, novel.LastEpisodeCount, info.EpisodeCount);
+ continue;
+ }
+
+ if (WebNovelSchedule.ShouldSync(novel, info))
+ {
+ dirty++;
+ var deckId = novel.DeckId;
+ backgroundJobs.Enqueue(job => job.Sync(deckId));
+
+ // The fetch job owns the next check time from here
+ continue;
+ }
+
+ novel.NextCheckAt = WebNovelSchedule.NextCheck(info.IsCompleted);
+
+ // The novel polled healthy — a stale failure state (e.g. temporarily private) shouldn't linger
+ novel.ConsecutiveFailures = 0;
+ novel.LastError = null;
+
+ // Only mark as synced when nothing is pending. A below-threshold backlog keeps LastSyncedAt at
+ // the last ingest, so the max-lag clock in ShouldSync keeps running instead of resetting daily.
+ if (!WebNovelSchedule.IsDirty(novel, info))
+ novel.LastSyncedAt = DateTimeOffset.UtcNow;
+ }
+
+ return dirty;
+ }
+
+ private static async Task> PollIndividuallyAsync(IWebNovelSource source,
+ List tracked)
+ {
+ var polled = new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ foreach (var novel in tracked)
+ polled[novel.SourceId] = await source.GetInfoAsync(novel.SourceId);
+
+ return polled;
+ }
+}
diff --git a/Jiten.Api/Program.cs b/Jiten.Api/Program.cs
index fc7a4295..a1c68240 100644
--- a/Jiten.Api/Program.cs
+++ b/Jiten.Api/Program.cs
@@ -12,6 +12,7 @@
using Jiten.Api.Authentication;
using Jiten.Core;
using Jiten.Core.Data.Authentication;
+using Jiten.Core.WebNovel;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.HttpOverrides;
@@ -115,6 +116,22 @@
});
builder.Services.AddSingleton();
+builder.Services.AddHttpClient(SyosetuSource.HttpClientName, client =>
+ {
+ client.DefaultRequestHeaders.Add("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ })
+ // The API's gzip=5 body is inflated by hand; this covers the HTML pages
+ .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
+ {
+ AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate
+ });
+
+builder.Services.AddSingleton();
+builder.Services.AddSingleton();
+
// OpenTelemetry Configuration
var otelConfig = builder.Configuration.GetSection("OpenTelemetry");
var enableOtlpExporter = otelConfig.GetValue("EnableOtlpExporter");
@@ -532,6 +549,9 @@ bool IsTrustedSsr(HttpContext ctx)
// Hangfire jobs
builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
+builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
builder.Services.AddScoped();
@@ -593,6 +613,24 @@ bool IsTrustedSsr(HttpContext ctx)
options.WorkerCount = 1;
});
+
+builder.Services.AddHangfireServer((options) =>
+{
+ options.ServerName = "WebNovelSyosetuServer";
+ options.Queues = [WebNovelQueues.Syosetu];
+ options.WorkerCount = 1;
+ // A large novel can take hours
+ options.ShutdownTimeout = TimeSpan.FromHours(3);
+ options.StopTimeout = TimeSpan.FromHours(3);
+});
+
+builder.Services.AddHangfireServer((options) =>
+{
+ options.ServerName = "WebNovelSyosetuMetadataServer";
+ options.Queues = [WebNovelQueues.SyosetuMetadata];
+ options.WorkerCount = 1;
+});
+
builder.Services.AddHangfireServer((options) =>
{
options.ServerName = "CoverageServer";
@@ -668,6 +706,11 @@ bool IsTrustedSsr(HttpContext ctx)
"embed-pending-decks",
job => job.EmbedPending(),
"*/30 * * * *");
+
+ recurringJobs.AddOrUpdate(
+ "webnovel-sync-sweep",
+ job => job.Sweep(),
+ Cron.Daily(5));
}
app.UseResponseCompression();
diff --git a/Jiten.Cli/CliOptions.cs b/Jiten.Cli/CliOptions.cs
index 37e9a156..afc637d7 100644
--- a/Jiten.Cli/CliOptions.cs
+++ b/Jiten.Cli/CliOptions.cs
@@ -317,4 +317,12 @@ public class CliOptions
[Option(longName: "min-shared", Required = false, HelpText = "Override the gated minimum shared-distinctive-word count for --explain probing.")]
public int? MinShared { get; set; }
+
+ [Option(longName: "webnovel-test", Required = false,
+ HelpText = "Dry run a webnovel URL or ncode: metadata, table of contents, first episode and the subdeck split. Writes nothing.")]
+ public string? WebNovelTest { get; set; }
+
+ [Option(longName: "chunk-chars", Required = false,
+ HelpText = "Subdeck character budget for --webnovel-test (default: 150000).")]
+ public int? ChunkChars { get; set; }
}
diff --git a/Jiten.Cli/Commands/WebNovelCommands.cs b/Jiten.Cli/Commands/WebNovelCommands.cs
new file mode 100644
index 00000000..018898a9
--- /dev/null
+++ b/Jiten.Cli/Commands/WebNovelCommands.cs
@@ -0,0 +1,118 @@
+using Jiten.Core.WebNovel;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace Jiten.Cli.Commands;
+
+public class WebNovelCommands
+{
+ ///
+ /// Dry run: fetches a novel's metadata, table of contents and first episode, and reports how it would be
+ /// split into subdecks. Writes nothing. Use it to check the site's markup after a failed sync.
+ ///
+ public async Task Test(string url, int? chunkCharBudget)
+ {
+ if (!WebNovelUrlParser.TryParse(url, out var provider, out var sourceId))
+ {
+ Console.WriteLine($"'{url}' is not a supported webnovel URL or ncode.");
+ return;
+ }
+
+ var services = new ServiceCollection();
+ services.AddHttpClient(SyosetuSource.HttpClientName, client =>
+ {
+ client.DefaultRequestHeaders.Add("User-Agent",
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
+ "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
+ client.Timeout = TimeSpan.FromSeconds(30);
+ })
+ .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler
+ {
+ AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
+ });
+
+ await using var serviceProvider = services.BuildServiceProvider();
+ var httpClientFactory = serviceProvider.GetRequiredService();
+
+ var source = new SyosetuSource(httpClientFactory, NullLogger.Instance, provider);
+
+ Console.WriteLine($"Provider: {provider} Source id: {sourceId}");
+ Console.WriteLine("Fetching metadata...");
+
+ var info = await source.GetInfoAsync(sourceId);
+
+ Console.WriteLine();
+ Console.WriteLine($" Title : {info.Title}");
+ Console.WriteLine($" Author : {info.Author}");
+ Console.WriteLine($" Genre : {info.Genre}");
+ Console.WriteLine($" Keywords : {string.Join(", ", info.Keywords)}");
+ Console.WriteLine($" Episodes : {info.EpisodeCount}");
+ Console.WriteLine($" Characters : {info.TotalCharacters:N0}");
+ Console.WriteLine($" First up : {info.FirstPublishedAt:yyyy-MM-dd}");
+ Console.WriteLine($" Last up : {info.LastUpdatedAt:yyyy-MM-dd HH:mm}");
+ Console.WriteLine($" One-shot : {info.IsOneShot} Completed: {info.IsCompleted} Hiatus: {info.IsOnHiatus} R15: {info.IsR15}");
+ Console.WriteLine($" Synopsis : {Preview(info.Synopsis, 120)}");
+
+ Console.WriteLine();
+ Console.WriteLine("Fetching table of contents...");
+ var toc = await source.GetTocAsync(sourceId);
+ Console.WriteLine($" {toc.Count} episodes listed (API reported {info.EpisodeCount})");
+
+ if (toc.Count == 0)
+ return;
+
+ foreach (var episode in toc.Take(3))
+ {
+ Console.WriteLine($" #{episode.Number,-4} {Preview(episode.Title, 40),-42} " +
+ $"updated {episode.UpdatedAt:yyyy-MM-dd HH:mm} section: {episode.SectionTitle}");
+ }
+
+ if (toc.Count > 3)
+ Console.WriteLine($" ... #{toc[^1].Number} {Preview(toc[^1].Title, 40)}");
+
+ Console.WriteLine();
+ Console.WriteLine($"Fetching episode #{toc[0].Number}...");
+ var text = await source.GetEpisodeTextAsync(sourceId, toc[0]);
+
+ var characters = SubdeckChunker.CountCharacters(text);
+ var rubyCount = text.Count(c => c == '{');
+
+ Console.WriteLine($" Raw length : {text.Length:N0}");
+ Console.WriteLine($" Counted chars : {characters:N0} (furigana + whitespace excluded)");
+ Console.WriteLine($" Ruby annotations: {rubyCount}");
+ Console.WriteLine();
+ Console.WriteLine(" --- first 300 chars ---");
+ Console.WriteLine(" " + Preview(text, 300).Replace("\n", "\n "));
+ Console.WriteLine(" ---");
+
+ // Project the whole work using this episode's length as the per-episode estimate
+ var budget = chunkCharBudget ?? SubdeckChunker.DefaultCharBudget;
+ var averageChars = info.EpisodeCount > 0 && info.TotalCharacters > 0
+ ? (int)(info.TotalCharacters / info.EpisodeCount)
+ : characters;
+
+ var projected = SubdeckChunker.Plan([],
+ toc.Select(e => new ChunkEpisode(e.Number, e.Title, averageChars)).ToList(),
+ budget);
+
+ Console.WriteLine();
+ Console.WriteLine($"Subdeck projection (budget {budget:N0} chars, ~{averageChars:N0} chars/episode):");
+ Console.WriteLine($" {projected.Count} subdecks");
+
+ foreach (var plan in projected.Take(5))
+ Console.WriteLine($" [{plan.ChunkIndex}] {plan.Title} ({plan.EpisodesToAppend.Count} episodes)");
+
+ if (projected.Count > 5)
+ Console.WriteLine($" ... [{projected[^1].ChunkIndex}] {projected[^1].Title} ({projected[^1].EpisodesToAppend.Count} episodes)");
+ }
+
+ private static string Preview(string? value, int maxLength)
+ {
+ if (string.IsNullOrEmpty(value))
+ return "";
+
+ var single = value.Length <= maxLength ? value : value[..maxLength] + "…";
+ return single;
+ }
+}
diff --git a/Jiten.Cli/Program.cs b/Jiten.Cli/Program.cs
index 5ad32629..99e64e9f 100644
--- a/Jiten.Cli/Program.cs
+++ b/Jiten.Cli/Program.cs
@@ -43,6 +43,13 @@ private static async Task ExecuteCommands(CliContext context, CliOptions options
var metadataCommands = new MetadataCommands();
var benchmarkCommands = new BenchmarkCommands(context);
var rubyExtractCommands = new RubyExtractCommands(context);
+ var webNovelCommands = new WebNovelCommands();
+
+ if (!string.IsNullOrEmpty(options.WebNovelTest))
+ {
+ await webNovelCommands.Test(options.WebNovelTest, options.ChunkChars);
+ return;
+ }
// Import commands
if (options.Import)
diff --git a/Jiten.Core/Data/WebNovel/WebNovelChapter.cs b/Jiten.Core/Data/WebNovel/WebNovelChapter.cs
new file mode 100644
index 00000000..b3270397
--- /dev/null
+++ b/Jiten.Core/Data/WebNovel/WebNovelChapter.cs
@@ -0,0 +1,37 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Jiten.Core.Data.WebNovel;
+
+///
+/// Per-episode ledger. Subdeck ranges, revision detection and child identity all derive from this,
+/// never from deck titles — subdeck titles change as the open subdeck grows.
+///
+public class WebNovelChapter
+{
+ ///
+ /// Parent deck (the tracked novel)
+ ///
+ public int DeckId { get; set; }
+
+ ///
+ /// 1-based episode index at the source
+ ///
+ public int EpisodeNumber { get; set; }
+
+ ///
+ /// Subdeck this episode's text lives in
+ ///
+ public int ChildDeckId { get; set; }
+
+ [MaxLength(500)]
+ public string Title { get; set; } = string.Empty;
+
+ ///
+ /// Latest publish/revision (改稿) timestamp from the table of contents
+ ///
+ public DateTimeOffset? SourceUpdatedAt { get; set; }
+
+ public int CharCount { get; set; }
+
+ public WebNovelSource Source { get; set; } = null!;
+}
diff --git a/Jiten.Core/Data/WebNovel/WebNovelProvider.cs b/Jiten.Core/Data/WebNovel/WebNovelProvider.cs
new file mode 100644
index 00000000..cf4cee71
--- /dev/null
+++ b/Jiten.Core/Data/WebNovel/WebNovelProvider.cs
@@ -0,0 +1,9 @@
+namespace Jiten.Core.Data.WebNovel;
+
+public enum WebNovelProvider
+{
+ Syosetu = 1,
+ SyosetuNovel18 = 2,
+ Kakuyomu = 3,
+ Hameln = 4
+}
diff --git a/Jiten.Core/Data/WebNovel/WebNovelSource.cs b/Jiten.Core/Data/WebNovel/WebNovelSource.cs
new file mode 100644
index 00000000..058324e2
--- /dev/null
+++ b/Jiten.Core/Data/WebNovel/WebNovelSource.cs
@@ -0,0 +1,70 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Jiten.Core.Data.WebNovel;
+
+///
+/// One tracked webnovel. Keyed on the parent deck; chapter-range subdecks hang off that deck.
+///
+public class WebNovelSource
+{
+ ///
+ /// Parent deck holding the chapter-range subdecks
+ ///
+ public int DeckId { get; set; }
+
+ public Deck Deck { get; set; } = null!;
+
+ public WebNovelProvider Provider { get; set; }
+
+ ///
+ /// Provider-specific work id (Syosetu ncode, lowercase)
+ ///
+ [MaxLength(64)]
+ public string SourceId { get; set; } = string.Empty;
+
+ ///
+ /// Number of episodes ingested so far
+ ///
+ public int LastEpisodeCount { get; set; }
+
+ ///
+ /// Latest episode timestamp reported by the source at the last sync (Narou general_lastup)
+ ///
+ public DateTimeOffset? LastSourceUpdate { get; set; }
+
+ public DateTimeOffset? LastSyncedAt { get; set; }
+
+ ///
+ /// Staggered next poll; also used to back off after failures
+ ///
+ public DateTimeOffset NextCheckAt { get; set; }
+
+ public bool SyncEnabled { get; set; } = true;
+
+ ///
+ /// Finished at the source (Narou end=0) — polled monthly instead of weekly
+ ///
+ public bool CompletedAtSource { get; set; }
+
+ ///
+ /// Serialisation stopped at the source (Narou isstop)
+ ///
+ public bool OnHiatusAtSource { get; set; }
+
+ public int ConsecutiveFailures { get; set; }
+
+ [MaxLength(1000)]
+ public string? LastError { get; set; }
+
+ ///
+ /// Per-novel override of the subdeck character budget; null uses the global default
+ ///
+ public int? ChunkCharBudget { get; set; }
+
+ ///
+ /// Episodes revised at the source inside already-closed subdecks, awaiting a manual refresh
+ ///
+ public int PendingRevisionCount { get; set; }
+
+ public List Chapters { get; set; } = new();
+}
diff --git a/Jiten.Core/Extractors/EbookExtractor.cs b/Jiten.Core/Extractors/EbookExtractor.cs
index 53bdddcc..79d40a85 100644
--- a/Jiten.Core/Extractors/EbookExtractor.cs
+++ b/Jiten.Core/Extractors/EbookExtractor.cs
@@ -150,33 +150,7 @@ private async Task ExtractTextFromChapter(string htmlContent)
if (body == null)
return "";
- foreach (var rubyElement in body.QuerySelectorAll("ruby").ToList())
- {
- string baseText = "";
- var rbElements = rubyElement.QuerySelectorAll("rb");
- if (rbElements.Any())
- {
- baseText = string.Concat(rbElements.Select(rb => rb.TextContent));
- }
- else
- {
- baseText = string.Concat(
- rubyElement.ChildNodes
- .Where(cn => cn.NodeType == NodeType.Text || (cn is IElement el &&
- !el.TagName.Equals("RT", StringComparison.OrdinalIgnoreCase) &&
- !el.TagName.Equals("RP", StringComparison.OrdinalIgnoreCase)))
- .Select(cn => cn.TextContent)
- );
- }
-
- var rtText = string.Concat(rubyElement.QuerySelectorAll("rt").Select(rt => rt.TextContent)).Trim();
- var trimmedBase = baseText.Trim();
- var replacement = !string.IsNullOrEmpty(rtText) && trimmedBase.Length > 0
- ? $"{{{trimmedBase}'{rtText}}}"
- : trimmedBase;
-
- rubyElement.Parent?.ReplaceChild(document.CreateTextNode(replacement), rubyElement);
- }
+ RubyHtmlHelper.InlineRubyAnnotations(body, document);
var textNodes = body.Descendants()
.Where(n => n.ParentElement != null &&
diff --git a/Jiten.Core/Extractors/RubyHtmlHelper.cs b/Jiten.Core/Extractors/RubyHtmlHelper.cs
new file mode 100644
index 00000000..2a2b150a
--- /dev/null
+++ b/Jiten.Core/Extractors/RubyHtmlHelper.cs
@@ -0,0 +1,36 @@
+using AngleSharp.Dom;
+
+namespace Jiten.Core;
+
+public static class RubyHtmlHelper
+{
+ ///
+ /// Replaces every <ruby> element under with the parser's inline furigana
+ /// format ({base'reading}), so readings survive into DeckRawText and feed FuriganaHintExtractor.
+ /// Handles both <rb>-wrapped bases (epub) and bare text-node bases (Syosetu).
+ ///
+ public static void InlineRubyAnnotations(IElement root, IDocument document)
+ {
+ foreach (var rubyElement in root.QuerySelectorAll("ruby").ToList())
+ {
+ var rbElements = rubyElement.QuerySelectorAll("rb");
+ var baseText = rbElements.Any()
+ ? string.Concat(rbElements.Select(rb => rb.TextContent))
+ : string.Concat(rubyElement.ChildNodes
+ .Where(cn => cn.NodeType == NodeType.Text ||
+ (cn is IElement el &&
+ !el.TagName.Equals("RT", StringComparison.OrdinalIgnoreCase) &&
+ !el.TagName.Equals("RP", StringComparison.OrdinalIgnoreCase)))
+ .Select(cn => cn.TextContent));
+
+ var rtText = string.Concat(rubyElement.QuerySelectorAll("rt").Select(rt => rt.TextContent)).Trim();
+ var trimmedBase = baseText.Trim();
+
+ var replacement = !string.IsNullOrEmpty(rtText) && trimmedBase.Length > 0
+ ? $"{{{trimmedBase}'{rtText}}}"
+ : trimmedBase;
+
+ rubyElement.Parent?.ReplaceChild(document.CreateTextNode(replacement), rubyElement);
+ }
+ }
+}
diff --git a/Jiten.Core/JitenDbContext.cs b/Jiten.Core/JitenDbContext.cs
index d6f22614..b98882ab 100644
--- a/Jiten.Core/JitenDbContext.cs
+++ b/Jiten.Core/JitenDbContext.cs
@@ -1,5 +1,6 @@
using Jiten.Core.Data;
using Jiten.Core.Data.JMDict;
+using Jiten.Core.Data.WebNovel;
using Microsoft.Extensions.Configuration;
namespace Jiten.Core;
@@ -44,6 +45,9 @@ public class JitenDbContext : DbContext
public DbSet DeckRelationships { get; set; }
+ public DbSet WebNovelSources { get; set; }
+ public DbSet WebNovelChapters { get; set; }
+
public DbSet WordSets { get; set; }
public DbSet WordSetMembers { get; set; }
@@ -210,6 +214,40 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
.HasForeignKey(drt => drt.DeckId);
});
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("WebNovelSources", "jiten");
+ entity.HasKey(s => s.DeckId);
+ entity.Property(s => s.DeckId).ValueGeneratedNever();
+
+ entity.HasOne(s => s.Deck)
+ .WithOne()
+ .HasForeignKey(s => s.DeckId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasIndex(s => new { s.Provider, s.SourceId })
+ .IsUnique()
+ .HasDatabaseName("IX_WebNovelSources_Provider_SourceId");
+
+ // The sweeper scans for novels due a check
+ entity.HasIndex(s => new { s.SyncEnabled, s.NextCheckAt })
+ .HasDatabaseName("IX_WebNovelSources_SyncEnabled_NextCheckAt");
+ });
+
+ modelBuilder.Entity(entity =>
+ {
+ entity.ToTable("WebNovelChapters", "jiten");
+ entity.HasKey(c => new { c.DeckId, c.EpisodeNumber });
+
+ entity.HasOne(c => c.Source)
+ .WithMany(s => s.Chapters)
+ .HasForeignKey(c => c.DeckId)
+ .OnDelete(DeleteBehavior.Cascade);
+
+ entity.HasIndex(c => c.ChildDeckId)
+ .HasDatabaseName("IX_WebNovelChapters_ChildDeckId");
+ });
+
modelBuilder.Entity(entity =>
{
entity.ToTable("DeckStats", "jiten");
diff --git a/Jiten.Core/Migrations/20260711232624_WebNovelDecks.Designer.cs b/Jiten.Core/Migrations/20260711232624_WebNovelDecks.Designer.cs
new file mode 100644
index 00000000..f84afb97
--- /dev/null
+++ b/Jiten.Core/Migrations/20260711232624_WebNovelDecks.Designer.cs
@@ -0,0 +1,2268 @@
+//
+using System;
+using System.Collections.Generic;
+using Jiten.Core;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Jiten.Core.Migrations
+{
+ [DbContext(typeof(JitenDbContext))]
+ [Migration("20260711232624_WebNovelDecks")]
+ partial class WebNovelDecks
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasDefaultSchema("jiten")
+ .HasAnnotation("ProductVersion", "9.0.2")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.HasPostgresExtension(modelBuilder, "fuzzystrmatch");
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Jiten.Core.Data.BlacklistedComparisonDeck", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeckId");
+
+ b.HasIndex("UserId", "DeckId")
+ .IsUnique()
+ .HasDatabaseName("IX_BlacklistedComparisonDecks_UserDeck");
+
+ b.ToTable("BlacklistedComparisonDecks", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.Deck", b =>
+ {
+ b.Property("DeckId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DeckId"));
+
+ b.Property("CharacterCount")
+ .HasColumnType("integer");
+
+ b.Property("CoverName")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("CreationDate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("DeckOrder")
+ .HasColumnType("integer");
+
+ b.Property("Description")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)");
+
+ b.Property("DialoguePercentage")
+ .HasColumnType("real");
+
+ b.Property("Difficulty")
+ .HasColumnType("real");
+
+ b.Property("DifficultyOverride")
+ .HasColumnType("real");
+
+ b.Property("EnglishTitle")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ExternalRating")
+ .HasColumnType("smallint");
+
+ b.Property("HideAverageSentenceLength")
+ .HasColumnType("boolean");
+
+ b.Property("HideDialoguePercentage")
+ .HasColumnType("boolean");
+
+ b.Property("LastUpdate")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("MediaType")
+ .HasColumnType("integer");
+
+ b.Property("OriginalFileName")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)");
+
+ b.Property("OriginalTitle")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("ParentDeckId")
+ .HasColumnType("integer");
+
+ b.Property("ReleaseDate")
+ .HasColumnType("date");
+
+ b.Property("RomajiTitle")
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("SentenceCount")
+ .HasColumnType("integer");
+
+ b.Property("SpeechDuration")
+ .HasColumnType("bigint");
+
+ b.Property("SpeechMoraCount")
+ .HasColumnType("bigint");
+
+ b.Property("UniqueKanjiCount")
+ .HasColumnType("integer");
+
+ b.Property("UniqueKanjiUsedOnceCount")
+ .HasColumnType("integer");
+
+ b.Property("UniqueWordCount")
+ .HasColumnType("integer");
+
+ b.Property("UniqueWordUsedOnceCount")
+ .HasColumnType("integer");
+
+ b.Property("WordCount")
+ .HasColumnType("integer");
+
+ b.HasKey("DeckId");
+
+ b.HasIndex("CharacterCount")
+ .HasDatabaseName("IX_CharacterCount");
+
+ b.HasIndex("Difficulty")
+ .HasDatabaseName("IX_Difficulty");
+
+ b.HasIndex("EnglishTitle")
+ .HasDatabaseName("IX_EnglishTitle");
+
+ b.HasIndex("ExternalRating")
+ .HasDatabaseName("IX_ExternalRating");
+
+ b.HasIndex("MediaType")
+ .HasDatabaseName("IX_MediaType");
+
+ b.HasIndex("OriginalTitle")
+ .HasDatabaseName("IX_OriginalTitle");
+
+ b.HasIndex("ReleaseDate")
+ .HasDatabaseName("IX_ReleaseDate");
+
+ b.HasIndex("RomajiTitle")
+ .HasDatabaseName("IX_RomajiTitle");
+
+ b.HasIndex("UniqueKanjiCount")
+ .HasDatabaseName("IX_UniqueKanjiCount");
+
+ b.HasIndex("ParentDeckId", "MediaType")
+ .HasDatabaseName("IX_ParentDeckId_MediaType");
+
+ b.ToTable("Decks", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckDictionaryEntry", b =>
+ {
+ b.Property("DeckDictionaryEntryId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DeckDictionaryEntryId"));
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("EntryType")
+ .HasColumnType("integer");
+
+ b.Property("Surface")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.HasKey("DeckDictionaryEntryId");
+
+ b.HasIndex("DeckId", "Surface")
+ .IsUnique()
+ .HasDatabaseName("IX_DeckDictionaryEntry_DeckId_Surface");
+
+ b.ToTable("DeckDictionaryEntries", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckDifficulty", b =>
+ {
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("DecilesJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("Difficulty")
+ .HasPrecision(4, 2)
+ .HasColumnType("numeric(4,2)");
+
+ b.Property("DistinctVoterCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("EasierVoteCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("HarderVoteCount")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("LastUpdated")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("NEffective")
+ .ValueGeneratedOnAdd()
+ .HasPrecision(6, 2)
+ .HasColumnType("numeric(6,2)")
+ .HasDefaultValue(0m);
+
+ b.Property("Peak")
+ .HasPrecision(4, 2)
+ .HasColumnType("numeric(4,2)");
+
+ b.Property("ProgressionJson")
+ .IsRequired()
+ .HasColumnType("jsonb");
+
+ b.Property("UserAdjustment")
+ .ValueGeneratedOnAdd()
+ .HasPrecision(4, 2)
+ .HasColumnType("numeric(4,2)")
+ .HasDefaultValue(0m);
+
+ b.HasKey("DeckId");
+
+ b.ToTable("DeckDifficulty", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckEmbedding", b =>
+ {
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("Signature")
+ .HasColumnType("bytea");
+
+ b.Property("Vector")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.HasKey("DeckId");
+
+ b.ToTable("DeckEmbeddings", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckEmbeddingSpace", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("integer");
+
+ b.Property("Components")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("Dimension")
+ .HasColumnType("integer");
+
+ b.Property("Idf")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.Property("Mean")
+ .IsRequired()
+ .HasColumnType("bytea");
+
+ b.HasKey("Id");
+
+ b.ToTable("DeckEmbeddingSpaces", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckGenre", b =>
+ {
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("Genre")
+ .HasColumnType("integer");
+
+ b.HasKey("DeckId", "Genre");
+
+ b.HasIndex("DeckId")
+ .HasDatabaseName("IX_DeckGenres_DeckId");
+
+ b.HasIndex("Genre")
+ .HasDatabaseName("IX_DeckGenres_Genre");
+
+ b.ToTable("DeckGenres", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckRawText", b =>
+ {
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("RawText")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("DeckId");
+
+ b.HasIndex("DeckId")
+ .HasDatabaseName("IX_DeckRawText_DeckId");
+
+ b.ToTable("DeckRawTexts", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckRelationship", b =>
+ {
+ b.Property("SourceDeckId")
+ .HasColumnType("integer");
+
+ b.Property("TargetDeckId")
+ .HasColumnType("integer");
+
+ b.Property("RelationshipType")
+ .HasColumnType("integer");
+
+ b.HasKey("SourceDeckId", "TargetDeckId", "RelationshipType");
+
+ b.HasIndex("SourceDeckId")
+ .HasDatabaseName("IX_DeckRelationships_SourceDeckId");
+
+ b.HasIndex("TargetDeckId")
+ .HasDatabaseName("IX_DeckRelationships_TargetDeckId");
+
+ b.ToTable("DeckRelationships", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckStats", b =>
+ {
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("ComputedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("CoverageCurve")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("CoverageSamplesJson")
+ .HasMaxLength(10000)
+ .HasColumnType("character varying(10000)");
+
+ b.HasKey("DeckId");
+
+ b.HasIndex("ComputedAt")
+ .HasDatabaseName("IX_DeckStats_ComputedAt");
+
+ b.ToTable("DeckStats", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckTag", b =>
+ {
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("TagId")
+ .HasColumnType("integer");
+
+ b.Property("Percentage")
+ .HasColumnType("smallint");
+
+ b.HasKey("DeckId", "TagId");
+
+ b.HasIndex("Percentage")
+ .HasDatabaseName("IX_DeckTags_Percentage");
+
+ b.HasIndex("TagId")
+ .HasDatabaseName("IX_DeckTags_TagId");
+
+ b.ToTable("DeckTags", "jiten", t =>
+ {
+ t.HasCheckConstraint("CK_DeckTags_Percentage", "\"Percentage\" >= 0 AND \"Percentage\" <= 100");
+ });
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckTitle", b =>
+ {
+ b.Property("DeckTitleId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DeckTitleId"));
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)");
+
+ b.Property("TitleNoSpaces")
+ .ValueGeneratedOnAddOrUpdate()
+ .HasColumnType("text")
+ .HasComputedColumnSql("REPLACE(\"Title\", ' ', '')", true);
+
+ b.Property("TitleType")
+ .HasColumnType("integer");
+
+ b.HasKey("DeckTitleId");
+
+ b.HasIndex("Title")
+ .HasDatabaseName("IX_DeckTitles_Title");
+
+ b.HasIndex("DeckId", "TitleType")
+ .HasDatabaseName("IX_DeckTitles_DeckId_TitleType");
+
+ b.ToTable("DeckTitles", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DeckWord", b =>
+ {
+ b.Property("DeckWordId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("bigint");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DeckWordId"));
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("Occurrences")
+ .HasColumnType("integer");
+
+ b.Property("ReadingIndex")
+ .HasColumnType("smallint");
+
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.HasKey("DeckWordId");
+
+ b.HasIndex("DeckId")
+ .HasDatabaseName("IX_DeckWords_DeckId_IncWordIdReadingIndexOcc");
+
+ b.HasIndex("WordId", "ReadingIndex")
+ .HasDatabaseName("IX_DeckWords_WordId_ReadingIndex_IncDeckIdOcc");
+
+ b.HasIndex("DeckId", "WordId", "ReadingIndex")
+ .IsUnique()
+ .HasDatabaseName("IX_DeckWords_DeckId_WordId_ReadingIndex");
+
+ b.ToTable("DeckWords", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DifficultyRankGroup", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("MediaTypeGroup")
+ .HasColumnType("integer");
+
+ b.Property("SortIndex")
+ .HasColumnType("integer");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId", "MediaTypeGroup", "SortIndex")
+ .IsUnique()
+ .HasDatabaseName("IX_DifficultyRankGroups_UserGroupSort");
+
+ b.ToTable("DifficultyRankGroups", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DifficultyRankItem", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("GroupId")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeckId");
+
+ b.HasIndex("GroupId")
+ .HasDatabaseName("IX_DifficultyRankItems_GroupId");
+
+ b.HasIndex("UserId", "DeckId")
+ .IsUnique()
+ .HasDatabaseName("IX_DifficultyRankItems_UserDeck");
+
+ b.ToTable("DifficultyRankItems", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DifficultyRating", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("Rating")
+ .HasColumnType("integer");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeckId");
+
+ b.HasIndex("UserId", "DeckId")
+ .IsUnique()
+ .HasDatabaseName("IX_DifficultyRatings_UserDeck");
+
+ b.ToTable("DifficultyRatings", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.DifficultyVote", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("DeckHighId")
+ .HasColumnType("integer");
+
+ b.Property("DeckLowId")
+ .HasColumnType("integer");
+
+ b.Property("IsValid")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.Property("Outcome")
+ .HasColumnType("integer");
+
+ b.Property("Source")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserId")
+ .IsRequired()
+ .HasMaxLength(450)
+ .HasColumnType("character varying(450)");
+
+ b.HasKey("Id");
+
+ b.HasIndex("DeckHighId")
+ .HasDatabaseName("IX_DifficultyVotes_DeckHighId");
+
+ b.HasIndex("DeckLowId")
+ .HasDatabaseName("IX_DifficultyVotes_DeckLowId");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("IX_DifficultyVotes_UserId");
+
+ b.HasIndex("UserId", "DeckLowId", "DeckHighId")
+ .IsUnique()
+ .HasDatabaseName("IX_DifficultyVotes_Unique")
+ .HasFilter("\"IsValid\" = true");
+
+ b.ToTable("DifficultyVotes", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.ExampleSentence", b =>
+ {
+ b.Property("SentenceId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SentenceId"));
+
+ b.Property("DeckId")
+ .HasColumnType("integer");
+
+ b.Property("Difficulty")
+ .HasColumnType("real");
+
+ b.Property("Position")
+ .HasColumnType("integer");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.HasKey("SentenceId");
+
+ b.HasIndex("DeckId")
+ .HasDatabaseName("IX_ExampleSentence_DeckId");
+
+ b.HasIndex("DeckId", "Difficulty")
+ .HasDatabaseName("IX_ExampleSentence_DeckId_Difficulty");
+
+ b.ToTable("ExampleSentences", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.ExampleSentenceWord", b =>
+ {
+ b.Property("ExampleSentenceId")
+ .HasColumnType("integer");
+
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.Property("Position")
+ .HasColumnType("smallint");
+
+ b.Property("Length")
+ .HasColumnType("smallint");
+
+ b.Property("ReadingIndex")
+ .HasColumnType("smallint");
+
+ b.HasKey("ExampleSentenceId", "WordId", "Position");
+
+ b.HasIndex("WordId", "ReadingIndex")
+ .HasDatabaseName("IX_ExampleSentenceWord_WordIdReadingIndex");
+
+ b.ToTable("ExampleSentenceWords", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.ExternalGenreMapping", b =>
+ {
+ b.Property("ExternalGenreMappingId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ExternalGenreMappingId"));
+
+ b.Property("ExternalGenreName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("JitenGenre")
+ .HasColumnType("integer");
+
+ b.Property("Provider")
+ .HasColumnType("integer");
+
+ b.HasKey("ExternalGenreMappingId");
+
+ b.HasIndex("Provider", "ExternalGenreName")
+ .HasDatabaseName("IX_ExternalGenreMapping_Provider_ExternalName");
+
+ b.HasIndex("Provider", "ExternalGenreName", "JitenGenre")
+ .IsUnique()
+ .HasDatabaseName("IX_ExternalGenreMapping_Provider_ExternalName_JitenGenre");
+
+ b.ToTable("ExternalGenreMappings", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.ExternalTagMapping", b =>
+ {
+ b.Property("ExternalTagMappingId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("ExternalTagMappingId"));
+
+ b.Property("ExternalTagName")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)");
+
+ b.Property("Provider")
+ .HasColumnType("integer");
+
+ b.Property("TagId")
+ .HasColumnType("integer");
+
+ b.HasKey("ExternalTagMappingId");
+
+ b.HasIndex("TagId");
+
+ b.HasIndex("Provider", "ExternalTagName")
+ .HasDatabaseName("IX_ExternalTagMapping_Provider_ExternalName");
+
+ b.HasIndex("Provider", "ExternalTagName", "TagId")
+ .IsUnique()
+ .HasDatabaseName("IX_ExternalTagMapping_Provider_ExternalName_TagId");
+
+ b.ToTable("ExternalTagMappings", "jiten");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.JMDict.JmDictCrossReference", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("FromSenseIndex")
+ .HasColumnType("integer");
+
+ b.Property("FromWordId")
+ .HasColumnType("integer");
+
+ b.Property("RawText")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("TargetDict")
+ .HasColumnType("int");
+
+ b.Property("TargetKanji")
+ .HasColumnType("text");
+
+ b.Property("TargetReading")
+ .HasColumnType("text");
+
+ b.Property("TargetSenseIndex")
+ .HasColumnType("smallint");
+
+ b.Property("TargetWordId")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("int");
+
+ b.HasKey("Id");
+
+ b.HasIndex("TargetWordId")
+ .HasDatabaseName("IX_CrossReferences_TargetWordId");
+
+ b.HasIndex("FromWordId", "Type")
+ .HasDatabaseName("IX_CrossReferences_FromWordId_Type");
+
+ b.ToTable("CrossReferences", "jmdict");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.JMDict.JmDictDefinition", b =>
+ {
+ b.Property("DefinitionId")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("DefinitionId"));
+
+ b.PrimitiveCollection>("Dial")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text[]")
+ .HasDefaultValueSql("'{}'");
+
+ b.PrimitiveCollection>("EnglishMeanings")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.PrimitiveCollection>("Field")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text[]")
+ .HasDefaultValueSql("'{}'");
+
+ b.PrimitiveCollection>("GlossTypes")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text[]")
+ .HasDefaultValueSql("'{}'");
+
+ b.Property("IsActiveInLatestSource")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("boolean")
+ .HasDefaultValue(true);
+
+ b.PrimitiveCollection>("Misc")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text[]")
+ .HasDefaultValueSql("'{}'");
+
+ b.PrimitiveCollection>("PartsOfSpeech")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.PrimitiveCollection>("Pos")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text[]")
+ .HasDefaultValueSql("'{}'");
+
+ b.PrimitiveCollection>("RestrictedToReadingIndices")
+ .HasColumnType("smallint[]");
+
+ b.Property("SenseIndex")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0);
+
+ b.PrimitiveCollection>("SenseInfo")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("text[]")
+ .HasDefaultValueSql("'{}'");
+
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.HasKey("DefinitionId");
+
+ b.HasIndex("WordId", "SenseIndex")
+ .HasDatabaseName("IX_Definitions_WordId_SenseIndex");
+
+ b.ToTable("Definitions", "jmdict");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.JMDict.JmDictLookup", b =>
+ {
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.Property("LookupKey")
+ .HasColumnType("text");
+
+ b.HasKey("WordId", "LookupKey");
+
+ b.ToTable("Lookups", "jmdict");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.JMDict.JmDictWord", b =>
+ {
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.Property("EntryInfoJson")
+ .HasColumnType("jsonb");
+
+ b.Property("LanguageSourcesJson")
+ .HasColumnType("jsonb");
+
+ b.Property("Origin")
+ .HasColumnType("int");
+
+ b.PrimitiveCollection>("PartsOfSpeech")
+ .IsRequired()
+ .HasColumnType("text[]");
+
+ b.PrimitiveCollection>("PitchAccents")
+ .HasColumnType("int[]");
+
+ b.PrimitiveCollection>("Priorities")
+ .HasColumnType("text[]");
+
+ b.HasKey("WordId");
+
+ b.ToTable("Words", "jmdict");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.JMDict.JmDictWordComposition", b =>
+ {
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.Property("ReadingIndex")
+ .HasColumnType("smallint");
+
+ b.Property("Position")
+ .HasColumnType("smallint");
+
+ b.Property("ComponentReadingIndex")
+ .HasColumnType("smallint");
+
+ b.Property("ComponentSurface")
+ .IsRequired()
+ .HasColumnType("text");
+
+ b.Property("ComponentWordId")
+ .HasColumnType("integer");
+
+ b.HasKey("WordId", "ReadingIndex", "Position");
+
+ b.HasIndex("ComponentWordId")
+ .HasDatabaseName("IX_WordComposition_ComponentWordId");
+
+ b.HasIndex("WordId", "ReadingIndex")
+ .HasDatabaseName("IX_WordComposition_WordId_ReadingIndex");
+
+ b.ToTable("WordCompositions", "jmdict");
+ });
+
+ modelBuilder.Entity("Jiten.Core.Data.JMDict.JmDictWordForm", b =>
+ {
+ b.Property("WordId")
+ .HasColumnType("integer");
+
+ b.Property("ReadingIndex")
+ .HasColumnType("smallint");
+
+ b.Property("FormType")
+ .HasColumnType("smallint");
+
+ b.PrimitiveCollection>("InfoTags")
+ .HasColumnType("text[]");
+
+ b.Property("IsActiveInLatestSource")
+ .HasColumnType("boolean");
+
+ b.Property("IsNoKanji")
+ .HasColumnType("boolean");
+
+ b.Property("IsObsolete")
+ .HasColumnType("boolean");
+
+ b.Property("IsSearchOnly")
+ .HasColumnType("boolean");
+
+ b.PrimitiveCollection>("Priorities")
+ .HasColumnType("text[]");
+
+ b.Property