From 7f56c79b20343526b4e65cf7d28c4e75047cab9f Mon Sep 17 00:00:00 2001 From: James Wampler Date: Wed, 19 Aug 2026 18:02:53 -0700 Subject: [PATCH] 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". --- src/Novelly.Api/Agent/NovelAgentToolset.cs | 99 +++++++--- src/Novelly.Api/Beats/BeatContracts.cs | 4 +- src/Novelly.Api/Beats/BeatEndpoints.cs | 18 +- .../Chapters/ChapterDisplayNumberLookup.cs | 38 ++++ src/Novelly.Api/Chapters/ChapterService.cs | 11 +- .../Characters/CharacterContracts.cs | 16 +- .../Characters/CharacterEndpoints.cs | 178 +++++++++++++++--- .../Common/NovellyServiceRegistration.cs | 1 + .../Locations/LocationContracts.cs | 10 +- .../Locations/LocationEndpoints.cs | 14 +- .../Questions/OpenQuestionContracts.cs | 7 +- .../Questions/OpenQuestionEndpoints.cs | 40 ++-- src/Novelly.Api/Tags/TagContracts.cs | 14 +- src/Novelly.Api/Tags/TagEndpoints.cs | 14 +- tests/Novelly.Api.Tests/ListingTests.cs | 2 +- .../NovelAgentServiceTests.cs | 2 +- tests/Novelly.Api.Tests/ServiceTestFixture.cs | 4 +- tests/Novelly.Api.Tests/TagServiceTests.cs | 23 +++ 18 files changed, 407 insertions(+), 88 deletions(-) create mode 100644 src/Novelly.Api/Chapters/ChapterDisplayNumberLookup.cs diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index e42c177..758544f 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -28,6 +28,7 @@ public class NovelAgentToolset( CharacterService characters, CharacterArcService arcs, ChapterService chapters, + ChapterDisplayNumberLookup chapterLabels, BeatService beats, TagService tags, LocationService locations, @@ -359,7 +360,14 @@ public class NovelAgentToolset( async (_, input, ct) => { 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( @@ -378,7 +386,14 @@ public class NovelAgentToolset( async (_, input, ct) => { 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( @@ -503,14 +518,18 @@ public class NovelAgentToolset( new JsonSchemaBuilder() .Str("character_id", "Id of the character.", required: true) .Build(), - async (_, input, ct) => + async (novelId, input, ct) => { var characterId = JsonInput.RequiredGuid(input, "character_id"); - return await OrNotFound( - beats.ListForCharacterAsync(characterId, ct), - list => list.Select(b => b.ToCharacterBeatResponse(characterId)), - "Character", - characterId); + var characterBeats = await beats.ListForCharacterAsync(characterId, ct); + if (characterBeats is null) + { + return new ToolNotFound("Character", 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( @@ -600,12 +619,18 @@ public class NovelAgentToolset( .Str("character_id", "Narrow to questions about one character.") .Bool("include_resolved", "Include questions already settled. Defaults to false.") .Build(), - async (novelId, input, ct) => (await questions.ListAsync( - novelId, - JsonInput.Guid(input, "chapter_id"), - JsonInput.Guid(input, "character_id"), - JsonInput.Bool(input, "include_resolved") ?? false, - ct)).Select(q => q.ToResponse())); + async (novelId, input, ct) => + { + var list = await questions.ListAsync( + novelId, + JsonInput.Guid(input, "chapter_id"), + 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( "raise_open_question", @@ -618,13 +643,24 @@ public class NovelAgentToolset( .Str("chapter_id", "The chapter outline this is about, if any.") .Str("character_id", "The character this is about, if any.") .Build(), - async (novelId, input, ct) => await OrNotFound(questions.CreateAsync( - novelId, - new CreateOpenQuestionRequest( - JsonInput.RequiredString(input, "question"), - JsonInput.String(input, "detail"), - JsonInput.Guid(input, "chapter_id"), - JsonInput.Guid(input, "character_id")), ct), q => q.ToResponse(), "Novel", novelId)); + async (novelId, input, ct) => + { + var question = await questions.CreateAsync( + novelId, + new CreateOpenQuestionRequest( + JsonInput.RequiredString(input, "question"), + 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( "resolve_open_question", @@ -638,11 +674,19 @@ public class NovelAgentToolset( async (_, input, ct) => { var questionId = JsonInput.RequiredGuid(input, "question_id"); - return await OrNotFound(questions.ResolveAsync( + var question = await questions.ResolveAsync( questionId, new ResolveOpenQuestionRequest( 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( @@ -654,7 +698,14 @@ public class NovelAgentToolset( async (_, input, ct) => { 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( diff --git a/src/Novelly.Api/Beats/BeatContracts.cs b/src/Novelly.Api/Beats/BeatContracts.cs index 2d7b21b..6081f78 100644 --- a/src/Novelly.Api/Beats/BeatContracts.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -81,6 +81,7 @@ public record CharacterBeatResponse( Guid ChapterId, int ChapterNumber, string ChapterTitle, + string ChapterLabel, int SortOrder, string Title, string? WhatHappened, @@ -151,11 +152,12 @@ public static class BeatMapping [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], 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.ChapterId, b.Chapter?.Number ?? 0, b.Chapter?.Title ?? "(unknown chapter)", + chapterLabel ?? b.Chapter?.Title ?? "(unknown chapter)", b.SortOrder, b.Title, b.WhatHappened, diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index 8f3d3af..b6d5874 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common; 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."); app.MapGet("/api/characters/{characterId:guid}/beats", async ( - Guid characterId, BeatService service, CancellationToken ct) => - (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse(characterId)).ToList().ToApiResult()) + Guid characterId, BeatService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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(); + + return Results.Ok(characterBeats.Select(b => + b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers))).ToList()); + }) .WithTags("Beats") .WithSummary("Every beat this character appears in, in manuscript order."); diff --git a/src/Novelly.Api/Chapters/ChapterDisplayNumberLookup.cs b/src/Novelly.Api/Chapters/ChapterDisplayNumberLookup.cs new file mode 100644 index 0000000..1e56769 --- /dev/null +++ b/src/Novelly.Api/Chapters/ChapterDisplayNumberLookup.cs @@ -0,0 +1,38 @@ +using Microsoft.EntityFrameworkCore; +using Novelly.Api.Data; + +namespace Novelly.Api.Chapters; + +public class ChapterDisplayNumberLookup(INovelDbContext db) +{ + public async Task> 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 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> ForChaptersAsync(IEnumerable chapters, CancellationToken ct = default) + { + var displayNumbers = new Dictionary(); + + 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 displayNumbers) => + ChapterNumbering.Label(chapter.Kind, displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title); +} diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index 3a5d202..bb8f396 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -14,6 +14,7 @@ public class ChapterService( NovelAccessService access, TagService tags, LocationService locations, + ChapterDisplayNumberLookup displayNumbers, ActivityLog activity, ILogger logger, IModelValidator createValidator, @@ -168,14 +169,8 @@ public class ChapterService( return true; } - public async Task DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) - { - if (chapter.Kind != ChapterKind.Body) - return null; - - return await db.Chapters.CountAsync( - c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct); - } + public Task DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) => + displayNumbers.ForChapterAsync(chapter, ct); private async Task NextChapterNumberAsync(Guid novelId, CancellationToken ct) { diff --git a/src/Novelly.Api/Characters/CharacterContracts.cs b/src/Novelly.Api/Characters/CharacterContracts.cs index 4159773..511672b 100644 --- a/src/Novelly.Api/Characters/CharacterContracts.cs +++ b/src/Novelly.Api/Characters/CharacterContracts.cs @@ -1,4 +1,5 @@ using Novelly.Api.Beats; +using Novelly.Api.Chapters; using Novelly.Api.Common.Validation; using Novelly.Api.Tags; @@ -25,6 +26,7 @@ public record CharacterResponse( string? SameCharacterAsName, Guid? RevealedInChapterId, int? RevealedInChapterNumber, + string? RevealedInChapterLabel, string? IdentityNote, IReadOnlyList OtherIdentities, IReadOnlyList Relationships, @@ -200,6 +202,7 @@ public record ArcStageResponse( Guid? ChapterId, int? ChapterNumber, string? ChapterTitle, + string? ChapterLabel, IReadOnlyList Beats, DateTimeOffset UpdatedAt); @@ -286,7 +289,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator new( + public static CharacterResponse ToResponse(this Character c, IReadOnlyDictionary? displayNumbers = null) => new( 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.Aliases], @@ -294,6 +297,7 @@ public static class CharacterMapping c.SameCharacterAs?.Name, c.RevealedInChapterId, c.RevealedInChapter?.Number, + c.RevealedInChapter is { } revealedInChapter ? ChapterLabel(revealedInChapter, displayNumbers) : null, c.IdentityNote, [.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))], [.. c.Relationships.Select(r => new RelationshipResponse( @@ -303,10 +307,10 @@ public static class CharacterMapping r.RelationshipType, r.Description))], [.. 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); - public static ArcStageResponse ToResponse(this CharacterArcStage s) => new( + public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary? displayNumbers = null) => new( s.Id, s.CharacterId, s.SortOrder, @@ -315,9 +319,13 @@ public static class CharacterMapping s.ChapterId, s.Chapter?.Number, s.Chapter?.Title, + s.Chapter is { } chapter ? ChapterLabel(chapter, displayNumbers) : null, [.. s.Beats .OrderBy(b => b.Chapter?.Number ?? 0) .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); + + private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary? displayNumbers) => + ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title); } diff --git a/src/Novelly.Api/Characters/CharacterEndpoints.cs b/src/Novelly.Api/Characters/CharacterEndpoints.cs index 4ab2e94..d47e236 100644 --- a/src/Novelly.Api/Characters/CharacterEndpoints.cs +++ b/src/Novelly.Api/Characters/CharacterEndpoints.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common; using Novelly.Api.Common.Validation; @@ -5,18 +6,57 @@ namespace Novelly.Api.Characters; public static class CharacterEndpoints { + private static IEnumerable 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 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) { var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters") .AddEndpointFilter() .AddEndpointFilter(); - novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) => - Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse()))) + novelScoped.MapGet("/", async (Guid novelId, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + var list = await service.ListAsync(novelId, ct); + var responses = new List(); + 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."); 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); if (character is null) @@ -24,7 +64,8 @@ public static class CharacterEndpoints 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); }) .WithSummary("Add a character dossier."); @@ -33,13 +74,31 @@ public static class CharacterEndpoints .AddEndpointFilter() .AddEndpointFilter(); - characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => - (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) + characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); characters.MapPatch("/{id:guid}", async ( - Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, UpdateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => @@ -47,8 +106,17 @@ public static class CharacterEndpoints .WithSummary("Delete a character."); characters.MapPost("/{id:guid}/relationships", async ( - Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) => - (await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); characters.MapDelete("/relationships/{relationshipId:guid}", async ( @@ -57,8 +125,17 @@ public static class CharacterEndpoints .WithSummary("Remove a relationship."); characters.MapPut("/{id:guid}/identity", async ( - Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) => - (await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, LinkCharacterIdentityRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); characters.MapDelete("/{id:guid}/identity", async ( @@ -67,12 +144,22 @@ public static class CharacterEndpoints .WithSummary("Remove this character's identity link."); characters.MapGet("/{id:guid}/arc", async ( - Guid id, CharacterArcService service, CancellationToken ct) => - Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse()))) + Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + var stages = await service.ListAsync(id, ct); + var responses = new List(); + 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."); 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); if (stage is null) @@ -80,27 +167,61 @@ public static class CharacterEndpoints 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); }) .WithSummary("Add a stage to a character's arc."); characters.MapPost("/{id:guid}/arc/reorder", async ( - Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) => - (await service.ReorderAsync(id, request, ct))?.Select(s => s.ToResponse()).ToList().ToApiResult()) + Guid id, ReorderArcStagesRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + var stages = await service.ReorderAsync(id, request, ct); + if (stages is null) + { + return Results.NotFound(); + } + + var responses = new List(); + 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."); var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters") .AddEndpointFilter() .AddEndpointFilter(); - arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => - (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) + arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); arcStages.MapPatch("/{id:guid}", async ( - Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, UpdateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) => @@ -108,8 +229,17 @@ public static class CharacterEndpoints .WithSummary("Delete an arc stage."); arcStages.MapPost("/{id:guid}/beats", async ( - Guid id, SetArcStageBeatsRequest request, CharacterArcService service, CancellationToken ct) => - (await service.SetBeatsAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, SetArcStageBeatsRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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. " + "A beat moved into this stage leaves any other stage of the same character it was in."); diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index 325b6f6..63a3f7f 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -86,6 +86,7 @@ public static class NovellyServiceRegistration services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Novelly.Api/Locations/LocationContracts.cs b/src/Novelly.Api/Locations/LocationContracts.cs index 4d6db31..df32daf 100644 --- a/src/Novelly.Api/Locations/LocationContracts.cs +++ b/src/Novelly.Api/Locations/LocationContracts.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common.Validation; namespace Novelly.Api.Locations; @@ -36,17 +37,20 @@ public class UpdateLocationRequestValidator : IModelValidator 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 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? displayNumbers = null) => new( location.ToResponse(), [.. location.Chapters .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(); } diff --git a/src/Novelly.Api/Locations/LocationEndpoints.cs b/src/Novelly.Api/Locations/LocationEndpoints.cs index 59159d1..e7ec535 100644 --- a/src/Novelly.Api/Locations/LocationEndpoints.cs +++ b/src/Novelly.Api/Locations/LocationEndpoints.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common; using Novelly.Api.Common.Validation; @@ -33,8 +34,17 @@ public static class LocationEndpoints .AddEndpointFilter() .AddEndpointFilter(); - locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, CancellationToken ct) => - (await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult()) + locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); locations.MapPatch("/{id:guid}", async ( diff --git a/src/Novelly.Api/Questions/OpenQuestionContracts.cs b/src/Novelly.Api/Questions/OpenQuestionContracts.cs index 4319964..757d918 100644 --- a/src/Novelly.Api/Questions/OpenQuestionContracts.cs +++ b/src/Novelly.Api/Questions/OpenQuestionContracts.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common.Validation; namespace Novelly.Api.Questions; @@ -10,6 +11,7 @@ public record OpenQuestionResponse( Guid? ChapterId, int? ChapterNumber, string? ChapterTitle, + string? ChapterLabel, Guid? CharacterId, string? CharacterName, string? Resolution, @@ -74,7 +76,7 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator new( + public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary? displayNumbers = null) => new( q.Id, q.NovelId, q.Question, @@ -82,6 +84,9 @@ public static class OpenQuestionMapping q.ChapterId, q.Chapter?.Number, 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.Character?.Name, q.Resolution, diff --git a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs index 1453553..8f30f0e 100644 --- a/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs +++ b/src/Novelly.Api/Questions/OpenQuestionEndpoints.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common; using Novelly.Api.Common.Validation; @@ -14,15 +15,20 @@ public static class OpenQuestionEndpoints novelScoped.MapGet("/", async ( Guid novelId, OpenQuestionService service, + ChapterDisplayNumberLookup chapterLabels, CancellationToken ct, Guid? chapterId = null, Guid? characterId = null, 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."); 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); if (question is null) @@ -30,7 +36,8 @@ public static class OpenQuestionEndpoints 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); }) .WithSummary("Raise an open question, optionally against a chapter outline and/or a character."); @@ -39,22 +46,22 @@ public static class OpenQuestionEndpoints .AddEndpointFilter() .AddEndpointFilter(); - questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => - (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) + questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + await WithLabel(await service.GetAsync(id, ct), chapterLabels, ct)) .WithSummary("Read one question."); questions.MapPatch("/{id:guid}", async ( - Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + await WithLabel(await service.UpdateAsync(id, request, ct), chapterLabels, ct)) .WithSummary("Update a question or change what it is attached to."); questions.MapPost("/{id:guid}/resolve", async ( - Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) => - (await service.ResolveAsync(id, request, ct))?.ToResponse().ToApiResult()) + Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + await WithLabel(await service.ResolveAsync(id, request, ct), chapterLabels, ct)) .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) => - (await service.ReopenAsync(id, ct))?.ToResponse().ToApiResult()) + questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + await WithLabel(await service.ReopenAsync(id, ct), chapterLabels, ct)) .WithSummary("Put a resolved question back on the list."); questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) => @@ -63,4 +70,15 @@ public static class OpenQuestionEndpoints return app; } + + private static async Task 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)); + } } diff --git a/src/Novelly.Api/Tags/TagContracts.cs b/src/Novelly.Api/Tags/TagContracts.cs index 354ddd8..8b7078a 100644 --- a/src/Novelly.Api/Tags/TagContracts.cs +++ b/src/Novelly.Api/Tags/TagContracts.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common.Validation; namespace Novelly.Api.Tags; @@ -53,13 +54,14 @@ public record TagReferencesResponse( 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( Guid Id, Guid ChapterId, int ChapterNumber, string ChapterTitle, + string ChapterLabel, int SortOrder, string Title, 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 TagReferencesResponse ToReferencesResponse(this Tag tag) => new( + public static TagReferencesResponse ToReferencesResponse(this Tag tag, IReadOnlyDictionary? displayNumbers = null) => new( tag.ToResponse(), [.. tag.Characters .OrderBy(c => c.Name) .Select(c => new TaggedCharacterResponse(c.Id, c.Name, c.Role.ToString()))], [.. tag.Chapters .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 .OrderBy(b => b.Chapter?.Number ?? 0) .ThenBy(b => b.SortOrder) @@ -85,6 +90,9 @@ public static class TagMapping b.ChapterId, b.Chapter?.Number ?? 0, 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.Title, b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null, diff --git a/src/Novelly.Api/Tags/TagEndpoints.cs b/src/Novelly.Api/Tags/TagEndpoints.cs index cee0aaa..5d62904 100644 --- a/src/Novelly.Api/Tags/TagEndpoints.cs +++ b/src/Novelly.Api/Tags/TagEndpoints.cs @@ -1,3 +1,4 @@ +using Novelly.Api.Chapters; using Novelly.Api.Common; using Novelly.Api.Common.Validation; @@ -33,8 +34,17 @@ public static class TagEndpoints .AddEndpointFilter() .AddEndpointFilter(); - tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) => - (await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult()) + tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => + { + 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."); tags.MapPatch("/{id:guid}", async ( diff --git a/tests/Novelly.Api.Tests/ListingTests.cs b/tests/Novelly.Api.Tests/ListingTests.cs index 8ddcd59..e80e031 100644 --- a/tests/Novelly.Api.Tests/ListingTests.cs +++ b/tests/Novelly.Api.Tests/ListingTests.cs @@ -85,7 +85,7 @@ public class ListingTests : ServiceTestFixture var agent = new NovelAgentService( Db.Context, new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), - new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger.Instance), + new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger.Instance), Options.Create(new AgentOptions()), NullLogger.Instance, new SendAgentMessageRequestValidator()); diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 8770524..5431b38 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture private NovelAgentToolset _toolset = null!; protected override void OnSetUp() => - _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger.Instance); + _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger.Instance); private NovelAgentService BuildAgent(ScriptedModelClient model) => new( Db.Context, diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 8aeecb3..c22a1ca 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -23,6 +23,7 @@ public abstract class ServiceTestFixture protected NovelService Novels { get; private set; } = null!; protected CharacterService Characters { 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 CharacterArcService Arcs { 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, new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), 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( Db.Context, Access, Tags, ActivityLog, BeatLogs, new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(), diff --git a/tests/Novelly.Api.Tests/TagServiceTests.cs b/tests/Novelly.Api.Tests/TagServiceTests.cs index 7187ca2..9674db3 100644 --- a/tests/Novelly.Api.Tests/TagServiceTests.cs +++ b/tests/Novelly.Api.Tests/TagServiceTests.cs @@ -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] public async Task Usage_counts_are_reported_per_kind() {