From ef5260a111df89cd246c49139121b03b473bd245 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Wed, 19 Aug 2026 17:54:20 -0700 Subject: [PATCH] Add ChapterKind for front/back matter chapters Chapters can now be marked FrontMatter/Body/BackMatter. Number stays the manuscript sort key for every chapter; the author-facing display number is now computed per-request as the chapter's ordinal among Body chapters only, so a foreword or afterword no longer shifts the numbering of the rest of the book. Surfaced through the API, agent toolset, and import toolset. --- src/Novelly.Api/Agent/NovelAgentToolset.cs | 73 +- src/Novelly.Api/Chapters/Chapter.cs | 3 + src/Novelly.Api/Chapters/ChapterContracts.cs | 14 +- src/Novelly.Api/Chapters/ChapterEndpoints.cs | 32 +- src/Novelly.Api/Chapters/ChapterKind.cs | 8 + src/Novelly.Api/Chapters/ChapterNumbering.cs | 25 + src/Novelly.Api/Chapters/ChapterService.cs | 11 + .../20260820005308_AddChapterKind.Designer.cs | 1289 +++++++++++++++++ .../20260820005308_AddChapterKind.cs | 30 + .../Migrations/NovelDbContextModelSnapshot.cs | 5 + src/Novelly.Api/Imports/ImportAgentToolset.cs | 5 +- .../Novelly.Api.Tests/ChapterServiceTests.cs | 58 + 12 files changed, 1526 insertions(+), 27 deletions(-) create mode 100644 src/Novelly.Api/Chapters/ChapterKind.cs create mode 100644 src/Novelly.Api/Chapters/ChapterNumbering.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.cs diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 4e96927..e42c177 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -385,7 +385,12 @@ public class NovelAgentToolset( "list_chapters", "List the novel's chapters in manuscript order with beat and word counts.", new JsonSchemaBuilder().Build(), - async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())); + async (novelId, _, ct) => + { + var list = await chapters.ListAsync(novelId, ct); + var displayNumbers = ChapterNumbering.DisplayNumbers(list); + return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null)); + }); yield return new AgentTool( "get_chapter", @@ -396,15 +401,25 @@ public class NovelAgentToolset( async (_, input, ct) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); - return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId); + var chapter = await chapters.GetAsync(chapterId, ct); + if (chapter is null) + { + return new ToolNotFound("Chapter", chapterId); + } + + var displayNumber = await chapters.DisplayNumberAsync(chapter, ct); + return chapter.ToResponse(displayNumber); }); yield return new AgentTool( "create_chapter", - "Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", + "Add a chapter. Its number is appended to the end of the manuscript unless you supply one. " + + "Front matter (foreword, introduction, prologue) and back matter (afterword, about the " + + "author) are labeled by title alone and do not count against the numbered chapters.", new JsonSchemaBuilder() .Str("title", "Chapter title.", required: true) - .Int("number", "Position in the manuscript, 1-based.") + .Int("number", "Manuscript position, 1-based, counting front and back matter.") + .Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames()) .Str("summary", "What the chapter covers.") .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.") .Str("notes", "Anything else worth recording.") @@ -413,26 +428,39 @@ public class NovelAgentToolset( .Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), - async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest( - JsonInput.RequiredString(input, "title"), - JsonInput.Int(input, "number"), - JsonInput.String(input, "summary"), - JsonInput.Strings(input, "locations"), - JsonInput.String(input, "notes"), - JsonInput.Enum(input, "status") ?? DraftStatus.Planned, - JsonInput.Int(input, "target_word_count"), - JsonInput.String(input, "prose"), - JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId)); + async (novelId, input, ct) => + { + var chapter = await chapters.CreateAsync(novelId, new CreateChapterRequest( + JsonInput.RequiredString(input, "title"), + JsonInput.Int(input, "number"), + JsonInput.Enum(input, "kind") ?? ChapterKind.Body, + JsonInput.String(input, "summary"), + JsonInput.Strings(input, "locations"), + JsonInput.String(input, "notes"), + JsonInput.Enum(input, "status") ?? DraftStatus.Planned, + JsonInput.Int(input, "target_word_count"), + JsonInput.String(input, "prose"), + JsonInput.Strings(input, "tags")), ct); + + if (chapter is null) + { + return new ToolNotFound("Novel", novelId); + } + + var displayNumber = await chapters.DisplayNumberAsync(chapter, ct); + return chapter.ToResponse(displayNumber); + }); yield return new AgentTool( "update_chapter", - "Revise a chapter's title, number, summary, locations, notes, status or drafted " + "Revise a chapter's title, number, kind, summary, locations, notes, status or drafted " + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + "word count is recomputed automatically.", new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter to update.", required: true) .Str("title", "New title.") - .Int("number", "Position in the manuscript.") + .Int("number", "Manuscript position, 1-based, counting front and back matter.") + .Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames()) .Str("summary", "What the chapter covers.") .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.") .Str("notes", "Anything else worth recording.") @@ -444,18 +472,27 @@ public class NovelAgentToolset( async (_, input, ct) => { var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); - return await OrNotFound(chapters.UpdateAsync( + var chapter = await chapters.UpdateAsync( chapterId, new UpdateChapterRequest( JsonInput.String(input, "title"), JsonInput.Int(input, "number"), + JsonInput.Enum(input, "kind"), JsonInput.String(input, "summary"), JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status"), JsonInput.Int(input, "target_word_count"), JsonInput.String(input, "prose"), - JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId); + JsonInput.Strings(input, "tags")), ct); + + if (chapter is null) + { + return new ToolNotFound("Chapter", chapterId); + } + + var displayNumber = await chapters.DisplayNumberAsync(chapter, ct); + return chapter.ToResponse(displayNumber); }); yield return new AgentTool( diff --git a/src/Novelly.Api/Chapters/Chapter.cs b/src/Novelly.Api/Chapters/Chapter.cs index db0d58a..c283e2c 100644 --- a/src/Novelly.Api/Chapters/Chapter.cs +++ b/src/Novelly.Api/Chapters/Chapter.cs @@ -16,6 +16,8 @@ public class Chapter public int Number { get; set; } + public ChapterKind Kind { get; set; } = ChapterKind.Body; + public string Title { get; set; } = string.Empty; public string? Summary { get; set; } @@ -44,6 +46,7 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration { entity.Property(c => c.Title).IsRequired().HasMaxLength(300); entity.Property(c => c.Status).HasConversion().HasMaxLength(32); + entity.Property(c => c.Kind).HasConversion().HasMaxLength(32); entity.HasIndex(c => new { c.NovelId, c.Number }); } } diff --git a/src/Novelly.Api/Chapters/ChapterContracts.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs index 6b4c7f6..26e82b2 100644 --- a/src/Novelly.Api/Chapters/ChapterContracts.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -10,6 +10,8 @@ public record ChapterSummaryResponse( Guid Id, Guid NovelId, int Number, + ChapterKind Kind, + int? DisplayNumber, string Title, string? Summary, IReadOnlyList Locations, @@ -24,6 +26,8 @@ public record ChapterResponse( Guid Id, Guid NovelId, int Number, + ChapterKind Kind, + int? DisplayNumber, string Title, string? Summary, IReadOnlyList Locations, @@ -39,6 +43,7 @@ public record ChapterResponse( public record CreateChapterRequest( string Title, int? Number = null, + ChapterKind Kind = ChapterKind.Body, string? Summary = null, IReadOnlyList? Locations = null, string? Notes = null, @@ -63,6 +68,7 @@ public class CreateChapterRequestValidator : IModelValidator? Locations = null, string? Notes = null, @@ -115,8 +121,8 @@ file static class ChapterValidation public static class ChapterMapping { - public static ChapterResponse ToResponse(this Chapter c) => new( - c.Id, c.NovelId, c.Number, c.Title, c.Summary, + public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new( + c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary, [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], c.Notes, c.Status, c.TargetWordCount, @@ -125,8 +131,8 @@ public static class ChapterMapping [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], c.UpdatedAt); - public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( - c.Id, c.NovelId, c.Number, c.Title, c.Summary, + public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new( + c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary, [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], c.Status, c.TargetWordCount, c.Beats.Count, c.WordCount, diff --git a/src/Novelly.Api/Chapters/ChapterEndpoints.cs b/src/Novelly.Api/Chapters/ChapterEndpoints.cs index eceeb74..f48efe1 100644 --- a/src/Novelly.Api/Chapters/ChapterEndpoints.cs +++ b/src/Novelly.Api/Chapters/ChapterEndpoints.cs @@ -12,7 +12,12 @@ public static class ChapterEndpoints .AddEndpointFilter(); novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) => - Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()))) + { + var chapters = await service.ListAsync(novelId, ct); + var displayNumbers = ChapterNumbering.DisplayNumbers(chapters); + return Results.Ok(chapters.Select(c => + c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null))); + }) .WithSummary("List a novel's chapters in manuscript order."); novelScoped.MapPost("/", async ( @@ -24,7 +29,8 @@ public static class ChapterEndpoints return Results.NotFound(); } - var created = chapter.ToResponse(); + var displayNumber = await service.DisplayNumberAsync(chapter, ct); + var created = chapter.ToResponse(displayNumber); return Results.Created($"/api/chapters/{created.Id}", created); }) .WithSummary("Add a chapter."); @@ -34,12 +40,30 @@ public static class ChapterEndpoints .AddEndpointFilter(); chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => - (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) + { + var chapter = await service.GetAsync(id, ct); + if (chapter is null) + { + return Results.NotFound(); + } + + var displayNumber = await service.DisplayNumberAsync(chapter, ct); + return chapter.ToResponse(displayNumber).ToApiResult(); + }) .WithSummary("Read a chapter with its beats and prose."); chapters.MapPatch("/{id:guid}", async ( Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) + { + var chapter = await service.UpdateAsync(id, request, ct); + if (chapter is null) + { + return Results.NotFound(); + } + + var displayNumber = await service.DisplayNumberAsync(chapter, ct); + return chapter.ToResponse(displayNumber).ToApiResult(); + }) .WithSummary("Update a chapter."); chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => diff --git a/src/Novelly.Api/Chapters/ChapterKind.cs b/src/Novelly.Api/Chapters/ChapterKind.cs new file mode 100644 index 0000000..2252e6c --- /dev/null +++ b/src/Novelly.Api/Chapters/ChapterKind.cs @@ -0,0 +1,8 @@ +namespace Novelly.Api.Chapters; + +public enum ChapterKind +{ + FrontMatter, + Body, + BackMatter +} diff --git a/src/Novelly.Api/Chapters/ChapterNumbering.cs b/src/Novelly.Api/Chapters/ChapterNumbering.cs new file mode 100644 index 0000000..bbb73df --- /dev/null +++ b/src/Novelly.Api/Chapters/ChapterNumbering.cs @@ -0,0 +1,25 @@ +namespace Novelly.Api.Chapters; + +public static class ChapterNumbering +{ + public static IReadOnlyDictionary DisplayNumbers(IEnumerable novelChapters) + { + var displayNumbers = new Dictionary(); + var next = 1; + + foreach (var chapter in novelChapters.OrderBy(c => c.Number)) + { + if (chapter.Kind != ChapterKind.Body) + continue; + + displayNumbers[chapter.Id] = next++; + } + + return displayNumbers; + } + + public static string Label(ChapterKind kind, int? displayNumber, string title) => + kind == ChapterKind.Body && displayNumber is { } number + ? $"Chapter {number}: {title}" + : title; +} diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index 99dc173..3a5d202 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -73,6 +73,7 @@ public class ChapterService( NovelId = novelId, Title = request.Title, Number = request.Number ?? await NextChapterNumberAsync(novelId, ct), + Kind = request.Kind, Summary = request.Summary, Notes = request.Notes, Status = request.Status, @@ -116,6 +117,7 @@ public class ChapterService( chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Number = request.Number ?? chapter.Number; + chapter.Kind = request.Kind ?? chapter.Kind; chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Status = request.Status ?? chapter.Status; @@ -166,6 +168,15 @@ public class ChapterService( return true; } + public async Task DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) + { + if (chapter.Kind != ChapterKind.Body) + return null; + + return await db.Chapters.CountAsync( + c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct); + } + private async Task NextChapterNumberAsync(Guid novelId, CancellationToken ct) { logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId); diff --git a/src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.Designer.cs b/src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.Designer.cs new file mode 100644 index 0000000..044ea01 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.Designer.cs @@ -0,0 +1,1289 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260820005308_AddChapterKind")] + partial class AddChapterKind + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.Property("ArcStagesId") + .HasColumnType("TEXT"); + + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.HasKey("ArcStagesId", "BeatsId"); + + b.HasIndex("BeatsId"); + + b.ToTable("ArcStageBeats", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterLocation", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("LocationsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "LocationsId"); + + b.HasIndex("LocationsId"); + + b.ToTable("ChapterLocations", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Action") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("DayKey") + .HasColumnType("INTEGER"); + + b.Property("EntityId") + .HasColumnType("TEXT"); + + b.Property("EntityKind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("OccurredAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("WordDelta") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "DayKey"); + + b.HasIndex("UserId", "DayKey"); + + b.ToTable("ActivityEvents"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Aliases") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IdentityNote") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Motivation") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("RevealedInChapterId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SameCharacterAsId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.HasIndex("RevealedInChapterId"); + + b.HasIndex("SameCharacterAsId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Result") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Locations.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("NovelId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("INTEGER"); + + b.Property("GrantedByUserId") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("NovelRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("NovelId", "UserId") + .IsUnique(); + + b.ToTable("NovelMembers"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.HasOne("Novelly.Api.Characters.CharacterArcStage", null) + .WithMany() + .HasForeignKey("ArcStagesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterLocation", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Locations.Location", null) + .WithMany() + .HasForeignKey("LocationsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Activity.ActivityEvent", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Users.NovellyUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Conversations") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Chapters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Characters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter") + .WithMany() + .HasForeignKey("RevealedInChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs") + .WithMany("OtherIdentities") + .HasForeignKey("SameCharacterAsId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Novel"); + + b.Navigation("RevealedInChapter"); + + b.Navigation("SameCharacterAs"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Locations.Location", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Tags") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Members") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Users.NovellyUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("OtherIdentities"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Members"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.cs b/src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.cs new file mode 100644 index 0000000..d440f30 --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260820005308_AddChapterKind.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class AddChapterKind : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Kind", + table: "Chapters", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: "Body"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Kind", + table: "Chapters"); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 5ac1fe1..392a7cd 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -319,6 +319,11 @@ namespace Novelly.Api.Data.Migrations b.Property("CreatedAt") .HasColumnType("INTEGER"); + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property("Notes") .HasColumnType("TEXT"); diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs index b23f0d0..3984b60 100644 --- a/src/Novelly.Api/Imports/ImportAgentToolset.cs +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -274,10 +274,12 @@ public class ImportAgentToolset( yield return new ImportAgentTool( "create_chapter", - "Add a chapter. Its number is appended to the end of the manuscript unless you supply one.", + "Add a chapter. Its number is appended to the end of the manuscript unless you supply one. " + + "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.", new JsonSchemaBuilder() .Str("title", "Chapter title.", required: true) .Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.") + .Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames()) .Str("summary", "The chapter's prose summary paragraph(s).") .Str("notes", "The chapter file's ## Notes section, if present.") .StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].") @@ -288,6 +290,7 @@ public class ImportAgentToolset( var created = await chapters.CreateAsync(novelId, new CreateChapterRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "number"), + JsonInput.Enum(input, "kind") ?? ChapterKind.Body, JsonInput.String(input, "summary"), Notes: JsonInput.String(input, "notes"), Tags: JsonInput.Strings(input, "tags")), ct); diff --git a/tests/Novelly.Api.Tests/ChapterServiceTests.cs b/tests/Novelly.Api.Tests/ChapterServiceTests.cs index 3e6a923..76cf8a3 100644 --- a/tests/Novelly.Api.Tests/ChapterServiceTests.cs +++ b/tests/Novelly.Api.Tests/ChapterServiceTests.cs @@ -78,4 +78,62 @@ public class ChapterServiceTests : ServiceTestFixture [Test] public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() => Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False); + + [Test] + public async Task A_chapter_defaults_to_a_body_chapter() + { + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); + + Assert.That(chapter!.Kind, Is.EqualTo(ChapterKind.Body)); + } + + [Test] + public async Task Front_matter_does_not_consume_a_chapter_number() + { + var foreword = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Foreword", Number: 1, Kind: ChapterKind.FrontMatter)); + var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 2)); + var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Harbour", Number: 3)); + + var displayNumbers = ChapterNumbering.DisplayNumbers(await Chapters.ListAsync(_novelId)); + + Assert.Multiple(() => + { + Assert.That(displayNumbers.ContainsKey(foreword!.Id), Is.False); + Assert.That(displayNumbers[first!.Id], Is.EqualTo(1)); + Assert.That(displayNumbers[second!.Id], Is.EqualTo(2)); + }); + } + + [Test] + public async Task Back_matter_is_listed_last_but_carries_no_chapter_number() + { + var body = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 1)); + var afterword = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Afterword", Number: 2, Kind: ChapterKind.BackMatter)); + + var afterwordDisplayNumber = await Chapters.DisplayNumberAsync(afterword!); + var bodyDisplayNumber = await Chapters.DisplayNumberAsync(body!); + + Assert.Multiple(() => + { + Assert.That(afterwordDisplayNumber, Is.Null); + Assert.That(bodyDisplayNumber, Is.EqualTo(1)); + }); + } + + [Test] + public async Task Changing_a_chapter_to_front_matter_drops_it_from_the_chapter_count() + { + var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Prologue", Number: 1)); + var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 2)); + + var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Kind: ChapterKind.FrontMatter)); + var updatedDisplayNumber = await Chapters.DisplayNumberAsync(updated!); + var otherDisplayNumber = await Chapters.DisplayNumberAsync(other!); + + Assert.Multiple(() => + { + Assert.That(updatedDisplayNumber, Is.Null); + Assert.That(otherDisplayNumber, Is.EqualTo(1)); + }); + } }