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.
This commit is contained in:
James Wampler
2026-08-19 17:54:20 -07:00
parent 71953220aa
commit ef5260a111
12 changed files with 1526 additions and 27 deletions
+3
View File
@@ -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<Chapter>
{
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.NovelId, c.Number });
}
}
+10 -4
View File
@@ -10,6 +10,8 @@ public record ChapterSummaryResponse(
Guid Id,
Guid NovelId,
int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title,
string? Summary,
IReadOnlyList<LocationResponse> Locations,
@@ -24,6 +26,8 @@ public record ChapterResponse(
Guid Id,
Guid NovelId,
int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title,
string? Summary,
IReadOnlyList<LocationResponse> Locations,
@@ -39,6 +43,7 @@ public record ChapterResponse(
public record CreateChapterRequest(
string Title,
int? Number = null,
ChapterKind Kind = ChapterKind.Body,
string? Summary = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
@@ -63,6 +68,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
ChapterKind? Kind = null,
string? Summary = null,
IReadOnlyList<string>? 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,
+28 -4
View File
@@ -12,7 +12,12 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>();
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<ValidationEndpointFilter>();
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) =>
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Chapters;
public enum ChapterKind
{
FrontMatter,
Body,
BackMatter
}
@@ -0,0 +1,25 @@
namespace Novelly.Api.Chapters;
public static class ChapterNumbering
{
public static IReadOnlyDictionary<Guid, int> DisplayNumbers(IEnumerable<Chapter> novelChapters)
{
var displayNumbers = new Dictionary<Guid, int>();
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;
}
@@ -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<int?> 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<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);