[FA-misc] Switches to using DTOs, updates frontend with details and reader page, updates novel import to be an upsert

This commit is contained in:
gamer147
2025-12-08 18:30:00 -05:00
parent c9d93a4e55
commit 81e4e88ad4
48 changed files with 3298 additions and 329 deletions

View File

@@ -1,4 +1,5 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.DTOs;
using FictionArchive.Service.NovelService.Services;
using HotChocolate.Authorization;
using HotChocolate.Data;
@@ -13,8 +14,186 @@ public class Query
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<Novel> GetNovels(NovelServiceDbContext dbContext)
public IQueryable<NovelDto> GetNovels(
NovelServiceDbContext dbContext,
Language preferredLanguage = Language.En)
{
return dbContext.Novels.AsQueryable();
return dbContext.Novels.Select(novel => new NovelDto
{
Id = novel.Id,
CreatedTime = novel.CreatedTime,
LastUpdatedTime = novel.LastUpdatedTime,
Url = novel.Url,
RawLanguage = novel.RawLanguage,
RawStatus = novel.RawStatus,
StatusOverride = novel.StatusOverride,
ExternalId = novel.ExternalId,
Name = novel.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? novel.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Description = novel.Description.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? novel.Description.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Author = new PersonDto
{
Id = novel.Author.Id,
CreatedTime = novel.Author.CreatedTime,
LastUpdatedTime = novel.Author.LastUpdatedTime,
Name = novel.Author.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? novel.Author.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
ExternalUrl = novel.Author.ExternalUrl
},
Source = new SourceDto
{
Id = novel.Source.Id,
CreatedTime = novel.Source.CreatedTime,
LastUpdatedTime = novel.Source.LastUpdatedTime,
Name = novel.Source.Name,
Key = novel.Source.Key,
Url = novel.Source.Url
},
CoverImage = novel.CoverImage != null
? new ImageDto
{
Id = novel.CoverImage.Id,
CreatedTime = novel.CoverImage.CreatedTime,
LastUpdatedTime = novel.CoverImage.LastUpdatedTime,
NewPath = novel.CoverImage.NewPath
}
: null,
Chapters = novel.Chapters.Select(chapter => new ChapterDto
{
Id = chapter.Id,
CreatedTime = chapter.CreatedTime,
LastUpdatedTime = chapter.LastUpdatedTime,
Revision = chapter.Revision,
Order = chapter.Order,
Url = chapter.Url,
Name = chapter.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Body = chapter.Body.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Images = chapter.Images.Select(image => new ImageDto
{
Id = image.Id,
CreatedTime = image.CreatedTime,
LastUpdatedTime = image.LastUpdatedTime,
NewPath = image.NewPath
}).ToList()
}).ToList(),
Tags = novel.Tags.Select(tag => new NovelTagDto
{
Id = tag.Id,
CreatedTime = tag.CreatedTime,
LastUpdatedTime = tag.LastUpdatedTime,
Key = tag.Key,
TagType = tag.TagType,
DisplayName = tag.DisplayName.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? tag.DisplayName.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Source = tag.Source != null
? new SourceDto
{
Id = tag.Source.Id,
CreatedTime = tag.Source.CreatedTime,
LastUpdatedTime = tag.Source.LastUpdatedTime,
Name = tag.Source.Name,
Key = tag.Source.Key,
Url = tag.Source.Url
}
: null
}).ToList()
});
}
}
[Authorize]
[UseFirstOrDefault]
[UseProjection]
public IQueryable<ChapterReaderDto> GetChapter(
NovelServiceDbContext dbContext,
uint novelId,
uint chapterOrder,
Language preferredLanguage = Language.En)
{
return dbContext.Chapters
.Where(c => c.Novel.Id == novelId && c.Order == chapterOrder)
.Select(chapter => new ChapterReaderDto
{
Id = chapter.Id,
CreatedTime = chapter.CreatedTime,
LastUpdatedTime = chapter.LastUpdatedTime,
Revision = chapter.Revision,
Order = chapter.Order,
Url = chapter.Url,
Name = chapter.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Body = chapter.Body.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Body.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
Images = chapter.Images.Select(image => new ImageDto
{
Id = image.Id,
CreatedTime = image.CreatedTime,
LastUpdatedTime = image.LastUpdatedTime,
NewPath = image.NewPath
}).ToList(),
NovelId = chapter.Novel.Id,
NovelName = chapter.Novel.Name.Texts
.Where(t => t.Language == preferredLanguage)
.Select(t => t.Text)
.FirstOrDefault()
?? chapter.Novel.Name.Texts.Select(t => t.Text).FirstOrDefault()
?? "",
TotalChapters = chapter.Novel.Chapters.Count,
PrevChapterOrder = chapter.Novel.Chapters
.Where(c => c.Order < chapterOrder)
.OrderByDescending(c => c.Order)
.Select(c => (uint?)c.Order)
.FirstOrDefault(),
NextChapterOrder = chapter.Novel.Chapters
.Where(c => c.Order > chapterOrder)
.OrderBy(c => c.Order)
.Select(c => (uint?)c.Order)
.FirstOrDefault()
});
}
}

View File

@@ -0,0 +1,547 @@
// <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("20251208230154_FA-misc_NovelConstraint")]
partial class FAmisc_NovelConstraint
{
/// <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>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<string>("Url")
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("BodyId");
b.HasIndex("NameId");
b.HasIndex("NovelId");
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("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.Novel", "Novel")
.WithMany("Chapters")
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Body");
b.Navigation("Name");
b.Navigation("Novel");
});
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("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("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class FAmisc_NovelConstraint : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter");
migrationBuilder.AlterColumn<long>(
name: "NovelId",
table: "Chapter",
type: "bigint",
nullable: false,
defaultValue: 0L,
oldClrType: typeof(long),
oldType: "bigint",
oldNullable: true);
migrationBuilder.CreateIndex(
name: "IX_Novels_ExternalId_SourceId",
table: "Novels",
columns: new[] { "ExternalId", "SourceId" },
unique: true);
migrationBuilder.AddForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter");
migrationBuilder.DropIndex(
name: "IX_Novels_ExternalId_SourceId",
table: "Novels");
migrationBuilder.AlterColumn<long>(
name: "NovelId",
table: "Chapter",
type: "bigint",
nullable: true,
oldClrType: typeof(long),
oldType: "bigint");
migrationBuilder.AddForeignKey(
name: "FK_Chapter_Novels_NovelId",
table: "Chapter",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id");
}
}
}

View File

@@ -153,7 +153,7 @@ namespace FictionArchive.Service.NovelService.Migrations
b.Property<Guid>("NameId")
.HasColumnType("uuid");
b.Property<long?>("NovelId")
b.Property<long>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
@@ -234,6 +234,9 @@ namespace FictionArchive.Service.NovelService.Migrations
b.HasIndex("SourceId");
b.HasIndex("ExternalId", "SourceId")
.IsUnique();
b.ToTable("Novels");
});
@@ -424,13 +427,17 @@ namespace FictionArchive.Service.NovelService.Migrations
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", null)
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Novel", "Novel")
.WithMany("Chapters")
.HasForeignKey("NovelId");
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Body");
b.Navigation("Name");
b.Navigation("Novel");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>

View File

@@ -0,0 +1,10 @@
using NodaTime;
namespace FictionArchive.Service.NovelService.Models.DTOs;
public abstract class BaseDto<TKey>
{
public TKey Id { get; init; }
public Instant CreatedTime { get; init; }
public Instant LastUpdatedTime { get; init; }
}

View File

@@ -0,0 +1,11 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class ChapterDto : BaseDto<uint>
{
public uint Revision { get; init; }
public uint Order { get; init; }
public string? Url { get; init; }
public required string Name { get; init; }
public required string Body { get; init; }
public required List<ImageDto> Images { get; init; }
}

View File

@@ -0,0 +1,18 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class ChapterReaderDto : BaseDto<uint>
{
public uint Revision { get; init; }
public uint Order { get; init; }
public string? Url { get; init; }
public required string Name { get; init; }
public required string Body { get; init; }
public required List<ImageDto> Images { get; init; }
// Navigation context
public uint NovelId { get; init; }
public required string NovelName { get; init; }
public int TotalChapters { get; init; }
public uint? PrevChapterOrder { get; init; }
public uint? NextChapterOrder { get; init; }
}

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class ImageDto : BaseDto<Guid>
{
public string? NewPath { get; init; }
}

View File

@@ -0,0 +1,20 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Enums;
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class NovelDto : BaseDto<uint>
{
public required PersonDto Author { get; init; }
public required string Url { get; init; }
public Language RawLanguage { get; init; }
public NovelStatus RawStatus { get; init; }
public NovelStatus? StatusOverride { get; init; }
public required SourceDto Source { get; init; }
public required string ExternalId { get; init; }
public required string Name { get; init; }
public required string Description { get; init; }
public required List<ChapterDto> Chapters { get; init; }
public required List<NovelTagDto> Tags { get; init; }
public ImageDto? CoverImage { get; init; }
}

View File

@@ -0,0 +1,11 @@
using FictionArchive.Service.NovelService.Models.Enums;
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class NovelTagDto : BaseDto<uint>
{
public required string Key { get; init; }
public required string DisplayName { get; init; }
public TagType TagType { get; init; }
public SourceDto? Source { get; init; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class PersonDto : BaseDto<uint>
{
public required string Name { get; init; }
public string? ExternalUrl { get; init; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.NovelService.Models.DTOs;
public class SourceDto : BaseDto<uint>
{
public required string Name { get; init; }
public required string Key { get; init; }
public required string Url { get; init; }
}

View File

@@ -1,9 +1,11 @@
using System.ComponentModel.DataAnnotations.Schema;
using FictionArchive.Service.NovelService.Models.Images;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.Shared.Models;
namespace FictionArchive.Service.NovelService.Models.Novels;
[Table("Chapter")]
public class Chapter : BaseEntity<uint>
{
public uint Revision { get; set; }
@@ -15,4 +17,10 @@ public class Chapter : BaseEntity<uint>
// Images appearing in this chapter.
public List<Image> Images { get; set; }
#region Navigation Properties
public Novel Novel { get; set; }
#endregion
}

View File

@@ -44,6 +44,7 @@ public class Program
#region GraphQL
builder.Services.AddDefaultGraphQl<Query, Mutation>()
.ModifyCostOptions(opt => opt.MaxFieldCost = 5000)
.AddAuthorization();
#endregion

View File

@@ -10,10 +10,20 @@ public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServic
: FictionArchiveDbContext(options, logger)
{
public DbSet<Novel> Novels { get; set; }
public DbSet<Chapter> Chapters { get; set; }
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
public DbSet<NovelTag> Tags { get; set; }
public DbSet<LocalizationKey> LocalizationKeys { get; set; }
public DbSet<LocalizationRequest> LocalizationRequests { get; set; }
public DbSet<Image> Images { get; set; }
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.Entity<Novel>()
.HasIndex("ExternalId", "SourceId")
.IsUnique();
}
}

View File

@@ -1,3 +1,4 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.FileService.IntegrationEvents;
using FictionArchive.Service.NovelService.Models.Configuration;
using FictionArchive.Service.NovelService.Models.Enums;
@@ -31,14 +32,241 @@ public class NovelUpdateService
_novelUpdateServiceConfiguration = novelUpdateServiceConfiguration.Value;
}
#region Helper Methods
private async Task<Source> GetOrCreateSource(SourceDescriptor descriptor)
{
var existingSource = await _dbContext.Sources
.FirstOrDefaultAsync(s => s.Key == descriptor.Key);
if (existingSource != null)
{
return existingSource;
}
return new Source
{
Name = descriptor.Name,
Url = descriptor.Url,
Key = descriptor.Key
};
}
private Person GetOrCreateAuthor(
string authorName,
string? authorUrl,
Language rawLanguage,
Person? existingAuthor)
{
// Case 1: No existing author - create new
if (existingAuthor == null)
{
return new Person
{
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
ExternalUrl = authorUrl
};
}
// Case 2: ExternalUrl differs - create new Person
if (existingAuthor.ExternalUrl != authorUrl)
{
return new Person
{
Name = LocalizationKey.CreateFromText(authorName, rawLanguage),
ExternalUrl = authorUrl
};
}
// Case 3: Same URL - update name if different
UpdateLocalizationKey(existingAuthor.Name, authorName, rawLanguage);
return existingAuthor;
}
private static void UpdateLocalizationKey(LocalizationKey key, string newText, Language language)
{
var existingText = key.Texts.FirstOrDefault(t => t.Language == language);
if (existingText != null)
{
existingText.Text = newText;
}
else
{
key.Texts.Add(new LocalizationText
{
Language = language,
Text = newText
});
}
}
private void UpdateNovelMetadata(Novel novel, NovelMetadata metadata)
{
UpdateLocalizationKey(novel.Name, metadata.Name, metadata.RawLanguage);
UpdateLocalizationKey(novel.Description, metadata.Description, metadata.RawLanguage);
novel.RawStatus = metadata.RawStatus;
novel.Url = metadata.Url;
}
private async Task<List<NovelTag>> SynchronizeTags(
List<string> sourceTags,
List<string> systemTags,
Language rawLanguage)
{
var allTagKeys = sourceTags.Concat(systemTags).ToHashSet();
// Query existing tags from DB by Key
var existingTagsInDb = await _dbContext.Tags
.Where(t => allTagKeys.Contains(t.Key))
.ToListAsync();
var existingTagKeyMap = existingTagsInDb.ToDictionary(t => t.Key);
var result = new List<NovelTag>();
// Process source tags
foreach (var tagKey in sourceTags)
{
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
{
result.Add(existingTag);
}
else
{
result.Add(new NovelTag
{
Key = tagKey,
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
TagType = TagType.External
});
}
}
// Process system tags
foreach (var tagKey in systemTags)
{
if (existingTagKeyMap.TryGetValue(tagKey, out var existingTag))
{
result.Add(existingTag);
}
else
{
result.Add(new NovelTag
{
Key = tagKey,
DisplayName = LocalizationKey.CreateFromText(tagKey, rawLanguage),
TagType = TagType.System
});
}
}
return result;
}
private static List<Chapter> SynchronizeChapters(
List<ChapterMetadata> metadataChapters,
Language rawLanguage,
List<Chapter>? existingChapters)
{
existingChapters ??= new List<Chapter>();
var existingOrderSet = existingChapters.Select(c => c.Order).ToHashSet();
// Only add chapters that don't already exist (by Order)
var newChapters = metadataChapters
.Where(mc => !existingOrderSet.Contains(mc.Order))
.Select(mc => new Chapter
{
Order = mc.Order,
Url = mc.Url,
Revision = mc.Revision,
Name = LocalizationKey.CreateFromText(mc.Name, rawLanguage),
Body = new LocalizationKey
{
Texts = new List<LocalizationText>()
}
})
.ToList();
// Combine existing chapters with new ones
return existingChapters.Concat(newChapters).ToList();
}
private static (Image? image, bool shouldPublishEvent) HandleCoverImage(
ImageData? newCoverData,
Image? existingCoverImage)
{
// Case 1: No new cover image - keep existing or null
if (newCoverData == null)
{
return (existingCoverImage, false);
}
// Case 2: New cover, no existing
if (existingCoverImage == null)
{
var newImage = new Image { OriginalPath = newCoverData.Url };
return (newImage, true);
}
// Case 3: Both exist - check if URL changed
if (existingCoverImage.OriginalPath != newCoverData.Url)
{
existingCoverImage.OriginalPath = newCoverData.Url;
existingCoverImage.NewPath = null; // Reset uploaded path
return (existingCoverImage, true);
}
// Case 4: Same cover URL - no change needed
return (existingCoverImage, false);
}
private async Task<Novel> CreateNewNovel(NovelMetadata metadata, Source source)
{
var author = new Person
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl
};
var tags = await SynchronizeTags(
metadata.SourceTags,
metadata.SystemTags,
metadata.RawLanguage);
var chapters = SynchronizeChapters(metadata.Chapters, metadata.RawLanguage, null);
var novel = new Novel
{
Author = author,
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
CoverImage = metadata.CoverImage != null
? new Image { OriginalPath = metadata.CoverImage.Url }
: null,
Chapters = chapters,
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = tags,
Source = source
};
_dbContext.Novels.Add(novel);
return novel;
}
#endregion
public async Task<Novel> ImportNovel(string novelUrl)
{
// Step 1: Get metadata from source adapter
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in _sourceAdapters)
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
break; // Stop after finding adapter
}
}
@@ -47,72 +275,82 @@ public class NovelUpdateService
throw new NotSupportedException("The provided novel url is currently unsupported.");
}
var systemTags = metadata.SystemTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.System
});
var sourceTags = metadata.SourceTags.Select(tag => new NovelTag()
{
Key = tag,
DisplayName = LocalizationKey.CreateFromText(tag, metadata.RawLanguage),
TagType = TagType.External
});
// Step 2: Resolve or create Source
var source = await GetOrCreateSource(metadata.SourceDescriptor);
var addedNovel = _dbContext.Novels.Add(new Novel()
// Step 3: Check for existing novel by ExternalId + Source.Key
var existingNovel = await _dbContext.Novels
.Include(n => n.Author)
.ThenInclude(a => a.Name)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Source)
.Include(n => n.Name)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Description)
.ThenInclude(lk => lk.Texts)
.Include(n => n.Tags)
.Include(n => n.Chapters)
.Include(n => n.CoverImage)
.FirstOrDefaultAsync(n =>
n.ExternalId == metadata.ExternalId &&
n.Source.Key == metadata.SourceDescriptor.Key);
Novel novel;
bool shouldPublishCoverEvent;
if (existingNovel == null)
{
Author = new Person()
{
Name = LocalizationKey.CreateFromText(metadata.AuthorName, metadata.RawLanguage),
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
CoverImage = metadata.CoverImage != null ? new Image()
{
OriginalPath = metadata.CoverImage.Url,
} : null,
Chapters = metadata.Chapters.Select(chapter =>
{
return new Chapter()
{
Order = chapter.Order,
Url = chapter.Url,
Revision = chapter.Revision,
Name = LocalizationKey.CreateFromText(chapter.Name, metadata.RawLanguage),
Body = new LocalizationKey()
{
Texts = new List<LocalizationText>()
}
};
}).ToList(),
Description = LocalizationKey.CreateFromText(metadata.Description, metadata.RawLanguage),
Name = LocalizationKey.CreateFromText(metadata.Name, metadata.RawLanguage),
RawStatus = metadata.RawStatus,
Tags = sourceTags.Concat(systemTags).ToList(),
Source = new Source()
{
Name = metadata.SourceDescriptor.Name,
Url = metadata.SourceDescriptor.Url,
Key = metadata.SourceDescriptor.Key,
}
});
// CREATE PATH: New novel
novel = await CreateNewNovel(metadata, source);
shouldPublishCoverEvent = novel.CoverImage != null;
}
else
{
// UPDATE PATH: Existing novel
novel = existingNovel;
// Update author
novel.Author = GetOrCreateAuthor(
metadata.AuthorName,
metadata.AuthorUrl,
metadata.RawLanguage,
existingNovel.Author);
// Update metadata (Name, Description, RawStatus)
UpdateNovelMetadata(novel, metadata);
// Synchronize tags (remove old, add new, reuse existing)
novel.Tags = await SynchronizeTags(
metadata.SourceTags,
metadata.SystemTags,
metadata.RawLanguage);
// Synchronize chapters (add only)
novel.Chapters = SynchronizeChapters(
metadata.Chapters,
metadata.RawLanguage,
existingNovel.Chapters);
// Handle cover image
(novel.CoverImage, shouldPublishCoverEvent) = HandleCoverImage(
metadata.CoverImage,
existingNovel.CoverImage);
}
await _dbContext.SaveChangesAsync();
// Signal request for cover image if present
if (addedNovel.Entity.CoverImage != null)
// Publish cover image event if needed
if (shouldPublishCoverEvent && novel.CoverImage != null && metadata.CoverImage != null)
{
await _eventBus.Publish(new FileUploadRequestCreatedEvent()
await _eventBus.Publish(new FileUploadRequestCreatedEvent
{
RequestId = addedNovel.Entity.CoverImage.Id,
RequestId = novel.CoverImage.Id,
FileData = metadata.CoverImage.Data,
FilePath = $"Novels/{addedNovel.Entity.Id}/Images/cover.jpg"
FilePath = $"Novels/{novel.Id}/Images/cover.jpg"
});
}
return addedNovel.Entity;
return novel;
}
public async Task<Chapter> PullChapterContents(uint novelId, uint chapterNumber)