diff --git a/.env.example b/.env.example
index 3952e99b..660df6df 100644
--- a/.env.example
+++ b/.env.example
@@ -39,3 +39,9 @@ RESEND_API_KEY=
RESEND_FROM_EMAIL=noreply@textstack.app
# Where SEO backfill / ops failure alerts go. Empty = alerts disabled.
ADMIN_ALERT_EMAIL=
+
+# PDF content cleanup (quality-poll.sh Phase 3 — feat-0007).
+# When true, chapters scoring below the threshold get an LLM cleanup pass
+# via Claude CLI. Off by default — leaves the poller at structure-only.
+CONTENT_CLEANUP_ENABLED=false
+CONTENT_QUALITY_THRESHOLD=60
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 70110691..aefc8498 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,32 @@
## [Unreleased]
+### PDF content quality — Claude cleanup pipeline (2026-05-22)
+
+Slices 1-4 of feat-0007 (`docs/05-features/feat-0007-pdf-content-quality.md`).
+Makes PDF-extracted books readable: heuristics get ~70-75%, the gap to ~90% is
+semantic (running headers in body, fragmented paragraphs, hyphenation, inlined
+footnotes). Closes it with a gated Claude cleanup pass, and logs every fix so
+the deterministic heuristics can ratchet up over time. Marker (ML PDF pipeline)
+was evaluated and shelved — the prod GPU's 4 GB VRAM can't hold its model set.
+
+- **`ChapterContentQualityAnalyzer`** — deterministic 0-100 content-quality
+ score + issue codes (fragmented paragraphs, running headers in body,
+ unmerged hyphenation, orphan page numbers, inlined footnotes) for extracted
+ chapter HTML. Pure C#, 12 unit tests. The gate that decides which chapters
+ warrant an LLM pass.
+- **Score persisted at ingest** — `ContentQualityScore` column on `Chapter` +
+ `UserChapter`, set in both ingestion paths; `BookQualityJob` carries Phase 3
+ tracking counters. Worker logs a per-book score distribution.
+- **`quality-poll.sh` Phase 3** — for each chapter below the quality threshold,
+ Claude CLI fixes structure (preserving content verbatim); a stdlib-only
+ preservation gate (`pdf-cleanup-gate.py`) rejects hallucination or
+ over-deletion via word-multiset diff before the cleaned HTML is written back.
+ Every (messy → clean) pair is logged to `data/pdf-cleanup-dataset/` as fuel
+ for the future heuristic ratchet. Off by default — `CONTENT_CLEANUP_ENABLED`.
+- **Admin observability** — the Book Quality job detail panel shows Phase 3
+ results (chapters cleaned / rejected / skipped).
+
### Mobile reader — autosave restore (2026-05-13)
- **WordCard parity with web WordPopup** — single-word tap on mobile
diff --git a/apps/admin/src/api/client.ts b/apps/admin/src/api/client.ts
index bb1f2b6d..69445bda 100644
--- a/apps/admin/src/api/client.ts
+++ b/apps/admin/src/api/client.ts
@@ -471,6 +471,9 @@ export interface BookQualityJobListItem {
export interface BookQualityJobDetail extends BookQualityJobListItem {
issuesJson: string | null
logOutput: string | null
+ contentChaptersCleaned: number | null
+ contentChaptersRejected: number | null
+ contentChaptersSkipped: number | null
}
export interface BookQualitySettings {
diff --git a/apps/admin/src/pages/BookQualityPage.tsx b/apps/admin/src/pages/BookQualityPage.tsx
index dfac4177..819c1d9a 100644
--- a/apps/admin/src/pages/BookQualityPage.tsx
+++ b/apps/admin/src/pages/BookQualityPage.tsx
@@ -187,6 +187,16 @@ export function BookQualityPage() {
Issues found: {selectedJob.issuesFound} | Fixed: {selectedJob.issuesFixed ?? 0}
)}
+ {(selectedJob.contentChaptersCleaned != null
+ || selectedJob.contentChaptersRejected != null
+ || selectedJob.contentChaptersSkipped != null) && (
+
+ Content cleanup — cleaned: {selectedJob.contentChaptersCleaned ?? 0}
+ {' | '}rejected: {selectedJob.contentChaptersRejected ?? 0}
+ {' | '}skipped: {selectedJob.contentChaptersSkipped ?? 0}
+
+ )}
+
{selectedJob.issuesJson && (
Issues:
diff --git a/backend/src/Api/Endpoints/AdminBookQualityEndpoints.cs b/backend/src/Api/Endpoints/AdminBookQualityEndpoints.cs
index abd43e83..6a40cd68 100644
--- a/backend/src/Api/Endpoints/AdminBookQualityEndpoints.cs
+++ b/backend/src/Api/Endpoints/AdminBookQualityEndpoints.cs
@@ -82,6 +82,7 @@ private static async Task
GetJob(Guid id, IAppDbContext db, Cancellatio
return Results.Ok(new QualityJobDetailDto(
job.Id, job.EditionId, job.UserBookId,
job.Status.ToString(), job.IssuesJson, job.IssuesFound, job.IssuesFixed,
+ job.ContentChaptersCleaned, job.ContentChaptersRejected, job.ContentChaptersSkipped,
job.Error, job.LogOutput,
job.CreatedAt, job.StartedAt, job.FinishedAt,
job.Edition?.Title, job.UserBook?.Title
@@ -134,6 +135,9 @@ private static async Task RetryJob(Guid id, IAppDbContext db, Cancellat
job.IssuesJson = null;
job.IssuesFound = null;
job.IssuesFixed = null;
+ job.ContentChaptersCleaned = null;
+ job.ContentChaptersRejected = null;
+ job.ContentChaptersSkipped = null;
job.StartedAt = null;
job.FinishedAt = null;
await db.SaveChangesAsync(ct);
@@ -177,6 +181,7 @@ public record QualityJobListDto(
public record QualityJobDetailDto(
Guid Id, Guid? EditionId, Guid? UserBookId,
string Status, string? IssuesJson, int? IssuesFound, int? IssuesFixed,
+ int? ContentChaptersCleaned, int? ContentChaptersRejected, int? ContentChaptersSkipped,
string? Error, string? LogOutput,
DateTimeOffset CreatedAt, DateTimeOffset? StartedAt, DateTimeOffset? FinishedAt,
string? EditionTitle, string? UserBookTitle);
diff --git a/backend/src/Api/Endpoints/InternalEndpoints.cs b/backend/src/Api/Endpoints/InternalEndpoints.cs
index 8582a58b..1c7c87a0 100644
--- a/backend/src/Api/Endpoints/InternalEndpoints.cs
+++ b/backend/src/Api/Endpoints/InternalEndpoints.cs
@@ -435,6 +435,9 @@ private static async Task GetQualityJob(
job.IssuesJson,
job.IssuesFound,
job.IssuesFixed,
+ job.ContentChaptersCleaned,
+ job.ContentChaptersRejected,
+ job.ContentChaptersSkipped,
job.Error,
job.LogOutput,
job.CreatedAt,
@@ -458,6 +461,9 @@ private static async Task UpdateQualityJob(
if (req.IssuesFixed.HasValue) job.IssuesFixed = req.IssuesFixed;
if (req.Error is not null) job.Error = req.Error;
if (req.LogOutput is not null) job.LogOutput = req.LogOutput;
+ if (req.ContentChaptersCleaned.HasValue) job.ContentChaptersCleaned = req.ContentChaptersCleaned;
+ if (req.ContentChaptersRejected.HasValue) job.ContentChaptersRejected = req.ContentChaptersRejected;
+ if (req.ContentChaptersSkipped.HasValue) job.ContentChaptersSkipped = req.ContentChaptersSkipped;
if (req.SetStartedAt) job.StartedAt = DateTimeOffset.UtcNow;
if (req.SetFinishedAt) job.FinishedAt = DateTimeOffset.UtcNow;
@@ -506,5 +512,8 @@ public record UpdateQualityJobRequest(
int? IssuesFixed = null,
string? Error = null,
string? LogOutput = null,
+ int? ContentChaptersCleaned = null,
+ int? ContentChaptersRejected = null,
+ int? ContentChaptersSkipped = null,
bool SetStartedAt = false,
bool SetFinishedAt = false);
diff --git a/backend/src/Application/Ingestion/IngestionService.cs b/backend/src/Application/Ingestion/IngestionService.cs
index 9d559f8a..7ff69602 100644
--- a/backend/src/Application/Ingestion/IngestionService.cs
+++ b/backend/src/Application/Ingestion/IngestionService.cs
@@ -4,6 +4,8 @@
using Domain.Enums;
using Domain.Utilities;
using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Logging;
+using TextStack.Extraction.Quality;
namespace Application.Ingestion;
@@ -29,7 +31,8 @@ List Warnings
public record ExtractionWarningDto(int Code, string Message);
-public class IngestionService(IAppDbContext db, IFileStorageService storage)
+public class IngestionService(
+ IAppDbContext db, IFileStorageService storage, ILogger logger)
{
private static readonly TimeSpan StuckJobTimeout = TimeSpan.FromMinutes(10);
@@ -86,9 +89,13 @@ public async Task ProcessParsedBookAsync(
db.Chapters.RemoveRange(existingChapters);
// Create new chapters
+ var qualityScores = new List();
foreach (var ch in parsed.Chapters)
{
var chapterSlug = SlugGenerator.GenerateChapterSlug(ch.Title, ch.Order);
+ var chapterHtml = SanitizeText(ch.Html);
+ var score = ChapterContentQualityAnalyzer.Analyze(chapterHtml).Score;
+ qualityScores.Add(score);
var chapter = new Chapter
{
Id = Guid.NewGuid(),
@@ -96,9 +103,10 @@ public async Task ProcessParsedBookAsync(
ChapterNumber = ch.Order,
Slug = chapterSlug,
Title = SanitizeText(ch.Title),
- Html = SanitizeText(ch.Html),
+ Html = chapterHtml,
PlainText = SanitizeText(ch.PlainText),
WordCount = ch.WordCount,
+ ContentQualityScore = score,
OriginalChapterNumber = ch.OriginalChapterNumber,
PartNumber = ch.PartNumber,
TotalParts = ch.TotalParts,
@@ -108,6 +116,14 @@ public async Task ProcessParsedBookAsync(
db.Chapters.Add(chapter);
}
+ if (qualityScores.Count > 0)
+ {
+ logger.LogInformation(
+ "Content quality for edition {EditionId}: {Count} chapters, avg score {Avg}, {Below} below 60",
+ job.EditionId, qualityScores.Count, (int)qualityScores.Average(),
+ qualityScores.Count(s => s < 60));
+ }
+
// Publish the edition
job.Edition.Status = EditionStatus.Published;
job.Edition.PublishedAt = DateTimeOffset.UtcNow;
diff --git a/backend/src/Domain/Entities/BookQualityJob.cs b/backend/src/Domain/Entities/BookQualityJob.cs
index 44e5d566..4d7c9001 100644
--- a/backend/src/Domain/Entities/BookQualityJob.cs
+++ b/backend/src/Domain/Entities/BookQualityJob.cs
@@ -16,6 +16,14 @@ public class BookQualityJob
public int? IssuesFound { get; set; }
public int? IssuesFixed { get; set; }
+ // ── Content-cleanup phase (Phase 3) — populated by quality-poll.sh ──
+ /// Chapters whose HTML the LLM cleanup pass rewrote and the gate accepted.
+ public int? ContentChaptersCleaned { get; set; }
+ /// Chapters where the LLM output was rejected by the preservation gate.
+ public int? ContentChaptersRejected { get; set; }
+ /// Flagged chapters skipped (non-PDF, or cleanup disabled).
+ public int? ContentChaptersSkipped { get; set; }
+
public string? Error { get; set; }
public string? LogOutput { get; set; }
diff --git a/backend/src/Domain/Entities/Chapter.cs b/backend/src/Domain/Entities/Chapter.cs
index 246f3742..86a589ef 100644
--- a/backend/src/Domain/Entities/Chapter.cs
+++ b/backend/src/Domain/Entities/Chapter.cs
@@ -22,6 +22,12 @@ public class Chapter
/// Total parts the original chapter was split into (for "Part 2 of 5" display)
public int? TotalParts { get; set; }
+ ///
+ /// Deterministic extraction-quality score 0-100 (see ChapterContentQualityAnalyzer).
+ /// Null = not yet analyzed. Below the flag threshold → candidate for LLM cleanup.
+ ///
+ public int? ContentQualityScore { get; set; }
+
public NpgsqlTsVector SearchVector { get; set; } = null!;
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
diff --git a/backend/src/Domain/Entities/UserChapter.cs b/backend/src/Domain/Entities/UserChapter.cs
index 90fdecee..8e62014d 100644
--- a/backend/src/Domain/Entities/UserChapter.cs
+++ b/backend/src/Domain/Entities/UserChapter.cs
@@ -10,6 +10,13 @@ public class UserChapter
public required string Html { get; set; }
public required string PlainText { get; set; }
public int? WordCount { get; set; }
+
+ ///
+ /// Deterministic extraction-quality score 0-100 (see ChapterContentQualityAnalyzer).
+ /// Null = not yet analyzed. Below the flag threshold → candidate for LLM cleanup.
+ ///
+ public int? ContentQualityScore { get; set; }
+
public DateTimeOffset CreatedAt { get; set; }
public UserBook UserBook { get; set; } = null!;
diff --git a/backend/src/Extraction/TextStack.Extraction/Quality/ChapterContentQualityAnalyzer.cs b/backend/src/Extraction/TextStack.Extraction/Quality/ChapterContentQualityAnalyzer.cs
new file mode 100644
index 00000000..975749e7
--- /dev/null
+++ b/backend/src/Extraction/TextStack.Extraction/Quality/ChapterContentQualityAnalyzer.cs
@@ -0,0 +1,151 @@
+using System.Text.RegularExpressions;
+using HtmlAgilityPack;
+
+namespace TextStack.Extraction.Quality;
+
+///
+/// Scores extracted chapter HTML for the structural defects typical of PDF
+/// extraction (see ). Pure, deterministic,
+/// no I/O — the gate that decides which chapters are worth an LLM cleanup pass.
+///
+/// Score starts at 100; each detected defect subtracts a frequency-scaled
+/// penalty. A defect is only reported once it crosses a floor, so trivial
+/// one-off noise doesn't flag an otherwise-clean chapter.
+///
+public static class ChapterContentQualityAnalyzer
+{
+ // Fragment-fraction is only meaningful once a chapter has enough paragraphs.
+ private const int MinParagraphsForFragmentCheck = 4;
+
+ // Compiled, not [GeneratedRegex] — ARM64 SIGILL bug (see Extraction/RULES.md).
+ private static readonly Regex PageNumberOnly =
+ new(@"^\s*\d{1,4}\s*$", RegexOptions.Compiled);
+ private static readonly Regex RunningHeaderPipe =
+ new(@"(^\s*\d{1,4}\s*\|)|(\|\s*\d{1,4}\s*$)", RegexOptions.Compiled);
+ private static readonly Regex HyphenArtifact =
+ new(@"\p{L}[‐‑] \p{Ll}", RegexOptions.Compiled);
+ private static readonly Regex FootnoteStart =
+ new(@"^\s*\d{1,3}\s+\p{Lu}", RegexOptions.Compiled);
+ private static readonly Regex Whitespace =
+ new(@"\s+", RegexOptions.Compiled);
+
+ private static readonly HashSet NoiseGlyphs =
+ new(StringComparer.Ordinal) { "|", "•", "·", "*", "■", "□", "—", "–" };
+
+ public static ContentQualityReport Analyze(string? html)
+ {
+ if (string.IsNullOrWhiteSpace(html))
+ return ContentQualityReport.Clean;
+
+ var doc = new HtmlDocument();
+ doc.LoadHtml(html);
+
+ var paragraphs = (doc.DocumentNode.SelectNodes("//p") ?? Enumerable.Empty())
+ .Select(p => NormalizeText(p.InnerText))
+ .Where(t => t.Length > 0)
+ .ToList();
+
+ if (paragraphs.Count == 0)
+ return ContentQualityReport.Clean;
+
+ var issues = new List();
+ var penalty = 0;
+
+ penalty += ScoreFragments(paragraphs, issues);
+ penalty += ScoreRunningHeaders(paragraphs, issues);
+ penalty += ScoreHyphenation(paragraphs, issues);
+ penalty += ScoreOrphanNumbers(paragraphs, issues);
+ penalty += ScoreFootnotes(paragraphs, issues);
+
+ return new ContentQualityReport(Math.Clamp(100 - penalty, 0, 100), issues);
+ }
+
+ // ── Detectors ──────────────────────────────────────────────────────────
+ // Each returns a penalty (0 = nothing wrong) and appends its issue code
+ // when the defect is real, not incidental.
+
+ private static int ScoreFragments(List paragraphs, List issues)
+ {
+ if (paragraphs.Count < MinParagraphsForFragmentCheck)
+ return 0;
+
+ var fragments = paragraphs.Count(IsFragment);
+ var fraction = (double)fragments / paragraphs.Count;
+
+ // Real signal: ≥12% of paragraphs are fragments, or ≥8 of them outright.
+ if (fraction < 0.12 && fragments < 8)
+ return 0;
+
+ issues.Add(ContentQualityIssue.FragmentedParagraphs);
+ return (int)Math.Min(60, fraction * 150);
+ }
+
+ private static int ScoreRunningHeaders(List paragraphs, List issues)
+ {
+ var pipeHeaders = paragraphs.Count(p => RunningHeaderPipe.IsMatch(p));
+
+ // Identical short paragraphs repeating within one chapter = leaked chrome.
+ var repeats = paragraphs
+ .Where(p => p.Length <= 100)
+ .GroupBy(p => p, StringComparer.Ordinal)
+ .Where(g => g.Count() >= 2)
+ .Sum(g => g.Count() - 1);
+
+ var count = pipeHeaders + repeats;
+ if (count < 2)
+ return 0;
+
+ issues.Add(ContentQualityIssue.RunningHeaderInBody);
+ return Math.Min(25, count * 7);
+ }
+
+ private static int ScoreHyphenation(List paragraphs, List issues)
+ {
+ var count = paragraphs.Sum(p => HyphenArtifact.Matches(p).Count);
+ if (count < 3)
+ return 0;
+
+ issues.Add(ContentQualityIssue.HyphenationArtifacts);
+ return Math.Min(20, count * 2);
+ }
+
+ private static int ScoreOrphanNumbers(List paragraphs, List issues)
+ {
+ var count = paragraphs.Count(IsOrphanNumberOrGlyph);
+ if (count < 2)
+ return 0;
+
+ issues.Add(ContentQualityIssue.OrphanPageNumbers);
+ return Math.Min(15, count * 5);
+ }
+
+ private static int ScoreFootnotes(List paragraphs, List issues)
+ {
+ var count = paragraphs.Count(p => FootnoteStart.IsMatch(p));
+ if (count < 3)
+ return 0;
+
+ issues.Add(ContentQualityIssue.SuspectedFootnotes);
+ return Math.Min(10, count * 2);
+ }
+
+ // ── Helpers ────────────────────────────────────────────────────────────
+
+ /// A stray ≤2-word paragraph that doesn't end a sentence.
+ private static bool IsFragment(string text)
+ {
+ var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (words.Length > 2)
+ return false;
+ var last = text[^1];
+ return last is not ('.' or '!' or '?' or '…' or ':' or ';');
+ }
+
+ private static bool IsOrphanNumberOrGlyph(string text)
+ => PageNumberOnly.IsMatch(text)
+ || (text.Length <= 2 && NoiseGlyphs.Contains(text));
+
+ /// De-entitize, collapse whitespace, trim.
+ private static string NormalizeText(string raw)
+ => Whitespace.Replace(HtmlEntity.DeEntitize(raw) ?? string.Empty, " ").Trim();
+}
diff --git a/backend/src/Extraction/TextStack.Extraction/Quality/ContentQualityReport.cs b/backend/src/Extraction/TextStack.Extraction/Quality/ContentQualityReport.cs
new file mode 100644
index 00000000..7818a533
--- /dev/null
+++ b/backend/src/Extraction/TextStack.Extraction/Quality/ContentQualityReport.cs
@@ -0,0 +1,37 @@
+namespace TextStack.Extraction.Quality;
+
+///
+/// A structural defect detected in extracted chapter HTML. These are the
+/// recurring failure modes of PDF text extraction — the things a clean EPUB
+/// never has.
+///
+public enum ContentQualityIssue
+{
+ /// Many one/two-word <p> in a row — paragraph reconstruction failed.
+ FragmentedParagraphs,
+
+ /// Running headers/footers ("Title | 4") leaked into the body.
+ RunningHeaderInBody,
+
+ /// Line-wrap hyphens left unmerged ("chal‐ lenges").
+ HyphenationArtifacts,
+
+ /// Bare page numbers / dividers surviving as their own paragraphs.
+ OrphanPageNumbers,
+
+ /// Footnote bodies ("1 In this book…") inlined into the flow.
+ SuspectedFootnotes,
+}
+
+///
+/// Deterministic content-quality verdict for one chapter.
+/// is 0-100, higher is cleaner. The caller decides the
+/// flag threshold (default 60 — see feat-0007).
+///
+public sealed record ContentQualityReport(
+ int Score,
+ IReadOnlyList Issues)
+{
+ /// A chapter with no analyzable paragraphs — nothing to score against.
+ public static ContentQualityReport Clean { get; } = new(100, []);
+}
diff --git a/backend/src/Infrastructure/Migrations/20260522170250_AddChapterContentQualityScore.Designer.cs b/backend/src/Infrastructure/Migrations/20260522170250_AddChapterContentQualityScore.Designer.cs
new file mode 100644
index 00000000..44f4e644
--- /dev/null
+++ b/backend/src/Infrastructure/Migrations/20260522170250_AddChapterContentQualityScore.Designer.cs
@@ -0,0 +1,4265 @@
+//
+using System;
+using Infrastructure.Persistence;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using NpgsqlTypes;
+
+#nullable disable
+
+namespace Infrastructure.Migrations
+{
+ [DbContext(typeof(AppDbContext))]
+ [Migration("20260522170250_AddChapterContentQualityScore")]
+ partial class AddChapterContentQualityScore
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.8")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Domain.Entities.AdminRefreshToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AdminUserId")
+ .HasColumnType("uuid")
+ .HasColumnName("admin_user_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("Token")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("token");
+
+ b.HasKey("Id")
+ .HasName("pk_admin_refresh_tokens");
+
+ b.HasIndex("AdminUserId")
+ .HasDatabaseName("ix_admin_refresh_tokens_admin_user_id");
+
+ b.HasIndex("ExpiresAt")
+ .HasDatabaseName("ix_admin_refresh_tokens_expires_at");
+
+ b.HasIndex("Token")
+ .IsUnique()
+ .HasDatabaseName("ix_admin_refresh_tokens_token");
+
+ b.ToTable("admin_refresh_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.AdminSettings", b =>
+ {
+ b.Property("Key")
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("key");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("Value")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("value");
+
+ b.HasKey("Key")
+ .HasName("pk_admin_settings");
+
+ b.ToTable("admin_settings", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.AdminUser", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Email")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("email");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("PasswordHash")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("password_hash");
+
+ b.Property("Role")
+ .HasColumnType("integer")
+ .HasColumnName("role");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_admin_users");
+
+ b.HasIndex("Email")
+ .IsUnique()
+ .HasDatabaseName("ix_admin_users_email");
+
+ b.ToTable("admin_users", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Author", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Bio")
+ .HasColumnType("text")
+ .HasColumnName("bio");
+
+ b.Property("CanonicalOverride")
+ .HasColumnType("text")
+ .HasColumnName("canonical_override");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExternalLinksJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("external_links_json");
+
+ b.Property("Indexable")
+ .HasColumnType("boolean")
+ .HasColumnName("indexable");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("name");
+
+ b.Property("PhotoPath")
+ .HasColumnType("text")
+ .HasColumnName("photo_path");
+
+ b.Property("SeoDescription")
+ .HasColumnType("text")
+ .HasColumnName("seo_description");
+
+ b.Property("SeoFaqsJson")
+ .HasColumnType("text")
+ .HasColumnName("seo_faqs_json");
+
+ b.Property("SeoRelevanceText")
+ .HasColumnType("text")
+ .HasColumnName("seo_relevance_text");
+
+ b.Property("SeoSource")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("seo_source");
+
+ b.Property("SeoThemesJson")
+ .HasColumnType("text")
+ .HasColumnName("seo_themes_json");
+
+ b.Property("SeoTitle")
+ .HasColumnType("text")
+ .HasColumnName("seo_title");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(255)
+ .HasColumnType("character varying(255)")
+ .HasColumnName("slug");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_authors");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_authors_site_id");
+
+ b.HasIndex("SiteId", "Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_authors_site_id_slug");
+
+ b.ToTable("authors", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.AutoPublishJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("FinishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("finished_at");
+
+ b.Property("GeneratedAuthorSeo")
+ .HasColumnType("boolean")
+ .HasColumnName("generated_author_seo");
+
+ b.Property("GeneratedEditionSeo")
+ .HasColumnType("boolean")
+ .HasColumnName("generated_edition_seo");
+
+ b.Property("LogOutput")
+ .HasColumnType("text")
+ .HasColumnName("log_output");
+
+ b.Property("Priority")
+ .HasColumnType("boolean")
+ .HasColumnName("priority");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("started_at");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_auto_publish_jobs");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_auto_publish_jobs_edition_id");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_auto_publish_jobs_site_id");
+
+ b.ToTable("auto_publish_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.BookAsset", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ByteSize")
+ .HasColumnType("bigint")
+ .HasColumnName("byte_size");
+
+ b.Property("ContentType")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("content_type");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Kind")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("kind");
+
+ b.Property("OriginalPath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("original_path");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("storage_path");
+
+ b.HasKey("Id")
+ .HasName("pk_book_assets");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_book_assets_edition_id");
+
+ b.HasIndex("EditionId", "OriginalPath")
+ .IsUnique()
+ .HasDatabaseName("ix_book_assets_edition_id_original_path");
+
+ b.ToTable("book_assets", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.BookCollection", b =>
+ {
+ b.Property("CollectionId")
+ .HasColumnType("uuid")
+ .HasColumnName("collection_id");
+
+ b.Property("BookId")
+ .HasColumnType("uuid")
+ .HasColumnName("book_id");
+
+ b.Property("BookType")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("book_type");
+
+ b.Property("AddedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("added_at");
+
+ b.HasKey("CollectionId", "BookId", "BookType")
+ .HasName("pk_book_collections");
+
+ b.HasIndex("BookId")
+ .HasDatabaseName("ix_book_collections_book_id");
+
+ b.ToTable("book_collections", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.BookFile", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Format")
+ .HasColumnType("integer")
+ .HasColumnName("format");
+
+ b.Property("OriginalFileName")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("original_file_name");
+
+ b.Property("Sha256")
+ .HasColumnType("text")
+ .HasColumnName("sha256");
+
+ b.Property("StoragePath")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("storage_path");
+
+ b.Property("UploadedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("uploaded_at");
+
+ b.HasKey("Id")
+ .HasName("pk_book_files");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_book_files_edition_id");
+
+ b.HasIndex("Sha256")
+ .HasDatabaseName("ix_book_files_sha256");
+
+ b.ToTable("book_files", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.BookQualityJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ContentChaptersCleaned")
+ .HasColumnType("integer")
+ .HasColumnName("content_chapters_cleaned");
+
+ b.Property("ContentChaptersRejected")
+ .HasColumnType("integer")
+ .HasColumnName("content_chapters_rejected");
+
+ b.Property("ContentChaptersSkipped")
+ .HasColumnType("integer")
+ .HasColumnName("content_chapters_skipped");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("FinishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("finished_at");
+
+ b.Property("IssuesFixed")
+ .HasColumnType("integer")
+ .HasColumnName("issues_fixed");
+
+ b.Property("IssuesFound")
+ .HasColumnType("integer")
+ .HasColumnName("issues_found");
+
+ b.Property("IssuesJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("issues_json");
+
+ b.Property("LogOutput")
+ .HasColumnType("text")
+ .HasColumnName("log_output");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("started_at");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("UserBookId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_book_id");
+
+ b.HasKey("Id")
+ .HasName("pk_book_quality_jobs");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_book_quality_jobs_edition_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_book_quality_jobs_status");
+
+ b.HasIndex("UserBookId")
+ .HasDatabaseName("ix_book_quality_jobs_user_book_id");
+
+ b.ToTable("book_quality_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Bookmark", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChapterId")
+ .HasColumnType("uuid")
+ .HasColumnName("chapter_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Locator")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("locator");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Title")
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_bookmarks");
+
+ b.HasIndex("ChapterId")
+ .HasDatabaseName("ix_bookmarks_chapter_id");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_bookmarks_edition_id");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_bookmarks_site_id");
+
+ b.HasIndex("UserId", "SiteId", "EditionId")
+ .HasDatabaseName("ix_bookmarks_user_id_site_id_edition_id");
+
+ b.ToTable("bookmarks", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Chapter", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChapterNumber")
+ .HasColumnType("integer")
+ .HasColumnName("chapter_number");
+
+ b.Property("ContentQualityScore")
+ .HasColumnType("integer")
+ .HasColumnName("content_quality_score");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Html")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("html");
+
+ b.Property("OriginalChapterNumber")
+ .HasColumnType("integer")
+ .HasColumnName("original_chapter_number");
+
+ b.Property("PartNumber")
+ .HasColumnType("integer")
+ .HasColumnName("part_number");
+
+ b.Property("PlainText")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("plain_text");
+
+ b.Property("SearchVector")
+ .IsRequired()
+ .HasColumnType("tsvector")
+ .HasColumnName("search_vector");
+
+ b.Property("Slug")
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.Property("TotalParts")
+ .HasColumnType("integer")
+ .HasColumnName("total_parts");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("WordCount")
+ .HasColumnType("integer")
+ .HasColumnName("word_count");
+
+ b.HasKey("Id")
+ .HasName("pk_chapters");
+
+ b.HasIndex("SearchVector")
+ .HasDatabaseName("ix_chapters_search_vector");
+
+ NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN");
+
+ b.HasIndex("EditionId", "ChapterNumber")
+ .IsUnique()
+ .HasDatabaseName("ix_chapters_edition_id_chapter_number");
+
+ b.HasIndex("EditionId", "Slug")
+ .HasDatabaseName("ix_chapters_edition_id_slug");
+
+ b.ToTable("chapters", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Collection", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Color")
+ .IsRequired()
+ .ValueGeneratedOnAdd()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasDefaultValue("default")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("SortOrder")
+ .HasColumnType("integer")
+ .HasColumnName("sort_order");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_collections");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_collections_user_id");
+
+ b.HasIndex("UserId", "SortOrder")
+ .HasDatabaseName("ix_collections_user_id_sort_order");
+
+ b.ToTable("collections", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Edition", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CanonicalOverride")
+ .HasColumnType("text")
+ .HasColumnName("canonical_override");
+
+ b.Property("CoverPath")
+ .HasColumnType("text")
+ .HasColumnName("cover_path");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Indexable")
+ .HasColumnType("boolean")
+ .HasColumnName("indexable");
+
+ b.Property("IsPublicDomain")
+ .HasColumnType("boolean")
+ .HasColumnName("is_public_domain");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)")
+ .HasColumnName("language");
+
+ b.Property("PublishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("published_at");
+
+ b.Property("SeoDescription")
+ .HasColumnType("text")
+ .HasColumnName("seo_description");
+
+ b.Property("SeoFaqsJson")
+ .HasColumnType("text")
+ .HasColumnName("seo_faqs_json");
+
+ b.Property("SeoRelevanceText")
+ .HasColumnType("text")
+ .HasColumnName("seo_relevance_text");
+
+ b.Property("SeoSource")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("seo_source");
+
+ b.Property("SeoThemesJson")
+ .HasColumnType("text")
+ .HasColumnName("seo_themes_json");
+
+ b.Property("SeoTitle")
+ .HasColumnType("text")
+ .HasColumnName("seo_title");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("slug");
+
+ b.Property("SourceEditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_edition_id");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("Title")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("title");
+
+ b.Property("TocJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("toc_json");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("WorkId")
+ .HasColumnType("uuid")
+ .HasColumnName("work_id");
+
+ b.HasKey("Id")
+ .HasName("pk_editions");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_editions_site_id");
+
+ b.HasIndex("SourceEditionId")
+ .HasDatabaseName("ix_editions_source_edition_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_editions_status");
+
+ b.HasIndex("WorkId", "Language")
+ .IsUnique()
+ .HasDatabaseName("ix_editions_work_id_language");
+
+ b.HasIndex("SiteId", "Language", "Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_editions_site_id_language_slug");
+
+ b.ToTable("editions", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.EditionAuthor", b =>
+ {
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("Order")
+ .HasColumnType("integer")
+ .HasColumnName("order");
+
+ b.Property("Role")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("role");
+
+ b.HasKey("EditionId", "AuthorId")
+ .HasName("pk_edition_authors");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_edition_authors_author_id");
+
+ b.ToTable("edition_authors", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Genre", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Description")
+ .HasColumnType("text")
+ .HasColumnName("description");
+
+ b.Property("Indexable")
+ .HasColumnType("boolean")
+ .HasColumnName("indexable");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("name");
+
+ b.Property("SeoDescription")
+ .HasColumnType("text")
+ .HasColumnName("seo_description");
+
+ b.Property("SeoSource")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("seo_source");
+
+ b.Property("SeoTitle")
+ .HasColumnType("text")
+ .HasColumnName("seo_title");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Slug")
+ .IsRequired()
+ .HasMaxLength(100)
+ .HasColumnType("character varying(100)")
+ .HasColumnName("slug");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_genres");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_genres_site_id");
+
+ b.HasIndex("SiteId", "Slug")
+ .IsUnique()
+ .HasDatabaseName("ix_genres_site_id_slug");
+
+ b.ToTable("genres", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Highlight", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AnchorJson")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("anchor_json");
+
+ b.Property("ChapterId")
+ .HasColumnType("uuid")
+ .HasColumnName("chapter_id");
+
+ b.Property("Color")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("color");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("LastReviewedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_reviewed_at");
+
+ b.Property("NoteText")
+ .HasColumnType("text")
+ .HasColumnName("note_text");
+
+ b.Property("SelectedText")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("selected_text");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserBookId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_book_id");
+
+ b.Property("UserChapterId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_chapter_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.HasKey("Id")
+ .HasName("pk_highlights");
+
+ b.HasIndex("ChapterId")
+ .HasDatabaseName("ix_highlights_chapter_id");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_highlights_edition_id");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_highlights_site_id");
+
+ b.HasIndex("UserBookId")
+ .HasDatabaseName("ix_highlights_user_book_id");
+
+ b.HasIndex("UserChapterId")
+ .HasDatabaseName("ix_highlights_user_chapter_id");
+
+ b.HasIndex("UserId", "SiteId", "EditionId")
+ .HasDatabaseName("ix_highlights_user_id_site_id_edition_id")
+ .HasFilter("edition_id IS NOT NULL");
+
+ b.HasIndex("UserId", "SiteId", "UserBookId")
+ .HasDatabaseName("ix_highlights_user_id_site_id_user_book_id")
+ .HasFilter("user_book_id IS NOT NULL");
+
+ b.ToTable("highlights", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.IngestionJob", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AttemptCount")
+ .HasColumnType("integer")
+ .HasColumnName("attempt_count");
+
+ b.Property("BookFileId")
+ .HasColumnType("uuid")
+ .HasColumnName("book_file_id");
+
+ b.Property("Confidence")
+ .HasColumnType("double precision")
+ .HasColumnName("confidence");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Error")
+ .HasColumnType("text")
+ .HasColumnName("error");
+
+ b.Property("FinishedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("finished_at");
+
+ b.Property("SourceEditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("source_edition_id");
+
+ b.Property("SourceFormat")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("source_format");
+
+ b.Property("StartedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("started_at");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.Property("TargetLanguage")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)")
+ .HasColumnName("target_language");
+
+ b.Property("TextSource")
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("text_source");
+
+ b.Property("UnitsCount")
+ .HasColumnType("integer")
+ .HasColumnName("units_count");
+
+ b.Property("WarningsJson")
+ .HasColumnType("jsonb")
+ .HasColumnName("warnings_json");
+
+ b.Property("WorkId")
+ .HasColumnType("uuid")
+ .HasColumnName("work_id");
+
+ b.HasKey("Id")
+ .HasName("pk_ingestion_jobs");
+
+ b.HasIndex("BookFileId")
+ .HasDatabaseName("ix_ingestion_jobs_book_file_id");
+
+ b.HasIndex("CreatedAt")
+ .HasDatabaseName("ix_ingestion_jobs_created_at");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_ingestion_jobs_edition_id");
+
+ b.HasIndex("SourceEditionId")
+ .HasDatabaseName("ix_ingestion_jobs_source_edition_id");
+
+ b.HasIndex("Status")
+ .HasDatabaseName("ix_ingestion_jobs_status");
+
+ b.HasIndex("WorkId")
+ .HasDatabaseName("ix_ingestion_jobs_work_id");
+
+ b.ToTable("ingestion_jobs", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.LintResult", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChapterNumber")
+ .HasColumnType("integer")
+ .HasColumnName("chapter_number");
+
+ b.Property("Code")
+ .IsRequired()
+ .HasMaxLength(10)
+ .HasColumnType("character varying(10)")
+ .HasColumnName("code");
+
+ b.Property("Context")
+ .HasColumnType("text")
+ .HasColumnName("context");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("LineNumber")
+ .HasColumnType("integer")
+ .HasColumnName("line_number");
+
+ b.Property("Message")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("message");
+
+ b.Property("Severity")
+ .IsRequired()
+ .HasMaxLength(20)
+ .HasColumnType("character varying(20)")
+ .HasColumnName("severity");
+
+ b.HasKey("Id")
+ .HasName("pk_lint_results");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_lint_results_edition_id");
+
+ b.ToTable("lint_results", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.Note", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("ChapterId")
+ .HasColumnType("uuid")
+ .HasColumnName("chapter_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("HighlightId")
+ .HasColumnType("uuid")
+ .HasColumnName("highlight_id");
+
+ b.Property("Locator")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("locator");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Text")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("text");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Version")
+ .HasColumnType("integer")
+ .HasColumnName("version");
+
+ b.HasKey("Id")
+ .HasName("pk_notes");
+
+ b.HasIndex("ChapterId")
+ .HasDatabaseName("ix_notes_chapter_id");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_notes_edition_id");
+
+ b.HasIndex("HighlightId")
+ .IsUnique()
+ .HasDatabaseName("ix_notes_highlight_id");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_notes_site_id");
+
+ b.HasIndex("UserId", "SiteId", "EditionId")
+ .HasDatabaseName("ix_notes_user_id_site_id_edition_id");
+
+ b.ToTable("notes", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.PasswordResetToken", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("ExpiresAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("expires_at");
+
+ b.Property("TokenHash")
+ .IsRequired()
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("token_hash");
+
+ b.Property("Used")
+ .HasColumnType("boolean")
+ .HasColumnName("used");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.HasKey("Id")
+ .HasName("pk_password_reset_tokens");
+
+ b.HasIndex("TokenHash")
+ .IsUnique()
+ .HasDatabaseName("ix_password_reset_tokens_token_hash");
+
+ b.HasIndex("UserId")
+ .HasDatabaseName("ix_password_reset_tokens_user_id");
+
+ b.ToTable("password_reset_tokens", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.PendingVocabularyWord", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BookTitle")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("book_title");
+
+ b.Property("ChapterId")
+ .HasColumnType("uuid")
+ .HasColumnName("chapter_id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("Definition")
+ .HasMaxLength(2000)
+ .HasColumnType("character varying(2000)")
+ .HasColumnName("definition");
+
+ b.Property("EditionId")
+ .HasColumnType("uuid")
+ .HasColumnName("edition_id");
+
+ b.Property("Language")
+ .IsRequired()
+ .HasMaxLength(8)
+ .HasColumnType("character varying(8)")
+ .HasColumnName("language");
+
+ b.Property("Priority")
+ .HasColumnType("double precision")
+ .HasColumnName("priority");
+
+ b.Property("Sentence")
+ .HasMaxLength(1000)
+ .HasColumnType("character varying(1000)")
+ .HasColumnName("sentence");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("Source")
+ .IsRequired()
+ .HasMaxLength(40)
+ .HasColumnType("character varying(40)")
+ .HasColumnName("source");
+
+ b.Property("Translation")
+ .HasMaxLength(500)
+ .HasColumnType("character varying(500)")
+ .HasColumnName("translation");
+
+ b.Property("UserBookId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_book_id");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Word")
+ .IsRequired()
+ .HasMaxLength(200)
+ .HasColumnType("character varying(200)")
+ .HasColumnName("word");
+
+ b.Property("ZipfRank")
+ .HasColumnType("integer")
+ .HasColumnName("zipf_rank");
+
+ b.Property("ZipfScore")
+ .HasColumnType("double precision")
+ .HasColumnName("zipf_score");
+
+ b.HasKey("Id")
+ .HasName("pk_pending_vocabulary_words");
+
+ b.HasIndex("ChapterId")
+ .HasDatabaseName("ix_pending_vocabulary_words_chapter_id");
+
+ b.HasIndex("EditionId")
+ .HasDatabaseName("ix_pending_vocabulary_words_edition_id");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_pending_vocabulary_words_site_id");
+
+ b.HasIndex("UserBookId")
+ .HasDatabaseName("ix_pending_vocabulary_words_user_book_id");
+
+ b.HasIndex("UserId", "SiteId", "CreatedAt")
+ .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_created_at");
+
+ b.HasIndex("UserId", "SiteId", "Priority")
+ .IsDescending(false, false, true)
+ .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_priority");
+
+ b.HasIndex("UserId", "SiteId", "Word", "Language")
+ .IsUnique()
+ .HasDatabaseName("ix_pending_vocabulary_words_user_id_site_id_word_language");
+
+ b.ToTable("pending_vocabulary_words", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.ReadingGoal", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("CreatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at");
+
+ b.Property("GoalType")
+ .IsRequired()
+ .HasMaxLength(50)
+ .HasColumnType("character varying(50)")
+ .HasColumnName("goal_type");
+
+ b.Property("IsActive")
+ .HasColumnType("boolean")
+ .HasColumnName("is_active");
+
+ b.Property("SiteId")
+ .HasColumnType("uuid")
+ .HasColumnName("site_id");
+
+ b.Property("StreakMinMinutes")
+ .HasColumnType("integer")
+ .HasColumnName("streak_min_minutes");
+
+ b.Property("TargetValue")
+ .HasColumnType("integer")
+ .HasColumnName("target_value");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.Property("UserId")
+ .HasColumnType("uuid")
+ .HasColumnName("user_id");
+
+ b.Property("Year")
+ .HasColumnType("integer")
+ .HasColumnName("year");
+
+ b.HasKey("Id")
+ .HasName("pk_reading_goals");
+
+ b.HasIndex("SiteId")
+ .HasDatabaseName("ix_reading_goals_site_id");
+
+ b.HasIndex("UserId", "SiteId")
+ .HasDatabaseName("ix_reading_goals_user_id_site_id");
+
+ b.HasIndex("UserId", "SiteId", "GoalType")
+ .IsUnique()
+ .HasDatabaseName("ix_reading_goals_user_id_site_id_goal_type");
+
+ b.ToTable("reading_goals", (string)null);
+ });
+
+ modelBuilder.Entity("Domain.Entities.ReadingProgress", b =>
+ {
+ b.Property