Compare commits

...

18 Commits

Author SHA1 Message Date
055ef33666 Merge pull request '[FA-24] Reading lists' (#60) from feature/FA-24_ReadingLists into master
All checks were successful
CI / build-backend (push) Successful in 1m4s
CI / build-frontend (push) Successful in 41s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 49s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 45s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 46s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:usernoveldata-service project:FictionArchive.Service.UserNovelDataService subgraph:UserNovelData]) (push) Successful in 44s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 2m3s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 1m53s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m39s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m42s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserNovelDataService/Dockerfile name:usernoveldata-service]) (push) Successful in 1m38s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m32s
Release / build-frontend (push) Successful in 1m38s
Build Gateway / build-gateway (push) Successful in 3m18s
Reviewed-on: #60
2026-01-20 03:09:45 +00:00
gamer147
48ee43c4f6 [FA-24] Reading lists
All checks were successful
CI / build-backend (pull_request) Successful in 1m32s
CI / build-frontend (pull_request) Successful in 42s
2026-01-19 22:06:34 -05:00
98ae4ea4f2 Merge pull request 'feature/FA-27_Bookmarks' (#59) from feature/FA-27_Bookmarks into master
All checks were successful
CI / build-backend (push) Successful in 1m16s
CI / build-frontend (push) Successful in 40s
Build Gateway / build-subgraphs (map[name:novel-service project:FictionArchive.Service.NovelService subgraph:Novel]) (push) Successful in 47s
Build Gateway / build-subgraphs (map[name:scheduler-service project:FictionArchive.Service.SchedulerService subgraph:Scheduler]) (push) Successful in 42s
Build Gateway / build-subgraphs (map[name:translation-service project:FictionArchive.Service.TranslationService subgraph:Translation]) (push) Successful in 45s
Build Gateway / build-subgraphs (map[name:user-service project:FictionArchive.Service.UserService subgraph:User]) (push) Successful in 43s
Build Gateway / build-subgraphs (map[name:usernoveldata-service project:FictionArchive.Service.UserNovelDataService subgraph:UserNovelData]) (push) Successful in 43s
Release / build-and-push (map[dockerfile:FictionArchive.Service.AuthenticationService/Dockerfile name:authentication-service]) (push) Successful in 2m19s
Release / build-and-push (map[dockerfile:FictionArchive.Service.FileService/Dockerfile name:file-service]) (push) Successful in 2m3s
Release / build-and-push (map[dockerfile:FictionArchive.Service.NovelService/Dockerfile name:novel-service]) (push) Successful in 1m41s
Release / build-and-push (map[dockerfile:FictionArchive.Service.SchedulerService/Dockerfile name:scheduler-service]) (push) Successful in 1m37s
Release / build-and-push (map[dockerfile:FictionArchive.Service.TranslationService/Dockerfile name:translation-service]) (push) Successful in 1m48s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserNovelDataService/Dockerfile name:usernoveldata-service]) (push) Successful in 1m34s
Release / build-and-push (map[dockerfile:FictionArchive.Service.UserService/Dockerfile name:user-service]) (push) Successful in 1m33s
Release / build-frontend (push) Successful in 1m39s
Build Gateway / build-gateway (push) Successful in 3m11s
Reviewed-on: #59
2026-01-19 22:28:03 +00:00
gamer147
15e1a84f55 [FA-27] Update CICD
All checks were successful
CI / build-backend (pull_request) Successful in 1m6s
CI / build-frontend (pull_request) Successful in 41s
2026-01-19 17:03:44 -05:00
gamer147
70d4ba201a [FA-27] Fix unit test based on changes
All checks were successful
CI / build-backend (pull_request) Successful in 1m10s
CI / build-frontend (pull_request) Successful in 43s
2026-01-19 16:47:55 -05:00
gamer147
b69bcd6bf4 [FA-27] Fix user adding not using correct id
Some checks failed
CI / build-backend (pull_request) Failing after 1m2s
CI / build-frontend (pull_request) Successful in 41s
2026-01-19 16:14:49 -05:00
gamer147
c97654631b [FA-27] Still need to test events 2026-01-19 15:40:21 -05:00
gamer147
1ecfd9cc99 [FA-27] Need to test events but seems to mostly work 2026-01-19 15:13:14 -05:00
gamer147
19ae4a8089 Add .worktrees/ to .gitignore 2026-01-19 01:36:10 -05:00
gamer147
f8a45ad891 [FA-27] Bookmark implementation 2026-01-19 00:01:16 -05:00
gamer147
f67c5c610c Merge branch 'refs/heads/master' into feature/FA-27_Bookmarks 2025-12-30 11:07:36 -05:00
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
112 changed files with 16431 additions and 282 deletions

View File

@@ -28,6 +28,9 @@ jobs:
- name: user-service - name: user-service
project: FictionArchive.Service.UserService project: FictionArchive.Service.UserService
subgraph: User subgraph: User
- name: usernoveldata-service
project: FictionArchive.Service.UserNovelDataService
subgraph: UserNovelData
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -110,6 +113,12 @@ jobs:
name: user-service-subgraph name: user-service-subgraph
path: subgraphs/user path: subgraphs/user
- name: Download UserNovelData Service subgraph
uses: christopherhx/gitea-download-artifact@v4
with:
name: usernoveldata-service-subgraph
path: subgraphs/usernoveldata
- name: Configure subgraph URLs for Docker - name: Configure subgraph URLs for Docker
run: | run: |
for fsp in subgraphs/*/*.fsp; do for fsp in subgraphs/*/*.fsp; do

View File

@@ -27,6 +27,8 @@ jobs:
dockerfile: FictionArchive.Service.SchedulerService/Dockerfile dockerfile: FictionArchive.Service.SchedulerService/Dockerfile
- name: authentication-service - name: authentication-service
dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile dockerfile: FictionArchive.Service.AuthenticationService/Dockerfile
- name: usernoveldata-service
dockerfile: FictionArchive.Service.UserNovelDataService/Dockerfile
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

3
.gitignore vendored
View File

@@ -140,3 +140,6 @@ appsettings.Local.json
schema.graphql schema.graphql
*.fsp *.fsp
gateway.fgp gateway.fgp
# Git worktrees
.worktrees/

File diff suppressed because it is too large Load Diff

View File

@@ -42,6 +42,13 @@ public class NovelUpdateServiceTests
Images = new List<Image>() 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 var novel = new Novel
{ {
Url = "http://demo/novel", Url = "http://demo/novel",
@@ -52,14 +59,14 @@ public class NovelUpdateServiceTests
Source = source, Source = source,
Name = LocalizationKey.CreateFromText("Demo Novel", Language.En), Name = LocalizationKey.CreateFromText("Demo Novel", Language.En),
Description = LocalizationKey.CreateFromText("Description", Language.En), Description = LocalizationKey.CreateFromText("Description", Language.En),
Chapters = new List<Chapter> { chapter }, Volumes = new List<Volume> { volume },
Tags = new List<NovelTag>() Tags = new List<NovelTag>()
}; };
dbContext.Novels.Add(novel); dbContext.Novels.Add(novel);
dbContext.SaveChanges(); dbContext.SaveChanges();
return new NovelCreateResult(novel, chapter); return new NovelCreateResult(novel, volume, chapter);
} }
private static NovelUpdateService CreateService( private static NovelUpdateService CreateService(
@@ -81,7 +88,7 @@ public class NovelUpdateServiceTests
{ {
using var dbContext = CreateDbContext(); using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; 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 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 } }; 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 pendingImageUrl = "https://pending/placeholder.jpg";
var service = CreateService(dbContext, adapter, eventBus, pendingImageUrl); 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.Should().HaveCount(2);
updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url }); updatedChapter.Images.Select(i => i.OriginalPath).Should().BeEquivalentTo(new[] { image1.Url, image2.Url });
@@ -131,7 +138,7 @@ public class NovelUpdateServiceTests
{ {
using var dbContext = CreateDbContext(); using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; 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 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 } }; 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 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 storedHtml = updatedChapter.Body.Texts.Single().Text;
var doc = new HtmlDocument(); var doc = new HtmlDocument();
@@ -161,7 +168,7 @@ public class NovelUpdateServiceTests
imgNode.GetAttributeValue("src", string.Empty).Should().Be("https://pending/placeholder.jpg"); 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 #region UpdateImage Tests
@@ -199,7 +206,7 @@ public class NovelUpdateServiceTests
// Arrange // Arrange
using var dbContext = CreateDbContext(); using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; 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 var image = new Image
{ {
@@ -252,7 +259,7 @@ public class NovelUpdateServiceTests
// Arrange // Arrange
using var dbContext = CreateDbContext(); using var dbContext = CreateDbContext();
var source = new Source { Name = "Demo", Key = "demo", Url = "http://demo" }; 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 image1 = new Image { OriginalPath = "http://original/img1.jpg", Chapter = chapter };
var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter }; var image2 = new Image { OriginalPath = "http://original/img2.jpg", Chapter = chapter };

View File

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

View File

@@ -77,7 +77,19 @@ public class Query
} }
: null, : 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, Id = chapter.Id,
CreatedTime = chapter.CreatedTime, CreatedTime = chapter.CreatedTime,
@@ -104,6 +116,7 @@ public class Query
LastUpdatedTime = image.LastUpdatedTime, LastUpdatedTime = image.LastUpdatedTime,
NewPath = image.NewPath NewPath = image.NewPath
}).ToList() }).ToList()
}).ToList()
}).ToList(), }).ToList(),
Tags = novel.Tags.Select(tag => new NovelTagDto Tags = novel.Tags.Select(tag => new NovelTagDto
@@ -140,11 +153,12 @@ public class Query
public IQueryable<ChapterReaderDto> GetChapter( public IQueryable<ChapterReaderDto> GetChapter(
NovelServiceDbContext dbContext, NovelServiceDbContext dbContext,
uint novelId, uint novelId,
uint volumeOrder,
uint chapterOrder, uint chapterOrder,
Language preferredLanguage = Language.En) Language preferredLanguage = Language.En)
{ {
return dbContext.Chapters 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 .Select(chapter => new ChapterReaderDto
{ {
Id = chapter.Id, Id = chapter.Id,
@@ -176,22 +190,72 @@ public class Query
NewPath = image.NewPath NewPath = image.NewPath
}).ToList(), }).ToList(),
NovelId = chapter.Novel.Id, NovelId = chapter.Volume.Novel.Id,
NovelName = chapter.Novel.Name.Texts NovelName = chapter.Volume.Novel.Name.Texts
.Where(t => t.Language == preferredLanguage) .Where(t => t.Language == preferredLanguage)
.Select(t => t.Text) .Select(t => t.Text)
.FirstOrDefault() .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) .Where(c => c.Order < chapterOrder)
.OrderByDescending(c => c.Order) .OrderByDescending(c => c.Order)
.Select(c => (uint?)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(), .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) .Where(c => c.Order > chapterOrder)
.OrderBy(c => c.Order) .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) .Select(c => (uint?)c.Order)
.FirstOrDefault() .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") b.Property<Guid>("NameId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order") b.Property<long>("Order")
.HasColumnType("bigint"); .HasColumnType("bigint");
@@ -165,13 +162,17 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<string>("Url") b.Property<string>("Url")
.HasColumnType("text"); .HasColumnType("text");
b.Property<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("BodyId"); b.HasIndex("BodyId");
b.HasIndex("NameId"); b.HasIndex("NameId");
b.HasIndex("NovelId"); b.HasIndex("VolumeId", "Order")
.IsUnique();
b.ToTable("Chapter"); b.ToTable("Chapter");
}); });
@@ -357,6 +358,39 @@ namespace FictionArchive.Service.NovelService.Migrations
b.ToTable("TranslationEngines"); 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 => modelBuilder.Entity("NovelNovelTag", b =>
{ {
b.Property<long>("NovelsId") b.Property<long>("NovelsId")
@@ -427,9 +461,9 @@ namespace FictionArchive.Service.NovelService.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel") b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Volume", "Volume")
.WithMany("Chapters") .WithMany("Chapters")
.HasForeignKey("NovelId") .HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
@@ -437,7 +471,7 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Name"); b.Navigation("Name");
b.Navigation("Novel"); b.Navigation("Volume");
}); });
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b => modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
@@ -509,6 +543,25 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Navigation("Name"); 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 => modelBuilder.Entity("NovelNovelTag", b =>
{ {
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null) 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 => modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Volume", b =>
{ {
b.Navigation("Chapters"); b.Navigation("Chapters");
}); });

View File

@@ -12,7 +12,16 @@ public class ChapterReaderDto : BaseDto<uint>
// Navigation context // Navigation context
public uint NovelId { get; init; } public uint NovelId { get; init; }
public required string NovelName { 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 uint? PrevChapterOrder { get; init; }
public int? NextChapterVolumeOrder { get; init; }
public uint? NextChapterOrder { 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 ExternalId { get; init; }
public required string Name { get; init; } public required string Name { get; init; }
public required string Description { 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 required List<NovelTagDto> Tags { get; init; }
public ImageDto? CoverImage { 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

@@ -0,0 +1,13 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterCreatedEvent : IIntegrationEvent
{
public required uint ChapterId { get; init; }
public required uint NovelId { get; init; }
public required uint VolumeId { get; init; }
public required int VolumeOrder { get; init; }
public required uint ChapterOrder { get; init; }
public required string ChapterTitle { get; init; }
}

View File

@@ -5,5 +5,6 @@ namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class ChapterPullRequestedEvent : IIntegrationEvent public class ChapterPullRequestedEvent : IIntegrationEvent
{ {
public uint NovelId { get; set; } public uint NovelId { get; set; }
public uint ChapterNumber { get; set; } public uint VolumeId { get; set; }
public uint ChapterOrder { get; set; }
} }

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.NovelService.Models.IntegrationEvents;
public class NovelCreatedEvent : IIntegrationEvent
{
public required uint NovelId { get; init; }
public required string Title { get; init; }
public required Language OriginalLanguage { get; init; }
public required string Source { get; init; }
public required string AuthorName { get; init; }
}

View File

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

View File

@@ -21,7 +21,7 @@ public class Novel : BaseEntity<uint>
public LocalizationKey Name { get; set; } public LocalizationKey Name { get; set; }
public LocalizationKey Description { 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 List<NovelTag> Tags { get; set; }
public Image? CoverImage { 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 Language RawLanguage { get; set; }
public NovelStatus RawStatus { 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> SourceTags { get; set; }
public List<string> SystemTags { get; set; } public List<string> SystemTags { get; set; }
public SourceDescriptor SourceDescriptor { 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) 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) : FictionArchiveDbContext(options, logger)
{ {
public DbSet<Novel> Novels { get; set; } public DbSet<Novel> Novels { get; set; }
public DbSet<Volume> Volumes { get; set; }
public DbSet<Chapter> Chapters { get; set; } public DbSet<Chapter> Chapters { get; set; }
public DbSet<Source> Sources { get; set; } public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; } public DbSet<TranslationEngine> TranslationEngines { get; set; }
@@ -25,5 +26,15 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
modelBuilder.Entity<Novel>() modelBuilder.Entity<Novel>()
.HasIndex("ExternalId", "SourceId") .HasIndex("ExternalId", "SourceId")
.IsUnique(); .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(); 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( private static (Image? image, bool shouldPublishEvent) HandleCoverImage(
ImageData? newCoverData, ImageData? newCoverData,
Image? existingCoverImage) Image? existingCoverImage)
@@ -232,7 +274,7 @@ public class NovelUpdateService
metadata.SystemTags, metadata.SystemTags,
metadata.RawLanguage); metadata.RawLanguage);
var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null); var volumes = SynchronizeVolumes(metadata.Volumes, metadata.RawLanguage, null);
var novel = new Novel var novel = new Novel
{ {
@@ -243,7 +285,7 @@ public class NovelUpdateService
CoverImage = metadata.CoverImage != null CoverImage = metadata.CoverImage != null
? new Image { OriginalPath = metadata.CoverImage.Url } ? new Image { OriginalPath = metadata.CoverImage.Url }
: null, : null,
Chapters = chapters, Volumes = volumes,
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage), Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage), Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus, RawStatus = metadata.RawStatus,
@@ -289,7 +331,9 @@ public class NovelUpdateService
.Include(n => n.Description) .Include(n => n.Description)
.ThenInclude(lk => lk.Texts) .ThenInclude(lk => lk.Texts)
.Include(n => n.Tags) .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) .ThenInclude(localizationKey => localizationKey.Texts)
.Include(n => n.CoverImage) .Include(n => n.CoverImage)
.FirstOrDefaultAsync(n => .FirstOrDefaultAsync(n =>
@@ -299,6 +343,12 @@ public class NovelUpdateService
Novel novel; Novel novel;
bool shouldPublishCoverEvent; bool shouldPublishCoverEvent;
// Capture existing chapter IDs to detect new chapters later
var existingChapterIds = existingNovel?.Volumes
.SelectMany(v => v.Chapters)
.Select(c => c.Id)
.ToHashSet() ?? new HashSet<uint>();
if (existingNovel == null) if (existingNovel == null)
{ {
// CREATE PATH: New novel // CREATE PATH: New novel
@@ -326,11 +376,11 @@ public class NovelUpdateService
metadata.SystemTags, metadata.SystemTags,
metadata.RawLanguage); metadata.RawLanguage);
// Synchronize chapters (add only) // Synchronize volumes (and their chapters)
novel.Chapters = SynchronizeChapters( novel.Volumes = SynchronizeVolumes(
metadata.Chapters, metadata.Volumes,
metadata.RawLanguage, metadata.RawLanguage,
existingNovel.Chapters); existingNovel.Volumes);
// Handle cover image // Handle cover image
(novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage( (novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage(
@@ -340,6 +390,36 @@ public class NovelUpdateService
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
// Publish novel created event for new novels
if (existingNovel == null)
{
await _eventBus.Publish(new NovelCreatedEvent
{
NovelId = novel.Id,
Title = novel.Name.Texts.First(t => t.Language == novel.RawLanguage).Text,
OriginalLanguage = novel.RawLanguage,
Source = novel.Source.Key,
AuthorName = novel.Author.Name.Texts.First(t => t.Language == novel.RawLanguage).Text
});
}
// Publish chapter created events for new chapters
foreach (var volume in novel.Volumes)
{
foreach (var chapter in volume.Chapters.Where(c => !existingChapterIds.Contains(c.Id)))
{
await _eventBus.Publish(new ChapterCreatedEvent
{
ChapterId = chapter.Id,
NovelId = novel.Id,
VolumeId = volume.Id,
VolumeOrder = volume.Order,
ChapterOrder = chapter.Order,
ChapterTitle = chapter.Name.Texts.First(t => t.Language == novel.RawLanguage).Text
});
}
}
// Publish cover image event if needed // Publish cover image event if needed
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null) if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
{ {
@@ -352,7 +432,9 @@ public class NovelUpdateService
} }
// Publish chapter pull events for chapters without body content // 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()) .Where(c => c.Body?.Texts == null || !c.Body.Texts.Any())
.ToList(); .ToList();
@@ -361,22 +443,29 @@ public class NovelUpdateService
await _eventBus.Publish(new ChapterPullRequestedEvent await _eventBus.Publish(new ChapterPullRequestedEvent
{ {
NovelId = novel.Id, NovelId = novel.Id,
ChapterNumber = chapter.Order VolumeId = volume.Id,
ChapterOrder = chapter.Order
}); });
} }
}
return novel; 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) 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(chapter => chapter.Body)
.ThenInclude(body => body.Texts) .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(); .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 adapter = _sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url); var rawChapter = await adapter.GetRawChapter(chapter.Url);
@@ -478,12 +567,13 @@ public class NovelUpdateService
return importNovelRequestEvent; 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() var chapterPullEvent = new ChapterPullRequestedEvent()
{ {
NovelId = novelId, NovelId = novelId,
ChapterNumber = chapterNumber VolumeId = volumeId,
ChapterOrder = chapterOrder
}; };
await _eventBus.Publish(chapterPullEvent); await _eventBus.Publish(chapterPullEvent);
return chapterPullEvent; return chapterPullEvent;
@@ -495,9 +585,10 @@ public class NovelUpdateService
.Include(n => n.CoverImage) .Include(n => n.CoverImage)
.Include(n => n.Name).ThenInclude(k => k.Texts) .Include(n => n.Name).ThenInclude(k => k.Texts)
.Include(n => n.Description).ThenInclude(k => k.Texts) .Include(n => n.Description).ThenInclude(k => k.Texts)
.Include(n => n.Chapters).ThenInclude(c => c.Images) .Include(n => n.Volumes).ThenInclude(v => v.Name).ThenInclude(k => k.Texts)
.Include(n => n.Chapters).ThenInclude(c => c.Name).ThenInclude(k => k.Texts) .Include(n => n.Volumes).ThenInclude(v => v.Chapters).ThenInclude(c => c.Images)
.Include(n => n.Chapters).ThenInclude(c => c.Body).ThenInclude(k => k.Texts) .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); .FirstOrDefaultAsync(n => n.Id == novelId);
if (novel == null) if (novel == null)
@@ -505,8 +596,12 @@ public class NovelUpdateService
// Collect all LocalizationKey IDs for cleanup // Collect all LocalizationKey IDs for cleanup
var locKeyIds = new List<Guid> { novel.Name.Id, novel.Description.Id }; var locKeyIds = new List<Guid> { novel.Name.Id, novel.Description.Id };
locKeyIds.AddRange(novel.Chapters.Select(c => c.Name.Id)); foreach (var volume in novel.Volumes)
locKeyIds.AddRange(novel.Chapters.Select(c => c.Body.Id)); {
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 // 1. Remove LocalizationRequests referencing these keys
var locRequests = await _dbContext.LocalizationRequests var locRequests = await _dbContext.LocalizationRequests
@@ -517,19 +612,26 @@ public class NovelUpdateService
// 2. Remove LocalizationTexts (NO ACTION FK - won't cascade) // 2. Remove LocalizationTexts (NO ACTION FK - won't cascade)
_dbContext.RemoveRange(novel.Name.Texts); _dbContext.RemoveRange(novel.Name.Texts);
_dbContext.RemoveRange(novel.Description.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.Name.Texts);
_dbContext.RemoveRange(chapter.Body.Texts); _dbContext.RemoveRange(chapter.Body.Texts);
} }
}
// 3. Remove Images (NO ACTION FK - won't cascade) // 3. Remove Images (NO ACTION FK - won't cascade)
if (novel.CoverImage != null) if (novel.CoverImage != null)
_dbContext.Images.Remove(novel.CoverImage); _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); _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); _dbContext.Novels.Remove(novel);
await _dbContext.SaveChangesAsync(); await _dbContext.SaveChangesAsync();
} }

View File

@@ -66,7 +66,7 @@ public class NovelpiaAdapter : ISourceAdapter
ExternalId = novelId.ToString(), ExternalId = novelId.ToString(),
SystemTags = new List<string>(), SystemTags = new List<string>(),
SourceTags = new List<string>(), SourceTags = new List<string>(),
Chapters = new List<ChapterMetadata>(), Volumes = new List<VolumeMetadata>(),
SourceDescriptor = SourceDescriptor SourceDescriptor = SourceDescriptor
}; };
@@ -133,6 +133,9 @@ public class NovelpiaAdapter : ISourceAdapter
novel.SourceTags.Add(tag); novel.SourceTags.Add(tag);
} }
// Author's posts (from notice_table in the page HTML)
var authorsPosts = ParseAuthorsPosts(novelData);
// Chapters // Chapters
uint page = 0; uint page = 0;
List<ChapterMetadata> chapters = new List<ChapterMetadata>(); List<ChapterMetadata> chapters = new List<ChapterMetadata>();
@@ -168,7 +171,25 @@ public class NovelpiaAdapter : ISourceAdapter
} }
page++; 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; return novel;
} }
@@ -241,4 +262,40 @@ public class NovelpiaAdapter : ISourceAdapter
} }
return await image.Content.ReadAsByteArrayAsync(); 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

@@ -7,6 +7,13 @@
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="..\.dockerignore"> <Content Include="..\.dockerignore">
<Link>.dockerignore</Link> <Link>.dockerignore</Link>

View File

@@ -1,6 +1,498 @@
using System.Security.Claims;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.DTOs;
using FictionArchive.Service.UserNovelDataService.Services;
using HotChocolate.Authorization;
using HotChocolate.Types;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.GraphQL; namespace FictionArchive.Service.UserNovelDataService.GraphQL;
public class Mutation public class Mutation
{ {
[Authorize]
[Error<InvalidOperationException>]
public async Task<BookmarkPayload> UpsertBookmark(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
UpsertBookmarkInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
// Auto-create user if not exists
user = new User { OAuthProviderId = oAuthProviderId };
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync();
}
var existingBookmark = await dbContext.Bookmarks
.FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == input.ChapterId);
if (existingBookmark != null)
{
// Update existing
existingBookmark.Description = input.Description;
}
else
{
// Create new
existingBookmark = new Bookmark
{
UserId = user.Id,
NovelId = input.NovelId,
ChapterId = input.ChapterId,
Description = input.Description
};
dbContext.Bookmarks.Add(existingBookmark);
}
await dbContext.SaveChangesAsync();
return new BookmarkPayload
{
Success = true,
Bookmark = new BookmarkDto
{
Id = existingBookmark.Id,
ChapterId = existingBookmark.ChapterId,
NovelId = existingBookmark.NovelId,
Description = existingBookmark.Description,
CreatedTime = existingBookmark.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<BookmarkPayload> RemoveBookmark(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
uint chapterId)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new BookmarkPayload { Success = false };
}
var bookmark = await dbContext.Bookmarks
.FirstOrDefaultAsync(b => b.UserId == user.Id && b.ChapterId == chapterId);
if (bookmark == null)
{
return new BookmarkPayload { Success = false };
}
dbContext.Bookmarks.Remove(bookmark);
await dbContext.SaveChangesAsync();
return new BookmarkPayload { Success = true };
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> CreateReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
CreateReadingListInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
if (string.IsNullOrWhiteSpace(input.Name))
{
throw new InvalidOperationException("Reading list name is required");
}
var user = await dbContext.Users
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
user = new User { OAuthProviderId = oAuthProviderId };
dbContext.Users.Add(user);
await dbContext.SaveChangesAsync();
}
var readingList = new ReadingList
{
UserId = user.Id,
Name = input.Name.Trim(),
Description = input.Description?.Trim()
};
dbContext.ReadingLists.Add(readingList);
await dbContext.SaveChangesAsync();
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = [],
ItemCount = 0,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> UpdateReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
UpdateReadingListInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
if (string.IsNullOrWhiteSpace(input.Name))
{
throw new InvalidOperationException("Reading list name is required");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == input.Id && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
readingList.Name = input.Name.Trim();
readingList.Description = input.Description?.Trim();
await dbContext.SaveChangesAsync();
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<DeleteReadingListPayload> DeleteReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
int id)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new DeleteReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id);
if (readingList == null)
{
return new DeleteReadingListPayload { Success = false };
}
dbContext.ReadingLists.Remove(readingList);
await dbContext.SaveChangesAsync();
return new DeleteReadingListPayload { Success = true };
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> AddToReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
AddToReadingListInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
// Idempotent: if already in list, return success
var existingItem = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId);
if (existingItem != null)
{
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
// Add at the end (highest order + 1)
var maxOrder = readingList.Items.Any() ? readingList.Items.Max(i => i.Order) : -1;
var newItem = new ReadingListItem
{
ReadingListId = readingList.Id,
NovelId = input.NovelId,
Order = maxOrder + 1
};
dbContext.ReadingListItems.Add(newItem);
await dbContext.SaveChangesAsync();
// Reload to get updated items
readingList = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items.OrderBy(i => i.Order))
.FirstAsync(r => r.Id == input.ReadingListId);
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> RemoveFromReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
int listId,
uint novelId)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == listId && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
var item = readingList.Items.FirstOrDefault(i => i.NovelId == novelId);
if (item != null)
{
dbContext.ReadingListItems.Remove(item);
await dbContext.SaveChangesAsync();
}
// Reload to get updated items
readingList = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items.OrderBy(i => i.Order))
.FirstAsync(r => r.Id == listId);
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
[Authorize]
[Error<InvalidOperationException>]
public async Task<ReadingListPayload> ReorderReadingListItem(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
ReorderReadingListItemInput input)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
throw new InvalidOperationException("Unable to determine current user identity");
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new ReadingListPayload { Success = false };
}
var readingList = await dbContext.ReadingLists
.Include(r => r.Items)
.FirstOrDefaultAsync(r => r.Id == input.ReadingListId && r.UserId == user.Id);
if (readingList == null)
{
return new ReadingListPayload { Success = false };
}
var item = readingList.Items.FirstOrDefault(i => i.NovelId == input.NovelId);
if (item == null)
{
throw new InvalidOperationException("Novel not found in reading list");
}
var oldOrder = item.Order;
var newOrder = input.NewOrder;
// Shift other items
if (newOrder < oldOrder)
{
// Moving up: shift items between newOrder and oldOrder down
foreach (var i in readingList.Items.Where(x => x.Order >= newOrder && x.Order < oldOrder))
{
i.Order++;
}
}
else if (newOrder > oldOrder)
{
// Moving down: shift items between oldOrder and newOrder up
foreach (var i in readingList.Items.Where(x => x.Order > oldOrder && x.Order <= newOrder))
{
i.Order--;
}
}
item.Order = newOrder;
await dbContext.SaveChangesAsync();
return new ReadingListPayload
{
Success = true,
ReadingList = new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
Items = readingList.Items.OrderBy(i => i.Order).Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
ItemCount = readingList.Items.Count,
CreatedTime = readingList.CreatedTime
}
};
}
} }

View File

@@ -1,6 +1,136 @@
using System.Security.Claims;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.DTOs;
using FictionArchive.Service.UserNovelDataService.Services;
using HotChocolate.Authorization;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.GraphQL; namespace FictionArchive.Service.UserNovelDataService.GraphQL;
public class Query public class Query
{ {
[Authorize]
public async Task<IQueryable<BookmarkDto>> GetBookmarks(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
uint novelId)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
return new List<BookmarkDto>().AsQueryable();
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return new List<BookmarkDto>().AsQueryable();
}
return dbContext.Bookmarks
.AsNoTracking()
.Where(b => b.UserId == user.Id && b.NovelId == novelId)
.OrderByDescending(b => b.CreatedTime)
.Select(b => new BookmarkDto
{
Id = b.Id,
ChapterId = b.ChapterId,
NovelId = b.NovelId,
Description = b.Description,
CreatedTime = b.CreatedTime
});
}
[Authorize]
public async Task<IEnumerable<ReadingListDto>> GetReadingLists(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
return [];
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return [];
}
var lists = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items)
.Where(r => r.UserId == user.Id)
.OrderByDescending(r => r.LastUpdatedTime)
.ToListAsync();
return lists.Select(r => new ReadingListDto
{
Id = r.Id,
Name = r.Name,
Description = r.Description,
ItemCount = r.Items.Count,
Items = r.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
CreatedTime = r.CreatedTime
});
}
[Authorize]
public async Task<ReadingListDto?> GetReadingList(
UserNovelDataServiceDbContext dbContext,
ClaimsPrincipal claimsPrincipal,
int id)
{
var oAuthProviderId = claimsPrincipal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
if (string.IsNullOrEmpty(oAuthProviderId))
{
return null;
}
var user = await dbContext.Users
.AsNoTracking()
.FirstOrDefaultAsync(u => u.OAuthProviderId == oAuthProviderId);
if (user == null)
{
return null;
}
var readingList = await dbContext.ReadingLists
.AsNoTracking()
.Include(r => r.Items.OrderBy(i => i.Order))
.FirstOrDefaultAsync(r => r.Id == id && r.UserId == user.Id);
if (readingList == null)
{
return null;
}
return new ReadingListDto
{
Id = readingList.Id,
Name = readingList.Name,
Description = readingList.Description,
ItemCount = readingList.Items.Count,
Items = readingList.Items.Select(i => new ReadingListItemDto
{
NovelId = i.NovelId,
Order = i.Order,
AddedTime = i.CreatedTime
}),
CreatedTime = readingList.CreatedTime
};
}
} }

View File

@@ -0,0 +1,99 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
[Migration("20251230181559_AddBookmarks")]
partial class AddBookmarks
{
/// <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.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", 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.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,76 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
/// <inheritdoc />
public partial class AddBookmarks : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Users",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
OAuthProviderId = table.Column<string>(type: "text", 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_Users", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Bookmarks",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
ChapterId = table.Column<long>(type: "bigint", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
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_Bookmarks", x => x.Id);
table.ForeignKey(
name: "FK_Bookmarks_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_UserId_ChapterId",
table: "Bookmarks",
columns: new[] { "UserId", "ChapterId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_Bookmarks_UserId_NovelId",
table: "Bookmarks",
columns: new[] { "UserId", "NovelId" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Bookmarks");
migrationBuilder.DropTable(
name: "Users");
}
}
}

View File

@@ -0,0 +1,198 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
[Migration("20260119184741_AddNovelVolumeChapter")]
partial class AddNovelVolumeChapter
{
/// <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.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", 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<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapters");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", 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.HasKey("Id");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", 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.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.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<long>("NovelId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NovelId");
b.ToTable("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
.WithMany("Volumes")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,95 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
/// <inheritdoc />
public partial class AddNovelVolumeChapter : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Novels",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
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_Novels", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Volumes",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
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_Volumes", x => x.Id);
table.ForeignKey(
name: "FK_Volumes_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Chapters",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
VolumeId = 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_Chapters", x => x.Id);
table.ForeignKey(
name: "FK_Chapters_Volumes_VolumeId",
column: x => x.VolumeId,
principalTable: "Volumes",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Chapters_VolumeId",
table: "Chapters",
column: "VolumeId");
migrationBuilder.CreateIndex(
name: "IX_Volumes_NovelId",
table: "Volumes",
column: "NovelId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Chapters");
migrationBuilder.DropTable(
name: "Volumes");
migrationBuilder.DropTable(
name: "Novels");
}
}
}

View File

@@ -0,0 +1,289 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.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.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
[Migration("20260120014840_AddReadingLists")]
partial class AddReadingLists
{
/// <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.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", 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<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapters");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", 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.HasKey("Id");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ReadingLists");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<int>("ReadingListId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ReadingListId", "NovelId")
.IsUnique();
b.HasIndex("ReadingListId", "Order");
b.ToTable("ReadingListItems");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", 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.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.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<long>("NovelId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NovelId");
b.ToTable("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList")
.WithMany("Items")
.HasForeignKey("ReadingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ReadingList");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
.WithMany("Volumes")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
/// <inheritdoc />
public partial class AddReadingLists : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "ReadingLists",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Name = table.Column<string>(type: "text", nullable: false),
Description = table.Column<string>(type: "text", nullable: true),
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_ReadingLists", x => x.Id);
table.ForeignKey(
name: "FK_ReadingLists_Users_UserId",
column: x => x.UserId,
principalTable: "Users",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ReadingListItems",
columns: table => new
{
Id = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
ReadingListId = table.Column<int>(type: "integer", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: false),
Order = table.Column<int>(type: "integer", 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_ReadingListItems", x => x.Id);
table.ForeignKey(
name: "FK_ReadingListItems_ReadingLists_ReadingListId",
column: x => x.ReadingListId,
principalTable: "ReadingLists",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ReadingListItems_ReadingListId_NovelId",
table: "ReadingListItems",
columns: new[] { "ReadingListId", "NovelId" },
unique: true);
migrationBuilder.CreateIndex(
name: "IX_ReadingListItems_ReadingListId_Order",
table: "ReadingListItems",
columns: new[] { "ReadingListId", "Order" });
migrationBuilder.CreateIndex(
name: "IX_ReadingLists_UserId",
table: "ReadingLists",
column: "UserId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ReadingListItems");
migrationBuilder.DropTable(
name: "ReadingLists");
}
}
}

View File

@@ -0,0 +1,286 @@
// <auto-generated />
using System;
using FictionArchive.Service.UserNovelDataService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NodaTime;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.UserNovelDataService.Migrations
{
[DbContext(typeof(UserNovelDataServiceDbContext))]
partial class UserNovelDataServiceDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.11")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<long>("ChapterId")
.HasColumnType("bigint");
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "ChapterId")
.IsUnique();
b.HasIndex("UserId", "NovelId");
b.ToTable("Bookmarks");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", 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<long>("VolumeId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("VolumeId");
b.ToTable("Chapters");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", 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.HasKey("Id");
b.ToTable("Novels");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("ReadingLists");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<Instant>("CreatedTime")
.HasColumnType("timestamp with time zone");
b.Property<Instant>("LastUpdatedTime")
.HasColumnType("timestamp with time zone");
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<int>("Order")
.HasColumnType("integer");
b.Property<int>("ReadingListId")
.HasColumnType("integer");
b.HasKey("Id");
b.HasIndex("ReadingListId", "NovelId")
.IsUnique();
b.HasIndex("ReadingListId", "Order");
b.ToTable("ReadingListItems");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.User", 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.Property<string>("OAuthProviderId")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Users");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.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<long>("NovelId")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("NovelId");
b.ToTable("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Bookmark", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Chapter", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", "Volume")
.WithMany("Chapters")
.HasForeignKey("VolumeId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Volume");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingListItem", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", "ReadingList")
.WithMany("Items")
.HasForeignKey("ReadingListId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ReadingList");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.HasOne("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", "Novel")
.WithMany("Volumes")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Novel", b =>
{
b.Navigation("Volumes");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.ReadingList", b =>
{
b.Navigation("Items");
});
modelBuilder.Entity("FictionArchive.Service.UserNovelDataService.Models.Database.Volume", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record AddToReadingListInput(int ReadingListId, uint NovelId);

View File

@@ -0,0 +1,12 @@
using NodaTime;
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class BookmarkDto
{
public int Id { get; init; }
public uint ChapterId { get; init; }
public uint NovelId { get; init; }
public string? Description { get; init; }
public Instant CreatedTime { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class BookmarkPayload
{
public BookmarkDto? Bookmark { get; init; }
public bool Success { get; init; }
}

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record CreateReadingListInput(string Name, string? Description);

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class DeleteReadingListPayload
{
public bool Success { get; init; }
}

View File

@@ -0,0 +1,13 @@
using NodaTime;
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class ReadingListDto
{
public int Id { get; init; }
public required string Name { get; init; }
public string? Description { get; init; }
public IEnumerable<ReadingListItemDto> Items { get; init; } = [];
public int ItemCount { get; init; }
public Instant CreatedTime { get; init; }
}

View File

@@ -0,0 +1,10 @@
using NodaTime;
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class ReadingListItemDto
{
public uint NovelId { get; init; }
public int Order { get; init; }
public Instant AddedTime { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public class ReadingListPayload
{
public ReadingListDto? ReadingList { get; init; }
public bool Success { get; init; }
}

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record ReorderReadingListItemInput(int ReadingListId, uint NovelId, int NewOrder);

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record UpdateReadingListInput(int Id, string Name, string? Description);

View File

@@ -0,0 +1,3 @@
namespace FictionArchive.Service.UserNovelDataService.Models.DTOs;
public record UpsertBookmarkInput(uint NovelId, uint ChapterId, string? Description);

View File

@@ -0,0 +1,14 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Bookmark : BaseEntity<int>
{
public Guid UserId { get; set; }
public virtual User User { get; set; } = null!;
public uint ChapterId { get; set; }
public uint NovelId { get; set; }
public string? Description { get; set; }
}

View File

@@ -0,0 +1,9 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Chapter : BaseEntity<uint>
{
public uint VolumeId { get; set; }
public virtual Volume Volume { get; set; } = null!;
}

View File

@@ -0,0 +1,8 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Novel : BaseEntity<uint>
{
public virtual ICollection<Volume> Volumes { get; set; } = new List<Volume>();
}

View File

@@ -0,0 +1,14 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class ReadingList : BaseEntity<int>
{
public Guid UserId { get; set; }
public virtual User User { get; set; } = null!;
public required string Name { get; set; }
public string? Description { get; set; }
public virtual ICollection<ReadingListItem> Items { get; set; } = new List<ReadingListItem>();
}

View File

@@ -0,0 +1,12 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class ReadingListItem : BaseEntity<int>
{
public int ReadingListId { get; set; }
public virtual ReadingList ReadingList { get; set; } = null!;
public uint NovelId { get; set; }
public int Order { get; set; }
}

View File

@@ -0,0 +1,8 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class User : BaseEntity<Guid>
{
public required string OAuthProviderId { get; set; }
}

View File

@@ -0,0 +1,10 @@
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.UserNovelDataService.Models.Database;
public class Volume : BaseEntity<uint>
{
public uint NovelId { get; set; }
public virtual Novel Novel { get; set; } = null!;
public virtual ICollection<Chapter> Chapters { get; set; } = new List<Chapter>();
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class ChapterCreatedEvent : IIntegrationEvent
{
public required uint ChapterId { get; init; }
public required uint NovelId { get; init; }
public required uint VolumeId { get; init; }
public required int VolumeOrder { get; init; }
public required uint ChapterOrder { get; init; }
public required string ChapterTitle { get; init; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class NovelCreatedEvent : IIntegrationEvent
{
public required uint NovelId { get; init; }
public required string Title { get; init; }
public required Language OriginalLanguage { get; init; }
public required string Source { get; init; }
public required string AuthorName { get; init; }
}

View File

@@ -0,0 +1,15 @@
using FictionArchive.Service.Shared.Services.EventBus;
namespace FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
public class UserInvitedEvent : IIntegrationEvent
{
public Guid InvitedUserId { get; set; }
public required string InvitedUsername { get; set; }
public required string InvitedEmail { get; set; }
public required string InvitedOAuthProviderId { get; set; }
public Guid InviterId { get; set; }
public required string InviterUsername { get; set; }
public required string InviterOAuthProviderId { get; set; }
}

View File

@@ -3,7 +3,9 @@ using FictionArchive.Service.Shared;
using FictionArchive.Service.Shared.Extensions; using FictionArchive.Service.Shared.Extensions;
using FictionArchive.Service.Shared.Services.EventBus.Implementations; using FictionArchive.Service.Shared.Services.EventBus.Implementations;
using FictionArchive.Service.UserNovelDataService.GraphQL; using FictionArchive.Service.UserNovelDataService.GraphQL;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using FictionArchive.Service.UserNovelDataService.Services; using FictionArchive.Service.UserNovelDataService.Services;
using FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
namespace FictionArchive.Service.UserNovelDataService; namespace FictionArchive.Service.UserNovelDataService;
@@ -27,7 +29,10 @@ public class Program
builder.Services.AddRabbitMQ(opt => builder.Services.AddRabbitMQ(opt =>
{ {
builder.Configuration.GetSection("RabbitMQ").Bind(opt); builder.Configuration.GetSection("RabbitMQ").Bind(opt);
}); })
.Subscribe<NovelCreatedEvent, NovelCreatedEventHandler>()
.Subscribe<ChapterCreatedEvent, ChapterCreatedEventHandler>()
.Subscribe<UserInvitedEvent, UserInvitedEventHandler>();
} }
#endregion #endregion

View File

@@ -0,0 +1,93 @@
# UserNovelDataService Backfill Scripts
SQL scripts for backfilling data from UserService and NovelService into UserNovelDataService.
## Prerequisites
1. **Run EF migrations** on the UserNovelDataService database to ensure all tables exist:
```bash
dotnet ef database update --project FictionArchive.Service.UserNovelDataService
```
This will apply the `AddNovelVolumeChapter` migration which creates:
- `Novels` table (Id, CreatedTime, LastUpdatedTime)
- `Volumes` table (Id, NovelId FK, CreatedTime, LastUpdatedTime)
- `Chapters` table (Id, VolumeId FK, CreatedTime, LastUpdatedTime)
## Execution Order
Run scripts in numeric order:
### Extraction (run against source databases)
1. `01_extract_users_from_userservice.sql` - Run against **UserService** DB
2. `02_extract_novels_from_novelservice.sql` - Run against **NovelService** DB
3. `03_extract_volumes_from_novelservice.sql` - Run against **NovelService** DB
4. `04_extract_chapters_from_novelservice.sql` - Run against **NovelService** DB
### Insertion (run against UserNovelDataService database)
5. `05_insert_users_to_usernoveldataservice.sql`
6. `06_insert_novels_to_usernoveldataservice.sql`
7. `07_insert_volumes_to_usernoveldataservice.sql`
8. `08_insert_chapters_to_usernoveldataservice.sql`
## Methods
Each script provides three options:
1. **SELECT for review** - Review data before export
2. **Generate INSERT statements** - Creates individual INSERT statements (good for small datasets)
3. **CSV export/import** - Use PostgreSQL `\copy` for bulk operations (recommended for large datasets)
## Example Workflow
### Using CSV Export/Import (Recommended)
```bash
# 1. Export from source databases
psql -h localhost -U postgres -d userservice -c "\copy (SELECT \"Id\", \"OAuthProviderId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Users\" WHERE \"Disabled\" = false) TO '/tmp/users_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Novels\") TO '/tmp/novels_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"NovelId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Volume\" ORDER BY \"NovelId\", \"Id\") TO '/tmp/volumes_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d novelservice -c "\copy (SELECT \"Id\", \"VolumeId\", \"CreatedTime\", \"LastUpdatedTime\" FROM \"Chapter\" ORDER BY \"VolumeId\", \"Id\") TO '/tmp/chapters_export.csv' WITH CSV HEADER"
# 2. Import into UserNovelDataService (order matters due to FK constraints!)
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Users\" (\"Id\", \"OAuthProviderId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/users_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Novels\" (\"Id\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/novels_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Volumes\" (\"Id\", \"NovelId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/volumes_export.csv' WITH CSV HEADER"
psql -h localhost -U postgres -d usernoveldataservice -c "\copy \"Chapters\" (\"Id\", \"VolumeId\", \"CreatedTime\", \"LastUpdatedTime\") FROM '/tmp/chapters_export.csv' WITH CSV HEADER"
```
**Important**: Insert order matters due to foreign key constraints:
1. Users (no dependencies)
2. Novels (no dependencies)
3. Volumes (depends on Novels)
4. Chapters (depends on Volumes)
### Using dblink (Cross-database queries)
If both databases are on the same PostgreSQL server, you can use `dblink` extension for direct cross-database inserts. See the commented examples in each insert script.
## Verification
After running the backfill, verify counts match:
```sql
-- Run on UserService DB
SELECT COUNT(*) as user_count FROM "Users" WHERE "Disabled" = false;
-- Run on NovelService DB
SELECT COUNT(*) as novel_count FROM "Novels";
SELECT COUNT(*) as volume_count FROM "Volume";
SELECT COUNT(*) as chapter_count FROM "Chapter";
-- Run on UserNovelDataService DB
SELECT COUNT(*) as user_count FROM "Users";
SELECT COUNT(*) as novel_count FROM "Novels";
SELECT COUNT(*) as volume_count FROM "Volumes";
SELECT COUNT(*) as chapter_count FROM "Chapters";
```

View File

@@ -0,0 +1,28 @@
-- Extract Users from UserService database
-- Run this against: UserService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"OAuthProviderId",
"CreatedTime",
"LastUpdatedTime"
FROM "Users"
WHERE "Disabled" = false
ORDER BY "CreatedTime";
-- Option 2: Generate INSERT statements (useful for small datasets)
SELECT format(
'INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") VALUES (%L, %L, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"OAuthProviderId",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Users"
WHERE "Disabled" = false
ORDER BY "CreatedTime";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime" FROM "Users" WHERE "Disabled" = false ORDER BY "CreatedTime") TO '/tmp/users_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,24 @@
-- Extract Novels from NovelService database
-- Run this against: NovelService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"CreatedTime",
"LastUpdatedTime"
FROM "Novels"
ORDER BY "Id";
-- Option 2: Generate INSERT statements
SELECT format(
'INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") VALUES (%s, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Novels"
ORDER BY "Id";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "CreatedTime", "LastUpdatedTime" FROM "Novels" ORDER BY "Id") TO '/tmp/novels_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,26 @@
-- Extract Volumes from NovelService database
-- Run this against: NovelService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"NovelId",
"CreatedTime",
"LastUpdatedTime"
FROM "Volume"
ORDER BY "NovelId", "Id";
-- Option 2: Generate INSERT statements
SELECT format(
'INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") VALUES (%s, %s, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"NovelId",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Volume"
ORDER BY "NovelId", "Id";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "NovelId", "CreatedTime", "LastUpdatedTime" FROM "Volume" ORDER BY "NovelId", "Id") TO '/tmp/volumes_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,26 @@
-- Extract Chapters from NovelService database
-- Run this against: NovelService PostgreSQL database
-- Output: CSV or use COPY TO for bulk export
-- Option 1: Simple SELECT for review/testing
SELECT
"Id",
"VolumeId",
"CreatedTime",
"LastUpdatedTime"
FROM "Chapter"
ORDER BY "VolumeId", "Id";
-- Option 2: Generate INSERT statements
SELECT format(
'INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") VALUES (%s, %s, %L, %L) ON CONFLICT ("Id") DO NOTHING;',
"Id",
"VolumeId",
"CreatedTime",
"LastUpdatedTime"
)
FROM "Chapter"
ORDER BY "VolumeId", "Id";
-- Option 3: Export to CSV (run from psql)
-- \copy (SELECT "Id", "VolumeId", "CreatedTime", "LastUpdatedTime" FROM "Chapter" ORDER BY "VolumeId", "Id") TO '/tmp/chapters_export.csv' WITH CSV HEADER;

View File

@@ -0,0 +1,32 @@
-- Insert Users into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE: You must have extracted users from UserService first
-- using 01_extract_users_from_userservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/users_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::uuid,
"OAuthProviderId",
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=userservice user=postgres password=yourpassword',
'SELECT "Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime" FROM "Users" WHERE "Disabled" = false'
) AS t("Id" uuid, "OAuthProviderId" text, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"OAuthProviderId" = EXCLUDED."OAuthProviderId",
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Users" ("Id", "OAuthProviderId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,31 @@
-- Insert Novels into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE:
-- 1. Ensure the Novels table exists (run EF migrations first if needed)
-- 2. Extract novels from NovelService using 02_extract_novels_from_novelservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Novels" ("Id", "CreatedTime", "LastUpdatedTime") FROM '/tmp/novels_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::bigint,
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword',
'SELECT "Id", "CreatedTime", "LastUpdatedTime" FROM "Novels"'
) AS t("Id" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Novels" ("Id", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,34 @@
-- Insert Volumes into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE:
-- 1. Ensure the Volumes table exists (run EF migrations first if needed)
-- 2. Novels must be inserted first (FK constraint)
-- 3. Extract volumes from NovelService using 03_extract_volumes_from_novelservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/volumes_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::bigint,
"NovelId"::bigint,
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword',
'SELECT "Id", "NovelId", "CreatedTime", "LastUpdatedTime" FROM "Volume"'
) AS t("Id" bigint, "NovelId" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"NovelId" = EXCLUDED."NovelId",
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Volumes" ("Id", "NovelId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,34 @@
-- Insert Chapters into UserNovelDataService database
-- Run this against: UserNovelDataService PostgreSQL database
--
-- PREREQUISITE:
-- 1. Ensure the Chapters table exists (run EF migrations first if needed)
-- 2. Volumes must be inserted first (FK constraint)
-- 3. Extract chapters from NovelService using 04_extract_chapters_from_novelservice.sql
-- Option 1: If you have a CSV file from export
-- \copy "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") FROM '/tmp/chapters_export.csv' WITH CSV HEADER;
-- Option 2: Direct cross-database insert using dblink
-- First, install dblink extension if not already done:
-- CREATE EXTENSION IF NOT EXISTS dblink;
-- Example using dblink (adjust connection string):
/*
INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime")
SELECT
"Id"::bigint,
"VolumeId"::bigint,
"CreatedTime"::timestamp with time zone,
"LastUpdatedTime"::timestamp with time zone
FROM dblink(
'host=localhost port=5432 dbname=novelservice user=postgres password=yourpassword',
'SELECT "Id", "VolumeId", "CreatedTime", "LastUpdatedTime" FROM "Chapter"'
) AS t("Id" bigint, "VolumeId" bigint, "CreatedTime" timestamp with time zone, "LastUpdatedTime" timestamp with time zone)
ON CONFLICT ("Id") DO UPDATE SET
"VolumeId" = EXCLUDED."VolumeId",
"LastUpdatedTime" = EXCLUDED."LastUpdatedTime";
*/
-- Option 3: Paste generated INSERT statements from extraction script here
-- INSERT INTO "Chapters" ("Id", "VolumeId", "CreatedTime", "LastUpdatedTime") VALUES (...) ON CONFLICT ("Id") DO NOTHING;

View File

@@ -0,0 +1,53 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class ChapterCreatedEventHandler : IIntegrationEventHandler<ChapterCreatedEvent>
{
private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<ChapterCreatedEventHandler> _logger;
public ChapterCreatedEventHandler(
UserNovelDataServiceDbContext dbContext,
ILogger<ChapterCreatedEventHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Handle(ChapterCreatedEvent @event)
{
// Ensure novel exists
var novelExists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId);
if (!novelExists)
{
var novel = new Novel { Id = @event.NovelId };
_dbContext.Novels.Add(novel);
}
// Ensure volume exists
var volumeExists = await _dbContext.Volumes.AnyAsync(v => v.Id == @event.VolumeId);
if (!volumeExists)
{
var volume = new Volume { Id = @event.VolumeId };
_dbContext.Volumes.Add(volume);
}
// Create chapter if not exists
var chapterExists = await _dbContext.Chapters.AnyAsync(c => c.Id == @event.ChapterId);
if (chapterExists)
{
_logger.LogDebug("Chapter {ChapterId} already exists, skipping", @event.ChapterId);
return;
}
var chapter = new Chapter { Id = @event.ChapterId };
_dbContext.Chapters.Add(chapter);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Created chapter stub for {ChapterId} in novel {NovelId}", @event.ChapterId, @event.NovelId);
}
}

View File

@@ -0,0 +1,36 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class NovelCreatedEventHandler : IIntegrationEventHandler<NovelCreatedEvent>
{
private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<NovelCreatedEventHandler> _logger;
public NovelCreatedEventHandler(
UserNovelDataServiceDbContext dbContext,
ILogger<NovelCreatedEventHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Handle(NovelCreatedEvent @event)
{
var exists = await _dbContext.Novels.AnyAsync(n => n.Id == @event.NovelId);
if (exists)
{
_logger.LogDebug("Novel {NovelId} already exists, skipping", @event.NovelId);
return;
}
var novel = new Novel { Id = @event.NovelId };
_dbContext.Novels.Add(novel);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Created novel stub for {NovelId}", @event.NovelId);
}
}

View File

@@ -0,0 +1,40 @@
using FictionArchive.Service.Shared.Services.EventBus;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using FictionArchive.Service.UserNovelDataService.Models.IntegrationEvents;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services.EventHandlers;
public class UserInvitedEventHandler : IIntegrationEventHandler<UserInvitedEvent>
{
private readonly UserNovelDataServiceDbContext _dbContext;
private readonly ILogger<UserInvitedEventHandler> _logger;
public UserInvitedEventHandler(
UserNovelDataServiceDbContext dbContext,
ILogger<UserInvitedEventHandler> logger)
{
_dbContext = dbContext;
_logger = logger;
}
public async Task Handle(UserInvitedEvent @event)
{
var exists = await _dbContext.Users.AnyAsync(u => u.Id == @event.InvitedUserId);
if (exists)
{
_logger.LogDebug("User {UserId} already exists, skipping", @event.InvitedUserId);
return;
}
var user = new User
{
Id = @event.InvitedUserId,
OAuthProviderId = @event.InvitedOAuthProviderId
};
_dbContext.Users.Add(user);
await _dbContext.SaveChangesAsync();
_logger.LogInformation("Created user stub for {UserId}", @event.InvitedUserId);
}
}

View File

@@ -1,11 +1,67 @@
using FictionArchive.Service.Shared.Services.Database; using FictionArchive.Service.Shared.Services.Database;
using FictionArchive.Service.UserNovelDataService.Models.Database;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.UserNovelDataService.Services; namespace FictionArchive.Service.UserNovelDataService.Services;
public class UserNovelDataServiceDbContext : FictionArchiveDbContext public class UserNovelDataServiceDbContext : FictionArchiveDbContext
{ {
public DbSet<User> Users { get; set; }
public DbSet<Bookmark> Bookmarks { get; set; }
public DbSet<Novel> Novels { get; set; }
public DbSet<Volume> Volumes { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<ReadingList> ReadingLists { get; set; }
public DbSet<ReadingListItem> ReadingListItems { get; set; }
public UserNovelDataServiceDbContext(DbContextOptions options, ILogger<UserNovelDataServiceDbContext> logger) : base(options, logger) public UserNovelDataServiceDbContext(DbContextOptions options, ILogger<UserNovelDataServiceDbContext> logger) : base(options, logger)
{ {
} }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Bookmark>(entity =>
{
// Unique constraint: one bookmark per chapter per user
entity.HasIndex(b => new { b.UserId, b.ChapterId }).IsUnique();
// Index for efficient "get bookmarks for novel" queries
entity.HasIndex(b => new { b.UserId, b.NovelId });
// User relationship
entity.HasOne(b => b.User)
.WithMany()
.HasForeignKey(b => b.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ReadingList>(entity =>
{
// Index for fetching user's lists
entity.HasIndex(r => r.UserId);
// User relationship with cascade delete
entity.HasOne(r => r.User)
.WithMany()
.HasForeignKey(r => r.UserId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<ReadingListItem>(entity =>
{
// Unique constraint: one entry per novel per list
entity.HasIndex(i => new { i.ReadingListId, i.NovelId }).IsUnique();
// Index for efficient ordered retrieval
entity.HasIndex(i => new { i.ReadingListId, i.Order });
// ReadingList relationship with cascade delete
entity.HasOne(i => i.ReadingList)
.WithMany(r => r.Items)
.HasForeignKey(i => i.ReadingListId)
.OnDelete(DeleteBehavior.Cascade);
});
}
} }

View File

@@ -12,5 +12,15 @@
"ConnectionString": "amqp://localhost", "ConnectionString": "amqp://localhost",
"ClientIdentifier": "UserNovelDataService" "ClientIdentifier": "UserNovelDataService"
}, },
"OIDC": {
"Authority": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ClientId": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"Audience": "ldi5IpEidq2WW0Ka1lehVskb2SOBjnYRaZCpEyBh",
"ValidIssuer": "https://auth.orfl.xyz/application/o/fiction-archive/",
"ValidateIssuer": true,
"ValidateAudience": true,
"ValidateLifetime": true,
"ValidateIssuerSigningKey": true
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }

View File

@@ -0,0 +1,6 @@
{
"subgraph": "UserNovelData",
"http": {
"baseAddress": "https://localhost:7298/graphql"
}
}

View File

@@ -213,10 +213,10 @@ public class UserManagementServiceTests
dbContext.Users.Add(inviter); dbContext.Users.Add(inviter);
await dbContext.SaveChangesAsync(); await dbContext.SaveChangesAsync();
var authentikUid = "authentik-uid-789"; var authentikPk = 456;
var authClient = Substitute.For<IAuthenticationServiceClient>(); var authClient = Substitute.For<IAuthenticationServiceClient>();
authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>()) authClient.CreateUserAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>())
.Returns(new AuthentikUserResponse { Pk = 456, Uid = authentikUid }); .Returns(new AuthentikUserResponse { Pk = authentikPk, Uid = "authentik-uid-789" });
authClient.SendRecoveryEmailAsync(Arg.Any<int>()).Returns(true); authClient.SendRecoveryEmailAsync(Arg.Any<int>()).Returns(true);
var service = CreateService(dbContext, authClient); var service = CreateService(dbContext, authClient);
@@ -228,7 +228,7 @@ public class UserManagementServiceTests
result.Should().NotBeNull(); result.Should().NotBeNull();
result!.Username.Should().Be("newusername"); result!.Username.Should().Be("newusername");
result.Email.Should().Be("newuser@test.com"); result.Email.Should().Be("newuser@test.com");
result.OAuthProviderId.Should().Be(authentikUid); result.OAuthProviderId.Should().Be(authentikPk.ToString());
result.InviterId.Should().Be(inviter.Id); result.InviterId.Should().Be(inviter.Id);
result.AvailableInvites.Should().Be(0); result.AvailableInvites.Should().Be(0);
result.Disabled.Should().BeFalse(); result.Disabled.Should().BeFalse();

View File

@@ -86,7 +86,7 @@ public class UserManagementService
{ {
Username = username, Username = username,
Email = email, Email = email,
OAuthProviderId = authentikUser.Uid, OAuthProviderId = authentikUser.Pk.ToString(),
Disabled = false, Disabled = false,
AvailableInvites = 0, AvailableInvites = 0,
InviterId = inviter.Id InviterId = inviter.Id

View File

@@ -4,25 +4,34 @@ services:
# =========================================== # ===========================================
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
networks:
fictionarchive:
ipv4_address: 172.20.0.10
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-postgres} POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - /srv/docker_volumes/fictionarchive/postgres:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 5 retries: 5
restart: unless-stopped restart: unless-stopped
ports:
- 4321:5432
rabbitmq: rabbitmq:
image: rabbitmq:3-management-alpine image: rabbitmq:3-management-alpine
networks:
fictionarchive:
ipv4_address: 172.20.0.11
environment: environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest} RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER:-guest}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-guest}
RABBITMQ_SERVER_ADDITIONAL_ERL_ARGS: -rabbit max_message_size 536870912
volumes: volumes:
- rabbitmq_data:/var/lib/rabbitmq - /srv/docker_volumes/fictionarchive/rabbitmq:/var/lib/rabbitmq
healthcheck: healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "check_running"] test: ["CMD", "rabbitmq-diagnostics", "check_running"]
interval: 10s interval: 10s
@@ -30,6 +39,37 @@ services:
retries: 5 retries: 5
restart: unless-stopped restart: unless-stopped
# ===========================================
# VPN Container
# ===========================================
vpn:
image: dperson/openvpn-client
networks:
fictionarchive:
ipv4_address: 172.20.0.20
aliases:
- novel-service
cap_add:
- NET_ADMIN
devices:
- /dev/net/tun
volumes:
- /srv/docker_volumes/korean_vpn:/vpn
dns:
- 192.168.3.1
environment:
- DNS=1.1.1.1,8.8.8.8
extra_hosts:
- "postgres:172.20.0.10"
- "rabbitmq:172.20.0.11"
healthcheck:
test: ["CMD", "ping", "-c", "1", "-W", "5", "1.1.1.1"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
restart: unless-stopped
# =========================================== # ===========================================
# Backend Services # Backend Services
# =========================================== # ===========================================
@@ -37,50 +77,27 @@ services:
image: git.orfl.xyz/conco/fictionarchive-novel-service:latest image: git.orfl.xyz/conco/fictionarchive-novel-service:latest
environment: environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_NovelService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres} 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__Username: ${NOVELPIA_USERNAME}
Novelpia__Password: ${NOVELPIA_PASSWORD} Novelpia__Password: ${NOVELPIA_PASSWORD}
NovelUpdateService__PendingImageUrl: https://files.fictionarchive.orfl.xyz/api/pendingupload.png UpdateService__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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
rabbitmq: rabbitmq:
condition: service_healthy condition: service_healthy
restart: unless-stopped vpn:
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
depends_on:
postgres:
condition: service_healthy
rabbitmq:
condition: service_healthy condition: service_healthy
network_mode: "service:vpn"
restart: unless-stopped restart: unless-stopped
scheduler-service: scheduler-service:
image: git.orfl.xyz/conco/fictionarchive-scheduler-service:latest image: git.orfl.xyz/conco/fictionarchive-scheduler-service:latest
networks:
- fictionarchive
environment: environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_SchedulerService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres} 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 RabbitMQ__ConnectionString: 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: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -90,14 +107,14 @@ services:
user-service: user-service:
image: git.orfl.xyz/conco/fictionarchive-user-service:latest image: git.orfl.xyz/conco/fictionarchive-user-service:latest
networks:
- fictionarchive
environment: environment:
ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres} 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 RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
healthcheck: Authentik__BaseUrl: https://auth.orfl.xyz
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"] Authentik__ApiToken: ${AUTHENTIK_API_TOKEN}
interval: 30s Authentik__EmailStageId: 10df0c18-8802-4ec7-852e-3cdd355514d3
timeout: 10s
retries: 3
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -105,42 +122,35 @@ services:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
authentication-service: usernoveldata-service:
image: git.orfl.xyz/conco/fictionarchive-authentication-service:latest image: git.orfl.xyz/conco/fictionarchive-usernoveldata-service:latest
networks:
- fictionarchive
environment: environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq ConnectionStrings__DefaultConnection: Host=postgres;Database=FictionArchive_UserNovelDataService;Username=${POSTGRES_USER:-postgres};Password=${POSTGRES_PASSWORD:-postgres}
healthcheck: RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/healthz"]
interval: 30s
timeout: 10s
retries: 3
depends_on: depends_on:
postgres:
condition: service_healthy
rabbitmq: rabbitmq:
condition: service_healthy condition: service_healthy
restart: unless-stopped restart: unless-stopped
file-service: file-service:
image: git.orfl.xyz/conco/fictionarchive-file-service:latest image: git.orfl.xyz/conco/fictionarchive-file-service:latest
networks:
- web
- fictionarchive
environment: environment:
ConnectionStrings__RabbitMQ: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq RabbitMQ__ConnectionString: amqp://${RABBITMQ_USER:-guest}:${RABBITMQ_PASSWORD:-guest}@rabbitmq
S3__Endpoint: ${S3_ENDPOINT:-https://s3.orfl.xyz}
S3__Bucket: ${S3_BUCKET:-fictionarchive}
S3__AccessKey: ${S3_ACCESS_KEY} S3__AccessKey: ${S3_ACCESS_KEY}
S3__SecretKey: ${S3_SECRET_KEY} S3__SecretKey: ${S3_SECRET_KEY}
Proxy__BaseUrl: https://files.orfl.xyz/api ProxyConfiguration__BaseUrl: https://files.fictionarchive.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
labels: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.file-service.rule=Host(`files.orfl.xyz`)" - "traefik.http.routers.file-service.rule=Host(`files.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.file-service.entrypoints=websecure" - "traefik.http.routers.file-service.tls=true"
- "traefik.http.routers.file-service.tls.certresolver=letsencrypt" - "traefik.http.routers.file-service.tls.certresolver=lets-encrypt"
- "traefik.http.services.file-service.loadbalancer.server.port=8080" - "traefik.http.services.file-service.loadbalancer.server.port=8080"
depends_on: depends_on:
rabbitmq: rabbitmq:
@@ -152,30 +162,23 @@ services:
# =========================================== # ===========================================
api-gateway: api-gateway:
image: git.orfl.xyz/conco/fictionarchive-api:latest image: git.orfl.xyz/conco/fictionarchive-api:latest
networks:
- web
- fictionarchive
environment: 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 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: labels:
- "traefik.enable=true" - "traefik.enable=true"
- "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)" - "traefik.http.routers.api-gateway.rule=Host(`api.fictionarchive.orfl.xyz`)"
- "traefik.http.routers.api-gateway.entrypoints=websecure" - "traefik.http.routers.api-gateway.tls=true"
- "traefik.http.routers.api-gateway.tls.certresolver=letsencrypt" - "traefik.http.routers.api-gateway.tls.certresolver=lets-encrypt"
- "traefik.http.services.api-gateway.loadbalancer.server.port=8080" - "traefik.http.services.api-gateway.loadbalancer.server.port=8080"
depends_on: depends_on:
- novel-service - novel-service
- translation-service
- scheduler-service - scheduler-service
- user-service
- authentication-service
- file-service - file-service
- user-service
- usernoveldata-service
restart: unless-stopped restart: unless-stopped
# =========================================== # ===========================================
@@ -183,20 +186,21 @@ services:
# =========================================== # ===========================================
frontend: frontend:
image: git.orfl.xyz/conco/fictionarchive-frontend:latest image: git.orfl.xyz/conco/fictionarchive-frontend:latest
healthcheck: networks:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost/"] - web
interval: 30s
timeout: 10s
retries: 3
labels: labels:
- "traefik.enable=true" - traefik.http.routers.fafrontend.rule=Host(`fictionarchive.orfl.xyz`)
- "traefik.http.routers.frontend.rule=Host(`fictionarchive.orfl.xyz`)" - traefik.http.routers.fafrontend.tls=true
- "traefik.http.routers.frontend.entrypoints=websecure" - traefik.http.routers.fafrontend.tls.certresolver=lets-encrypt
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt" - traefik.http.services.fafrontend.loadbalancer.server.port=80
- "traefik.http.services.frontend.loadbalancer.server.port=80" - traefik.enable=true
restart: unless-stopped restart: unless-stopped
volumes: networks:
postgres_data: web:
rabbitmq_data: external: yes
letsencrypt: fictionarchive:
ipam:
driver: default
config:
- subnet: 172.20.0.0/24

View File

@@ -0,0 +1,368 @@
<script lang="ts">
import { SvelteSet } from 'svelte/reactivity';
import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Input } from '$lib/components/ui/input';
import { client } from '$lib/graphql/client';
import {
GetReadingListsWithItemsDocument,
AddToReadingListDocument,
RemoveFromReadingListDocument,
CreateReadingListDocument,
type GetReadingListsWithItemsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated } from '$lib/auth/authStore';
import ListPlus from '@lucide/svelte/icons/list-plus';
import Plus from '@lucide/svelte/icons/plus';
import Check from '@lucide/svelte/icons/check';
import Loader2 from '@lucide/svelte/icons/loader-2';
interface Props {
novelId: number;
size?: 'default' | 'sm' | 'icon';
}
let { novelId, size = 'default' }: Props = $props();
type ReadingList = GetReadingListsWithItemsQuery['readingLists'][0];
// State
let popoverOpen = $state(false);
let readingLists: ReadingList[] = $state([]);
let fetching = $state(false);
let error: string | null = $state(null);
// Track which lists the novel is in (by list ID)
let novelInLists = new SvelteSet<number>();
// Track loading state for individual list toggles
let loadingListIds = new SvelteSet<number>();
// Quick-create state
let showQuickCreate = $state(false);
let newListName = $state('');
let creatingList = $state(false);
let createError: string | null = $state(null);
// Fetch reading lists when popover opens
$effect(() => {
if (popoverOpen && $isAuthenticated) {
fetchReadingLists();
}
});
// Reset quick-create form when popover closes
$effect(() => {
if (!popoverOpen) {
showQuickCreate = false;
newListName = '';
createError = null;
}
});
async function fetchReadingLists() {
fetching = true;
error = null;
try {
const result = await client.query(GetReadingListsWithItemsDocument, {}).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data) {
readingLists = result.data.readingLists;
// Build the set of list IDs that contain this novel
const inLists = new SvelteSet<number>();
for (const list of readingLists) {
if (list.items.some((item) => item.novelId === novelId)) {
inLists.add(list.id);
}
}
novelInLists = inLists;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load reading lists';
} finally {
fetching = false;
}
}
async function toggleNovelInList(listId: number) {
const isInList = novelInLists.has(listId);
loadingListIds = new SvelteSet([...loadingListIds, listId]);
try {
if (isInList) {
// Remove from list
const result = await client
.mutation(RemoveFromReadingListDocument, {
input: { listId, novelId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeFromReadingList?.errors?.length) {
error = result.data.removeFromReadingList.errors[0]?.message ?? 'Failed to remove from list';
return;
}
if (result.data?.removeFromReadingList?.readingListPayload?.success) {
// Update local state
novelInLists = new SvelteSet([...novelInLists].filter((id) => id !== listId));
// Update item count in list
readingLists = readingLists.map((list) =>
list.id === listId
? { ...list, itemCount: list.itemCount - 1, items: list.items.filter((i) => i.novelId !== novelId) }
: list
);
}
} else {
// Add to list
const result = await client
.mutation(AddToReadingListDocument, {
input: { readingListId: listId, novelId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.addToReadingList?.errors?.length) {
error = result.data.addToReadingList.errors[0]?.message ?? 'Failed to add to list';
return;
}
if (result.data?.addToReadingList?.readingListPayload?.success) {
// Update local state
novelInLists = new SvelteSet([...novelInLists, listId]);
// Update item count in list
readingLists = readingLists.map((list) =>
list.id === listId
? {
...list,
itemCount: list.itemCount + 1,
items: [...list.items, { novelId, order: list.itemCount, addedTime: new Date().toISOString() }]
}
: list
);
}
}
} catch (e) {
error = e instanceof Error ? e.message : 'An error occurred';
} finally {
loadingListIds = new SvelteSet([...loadingListIds].filter((id) => id !== listId));
}
}
async function createListAndAdd() {
if (!newListName.trim()) {
createError = 'Name is required';
return;
}
creatingList = true;
createError = null;
try {
const result = await client
.mutation(CreateReadingListDocument, {
input: {
name: newListName.trim(),
description: null
}
})
.toPromise();
if (result.error) {
createError = result.error.message;
return;
}
if (result.data?.createReadingList?.errors?.length) {
createError = result.data.createReadingList.errors[0]?.message ?? 'Failed to create list';
return;
}
const newList = result.data?.createReadingList?.readingListPayload?.readingList;
if (newList) {
// Now add the novel to the new list
const addResult = await client
.mutation(AddToReadingListDocument, {
input: { readingListId: newList.id, novelId }
})
.toPromise();
if (addResult.error) {
createError = addResult.error.message;
return;
}
if (addResult.data?.addToReadingList?.errors?.length) {
createError = addResult.data.addToReadingList.errors[0]?.message ?? 'Failed to add to list';
return;
}
// Add the new list to our local state
const fullNewList: ReadingList = {
...newList,
itemCount: 1,
items: [{ novelId, order: 0, addedTime: new Date().toISOString() }]
};
readingLists = [...readingLists, fullNewList];
novelInLists = new SvelteSet([...novelInLists, newList.id]);
// Reset quick-create form
showQuickCreate = false;
newListName = '';
}
} catch (e) {
createError = e instanceof Error ? e.message : 'An error occurred';
} finally {
creatingList = false;
}
}
function handleClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
createListAndAdd();
}
}
// Compute if novel is in any list for button state
let isInAnyList = $derived(novelInLists.size > 0);
</script>
{#if $isAuthenticated}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleClick}>
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isInAnyList ? 'default' : 'outline'}
{size}
class={size === 'icon' ? 'h-8 w-8' : 'gap-2'}
{...props}
>
<ListPlus class="h-4 w-4" />
{#if size !== 'icon'}
<span>{isInAnyList ? 'In Lists' : 'Add to List'}</span>
{/if}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">Add to Reading List</h4>
<p class="text-sm text-muted-foreground">
Select lists to add or remove this novel.
</p>
</div>
{#if fetching}
<div class="flex items-center justify-center py-4">
<Loader2 class="h-5 w-5 animate-spin text-muted-foreground" />
</div>
{:else if error}
<p class="text-sm text-destructive">{error}</p>
{:else if readingLists.length === 0 && !showQuickCreate}
<div class="text-center py-4">
<p class="text-sm text-muted-foreground mb-3">No reading lists yet</p>
<Button size="sm" variant="outline" onclick={() => (showQuickCreate = true)}>
<Plus class="h-4 w-4 mr-1" />
Create your first list
</Button>
</div>
{:else}
<!-- Reading lists -->
<div class="space-y-1 max-h-[200px] overflow-y-auto">
{#each readingLists as list (list.id)}
{@const isInList = novelInLists.has(list.id)}
{@const isLoading = loadingListIds.has(list.id)}
<button
type="button"
class="flex w-full items-center gap-3 rounded-md px-2 py-2 text-left hover:bg-accent transition-colors disabled:opacity-50"
onclick={() => toggleNovelInList(list.id)}
disabled={isLoading}
>
<div
class="flex h-4 w-4 shrink-0 items-center justify-center rounded border {isInList
? 'bg-primary border-primary'
: 'border-input'}"
>
{#if isLoading}
<Loader2 class="h-3 w-3 animate-spin text-primary-foreground" />
{:else if isInList}
<Check class="h-3 w-3 text-primary-foreground" />
{/if}
</div>
<div class="flex-1 min-w-0">
<div class="text-sm font-medium truncate">{list.name}</div>
<div class="text-xs text-muted-foreground">
{list.itemCount} {list.itemCount === 1 ? 'novel' : 'novels'}
</div>
</div>
</button>
{/each}
</div>
<!-- Quick-create section -->
{#if showQuickCreate}
<div class="border-t pt-3 space-y-2">
<div class="flex gap-2">
<Input
type="text"
placeholder="New list name"
bind:value={newListName}
disabled={creatingList}
onkeydown={handleKeyDown}
class="flex-1"
/>
<Button size="sm" onclick={createListAndAdd} disabled={creatingList || !newListName.trim()}>
{#if creatingList}
<Loader2 class="h-4 w-4 animate-spin" />
{:else}
Add
{/if}
</Button>
</div>
{#if createError}
<p class="text-xs text-destructive">{createError}</p>
{/if}
</div>
{:else}
<div class="border-t pt-3">
<Button
size="sm"
variant="ghost"
class="w-full justify-start"
onclick={() => (showQuickCreate = true)}
>
<Plus class="h-4 w-4 mr-2" />
Create new list
</Button>
</div>
{/if}
{/if}
</div>
</PopoverContent>
</Popover>
</div>
{/if}

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Textarea } from '$lib/components/ui/textarea';
import { client } from '$lib/graphql/client';
import { UpsertBookmarkDocument, RemoveBookmarkDocument } from '$lib/graphql/__generated__/graphql';
import Bookmark from '@lucide/svelte/icons/bookmark';
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
interface Props {
novelId: number;
chapterId: number;
isBookmarked?: boolean;
bookmarkDescription?: string | null;
size?: 'default' | 'sm' | 'icon';
onBookmarkChange?: (isBookmarked: boolean, description?: string | null) => void;
}
let {
novelId,
chapterId,
isBookmarked = false,
bookmarkDescription = null,
size = 'icon',
onBookmarkChange
}: Props = $props();
// Bookmark state
let popoverOpen = $state(false);
let description = $state(bookmarkDescription ?? '');
let saving = $state(false);
let removing = $state(false);
let error: string | null = $state(null);
// Reset description when popover opens
$effect(() => {
if (popoverOpen) {
description = bookmarkDescription ?? '';
error = null;
}
});
async function saveBookmark() {
saving = true;
error = null;
try {
const result = await client
.mutation(UpsertBookmarkDocument, {
input: {
chapterId,
novelId,
description: description.trim() || null
}
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.upsertBookmark?.errors?.length) {
error = result.data.upsertBookmark.errors[0]?.message ?? 'Failed to save bookmark';
return;
}
if (result.data?.upsertBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
onBookmarkChange?.(true, description.trim() || null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save bookmark';
} finally {
saving = false;
}
}
async function removeBookmark() {
removing = true;
error = null;
try {
const result = await client
.mutation(RemoveBookmarkDocument, {
input: { chapterId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeBookmark?.errors?.length) {
error = result.data.removeBookmark.errors[0]?.message ?? 'Failed to remove bookmark';
return;
}
if (result.data?.removeBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
description = '';
onBookmarkChange?.(false, null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove bookmark';
} finally {
removing = false;
}
}
function handleClick(e: MouseEvent) {
e.preventDefault();
e.stopPropagation();
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div onclick={handleClick}>
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isBookmarked ? 'default' : 'ghost'}
{size}
class={size === 'icon' ? 'h-8 w-8' : 'gap-2'}
{...props}
>
{#if isBookmarked}
<BookmarkCheck class="h-4 w-4" />
{:else}
<Bookmark class="h-4 w-4" />
{/if}
{#if size !== 'icon'}
<span>{isBookmarked ? 'Bookmarked' : 'Bookmark'}</span>
{/if}
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">
{isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'}
</h4>
<p class="text-sm text-muted-foreground">
{isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'}
</p>
</div>
<Textarea
bind:value={description}
placeholder="Add a note..."
class="min-h-[80px] resize-none"
/>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex justify-end gap-2">
{#if isBookmarked}
<Button
variant="destructive"
size="sm"
onclick={removeBookmark}
disabled={removing || saving}
>
{removing ? 'Removing...' : 'Remove'}
</Button>
{/if}
<Button
size="sm"
onclick={saveBookmark}
disabled={saving || removing}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</div>

View File

@@ -1,27 +1,138 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Popover, PopoverTrigger, PopoverContent } from '$lib/components/ui/popover';
import { Textarea } from '$lib/components/ui/textarea';
import { client } from '$lib/graphql/client';
import { UpsertBookmarkDocument, RemoveBookmarkDocument } from '$lib/graphql/__generated__/graphql';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import ChevronRight from '@lucide/svelte/icons/chevron-right'; import ChevronRight from '@lucide/svelte/icons/chevron-right';
import List from '@lucide/svelte/icons/list'; import List from '@lucide/svelte/icons/list';
import Bookmark from '@lucide/svelte/icons/bookmark';
import BookmarkCheck from '@lucide/svelte/icons/bookmark-check';
interface Props { interface Props {
novelId: string; novelId: string;
chapterId?: number;
prevChapterVolumeOrder: number | null | undefined;
prevChapterOrder: number | null | undefined; prevChapterOrder: number | null | undefined;
nextChapterVolumeOrder: number | null | undefined;
nextChapterOrder: number | null | undefined; nextChapterOrder: number | null | undefined;
showKeyboardHints?: boolean; showKeyboardHints?: boolean;
isBookmarked?: boolean;
bookmarkDescription?: string | null;
onBookmarkChange?: (isBookmarked: boolean, description?: string | null) => void;
} }
let { novelId, prevChapterOrder, nextChapterOrder, showKeyboardHints = true }: Props = $props(); let {
novelId,
chapterId,
prevChapterVolumeOrder,
prevChapterOrder,
nextChapterVolumeOrder,
nextChapterOrder,
showKeyboardHints = true,
isBookmarked = false,
bookmarkDescription = null,
onBookmarkChange
}: Props = $props();
const hasPrev = $derived(prevChapterOrder != null); const hasPrev = $derived(prevChapterOrder != null && prevChapterVolumeOrder != null);
const hasNext = $derived(nextChapterOrder != null); const hasNext = $derived(nextChapterOrder != null && nextChapterVolumeOrder != null);
// Bookmark state
let popoverOpen = $state(false);
let description = $state(bookmarkDescription ?? '');
let saving = $state(false);
let removing = $state(false);
let error: string | null = $state(null);
// Reset description when popover opens
$effect(() => {
if (popoverOpen) {
description = bookmarkDescription ?? '';
error = null;
}
});
async function saveBookmark() {
if (!chapterId) return;
saving = true;
error = null;
try {
const result = await client
.mutation(UpsertBookmarkDocument, {
input: {
chapterId,
novelId: parseInt(novelId, 10),
description: description.trim() || null
}
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.upsertBookmark?.errors?.length) {
error = result.data.upsertBookmark.errors[0]?.message ?? 'Failed to save bookmark';
return;
}
if (result.data?.upsertBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
onBookmarkChange?.(true, description.trim() || null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to save bookmark';
} finally {
saving = false;
}
}
async function removeBookmark() {
if (!chapterId) return;
removing = true;
error = null;
try {
const result = await client
.mutation(RemoveBookmarkDocument, {
input: { chapterId }
})
.toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data?.removeBookmark?.errors?.length) {
error = result.data.removeBookmark.errors[0]?.message ?? 'Failed to remove bookmark';
return;
}
if (result.data?.removeBookmark?.bookmarkPayload?.success) {
popoverOpen = false;
description = '';
onBookmarkChange?.(false, null);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove bookmark';
} finally {
removing = false;
}
}
</script> </script>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<div class="flex items-center justify-between gap-4"> <div class="flex items-center justify-between gap-4">
<Button <Button
variant="outline" variant="outline"
href={hasPrev ? `/novels/${novelId}/chapters/${prevChapterOrder}` : undefined} href={hasPrev ? `/novels/${novelId}/volumes/${prevChapterVolumeOrder}/chapters/${prevChapterOrder}` : undefined}
disabled={!hasPrev} disabled={!hasPrev}
class="gap-2" class="gap-2"
> >
@@ -29,14 +140,76 @@
<span class="hidden sm:inline">Previous</span> <span class="hidden sm:inline">Previous</span>
</Button> </Button>
<div class="flex items-center gap-2">
<Button variant="outline" href="/novels/{novelId}" class="gap-2"> <Button variant="outline" href="/novels/{novelId}" class="gap-2">
<List class="h-4 w-4" /> <List class="h-4 w-4" />
<span class="hidden sm:inline">Contents</span> <span class="hidden sm:inline">Contents</span>
</Button> </Button>
{#if chapterId}
<Popover bind:open={popoverOpen}>
<PopoverTrigger asChild>
{#snippet child({ props })}
<Button
variant={isBookmarked ? 'default' : 'outline'}
class="gap-2"
{...props}
>
{#if isBookmarked}
<BookmarkCheck class="h-4 w-4" />
{:else}
<Bookmark class="h-4 w-4" />
{/if}
<span class="hidden sm:inline">{isBookmarked ? 'Bookmarked' : 'Bookmark'}</span>
</Button>
{/snippet}
</PopoverTrigger>
<PopoverContent class="w-80">
<div class="space-y-4">
<div class="space-y-2">
<h4 class="font-medium leading-none">
{isBookmarked ? 'Edit bookmark' : 'Bookmark this chapter'}
</h4>
<p class="text-sm text-muted-foreground">
{isBookmarked ? 'Update your note or remove the bookmark.' : 'Add an optional note to remember why you bookmarked this.'}
</p>
</div>
<Textarea
bind:value={description}
placeholder="Add a note..."
class="min-h-[80px] resize-none"
/>
{#if error}
<p class="text-sm text-destructive">{error}</p>
{/if}
<div class="flex justify-end gap-2">
{#if isBookmarked}
<Button
variant="destructive"
size="sm"
onclick={removeBookmark}
disabled={removing || saving}
>
{removing ? 'Removing...' : 'Remove'}
</Button>
{/if}
<Button
size="sm"
onclick={saveBookmark}
disabled={saving || removing}
>
{saving ? 'Saving...' : 'Save'}
</Button>
</div>
</div>
</PopoverContent>
</Popover>
{/if}
</div>
<Button <Button
variant="outline" variant="outline"
href={hasNext ? `/novels/${novelId}/chapters/${nextChapterOrder}` : undefined} href={hasNext ? `/novels/${novelId}/volumes/${nextChapterVolumeOrder}/chapters/${nextChapterOrder}` : undefined}
disabled={!hasNext} disabled={!hasNext}
class="gap-2" class="gap-2"
> >

View File

@@ -1,13 +1,14 @@
<script lang="ts" module> <script lang="ts" module>
import type { GetChapterQuery } from '$lib/graphql/__generated__/graphql'; import type { GetChapterQuery, GetBookmarksQuery } from '$lib/graphql/__generated__/graphql';
export type ChapterData = NonNullable<GetChapterQuery['chapter']>; export type ChapterData = NonNullable<GetChapterQuery['chapter']>;
export type BookmarkData = GetBookmarksQuery['bookmarks'][number];
</script> </script>
<script lang="ts"> <script lang="ts">
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy } from 'svelte';
import { client } from '$lib/graphql/client'; import { client } from '$lib/graphql/client';
import { GetChapterDocument } from '$lib/graphql/__generated__/graphql'; import { GetChapterDocument, GetBookmarksDocument } from '$lib/graphql/__generated__/graphql';
import { Card, CardContent } from '$lib/components/ui/card'; import { Card, CardContent } from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import ChapterNavigation from './ChapterNavigation.svelte'; import ChapterNavigation from './ChapterNavigation.svelte';
@@ -16,10 +17,11 @@
interface Props { interface Props {
novelId?: string; novelId?: string;
volumeOrder?: string;
chapterNumber?: string; chapterNumber?: string;
} }
let { novelId, chapterNumber }: Props = $props(); let { novelId, volumeOrder, chapterNumber }: Props = $props();
// State // State
let chapter: ChapterData | null = $state(null); let chapter: ChapterData | null = $state(null);
@@ -27,6 +29,10 @@
let error: string | null = $state(null); let error: string | null = $state(null);
let scrollProgress = $state(0); let scrollProgress = $state(0);
// Bookmark state
let isBookmarked = $state(false);
let bookmarkDescription: string | null = $state(null);
// Derived values // Derived values
const sanitizedBody = $derived(chapter?.body ? sanitizeChapterHtml(chapter.body) : ''); const sanitizedBody = $derived(chapter?.body ? sanitizeChapterHtml(chapter.body) : '');
@@ -42,16 +48,16 @@
return; return;
} }
if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null) { if (event.key === 'ArrowLeft' && chapter?.prevChapterOrder != null && chapter?.prevChapterVolumeOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.prevChapterOrder}`; window.location.href = `/novels/${novelId}/volumes/${chapter.prevChapterVolumeOrder}/chapters/${chapter.prevChapterOrder}`;
} else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null) { } else if (event.key === 'ArrowRight' && chapter?.nextChapterOrder != null && chapter?.nextChapterVolumeOrder != null) {
window.location.href = `/novels/${novelId}/chapters/${chapter.nextChapterOrder}`; window.location.href = `/novels/${novelId}/volumes/${chapter.nextChapterVolumeOrder}/chapters/${chapter.nextChapterOrder}`;
} }
} }
async function fetchChapter() { async function fetchChapter() {
if (!novelId || !chapterNumber) { if (!novelId || !volumeOrder || !chapterNumber) {
error = 'Missing novel ID or chapter number'; error = 'Missing novel ID, volume order, or chapter number';
fetching = false; fetching = false;
return; return;
} }
@@ -63,6 +69,7 @@
const result = await client const result = await client
.query(GetChapterDocument, { .query(GetChapterDocument, {
novelId: parseInt(novelId, 10), novelId: parseInt(novelId, 10),
volumeOrder: parseInt(volumeOrder, 10),
chapterOrder: parseInt(chapterNumber, 10) chapterOrder: parseInt(chapterNumber, 10)
}) })
.toPromise(); .toPromise();
@@ -76,6 +83,8 @@
chapter = result.data.chapter; chapter = result.data.chapter;
// Update the page title with chapter info // Update the page title with chapter info
document.title = `${chapter.novelName} - ${chapter.order}`; document.title = `${chapter.novelName} - ${chapter.order}`;
// Fetch bookmark status
await fetchBookmarks();
} else { } else {
error = 'Chapter not found'; error = 'Chapter not found';
} }
@@ -86,6 +95,34 @@
} }
} }
async function fetchBookmarks() {
if (!novelId || !chapter) return;
try {
const result = await client
.query(GetBookmarksDocument, { novelId: parseInt(novelId, 10) })
.toPromise();
if (result.data?.bookmarks) {
const bookmark = result.data.bookmarks.find((b) => b.chapterId === chapter!.id);
if (bookmark) {
isBookmarked = true;
bookmarkDescription = bookmark.description ?? null;
} else {
isBookmarked = false;
bookmarkDescription = null;
}
}
} catch {
// Silently fail - bookmark status is non-critical
}
}
function handleBookmarkChange(newIsBookmarked: boolean, newDescription?: string | null) {
isBookmarked = newIsBookmarked;
bookmarkDescription = newDescription ?? null;
}
onMount(() => { onMount(() => {
fetchChapter(); fetchChapter();
window.addEventListener('scroll', handleScroll, { passive: true }); window.addEventListener('scroll', handleScroll, { passive: true });
@@ -137,8 +174,14 @@
<!-- Navigation (top) --> <!-- Navigation (top) -->
<ChapterNavigation <ChapterNavigation
novelId={novelId ?? ''} novelId={novelId ?? ''}
chapterId={chapter.id}
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
prevChapterOrder={chapter.prevChapterOrder} prevChapterOrder={chapter.prevChapterOrder}
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
nextChapterOrder={chapter.nextChapterOrder} nextChapterOrder={chapter.nextChapterOrder}
{isBookmarked}
{bookmarkDescription}
onBookmarkChange={handleBookmarkChange}
/> />
<!-- Chapter Header --> <!-- Chapter Header -->
@@ -169,9 +212,15 @@
<!-- Navigation (bottom) --> <!-- Navigation (bottom) -->
<ChapterNavigation <ChapterNavigation
novelId={novelId ?? ''} novelId={novelId ?? ''}
chapterId={chapter.id}
prevChapterVolumeOrder={chapter.prevChapterVolumeOrder}
prevChapterOrder={chapter.prevChapterOrder} prevChapterOrder={chapter.prevChapterOrder}
nextChapterVolumeOrder={chapter.nextChapterVolumeOrder}
nextChapterOrder={chapter.nextChapterOrder} nextChapterOrder={chapter.nextChapterOrder}
showKeyboardHints={false} showKeyboardHints={false}
{isBookmarked}
{bookmarkDescription}
onBookmarkChange={handleBookmarkChange}
/> />
{/if} {/if}
</div> </div>

View File

@@ -19,11 +19,10 @@
description="Explore and read archived novels." description="Explore and read archived novels."
/> />
<NavigationCard <NavigationCard
href="/lists" href="/reading-lists"
icon={List} icon={List}
title="Reading Lists" title="Reading Lists"
description="Organize stories into custom collections." description="Organize stories into custom collections."
disabled
/> />
<NavigationCard <NavigationCard
href="/recommendations" href="/recommendations"

View File

@@ -2,6 +2,7 @@
import * as NavigationMenu from '$lib/components/ui/navigation-menu'; import * as NavigationMenu from '$lib/components/ui/navigation-menu';
import AuthenticationDisplay from './AuthenticationDisplay.svelte'; import AuthenticationDisplay from './AuthenticationDisplay.svelte';
import SearchBar from './SearchBar.svelte'; import SearchBar from './SearchBar.svelte';
import { isAuthenticated } from '$lib/auth/authStore';
let pathname = $state(typeof window !== 'undefined' ? window.location.pathname : '/'); let pathname = $state(typeof window !== 'undefined' ? window.location.pathname : '/');
@@ -24,6 +25,11 @@
<NavigationMenu.Item> <NavigationMenu.Item>
<NavigationMenu.Link href="/novels" active={isActive('/novels')}>Novels</NavigationMenu.Link> <NavigationMenu.Link href="/novels" active={isActive('/novels')}>Novels</NavigationMenu.Link>
</NavigationMenu.Item> </NavigationMenu.Item>
{#if $isAuthenticated}
<NavigationMenu.Item>
<NavigationMenu.Link href="/reading-lists" active={isActive('/reading-lists')}>Reading Lists</NavigationMenu.Link>
</NavigationMenu.Item>
{/if}
</NavigationMenu.List> </NavigationMenu.List>
</NavigationMenu.Root> </NavigationMenu.Root>
<div class="flex-1"></div> <div class="flex-1"></div>

View File

@@ -1,8 +1,10 @@
<script lang="ts" module> <script lang="ts" module>
import type { NovelQuery, NovelStatus, Language } from '$lib/graphql/__generated__/graphql'; import type { NovelQuery, NovelStatus, Language, GetBookmarksQuery } from '$lib/graphql/__generated__/graphql';
import { TagType } from '$lib/graphql/__generated__/graphql'; import { TagType } from '$lib/graphql/__generated__/graphql';
import { SystemTags } from '$lib/constants/systemTags'; import { SystemTags } from '$lib/constants/systemTags';
export type BookmarkData = GetBookmarksQuery['bookmarks'][number];
export type NovelNode = NonNullable<NonNullable<NovelQuery['novels']>['nodes']>[number]; export type NovelNode = NonNullable<NonNullable<NovelQuery['novels']>['nodes']>[number];
const statusColors: Record<NovelStatus, string> = { const statusColors: Record<NovelStatus, string> = {
@@ -32,12 +34,18 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { client } from '$lib/graphql/client'; import { client } from '$lib/graphql/client';
import { NovelDocument, ImportNovelDocument, DeleteNovelDocument } from '$lib/graphql/__generated__/graphql'; import { NovelDocument, ImportNovelDocument, DeleteNovelDocument, GetBookmarksDocument } from '$lib/graphql/__generated__/graphql';
import { isAuthenticated } from '$lib/auth/authStore'; import { isAuthenticated } from '$lib/auth/authStore';
import { Card, CardContent, CardHeader } from '$lib/components/ui/card'; import { Card, CardContent, CardHeader } from '$lib/components/ui/card';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '$lib/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '$lib/components/ui/tabs';
import {
Accordion,
AccordionItem,
AccordionTrigger,
AccordionContent
} from '$lib/components/ui/accordion';
import { import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
@@ -46,6 +54,8 @@
} from '$lib/components/ui/tooltip'; } from '$lib/components/ui/tooltip';
import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time'; import { formatRelativeTime, formatAbsoluteTime } from '$lib/utils/time';
import { sanitizeHtml } from '$lib/utils/sanitize'; import { sanitizeHtml } from '$lib/utils/sanitize';
import ChapterBookmarkButton from './ChapterBookmarkButton.svelte';
import AddToReadingListButton from './AddToReadingListButton.svelte';
// Direct imports for faster builds // Direct imports for faster builds
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ExternalLink from '@lucide/svelte/icons/external-link'; import ExternalLink from '@lucide/svelte/icons/external-link';
@@ -81,6 +91,7 @@
type GalleryImage = { type GalleryImage = {
src: string; src: string;
alt: string; alt: string;
volumeOrder?: number;
chapterId?: number; chapterId?: number;
chapterOrder?: number; chapterOrder?: number;
chapterName?: string; chapterName?: string;
@@ -91,6 +102,11 @@
let activeTab = $state('chapters'); let activeTab = $state('chapters');
let galleryLoaded = $state(false); let galleryLoaded = $state(false);
// Bookmarks state
let bookmarks: BookmarkData[] = $state([]);
let bookmarksLoaded = $state(false);
let bookmarksFetching = $state(false);
const DESCRIPTION_PREVIEW_LENGTH = 300; const DESCRIPTION_PREVIEW_LENGTH = 300;
// Derived values // Derived values
@@ -114,11 +130,51 @@
: descriptionHtml : descriptionHtml
); );
const sortedChapters = $derived( // Volume-aware chapter organization
[...(novel?.chapters ?? [])].sort((a, b) => a.order - b.order) 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);
// Chapter lookup for bookmarks (maps chapterId to chapter details)
const chapterLookup = $derived(
new Map(
sortedVolumes.flatMap((v) =>
v.chapters.map((c) => [c.id, { ...c, volumeOrder: v.order }])
)
)
);
// Bookmark lookup by chapterId for quick access in chapter list
const bookmarkLookup = $derived(
new Map(bookmarks.map((b) => [b.chapterId, b]))
);
function handleChapterBookmarkChange(chapterId: number, isBookmarked: boolean, description?: string | null) {
if (isBookmarked) {
// Add or update bookmark in local state
const existingIndex = bookmarks.findIndex((b) => b.chapterId === chapterId);
const newBookmark = {
id: existingIndex >= 0 ? bookmarks[existingIndex].id : -1, // temp id
chapterId,
description: description ?? null,
createdTime: new Date().toISOString()
};
if (existingIndex >= 0) {
bookmarks[existingIndex] = newBookmark;
} else {
bookmarks = [...bookmarks, newBookmark];
}
} else {
// Remove bookmark from local state
bookmarks = bookmarks.filter((b) => b.chapterId !== chapterId);
}
}
const chapterCount = $derived(
sortedVolumes.reduce((sum, v) => sum + v.chapters.length, 0)
);
// Filter out system tags for display, check for NSFW // Filter out system tags for display, check for NSFW
const displayTags = $derived(novel?.tags?.filter((tag) => tag.tagType !== TagType.System) ?? []); const displayTags = $derived(novel?.tags?.filter((tag) => tag.tagType !== TagType.System) ?? []);
@@ -139,13 +195,16 @@
images.push({ src: coverSrc, alt: `${novel.name} cover`, isCover: true }); images.push({ src: coverSrc, alt: `${novel.name} cover`, isCover: true });
} }
// Add chapter images // Add chapter images (loop through volumes to preserve volumeOrder)
for (const chapter of sortedChapters) { 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 ?? []) { for (const img of chapter.images ?? []) {
if (img.newPath) { if (img.newPath) {
images.push({ images.push({
src: img.newPath, src: img.newPath,
alt: `Image from ${chapter.name}`, alt: `Image from ${chapter.name}`,
volumeOrder: volume.order,
chapterId: chapter.id, chapterId: chapter.id,
chapterOrder: chapter.order, chapterOrder: chapter.order,
chapterName: chapter.name, chapterName: chapter.name,
@@ -154,6 +213,7 @@
} }
} }
} }
}
return images; return images;
}); });
@@ -168,6 +228,34 @@
} }
}); });
// Load bookmarks when novel is loaded (for count display)
$effect(() => {
if (novel && !bookmarksLoaded && novelId) {
fetchBookmarks();
}
});
async function fetchBookmarks() {
if (!novelId || bookmarksFetching) return;
bookmarksFetching = true;
try {
const result = await client
.query(GetBookmarksDocument, { novelId: parseInt(novelId, 10) })
.toPromise();
if (result.data?.bookmarks) {
bookmarks = result.data.bookmarks;
}
} catch {
// Silently fail - bookmarks are non-critical
} finally {
bookmarksFetching = false;
bookmarksLoaded = true;
}
}
// Image viewer functions // Image viewer functions
function openImageViewer(index: number) { function openImageViewer(index: number) {
viewerIndex = index; viewerIndex = index;
@@ -404,6 +492,7 @@
<Trash2 class="h-3 w-3" /> <Trash2 class="h-3 w-3" />
Delete Delete
</Button> </Button>
<AddToReadingListButton novelId={novel.id} />
{/if} {/if}
{#if refreshSuccess} {#if refreshSuccess}
<Badge variant="outline" class="bg-green-500/10 text-green-600 border-green-500/30"> <Badge variant="outline" class="bg-green-500/10 text-green-600 border-green-500/30">
@@ -512,44 +601,113 @@
</TabsTrigger> </TabsTrigger>
<TabsTrigger <TabsTrigger
value="bookmarks" value="bookmarks"
disabled class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all"
class="rounded-md data-[state=active]:bg-background data-[state=active]:shadow-sm px-3 py-1.5 text-sm font-medium transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
Bookmarks Bookmarks{bookmarksLoaded ? ` (${bookmarks.length})` : ''}
</TabsTrigger> </TabsTrigger>
</TabsList> </TabsList>
</CardHeader> </CardHeader>
<CardContent class="pt-4"> <CardContent class="pt-4">
<TabsContent value="chapters" class="mt-0"> <TabsContent value="chapters" class="mt-0">
{#if sortedChapters.length === 0} {#if chapterCount === 0}
<p class="text-muted-foreground text-sm py-4 text-center"> <p class="text-muted-foreground text-sm py-4 text-center">
No chapters available yet. No chapters available yet.
</p> </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"> <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} {@const chapterDate = chapter.lastUpdatedTime ? new Date(chapter.lastUpdatedTime) : null}
{@const chapterBookmark = bookmarkLookup.get(chapter.id)}
<div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
<a <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" class="flex items-center gap-3 min-w-0 flex-1"
> >
<div class="flex items-center gap-3 min-w-0">
<span class="text-muted-foreground text-sm font-medium shrink-0 w-14"> <span class="text-muted-foreground text-sm font-medium shrink-0 w-14">
Ch. {chapter.order} Ch. {chapter.order}
</span> </span>
<span class="text-sm truncate group-hover:text-primary transition-colors"> <span class="text-sm truncate group-hover:text-primary transition-colors">
{chapter.name} {chapter.name}
</span> </span>
</div> </a>
<div class="flex items-center gap-2 shrink-0 ml-2">
{#if chapterDate} {#if chapterDate}
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2"> <span class="text-xs text-muted-foreground/70">
{formatRelativeTime(chapterDate)} {formatRelativeTime(chapterDate)}
</span> </span>
{/if} {/if}
</a> {#if novelId}
<ChapterBookmarkButton
novelId={parseInt(novelId, 10)}
chapterId={chapter.id}
isBookmarked={!!chapterBookmark}
bookmarkDescription={chapterBookmark?.description}
onBookmarkChange={(isBookmarked, description) => handleChapterBookmarkChange(chapter.id, isBookmarked, description)}
/>
{/if}
</div>
</div>
{/each} {/each}
</div> </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}
{@const chapterBookmark = bookmarkLookup.get(chapter.id)}
<div class="flex items-center px-3 py-2.5 hover:bg-muted/50 rounded-md transition-colors group">
<a
href="/novels/{novelId}/volumes/{volume.order}/chapters/{chapter.order}"
class="flex items-center gap-3 min-w-0 flex-1"
>
<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>
</a>
<div class="flex items-center gap-2 shrink-0 ml-2">
{#if chapterDate}
<span class="text-xs text-muted-foreground/70">
{formatRelativeTime(chapterDate)}
</span>
{/if}
{#if novelId}
<ChapterBookmarkButton
novelId={parseInt(novelId, 10)}
chapterId={chapter.id}
isBookmarked={!!chapterBookmark}
bookmarkDescription={chapterBookmark?.description}
onBookmarkChange={(isBookmarked, description) => handleChapterBookmarkChange(chapter.id, isBookmarked, description)}
/>
{/if}
</div>
</div>
{/each}
</div>
</AccordionContent>
</AccordionItem>
{/each}
</Accordion>
</div>
{/if} {/if}
</TabsContent> </TabsContent>
@@ -584,9 +742,50 @@
</TabsContent> </TabsContent>
<TabsContent value="bookmarks" class="mt-0"> <TabsContent value="bookmarks" class="mt-0">
{#if bookmarksFetching}
<div class="flex items-center justify-center py-8">
<div
class="border-primary h-8 w-8 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading bookmarks"
></div>
</div>
{:else if bookmarks.length === 0}
<p class="text-muted-foreground text-sm py-8 text-center"> <p class="text-muted-foreground text-sm py-8 text-center">
Bookmarks coming soon. No bookmarks yet. Add bookmarks while reading chapters.
</p> </p>
{:else}
<div class="max-h-96 overflow-y-auto -mx-2">
{#each bookmarks as bookmark (bookmark.id)}
{@const chapter = chapterLookup.get(bookmark.chapterId)}
{#if chapter}
{@const bookmarkDate = new Date(bookmark.createdTime)}
<a
href="/novels/{novelId}/volumes/{chapter.volumeOrder}/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-1 min-w-0">
<div class="flex items-center gap-3">
<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 bookmark.description}
<p class="text-xs text-muted-foreground/70 mt-1 ml-[4.25rem] truncate">
{bookmark.description}
</p>
{/if}
</div>
<span class="text-xs text-muted-foreground/70 shrink-0 ml-2">
{formatRelativeTime(bookmarkDate)}
</span>
</a>
{/if}
{/each}
</div>
{/if}
</TabsContent> </TabsContent>
</CardContent> </CardContent>
</Tabs> </Tabs>
@@ -645,9 +844,9 @@
/> />
<!-- Chapter link (if not cover) --> <!-- Chapter link (if not cover) -->
{#if !currentImage.isCover && currentImage.chapterOrder} {#if !currentImage.isCover && currentImage.volumeOrder != null && currentImage.chapterOrder}
<a <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" class="text-white/80 hover:text-white text-sm inline-flex items-center gap-1 mt-3"
> >
From: Ch. {currentImage.chapterOrder} - {currentImage.chapterName} From: Ch. {currentImage.chapterOrder} - {currentImage.chapterName}

View File

@@ -0,0 +1,393 @@
<script lang="ts">
import { SvelteMap } from 'svelte/reactivity';
import { onMount } from 'svelte';
import { client } from '$lib/graphql/client';
import {
GetReadingListDocument,
NovelsDocument,
RemoveFromReadingListDocument,
ReorderReadingListItemDocument,
type GetReadingListQuery,
type NovelsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated, login } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import Trash2 from '@lucide/svelte/icons/trash-2';
import BookOpen from '@lucide/svelte/icons/book-open';
import LogIn from '@lucide/svelte/icons/log-in';
interface Props {
listId: string;
}
let { listId }: Props = $props();
type ReadingList = NonNullable<GetReadingListQuery['readingList']>;
type ReadingListItem = ReadingList['items'][number];
type NovelNode = NonNullable<NonNullable<NovelsQuery['novels']>['edges']>[number]['node'];
// State
let readingList: ReadingList | null = $state(null);
let novels = new SvelteMap<number, NovelNode>();
let fetching = $state(true);
let error: string | null = $state(null);
// Operation state
let reordering = $state(false);
let removing: number | null = $state(null);
let operationError: string | null = $state(null);
// Derived: sorted items by order
const sortedItems = $derived(
readingList?.items ? [...readingList.items].sort((a, b) => a.order - b.order) : []
);
async function fetchReadingList() {
fetching = true;
error = null;
try {
const id = parseInt(listId, 10);
if (isNaN(id)) {
error = 'Invalid reading list ID';
fetching = false;
return;
}
const result = await client.query(GetReadingListDocument, { id }).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (!result.data?.readingList) {
error = 'Reading list not found';
return;
}
readingList = result.data.readingList;
// Fetch novel details for all items
if (readingList.items.length > 0) {
await fetchNovels(readingList.items.map((item) => item.novelId));
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
async function fetchNovels(novelIds: number[]) {
if (novelIds.length === 0) return;
try {
const result = await client
.query(NovelsDocument, {
first: novelIds.length,
where: { id: { in: novelIds } }
})
.toPromise();
if (result.data?.novels?.edges) {
for (const edge of result.data.novels.edges) {
novels.set(edge.node.id, edge.node);
}
}
} catch {
// Non-critical: novels just won't show extra details
}
}
async function moveItem(item: ReadingListItem, direction: 'up' | 'down') {
if (!readingList || reordering) return;
const currentIndex = sortedItems.findIndex((i) => i.novelId === item.novelId);
const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
if (targetIndex < 0 || targetIndex >= sortedItems.length) return;
const targetItem = sortedItems[targetIndex];
const newOrder = targetItem.order;
reordering = true;
operationError = null;
try {
const result = await client
.mutation(ReorderReadingListItemDocument, {
input: {
readingListId: readingList.id,
novelId: item.novelId,
newOrder
}
})
.toPromise();
if (result.error) {
operationError = result.error.message;
return;
}
if (result.data?.reorderReadingListItem?.errors?.length) {
operationError = result.data.reorderReadingListItem.errors[0]?.message ?? 'Failed to reorder';
return;
}
// Refresh the list to get updated order
await fetchReadingList();
} catch (e) {
operationError = e instanceof Error ? e.message : 'Failed to reorder';
} finally {
reordering = false;
}
}
async function removeItem(novelId: number) {
if (!readingList || removing !== null) return;
removing = novelId;
operationError = null;
try {
const result = await client
.mutation(RemoveFromReadingListDocument, {
input: {
listId: readingList.id,
novelId
}
})
.toPromise();
if (result.error) {
operationError = result.error.message;
return;
}
if (result.data?.removeFromReadingList?.errors?.length) {
operationError = result.data.removeFromReadingList.errors[0]?.message ?? 'Failed to remove';
return;
}
// Update local state
if (readingList) {
readingList = {
...readingList,
items: readingList.items.filter((item) => item.novelId !== novelId),
itemCount: readingList.itemCount - 1
};
}
} catch (e) {
operationError = e instanceof Error ? e.message : 'Failed to remove';
} finally {
removing = null;
}
}
onMount(() => {
if ($isAuthenticated) {
fetchReadingList();
} else {
fetching = false;
}
});
// Re-fetch when auth changes
$effect(() => {
if ($isAuthenticated) {
fetchReadingList();
} else {
readingList = null;
novels.clear();
fetching = false;
}
});
</script>
<div class="space-y-6">
<!-- Back Navigation -->
<Button variant="ghost" href="/reading-lists" class="gap-2 -ml-2">
<ArrowLeft class="h-4 w-4" />
Back to Reading Lists
</Button>
{#if !$isAuthenticated}
<!-- Auth gate - sign in prompt -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">Sign in to view Reading Lists</h3>
<p class="text-muted-foreground">
Sign in to view and manage your reading lists.
</p>
</div>
<Button onclick={login}>
<LogIn class="mr-2 h-4 w-4" />
Sign In
</Button>
</div>
</CardContent>
</Card>
{:else if fetching}
<!-- Loading state -->
<Card>
<CardContent>
<div class="flex items-center justify-center py-12">
<div
class="border-primary h-10 w-10 animate-spin rounded-full border-2 border-t-transparent"
aria-label="Loading reading list"
></div>
</div>
</CardContent>
</Card>
{:else if error}
<!-- Error state -->
<Card class="border-destructive/40 bg-destructive/5">
<CardContent class="py-8">
<div class="text-center">
<p class="text-destructive text-lg font-medium">
{error === 'Reading list not found' ? 'Reading List Not Found' : 'Error Loading Reading List'}
</p>
<p class="text-muted-foreground mt-2 text-sm">{error}</p>
<Button variant="outline" onclick={fetchReadingList} class="mt-4">
Try Again
</Button>
</div>
</CardContent>
</Card>
{:else if readingList}
<!-- Header -->
<Card>
<CardHeader>
<CardTitle class="text-2xl">{readingList.name}</CardTitle>
{#if readingList.description}
<CardDescription class="text-base">{readingList.description}</CardDescription>
{/if}
<p class="text-sm text-muted-foreground">
{readingList.itemCount} {readingList.itemCount === 1 ? 'novel' : 'novels'}
</p>
</CardHeader>
</Card>
<!-- Operation error -->
{#if operationError}
<Card class="border-destructive/40 bg-destructive/5">
<CardContent class="py-4">
<p class="text-destructive text-sm">{operationError}</p>
</CardContent>
</Card>
{/if}
<!-- Novels list -->
{#if sortedItems.length === 0}
<!-- Empty state -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">No novels in this list</h3>
<p class="text-muted-foreground">
Add novels to this reading list from a novel's detail page.
</p>
</div>
<Button href="/novels" variant="outline">
Browse Novels
</Button>
</div>
</CardContent>
</Card>
{:else}
<Card>
<CardContent class="py-4">
<div class="space-y-2">
{#each sortedItems as item, index (item.novelId)}
{@const novel = novels.get(item.novelId)}
<div
class="flex items-center gap-4 p-3 rounded-lg hover:bg-muted/50 transition-colors {removing === item.novelId || reordering ? 'opacity-50' : ''}"
>
<!-- Cover image -->
<a href={`/novels/${item.novelId}`} class="shrink-0">
{#if novel?.coverImage?.newPath}
<div class="w-16 h-20 overflow-hidden rounded-md bg-muted/50">
<img
src={novel.coverImage.newPath}
alt={novel?.name ?? 'Novel cover'}
class="h-full w-full object-cover"
/>
</div>
{:else}
<div class="w-16 h-20 rounded-md bg-muted/50 flex items-center justify-center">
<BookOpen class="h-6 w-6 text-muted-foreground/50" />
</div>
{/if}
</a>
<!-- Novel info -->
<div class="flex-1 min-w-0">
<a
href={`/novels/${item.novelId}`}
class="font-medium hover:text-primary transition-colors line-clamp-1"
>
{novel?.name ?? `Novel #${item.novelId}`}
</a>
{#if novel?.description}
<p class="text-sm text-muted-foreground line-clamp-2 mt-1">
{novel.description}
</p>
{/if}
</div>
<!-- Action buttons -->
<div class="flex items-center gap-1 shrink-0">
<!-- Move up -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
disabled={index === 0 || reordering || removing !== null}
onclick={() => moveItem(item, 'up')}
>
<ArrowUp class="h-4 w-4" />
<span class="sr-only">Move up</span>
</Button>
<!-- Move down -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
disabled={index === sortedItems.length - 1 || reordering || removing !== null}
onclick={() => moveItem(item, 'down')}
>
<ArrowDown class="h-4 w-4" />
<span class="sr-only">Move down</span>
</Button>
<!-- Remove -->
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
disabled={reordering || removing !== null}
onclick={() => removeItem(item.novelId)}
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Remove from list</span>
</Button>
</div>
</div>
{/each}
</div>
</CardContent>
</Card>
{/if}
{/if}
</div>

View File

@@ -0,0 +1,432 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Dialog as DialogPrimitive } from 'bits-ui';
import { client } from '$lib/graphql/client';
import {
GetReadingListsDocument,
CreateReadingListDocument,
UpdateReadingListDocument,
DeleteReadingListDocument,
type GetReadingListsQuery
} from '$lib/graphql/__generated__/graphql';
import { isAuthenticated, login } from '$lib/auth/authStore';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { Textarea } from '$lib/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle, CardDescription, CardFooter } from '$lib/components/ui/card';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import BookOpen from '@lucide/svelte/icons/book-open';
import X from '@lucide/svelte/icons/x';
import LogIn from '@lucide/svelte/icons/log-in';
type ReadingList = GetReadingListsQuery['readingLists'][0];
// State
let readingLists: ReadingList[] = $state([]);
let fetching = $state(true);
let error: string | null = $state(null);
// Dialog state
let dialogOpen = $state(false);
let dialogMode: 'create' | 'edit' = $state('create');
let editingList: ReadingList | null = $state(null);
// Form state
let formName = $state('');
let formDescription = $state('');
let formSubmitting = $state(false);
let formError: string | null = $state(null);
// Delete confirmation state
let deleteDialogOpen = $state(false);
let deletingList: ReadingList | null = $state(null);
let deleteSubmitting = $state(false);
let deleteError: string | null = $state(null);
// Reset form when dialog opens/closes
$effect(() => {
if (dialogOpen) {
if (dialogMode === 'edit' && editingList) {
formName = editingList.name;
formDescription = editingList.description ?? '';
} else {
formName = '';
formDescription = '';
}
formError = null;
}
});
async function fetchReadingLists(skipCache = false) {
fetching = true;
error = null;
try {
const result = await client.query(GetReadingListsDocument, {}, { requestPolicy: skipCache ? 'network-only' : 'cache-first' }).toPromise();
if (result.error) {
error = result.error.message;
return;
}
if (result.data) {
readingLists = result.data.readingLists;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
fetching = false;
}
}
function openCreateDialog() {
dialogMode = 'create';
editingList = null;
dialogOpen = true;
}
function openEditDialog(list: ReadingList) {
dialogMode = 'edit';
editingList = list;
dialogOpen = true;
}
function openDeleteDialog(list: ReadingList) {
deletingList = list;
deleteError = null;
deleteDialogOpen = true;
}
async function handleSubmit(e: Event) {
e.preventDefault();
if (!formName.trim()) {
formError = 'Name is required';
return;
}
formSubmitting = true;
formError = null;
try {
if (dialogMode === 'create') {
const result = await client
.mutation(CreateReadingListDocument, {
input: {
name: formName.trim(),
description: formDescription.trim() || null
}
})
.toPromise();
if (result.error) {
formError = result.error.message;
return;
}
if (result.data?.createReadingList?.errors?.length) {
formError = result.data.createReadingList.errors[0]?.message ?? 'Failed to create reading list';
return;
}
if (result.data?.createReadingList?.readingListPayload?.readingList) {
dialogOpen = false;
await fetchReadingLists(true);
}
} else if (dialogMode === 'edit' && editingList) {
const result = await client
.mutation(UpdateReadingListDocument, {
input: {
id: editingList.id,
name: formName.trim(),
description: formDescription.trim() || null
}
})
.toPromise();
if (result.error) {
formError = result.error.message;
return;
}
if (result.data?.updateReadingList?.errors?.length) {
formError = result.data.updateReadingList.errors[0]?.message ?? 'Failed to update reading list';
return;
}
if (result.data?.updateReadingList?.readingListPayload?.readingList) {
dialogOpen = false;
await fetchReadingLists(true);
}
}
} catch (e) {
formError = e instanceof Error ? e.message : 'An error occurred';
} finally {
formSubmitting = false;
}
}
async function handleDelete() {
if (!deletingList) return;
deleteSubmitting = true;
deleteError = null;
try {
const result = await client
.mutation(DeleteReadingListDocument, {
input: { id: deletingList.id }
})
.toPromise();
if (result.error) {
deleteError = result.error.message;
return;
}
if (result.data?.deleteReadingList?.errors?.length) {
deleteError = result.data.deleteReadingList.errors[0]?.message ?? 'Failed to delete reading list';
return;
}
if (result.data?.deleteReadingList?.success) {
deleteDialogOpen = false;
deletingList = null;
await fetchReadingLists(true);
}
} catch (e) {
deleteError = e instanceof Error ? e.message : 'An error occurred';
} finally {
deleteSubmitting = false;
}
}
onMount(() => {
if ($isAuthenticated) {
fetchReadingLists();
} else {
fetching = false;
}
});
// Re-fetch when auth changes
$effect(() => {
if ($isAuthenticated) {
fetchReadingLists();
} else {
readingLists = [];
fetching = false;
}
});
</script>
<div class="space-y-6">
<div class="flex items-center justify-between">
<div>
<h1 class="text-3xl font-bold">Reading Lists</h1>
<p class="text-muted-foreground">Organize your novels into collections</p>
</div>
{#if $isAuthenticated}
<Button onclick={openCreateDialog}>
<Plus class="mr-2 h-4 w-4" />
New List
</Button>
{/if}
</div>
{#if !$isAuthenticated}
<!-- Auth gate - sign in prompt -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">Sign in to use Reading Lists</h3>
<p class="text-muted-foreground">
Create and manage your personal reading lists to organize novels.
</p>
</div>
<Button onclick={login}>
<LogIn class="mr-2 h-4 w-4" />
Sign In
</Button>
</div>
</CardContent>
</Card>
{:else if fetching}
<!-- Loading state -->
<div class="flex items-center justify-center py-12">
<div class="text-muted-foreground">Loading your reading lists...</div>
</div>
{:else if error}
<!-- Error state -->
<Card>
<CardContent class="py-6">
<p class="text-destructive">{error}</p>
<Button class="mt-4" onclick={fetchReadingLists}>Try Again</Button>
</CardContent>
</Card>
{:else if readingLists.length === 0}
<!-- Empty state -->
<Card>
<CardContent class="py-12">
<div class="text-center space-y-4">
<BookOpen class="mx-auto h-12 w-12 text-muted-foreground" />
<div>
<h3 class="text-lg font-medium">No reading lists yet</h3>
<p class="text-muted-foreground">
Create your first reading list to start organizing your novels.
</p>
</div>
<Button onclick={openCreateDialog}>
<Plus class="mr-2 h-4 w-4" />
Create Your First List
</Button>
</div>
</CardContent>
</Card>
{:else}
<!-- Reading lists grid -->
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each readingLists as list (list.id)}
<a href={`/reading-lists/${list.id}`} class="block group">
<Card class="h-full transition-colors hover:border-primary/50">
<CardHeader class="pb-2">
<div class="flex items-start justify-between">
<CardTitle class="line-clamp-1">{list.name}</CardTitle>
<div class="flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
class="h-8 w-8"
onclick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openEditDialog(list);
}}
>
<Pencil class="h-4 w-4" />
<span class="sr-only">Edit</span>
</Button>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-destructive hover:text-destructive"
onclick={(e: MouseEvent) => {
e.preventDefault();
e.stopPropagation();
openDeleteDialog(list);
}}
>
<Trash2 class="h-4 w-4" />
<span class="sr-only">Delete</span>
</Button>
</div>
</div>
{#if list.description}
<CardDescription class="line-clamp-2">{list.description}</CardDescription>
{/if}
</CardHeader>
<CardFooter class="pt-2">
<span class="text-sm text-muted-foreground">
{list.itemCount} {list.itemCount === 1 ? 'novel' : 'novels'}
</span>
</CardFooter>
</Card>
</a>
{/each}
</div>
{/if}
</div>
<!-- Create/Edit Dialog -->
<DialogPrimitive.Root bind:open={dialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content class="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
{dialogMode === 'create' ? 'Create Reading List' : 'Edit Reading List'}
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
{dialogMode === 'create' ? 'Create a new reading list to organize your novels.' : 'Update your reading list details.'}
</DialogPrimitive.Description>
</div>
<form onsubmit={handleSubmit} class="space-y-4">
<div class="space-y-2">
<label for="list-name" class="text-sm font-medium">Name</label>
<Input
id="list-name"
type="text"
placeholder="My Reading List"
bind:value={formName}
disabled={formSubmitting}
/>
</div>
<div class="space-y-2">
<label for="list-description" class="text-sm font-medium">Description (optional)</label>
<Textarea
id="list-description"
placeholder="A collection of..."
bind:value={formDescription}
disabled={formSubmitting}
class="min-h-[80px] resize-none"
/>
</div>
{#if formError}
<p class="text-sm text-destructive">{formError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button variant="outline" type="button" disabled={formSubmitting} onclick={() => dialogOpen = false}>
Cancel
</Button>
<Button type="submit" disabled={formSubmitting || !formName.trim()}>
{#if formSubmitting}
{dialogMode === 'create' ? 'Creating...' : 'Saving...'}
{:else}
{dialogMode === 'create' ? 'Create' : 'Save'}
{/if}
</Button>
</div>
</form>
<DialogPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>
<!-- Delete Confirmation Dialog -->
<DialogPrimitive.Root bind:open={deleteDialogOpen}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay class="fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content class="fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg">
<div class="flex flex-col space-y-1.5 text-center sm:text-left">
<DialogPrimitive.Title class="text-lg font-semibold leading-none tracking-tight">
Delete Reading List
</DialogPrimitive.Title>
<DialogPrimitive.Description class="text-sm text-muted-foreground">
Are you sure you want to delete "{deletingList?.name}"? This action cannot be undone.
</DialogPrimitive.Description>
</div>
{#if deleteError}
<p class="text-sm text-destructive">{deleteError}</p>
{/if}
<div class="flex justify-end gap-2">
<Button variant="outline" type="button" disabled={deleteSubmitting} onclick={() => deleteDialogOpen = false}>
Cancel
</Button>
<Button variant="destructive" onclick={handleDelete} disabled={deleteSubmitting}>
{deleteSubmitting ? 'Deleting...' : 'Delete'}
</Button>
</div>
<DialogPrimitive.Close class="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X class="h-4 w-4" />
<span class="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPrimitive.Portal>
</DialogPrimitive.Root>

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

@@ -0,0 +1,18 @@
import { Popover as PopoverPrimitive } from 'bits-ui';
import Content from './popover-content.svelte';
const Root = PopoverPrimitive.Root;
const Trigger = PopoverPrimitive.Trigger;
const Close = PopoverPrimitive.Close;
export {
Root,
Trigger,
Content,
Close,
//
Root as Popover,
Trigger as PopoverTrigger,
Content as PopoverContent,
Close as PopoverClose
};

View File

@@ -0,0 +1,26 @@
<script lang="ts">
import { Popover as PopoverPrimitive } from 'bits-ui';
import { cn } from '$lib/utils.js';
let {
ref = $bindable(null),
sideOffset = 4,
align = 'center',
class: className,
...restProps
}: PopoverPrimitive.ContentProps = $props();
</script>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
bind:ref
data-slot="popover-content"
{sideOffset}
{align}
class={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 overflow-hidden rounded-md border p-4 shadow-md outline-none',
className
)}
{...restProps}
/>
</PopoverPrimitive.Portal>

View File

@@ -0,0 +1,7 @@
import Root from './textarea.svelte';
export {
Root,
//
Root as Textarea
};

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import type { HTMLTextareaAttributes } from 'svelte/elements';
import { cn, type WithElementRef } from '$lib/utils.js';
type Props = WithElementRef<HTMLTextareaAttributes, HTMLTextAreaElement>;
let {
ref = $bindable(null),
value = $bindable(),
class: className,
'data-slot': dataSlot = 'textarea',
...restProps
}: Props = $props();
</script>
<textarea
bind:this={ref}
data-slot={dataSlot}
class={cn(
'border-input bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground shadow-xs flex min-h-[80px] w-full min-w-0 rounded-md border px-3 py-2 text-base outline-none transition-[color,box-shadow] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
bind:value
{...restProps}
></textarea>

View File

@@ -18,6 +18,18 @@ export type Scalars = {
UnsignedInt: { input: any; output: any; } UnsignedInt: { input: any; output: any; }
}; };
export type AddToReadingListError = InvalidOperationError;
export type AddToReadingListInput = {
novelId: Scalars['UnsignedInt']['input'];
readingListId: Scalars['Int']['input'];
};
export type AddToReadingListPayload = {
errors: Maybe<Array<AddToReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
/** Defines when a policy shall be executed. */ /** Defines when a policy shall be executed. */
export const ApplyPolicy = { export const ApplyPolicy = {
/** After the resolver was executed. */ /** After the resolver was executed. */
@@ -29,6 +41,19 @@ export const ApplyPolicy = {
} as const; } as const;
export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy]; export type ApplyPolicy = typeof ApplyPolicy[keyof typeof ApplyPolicy];
export type BookmarkDto = {
chapterId: Scalars['UnsignedInt']['output'];
createdTime: Scalars['Instant']['output'];
description: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
novelId: Scalars['UnsignedInt']['output'];
};
export type BookmarkPayload = {
bookmark: Maybe<BookmarkDto>;
success: Scalars['Boolean']['output'];
};
export type ChapterDto = { export type ChapterDto = {
body: Scalars['String']['output']; body: Scalars['String']['output'];
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
@@ -56,8 +81,9 @@ export type ChapterDtoFilterInput = {
}; };
export type ChapterPullRequestedEvent = { export type ChapterPullRequestedEvent = {
chapterNumber: Scalars['UnsignedInt']['output']; chapterOrder: Scalars['UnsignedInt']['output'];
novelId: Scalars['UnsignedInt']['output']; novelId: Scalars['UnsignedInt']['output'];
volumeId: Scalars['UnsignedInt']['output'];
}; };
export type ChapterReaderDto = { export type ChapterReaderDto = {
@@ -68,13 +94,30 @@ export type ChapterReaderDto = {
lastUpdatedTime: Scalars['Instant']['output']; lastUpdatedTime: Scalars['Instant']['output'];
name: Scalars['String']['output']; name: Scalars['String']['output'];
nextChapterOrder: Maybe<Scalars['UnsignedInt']['output']>; nextChapterOrder: Maybe<Scalars['UnsignedInt']['output']>;
nextChapterVolumeOrder: Maybe<Scalars['Int']['output']>;
novelId: Scalars['UnsignedInt']['output']; novelId: Scalars['UnsignedInt']['output'];
novelName: Scalars['String']['output']; novelName: Scalars['String']['output'];
order: Scalars['UnsignedInt']['output']; order: Scalars['UnsignedInt']['output'];
prevChapterOrder: Maybe<Scalars['UnsignedInt']['output']>; prevChapterOrder: Maybe<Scalars['UnsignedInt']['output']>;
prevChapterVolumeOrder: Maybe<Scalars['Int']['output']>;
revision: Scalars['UnsignedInt']['output']; revision: Scalars['UnsignedInt']['output'];
totalChapters: Scalars['Int']['output']; totalChaptersInVolume: Scalars['Int']['output'];
url: Maybe<Scalars['String']['output']>; url: Maybe<Scalars['String']['output']>;
volumeId: Scalars['UnsignedInt']['output'];
volumeName: Scalars['String']['output'];
volumeOrder: Scalars['Int']['output'];
};
export type CreateReadingListError = InvalidOperationError;
export type CreateReadingListInput = {
description?: InputMaybe<Scalars['String']['input']>;
name: Scalars['String']['input'];
};
export type CreateReadingListPayload = {
errors: Maybe<Array<CreateReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
}; };
export type DeleteJobError = KeyNotFoundError; export type DeleteJobError = KeyNotFoundError;
@@ -99,6 +142,17 @@ export type DeleteNovelPayload = {
errors: Maybe<Array<DeleteNovelError>>; errors: Maybe<Array<DeleteNovelError>>;
}; };
export type DeleteReadingListError = InvalidOperationError;
export type DeleteReadingListInput = {
id: Scalars['Int']['input'];
};
export type DeleteReadingListPayload = {
errors: Maybe<Array<DeleteReadingListError>>;
success: Maybe<Scalars['Boolean']['output']>;
};
export type DuplicateNameError = Error & { export type DuplicateNameError = Error & {
message: Scalars['String']['output']; message: Scalars['String']['output'];
}; };
@@ -108,8 +162,9 @@ export type Error = {
}; };
export type FetchChapterContentsInput = { export type FetchChapterContentsInput = {
chapterNumber: Scalars['UnsignedInt']['input']; chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input']; novelId: Scalars['UnsignedInt']['input'];
volumeId: Scalars['UnsignedInt']['input'];
}; };
export type FetchChapterContentsPayload = { export type FetchChapterContentsPayload = {
@@ -156,6 +211,21 @@ export type InstantFilterInput = {
or?: InputMaybe<Array<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 & { export type InvalidOperationError = Error & {
message: Scalars['String']['output']; message: Scalars['String']['output'];
}; };
@@ -231,15 +301,40 @@ export type ListFilterInputTypeOfNovelTagDtoFilterInput = {
some?: InputMaybe<NovelTagDtoFilterInput>; some?: InputMaybe<NovelTagDtoFilterInput>;
}; };
export type ListFilterInputTypeOfVolumeDtoFilterInput = {
all?: InputMaybe<VolumeDtoFilterInput>;
any?: InputMaybe<Scalars['Boolean']['input']>;
none?: InputMaybe<VolumeDtoFilterInput>;
some?: InputMaybe<VolumeDtoFilterInput>;
};
export type Mutation = { export type Mutation = {
addToReadingList: AddToReadingListPayload;
createReadingList: CreateReadingListPayload;
deleteJob: DeleteJobPayload; deleteJob: DeleteJobPayload;
deleteNovel: DeleteNovelPayload; deleteNovel: DeleteNovelPayload;
deleteReadingList: DeleteReadingListPayload;
fetchChapterContents: FetchChapterContentsPayload; fetchChapterContents: FetchChapterContentsPayload;
importNovel: ImportNovelPayload; importNovel: ImportNovelPayload;
inviteUser: InviteUserPayload; inviteUser: InviteUserPayload;
removeBookmark: RemoveBookmarkPayload;
removeFromReadingList: RemoveFromReadingListPayload;
reorderReadingListItem: ReorderReadingListItemPayload;
runJob: RunJobPayload; runJob: RunJobPayload;
scheduleEventJob: ScheduleEventJobPayload; scheduleEventJob: ScheduleEventJobPayload;
translateText: TranslateTextPayload; translateText: TranslateTextPayload;
updateReadingList: UpdateReadingListPayload;
upsertBookmark: UpsertBookmarkPayload;
};
export type MutationAddToReadingListArgs = {
input: AddToReadingListInput;
};
export type MutationCreateReadingListArgs = {
input: CreateReadingListInput;
}; };
@@ -253,6 +348,11 @@ export type MutationDeleteNovelArgs = {
}; };
export type MutationDeleteReadingListArgs = {
input: DeleteReadingListInput;
};
export type MutationFetchChapterContentsArgs = { export type MutationFetchChapterContentsArgs = {
input: FetchChapterContentsInput; input: FetchChapterContentsInput;
}; };
@@ -268,6 +368,21 @@ export type MutationInviteUserArgs = {
}; };
export type MutationRemoveBookmarkArgs = {
input: RemoveBookmarkInput;
};
export type MutationRemoveFromReadingListArgs = {
input: RemoveFromReadingListInput;
};
export type MutationReorderReadingListItemArgs = {
input: ReorderReadingListItemInput;
};
export type MutationRunJobArgs = { export type MutationRunJobArgs = {
input: RunJobInput; input: RunJobInput;
}; };
@@ -282,9 +397,18 @@ export type MutationTranslateTextArgs = {
input: TranslateTextInput; input: TranslateTextInput;
}; };
export type MutationUpdateReadingListArgs = {
input: UpdateReadingListInput;
};
export type MutationUpsertBookmarkArgs = {
input: UpsertBookmarkInput;
};
export type NovelDto = { export type NovelDto = {
author: PersonDto; author: PersonDto;
chapters: Array<ChapterDto>;
coverImage: Maybe<ImageDto>; coverImage: Maybe<ImageDto>;
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
description: Scalars['String']['output']; description: Scalars['String']['output'];
@@ -298,12 +422,12 @@ export type NovelDto = {
statusOverride: Maybe<NovelStatus>; statusOverride: Maybe<NovelStatus>;
tags: Array<NovelTagDto>; tags: Array<NovelTagDto>;
url: Scalars['String']['output']; url: Scalars['String']['output'];
volumes: Array<VolumeDto>;
}; };
export type NovelDtoFilterInput = { export type NovelDtoFilterInput = {
and?: InputMaybe<Array<NovelDtoFilterInput>>; and?: InputMaybe<Array<NovelDtoFilterInput>>;
author?: InputMaybe<PersonDtoFilterInput>; author?: InputMaybe<PersonDtoFilterInput>;
chapters?: InputMaybe<ListFilterInputTypeOfChapterDtoFilterInput>;
coverImage?: InputMaybe<ImageDtoFilterInput>; coverImage?: InputMaybe<ImageDtoFilterInput>;
createdTime?: InputMaybe<InstantFilterInput>; createdTime?: InputMaybe<InstantFilterInput>;
description?: InputMaybe<StringOperationFilterInput>; description?: InputMaybe<StringOperationFilterInput>;
@@ -318,6 +442,7 @@ export type NovelDtoFilterInput = {
statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>; statusOverride?: InputMaybe<NullableOfNovelStatusOperationFilterInput>;
tags?: InputMaybe<ListFilterInputTypeOfNovelTagDtoFilterInput>; tags?: InputMaybe<ListFilterInputTypeOfNovelTagDtoFilterInput>;
url?: InputMaybe<StringOperationFilterInput>; url?: InputMaybe<StringOperationFilterInput>;
volumes?: InputMaybe<ListFilterInputTypeOfVolumeDtoFilterInput>;
}; };
export type NovelDtoSortInput = { export type NovelDtoSortInput = {
@@ -442,19 +567,28 @@ export type PersonDtoSortInput = {
}; };
export type Query = { export type Query = {
bookmarks: Array<BookmarkDto>;
chapter: Maybe<ChapterReaderDto>; chapter: Maybe<ChapterReaderDto>;
currentUser: Array<UserDto>; currentUser: Maybe<UserDto>;
jobs: Array<SchedulerJob>; jobs: Array<SchedulerJob>;
novels: Maybe<NovelsConnection>; novels: Maybe<NovelsConnection>;
readingList: Maybe<ReadingListDto>;
readingLists: Array<ReadingListDto>;
translationEngines: Array<TranslationEngineDescriptor>; translationEngines: Array<TranslationEngineDescriptor>;
translationRequests: Maybe<TranslationRequestsConnection>; translationRequests: Maybe<TranslationRequestsConnection>;
}; };
export type QueryBookmarksArgs = {
novelId: Scalars['UnsignedInt']['input'];
};
export type QueryChapterArgs = { export type QueryChapterArgs = {
chapterOrder: Scalars['UnsignedInt']['input']; chapterOrder: Scalars['UnsignedInt']['input'];
novelId: Scalars['UnsignedInt']['input']; novelId: Scalars['UnsignedInt']['input'];
preferredLanguage?: Language; preferredLanguage?: Language;
volumeOrder: Scalars['UnsignedInt']['input'];
}; };
@@ -469,6 +603,11 @@ export type QueryNovelsArgs = {
}; };
export type QueryReadingListArgs = {
id: Scalars['Int']['input'];
};
export type QueryTranslationEnginesArgs = { export type QueryTranslationEnginesArgs = {
order?: InputMaybe<Array<TranslationEngineDescriptorSortInput>>; order?: InputMaybe<Array<TranslationEngineDescriptorSortInput>>;
where?: InputMaybe<TranslationEngineDescriptorFilterInput>; where?: InputMaybe<TranslationEngineDescriptorFilterInput>;
@@ -484,6 +623,62 @@ export type QueryTranslationRequestsArgs = {
where?: InputMaybe<TranslationRequestDtoFilterInput>; where?: InputMaybe<TranslationRequestDtoFilterInput>;
}; };
export type ReadingListDto = {
createdTime: Scalars['Instant']['output'];
description: Maybe<Scalars['String']['output']>;
id: Scalars['Int']['output'];
itemCount: Scalars['Int']['output'];
items: Array<ReadingListItemDto>;
name: Scalars['String']['output'];
};
export type ReadingListItemDto = {
addedTime: Scalars['Instant']['output'];
novelId: Scalars['UnsignedInt']['output'];
order: Scalars['Int']['output'];
};
export type ReadingListPayload = {
readingList: Maybe<ReadingListDto>;
success: Scalars['Boolean']['output'];
};
export type RemoveBookmarkError = InvalidOperationError;
export type RemoveBookmarkInput = {
chapterId: Scalars['UnsignedInt']['input'];
};
export type RemoveBookmarkPayload = {
bookmarkPayload: Maybe<BookmarkPayload>;
errors: Maybe<Array<RemoveBookmarkError>>;
};
export type RemoveFromReadingListError = InvalidOperationError;
export type RemoveFromReadingListInput = {
listId: Scalars['Int']['input'];
novelId: Scalars['UnsignedInt']['input'];
};
export type RemoveFromReadingListPayload = {
errors: Maybe<Array<RemoveFromReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type ReorderReadingListItemError = InvalidOperationError;
export type ReorderReadingListItemInput = {
newOrder: Scalars['Int']['input'];
novelId: Scalars['UnsignedInt']['input'];
readingListId: Scalars['Int']['input'];
};
export type ReorderReadingListItemPayload = {
errors: Maybe<Array<ReorderReadingListItemError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type RunJobError = JobPersistenceError; export type RunJobError = JobPersistenceError;
export type RunJobInput = { export type RunJobInput = {
@@ -709,6 +904,32 @@ export type UnsignedIntOperationFilterInputType = {
nlte?: InputMaybe<Scalars['UnsignedInt']['input']>; nlte?: InputMaybe<Scalars['UnsignedInt']['input']>;
}; };
export type UpdateReadingListError = InvalidOperationError;
export type UpdateReadingListInput = {
description?: InputMaybe<Scalars['String']['input']>;
id: Scalars['Int']['input'];
name: Scalars['String']['input'];
};
export type UpdateReadingListPayload = {
errors: Maybe<Array<UpdateReadingListError>>;
readingListPayload: Maybe<ReadingListPayload>;
};
export type UpsertBookmarkError = InvalidOperationError;
export type UpsertBookmarkInput = {
chapterId: Scalars['UnsignedInt']['input'];
description?: InputMaybe<Scalars['String']['input']>;
novelId: Scalars['UnsignedInt']['input'];
};
export type UpsertBookmarkPayload = {
bookmarkPayload: Maybe<BookmarkPayload>;
errors: Maybe<Array<UpsertBookmarkError>>;
};
export type UserDto = { export type UserDto = {
availableInvites: Scalars['Int']['output']; availableInvites: Scalars['Int']['output'];
createdTime: Scalars['Instant']['output']; createdTime: Scalars['Instant']['output'];
@@ -736,6 +957,40 @@ export type UuidOperationFilterInput = {
nlte?: InputMaybe<Scalars['UUID']['input']>; 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 AddToReadingListMutationVariables = Exact<{
input: AddToReadingListInput;
}>;
export type AddToReadingListMutation = { addToReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, itemCount: number } | null } | null, errors: Array<{ message: string }> | null } };
export type CreateReadingListMutationVariables = Exact<{
input: CreateReadingListInput;
}>;
export type CreateReadingListMutation = { createReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, description: string | null, itemCount: number, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } };
export type DeleteNovelMutationVariables = Exact<{ export type DeleteNovelMutationVariables = Exact<{
input: DeleteNovelInput; input: DeleteNovelInput;
}>; }>;
@@ -743,6 +998,13 @@ export type DeleteNovelMutationVariables = Exact<{
export type DeleteNovelMutation = { deleteNovel: { boolean: boolean | null, errors: Array<{ message: string }> | null } }; export type DeleteNovelMutation = { deleteNovel: { boolean: boolean | null, errors: Array<{ message: string }> | null } };
export type DeleteReadingListMutationVariables = Exact<{
input: DeleteReadingListInput;
}>;
export type DeleteReadingListMutation = { deleteReadingList: { success: boolean | null, errors: Array<{ message: string }> | null } };
export type ImportNovelMutationVariables = Exact<{ export type ImportNovelMutationVariables = Exact<{
input: ImportNovelInput; input: ImportNovelInput;
}>; }>;
@@ -757,20 +1019,63 @@ export type InviteUserMutationVariables = Exact<{
export type InviteUserMutation = { inviteUser: { userDto: { id: any, username: string, email: string } | null, errors: Array<{ message: string }> | null } }; export type InviteUserMutation = { inviteUser: { userDto: { id: any, username: string, email: string } | null, errors: Array<{ message: string }> | null } };
export type RemoveBookmarkMutationVariables = Exact<{
input: RemoveBookmarkInput;
}>;
export type RemoveBookmarkMutation = { removeBookmark: { bookmarkPayload: { success: boolean } | null, errors: Array<{ message: string }> | null } };
export type RemoveFromReadingListMutationVariables = Exact<{
input: RemoveFromReadingListInput;
}>;
export type RemoveFromReadingListMutation = { removeFromReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, itemCount: number } | null } | null, errors: Array<{ message: string }> | null } };
export type ReorderReadingListItemMutationVariables = Exact<{
input: ReorderReadingListItemInput;
}>;
export type ReorderReadingListItemMutation = { reorderReadingListItem: { readingListPayload: { success: boolean } | null, errors: Array<{ message: string }> | null } };
export type UpdateReadingListMutationVariables = Exact<{
input: UpdateReadingListInput;
}>;
export type UpdateReadingListMutation = { updateReadingList: { readingListPayload: { success: boolean, readingList: { id: number, name: string, description: string | null, itemCount: number, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } };
export type UpsertBookmarkMutationVariables = Exact<{
input: UpsertBookmarkInput;
}>;
export type UpsertBookmarkMutation = { upsertBookmark: { bookmarkPayload: { success: boolean, bookmark: { id: number, chapterId: any, novelId: any, description: string | null, createdTime: any } | null } | null, errors: Array<{ message: string }> | null } };
export type GetBookmarksQueryVariables = Exact<{
novelId: Scalars['UnsignedInt']['input'];
}>;
export type GetBookmarksQuery = { bookmarks: Array<{ id: number, chapterId: any, novelId: any, description: string | null, createdTime: any }> };
export type GetChapterQueryVariables = Exact<{ export type GetChapterQueryVariables = Exact<{
novelId: Scalars['UnsignedInt']['input']; novelId: Scalars['UnsignedInt']['input'];
volumeOrder: Scalars['UnsignedInt']['input'];
chapterOrder: 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<{ export type NovelQueryVariables = Exact<{
id: Scalars['UnsignedInt']['input']; 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<{ export type NovelsQueryVariables = Exact<{
first?: InputMaybe<Scalars['Int']['input']>; first?: InputMaybe<Scalars['Int']['input']>;
@@ -780,18 +1085,47 @@ 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 GetReadingListQueryVariables = Exact<{
id: Scalars['Int']['input'];
}>;
export type GetReadingListQuery = { readingList: { id: number, name: string, description: string | null, itemCount: number, createdTime: any, items: Array<{ novelId: any, order: number, addedTime: any }> } | null };
export type GetReadingListsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetReadingListsQuery = { readingLists: Array<{ id: number, name: string, description: string | null, itemCount: number, createdTime: any }> };
export type GetReadingListsWithItemsQueryVariables = Exact<{ [key: string]: never; }>;
export type GetReadingListsWithItemsQuery = { readingLists: Array<{ id: number, name: string, description: string | null, itemCount: number, createdTime: any, items: Array<{ novelId: any, order: number, addedTime: any }> }> };
export type GetSettingsPageDataQueryVariables = Exact<{ [key: string]: never; }>; 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 AddToReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"AddToReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"AddToReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"addToReadingList"},"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":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"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<AddToReadingListMutation, AddToReadingListMutationVariables>;
export const CreateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createReadingList"},"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":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"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":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"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<CreateReadingListMutation, CreateReadingListMutationVariables>;
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 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 DeleteReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteReadingList"},"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":"success"}},{"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<DeleteReadingListMutation, DeleteReadingListMutationVariables>;
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 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 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 RemoveBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeBookmark"},"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":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"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<RemoveBookmarkMutation, RemoveBookmarkMutationVariables>;
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 RemoveFromReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"RemoveFromReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoveFromReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"removeFromReadingList"},"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":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"itemCount"}}]}}]}},{"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<RemoveFromReadingListMutation, RemoveFromReadingListMutationVariables>;
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 ReorderReadingListItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ReorderReadingListItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ReorderReadingListItemInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"reorderReadingListItem"},"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":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}}]}},{"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<ReorderReadingListItemMutation, ReorderReadingListItemMutationVariables>;
export const UpdateReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateReadingListInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateReadingList"},"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":"readingListPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"readingList"},"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":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"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<UpdateReadingListMutation, UpdateReadingListMutationVariables>;
export const UpsertBookmarkDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpsertBookmark"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpsertBookmarkInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"upsertBookmark"},"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":"bookmarkPayload"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"success"}},{"kind":"Field","name":{"kind":"Name","value":"bookmark"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}},{"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<UpsertBookmarkMutation, UpsertBookmarkMutationVariables>;
export const GetBookmarksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetBookmarks"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UnsignedInt"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"bookmarks"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"novelId"},"value":{"kind":"Variable","name":{"kind":"Name","value":"novelId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"chapterId"}},{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}}]} as unknown as DocumentNode<GetBookmarksQuery, GetBookmarksQueryVariables>;
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 GetReadingListDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingList"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"Int"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingList"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"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":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"addedTime"}}]}}]}}]}}]} as unknown as DocumentNode<GetReadingListQuery, GetReadingListQueryVariables>;
export const GetReadingListsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingLists"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingLists"},"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":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}}]}}]}}]} as unknown as DocumentNode<GetReadingListsQuery, GetReadingListsQueryVariables>;
export const GetReadingListsWithItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetReadingListsWithItems"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"readingLists"},"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":"itemCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdTime"}},{"kind":"Field","name":{"kind":"Name","value":"items"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"novelId"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"addedTime"}}]}}]}}]}}]} as unknown as DocumentNode<GetReadingListsWithItemsQuery, GetReadingListsWithItemsQueryVariables>;
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>; 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

@@ -0,0 +1,17 @@
mutation AddToReadingList($input: AddToReadingListInput!) {
addToReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
itemCount
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,19 @@
mutation CreateReadingList($input: CreateReadingListInput!) {
createReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
description
itemCount
createdTime
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,10 @@
mutation DeleteReadingList($input: DeleteReadingListInput!) {
deleteReadingList(input: $input) {
success
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,12 @@
mutation RemoveBookmark($input: RemoveBookmarkInput!) {
removeBookmark(input: $input) {
bookmarkPayload {
success
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,17 @@
mutation RemoveFromReadingList($input: RemoveFromReadingListInput!) {
removeFromReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
itemCount
}
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,12 @@
mutation ReorderReadingListItem($input: ReorderReadingListItemInput!) {
reorderReadingListItem(input: $input) {
readingListPayload {
success
}
errors {
... on Error {
message
}
}
}
}

View File

@@ -0,0 +1,19 @@
mutation UpdateReadingList($input: UpdateReadingListInput!) {
updateReadingList(input: $input) {
readingListPayload {
success
readingList {
id
name
description
itemCount
createdTime
}
}
errors {
... on Error {
message
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More