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".
This commit is contained in:
James Wampler
2026-08-19 18:02:53 -07:00
parent ef5260a111
commit 7f56c79b20
18 changed files with 407 additions and 88 deletions
+66 -15
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,7 +386,14 @@ 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(
@@ -503,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(
@@ -600,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) =>
{
var list = await questions.ListAsync(
novelId, novelId,
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id"), JsonInput.Guid(input, "character_id"),
JsonInput.Bool(input, "include_resolved") ?? false, JsonInput.Bool(input, "include_resolved") ?? false,
ct)).Select(q => q.ToResponse())); 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",
@@ -618,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) =>
{
var question = await questions.CreateAsync(
novelId, novelId,
new CreateOpenQuestionRequest( new CreateOpenQuestionRequest(
JsonInput.RequiredString(input, "question"), JsonInput.RequiredString(input, "question"),
JsonInput.String(input, "detail"), JsonInput.String(input, "detail"),
JsonInput.Guid(input, "chapter_id"), JsonInput.Guid(input, "chapter_id"),
JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId)); 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",
@@ -638,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(
@@ -654,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.");
@@ -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);
}
+3 -8
View File
@@ -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,
@@ -168,14 +169,8 @@ public class ChapterService(
return true; return true;
} }
public async Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) public Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) =>
{ displayNumbers.ForChapterAsync(chapter, ct);
if (chapter.Kind != ChapterKind.Body)
return null;
return await db.Chapters.CountAsync(
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
}
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct) private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
{ {
@@ -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>();
@@ -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 (
+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()
{ {