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:
@@ -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(
|
||||
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)).Select(q => q.ToResponse()));
|
||||
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(
|
||||
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), 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(
|
||||
"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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Guid, int>();
|
||||
|
||||
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.");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -14,6 +14,7 @@ public class ChapterService(
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
LocationService locations,
|
||||
ChapterDisplayNumberLookup displayNumbers,
|
||||
ActivityLog activity,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
@@ -168,14 +169,8 @@ public class ChapterService(
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default)
|
||||
{
|
||||
if (chapter.Kind != ChapterKind.Body)
|
||||
return null;
|
||||
|
||||
return await db.Chapters.CountAsync(
|
||||
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
|
||||
}
|
||||
public Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) =>
|
||||
displayNumbers.ForChapterAsync(chapter, ct);
|
||||
|
||||
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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<CharacterIdentityResponse> OtherIdentities,
|
||||
IReadOnlyList<RelationshipResponse> Relationships,
|
||||
@@ -200,6 +202,7 @@ public record ArcStageResponse(
|
||||
Guid? ChapterId,
|
||||
int? ChapterNumber,
|
||||
string? ChapterTitle,
|
||||
string? ChapterLabel,
|
||||
IReadOnlyList<CharacterBeatResponse> Beats,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
@@ -286,7 +289,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeats
|
||||
|
||||
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.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<Guid, int>? 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<Guid, int>? displayNumbers) =>
|
||||
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
|
||||
}
|
||||
|
||||
@@ -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<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)
|
||||
{
|
||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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<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.");
|
||||
|
||||
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<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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<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.");
|
||||
|
||||
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<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.");
|
||||
|
||||
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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.");
|
||||
|
||||
|
||||
@@ -86,6 +86,7 @@ public static class NovellyServiceRegistration
|
||||
services.AddScoped<LocationService>();
|
||||
services.AddScoped<GenreService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<ChapterDisplayNumberLookup>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
services.AddScoped<NovelAgentService>();
|
||||
|
||||
@@ -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<UpdateLocationRequ
|
||||
|
||||
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 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.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();
|
||||
}
|
||||
|
||||
@@ -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<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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<ResolveOpenQu
|
||||
|
||||
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.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,
|
||||
|
||||
@@ -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<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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<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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Guid, int>? 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,
|
||||
|
||||
@@ -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<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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 (
|
||||
|
||||
@@ -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<NovelAgentToolset>.Instance),
|
||||
new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance),
|
||||
Options.Create(new AgentOptions()),
|
||||
NullLogger<NovelAgentService>.Instance,
|
||||
new SendAgentMessageRequestValidator());
|
||||
|
||||
@@ -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<NovelAgentToolset>.Instance);
|
||||
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, ChapterLabels, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||
|
||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||
Db.Context,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user