Compare commits

..

8 Commits

Author SHA1 Message Date
b5d4694f12 Merge pull request '[FA-misc] Update docker-compose.yml' (#58) from feature/FA-misc_AddDockerComposeUserService into master
All checks were successful
CI / build-backend (push) Successful in 1m7s
CI / build-frontend (push) Successful in 40s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 55s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 49s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 50s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 48s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 2m25s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 2m28s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 2m14s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 2m8s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 2m15s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m43s
Release / build-frontend (push) Successful in 1m43s
Build Gateway / build-gateway (push) Successful in 4m1s
Reviewed-on: #58
2025-12-30 03:26:06 +00:00
gamer147
6d47153a42 [FA-misc] Update docker-compose.yml
All checks were successful
CI / build-backend (pull_request) Successful in 1m26s
CI / build-frontend (pull_request) Successful in 50s
2025-12-29 22:23:29 -05:00
dbbc2fd8dc Merge pull request 'feature/FA-6_AuthorsPosts' (#57) from feature/FA-6_AuthorsPosts into master
All checks were successful
CI / build-backend (push) Successful in 1m16s
CI / build-frontend (push) Successful in 51s
Reviewed-on: #57
2025-12-30 03:14:53 +00:00
gamer147
176c94297b [FA-6] Author's posts seem to work
All checks were successful
CI / build-backend (pull_request) Successful in 2m4s
CI / build-frontend (pull_request) Successful in 46s
2025-12-29 22:06:12 -05:00
gamer147
8b3faa8f6c [FA-6] Good spot 2025-12-29 21:40:44 -05:00
gamer147
d87bd81190 [FA-6] Volumes work probably? 2025-12-29 21:28:07 -05:00
gamer147
bee805c441 [FA-6] Need to test Novelpia import 2025-12-29 20:27:04 -05:00
d8e3ec7ec9 Merge pull request 'feature/FA-55_UserServiceSetup' (#56) from feature/FA-55_UserServiceSetup into master
Some checks failed
CI / build-backend (push) Successful in 1m9s
CI / build-frontend (push) Failing after 44s
Reviewed-on: #56
2025-12-29 19:38:43 +00:00
34 changed files with 11532 additions and 252 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,13 @@ public class NovelUpdateServiceTests
Images = new List<Image>()
};
var volume = new Volume
{
Order = 1,
Name = LocalizationKey.CreateFromText("Main Story", Language.En),
Chapters = new List<Chapter> { 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> { chapter },
Volumes = new List<Volume> { volume },
Tags = new List<NovelTag>()
};
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 = "<p>Hello</p><img src=\"http://img/x1.jpg\" alt=\"first\" /><img src=\"http://img/x2.jpg\" alt=\"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 = "<p>Hi</p><img src=\"http://img/x1.jpg\">";
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 };

View File

@@ -21,11 +21,13 @@ public class Mutation
}
[Authorize]
public async Task<ChapterPullRequestedEvent> FetchChapterContents(uint novelId,
uint chapterNumber,
public async Task<ChapterPullRequestedEvent> FetchChapterContents(
uint novelId,
uint volumeId,
uint chapterOrder,
NovelUpdateService service)
{
return await service.QueueChapterPull(novelId, chapterNumber);
return await service.QueueChapterPull(novelId, volumeId, chapterOrder);
}
[Error<KeyNotFoundException>]

View File

@@ -77,7 +77,19 @@ public class Query
}
: null,
Chapters = novel.Chapters.Select(chapter => new ChapterDto
Volumes = novel.Volumes.OrderBy(v => v.Order).Select(volume => new VolumeDto
{
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()
?? volume.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Chapters = volume.Chapters.OrderBy(c => c.Order).Select(chapter => new ChapterDto
{
Id = chapter.Id,
CreatedTime = chapter.CreatedTime,
@@ -104,6 +116,7 @@ public class Query
LastUpdatedTime = image.LastUpdatedTime,
NewPath = image.NewPath
}).ToList()
}).ToList()
}).ToList(),
Tags = novel.Tags.Select(tag => new NovelTagDto
@@ -140,11 +153,12 @@ public class Query
public IQueryable<ChapterReaderDto> GetChapter(
NovelServiceDbContext dbContext,
uint novelId,
uint volumeOrder,
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.Order == volumeOrder && c.Order == chapterOrder)
.Select(chapter => new ChapterReaderDto
{
Id = chapter.Id,
@@ -176,22 +190,72 @@ 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
PrevChapterVolumeOrder = chapter.Volume.Chapters
.Where(c => c.Order < chapterOrder)
.OrderByDescending(c => c.Order)
.Select(c => (int?)chapter.Volume.Order)
.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 => (int?)c.Volume.Order)
.FirstOrDefault(),
PrevChapterOrder = chapter.Volume.Chapters
.Where(c => c.Order < chapterOrder)
.OrderByDescending(c => c.Order)
.Select(c => (uint?)c.Order)
.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(),
NextChapterOrder = chapter.Novel.Chapters
// Next chapter: first try same volume, then first chapter of next volume
NextChapterVolumeOrder = chapter.Volume.Chapters
.Where(c => c.Order > chapterOrder)
.OrderBy(c => c.Order)
.Select(c => (int?)chapter.Volume.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 => (int?)c.Volume.Order)
.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()
});

View File

@@ -0,0 +1,605 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<long?>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("NewPath")
.HasColumnType("text");
b.Property<string>("OriginalPath")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.ToTable("Images");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKeys");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("EngineId")
.HasColumnType("bigint");
b.Property<Guid>("KeyRequestedForTranslationId")
.HasColumnType("uuid");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("LocalizationKeyId")
.HasColumnType("uuid");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("BodyId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<string>("Url")
.HasColumnType("text");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<long>("AuthorId")
.HasColumnType("bigint");
b.Property<Guid?>("CoverImageId")
.HasColumnType("uuid");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DescriptionId")
.HasColumnType("uuid");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<int>("RawLanguage")
.HasColumnType("integer");
b.Property<int>("RawStatus")
.HasColumnType("integer");
b.Property<long>("SourceId")
.HasColumnType("bigint");
b.Property<int?>("StatusOverride")
.HasColumnType("integer");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("DisplayNameId")
.HasColumnType("uuid");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("NameId");
b.ToTable("Person");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Source", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Sources");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.TranslationEngine", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("TranslationEngines");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<int>("Order")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("NameId");
b.HasIndex("NovelId", "Order")
.IsUnique();
b.ToTable("Volume");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.Property<long>("NovelsId")
.HasColumnType("bigint");
b.Property<long>("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
}
}
}

View File

@@ -0,0 +1,195 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class AddVolumes : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// 1. Create the Volume table
migrationBuilder.CreateTable(
name: "Volume",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Order = table.Column<int>(type: "integer", nullable: false),
NameId = table.Column<Guid>(type: "uuid", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: false),
CreatedTime = table.Column<Instant>(type: "timestamp with time zone", nullable: false),
LastUpdatedTime = table.Column<Instant>(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<long>(
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<long>(
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);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// Add back NovelId column
migrationBuilder.AddColumn<long>(
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<long>(
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");
}
}
}

View File

@@ -153,9 +153,6 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
@@ -165,13 +162,17 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<string>("Url")
.HasColumnType("text");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<int>("Order")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("NameId");
b.HasIndex("NovelId", "Order")
.IsUnique();
b.ToTable("Volume");
});
modelBuilder.Entity("NovelNovelTag", b =>
{
b.Property<long>("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");
});

View File

@@ -12,7 +12,16 @@ public class ChapterReaderDto : BaseDto<uint>
// 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 (VolumeOrder + ChapterOrder identify a chapter)
public int? PrevChapterVolumeOrder { get; init; }
public uint? PrevChapterOrder { get; init; }
public int? NextChapterVolumeOrder { get; init; }
public uint? NextChapterOrder { get; init; }
}

View File

@@ -14,7 +14,7 @@ public class NovelDto : BaseDto<uint>
public required string ExternalId { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required List<ChapterDto> Chapters { get; init; }
public required List<VolumeDto> Volumes { get; init; }
public required List<NovelTagDto> Tags { get; init; }
public ImageDto? CoverImage { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class VolumeDto : BaseDto<uint>
{
public int Order { get; init; }
public required string Name { get; init; }
public required List<ChapterDto> Chapters { get; init; }
}

View File

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

View File

@@ -20,7 +20,7 @@ public class Chapter : BaseEntity<uint>
#region Navigation Properties
public Novel Novel { get; set; }
public Volume Volume { get; set; }
#endregion
}

View File

@@ -21,7 +21,7 @@ public class Novel : BaseEntity<uint>
public LocalizationKey Name { get; set; }
public LocalizationKey Description { get; set; }
public List<Chapter> Chapters { get; set; }
public List<Volume> Volumes { get; set; }
public List<NovelTag> Tags { get; set; }
public Image? CoverImage { get; set; }
}

View File

@@ -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<uint>
{
/// <summary>
/// Signed int to allow special ordering like -1 for "Author Notes" at top.
/// </summary>
public int Order { get; set; }
public LocalizationKey Name { get; set; }
public List<Chapter> Chapters { get; set; }
#region Navigation Properties
public Novel Novel { get; set; }
#endregion
}

View File

@@ -16,7 +16,7 @@ public class NovelMetadata
public Language RawLanguage { get; set; }
public NovelStatus RawStatus { get; set; }
public List<ChapterMetadata> Chapters { get; set; }
public List<VolumeMetadata> Volumes { get; set; }
public List<string> SourceTags { get; set; }
public List<string> SystemTags { get; set; }
public SourceDescriptor SourceDescriptor { get; set; }

View File

@@ -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<ChapterMetadata> Chapters { get; set; }
}

View File

@@ -14,6 +14,6 @@ public class ChapterPullRequestedEventHandler : IIntegrationEventHandler<Chapter
public async Task Handle(ChapterPullRequestedEvent @event)
{
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.ChapterNumber);
await _novelUpdateService.PullChapterContents(@event.NovelId, @event.VolumeId, @event.ChapterOrder);
}
}

View File

@@ -10,6 +10,7 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
: FictionArchiveDbContext(options, logger)
{
public DbSet<Novel> Novels { get; set; }
public DbSet<Volume> Volumes { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
@@ -25,5 +26,15 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
modelBuilder.Entity<Novel>()
.HasIndex("ExternalId", "SourceId")
.IsUnique();
// Volume.Order is unique per Novel
modelBuilder.Entity<Volume>()
.HasIndex("NovelId", "Order")
.IsUnique();
// Chapter.Order is unique per Volume
modelBuilder.Entity<Chapter>()
.HasIndex("VolumeId", "Order")
.IsUnique();
}
}

View File

@@ -190,6 +190,48 @@ public class NovelUpdateService
return existingChapters.Concat(newChapters).ToList();
}
private static List<Volume> SynchronizeVolumes(
List<VolumeMetadata> metadataVolumes,
Language rawLanguage,
List<Volume>? existingVolumes)
{
existingVolumes ??= new List<Volume>();
var result = new List<Volume>();
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,7 +396,9 @@ public class NovelUpdateService
}
// Publish chapter pull events for chapters without body content
var chaptersNeedingPull = novel.Chapters
foreach (var volume in novel.Volumes)
{
var chaptersNeedingPull = volume.Chapters
.Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
.ToList();
@@ -361,22 +407,29 @@ public class NovelUpdateService
await _eventBus.Publish(new ChapterPullRequestedEvent
{
NovelId = novel.Id,
ChapterNumber = chapter.Order
VolumeId = volume.Id,
ChapterOrder = chapter.Order
});
}
}
return novel;
}
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)
public async Task<Chapter> 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<ChapterPullRequestedEvent> QueueChapterPull(uint novelId, uint chapterNumber)
public async Task<ChapterPullRequestedEvent> 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<Guid> { 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(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)
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();
}

View File

@@ -66,7 +66,7 @@ public class NovelpiaAdapter : ISourceAdapter
ExternalId = novelId.ToString(),
SystemTags = new List<string>(),
SourceTags = new List<string>(),
Chapters = new List<ChapterMetadata>(),
Volumes = new List<VolumeMetadata>(),
SourceDescriptor = SourceDescriptor
};
@@ -133,6 +133,9 @@ public class NovelpiaAdapter : ISourceAdapter
novel.SourceTags.Add(tag);
}
// Author's posts (from notice_table in the page HTML)
var authorsPosts = ParseAuthorsPosts(novelData);
// Chapters
uint page = 0;
List<ChapterMetadata> chapters = new List<ChapterMetadata>();
@@ -168,7 +171,25 @@ public class NovelpiaAdapter : ISourceAdapter
}
page++;
}
novel.Chapters = chapters;
// Add Author's Posts volume if there are any
if (authorsPosts.Count > 0)
{
novel.Volumes.Add(new VolumeMetadata
{
Order = 0,
Name = "Author's Posts",
Chapters = authorsPosts
});
}
// Main Story volume
novel.Volumes.Add(new VolumeMetadata
{
Order = 1,
Name = "Main Story",
Chapters = chapters
});
return novel;
}
@@ -241,4 +262,40 @@ public class NovelpiaAdapter : ISourceAdapter
}
return await image.Content.ReadAsByteArrayAsync();
}
private List<ChapterMetadata> ParseAuthorsPosts(string novelHtml)
{
var posts = new List<ChapterMetadata>();
// Find the notice_table section
var noticeTableMatch = Regex.Match(novelHtml,
@"(?s)<table[^>]*class=""notice_table[^""]*""[^>]*>(.*?)</table>");
if (!noticeTableMatch.Success)
return posts;
var tableContent = noticeTableMatch.Groups[1].Value;
// Find all td elements with onclick containing viewer URL and extract title from <b>
// HTML structure: <td ... onclick="...location='/viewer/3330612';"><b>Title</b>
var postMatches = Regex.Matches(tableContent,
@"onclick=""[^""]*location='/viewer/(\d+)'[^""]*""[^>]*><b>([^<]+)</b>");
uint order = 1;
foreach (Match match in postMatches)
{
string viewerId = match.Groups[1].Value;
string title = WebUtility.HtmlDecode(match.Groups[2].Value.Trim());
posts.Add(new ChapterMetadata
{
Revision = 0,
Order = order,
Url = $"https://novelpia.com/viewer/{viewerId}",
Name = title
});
order++;
}
return posts;
}
}

View File

@@ -4,25 +4,31 @@ services:
# ===========================================
postgres:
image: postgres:16-alpine
networks:
- fictionarchive
environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes:
- postgres_data:/var/lib/postgresql/data
- /srv/docker_volumes/fictionarchive/postgres:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
ports:
- 4321:5432
rabbitmq:
image: rabbitmq:3-management-alpine
networks:
- fictionarchive
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
- /srv/docker_volumes/fictionarchive/rabbitmq:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 10s
@@ -30,6 +36,20 @@ services:
retries: 5
restart: unless-stopped
vpn:
image: dperson/openvpn-client # or gluetun, wireguard, etc.
networks:
fictionarchive:
aliases:
- novel-service
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun
volumes:
- /srv/docker_volumes/korean_vpn:/vpn
restart: unless-stopped
# ===========================================
# Backend Services
# ===========================================
@@ -37,50 +57,27 @@ services:
image: git.orfl.xyz/conco/fictionarchive-novel-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_NovelService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
Novelpia__Username: ${NOVELPIA_USERNAME}
Novelpia__Password: ${NOVELPIA_PASSWORD}
NovelUpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
restart: unless-stopped
translation-service:
image: git.orfl.xyz/conco/fictionarchive-translation-service:latest
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_TranslationService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
DeepL__ApiKey: ${DEEPL_API_KEY}
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
UpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy
vpn:
condition: service_started
network_mode: "service:vpn"
restart: unless-stopped
scheduler-service:
image: git.orfl.xyz/conco/fictionarchive-scheduler-service:latest
networks:
- fictionarchive
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_SchedulerService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
depends_on:
postgres:
condition: service_healthy
@@ -90,14 +87,14 @@ services:
user-service:
image: git.orfl.xyz/conco/fictionarchive-user-service:latest
networks:
- fictionarchive
environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
Authentik__BaseUrl: https://auth.orfl.xyz
Authentik__ApiToken: ${AUTHENTIK_API_TOKEN}
Authentik__EmailStageId: 10df0c18-8802-4ec7-852e-3cdd355514d3
depends_on:
postgres:
condition: service_healthy
@@ -105,42 +102,21 @@ services:
condition: service_healthy
restart: unless-stopped
authentication-service:
image: git.orfl.xyz/conco/fictionarchive-authentication-service:latest
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on:
rabbitmq:
condition: service_healthy
restart: unless-stopped
file-service:
image: git.orfl.xyz/conco/fictionarchive-file-service:latest
networks:
- web
- fictionarchive
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__Endpoint: ${S3_ENDPOINT:-https://s3.orfl.xyz}
S3__Bucket: ${S3_BUCKET:-fictionarchive}
RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-files
OIDC__Audience: fictionarchive-api
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
ProxyConfiguration__BaseUrl: https://files.fictionarchive.orfl.xyz/api
labels:
- "traefik.enable=true"
- "traefik.http.routers.file-service.rule=Host(`files.orfl.xyz`)"
- "traefik.http.routers.file-service.entrypoints=websecure"
- "traefik.http.routers.file-service.tls.certresolver=letsencrypt"
- "traefik.http.routers.file-service.rule=Host(`files.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.file-service.tls=true"
- "traefik.http.routers.file-service.tls.certresolver=lets-encrypt"
- "traefik.http.services.file-service.loadbalancer.server.port=8080"
depends_on:
rabbitmq:
@@ -152,30 +128,22 @@ services:
# ===========================================
api-gateway:
image: git.orfl.xyz/conco/fictionarchive-api:latest
networks:
- web
- fictionarchive
environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
OIDC__Authority: https://auth.orfl.xyz/application/o/fictionarchive/
OIDC__ClientId: fictionarchive-api
OIDC__Audience: fictionarchive-api
Cors__AllowedOrigin: https://fictionarchive.orfl.xyz
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
labels:
- "traefik.enable=true"
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.api-gateway.entrypoints=websecure"
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt"
- "traefik.http.routers.api-gateway.tls=true"
- "traefik.http.routers.api-gateway.tls.certresolver=lets-encrypt"
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
depends_on:
- novel-service
- translation-service
- scheduler-service
- user-service
- authentication-service
- file-service
- user-service
restart: unless-stopped
# ===========================================
@@ -183,20 +151,17 @@ services:
# ===========================================
frontend:
image: git.orfl.xyz/conco/fictionarchive-frontend:latest
healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
networks:
- web
labels:
- "traefik.enable=true"
- "traefik.http.routers.frontend.rule=Host(`fictionarchive.orfl.xyz`)"
- "traefik.http.routers.frontend.entrypoints=websecure"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
- traefik.http.routers.fafrontend.rule=Host(`fictionarchive.orfl.xyz`)
- traefik.http.routers.fafrontend.tls=true
- traefik.http.routers.fafrontend.tls.certresolver=lets-encrypt
- traefik.http.services.fafrontend.loadbalancer.server.port=80
- traefik.enable=true
restart: unless-stopped
volumes:
postgres_data:
rabbitmq_data:
letsencrypt:
networks:
web:
external: yes
fictionarchive:

View File

@@ -6,22 +6,31 @@
interface Props {
novelId: string;
prevChapterVolumeOrder: number | null | undefined;
prevChapterOrder: number | null | undefined;
nextChapterVolumeOrder: number | null | undefined;
nextChapterOrder: number | null | undefined;
showKeyboardHints?: boolean;
}
let { novelId, prevChapterOrder, nextChapterOrder, showKeyboardHints = true }: Props = $props();
let {
novelId,
prevChapterVolumeOrder,
prevChapterOrder,
nextChapterVolumeOrder,
nextChapterOrder,
showKeyboardHints = true
}: Props = $props();
const hasPrev = $derived(prevChapterOrder != null);
const hasNext = $derived(nextChapterOrder != null);
const hasPrev = $derived(prevChapterOrder != null && prevChapterVolumeOrder != null);
const hasNext = $derived(nextChapterOrder != null && nextChapterVolumeOrder != null);
</script>
<div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-4">
<Button
variant="outline"
href={hasPrev ? `/novels/${novelId}/chapters/${prevChapterOrder}` : undefined}
href={hasPrev ? `/novels/${novelId}/volumes/${prevChapterVolumeOrder}/chapters/${prevChapterOrder}` : undefined}
disabled={!hasPrev}
class="gap-2"
>
@@ -36,7 +45,7 @@
<Button
variant="outline"
href={hasNext ? `/novels/${novelId}/chapters/${nextChapterOrder}` : undefined}
href={hasNext ? `/novels/${novelId}/volumes/${nextChapterVolumeOrder}/chapters/${nextChapterOrder}` : undefined}
disabled={!hasNext}
class="gap-2"
>

View File

@@ -16,10 +16,11 @@
interface Props {
novelId?: string;
volumeOrder?: string;
chapterNumber?: string;
}
let { novelId, chapterNumber }: Props = $props();
let { novelId, volumeOrder, chapterNumber }: Props = $props();
// State
let chapter: ChapterData | null = $state(null);
@@ -42,16 +43,16 @@
return;
}
if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.prevChapterOrder}`;
} else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.nextChapterOrder}`;
if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null && chapter?.prevChapterVolumeOrder != null) {
window.location.href = `/novels/${novelId}/volumes/${chapter.prevChapterVolumeOrder}/chapters/${chapter.prevChapterOrder}`;
} else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null && chapter?.nextChapterVolumeOrder != null) {
window.location.href = `/novels/${novelId}/volumes/${chapter.nextChapterVolumeOrder}/chapters/${chapter.nextChapterOrder}`;
}
}
async function fetchChapter() {
if (!novelId || !chapterNumber) {
error = 'Missing novel ID or chapter number';
if (!novelId || !volumeOrder || !chapterNumber) {
error = 'Missing novel ID, volume order, or chapter number';
fetching = false;
return;
}
@@ -63,6 +64,7 @@
const result = await client
.query(GetChapterDocument, {
novelId: parseInt(novelId, 10),
volumeOrder: parseInt(volumeOrder, 10),
chapterOrder: parseInt(chapterNumber, 10)
})
.toPromise();
@@ -137,7 +139,9 @@
<!-- Navigation (top) -->
<ChapterNavigation
novelId={novelId ?? ''}
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
nextChapterOrder={chapter.nextChapterOrder}
/>
@@ -169,7 +173,9 @@
<!-- Navigation (bottom) -->
<ChapterNavigation
novelId={novelId ?? ''}
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
prevChapterOrder={chapter.prevChapterOrder}
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
nextChapterOrder={chapter.nextChapterOrder}
showKeyboardHints={false}
/>

View File

@@ -38,6 +38,12 @@
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '$lib/components/ui/tabs';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent
} from '$lib/components/ui/accordion';
import {
Tooltip,
TooltipTrigger,
@@ -81,6 +87,7 @@
type GalleryImage = {
src: string;
alt: string;
volumeOrder?: number;
chapterId?: number;
chapterOrder?: number;
chapterName?: string;
@@ -114,11 +121,16 @@
: descriptionHtml
);
const sortedChapters = $derived(
[...(novel?.chapters ?? [])].sort((a, b) => a.order - b.order)
// Volume-aware chapter organization
const sortedVolumes = $derived(
[...(novel?.volumes ?? [])].sort((a, b) => a.order - b.order)
);
const chapterCount = $derived(novel?.chapters?.length ?? 0);
const isSingleVolume = $derived(sortedVolumes.length === 1);
const chapterCount = $derived(
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
);
// Filter out system tags for display, check for NSFW
const displayTags = $derived(novel?.tags?.filter((tag) => tag.tagType !== TagType.System) ?? []);
@@ -139,13 +151,16 @@
images.push({ src: coverSrc, alt: `${novel.name} cover`, isCover: true });
}
// Add chapter images
for (const chapter of sortedChapters) {
// Add chapter images (loop through volumes to preserve volumeOrder)
for (const volume of sortedVolumes) {
const volumeChapters = [...volume.chapters].sort((a, b) => a.order - b.order);
for (const chapter of volumeChapters) {
for (const img of chapter.images ?? []) {
if (img.newPath) {
images.push({
src: img.newPath,
alt: `Image from ${chapter.name}`,
volumeOrder: volume.order,
chapterId: chapter.id,
chapterOrder: chapter.order,
chapterName: chapter.name,
@@ -154,6 +169,7 @@
}
}
}
}
return images;
});
@@ -522,16 +538,18 @@
<CardContent class="pt-4">
<TabsContent value="chapters" class="mt-0">
{#if sortedChapters.length === 0}
{#if chapterCount === 0}
<p class="text-muted-foreground text-sm py-4 text-center">
No chapters available yet.
</p>
{:else}
{:else if isSingleVolume}
<!-- Single volume: flat chapter list -->
{@const singleVolumeChapters = [...(sortedVolumes[0]?.chapters ?? [])].sort((a, b) => a.order - b.order)}
<div class="max-h-96 overflow-y-auto -mx-2">
{#each sortedChapters as chapter (chapter.id)}
{#each singleVolumeChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a
href="/novels/{novelId}/chapters/{chapter.order}"
href="/novels/{novelId}/volumes/{sortedVolumes[0]?.order}/chapters/{chapter.order}"
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
>
<div class="flex items-center gap-3 min-w-0">
@@ -550,6 +568,50 @@
</a>
{/each}
</div>
{:else}
<!-- Multiple volumes: accordion display -->
<div class="max-h-96 overflow-y-auto -mx-2">
<Accordion type="single">
{#each sortedVolumes as volume (volume.id)}
{@const volumeChapters = [...volume.chapters].sort((a, b) => a.order - b.order)}
<AccordionItem value="volume-{volume.id}">
<AccordionTrigger class="px-3">
<div class="flex items-center gap-3">
<span class="font-medium">{volume.name}</span>
<span class="text-xs text-muted-foreground">
({volumeChapters.length} chapters)
</span>
</div>
</AccordionTrigger>
<AccordionContent>
<div class="space-y-0.5">
{#each volumeChapters as chapter (chapter.id)}
{@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
<a
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
class="flex items-center justify-between px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group"
>
<div class="flex items-center gap-3 min-w-0">
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order}
</span>
<span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name}
</span>
</div>
{#if chapterDate}
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(chapterDate)}
</span>
{/if}
</a>
{/each}
</div>
</AccordionContent>
</AccordionItem>
{/each}
</Accordion>
</div>
{/if}
</TabsContent>
@@ -645,9 +707,9 @@
/>
<!-- Chapter link (if not cover) -->
{#if !currentImage.isCover && currentImage.chapterOrder}
{#if !currentImage.isCover && currentImage.volumeOrder != null && currentImage.chapterOrder}
<a
href="/novels/{novelId}/chapters/{currentImage.chapterOrder}"
href="/novels/{novelId}/volumes/{currentImage.volumeOrder}/chapters/{currentImage.chapterOrder}"
class="text-white/80 hover:text-white text-sm inline-flex items-center gap-1 mt-3"
>
From: Ch. {currentImage.chapterOrder} - {currentImage.chapterName}

View File

@@ -0,0 +1,20 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
class: className,
children,
...restProps
}: ComponentProps<typeof AccordionPrimitive.Content> = $props();
</script>
<AccordionPrimitive.Content
class={cn('overflow-hidden text-sm transition-all', className)}
{...restProps}
>
<div class="pb-4 pt-0">
{@render children?.()}
</div>
</AccordionPrimitive.Content>

View File

@@ -0,0 +1,15 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
let {
class: className,
children,
...restProps
}: ComponentProps<typeof AccordionPrimitive.Item> = $props();
</script>
<AccordionPrimitive.Item class={cn('border-b', className)} {...restProps}>
{@render children?.()}
</AccordionPrimitive.Item>

View File

@@ -0,0 +1,25 @@
<script lang="ts">
import { Accordion as AccordionPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import type { ComponentProps } from 'svelte';
let {
class: className,
children,
...restProps
}: ComponentProps<typeof AccordionPrimitive.Trigger> = $props();
</script>
<AccordionPrimitive.Header class="flex">
<AccordionPrimitive.Trigger
class={cn(
'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline [&[data-state=open]>svg]:rotate-180',
className
)}
{...restProps}
>
{@render children?.()}
<ChevronDown class="h-4 w-4 shrink-0 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>

View File

@@ -0,0 +1,18 @@
import { Accordion as AccordionPrimitive } from 'bits-ui';
import Item from './accordion-item.svelte';
import Trigger from './accordion-trigger.svelte';
import Content from './accordion-content.svelte';
const Root = AccordionPrimitive.Root;
export {
Root,
Item,
Trigger,
Content,
//
Root as Accordion,
Item as AccordionItem,
Trigger as AccordionTrigger,
Content as AccordionContent
};

View File

@@ -56,8 +56,9 @@ export type ChapterDtoFilterInput = {
};
export type ChapterPullRequestedEvent = {
chapterNumber: Scalars['UnsignedInt']['output'];
chapterOrder: Scalars['UnsignedInt']['output'];
novelId: Scalars['UnsignedInt']['output'];
volumeId: Scalars['UnsignedInt']['output'];
};
export type ChapterReaderDto = {
@@ -68,13 +69,18 @@ export type ChapterReaderDto = {
lastUpdatedTime: Scalars['Instant']['output'];
name: Scalars['String']['output'];
nextChapterOrder: Maybe<Scalars['UnsignedInt']['output']>;
nextChapterVolumeOrder: Maybe<Scalars['Int']['output']>;
novelId: Scalars['UnsignedInt']['output'];
novelName: Scalars['String']['output'];
order: Scalars['UnsignedInt']['output'];
prevChapterOrder: Maybe<Scalars['UnsignedInt']['output']>;
prevChapterVolumeOrder: Maybe<Scalars['Int']['output']>;
revision: Scalars['UnsignedInt']['output'];
totalChapters: Scalars['Int']['output'];
totalChaptersInVolume: Scalars['Int']['output'];
url: Maybe<Scalars['String']['output']>;
volumeId: Scalars['UnsignedInt']['output'];
volumeName: Scalars['String']['output'];
volumeOrder: Scalars['Int']['output'];
};
export type DeleteJobError = KeyNotFoundError;
@@ -108,8 +114,9 @@ export type Error = {
};
export type FetchChapterContentsInput = {
chapterNumber: Scalars['UnsignedInt']['input'];
chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
volumeId: Scalars['UnsignedInt']['input'];
};
export type FetchChapterContentsPayload = {
@@ -156,6 +163,21 @@ export type InstantFilterInput = {
or?: InputMaybe<Array<InstantFilterInput>>;
};
export type IntOperationFilterInput = {
eq?: InputMaybe<Scalars['Int']['input']>;
gt?: InputMaybe<Scalars['Int']['input']>;
gte?: InputMaybe<Scalars['Int']['input']>;
in?: InputMaybe<Array<InputMaybe<Scalars['Int']['input']>>>;
lt?: InputMaybe<Scalars['Int']['input']>;
lte?: InputMaybe<Scalars['Int']['input']>;
neq?: InputMaybe<Scalars['Int']['input']>;
ngt?: InputMaybe<Scalars['Int']['input']>;
ngte?: InputMaybe<Scalars['Int']['input']>;
nin?: InputMaybe<Array<InputMaybe<Scalars['Int']['input']>>>;
nlt?: InputMaybe<Scalars['Int']['input']>;
nlte?: InputMaybe<Scalars['Int']['input']>;
};
export type InvalidOperationError = Error & {
message: Scalars['String']['output'];
};
@@ -231,6 +253,13 @@ export type ListFilterInputTypeOfNovelTagDtoFilterInput = {
some?: InputMaybe<NovelTagDtoFilterInput>;
};
export type ListFilterInputTypeOfVolumeDtoFilterInput = {
all?: InputMaybe<VolumeDtoFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<VolumeDtoFilterInput>;
some?: InputMaybe<VolumeDtoFilterInput>;
};
export type Mutation = {
deleteJob: DeleteJobPayload;
deleteNovel: DeleteNovelPayload;
@@ -284,7 +313,6 @@ export type MutationTranslateTextArgs = {
export type NovelDto = {
author: PersonDto;
chapters: Array<ChapterDto>;
coverImage: Maybe<ImageDto>;
createdTime: Scalars['Instant']['output'];
description: Scalars['String']['output'];
@@ -298,12 +326,12 @@ export type NovelDto = {
statusOverride: Maybe<NovelStatus>;
tags: Array<NovelTagDto>;
url: Scalars['String']['output'];
volumes: Array<VolumeDto>;
};
export type NovelDtoFilterInput = {
and?: InputMaybe<Array<NovelDtoFilterInput>>;
author?: InputMaybe<PersonDtoFilterInput>;
chapters?: InputMaybe<ListFilterInputTypeOfChapterDtoFilterInput>;
coverImage?: InputMaybe<ImageDtoFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
description?: InputMaybe<StringOperationFilterInput>;
@@ -318,6 +346,7 @@ export type NovelDtoFilterInput = {
statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>;
tags?: InputMaybe<ListFilterInputTypeOfNovelTagDtoFilterInput>;
url?: InputMaybe<StringOperationFilterInput>;
volumes?: InputMaybe<ListFilterInputTypeOfVolumeDtoFilterInput>;
};
export type NovelDtoSortInput = {
@@ -443,7 +472,7 @@ export type PersonDtoSortInput = {
export type Query = {
chapter: Maybe<ChapterReaderDto>;
currentUser: Array<UserDto>;
currentUser: Maybe<UserDto>;
jobs: Array<SchedulerJob>;
novels: Maybe<NovelsConnection>;
translationEngines: Array<TranslationEngineDescriptor>;
@@ -455,6 +484,7 @@ export type QueryChapterArgs = {
chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input'];
preferredLanguage?: Language;
volumeOrder: Scalars['UnsignedInt']['input'];
};
@@ -736,6 +766,26 @@ export type UuidOperationFilterInput = {
nlte?: InputMaybe<Scalars['UUID']['input']>;
};
export type VolumeDto = {
chapters: Array<ChapterDto>;
createdTime: Scalars['Instant']['output'];
id: Scalars['UnsignedInt']['output'];
lastUpdatedTime: Scalars['Instant']['output'];
name: Scalars['String']['output'];
order: Scalars['Int']['output'];
};
export type VolumeDtoFilterInput = {
and?: InputMaybe<Array<VolumeDtoFilterInput>>;
chapters?: InputMaybe<ListFilterInputTypeOfChapterDtoFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>;
id?: InputMaybe<UnsignedIntOperationFilterInputType>;
lastUpdatedTime?: InputMaybe<InstantFilterInput>;
name?: InputMaybe<StringOperationFilterInput>;
or?: InputMaybe<Array<VolumeDtoFilterInput>>;
order?: InputMaybe<IntOperationFilterInput>;
};
export type DeleteNovelMutationVariables = Exact<{
input: DeleteNovelInput;
}>;
@@ -759,18 +809,19 @@ export type InviteUserMutation = { inviteUser: { userDto: { id: any, username: s
export type GetChapterQueryVariables = Exact<{
novelId: Scalars['UnsignedInt']['input'];
volumeOrder: Scalars['UnsignedInt']['input'];
chapterOrder: Scalars['UnsignedInt']['input'];
}>;
export type GetChapterQuery = { chapter: { id: any, order: any, name: string, body: string, url: string | null, revision: any, createdTime: any, lastUpdatedTime: any, novelId: any, novelName: string, totalChapters: number, prevChapterOrder: any | null, nextChapterOrder: any | null, images: Array<{ id: any, newPath: string | null }> } | null };
export type GetChapterQuery = { chapter: { id: any, order: any, name: string, body: string, url: string | null, revision: any, createdTime: any, lastUpdatedTime: any, novelId: any, novelName: string, volumeId: any, volumeName: string, volumeOrder: number, totalChaptersInVolume: number, prevChapterVolumeOrder: number | null, prevChapterOrder: any | null, nextChapterVolumeOrder: number | null, nextChapterOrder: any | null, images: Array<{ id: any, newPath: string | null }> } | null };
export type NovelQueryVariables = Exact<{
id: Scalars['UnsignedInt']['input'];
}>;
export type NovelQuery = { novels: { nodes: Array<{ id: any, name: string, description: string, url: string, rawLanguage: Language, rawStatus: NovelStatus, statusOverride: NovelStatus | null, externalId: string, createdTime: any, lastUpdatedTime: any, author: { id: any, name: string, externalUrl: string | null }, source: { id: any, name: string, key: string, url: string }, coverImage: { newPath: string | null } | null, tags: Array<{ id: any, key: string, displayName: string, tagType: TagType }>, chapters: Array<{ id: any, order: any, name: string, lastUpdatedTime: any, images: Array<{ id: any, newPath: string | null }> }> }> | null } | null };
export type NovelQuery = { novels: { nodes: Array<{ id: any, name: string, description: string, url: string, rawLanguage: Language, rawStatus: NovelStatus, statusOverride: NovelStatus | null, externalId: string, createdTime: any, lastUpdatedTime: any, author: { id: any, name: string, externalUrl: string | null }, source: { id: any, name: string, key: string, url: string }, coverImage: { newPath: string | null } | null, tags: Array<{ id: any, key: string, displayName: string, tagType: TagType }>, volumes: Array<{ id: any, order: number, name: string, createdTime: any, lastUpdatedTime: any, chapters: Array<{ id: any, order: any, name: string, lastUpdatedTime: any, images: Array<{ id: any, newPath: string | null }> }> }> }> | null } | null };
export type NovelsQueryVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>;
@@ -780,18 +831,18 @@ export type NovelsQueryVariables = Exact<{
}>;
export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, chapters: Array<{ order: any, name: string }>, tags: Array<{ key: string, displayName: string, tagType: TagType }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null };
export type NovelsQuery = { novels: { edges: Array<{ cursor: string, node: { id: any, url: string, name: string, description: string, rawStatus: NovelStatus, lastUpdatedTime: any, coverImage: { newPath: string | null } | null, volumes: Array<{ id: any, order: number, name: string, chapters: Array<{ order: any, name: string }> }>, tags: Array<{ key: string, displayName: string, tagType: TagType }> } }> | null, pageInfo: { hasNextPage: boolean, endCursor: string | null } } | null };
export type GetSettingsPageDataQueryVariables = Exact<{ [key: string]: never; }>;
export type GetSettingsPageDataQuery = { currentUser: Array<{ id: any, username: string, availableInvites: number, invitedUsers: Array<{ username: string, email: string }> | null }> };
export type GetSettingsPageDataQuery = { currentUser: { id: any, username: string, availableInvites: number, invitedUsers: Array<{ username: string, email: string }> | null } | null };
export const DeleteNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"boolean"}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Error"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<DeleteNovelMutation, DeleteNovelMutationVariables>;
export const ImportNovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ImportNovel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ImportNovelInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"importNovel"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUpdateRequestedEvent"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelUrl"}}]}}]}}]}}]} as unknown as DocumentNode<ImportNovelMutation, ImportNovelMutationVariables>;
export const InviteUserDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"InviteUser"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"InviteUserInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"inviteUser"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"userDto"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}},{"kind":"Field","name":{"kind":"Name","value":"errors"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"InlineFragment","typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"InvalidOperationError"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"message"}}]}}]}}]}}]}}]} as unknown as DocumentNode<InviteUserMutation, InviteUserMutationVariables>;
export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"totalChapters"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode<GetChapterQuery, GetChapterQueryVariables>;
export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<NovelQuery, NovelQueryVariables>;
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<NovelsQuery, NovelsQueryVariables>;
export const GetChapterDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetChapter"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"chapter"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}},{"kind":"Argument","name":{"kind":"Name","value":"volumeOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"volumeOrder"}}},{"kind":"Argument","name":{"kind":"Name","value":"chapterOrder"},"value":{"kind":"Variable","name":{"kind":"Name","value":"chapterOrder"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"body"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"revision"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"novelName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeId"}},{"kind":"Field","name":{"kind":"Name","value":"volumeName"}},{"kind":"Field","name":{"kind":"Name","value":"volumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"totalChaptersInVolume"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"prevChapterOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterVolumeOrder"}},{"kind":"Field","name":{"kind":"Name","value":"nextChapterOrder"}}]}}]}}]} as unknown as DocumentNode<GetChapterQuery, GetChapterQueryVariables>;
export const NovelDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novel"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"eq"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}]}}]}},{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"nodes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"rawLanguage"}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"statusOverride"}},{"kind":"Field","name":{"kind":"Name","value":"externalId"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"author"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"externalUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"source"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"images"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode<NovelQuery, NovelQueryVariables>;
export const NovelsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Novels"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"first"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"after"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"where"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoFilterInput"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"order"}},"type":{"kind":"ListType","type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"NovelDtoSortInput"}}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novels"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"first"},"value":{"kind":"Variable","name":{"kind":"Name","value":"first"}}},{"kind":"Argument","name":{"kind":"Name","value":"after"},"value":{"kind":"Variable","name":{"kind":"Name","value":"after"}}},{"kind":"Argument","name":{"kind":"Name","value":"where"},"value":{"kind":"Variable","name":{"kind":"Name","value":"where"}}},{"kind":"Argument","name":{"kind":"Name","value":"order"},"value":{"kind":"Variable","name":{"kind":"Name","value":"order"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cursor"}},{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"url"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"coverImage"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"newPath"}}]}},{"kind":"Field","name":{"kind":"Name","value":"rawStatus"}},{"kind":"Field","name":{"kind":"Name","value":"lastUpdatedTime"}},{"kind":"Field","name":{"kind":"Name","value":"volumes"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"chapters"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"tags"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"displayName"}},{"kind":"Field","name":{"kind":"Name","value":"tagType"}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode<NovelsQuery, NovelsQueryVariables>;
export const GetSettingsPageDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetSettingsPageData"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"currentUser"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"availableInvites"}},{"kind":"Field","name":{"kind":"Name","value":"invitedUsers"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"username"}},{"kind":"Field","name":{"kind":"Name","value":"email"}}]}}]}}]}}]} as unknown as DocumentNode<GetSettingsPageDataQuery, GetSettingsPageDataQueryVariables>;

View File

@@ -1,5 +1,5 @@
query GetChapter($novelId: UnsignedInt!, $chapterOrder: UnsignedInt!) {
chapter(novelId: $novelId, chapterOrder: $chapterOrder) {
query GetChapter($novelId: UnsignedInt!, $volumeOrder: UnsignedInt!, $chapterOrder: UnsignedInt!) {
chapter(novelId: $novelId, volumeOrder: $volumeOrder, chapterOrder: $chapterOrder) {
id
order
name
@@ -16,8 +16,13 @@ query GetChapter($novelId: UnsignedInt!, $chapterOrder: UnsignedInt!) {
novelId
novelName
totalChapters
volumeId
volumeName
volumeOrder
totalChaptersInVolume
prevChapterVolumeOrder
prevChapterOrder
nextChapterVolumeOrder
nextChapterOrder
}
}

View File

@@ -36,6 +36,12 @@ query Novel($id: UnsignedInt!) {
tagType
}
volumes {
id
order
name
createdTime
lastUpdatedTime
chapters {
id
order
@@ -49,3 +55,4 @@ query Novel($id: UnsignedInt!) {
}
}
}
}

View File

@@ -12,10 +12,15 @@ query Novels($first: Int, $after: String, $where: NovelDtoFilterInput, $order: [
}
rawStatus
lastUpdatedTime
volumes {
id
order
name
chapters {
order
name
}
}
tags {
key
displayName

View File

@@ -1,10 +0,0 @@
---
import AppLayout from '../../../../layouts/AppLayout.astro';
import ChapterReaderPage from '../../../../lib/components/ChapterReaderPage.svelte';
const { id, chapterNumber } = Astro.params;
---
<AppLayout title="Reading - FictionArchive">
<ChapterReaderPage novelId={id} chapterNumber={chapterNumber} client:load />
</AppLayout>

View File

@@ -0,0 +1,10 @@
---
import AppLayout from '../../../../../../layouts/AppLayout.astro';
import ChapterReaderPage from '../../../../../../lib/components/ChapterReaderPage.svelte';
const { id, volumeOrder, chapterNumber } = Astro.params;
---
<AppLayout title="Reading - FictionArchive">
<ChapterReaderPage novelId={id} volumeOrder={volumeOrder} chapterNumber={chapterNumber} client:load />
</AppLayout>