diff --git a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs index dad1df3..d5d2790 100644 --- a/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs +++ b/FictionArchive.Service.NovelService.Tests/NovelUpdateServiceTests.cs @@ -42,6 +42,13 @@ public class NovelUpdateServiceTests Images = new List() }; + var volume = new Volume + { + Order = 1, + Name = LocalizationKey.CreateFromText("Main Story", Language.En), + Chapters = new List { chapter } + }; + var novel = new Novel { Url = "http://demo/novel", @@ -52,14 +59,14 @@ public class NovelUpdateServiceTests Source = source, Name = LocalizationKey.CreateFromText("Demo Novel", Language.En), Description = LocalizationKey.CreateFromText("Description", Language.En), - Chapters = new List { chapter }, + Volumes = new List { volume }, Tags = new List() }; dbContext.Novels.Add(novel); dbContext.SaveChanges(); - return new NovelCreateResult(novel, chapter); + return new NovelCreateResult(novel, volume, chapter); } private static NovelUpdateService CreateService( @@ -81,7 +88,7 @@ public class NovelUpdateServiceTests { using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; - var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + var (novel, volume, chapter) = CreateNovelWithSingleChapter(dbContext, source); var rawHtml = "

Hello

\"first\"\"second\""; var image1 = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 1, 2, 3 } }; @@ -103,7 +110,7 @@ public class NovelUpdateServiceTests var pendingImageUrl = "https://pending/placeholder.jpg"; var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl); - var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order); + var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); updatedChapter.Images.Should().HaveCount(2); updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url }); @@ -131,7 +138,7 @@ public class NovelUpdateServiceTests { using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; - var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + var (novel, volume, chapter) = CreateNovelWithSingleChapter(dbContext, source); var rawHtml = "

Hi

"; var image = new ImageData { Url = "http://img/x1.jpg", Data = new byte[] { 7, 8, 9 } }; @@ -150,7 +157,7 @@ public class NovelUpdateServiceTests var service = CreateService(dbContext, adapter, eventBus); - var updatedChapter = await service.PullChapterContents(novel.Id, chapter.Order); + var updatedChapter = await service.PullChapterContents(novel.Id, volume.Id, chapter.Order); var storedHtml = updatedChapter.Body.Texts.Single().Text; var doc = new HtmlDocument(); @@ -161,7 +168,7 @@ public class NovelUpdateServiceTests imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg"); } - private record NovelCreateResult(Novel Novel, Chapter Chapter); + private record NovelCreateResult(Novel Novel, Volume Volume, Chapter Chapter); #region UpdateImage Tests @@ -199,7 +206,7 @@ public class NovelUpdateServiceTests // Arrange using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; - var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + var (novel, _, chapter) = CreateNovelWithSingleChapter(dbContext, source); var image = new Image { @@ -252,7 +259,7 @@ public class NovelUpdateServiceTests // Arrange using var dbContext = CreateDbContext(); var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; - var (novel, chapter) = CreateNovelWithSingleChapter(dbContext, source); + var (_, _, chapter) = CreateNovelWithSingleChapter(dbContext, source); var image1 = new Image { OriginalPath = "http://original/img1.jpg", Chapter = chapter }; var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter }; diff --git a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs index cea85b8..47c3e40 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Mutation.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Mutation.cs @@ -21,11 +21,13 @@ public class Mutation } [Authorize] - public async Task FetchChapterContents(uint novelId, - uint chapterNumber, + public async Task FetchChapterContents( + uint novelId, + uint volumeId, + uint chapterOrder, NovelUpdateService service) { - return await service.QueueChapterPull(novelId, chapterNumber); + return await service.QueueChapterPull(novelId, volumeId, chapterOrder); } [Error] diff --git a/FictionArchive.Service.NovelService/GraphQL/Query.cs b/FictionArchive.Service.NovelService/GraphQL/Query.cs index 2f0211b..2c572bb 100644 --- a/FictionArchive.Service.NovelService/GraphQL/Query.cs +++ b/FictionArchive.Service.NovelService/GraphQL/Query.cs @@ -77,32 +77,45 @@ public class Query } : null, - Chapters = novel.Chapters.Select(chapter => new ChapterDto + Volumes = novel.Volumes.OrderBy(v => v.Order).Select(volume => new VolumeDto { - Id = chapter.Id, - CreatedTime = chapter.CreatedTime, - LastUpdatedTime = chapter.LastUpdatedTime, - Revision = chapter.Revision, - Order = chapter.Order, - Url = chapter.Url, - Name = chapter.Name.Texts + Id = volume.Id, + CreatedTime = volume.CreatedTime, + LastUpdatedTime = volume.LastUpdatedTime, + Order = volume.Order, + Name = volume.Name.Texts .Where(t => t.Language == preferredLanguage) .Select(t => t.Text) .FirstOrDefault() - ?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? volume.Name.Texts.Select(t => t.Text).FirstOrDefault() ?? "", - Body = chapter.Body.Texts - .Where(t => t.Language == preferredLanguage) - .Select(t => t.Text) - .FirstOrDefault() - ?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault() - ?? "", - Images = chapter.Images.Select(image => new ImageDto + Chapters = volume.Chapters.OrderBy(c => c.Order).Select(chapter => new ChapterDto { - Id = image.Id, - CreatedTime = image.CreatedTime, - LastUpdatedTime = image.LastUpdatedTime, - NewPath = image.NewPath + Id = chapter.Id, + CreatedTime = chapter.CreatedTime, + LastUpdatedTime = chapter.LastUpdatedTime, + Revision = chapter.Revision, + Order = chapter.Order, + Url = chapter.Url, + Name = chapter.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + Body = chapter.Body.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + Images = chapter.Images.Select(image => new ImageDto + { + Id = image.Id, + CreatedTime = image.CreatedTime, + LastUpdatedTime = image.LastUpdatedTime, + NewPath = image.NewPath + }).ToList() }).ToList() }).ToList(), @@ -140,11 +153,12 @@ public class Query public IQueryable GetChapter( NovelServiceDbContext dbContext, uint novelId, + uint volumeId, uint chapterOrder, Language preferredLanguage = Language.En) { return dbContext.Chapters - .Where(c => c.Novel.Id == novelId && c.Order == chapterOrder) + .Where(c => c.Volume.Novel.Id == novelId && c.Volume.Id == volumeId && c.Order == chapterOrder) .Select(chapter => new ChapterReaderDto { Id = chapter.Id, @@ -176,24 +190,74 @@ public class Query NewPath = image.NewPath }).ToList(), - NovelId = chapter.Novel.Id, - NovelName = chapter.Novel.Name.Texts + NovelId = chapter.Volume.Novel.Id, + NovelName = chapter.Volume.Novel.Name.Texts .Where(t => t.Language == preferredLanguage) .Select(t => t.Text) .FirstOrDefault() - ?? chapter.Novel.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? chapter.Volume.Novel.Name.Texts.Select(t => t.Text).FirstOrDefault() ?? "", - TotalChapters = chapter.Novel.Chapters.Count, - PrevChapterOrder = chapter.Novel.Chapters + + // Volume context + VolumeId = chapter.Volume.Id, + VolumeName = chapter.Volume.Name.Texts + .Where(t => t.Language == preferredLanguage) + .Select(t => t.Text) + .FirstOrDefault() + ?? chapter.Volume.Name.Texts.Select(t => t.Text).FirstOrDefault() + ?? "", + VolumeOrder = chapter.Volume.Order, + TotalChaptersInVolume = chapter.Volume.Chapters.Count, + + // Previous chapter: first try same volume, then last chapter of previous volume + PrevChapterVolumeId = chapter.Volume.Chapters + .Where(c => c.Order < chapterOrder) + .OrderByDescending(c => c.Order) + .Select(c => (uint?)chapter.Volume.Id) + .FirstOrDefault() + ?? chapter.Volume.Novel.Volumes + .Where(v => v.Order < chapter.Volume.Order) + .OrderByDescending(v => v.Order) + .SelectMany(v => v.Chapters.OrderByDescending(c => c.Order).Take(1)) + .Select(c => (uint?)c.Volume.Id) + .FirstOrDefault(), + + PrevChapterOrder = chapter.Volume.Chapters .Where(c => c.Order < chapterOrder) .OrderByDescending(c => c.Order) .Select(c => (uint?)c.Order) - .FirstOrDefault(), - NextChapterOrder = chapter.Novel.Chapters + .FirstOrDefault() + ?? chapter.Volume.Novel.Volumes + .Where(v => v.Order < chapter.Volume.Order) + .OrderByDescending(v => v.Order) + .SelectMany(v => v.Chapters.OrderByDescending(c => c.Order).Take(1)) + .Select(c => (uint?)c.Order) + .FirstOrDefault(), + + // Next chapter: first try same volume, then first chapter of next volume + NextChapterVolumeId = chapter.Volume.Chapters + .Where(c => c.Order > chapterOrder) + .OrderBy(c => c.Order) + .Select(c => (uint?)chapter.Volume.Id) + .FirstOrDefault() + ?? chapter.Volume.Novel.Volumes + .Where(v => v.Order > chapter.Volume.Order) + .OrderBy(v => v.Order) + .SelectMany(v => v.Chapters.OrderBy(c => c.Order).Take(1)) + .Select(c => (uint?)c.Volume.Id) + .FirstOrDefault(), + + NextChapterOrder = chapter.Volume.Chapters .Where(c => c.Order > chapterOrder) .OrderBy(c => c.Order) .Select(c => (uint?)c.Order) .FirstOrDefault() + ?? chapter.Volume.Novel.Volumes + .Where(v => v.Order > chapter.Volume.Order) + .OrderBy(v => v.Order) + .SelectMany(v => v.Chapters.OrderBy(c => c.Order).Take(1)) + .Select(c => (uint?)c.Order) + .FirstOrDefault() }); } } diff --git a/FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.Designer.cs b/FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.Designer.cs new file mode 100644 index 0000000..3fe1132 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.Designer.cs @@ -0,0 +1,605 @@ +// +using System; +using FictionArchive.Service.NovelService.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + [DbContext(typeof(NovelServiceDbContext))] + [Migration("20251229203027_AddVolumes")] + partial class AddVolumes + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.11") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChapterId") + .HasColumnType("bigint"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NewPath") + .HasColumnType("text"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.ToTable("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("LocalizationKeys"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("EngineId") + .HasColumnType("bigint"); + + b.Property("KeyRequestedForTranslationId") + .HasColumnType("uuid"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("TranslateTo") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("EngineId"); + + b.HasIndex("KeyRequestedForTranslationId"); + + b.ToTable("LocalizationRequests"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Language") + .HasColumnType("integer"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LocalizationKeyId") + .HasColumnType("uuid"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text"); + + b.Property("TranslationEngineId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("LocalizationKeyId"); + + b.HasIndex("TranslationEngineId"); + + b.ToTable("LocalizationText"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("BodyId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("Order") + .HasColumnType("bigint"); + + b.Property("Revision") + .HasColumnType("bigint"); + + b.Property("Url") + .HasColumnType("text"); + + b.Property("VolumeId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("BodyId"); + + b.HasIndex("NameId"); + + b.HasIndex("VolumeId", "Order") + .IsUnique(); + + b.ToTable("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CoverImageId") + .HasColumnType("uuid"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DescriptionId") + .HasColumnType("uuid"); + + b.Property("ExternalId") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("RawLanguage") + .HasColumnType("integer"); + + b.Property("RawStatus") + .HasColumnType("integer"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("StatusOverride") + .HasColumnType("integer"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("CoverImageId"); + + b.HasIndex("DescriptionId"); + + b.HasIndex("NameId"); + + b.HasIndex("SourceId"); + + b.HasIndex("ExternalId", "SourceId") + .IsUnique(); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayNameId") + .HasColumnType("uuid"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceId") + .HasColumnType("bigint"); + + b.Property("TagType") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("DisplayNameId"); + + b.HasIndex("SourceId"); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExternalUrl") + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.ToTable("Person"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Sources"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("TranslationEngines"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.HasIndex("NovelId", "Order") + .IsUnique(); + + b.ToTable("Volume"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.Property("NovelsId") + .HasColumnType("bigint"); + + b.Property("TagsId") + .HasColumnType("bigint"); + + b.HasKey("NovelsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("NovelNovelTag"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Images.Image", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Chapter", "Chapter") + .WithMany("Images") + .HasForeignKey("ChapterId"); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "Engine") + .WithMany() + .HasForeignKey("EngineId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "KeyRequestedForTranslation") + .WithMany() + .HasForeignKey("KeyRequestedForTranslationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Engine"); + + b.Navigation("KeyRequestedForTranslation"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", null) + .WithMany("Texts") + .HasForeignKey("LocalizationKeyId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", "TranslationEngine") + .WithMany() + .HasForeignKey("TranslationEngineId"); + + b.Navigation("TranslationEngine"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Body") + .WithMany() + .HasForeignKey("BodyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Volume", "Volume") + .WithMany("Chapters") + .HasForeignKey("VolumeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Body"); + + b.Navigation("Name"); + + b.Navigation("Volume"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Person", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Images.Image", "CoverImage") + .WithMany() + .HasForeignKey("CoverImageId"); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Description") + .WithMany() + .HasForeignKey("DescriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + + b.Navigation("CoverImage"); + + b.Navigation("Description"); + + b.Navigation("Name"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.NovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "DisplayName") + .WithMany() + .HasForeignKey("DisplayNameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source") + .WithMany() + .HasForeignKey("SourceId"); + + b.Navigation("DisplayName"); + + b.Navigation("Source"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Person", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") + .WithMany("Volumes") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("NovelNovelTag", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) + .WithMany() + .HasForeignKey("NovelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.NovelTag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b => + { + b.Navigation("Texts"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Chapter", b => + { + b.Navigation("Images"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.Navigation("Chapters"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.cs b/FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.cs new file mode 100644 index 0000000..6f72a54 --- /dev/null +++ b/FictionArchive.Service.NovelService/Migrations/20251229203027_AddVolumes.cs @@ -0,0 +1,195 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace FictionArchive.Service.NovelService.Migrations +{ + /// + public partial class AddVolumes : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + // 1. Create the Volume table + migrationBuilder.CreateTable( + name: "Volume", + columns: table => new + { + Id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Order = table.Column(type: "integer", nullable: false), + NameId = table.Column(type: "uuid", nullable: false), + NovelId = table.Column(type: "bigint", nullable: false), + CreatedTime = table.Column(type: "timestamp with time zone", nullable: false), + LastUpdatedTime = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Volume", x => x.Id); + table.ForeignKey( + name: "FK_Volume_LocalizationKeys_NameId", + column: x => x.NameId, + principalTable: "LocalizationKeys", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Volume_Novels_NovelId", + column: x => x.NovelId, + principalTable: "Novels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_Volume_NameId", + table: "Volume", + column: "NameId"); + + migrationBuilder.CreateIndex( + name: "IX_Volume_NovelId_Order", + table: "Volume", + columns: new[] { "NovelId", "Order" }, + unique: true); + + // 2. Add nullable VolumeId column to Chapter (keep NovelId for now) + migrationBuilder.AddColumn( + name: "VolumeId", + table: "Chapter", + type: "bigint", + nullable: true); + + // 3. Data migration: Create volumes and link chapters for each novel + migrationBuilder.Sql(@" +DO $$ +DECLARE + novel_rec RECORD; + loc_key_id uuid; + volume_id bigint; +BEGIN + FOR novel_rec IN SELECT ""Id"", ""RawLanguage"" FROM ""Novels"" LOOP + -- Create LocalizationKey for volume name + loc_key_id := gen_random_uuid(); + INSERT INTO ""LocalizationKeys"" (""Id"", ""CreatedTime"", ""LastUpdatedTime"") + VALUES (loc_key_id, NOW(), NOW()); + + -- Create LocalizationText for 'Main Story' in novel's raw language + INSERT INTO ""LocalizationText"" (""Id"", ""LocalizationKeyId"", ""Language"", ""Text"", ""CreatedTime"", ""LastUpdatedTime"") + VALUES (gen_random_uuid(), loc_key_id, novel_rec.""RawLanguage"", 'Main Story', NOW(), NOW()); + + -- Create Volume for this novel + INSERT INTO ""Volume"" (""Order"", ""NameId"", ""NovelId"", ""CreatedTime"", ""LastUpdatedTime"") + VALUES (1, loc_key_id, novel_rec.""Id"", NOW(), NOW()) + RETURNING ""Id"" INTO volume_id; + + -- Link all chapters of this novel to the new volume + UPDATE ""Chapter"" SET ""VolumeId"" = volume_id WHERE ""NovelId"" = novel_rec.""Id""; + END LOOP; +END $$; + "); + + // 4. Drop old FK and index for NovelId + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Novels_NovelId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_NovelId", + table: "Chapter"); + + // 5. Drop NovelId column from Chapter + migrationBuilder.DropColumn( + name: "NovelId", + table: "Chapter"); + + // 6. Make VolumeId non-nullable + migrationBuilder.AlterColumn( + name: "VolumeId", + table: "Chapter", + type: "bigint", + nullable: false, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + // 7. Add unique index and FK for VolumeId + migrationBuilder.CreateIndex( + name: "IX_Chapter_VolumeId_Order", + table: "Chapter", + columns: new[] { "VolumeId", "Order" }, + unique: true); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Volume_VolumeId", + table: "Chapter", + column: "VolumeId", + principalTable: "Volume", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + // Add back NovelId column + migrationBuilder.AddColumn( + name: "NovelId", + table: "Chapter", + type: "bigint", + nullable: true); + + // Migrate data back: set NovelId from Volume + migrationBuilder.Sql(@" +UPDATE ""Chapter"" c +SET ""NovelId"" = v.""NovelId"" +FROM ""Volume"" v +WHERE c.""VolumeId"" = v.""Id""; + "); + + // Make NovelId non-nullable + migrationBuilder.AlterColumn( + name: "NovelId", + table: "Chapter", + type: "bigint", + nullable: false, + oldClrType: typeof(long), + oldType: "bigint", + oldNullable: true); + + // Drop VolumeId FK and index + migrationBuilder.DropForeignKey( + name: "FK_Chapter_Volume_VolumeId", + table: "Chapter"); + + migrationBuilder.DropIndex( + name: "IX_Chapter_VolumeId_Order", + table: "Chapter"); + + // Drop VolumeId column + migrationBuilder.DropColumn( + name: "VolumeId", + table: "Chapter"); + + // Recreate NovelId index and FK + migrationBuilder.CreateIndex( + name: "IX_Chapter_NovelId", + table: "Chapter", + column: "NovelId"); + + migrationBuilder.AddForeignKey( + name: "FK_Chapter_Novels_NovelId", + table: "Chapter", + column: "NovelId", + principalTable: "Novels", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + + // Note: Volume LocalizationKeys are not cleaned up in Down migration + // as they may have been modified. Manual cleanup may be needed. + migrationBuilder.DropTable( + name: "Volume"); + } + } +} diff --git a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs index 5f7b749..f4c8aea 100644 --- a/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs +++ b/FictionArchive.Service.NovelService/Migrations/NovelServiceDbContextModelSnapshot.cs @@ -153,9 +153,6 @@ namespace FictionArchive.Service.NovelService.Migrations b.Property("NameId") .HasColumnType("uuid"); - b.Property("NovelId") - .HasColumnType("bigint"); - b.Property("Order") .HasColumnType("bigint"); @@ -165,13 +162,17 @@ namespace FictionArchive.Service.NovelService.Migrations b.Property("Url") .HasColumnType("text"); + b.Property("VolumeId") + .HasColumnType("bigint"); + b.HasKey("Id"); b.HasIndex("BodyId"); b.HasIndex("NameId"); - b.HasIndex("NovelId"); + b.HasIndex("VolumeId", "Order") + .IsUnique(); b.ToTable("Chapter"); }); @@ -357,6 +358,39 @@ namespace FictionArchive.Service.NovelService.Migrations b.ToTable("TranslationEngines"); }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastUpdatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("NameId") + .HasColumnType("uuid"); + + b.Property("NovelId") + .HasColumnType("bigint"); + + b.Property("Order") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NameId"); + + b.HasIndex("NovelId", "Order") + .IsUnique(); + + b.ToTable("Volume"); + }); + modelBuilder.Entity("NovelNovelTag", b => { b.Property("NovelsId") @@ -427,9 +461,9 @@ namespace FictionArchive.Service.NovelService.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Volume", "Volume") .WithMany("Chapters") - .HasForeignKey("NovelId") + .HasForeignKey("VolumeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -437,7 +471,7 @@ namespace FictionArchive.Service.NovelService.Migrations b.Navigation("Name"); - b.Navigation("Novel"); + b.Navigation("Volume"); }); modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => @@ -509,6 +543,25 @@ namespace FictionArchive.Service.NovelService.Migrations b.Navigation("Name"); }); + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => + { + b.HasOne("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", "Name") + .WithMany() + .HasForeignKey("NameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") + .WithMany("Volumes") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Name"); + + b.Navigation("Novel"); + }); + modelBuilder.Entity("NovelNovelTag", b => { b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) @@ -535,6 +588,11 @@ namespace FictionArchive.Service.NovelService.Migrations }); modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => + { + b.Navigation("Volumes"); + }); + + modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b => { b.Navigation("Chapters"); }); diff --git a/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs index ceb9b1b..e976bd9 100644 --- a/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs +++ b/FictionArchive.Service.NovelService/Models/DTOs/ChapterReaderDto.cs @@ -12,7 +12,16 @@ public class ChapterReaderDto : BaseDto // Navigation context public uint NovelId { get; init; } public required string NovelName { get; init; } - public int TotalChapters { get; init; } + + // Volume context + public uint VolumeId { get; init; } + public required string VolumeName { get; init; } + public int VolumeOrder { get; init; } + public int TotalChaptersInVolume { get; init; } + + // Cross-volume navigation (VolumeId + Order identify a chapter) + public uint? PrevChapterVolumeId { get; init; } public uint? PrevChapterOrder { get; init; } + public uint? NextChapterVolumeId { get; init; } public uint? NextChapterOrder { get; init; } } diff --git a/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs index 637148a..ffd6ac5 100644 --- a/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs +++ b/FictionArchive.Service.NovelService/Models/DTOs/NovelDto.cs @@ -14,7 +14,7 @@ public class NovelDto : BaseDto public required string ExternalId { get; init; } public required string Name { get; init; } public required string Description { get; init; } - public required List Chapters { get; init; } + public required List Volumes { get; init; } public required List Tags { get; init; } public ImageDto? CoverImage { get; init; } } diff --git a/FictionArchive.Service.NovelService/Models/DTOs/VolumeDto.cs b/FictionArchive.Service.NovelService/Models/DTOs/VolumeDto.cs new file mode 100644 index 0000000..5cf694f --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/DTOs/VolumeDto.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Service.NovelService.Models.DTOs; + +public class VolumeDto : BaseDto +{ + public int Order { get; init; } + public required string Name { get; init; } + public required List Chapters { get; init; } +} diff --git a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs b/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs index 5f64294..cd70549 100644 --- a/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs +++ b/FictionArchive.Service.NovelService/Models/IntegrationEvents/ChapterPullRequestedEvent.cs @@ -5,5 +5,6 @@ namespace FictionArchive.Service.NovelService.Models.IntegrationEvents; public class ChapterPullRequestedEvent : IIntegrationEvent { public uint NovelId { get; set; } - public uint ChapterNumber { get; set; } + public uint VolumeId { get; set; } + public uint ChapterOrder { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs b/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs index 0f443e5..b4e7eb1 100644 --- a/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs +++ b/FictionArchive.Service.NovelService/Models/Novels/Chapter.cs @@ -19,8 +19,8 @@ public class Chapter : BaseEntity public List Images { get; set; } #region Navigation Properties - - public Novel Novel { get; set; } - + + public Volume Volume { get; set; } + #endregion } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/Novels/Novel.cs b/FictionArchive.Service.NovelService/Models/Novels/Novel.cs index 582cd96..dd0d90e 100644 --- a/FictionArchive.Service.NovelService/Models/Novels/Novel.cs +++ b/FictionArchive.Service.NovelService/Models/Novels/Novel.cs @@ -21,7 +21,7 @@ public class Novel : BaseEntity public LocalizationKey Name { get; set; } public LocalizationKey Description { get; set; } - public List Chapters { get; set; } + public List Volumes { get; set; } public List Tags { get; set; } public Image? CoverImage { get; set; } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Models/Novels/Volume.cs b/FictionArchive.Service.NovelService/Models/Novels/Volume.cs new file mode 100644 index 0000000..9469360 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/Novels/Volume.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations.Schema; +using FictionArchive.Service.NovelService.Models.Localization; +using FictionArchive.Service.Shared.Models; + +namespace FictionArchive.Service.NovelService.Models.Novels; + +[Table("Volume")] +public class Volume : BaseEntity +{ + /// + /// Signed int to allow special ordering like -1 for "Author Notes" at top. + /// + public int Order { get; set; } + + public LocalizationKey Name { get; set; } + + public List Chapters { get; set; } + + #region Navigation Properties + + public Novel Novel { get; set; } + + #endregion +} diff --git a/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs b/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs index 716dcbe..c226bb0 100644 --- a/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs +++ b/FictionArchive.Service.NovelService/Models/SourceAdapters/NovelMetadata.cs @@ -16,7 +16,7 @@ public class NovelMetadata public Language RawLanguage { get; set; } public NovelStatus RawStatus { get; set; } - public List Chapters { get; set; } + public List Volumes { get; set; } public List SourceTags { get; set; } public List SystemTags { get; set; } public SourceDescriptor SourceDescriptor { get; set; } diff --git a/FictionArchive.Service.NovelService/Models/SourceAdapters/VolumeMetadata.cs b/FictionArchive.Service.NovelService/Models/SourceAdapters/VolumeMetadata.cs new file mode 100644 index 0000000..d5d2f58 --- /dev/null +++ b/FictionArchive.Service.NovelService/Models/SourceAdapters/VolumeMetadata.cs @@ -0,0 +1,8 @@ +namespace FictionArchive.Service.NovelService.Models.SourceAdapters; + +public class VolumeMetadata +{ + public int Order { get; set; } + public string Name { get; set; } + public List Chapters { get; set; } +} diff --git a/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs b/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs index d05b52e..6acd8ee 100644 --- a/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs +++ b/FictionArchive.Service.NovelService/Services/EventHandlers/ChapterPullRequestedEventHandler.cs @@ -14,6 +14,6 @@ public class ChapterPullRequestedEventHandler : IIntegrationEventHandler Novels { get; set; } + public DbSet Volumes { get; set; } public DbSet Chapters { get; set; } public DbSet Sources { get; set; } public DbSet TranslationEngines { get; set; } @@ -25,5 +26,15 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger() .HasIndex("ExternalId", "SourceId") .IsUnique(); + + // Volume.Order is unique per Novel + modelBuilder.Entity() + .HasIndex("NovelId", "Order") + .IsUnique(); + + // Chapter.Order is unique per Volume + modelBuilder.Entity() + .HasIndex("VolumeId", "Order") + .IsUnique(); } } \ No newline at end of file diff --git a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs index ae5c917..e9e33be 100644 --- a/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs +++ b/FictionArchive.Service.NovelService/Services/NovelUpdateService.cs @@ -190,6 +190,48 @@ public class NovelUpdateService return existingChapters.Concat(newChapters).ToList(); } + private static List SynchronizeVolumes( + List metadataVolumes, + Language rawLanguage, + List? existingVolumes) + { + existingVolumes ??= new List(); + var result = new List(); + + foreach (var metaVolume in metadataVolumes) + { + // Match volumes by Order (unique per novel) + var existingVolume = existingVolumes.FirstOrDefault(v => v.Order == metaVolume.Order); + + if (existingVolume != null) + { + // Volume exists - sync its chapters + existingVolume.Chapters = SynchronizeChapters( + metaVolume.Chapters, + rawLanguage, + existingVolume.Chapters); + result.Add(existingVolume); + } + else + { + // New volume - create it with synced chapters + var newVolume = new Volume + { + Order = metaVolume.Order, + Name = LocalizationKey.CreateFromText(metaVolume.Name, rawLanguage), + Chapters = SynchronizeChapters(metaVolume.Chapters, rawLanguage, null) + }; + result.Add(newVolume); + } + } + + // Keep existing volumes not in metadata (user-created volumes) + var metaOrders = metadataVolumes.Select(v => v.Order).ToHashSet(); + result.AddRange(existingVolumes.Where(v => !metaOrders.Contains(v.Order))); + + return result; + } + private static (Image? image, bool shouldPublishEvent) HandleCoverImage( ImageData? newCoverData, Image? existingCoverImage) @@ -232,7 +274,7 @@ public class NovelUpdateService metadata.SystemTags, metadata.RawLanguage); - var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null); + var volumes = SynchronizeVolumes(metadata.Volumes, metadata.RawLanguage, null); var novel = new Novel { @@ -243,7 +285,7 @@ public class NovelUpdateService CoverImage = metadata.CoverImage != null ? new Image { OriginalPath = metadata.CoverImage.Url } : null, - Chapters = chapters, + Volumes = volumes, Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage), Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage), RawStatus = metadata.RawStatus, @@ -289,7 +331,9 @@ public class NovelUpdateService .Include(n => n.Description) .ThenInclude(lk => lk.Texts) .Include(n => n.Tags) - .Include(n => n.Chapters).ThenInclude(chapter => chapter.Body) + .Include(n => n.Volumes) + .ThenInclude(volume => volume.Chapters) + .ThenInclude(chapter => chapter.Body) .ThenInclude(localizationKey => localizationKey.Texts) .Include(n => n.CoverImage) .FirstOrDefaultAsync(n => @@ -326,11 +370,11 @@ public class NovelUpdateService metadata.SystemTags, metadata.RawLanguage); - // Synchronize chapters (add only) - novel.Chapters = SynchronizeChapters( - metadata.Chapters, + // Synchronize volumes (and their chapters) + novel.Volumes = SynchronizeVolumes( + metadata.Volumes, metadata.RawLanguage, - existingNovel.Chapters); + existingNovel.Volumes); // Handle cover image (novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage( @@ -352,31 +396,40 @@ public class NovelUpdateService } // Publish chapter pull events for chapters without body content - var chaptersNeedingPull = novel.Chapters - .Where(c => c.Body?.Texts == null || !c.Body.Texts.Any()) - .ToList(); - - foreach (var chapter in chaptersNeedingPull) + foreach (var volume in novel.Volumes) { - await _eventBus.Publish(new ChapterPullRequestedEvent + var chaptersNeedingPull = volume.Chapters + .Where(c => c.Body?.Texts == null || !c.Body.Texts.Any()) + .ToList(); + + foreach (var chapter in chaptersNeedingPull) { - NovelId = novel.Id, - ChapterNumber = chapter.Order - }); + await _eventBus.Publish(new ChapterPullRequestedEvent + { + NovelId = novel.Id, + VolumeId = volume.Id, + ChapterOrder = chapter.Order + }); + } } return novel; } - public async Task PullChapterContents(uint novelId, uint chapterNumber) + public async Task PullChapterContents(uint novelId, uint volumeId, uint chapterOrder) { var novel = await _dbContext.Novels.Where(novel => novel.Id == novelId) - .Include(novel => novel.Chapters) + .Include(novel => novel.Volumes) + .ThenInclude(volume => volume.Chapters) .ThenInclude(chapter => chapter.Body) .ThenInclude(body => body.Texts) - .Include(novel => novel.Source).Include(novel => novel.Chapters).ThenInclude(chapter => chapter.Images) + .Include(novel => novel.Source) + .Include(novel => novel.Volumes) + .ThenInclude(volume => volume.Chapters) + .ThenInclude(chapter => chapter.Images) .FirstOrDefaultAsync(); - var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault(); + var volume = novel.Volumes.FirstOrDefault(v => v.Id == volumeId); + var chapter = volume.Chapters.FirstOrDefault(c => c.Order == chapterOrder); var adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key); var rawChapter = await adapter.GetRawChapter(chapter.Url); @@ -478,12 +531,13 @@ public class NovelUpdateService return importNovelRequestEvent; } - public async Task QueueChapterPull(uint novelId, uint chapterNumber) + public async Task QueueChapterPull(uint novelId, uint volumeId, uint chapterOrder) { var chapterPullEvent = new ChapterPullRequestedEvent() { NovelId = novelId, - ChapterNumber = chapterNumber + VolumeId = volumeId, + ChapterOrder = chapterOrder }; await _eventBus.Publish(chapterPullEvent); return chapterPullEvent; @@ -495,9 +549,10 @@ public class NovelUpdateService .Include(n => n.CoverImage) .Include(n => n.Name).ThenInclude(k => k.Texts) .Include(n => n.Description).ThenInclude(k => k.Texts) - .Include(n => n.Chapters).ThenInclude(c => c.Images) - .Include(n => n.Chapters).ThenInclude(c => c.Name).ThenInclude(k => k.Texts) - .Include(n => n.Chapters).ThenInclude(c => c.Body).ThenInclude(k => k.Texts) + .Include(n => n.Volumes).ThenInclude(v => v.Name).ThenInclude(k => k.Texts) + .Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Images) + .Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Name).ThenInclude(k => k.Texts) + .Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Body).ThenInclude(k => k.Texts) .FirstOrDefaultAsync(n => n.Id == novelId); if (novel == null) @@ -505,8 +560,12 @@ public class NovelUpdateService // Collect all LocalizationKey IDs for cleanup var locKeyIds = new List { novel.Name.Id, novel.Description.Id }; - locKeyIds.AddRange(novel.Chapters.Select(c => c.Name.Id)); - locKeyIds.AddRange(novel.Chapters.Select(c => c.Body.Id)); + foreach (var volume in novel.Volumes) + { + locKeyIds.Add(volume.Name.Id); + locKeyIds.AddRange(volume.Chapters.Select(c => c.Name.Id)); + locKeyIds.AddRange(volume.Chapters.Select(c => c.Body.Id)); + } // 1. Remove LocalizationRequests referencing these keys var locRequests = await _dbContext.LocalizationRequests @@ -517,19 +576,26 @@ public class NovelUpdateService // 2. Remove LocalizationTexts (NO ACTION FK - won't cascade) _dbContext.RemoveRange(novel.Name.Texts); _dbContext.RemoveRange(novel.Description.Texts); - foreach (var chapter in novel.Chapters) + foreach (var volume in novel.Volumes) { - _dbContext.RemoveRange(chapter.Name.Texts); - _dbContext.RemoveRange(chapter.Body.Texts); + _dbContext.RemoveRange(volume.Name.Texts); + foreach (var chapter in volume.Chapters) + { + _dbContext.RemoveRange(chapter.Name.Texts); + _dbContext.RemoveRange(chapter.Body.Texts); + } } // 3. Remove Images (NO ACTION FK - won't cascade) if (novel.CoverImage != null) _dbContext.Images.Remove(novel.CoverImage); - foreach (var chapter in novel.Chapters) - _dbContext.Images.RemoveRange(chapter.Images); + foreach (var volume in novel.Volumes) + { + foreach (var chapter in volume.Chapters) + _dbContext.Images.RemoveRange(chapter.Images); + } - // 4. Remove novel - cascades: chapters, localization keys, tag mappings + // 4. Remove novel - cascades: volumes, chapters, localization keys, tag mappings _dbContext.Novels.Remove(novel); await _dbContext.SaveChangesAsync(); } diff --git a/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs b/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs index 5843434..ad8a56f 100644 --- a/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs +++ b/FictionArchive.Service.NovelService/Services/SourceAdapters/Novelpia/NovelpiaAdapter.cs @@ -66,7 +66,7 @@ public class NovelpiaAdapter : ISourceAdapter ExternalId = novelId.ToString(), SystemTags = new List(), SourceTags = new List(), - Chapters = new List(), + Volumes = new List(), SourceDescriptor = SourceDescriptor }; @@ -168,8 +168,18 @@ public class NovelpiaAdapter : ISourceAdapter } page++; } - novel.Chapters = chapters; - + + // Wrap all chapters in a single "Main Story" volume + novel.Volumes = new List + { + new VolumeMetadata + { + Order = 1, + Name = "Main Story", + Chapters = chapters + } + }; + return novel; }