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:
@@ -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,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,
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user