Compare commits

..
4 Commits
Author SHA1 Message Date
James Wampler 3795ddd541 Add chapter kind to the web client
CI / deploy (push) Successful in 9s
CI / build-and-push (push) Successful in 54s
Chapter and its cross-referencing chips (tags, locations, questions,
character arcs) now carry kind/displayNumber/label fields end to end.
Chapter detail page gets a Kind selector; chapter chips across the
app render "Foreword"/"Afterword" instead of a misleading number for
front and back matter.
2026-08-19 18:07:21 -07:00
James Wampler 6b0cdd0d71 Surface chapter kind through the MCP server
create_chapter/update_chapter now pass kind through to the API,
matching the FrontMatter/Body/BackMatter option added to the embedded
agent's toolset.
2026-08-19 18:03:18 -07:00
James Wampler 7f56c79b20 Label chapter chips by kind, not raw number
Beats, tags, locations, open questions, and character/arc-stage
responses that reference a chapter now carry a ChapterLabel/DisplayNumber
alongside the raw Number, computed via the new
ChapterDisplayNumberLookup. Front/back matter chips show their title;
body chapters show "Chapter N: Title".
2026-08-19 18:02:53 -07:00
James Wampler ef5260a111 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.
2026-08-19 17:54:20 -07:00
40 changed files with 1991 additions and 136 deletions
+130 -42
View File
@@ -28,6 +28,7 @@ public class NovelAgentToolset(
CharacterService characters, CharacterService characters,
CharacterArcService arcs, CharacterArcService arcs,
ChapterService chapters, ChapterService chapters,
ChapterDisplayNumberLookup chapterLabels,
BeatService beats, BeatService beats,
TagService tags, TagService tags,
LocationService locations, LocationService locations,
@@ -359,7 +360,14 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var tagId = JsonInput.RequiredGuid(input, "tag_id"); var tagId = JsonInput.RequiredGuid(input, "tag_id");
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId); var tag = await tags.GetReferencesAsync(tagId, ct);
if (tag is null)
{
return new ToolNotFound("Tag", tagId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(tag.NovelId, ct);
return tag.ToReferencesResponse(displayNumbers);
}); });
yield return new AgentTool( yield return new AgentTool(
@@ -378,14 +386,26 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var locationId = JsonInput.RequiredGuid(input, "location_id"); var locationId = JsonInput.RequiredGuid(input, "location_id");
return await OrNotFound(locations.GetReferencesAsync(locationId, ct), l => l.ToReferencesResponse(), "Location", locationId); var location = await locations.GetReferencesAsync(locationId, ct);
if (location is null)
{
return new ToolNotFound("Location", locationId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(location.NovelId, ct);
return location.ToReferencesResponse(displayNumbers);
}); });
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_chapters",
"List the novel's chapters in manuscript order with beat and word counts.", "List the novel's chapters in manuscript order with beat and word counts.",
new JsonSchemaBuilder().Build(), 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( yield return new AgentTool(
"get_chapter", "get_chapter",
@@ -396,15 +416,25 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); 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( yield return new AgentTool(
"create_chapter", "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() new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true) .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<ChapterKind>())
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.") .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
@@ -413,26 +443,39 @@ public class NovelAgentToolset(
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .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.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest( async (novelId, input, ct) =>
JsonInput.RequiredString(input, "title"), {
JsonInput.Int(input, "number"), var chapter = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.String(input, "summary"), JsonInput.RequiredString(input, "title"),
JsonInput.Strings(input, "locations"), JsonInput.Int(input, "number"),
JsonInput.String(input, "notes"), JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.String(input, "summary"),
JsonInput.Int(input, "target_word_count"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "prose"), JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId)); JsonInput.Enum<DraftStatus>(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( yield return new AgentTool(
"update_chapter", "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 " + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
+ "word count is recomputed automatically.", + "word count is recomputed automatically.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter to update.", required: true) .Str("chapter_id", "Id of the chapter to update.", required: true)
.Str("title", "New title.") .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<ChapterKind>())
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.") .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
@@ -444,18 +487,27 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
return await OrNotFound(chapters.UpdateAsync( var chapter = await chapters.UpdateAsync(
chapterId, chapterId,
new UpdateChapterRequest( new UpdateChapterRequest(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.Strings(input, "locations"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
JsonInput.String(input, "prose"), 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( yield return new AgentTool(
@@ -466,14 +518,18 @@ public class NovelAgentToolset(
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("character_id", "Id of the character.", required: true) .Str("character_id", "Id of the character.", required: true)
.Build(), .Build(),
async (_, input, ct) => async (novelId, input, ct) =>
{ {
var characterId = JsonInput.RequiredGuid(input, "character_id"); var characterId = JsonInput.RequiredGuid(input, "character_id");
return await OrNotFound( var characterBeats = await beats.ListForCharacterAsync(characterId, ct);
beats.ListForCharacterAsync(characterId, ct), if (characterBeats is null)
list => list.Select(b => b.ToCharacterBeatResponse(characterId)), {
"Character", return new ToolNotFound("Character", characterId);
characterId); }
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return characterBeats.Select(b =>
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers)));
}); });
yield return new AgentTool( yield return new AgentTool(
@@ -563,12 +619,18 @@ public class NovelAgentToolset(
.Str("character_id", "Narrow to questions about one character.") .Str("character_id", "Narrow to questions about one character.")
.Bool("include_resolved", "Include questions already settled. Defaults to false.") .Bool("include_resolved", "Include questions already settled. Defaults to false.")
.Build(), .Build(),
async (novelId, input, ct) => (await questions.ListAsync( async (novelId, input, ct) =>
novelId, {
JsonInput.Guid(input, "chapter_id"), var list = await questions.ListAsync(
JsonInput.Guid(input, "character_id"), novelId,
JsonInput.Bool(input, "include_resolved") ?? false, JsonInput.Guid(input, "chapter_id"),
ct)).Select(q => q.ToResponse())); JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false,
ct);
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return list.Select(q => q.ToResponse(displayNumbers));
});
yield return new AgentTool( yield return new AgentTool(
"raise_open_question", "raise_open_question",
@@ -581,13 +643,24 @@ public class NovelAgentToolset(
.Str("chapter_id", "The chapter outline this is about, if any.") .Str("chapter_id", "The chapter outline this is about, if any.")
.Str("character_id", "The character this is about, if any.") .Str("character_id", "The character this is about, if any.")
.Build(), .Build(),
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync( async (novelId, input, ct) =>
novelId, {
new CreateOpenQuestionRequest( var question = await questions.CreateAsync(
JsonInput.RequiredString(input, "question"), novelId,
JsonInput.String(input, "detail"), new CreateOpenQuestionRequest(
JsonInput.Guid(input, "chapter_id"), JsonInput.RequiredString(input, "question"),
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId)); JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct);
if (question is null)
{
return new ToolNotFound("Novel", novelId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return question.ToResponse(displayNumbers);
});
yield return new AgentTool( yield return new AgentTool(
"resolve_open_question", "resolve_open_question",
@@ -601,11 +674,19 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var questionId = JsonInput.RequiredGuid(input, "question_id"); var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ResolveAsync( var question = await questions.ResolveAsync(
questionId, questionId,
new ResolveOpenQuestionRequest( new ResolveOpenQuestionRequest(
JsonInput.RequiredString(input, "resolution"), JsonInput.RequiredString(input, "resolution"),
JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId); JsonInput.Bool(input, "append_to_notes") ?? false), ct);
if (question is null)
{
return new ToolNotFound("OpenQuestion", questionId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
return question.ToResponse(displayNumbers);
}); });
yield return new AgentTool( yield return new AgentTool(
@@ -617,7 +698,14 @@ public class NovelAgentToolset(
async (_, input, ct) => async (_, input, ct) =>
{ {
var questionId = JsonInput.RequiredGuid(input, "question_id"); var questionId = JsonInput.RequiredGuid(input, "question_id");
return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId); var question = await questions.ReopenAsync(questionId, ct);
if (question is null)
{
return new ToolNotFound("OpenQuestion", questionId);
}
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
return question.ToResponse(displayNumbers);
}); });
yield return new AgentTool( yield return new AgentTool(
+3 -1
View File
@@ -81,6 +81,7 @@ public record CharacterBeatResponse(
Guid ChapterId, Guid ChapterId,
int ChapterNumber, int ChapterNumber,
string ChapterTitle, string ChapterTitle,
string ChapterLabel,
int SortOrder, int SortOrder,
string Title, string Title,
string? WhatHappened, string? WhatHappened,
@@ -151,11 +152,12 @@ public static class BeatMapping
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
b.UpdatedAt); b.UpdatedAt);
public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId) => new( public static CharacterBeatResponse ToCharacterBeatResponse(this Beat b, Guid characterId, string? chapterLabel = null) => new(
b.Id, b.Id,
b.ChapterId, b.ChapterId,
b.Chapter?.Number ?? 0, b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)", b.Chapter?.Title ?? "(unknown chapter)",
chapterLabel ?? b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.WhatHappened, b.WhatHappened,
+16 -2
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -45,8 +46,21 @@ public static class BeatEndpoints
.WithSummary("Move one or more beats to another chapter, appending them to its end."); .WithSummary("Move one or more beats to another chapter, appending them to its end.");
app.MapGet("/api/characters/{characterId:guid}/beats", async ( app.MapGet("/api/characters/{characterId:guid}/beats", async (
Guid characterId, BeatService service, CancellationToken ct) => Guid characterId, BeatService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse(characterId)).ToList().ToApiResult()) {
var characterBeats = await service.ListForCharacterAsync(characterId, ct);
if (characterBeats is null)
{
return Results.NotFound();
}
var displayNumbers = characterBeats.Count > 0
? await chapterLabels.ForNovelAsync(characterBeats[0].Chapter!.NovelId, ct)
: new Dictionary<Guid, int>();
return Results.Ok(characterBeats.Select(b =>
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers))).ToList());
})
.WithTags("Beats") .WithTags("Beats")
.WithSummary("Every beat this character appears in, in manuscript order."); .WithSummary("Every beat this character appears in, in manuscript order.");
+3
View File
@@ -16,6 +16,8 @@ public class Chapter
public int Number { get; set; } public int Number { get; set; }
public ChapterKind Kind { get; set; } = ChapterKind.Body;
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string? Summary { get; set; } 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.Title).IsRequired().HasMaxLength(300);
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32); 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 }); entity.HasIndex(c => new { c.NovelId, c.Number });
} }
} }
+10 -4
View File
@@ -10,6 +10,8 @@ public record ChapterSummaryResponse(
Guid Id, Guid Id,
Guid NovelId, Guid NovelId,
int Number, int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title, string Title,
string? Summary, string? Summary,
IReadOnlyList<LocationResponse> Locations, IReadOnlyList<LocationResponse> Locations,
@@ -24,6 +26,8 @@ public record ChapterResponse(
Guid Id, Guid Id,
Guid NovelId, Guid NovelId,
int Number, int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title, string Title,
string? Summary, string? Summary,
IReadOnlyList<LocationResponse> Locations, IReadOnlyList<LocationResponse> Locations,
@@ -39,6 +43,7 @@ public record ChapterResponse(
public record CreateChapterRequest( public record CreateChapterRequest(
string Title, string Title,
int? Number = null, int? Number = null,
ChapterKind Kind = ChapterKind.Body,
string? Summary = null, string? Summary = null,
IReadOnlyList<string>? Locations = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
@@ -63,6 +68,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
public record UpdateChapterRequest( public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
ChapterKind? Kind = null,
string? Summary = null, string? Summary = null,
IReadOnlyList<string>? Locations = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
@@ -115,8 +121,8 @@ file static class ChapterValidation
public static class ChapterMapping public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c) => new( public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
@@ -125,8 +131,8 @@ public static class ChapterMapping
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], [.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
@@ -0,0 +1,38 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Chapters;
public class ChapterDisplayNumberLookup(INovelDbContext db)
{
public async Task<IReadOnlyDictionary<Guid, int>> ForNovelAsync(Guid novelId, CancellationToken ct = default)
{
var chapters = await db.Chapters.AsNoTracking().Where(c => c.NovelId == novelId).ToListAsync(ct);
return ChapterNumbering.DisplayNumbers(chapters);
}
public async Task<int?> ForChapterAsync(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);
}
public async Task<IReadOnlyDictionary<Guid, int>> ForChaptersAsync(IEnumerable<Chapter> chapters, CancellationToken ct = default)
{
var displayNumbers = new Dictionary<Guid, int>();
foreach (var chapter in chapters.DistinctBy(c => c.Id))
{
if (await ForChapterAsync(chapter, ct) is { } number)
displayNumbers[chapter.Id] = number;
}
return displayNumbers;
}
public string LabelFor(Chapter chapter, IReadOnlyDictionary<Guid, int> displayNumbers) =>
ChapterNumbering.Label(chapter.Kind, displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
}
+28 -4
View File
@@ -12,7 +12,12 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) => 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."); .WithSummary("List a novel's chapters in manuscript order.");
novelScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
@@ -24,7 +29,8 @@ public static class ChapterEndpoints
return Results.NotFound(); 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); return Results.Created($"/api/chapters/{created.Id}", created);
}) })
.WithSummary("Add a chapter."); .WithSummary("Add a chapter.");
@@ -34,12 +40,30 @@ public static class ChapterEndpoints
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => 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."); .WithSummary("Read a chapter with its beats and prose.");
chapters.MapPatch("/{id:guid}", async ( chapters.MapPatch("/{id:guid}", async (
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => 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."); .WithSummary("Update a chapter.");
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => 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;
}
@@ -14,6 +14,7 @@ public class ChapterService(
NovelAccessService access, NovelAccessService access,
TagService tags, TagService tags,
LocationService locations, LocationService locations,
ChapterDisplayNumberLookup displayNumbers,
ActivityLog activity, ActivityLog activity,
ILogger<ChapterService> logger, ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator, IModelValidator<CreateChapterRequest> createValidator,
@@ -73,6 +74,7 @@ public class ChapterService(
NovelId = novelId, NovelId = novelId,
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct), Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Kind = request.Kind,
Summary = request.Summary, Summary = request.Summary,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
@@ -116,6 +118,7 @@ public class ChapterService(
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
chapter.Kind = request.Kind ?? chapter.Kind;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status; chapter.Status = request.Status ?? chapter.Status;
@@ -166,6 +169,9 @@ public class ChapterService(
return true; return true;
} }
public Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) =>
displayNumbers.ForChapterAsync(chapter, ct);
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{ {
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId); logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
@@ -1,4 +1,5 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -25,6 +26,7 @@ public record CharacterResponse(
string? SameCharacterAsName, string? SameCharacterAsName,
Guid? RevealedInChapterId, Guid? RevealedInChapterId,
int? RevealedInChapterNumber, int? RevealedInChapterNumber,
string? RevealedInChapterLabel,
string? IdentityNote, string? IdentityNote,
IReadOnlyList<CharacterIdentityResponse> OtherIdentities, IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
IReadOnlyList<RelationshipResponse> Relationships, IReadOnlyList<RelationshipResponse> Relationships,
@@ -200,6 +202,7 @@ public record ArcStageResponse(
Guid? ChapterId, Guid? ChapterId,
int? ChapterNumber, int? ChapterNumber,
string? ChapterTitle, string? ChapterTitle,
string? ChapterLabel,
IReadOnlyList<CharacterBeatResponse> Beats, IReadOnlyList<CharacterBeatResponse> Beats,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
@@ -286,7 +289,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeats
public static class CharacterMapping public static class CharacterMapping
{ {
public static CharacterResponse ToResponse(this Character c) => new( public static CharacterResponse ToResponse(this Character c, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation, c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes, c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
[.. c.Aliases], [.. c.Aliases],
@@ -294,6 +297,7 @@ public static class CharacterMapping
c.SameCharacterAs?.Name, c.SameCharacterAs?.Name,
c.RevealedInChapterId, c.RevealedInChapterId,
c.RevealedInChapter?.Number, c.RevealedInChapter?.Number,
c.RevealedInChapter is { } revealedInChapter ? ChapterLabel(revealedInChapter, displayNumbers) : null,
c.IdentityNote, c.IdentityNote,
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))], [.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
[.. c.Relationships.Select(r => new RelationshipResponse( [.. c.Relationships.Select(r => new RelationshipResponse(
@@ -303,10 +307,10 @@ public static class CharacterMapping
r.RelationshipType, r.RelationshipType,
r.Description))], r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())], [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
c.UpdatedAt); c.UpdatedAt);
public static ArcStageResponse ToResponse(this CharacterArcStage s) => new( public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
s.Id, s.Id,
s.CharacterId, s.CharacterId,
s.SortOrder, s.SortOrder,
@@ -315,9 +319,13 @@ public static class CharacterMapping
s.ChapterId, s.ChapterId,
s.Chapter?.Number, s.Chapter?.Number,
s.Chapter?.Title, s.Chapter?.Title,
s.Chapter is { } chapter ? ChapterLabel(chapter, displayNumbers) : null,
[.. s.Beats [.. s.Beats
.OrderBy(b => b.Chapter?.Number ?? 0) .OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder) .ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId))], .Select(b => b.ToCharacterBeatResponse(s.CharacterId, b.Chapter is { } beatChapter ? ChapterLabel(beatChapter, displayNumbers) : null))],
s.UpdatedAt); s.UpdatedAt);
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
} }
+154 -24
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -5,18 +6,57 @@ namespace Novelly.Api.Characters;
public static class CharacterEndpoints public static class CharacterEndpoints
{ {
private static IEnumerable<Chapter> ChaptersOf(Character c)
{
if (c.RevealedInChapter is { } revealed)
{
yield return revealed;
}
foreach (var chapter in c.ArcStages.SelectMany(ChaptersOf))
{
yield return chapter;
}
}
private static IEnumerable<Chapter> ChaptersOf(CharacterArcStage stage)
{
if (stage.Chapter is { } chapter)
{
yield return chapter;
}
foreach (var beat in stage.Beats)
{
if (beat.Chapter is { } beatChapter)
{
yield return beatChapter;
}
}
}
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app) public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
{ {
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters") var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) => novelScoped.MapGet("/", async (Guid novelId, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse()))) {
var list = await service.ListAsync(novelId, ct);
var responses = new List<CharacterResponse>();
foreach (var character in list)
{
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
responses.Add(character.ToResponse(displayNumbers));
}
return Results.Ok(responses);
})
.WithSummary("List a novel's character dossiers."); .WithSummary("List a novel's character dossiers.");
novelScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid novelId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid novelId, CreateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{ {
var character = await service.CreateAsync(novelId, request, ct); var character = await service.CreateAsync(novelId, request, ct);
if (character is null) if (character is null)
@@ -24,7 +64,8 @@ public static class CharacterEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var created = character.ToResponse(); var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
var created = character.ToResponse(displayNumbers);
return Results.Created($"/api/characters/{created.Id}", created); return Results.Created($"/api/characters/{created.Id}", created);
}) })
.WithSummary("Add a character dossier."); .WithSummary("Add a character dossier.");
@@ -33,13 +74,31 @@ public static class CharacterEndpoints
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
var character = await service.GetAsync(id, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Read a character dossier."); .WithSummary("Read a character dossier.");
characters.MapPatch("/{id:guid}", async ( characters.MapPatch("/{id:guid}", async (
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) => Guid id, UpdateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var character = await service.UpdateAsync(id, request, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Update a character dossier."); .WithSummary("Update a character dossier.");
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
@@ -47,8 +106,17 @@ public static class CharacterEndpoints
.WithSummary("Delete a character."); .WithSummary("Delete a character.");
characters.MapPost("/{id:guid}/relationships", async ( characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var character = await service.AddRelationshipAsync(id, request, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Relate this character to another in the same novel."); .WithSummary("Relate this character to another in the same novel.");
characters.MapDelete("/relationships/{relationshipId:guid}", async ( characters.MapDelete("/relationships/{relationshipId:guid}", async (
@@ -57,8 +125,17 @@ public static class CharacterEndpoints
.WithSummary("Remove a relationship."); .WithSummary("Remove a relationship.");
characters.MapPut("/{id:guid}/identity", async ( characters.MapPut("/{id:guid}/identity", async (
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) => Guid id, LinkCharacterIdentityRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var character = await service.LinkIdentityAsync(id, request, ct);
if (character is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
return character.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Link this character as another identity of a character in the same novel."); .WithSummary("Link this character as another identity of a character in the same novel.");
characters.MapDelete("/{id:guid}/identity", async ( characters.MapDelete("/{id:guid}/identity", async (
@@ -67,12 +144,22 @@ public static class CharacterEndpoints
.WithSummary("Remove this character's identity link."); .WithSummary("Remove this character's identity link.");
characters.MapGet("/{id:guid}/arc", async ( characters.MapGet("/{id:guid}/arc", async (
Guid id, CharacterArcService service, CancellationToken ct) => Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse()))) {
var stages = await service.ListAsync(id, ct);
var responses = new List<ArcStageResponse>();
foreach (var stage in stages)
{
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
responses.Add(stage.ToResponse(displayNumbers));
}
return Results.Ok(responses);
})
.WithSummary("Read a character's arc: its stages, in order."); .WithSummary("Read a character's arc: its stages, in order.");
characters.MapPost("/{id:guid}/arc", async ( characters.MapPost("/{id:guid}/arc", async (
Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) => Guid id, CreateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{ {
var stage = await service.CreateAsync(id, request, ct); var stage = await service.CreateAsync(id, request, ct);
if (stage is null) if (stage is null)
@@ -80,27 +167,61 @@ public static class CharacterEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var created = stage.ToResponse(); var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
var created = stage.ToResponse(displayNumbers);
return Results.Created($"/api/arc-stages/{created.Id}", created); return Results.Created($"/api/arc-stages/{created.Id}", created);
}) })
.WithSummary("Add a stage to a character's arc."); .WithSummary("Add a stage to a character's arc.");
characters.MapPost("/{id:guid}/arc/reorder", async ( characters.MapPost("/{id:guid}/arc/reorder", async (
Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) => Guid id, ReorderArcStagesRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.ReorderAsync(id, request, ct))?.Select(s => s.ToResponse()).ToList().ToApiResult()) {
var stages = await service.ReorderAsync(id, request, ct);
if (stages is null)
{
return Results.NotFound();
}
var responses = new List<ArcStageResponse>();
foreach (var stage in stages)
{
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
responses.Add(stage.ToResponse(displayNumbers));
}
return Results.Ok(responses);
})
.WithSummary("Renumber a character's arc to match the order given."); .WithSummary("Renumber a character's arc to match the order given.");
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters") var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) {
var stage = await service.GetAsync(id, ct);
if (stage is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
return stage.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Read one arc stage."); .WithSummary("Read one arc stage.");
arcStages.MapPatch("/{id:guid}", async ( arcStages.MapPatch("/{id:guid}", async (
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) => Guid id, UpdateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var stage = await service.UpdateAsync(id, request, ct);
if (stage is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
return stage.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Update an arc stage."); .WithSummary("Update an arc stage.");
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
@@ -108,8 +229,17 @@ public static class CharacterEndpoints
.WithSummary("Delete an arc stage."); .WithSummary("Delete an arc stage.");
arcStages.MapPost("/{id:guid}/beats", async ( arcStages.MapPost("/{id:guid}/beats", async (
Guid id, SetArcStageBeatsRequest request, CharacterArcService service, CancellationToken ct) => Guid id, SetArcStageBeatsRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.SetBeatsAsync(id, request, ct))?.ToResponse().ToApiResult()) {
var stage = await service.SetBeatsAsync(id, request, ct);
if (stage is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
return stage.ToResponse(displayNumbers).ToApiResult();
})
.WithSummary("Set which beats belong to this arc stage, replacing its current set. " .WithSummary("Set which beats belong to this arc stage, replacing its current set. "
+ "A beat moved into this stage leaves any other stage of the same character it was in."); + "A beat moved into this stage leaves any other stage of the same character it was in.");
@@ -86,6 +86,7 @@ public static class NovellyServiceRegistration
services.AddScoped<LocationService>(); services.AddScoped<LocationService>();
services.AddScoped<GenreService>(); services.AddScoped<GenreService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<ChapterDisplayNumberLookup>();
services.AddScoped<OpenQuestionService>(); services.AddScoped<OpenQuestionService>();
services.AddScoped<NovelAgentToolset>(); services.AddScoped<NovelAgentToolset>();
services.AddScoped<NovelAgentService>(); services.AddScoped<NovelAgentService>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddChapterKind : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Kind",
table: "Chapters",
type: "TEXT",
maxLength: 32,
nullable: false,
defaultValue: "Body");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Kind",
table: "Chapters");
}
}
}
@@ -319,6 +319,11 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Notes") b.Property<string>("Notes")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -274,10 +274,12 @@ public class ImportAgentToolset(
yield return new ImportAgentTool( yield return new ImportAgentTool(
"create_chapter", "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() new JsonSchemaBuilder()
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.") .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<ChapterKind>())
.Str("summary", "The chapter's prose summary paragraph(s).") .Str("summary", "The chapter's prose summary paragraph(s).")
.Str("notes", "The chapter file's ## Notes section, if present.") .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'].") .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( var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
Notes: JsonInput.String(input, "notes"), Notes: JsonInput.String(input, "notes"),
Tags: JsonInput.Strings(input, "tags")), ct); Tags: JsonInput.Strings(input, "tags")), ct);
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations; namespace Novelly.Api.Locations;
@@ -36,17 +37,20 @@ public class UpdateLocationRequestValidator : IModelValidator<UpdateLocationRequ
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters); public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
public record LocatedChapterResponse(Guid Id, int Number, string Title, string? Summary); public record LocatedChapterResponse(Guid Id, int Number, ChapterKind Kind, int? DisplayNumber, string Title, string? Summary);
public static class LocationMapping public static class LocationMapping
{ {
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name); public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
public static LocationReferencesResponse ToReferencesResponse(this Location location) => new( public static LocationReferencesResponse ToReferencesResponse(this Location location, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
location.ToResponse(), location.ToResponse(),
[.. location.Chapters [.. location.Chapters
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.Select(c => new LocatedChapterResponse(c.Id, c.Number, c.Title, c.Summary))]); .Select(c => new LocatedChapterResponse(
c.Id, c.Number, c.Kind,
displayNumbers is not null && displayNumbers.TryGetValue(c.Id, out var n) ? n : null,
c.Title, c.Summary))]);
public static string Normalise(string name) => name.Trim(); public static string Normalise(string name) => name.Trim();
} }
+12 -2
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -33,8 +34,17 @@ public static class LocationEndpoints
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, CancellationToken ct) => locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult()) {
var location = await service.GetReferencesAsync(id, ct);
if (location is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForNovelAsync(location.NovelId, ct);
return location.ToReferencesResponse(displayNumbers).ToApiResult();
})
.WithSummary("Cross-reference: every chapter set at this location."); .WithSummary("Cross-reference: every chapter set at this location.");
locations.MapPatch("/{id:guid}", async ( locations.MapPatch("/{id:guid}", async (
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
namespace Novelly.Api.Questions; namespace Novelly.Api.Questions;
@@ -10,6 +11,7 @@ public record OpenQuestionResponse(
Guid? ChapterId, Guid? ChapterId,
int? ChapterNumber, int? ChapterNumber,
string? ChapterTitle, string? ChapterTitle,
string? ChapterLabel,
Guid? CharacterId, Guid? CharacterId,
string? CharacterName, string? CharacterName,
string? Resolution, string? Resolution,
@@ -74,7 +76,7 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu
public static class OpenQuestionMapping public static class OpenQuestionMapping
{ {
public static OpenQuestionResponse ToResponse(this OpenQuestion q) => new( public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
q.Id, q.Id,
q.NovelId, q.NovelId,
q.Question, q.Question,
@@ -82,6 +84,9 @@ public static class OpenQuestionMapping
q.ChapterId, q.ChapterId,
q.Chapter?.Number, q.Chapter?.Number,
q.Chapter?.Title, q.Chapter?.Title,
q.Chapter is { } chapter
? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title)
: null,
q.CharacterId, q.CharacterId,
q.Character?.Name, q.Character?.Name,
q.Resolution, q.Resolution,
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -14,15 +15,20 @@ public static class OpenQuestionEndpoints
novelScoped.MapGet("/", async ( novelScoped.MapGet("/", async (
Guid novelId, Guid novelId,
OpenQuestionService service, OpenQuestionService service,
ChapterDisplayNumberLookup chapterLabels,
CancellationToken ct, CancellationToken ct,
Guid? chapterId = null, Guid? chapterId = null,
Guid? characterId = null, Guid? characterId = null,
bool includeResolved = false) => bool includeResolved = false) =>
Results.Ok((await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct)).Select(q => q.ToResponse()))) {
var list = await service.ListAsync(novelId, chapterId, characterId, includeResolved, ct);
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
return Results.Ok(list.Select(q => q.ToResponse(displayNumbers)));
})
.WithSummary("List a novel's open questions, optionally narrowed to one chapter or character."); .WithSummary("List a novel's open questions, optionally narrowed to one chapter or character.");
novelScoped.MapPost("/", async ( novelScoped.MapPost("/", async (
Guid novelId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid novelId, CreateOpenQuestionRequest request, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
{ {
var question = await service.CreateAsync(novelId, request, ct); var question = await service.CreateAsync(novelId, request, ct);
if (question is null) if (question is null)
@@ -30,7 +36,8 @@ public static class OpenQuestionEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var created = question.ToResponse(); var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
var created = question.ToResponse(displayNumbers);
return Results.Created($"/api/questions/{created.Id}", created); return Results.Created($"/api/questions/{created.Id}", created);
}) })
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); .WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
@@ -39,22 +46,22 @@ public static class OpenQuestionEndpoints
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) await WithLabel(await service.GetAsync(id, ct), chapterLabels, ct))
.WithSummary("Read one question."); .WithSummary("Read one question.");
questions.MapPatch("/{id:guid}", async ( questions.MapPatch("/{id:guid}", async (
Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) await WithLabel(await service.UpdateAsync(id, request, ct), chapterLabels, ct))
.WithSummary("Update a question or change what it is attached to."); .WithSummary("Update a question or change what it is attached to.");
questions.MapPost("/{id:guid}/resolve", async ( questions.MapPost("/{id:guid}/resolve", async (
Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.ResolveAsync(id, request, ct))?.ToResponse().ToApiResult()) await WithLabel(await service.ResolveAsync(id, request, ct), chapterLabels, ct))
.WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off."); .WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off.");
questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.ReopenAsync(id, ct))?.ToResponse().ToApiResult()) await WithLabel(await service.ReopenAsync(id, ct), chapterLabels, ct))
.WithSummary("Put a resolved question back on the list."); .WithSummary("Put a resolved question back on the list.");
questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
@@ -63,4 +70,15 @@ public static class OpenQuestionEndpoints
return app; return app;
} }
private static async Task<IResult> WithLabel(OpenQuestion? question, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct)
{
if (question is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
return Results.Ok(question.ToResponse(displayNumbers));
}
} }
+11 -3
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
namespace Novelly.Api.Tags; namespace Novelly.Api.Tags;
@@ -53,13 +54,14 @@ public record TagReferencesResponse(
public record TaggedCharacterResponse(Guid Id, string Name, string Role); public record TaggedCharacterResponse(Guid Id, string Name, string Role);
public record TaggedChapterResponse(Guid Id, int Number, string Title, string? Summary); public record TaggedChapterResponse(Guid Id, int Number, ChapterKind Kind, int? DisplayNumber, string Title, string? Summary);
public record TaggedBeatResponse( public record TaggedBeatResponse(
Guid Id, Guid Id,
Guid ChapterId, Guid ChapterId,
int ChapterNumber, int ChapterNumber,
string ChapterTitle, string ChapterTitle,
string ChapterLabel,
int SortOrder, int SortOrder,
string Title, string Title,
string? CharacterName, string? CharacterName,
@@ -69,14 +71,17 @@ public static class TagMapping
{ {
public static TagResponse ToResponse(this Tag t) => new(t.Id, t.Name, t.Color); public static TagResponse ToResponse(this Tag t) => new(t.Id, t.Name, t.Color);
public static TagReferencesResponse ToReferencesResponse(this Tag tag) => new( public static TagReferencesResponse ToReferencesResponse(this Tag tag, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
tag.ToResponse(), tag.ToResponse(),
[.. tag.Characters [.. tag.Characters
.OrderBy(c => c.Name) .OrderBy(c => c.Name)
.Select(c => new TaggedCharacterResponse(c.Id, c.Name, c.Role.ToString()))], .Select(c => new TaggedCharacterResponse(c.Id, c.Name, c.Role.ToString()))],
[.. tag.Chapters [.. tag.Chapters
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.Select(c => new TaggedChapterResponse(c.Id, c.Number, c.Title, c.Summary))], .Select(c => new TaggedChapterResponse(
c.Id, c.Number, c.Kind,
displayNumbers is not null && displayNumbers.TryGetValue(c.Id, out var n) ? n : null,
c.Title, c.Summary))],
[.. tag.Beats [.. tag.Beats
.OrderBy(b => b.Chapter?.Number ?? 0) .OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder) .ThenBy(b => b.SortOrder)
@@ -85,6 +90,9 @@ public static class TagMapping
b.ChapterId, b.ChapterId,
b.Chapter?.Number ?? 0, b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)", b.Chapter?.Title ?? "(unknown chapter)",
b.Chapter is { } chapter
? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var bn) ? bn : null, chapter.Title)
: "(unknown chapter)",
b.SortOrder, b.SortOrder,
b.Title, b.Title,
b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null, b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null,
+12 -2
View File
@@ -1,3 +1,4 @@
using Novelly.Api.Chapters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
@@ -33,8 +34,17 @@ public static class TagEndpoints
.AddEndpointFilter<RequestLoggingEndpointFilter>() .AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>(); .AddEndpointFilter<ValidationEndpointFilter>();
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) => tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult()) {
var tag = await service.GetReferencesAsync(id, ct);
if (tag is null)
{
return Results.NotFound();
}
var displayNumbers = await chapterLabels.ForNovelAsync(tag.NovelId, ct);
return tag.ToReferencesResponse(displayNumbers).ToApiResult();
})
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag."); .WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
tags.MapPatch("/{id:guid}", async ( tags.MapPatch("/{id:guid}", async (
+9 -5
View File
@@ -24,13 +24,15 @@ public static class ManuscriptTools
api.GetAsync($"/api/chapters/{chapterId}", ct); api.GetAsync($"/api/chapters/{chapterId}", ct);
[McpServerTool(Name = "create_chapter")] [McpServerTool(Name = "create_chapter")]
[Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number.")] [Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number. "
+ "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.")]
public static Task<CallToolResult> CreateChapter( public static Task<CallToolResult> CreateChapter(
NovelApiClient api, NovelApiClient api,
[Description("The novel's id.")] Guid novelId, [Description("The novel's id.")] Guid novelId,
[Description("Chapter title.")] string title, [Description("Chapter title.")] string title,
CancellationToken ct, CancellationToken ct,
[Description("Position in the manuscript, 1-based.")] int? number = null, [Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null,
[Description("FrontMatter, Body, or BackMatter. Defaults to Body.")] string? kind = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null, [Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
@@ -41,6 +43,7 @@ public static class ManuscriptTools
{ {
title, title,
number, number,
kind = kind ?? "Body",
summary, summary,
locations, locations,
status = status ?? "Planned", status = status ?? "Planned",
@@ -50,7 +53,7 @@ public static class ManuscriptTools
}, ct); }, ct);
[McpServerTool(Name = "update_chapter")] [McpServerTool(Name = "update_chapter")]
[Description("Revise a chapter's title, number, summary, locations, notes, status " [Description("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 " + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
+ "markdown; the word count is recomputed automatically.")] + "markdown; the word count is recomputed automatically.")]
public static Task<CallToolResult> UpdateChapter( public static Task<CallToolResult> UpdateChapter(
@@ -58,7 +61,8 @@ public static class ManuscriptTools
[Description("The chapter's id.")] Guid chapterId, [Description("The chapter's id.")] Guid chapterId,
CancellationToken ct, CancellationToken ct,
[Description("New title.")] string? title = null, [Description("New title.")] string? title = null,
[Description("Position in the manuscript.")] int? number = null, [Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null,
[Description("FrontMatter, Body, or BackMatter.")] string? kind = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null, [Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
@@ -67,5 +71,5 @@ public static class ManuscriptTools
[Description("The chapter's drafted text, in markdown.")] string? prose = null, [Description("The chapter's drafted text, in markdown.")] string? prose = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
api.PatchAsync($"/api/chapters/{chapterId}", api.PatchAsync($"/api/chapters/{chapterId}",
new { title, number, summary, locations, notes, status, targetWordCount, prose, tags }, ct); new { title, number, kind, summary, locations, notes, status, targetWordCount, prose, tags }, ct);
} }
+6
View File
@@ -0,0 +1,6 @@
import type { ChapterKind } from './types'
export function chapterLabel(kind: ChapterKind, displayNumber: number | null, title: string): string {
if (kind === 'Body' && displayNumber !== null) return `Chapter ${displayNumber}: ${title}`
return title
}
+13 -2
View File
@@ -28,6 +28,10 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
export type ChapterKind = 'FrontMatter' | 'Body' | 'BackMatter'
export const chapterKinds: ChapterKind[] = ['FrontMatter', 'Body', 'BackMatter']
export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
@@ -112,12 +116,13 @@ export interface TagSummary extends Tag {
export interface TagReferences { export interface TagReferences {
tag: Tag tag: Tag
characters: { id: string; name: string; role: string }[] characters: { id: string; name: string; role: string }[]
chapters: { id: string; number: number; title: string; summary: string | null }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
beats: { beats: {
id: string id: string
chapterId: string chapterId: string
chapterNumber: number chapterNumber: number
chapterTitle: string chapterTitle: string
chapterLabel: string
sortOrder: number sortOrder: number
title: string title: string
characterName: string | null characterName: string | null
@@ -136,7 +141,7 @@ export interface LocationSummary extends Location {
export interface LocationReferences { export interface LocationReferences {
location: Location location: Location
chapters: { id: string; number: number; title: string; summary: string | null }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
} }
export interface BeatCharacter { export interface BeatCharacter {
@@ -173,6 +178,7 @@ export interface ArcStage {
chapterId: string | null chapterId: string | null
chapterNumber: number | null chapterNumber: number | null
chapterTitle: string | null chapterTitle: string | null
chapterLabel: string | null
beats: CharacterBeat[] beats: CharacterBeat[]
updatedAt: string updatedAt: string
} }
@@ -182,6 +188,7 @@ export interface CharacterBeat {
chapterId: string chapterId: string
chapterNumber: number chapterNumber: number
chapterTitle: string chapterTitle: string
chapterLabel: string
sortOrder: number sortOrder: number
title: string title: string
whatHappened: string | null whatHappened: string | null
@@ -210,6 +217,7 @@ export interface Character {
sameCharacterAsName: string | null sameCharacterAsName: string | null
revealedInChapterId: string | null revealedInChapterId: string | null
revealedInChapterNumber: number | null revealedInChapterNumber: number | null
revealedInChapterLabel: string | null
identityNote: string | null identityNote: string | null
otherIdentities: CharacterIdentity[] otherIdentities: CharacterIdentity[]
relationships: Relationship[] relationships: Relationship[]
@@ -227,6 +235,8 @@ export interface ChapterSummary {
id: string id: string
novelId: string novelId: string
number: number number: number
kind: ChapterKind
displayNumber: number | null
title: string title: string
summary: string | null summary: string | null
locations: Location[] locations: Location[]
@@ -254,6 +264,7 @@ export interface OpenQuestion {
chapterId: string | null chapterId: string | null
chapterNumber: number | null chapterNumber: number | null
chapterTitle: string | null chapterTitle: string | null
chapterLabel: string | null
characterId: string | null characterId: string | null
characterName: string | null characterName: string | null
resolution: string | null resolution: string | null
@@ -9,7 +9,8 @@ import {
useSetArcStageBeats, useSetArcStageBeats,
useUpdateArcStage, useUpdateArcStage,
} from '../api/hooks' } from '../api/hooks'
import type { ArcStage, Character } from '../api/types' import { chapterLabel } from '../api/chapterLabel'
import type { ArcStage, Character, ChapterKind } from '../api/types'
import { AutoField, ErrorNote } from './ui' import { AutoField, ErrorNote } from './ui'
export function CharacterArc({ export function CharacterArc({
@@ -127,7 +128,7 @@ function ArcStageRow({
}: { }: {
novelId: string novelId: string
stage: ArcStage stage: ArcStage
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[] unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
canMoveUp: boolean canMoveUp: boolean
canMoveDown: boolean canMoveDown: boolean
@@ -226,7 +227,7 @@ function ArcStageRow({
<option value="">Not pinned to a chapter</option> <option value="">Not pinned to a chapter</option>
{chapters.map((chapter) => ( {chapters.map((chapter) => (
<option key={chapter.id} value={chapter.id}> <option key={chapter.id} value={chapter.id}>
Ch. {chapter.number} {chapter.title} {chapterLabel(chapter.kind, chapter.displayNumber, chapter.title)}
</option> </option>
))} ))}
</select> </select>
@@ -182,7 +182,7 @@ function QuestionRow({
{(showsChapter || showsCharacter) && ( {(showsChapter || showsCharacter) && (
<p className="mt-1 text-xs muted"> <p className="mt-1 text-xs muted">
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`} {showsChapter && question.chapterLabel}
{showsChapter && showsCharacter && ' · '} {showsChapter && showsCharacter && ' · '}
{showsCharacter && question.characterName} {showsCharacter && question.characterName}
</p> </p>
+3 -1
View File
@@ -139,11 +139,13 @@ export function AutoField({
} }
export function Select<T extends string>({ export function Select<T extends string>({
id,
label, label,
value, value,
options, options,
onChange, onChange,
}: { }: {
id?: string
label?: string label?: string
value: T value: T
options: readonly T[] options: readonly T[]
@@ -152,7 +154,7 @@ export function Select<T extends string>({
return ( return (
<label className="block"> <label className="block">
{label && <span className="label">{label}</span>} {label && <span className="label">{label}</span>}
<select className="input" value={value} onChange={(e) => onChange(e.target.value as T)}> <select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
{options.map((option) => ( {options.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
+17 -7
View File
@@ -17,7 +17,8 @@ import {
useUpdateBeat, useUpdateBeat,
useUpdateChapter, useUpdateChapter,
} from '../api/hooks' } from '../api/hooks'
import { draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types' import { chapterKinds, draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -97,9 +98,9 @@ export default function ChapterPage() {
<Link <Link
to={`/novels/${novelId}/chapters/${prevChapter.id}`} to={`/novels/${novelId}/chapters/${prevChapter.id}`}
className="muted hover:underline" className="muted hover:underline"
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`} title={chapterLabel(prevChapter.kind, prevChapter.displayNumber, prevChapter.title)}
> >
Ch. {prevChapter.number} {prevChapter.kind === 'Body' ? `Ch. ${prevChapter.displayNumber}` : prevChapter.title}
</Link> </Link>
) : ( ) : (
<span className="muted" style={{ opacity: 0.4 }}> <span className="muted" style={{ opacity: 0.4 }}>
@@ -110,9 +111,9 @@ export default function ChapterPage() {
<Link <Link
to={`/novels/${novelId}/chapters/${nextChapter.id}`} to={`/novels/${novelId}/chapters/${nextChapter.id}`}
className="muted hover:underline" className="muted hover:underline"
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`} title={chapterLabel(nextChapter.kind, nextChapter.displayNumber, nextChapter.title)}
> >
Ch. {nextChapter.number} {nextChapter.kind === 'Body' ? `Ch. ${nextChapter.displayNumber}` : nextChapter.title}
</Link> </Link>
) : ( ) : (
<span className="muted" style={{ opacity: 0.4 }}> <span className="muted" style={{ opacity: 0.4 }}>
@@ -145,10 +146,11 @@ export default function ChapterPage() {
{tab === 'outline' && ( {tab === 'outline' && (
<section className="card mb-6 p-5"> <section className="card mb-6 p-5">
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]"> <div className="grid gap-4 sm:grid-cols-[4rem_1fr_9rem_10rem]">
<label className="block"> <label className="block">
<span className="label">No.</span> <span className="label">No.</span>
<input <input
id="chapter-number-input"
key={chapter.id} key={chapter.id}
className="input" className="input"
type="number" type="number"
@@ -168,6 +170,14 @@ export default function ChapterPage() {
readOnly={!canWrite} readOnly={!canWrite}
/> />
<Select <Select
id="chapter-kind-select"
label="Kind"
value={chapter.kind}
options={chapterKinds}
onChange={(kind) => canWrite && patch({ kind })}
/>
<Select
id="chapter-status-select"
label="Status" label="Status"
value={chapter.status} value={chapter.status}
options={draftStatuses} options={draftStatuses}
@@ -515,7 +525,7 @@ function BeatTable({
<option value="">Move to chapter…</option> <option value="">Move to chapter…</option>
{otherChapters.map((c) => ( {otherChapters.map((c) => (
<option key={c.id} value={c.id}> <option key={c.id} value={c.id}>
{c.number}. {c.title} {chapterLabel(c.kind, c.displayNumber, c.title)}
</option> </option>
))} ))}
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option> <option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
+1 -1
View File
@@ -49,7 +49,7 @@ export default function ChaptersPage() {
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md" className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
> >
<span className="w-8 shrink-0 text-right text-sm font-semibold muted"> <span className="w-8 shrink-0 text-right text-sm font-semibold muted">
{chapter.number} {chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div> <div className="truncate font-medium">{chapter.title}</div>
@@ -13,7 +13,8 @@ import {
useUnlinkCharacterIdentity, useUnlinkCharacterIdentity,
useUpdateCharacter, useUpdateCharacter,
} from '../api/hooks' } from '../api/hooks'
import { characterImportances, characterRoles, type Character } from '../api/types' import { characterImportances, characterRoles, type Character, type ChapterKind } from '../api/types'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -467,7 +468,7 @@ function IdentitySection({
}: { }: {
character: Character character: Character
allCharacters: Character[] allCharacters: Character[]
chapters: { id: string; number: number; title: string }[] chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
canWrite: boolean canWrite: boolean
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
onUnlink: () => void onUnlink: () => void
@@ -497,8 +498,8 @@ function IdentitySection({
<h3 className="label">Identity</h3> <h3 className="label">Identity</h3>
<p className="text-sm"> <p className="text-sm">
Really <span className="font-medium">{character.sameCharacterAsName}</span> Really <span className="font-medium">{character.sameCharacterAsName}</span>
{character.revealedInChapterNumber != null && ( {character.revealedInChapterLabel != null && (
<span className="muted"> revealed in Chapter {character.revealedInChapterNumber}</span> <span className="muted"> revealed in {character.revealedInChapterLabel}</span>
)} )}
</p> </p>
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>} {character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
@@ -560,7 +561,7 @@ function IdentitySection({
<option value=""></option> <option value=""></option>
{chapters.map((ch) => ( {chapters.map((ch) => (
<option key={ch.id} value={ch.id}> <option key={ch.id} value={ch.id}>
Ch. {ch.number} {ch.title} {chapterLabel(ch.kind, ch.displayNumber, ch.title)}
</option> </option>
))} ))}
</select> </select>
+1 -1
View File
@@ -97,7 +97,7 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm" className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
> >
<span className="w-6 shrink-0 text-right text-sm font-semibold muted"> <span className="w-6 shrink-0 text-right text-sm font-semibold muted">
{chapter.number} {chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate font-medium">{chapter.title}</div> <div className="truncate font-medium">{chapter.title}</div>
+2 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks' import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -151,7 +152,7 @@ function LocationReferencePanel({
to={`/novels/${novelId}/chapters/${c.id}`} to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{c.number}. {c.title} {chapterLabel(c.kind, c.displayNumber, c.title)}
</Link> </Link>
{c.summary && <span className="muted"> {c.summary}</span>} {c.summary && <span className="muted"> {c.summary}</span>}
</li> </li>
+3 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react' import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks' import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
@@ -175,7 +176,7 @@ function TagReferencePanel({
to={`/novels/${novelId}/chapters/${c.id}`} to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline" className="font-medium hover:underline"
> >
{c.number}. {c.title} {chapterLabel(c.kind, c.displayNumber, c.title)}
</Link> </Link>
{c.summary && <span className="muted"> {c.summary}</span>} {c.summary && <span className="muted"> {c.summary}</span>}
</li> </li>
@@ -198,7 +199,7 @@ function TagReferencePanel({
</Link> </Link>
<span className="muted"> <span className="muted">
{' '} {' '}
ch. {b.chapterNumber} {b.chapterTitle}, beat {b.sortOrder} {b.chapterLabel}, beat {b.sortOrder}
{b.characterName && `, ${b.characterName}`} {b.characterName && `, ${b.characterName}`}
</span> </span>
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>} {b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
@@ -78,4 +78,62 @@ public class ChapterServiceTests : ServiceTestFixture
[Test] [Test]
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() => public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False); 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));
});
}
} }
+1 -1
View File
@@ -85,7 +85,7 @@ public class ListingTests : ServiceTestFixture
var agent = new NovelAgentService( var agent = new NovelAgentService(
Db.Context, Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance), new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance, NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator()); new SendAgentMessageRequestValidator());
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
private NovelAgentToolset _toolset = null!; private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() => protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance); _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance);
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
Db.Context, Db.Context,
@@ -23,6 +23,7 @@ public abstract class ServiceTestFixture
protected NovelService Novels { get; private set; } = null!; protected NovelService Novels { get; private set; } = null!;
protected CharacterService Characters { get; private set; } = null!; protected CharacterService Characters { get; private set; } = null!;
protected ChapterService Chapters { get; private set; } = null!; protected ChapterService Chapters { get; private set; } = null!;
protected ChapterDisplayNumberLookup ChapterLabels { get; private set; } = null!;
protected BeatService Beats { get; private set; } = null!; protected BeatService Beats { get; private set; } = null!;
protected CharacterArcService Arcs { get; private set; } = null!; protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!;
@@ -75,7 +76,8 @@ public abstract class ServiceTestFixture
Db.Context, Access, Tags, ActivityLog, CharacterLogs, Db.Context, Access, Tags, ActivityLog, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
new LinkCharacterIdentityRequestValidator()); new LinkCharacterIdentityRequestValidator());
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ActivityLog, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator()); ChapterLabels = new ChapterDisplayNumberLookup(Db.Context);
Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ChapterLabels, ActivityLog, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Beats = new BeatService( Beats = new BeatService(
Db.Context, Access, Tags, ActivityLog, BeatLogs, Db.Context, Access, Tags, ActivityLog, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(), new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),
@@ -103,6 +103,29 @@ public class TagServiceTests : ServiceTestFixture
}); });
} }
[Test]
public async Task Cross_reference_labels_front_matter_by_title_not_chapter_number()
{
var foreword = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Foreword", Number: 1, Kind: ChapterKind.FrontMatter, Tags: ["betrayal"]));
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Number: 2, Tags: ["betrayal"]));
var tagId = (await Tags.ListAsync(_novelId)).Single().Id;
var tag = (await Tags.GetReferencesAsync(tagId))!;
var displayNumbers = await ChapterLabels.ForNovelAsync(_novelId);
var references = tag.ToReferencesResponse(displayNumbers);
var forewordResponse = references.Chapters.Single(c => c.Id == foreword.Id);
var chapterResponse = references.Chapters.Single(c => c.Id == chapter.Id);
Assert.Multiple(() =>
{
Assert.That(forewordResponse.DisplayNumber, Is.Null);
Assert.That(chapterResponse.DisplayNumber, Is.EqualTo(1));
});
}
[Test] [Test]
public async Task Usage_counts_are_reported_per_kind() public async Task Usage_counts_are_reported_per_kind()
{ {