Initial commit

This commit is contained in:
gamer147
2025-11-17 22:58:50 -05:00
commit 3bb8f7f158
63 changed files with 3733 additions and 0 deletions

View File

@@ -0,0 +1,6 @@
namespace FictionArchive.Service.NovelService.Constants;
public static class SystemTags
{
public const string Nsfw = "Nsfw";
}

View File

@@ -0,0 +1,23 @@
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
USER $APP_UID
WORKDIR /app
EXPOSE 8080
EXPOSE 8081
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
ARG BUILD_CONFIGURATION=Release
WORKDIR /src
COPY ["FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj", "FictionArchive.Service.NovelService/"]
RUN dotnet restore "FictionArchive.Service.NovelService/FictionArchive.Service.NovelService.csproj"
COPY . .
WORKDIR "/src/FictionArchive.Service.NovelService"
RUN dotnet build "./FictionArchive.Service.NovelService.csproj" -c $BUILD_CONFIGURATION -o /app/build
FROM build AS publish
ARG BUILD_CONFIGURATION=Release
RUN dotnet publish "./FictionArchive.Service.NovelService.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
FROM base AS final
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "FictionArchive.Service.NovelService.dll"]

View File

@@ -0,0 +1,43 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HotChocolate.AspNetCore" Version="15.1.11" />
<PackageReference Include="HotChocolate.Data.EntityFramework" Version="15.1.11" />
<PackageReference Include="HotChocolate.Types.Scalars" Version="15.1.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
</ItemGroup>
<ItemGroup>
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FictionArchive.Common\FictionArchive.Common.csproj" />
<ProjectReference Include="..\FictionArchive.Service.Shared\FictionArchive.Service.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Models\Interfaces\" />
<Folder Include="Services\GraphQL\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,107 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Localization;
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.GraphQL;
public class Mutation
{
// TODO Make this kick off a job in the background somehow. Probably want to think of how jobs will work across services
// Also of course need to make it a proper 'upsert'
public async Task<Novel> ImportNovel(string novelUrl, NovelServiceDbContext dbContext,
IEnumerable<ISourceAdapter> adapters)
{
NovelMetadata? metadata = null;
foreach (ISourceAdapter sourceAdapter in adapters)
{
if (await sourceAdapter.CanProcessNovel(novelUrl))
{
metadata = await sourceAdapter.GetMetadata(novelUrl);
}
}
if (metadata == null)
{
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
});
var addedNovel = dbContext.Novels.Add(new Novel()
{
Author = new Person()
{
Name = metadata.AuthorName,
ExternalUrl = metadata.AuthorUrl,
},
RawLanguage = metadata.RawLanguage,
Url = metadata.Url,
ExternalId = metadata.ExternalId,
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,
}
});
await dbContext.SaveChangesAsync();
return addedNovel.Entity;
}
public async Task<Chapter> FetchChapterContents(uint novelId,
uint chapterNumber,
NovelServiceDbContext dbContext,
IEnumerable<ISourceAdapter> sourceAdapters)
{
var novel = await dbContext.Novels.Where(novel => novel.Id == novelId)
.Include(novel => novel.Chapters)
.ThenInclude(chapter => chapter.Body)
.ThenInclude(body => body.Texts)
.Include(novel => novel.Source)
.FirstOrDefaultAsync();
var chapter = novel.Chapters.Where(chapter => chapter.Order == chapterNumber).FirstOrDefault();
var adapter = sourceAdapters.FirstOrDefault(adapter => adapter.SourceDescriptor.Key == novel.Source.Key);
var rawChapter = await adapter.GetRawChapter(chapter.Url);
chapter.Body.Texts.Add(new LocalizationText()
{
Text = rawChapter,
Language = novel.RawLanguage
});
await dbContext.SaveChangesAsync();
return chapter;
}
}

View File

@@ -0,0 +1,16 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Services;
namespace FictionArchive.Service.NovelService.GraphQL;
public class Query
{
[UsePaging]
[UseProjection]
[UseFiltering]
[UseSorting]
public IQueryable<Novel> GetNovels(NovelServiceDbContext dbContext)
{
return dbContext.Novels.AsQueryable();
}
}

View File

@@ -0,0 +1,409 @@
// <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 Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251118021857_Initial")]
partial class Initial
{
/// <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.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKey");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TranslationEngineId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<long>("BodyId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("NameId")
.HasColumnType("bigint");
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<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
b.HasIndex("SourceId");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("TagType")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("DisplayNameId");
b.HasIndex("NovelId");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("TranslationEngines");
});
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", null)
.WithMany("Chapters")
.HasForeignKey("NovelId");
b.Navigation("Body");
b.Navigation("Name");
});
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.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("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.Novel", null)
.WithMany("Tags")
.HasForeignKey("NovelId");
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source")
.WithMany()
.HasForeignKey("SourceId");
b.Navigation("DisplayName");
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Navigation("Texts");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Navigation("Chapters");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,313 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class Initial : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "LocalizationKey",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationKey", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Person",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
ExternalUrl = table.Column<string>(type: "text", nullable: true),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Person", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Sources",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Sources", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TranslationEngines",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayName = table.Column<string>(type: "text", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_TranslationEngines", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Novels",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AuthorId = table.Column<long>(type: "bigint", nullable: false),
Url = table.Column<string>(type: "text", nullable: false),
RawLanguage = table.Column<int>(type: "integer", nullable: false),
RawStatus = table.Column<int>(type: "integer", nullable: false),
StatusOverride = table.Column<int>(type: "integer", nullable: true),
SourceId = table.Column<long>(type: "bigint", nullable: false),
ExternalId = table.Column<string>(type: "text", nullable: false),
NameId = table.Column<long>(type: "bigint", nullable: false),
DescriptionId = table.Column<long>(type: "bigint", nullable: false),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Novels", x => x.Id);
table.ForeignKey(
name: "FK_Novels_LocalizationKey_DescriptionId",
column: x => x.DescriptionId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Novels_LocalizationKey_NameId",
column: x => x.NameId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Novels_Person_AuthorId",
column: x => x.AuthorId,
principalTable: "Person",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Novels_Sources_SourceId",
column: x => x.SourceId,
principalTable: "Sources",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "LocalizationText",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Language = table.Column<int>(type: "integer", nullable: false),
Text = table.Column<string>(type: "text", nullable: false),
TranslationEngineId = table.Column<long>(type: "bigint", nullable: true),
LocalizationKeyId = table.Column<long>(type: "bigint", nullable: true),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_LocalizationText", x => x.Id);
table.ForeignKey(
name: "FK_LocalizationText_LocalizationKey_LocalizationKeyId",
column: x => x.LocalizationKeyId,
principalTable: "LocalizationKey",
principalColumn: "Id");
table.ForeignKey(
name: "FK_LocalizationText_TranslationEngines_TranslationEngineId",
column: x => x.TranslationEngineId,
principalTable: "TranslationEngines",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Chapter",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Revision = table.Column<long>(type: "bigint", nullable: false),
Order = table.Column<long>(type: "bigint", nullable: false),
Url = table.Column<string>(type: "text", nullable: true),
NameId = table.Column<long>(type: "bigint", nullable: false),
BodyId = table.Column<long>(type: "bigint", nullable: false),
NovelId = table.Column<long>(type: "bigint", nullable: true),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Chapter", x => x.Id);
table.ForeignKey(
name: "FK_Chapter_LocalizationKey_BodyId",
column: x => x.BodyId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Chapter_LocalizationKey_NameId",
column: x => x.NameId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Chapter_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<long>(type: "bigint", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Key = table.Column<string>(type: "text", nullable: false),
DisplayNameId = table.Column<long>(type: "bigint", nullable: false),
TagType = table.Column<int>(type: "integer", nullable: false),
SourceId = table.Column<long>(type: "bigint", nullable: true),
NovelId = table.Column<long>(type: "bigint", nullable: true),
CreatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
UpdatedUtc = table.Column<DateTime>(type: "timestamp with time zone", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_LocalizationKey_DisplayNameId",
column: x => x.DisplayNameId,
principalTable: "LocalizationKey",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Tags_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Tags_Sources_SourceId",
column: x => x.SourceId,
principalTable: "Sources",
principalColumn: "Id");
});
migrationBuilder.CreateIndex(
name: "IX_Chapter_BodyId",
table: "Chapter",
column: "BodyId");
migrationBuilder.CreateIndex(
name: "IX_Chapter_NameId",
table: "Chapter",
column: "NameId");
migrationBuilder.CreateIndex(
name: "IX_Chapter_NovelId",
table: "Chapter",
column: "NovelId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationText_LocalizationKeyId",
table: "LocalizationText",
column: "LocalizationKeyId");
migrationBuilder.CreateIndex(
name: "IX_LocalizationText_TranslationEngineId",
table: "LocalizationText",
column: "TranslationEngineId");
migrationBuilder.CreateIndex(
name: "IX_Novels_AuthorId",
table: "Novels",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_Novels_DescriptionId",
table: "Novels",
column: "DescriptionId");
migrationBuilder.CreateIndex(
name: "IX_Novels_NameId",
table: "Novels",
column: "NameId");
migrationBuilder.CreateIndex(
name: "IX_Novels_SourceId",
table: "Novels",
column: "SourceId");
migrationBuilder.CreateIndex(
name: "IX_Tags_DisplayNameId",
table: "Tags",
column: "DisplayNameId");
migrationBuilder.CreateIndex(
name: "IX_Tags_NovelId",
table: "Tags",
column: "NovelId");
migrationBuilder.CreateIndex(
name: "IX_Tags_SourceId",
table: "Tags",
column: "SourceId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Chapter");
migrationBuilder.DropTable(
name: "LocalizationText");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.DropTable(
name: "TranslationEngines");
migrationBuilder.DropTable(
name: "Novels");
migrationBuilder.DropTable(
name: "LocalizationKey");
migrationBuilder.DropTable(
name: "Person");
migrationBuilder.DropTable(
name: "Sources");
}
}
}

View File

@@ -0,0 +1,413 @@
// <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 Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251118023157_AddSourceKey")]
partial class AddSourceKey
{
/// <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.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKey");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TranslationEngineId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<long>("BodyId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("NameId")
.HasColumnType("bigint");
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<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
b.HasIndex("SourceId");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("TagType")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("DisplayNameId");
b.HasIndex("NovelId");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("TranslationEngines");
});
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", null)
.WithMany("Chapters")
.HasForeignKey("NovelId");
b.Navigation("Body");
b.Navigation("Name");
});
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.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("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.Novel", null)
.WithMany("Tags")
.HasForeignKey("NovelId");
b.HasOne("FictionArchive.Service.NovelService.Models.Novels.Source", "Source")
.WithMany()
.HasForeignKey("SourceId");
b.Navigation("DisplayName");
b.Navigation("Source");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Navigation("Texts");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Novels.Novel", b =>
{
b.Navigation("Chapters");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class AddSourceKey : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Key",
table: "Sources",
type: "text",
nullable: false,
defaultValue: "");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Key",
table: "Sources");
}
}
}

View File

@@ -0,0 +1,432 @@
// <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 Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
[Migration("20251118030953_FixTagAssociation")]
partial class FixTagAssociation
{
/// <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.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKey");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TranslationEngineId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<long>("BodyId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("NameId")
.HasColumnType("bigint");
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<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
b.HasIndex("SourceId");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("TagType")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.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.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", null)
.WithMany("Chapters")
.HasForeignKey("NovelId");
b.Navigation("Body");
b.Navigation("Name");
});
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.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("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("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.Novel", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,80 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
/// <inheritdoc />
public partial class FixTagAssociation : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags");
migrationBuilder.DropIndex(
name: "IX_Tags_NovelId",
table: "Tags");
migrationBuilder.DropColumn(
name: "NovelId",
table: "Tags");
migrationBuilder.CreateTable(
name: "NovelNovelTag",
columns: table => new
{
NovelsId = table.Column<long>(type: "bigint", nullable: false),
TagsId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_NovelNovelTag", x => new { x.NovelsId, x.TagsId });
table.ForeignKey(
name: "FK_NovelNovelTag_Novels_NovelsId",
column: x => x.NovelsId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_NovelNovelTag_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_NovelNovelTag_TagsId",
table: "NovelNovelTag",
column: "TagsId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "NovelNovelTag");
migrationBuilder.AddColumn<long>(
name: "NovelId",
table: "Tags",
type: "bigint",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Tags_NovelId",
table: "Tags",
column: "NovelId");
migrationBuilder.AddForeignKey(
name: "FK_Tags_Novels_NovelId",
table: "Tags",
column: "NovelId",
principalTable: "Novels",
principalColumn: "Id");
}
}
}

View File

@@ -0,0 +1,429 @@
// <auto-generated />
using System;
using FictionArchive.Service.NovelService.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace FictionArchive.Service.NovelService.Migrations
{
[DbContext(typeof(NovelServiceDbContext))]
partial class NovelServiceDbContextModelSnapshot : 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.NovelService.Models.Localization.LocalizationKey", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("LocalizationKey");
});
modelBuilder.Entity("FictionArchive.Service.NovelService.Models.Localization.LocalizationText", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<int>("Language")
.HasColumnType("integer");
b.Property<long?>("LocalizationKeyId")
.HasColumnType("bigint");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("TranslationEngineId")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<long>("BodyId")
.HasColumnType("bigint");
b.Property<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("NameId")
.HasColumnType("bigint");
b.Property<long?>("NovelId")
.HasColumnType("bigint");
b.Property<long>("Order")
.HasColumnType("bigint");
b.Property<long>("Revision")
.HasColumnType("bigint");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DescriptionId")
.HasColumnType("bigint");
b.Property<string>("ExternalId")
.IsRequired()
.HasColumnType("text");
b.Property<long>("NameId")
.HasColumnType("bigint");
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<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("DescriptionId");
b.HasIndex("NameId");
b.HasIndex("SourceId");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<long>("DisplayNameId")
.HasColumnType("bigint");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<long?>("SourceId")
.HasColumnType("bigint");
b.Property<int>("TagType")
.HasColumnType("integer");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("ExternalUrl")
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.HasColumnType("timestamp with time zone");
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<DateTime>("CreatedUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasColumnType("text");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text");
b.Property<DateTime>("UpdatedUtc")
.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.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", null)
.WithMany("Chapters")
.HasForeignKey("NovelId");
b.Navigation("Body");
b.Navigation("Name");
});
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.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("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("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.Novel", b =>
{
b.Navigation("Chapters");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,10 @@
namespace FictionArchive.Service.NovelService.Models.Enums;
public enum NovelStatus
{
Unknown,
InProgress,
Completed,
Hiatus,
Abandoned
}

View File

@@ -0,0 +1,9 @@
namespace FictionArchive.Service.NovelService.Models.Enums;
public enum TagType
{
System,
External,
UserDefined,
Genre,
}

View File

@@ -0,0 +1,25 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Novels;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationKey : BaseEntity<uint>
{
public List<LocalizationText> Texts { get; set; }
public static LocalizationKey CreateFromText(string text, Language language)
{
return new LocalizationKey()
{
Texts = new List<LocalizationText>()
{
new LocalizationText()
{
Language = language,
Text = text
}
}
};
}
}

View File

@@ -0,0 +1,11 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Novels;
namespace FictionArchive.Service.NovelService.Models.Localization;
public class LocalizationText : BaseEntity<uint>
{
public Language Language { get; set; }
public string Text { get; set; }
public TranslationEngine? TranslationEngine { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.NovelService.Models.Novels;
public abstract class BaseEntity<TKey>
{
public uint Id { get; set; }
public DateTime CreatedUtc { get; set; }
public DateTime UpdatedUtc { get; set; }
}

View File

@@ -0,0 +1,13 @@
using FictionArchive.Service.NovelService.Models.Localization;
namespace FictionArchive.Service.NovelService.Models.Novels;
public class Chapter : BaseEntity<uint>
{
public uint Revision { get; set; }
public uint Order { get; set; }
public string? Url { get; set; }
public LocalizationKey Name { get; set; }
public LocalizationKey Body { get; set; }
}

View File

@@ -0,0 +1,24 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Localization;
using NovelStatus = FictionArchive.Service.NovelService.Models.Enums.NovelStatus;
namespace FictionArchive.Service.NovelService.Models.Novels;
public class Novel : BaseEntity<uint>
{
public Person Author { get; set; }
public string Url { get; set; }
public Language RawLanguage { get; set; }
public NovelStatus RawStatus { get; set; }
public NovelStatus? StatusOverride { get; set; }
public Source Source { get; set; }
public string ExternalId { get; set; }
public LocalizationKey Name { get; set; }
public LocalizationKey Description { get; set; }
public List<Chapter> Chapters { get; set; }
public List<NovelTag> Tags { get; set; }
}

View File

@@ -0,0 +1,14 @@
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.Localization;
namespace FictionArchive.Service.NovelService.Models.Novels;
public class NovelTag : BaseEntity<uint>
{
public string Key { get; set; }
public LocalizationKey DisplayName { get; set; }
public TagType TagType { get; set; }
public Source? Source { get; set; }
public List<Novel> Novels { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.Novels;
public class Person : BaseEntity<uint>
{
public string Name { get; set; }
public string? ExternalUrl { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.NovelService.Models.Novels;
public class Source : BaseEntity<uint>
{
public string Name { get; set; }
public string Key { get; set; }
public string Url { get; set; }
}

View File

@@ -0,0 +1,12 @@
using System.ComponentModel.DataAnnotations.Schema;
using FictionArchive.Service.NovelService.Models.Novels;
namespace FictionArchive.Service.NovelService.Models;
public class SourceConfiguration : BaseEntity<uint>
{
public string Key { get; set; }
[Column(TypeName = "jsonb")]
public string Configuration { get; set; }
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Models.Novels;
public class TranslationEngine : BaseEntity<uint>
{
public string Key { get; set; }
public string DisplayName { get; set; }
}

View File

@@ -0,0 +1,10 @@
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class ChapterMetadata
{
public uint Revision { get; set; }
public uint Order { get; set; }
public string? Url { get; set; }
public string Name { get; set; }
}

View File

@@ -0,0 +1,22 @@
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Models.Enums;
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class NovelMetadata
{
public string Name { get; set; }
public string Description { get; set; }
public string AuthorName { get; set; }
public string AuthorUrl { get; set; }
public string Url { get; set; }
public string ExternalId { get; set; }
public Language RawLanguage { get; set; }
public NovelStatus RawStatus { get; set; }
public List<ChapterMetadata> Chapters { get; set; }
public List<string> SourceTags { get; set; }
public List<string> SystemTags { get; set; }
public SourceDescriptor SourceDescriptor { get; set; }
}

View File

@@ -0,0 +1,8 @@
namespace FictionArchive.Service.NovelService.Models.SourceAdapters;
public class SourceDescriptor
{
public string Name { get; set; }
public string Key { get; set; }
public string Url { get; set; }
}

View File

@@ -0,0 +1,74 @@
using FictionArchive.Service.NovelService.GraphQL;
using FictionArchive.Service.NovelService.Services;
using FictionArchive.Service.NovelService.Services.SourceAdapters;
using FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
using FictionArchive.Service.Shared.Services.GraphQL;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService;
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMemoryCache();
#region GraphQL
builder.Services.AddGraphQLServer()
.AddQueryType<Query>()
.AddMutationType<Mutation>()
.AddType<UnsignedIntType>()
.AddMutationConventions(applyToAllMutations: true)
.AddFiltering(opt => opt.AddDefaults().BindRuntimeType<uint, UnsignedIntOperationFilterInputType>())
.AddSorting()
.AddProjections();
#endregion
#region Database
builder.Services.AddDbContext<NovelServiceDbContext>(opt =>
{
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection"));
});
#endregion
#region Source Adapters
builder.Services.Configure<NovelpiaConfiguration>(builder.Configuration.GetSection("Novelpia"));
builder.Services.AddHttpClient<NovelpiaAuthMessageHandler>(client =>
{
client.BaseAddress = new Uri("https://novelpia.com");
});
builder.Services.AddHttpClient<ISourceAdapter, NovelpiaAdapter>(client =>
{
client.BaseAddress = new Uri("https://novelpia.com");
})
.AddHttpMessageHandler<NovelpiaAuthMessageHandler>();
#endregion
builder.Services.AddHealthChecks();
var app = builder.Build();
// Update database
using (var scope = app.Services.CreateScope())
{
var dbContext = scope.ServiceProvider.GetRequiredService<NovelServiceDbContext>();
dbContext.UpdateDatabase();
}
app.UseHttpsRedirection();
app.MapHealthChecks("/healthz");
app.MapGraphQL();
app.Run();
}
}

View File

@@ -0,0 +1,41 @@
{
"$schema": "http://json.schemastore.org/launchsettings.json",
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:32130",
"sslPort": 44387
}
},
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "swagger",
"applicationUrl": "http://localhost:5101",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"launchUrl": "graphql",
"applicationUrl": "https://localhost:7208;http://localhost:5101",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"launchUrl": "swagger",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -0,0 +1,33 @@
using FictionArchive.Service.NovelService.Models.Novels;
using Microsoft.EntityFrameworkCore;
namespace FictionArchive.Service.NovelService.Services;
public class NovelServiceDbContext(DbContextOptions options, ILogger<NovelServiceDbContext> logger)
: DbContext(options)
{
public DbSet<Novel> Novels { get; set; }
public DbSet<Source> Sources { get; set; }
public DbSet<TranslationEngine> TranslationEngines { get; set; }
public DbSet<NovelTag> Tags { get; set; }
private readonly ILogger _logger = logger;
public void UpdateDatabase()
{
IEnumerable<string> pendingMigrations = Database.GetPendingMigrations();
if (!pendingMigrations.Any())
{
_logger.LogDebug("No pending migrations found, continuing.");
return;
}
foreach (string migration in pendingMigrations)
{
_logger.LogInformation("Found pending migration with name {migrationName}.", migration);
}
_logger.LogInformation("Attempting to apply pending migrations...");
Database.Migrate();
_logger.LogInformation("Migrations applied.");
}
}

View File

@@ -0,0 +1,12 @@
using FictionArchive.Service.NovelService.Models.Novels;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
namespace FictionArchive.Service.NovelService.Services.SourceAdapters;
public interface ISourceAdapter
{
public SourceDescriptor SourceDescriptor { get; }
public Task<bool> CanProcessNovel(string url);
public Task<NovelMetadata> GetMetadata(string novelUrl);
public Task<string> GetRawChapter(string chapterUrl);
}

View File

@@ -0,0 +1,200 @@
using System.Net;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using FictionArchive.Common.Enums;
using FictionArchive.Service.NovelService.Constants;
using FictionArchive.Service.NovelService.Models.Enums;
using FictionArchive.Service.NovelService.Models.SourceAdapters;
namespace FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
public class NovelpiaAdapter : ISourceAdapter
{
private readonly HttpClient _httpClient;
private readonly ILogger _logger;
private const string NovelIdRegex = @"novelpia.com\/novel\/(\d+)";
private const string ChapterIdRegex = @"novelpia.com\/viewer\/(\d+)";
private const string EpisodeListEndpoint = "/proc/episode_list";
private const string ChapterDownloadEndpoint = "/proc/viewer_data/";
private const string SourceKey = "novelpia";
private const string SourceName = "Novelpia";
private const string SourceUrl = "https://novelpia.com";
private const string ChapterDownloadFailedMessage = "본인인증";
public NovelpiaAdapter(HttpClient httpClient, ILogger<NovelpiaAdapter> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public SourceDescriptor SourceDescriptor
{
get
{
return new SourceDescriptor()
{
Name = SourceName,
Key = SourceKey,
Url = SourceUrl
};
}
}
public async Task<bool> CanProcessNovel(string url)
{
return Regex.IsMatch(url, @"https://novelpia.com/novel/(\d+)");
}
public async Task<NovelMetadata> GetMetadata(string novelUrl)
{
// PROCESS
// Get novelurl
// Title is <div class="ep-info-line epnew-novel-title">따먹히는 순애 금태양</div>
// Author is <a class="writer-name" href="/user/579482">구다수 </a>
// Chapters are gotten from the episode_list proc
uint novelId = uint.Parse(Regex.Match(novelUrl, NovelIdRegex).Groups[1].Value);
NovelMetadata novel = new NovelMetadata()
{
Url = novelUrl,
RawLanguage = Language.Kr,
ExternalId = novelId.ToString(),
SystemTags = new List<string>(),
SourceTags = new List<string>(),
Chapters = new List<ChapterMetadata>(),
SourceDescriptor = SourceDescriptor
};
// Novel metadata
var novelData = await _httpClient.GetStringAsync(novelUrl);
var novelNameMatch = Regex.Match(novelData, @"<div class=""ep-info-line epnew-novel-title"">(.+)<\/div>");
var authorMatch = Regex.Match(novelData, @"(?s)<a\s+class=""writer-name""\s+href=""([^""]+)"">\s*(.*?)\s*<\/a>");
var descriptionMatch = Regex.Match(novelData, @"(?s)<div\s+class=""synopsis"">\s*(.*?)\s*<\/div>");
novel.Name = novelNameMatch.Groups[1].Value;
novel.Description = descriptionMatch.Groups[1].Value;
novel.AuthorName = authorMatch.Groups[2].Value;
novel.AuthorUrl = authorMatch.Groups[2].Value;
// Some badge info
var badgeSet = Regex.Match(novelData, @"(?s)<p\s+class=""in-badge"">(.*?)<\/p>");
var badgeMatches = Regex.Matches(badgeSet.Groups[1].Value, @"<span[^>]*>(.*?)<\/span>");
foreach (Match badge in badgeMatches)
{
var innerText = badge.Groups[1].Value;
if (innerText == "19")
{
novel.SystemTags.Add(SystemTags.Nsfw);
}
if (innerText == "완결")
{
novel.RawStatus = NovelStatus.Completed;
}
else
{
novel.RawStatus = NovelStatus.InProgress;
}
}
// Novel tags
HashSet<string> tags = new HashSet<string>();
var tagSetMatch = Regex.Match(novelData, @"(?s)<p\s+class=""writer-tag"">(.*?)<\/p>");
var tagMatches =
Regex.Matches(tagSetMatch.Groups[1].Value, @"<span[^>]*>#(.*?)<\/span>");
foreach (Match tagMatch in tagMatches)
{
var tagText = tagMatch.Groups[1].Value;
tags.Add(tagText);
}
foreach (string tag in tags)
{
novel.SourceTags.Add(tag);
}
// Chapters
uint page = 0;
List<ChapterMetadata> chapters = new List<ChapterMetadata>();
List<uint> seenChapterIds = new List<uint>();
uint chapterOrder = 0;
while (true)
{
await Task.Delay(500);
_logger.LogInformation("Next chapter batch");
var response = await _httpClient.PostAsync(EpisodeListEndpoint, new FormUrlEncodedContent(new Dictionary<string, string>
{
{"novel_no", novelId.ToString()},
{"sort", "DOWN"},
{"page", page.ToString()}
}));
var responseContent = await response.Content.ReadAsStringAsync();
var capturedChapters = Regex.Matches(responseContent, @"id=""bookmark_(\d+)""></i>(.+?)</b>");
if (seenChapterIds.Contains(uint.Parse(capturedChapters[0].Groups[1].Value)))
{
break;
}
foreach (Match chapter in capturedChapters)
{
string chapterId = chapter.Groups[1].Value;
string chapterName = chapter.Groups[2].Value;
chapters.Add(new ChapterMetadata
{
Revision = 0,
Order = chapterOrder,
Url = $"https://novelpia.com/viewer/{chapterId}",
Name = chapterName
});
seenChapterIds.Add(uint.Parse(chapterId));
chapterOrder++;
}
page++;
}
novel.Chapters = chapters;
return novel;
}
public async Task<string> GetRawChapter(string chapterUrl)
{
var chapterId = uint.Parse(Regex.Match(chapterUrl, ChapterIdRegex).Groups[1].Value);
var endpoint = ChapterDownloadEndpoint + chapterId;
var result = await _httpClient.PostAsync(endpoint, null);
var responseContent = await result.Content.ReadAsStringAsync();
if (string.IsNullOrEmpty(responseContent) || responseContent.Contains(ChapterDownloadFailedMessage))
{
throw new Exception();
}
StringBuilder builder = new StringBuilder();
using var doc = JsonDocument.Parse(responseContent);
JsonElement root = doc.RootElement;
// Get the "s" array
JsonElement sArray = root.GetProperty("s");
foreach (JsonElement item in sArray.EnumerateArray())
{
string text = item.GetProperty("text").GetString();
if (text.Contains("cover-wrapper"))
{
continue;
}
if (text.Contains("opacity: 0"))
{
continue;
}
builder.Append(WebUtility.HtmlDecode(text));
}
return builder.ToString();
}
}

View File

@@ -0,0 +1,64 @@
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
namespace FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
public class NovelpiaAuthMessageHandler : DelegatingHandler
{
private readonly HttpClient _httpClient;
private readonly IMemoryCache _cache;
private readonly NovelpiaConfiguration _configuration;
private const string CacheKey = "novelpia_loginkey";
private const string LoginUrl = "/proc/login";
private const string LoginSuccessMessage = "감사합니다";
private const string UserAgent =
"Mozilla/5.0 (Linux; Android 6.0; Nexus 5 Build/MRA58N) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Mobile Safari/537.36";
public NovelpiaAuthMessageHandler(HttpClient httpClient, IOptions<NovelpiaConfiguration> configuration, IMemoryCache cache)
{
_httpClient = httpClient;
_configuration = configuration.Value;
_cache = cache;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
string loginKey = await GetLoginKey();
request.Headers.Add("cookie", $"LOGINKEY={loginKey}");
request.Headers.UserAgent.ParseAdd(UserAgent);
return await base.SendAsync(request, cancellationToken);
}
private async Task<string> GetLoginKey()
{
if (!_cache.TryGetValue(CacheKey, out string? loginKey))
{
var random = new Random();
var characters = "0123456789abcdef";
var firstPart = new string(Enumerable.Range(0, 32).Select(_ => characters[random.Next(characters.Length)]).ToArray());
var secondPart = new string(Enumerable.Range(0, 32).Select(_ => characters[random.Next(characters.Length)]).ToArray());
loginKey = firstPart + "_" + secondPart;
HttpRequestMessage loginMessage = new HttpRequestMessage(HttpMethod.Post, LoginUrl);
loginMessage.Headers.Add("cookie", $"LOGINKEY={loginKey}");
loginMessage.Headers.UserAgent.ParseAdd(UserAgent);
loginMessage.Content = new FormUrlEncodedContent(new Dictionary<string, string>
{
{ "redirecturl", string.Empty },
{ "email", _configuration.Username },
{ "wd", _configuration.Password }
});
var response = await _httpClient.SendAsync(loginMessage);
using (var streamReader = new StreamReader(response.Content.ReadAsStream()))
{
if (streamReader.ReadToEnd().Contains(LoginSuccessMessage))
{
_cache.Set(CacheKey, loginKey);
}
}
}
return loginKey;
}
}

View File

@@ -0,0 +1,7 @@
namespace FictionArchive.Service.NovelService.Services.SourceAdapters.Novelpia;
public class NovelpiaConfiguration
{
public string Username { get; set; }
public string Password { get; set; }
}

View File

@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@@ -0,0 +1,16 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Novelpia": {
"Username": "REPLACE_ME",
"Password": "REPLACE_ME"
},
"ConnectionStrings": {
"DefaultConnection": "Host=localhost;Database=FictionArchive_NovelService;Username=postgres;password=postgres"
},
"AllowedHosts": "*"
}