Compare commits
4
Commits
71953220aa
...
3795ddd541
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3795ddd541 | ||
|
|
6b0cdd0d71 | ||
|
|
7f56c79b20 | ||
|
|
ef5260a111 |
@@ -28,6 +28,7 @@ public class NovelAgentToolset(
|
|||||||
CharacterService characters,
|
CharacterService characters,
|
||||||
CharacterArcService arcs,
|
CharacterArcService arcs,
|
||||||
ChapterService chapters,
|
ChapterService chapters,
|
||||||
|
ChapterDisplayNumberLookup chapterLabels,
|
||||||
BeatService beats,
|
BeatService beats,
|
||||||
TagService tags,
|
TagService tags,
|
||||||
LocationService locations,
|
LocationService locations,
|
||||||
@@ -359,7 +360,14 @@ public class NovelAgentToolset(
|
|||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var tagId = JsonInput.RequiredGuid(input, "tag_id");
|
var tagId = JsonInput.RequiredGuid(input, "tag_id");
|
||||||
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
|
var tag = await tags.GetReferencesAsync(tagId, ct);
|
||||||
|
if (tag is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("Tag", tagId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForNovelAsync(tag.NovelId, ct);
|
||||||
|
return tag.ToReferencesResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
@@ -378,14 +386,26 @@ public class NovelAgentToolset(
|
|||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var locationId = JsonInput.RequiredGuid(input, "location_id");
|
var locationId = JsonInput.RequiredGuid(input, "location_id");
|
||||||
return await OrNotFound(locations.GetReferencesAsync(locationId, ct), l => l.ToReferencesResponse(), "Location", locationId);
|
var location = await locations.GetReferencesAsync(locationId, ct);
|
||||||
|
if (location is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("Location", locationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForNovelAsync(location.NovelId, ct);
|
||||||
|
return location.ToReferencesResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"list_chapters",
|
"list_chapters",
|
||||||
"List the novel's chapters in manuscript order with beat and word counts.",
|
"List the novel's chapters in manuscript order with beat and word counts.",
|
||||||
new JsonSchemaBuilder().Build(),
|
new JsonSchemaBuilder().Build(),
|
||||||
async (novelId, _, ct) => (await chapters.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse()));
|
async (novelId, _, ct) =>
|
||||||
|
{
|
||||||
|
var list = await chapters.ListAsync(novelId, ct);
|
||||||
|
var displayNumbers = ChapterNumbering.DisplayNumbers(list);
|
||||||
|
return list.Select(c => c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null));
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_chapter",
|
"get_chapter",
|
||||||
@@ -396,15 +416,25 @@ public class NovelAgentToolset(
|
|||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||||
return await OrNotFound(chapters.GetAsync(chapterId, ct), c => c.ToResponse(), "Chapter", chapterId);
|
var chapter = await chapters.GetAsync(chapterId, ct);
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("Chapter", chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
|
||||||
|
return chapter.ToResponse(displayNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"create_chapter",
|
"create_chapter",
|
||||||
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
|
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one. "
|
||||||
|
+ "Front matter (foreword, introduction, prologue) and back matter (afterword, about the "
|
||||||
|
+ "author) are labeled by title alone and do not count against the numbered chapters.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("title", "Chapter title.", required: true)
|
.Str("title", "Chapter title.", required: true)
|
||||||
.Int("number", "Position in the manuscript, 1-based.")
|
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
|
||||||
|
.Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames<ChapterKind>())
|
||||||
.Str("summary", "What the chapter covers.")
|
.Str("summary", "What the chapter covers.")
|
||||||
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
|
.StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
|
||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
@@ -413,26 +443,39 @@ public class NovelAgentToolset(
|
|||||||
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
||||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
|
async (novelId, input, ct) =>
|
||||||
|
{
|
||||||
|
var chapter = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||||
JsonInput.RequiredString(input, "title"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
JsonInput.Int(input, "number"),
|
JsonInput.Int(input, "number"),
|
||||||
|
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
|
||||||
JsonInput.String(input, "summary"),
|
JsonInput.String(input, "summary"),
|
||||||
JsonInput.Strings(input, "locations"),
|
JsonInput.Strings(input, "locations"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Int(input, "target_word_count"),
|
||||||
JsonInput.String(input, "prose"),
|
JsonInput.String(input, "prose"),
|
||||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
|
JsonInput.Strings(input, "tags")), ct);
|
||||||
|
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("Novel", novelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
|
||||||
|
return chapter.ToResponse(displayNumber);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_chapter",
|
"update_chapter",
|
||||||
"Revise a chapter's title, number, summary, locations, notes, status or drafted "
|
"Revise a chapter's title, number, kind, summary, locations, notes, status or drafted "
|
||||||
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
||||||
+ "word count is recomputed automatically.",
|
+ "word count is recomputed automatically.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||||
.Str("title", "New title.")
|
.Str("title", "New title.")
|
||||||
.Int("number", "Position in the manuscript.")
|
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
|
||||||
|
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
|
||||||
.Str("summary", "What the chapter covers.")
|
.Str("summary", "What the chapter covers.")
|
||||||
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
|
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
|
||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
@@ -444,18 +487,27 @@ public class NovelAgentToolset(
|
|||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||||
return await OrNotFound(chapters.UpdateAsync(
|
var chapter = await chapters.UpdateAsync(
|
||||||
chapterId,
|
chapterId,
|
||||||
new UpdateChapterRequest(
|
new UpdateChapterRequest(
|
||||||
JsonInput.String(input, "title"),
|
JsonInput.String(input, "title"),
|
||||||
JsonInput.Int(input, "number"),
|
JsonInput.Int(input, "number"),
|
||||||
|
JsonInput.Enum<ChapterKind>(input, "kind"),
|
||||||
JsonInput.String(input, "summary"),
|
JsonInput.String(input, "summary"),
|
||||||
JsonInput.Strings(input, "locations"),
|
JsonInput.Strings(input, "locations"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Int(input, "target_word_count"),
|
||||||
JsonInput.String(input, "prose"),
|
JsonInput.String(input, "prose"),
|
||||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
|
JsonInput.Strings(input, "tags")), ct);
|
||||||
|
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("Chapter", chapterId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
|
||||||
|
return chapter.ToResponse(displayNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
@@ -466,14 +518,18 @@ public class NovelAgentToolset(
|
|||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("character_id", "Id of the character.", required: true)
|
.Str("character_id", "Id of the character.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (novelId, input, ct) =>
|
||||||
{
|
{
|
||||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
||||||
return await OrNotFound(
|
var characterBeats = await beats.ListForCharacterAsync(characterId, ct);
|
||||||
beats.ListForCharacterAsync(characterId, ct),
|
if (characterBeats is null)
|
||||||
list => list.Select(b => b.ToCharacterBeatResponse(characterId)),
|
{
|
||||||
"Character",
|
return new ToolNotFound("Character", characterId);
|
||||||
characterId);
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForNovelAsync(novelId, ct);
|
||||||
|
return characterBeats.Select(b =>
|
||||||
|
b.ToCharacterBeatResponse(characterId, b.Chapter is null ? null : chapterLabels.LabelFor(b.Chapter, displayNumbers)));
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
@@ -563,12 +619,18 @@ public class NovelAgentToolset(
|
|||||||
.Str("character_id", "Narrow to questions about one character.")
|
.Str("character_id", "Narrow to questions about one character.")
|
||||||
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) => (await questions.ListAsync(
|
async (novelId, input, ct) =>
|
||||||
|
{
|
||||||
|
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",
|
||||||
@@ -581,13 +643,24 @@ public class NovelAgentToolset(
|
|||||||
.Str("chapter_id", "The chapter outline this is about, if any.")
|
.Str("chapter_id", "The chapter outline this is about, if any.")
|
||||||
.Str("character_id", "The character this is about, if any.")
|
.Str("character_id", "The character this is about, if any.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) => await OrNotFound(questions.CreateAsync(
|
async (novelId, input, ct) =>
|
||||||
|
{
|
||||||
|
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",
|
||||||
@@ -601,11 +674,19 @@ public class NovelAgentToolset(
|
|||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
||||||
return await OrNotFound(questions.ResolveAsync(
|
var question = await questions.ResolveAsync(
|
||||||
questionId,
|
questionId,
|
||||||
new ResolveOpenQuestionRequest(
|
new ResolveOpenQuestionRequest(
|
||||||
JsonInput.RequiredString(input, "resolution"),
|
JsonInput.RequiredString(input, "resolution"),
|
||||||
JsonInput.Bool(input, "append_to_notes") ?? false), ct), q => q.ToResponse(), "OpenQuestion", questionId);
|
JsonInput.Bool(input, "append_to_notes") ?? false), ct);
|
||||||
|
|
||||||
|
if (question is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("OpenQuestion", questionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
|
||||||
|
return question.ToResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
@@ -617,7 +698,14 @@ public class NovelAgentToolset(
|
|||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
{
|
{
|
||||||
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
var questionId = JsonInput.RequiredGuid(input, "question_id");
|
||||||
return await OrNotFound(questions.ReopenAsync(questionId, ct), q => q.ToResponse(), "OpenQuestion", questionId);
|
var question = await questions.ReopenAsync(questionId, ct);
|
||||||
|
if (question is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("OpenQuestion", questionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForNovelAsync(question.NovelId, ct);
|
||||||
|
return question.ToResponse(displayNumbers);
|
||||||
});
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ public class Chapter
|
|||||||
|
|
||||||
public int Number { get; set; }
|
public int Number { get; set; }
|
||||||
|
|
||||||
|
public ChapterKind Kind { get; set; } = ChapterKind.Body;
|
||||||
|
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
@@ -44,6 +46,7 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
|
|||||||
{
|
{
|
||||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
||||||
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
||||||
|
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
|
||||||
entity.HasIndex(c => new { c.NovelId, c.Number });
|
entity.HasIndex(c => new { c.NovelId, c.Number });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ public record ChapterSummaryResponse(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
Guid NovelId,
|
Guid NovelId,
|
||||||
int Number,
|
int Number,
|
||||||
|
ChapterKind Kind,
|
||||||
|
int? DisplayNumber,
|
||||||
string Title,
|
string Title,
|
||||||
string? Summary,
|
string? Summary,
|
||||||
IReadOnlyList<LocationResponse> Locations,
|
IReadOnlyList<LocationResponse> Locations,
|
||||||
@@ -24,6 +26,8 @@ public record ChapterResponse(
|
|||||||
Guid Id,
|
Guid Id,
|
||||||
Guid NovelId,
|
Guid NovelId,
|
||||||
int Number,
|
int Number,
|
||||||
|
ChapterKind Kind,
|
||||||
|
int? DisplayNumber,
|
||||||
string Title,
|
string Title,
|
||||||
string? Summary,
|
string? Summary,
|
||||||
IReadOnlyList<LocationResponse> Locations,
|
IReadOnlyList<LocationResponse> Locations,
|
||||||
@@ -39,6 +43,7 @@ public record ChapterResponse(
|
|||||||
public record CreateChapterRequest(
|
public record CreateChapterRequest(
|
||||||
string Title,
|
string Title,
|
||||||
int? Number = null,
|
int? Number = null,
|
||||||
|
ChapterKind Kind = ChapterKind.Body,
|
||||||
string? Summary = null,
|
string? Summary = null,
|
||||||
IReadOnlyList<string>? Locations = null,
|
IReadOnlyList<string>? Locations = null,
|
||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
@@ -63,6 +68,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
|
|||||||
public record UpdateChapterRequest(
|
public record UpdateChapterRequest(
|
||||||
string? Title = null,
|
string? Title = null,
|
||||||
int? Number = null,
|
int? Number = null,
|
||||||
|
ChapterKind? Kind = null,
|
||||||
string? Summary = null,
|
string? Summary = null,
|
||||||
IReadOnlyList<string>? Locations = null,
|
IReadOnlyList<string>? Locations = null,
|
||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
@@ -115,8 +121,8 @@ file static class ChapterValidation
|
|||||||
|
|
||||||
public static class ChapterMapping
|
public static class ChapterMapping
|
||||||
{
|
{
|
||||||
public static ChapterResponse ToResponse(this Chapter c) => new(
|
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
|
||||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
|
||||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||||
c.Notes,
|
c.Notes,
|
||||||
c.Status, c.TargetWordCount,
|
c.Status, c.TargetWordCount,
|
||||||
@@ -125,8 +131,8 @@ public static class ChapterMapping
|
|||||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
c.UpdatedAt);
|
c.UpdatedAt);
|
||||||
|
|
||||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
|
||||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
|
||||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||||
c.Status, c.TargetWordCount,
|
c.Status, c.TargetWordCount,
|
||||||
c.Beats.Count, c.WordCount,
|
c.Beats.Count, c.WordCount,
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Novelly.Api.Data;
|
||||||
|
|
||||||
|
namespace Novelly.Api.Chapters;
|
||||||
|
|
||||||
|
public class ChapterDisplayNumberLookup(INovelDbContext db)
|
||||||
|
{
|
||||||
|
public async Task<IReadOnlyDictionary<Guid, int>> ForNovelAsync(Guid novelId, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var chapters = await db.Chapters.AsNoTracking().Where(c => c.NovelId == novelId).ToListAsync(ct);
|
||||||
|
return ChapterNumbering.DisplayNumbers(chapters);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<int?> ForChapterAsync(Chapter chapter, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (chapter.Kind != ChapterKind.Body)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await db.Chapters.CountAsync(
|
||||||
|
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyDictionary<Guid, int>> ForChaptersAsync(IEnumerable<Chapter> chapters, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var displayNumbers = new Dictionary<Guid, int>();
|
||||||
|
|
||||||
|
foreach (var chapter in chapters.DistinctBy(c => c.Id))
|
||||||
|
{
|
||||||
|
if (await ForChapterAsync(chapter, ct) is { } number)
|
||||||
|
displayNumbers[chapter.Id] = number;
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public string LabelFor(Chapter chapter, IReadOnlyDictionary<Guid, int> displayNumbers) =>
|
||||||
|
ChapterNumbering.Label(chapter.Kind, displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
|
||||||
|
}
|
||||||
@@ -12,7 +12,12 @@ public static class ChapterEndpoints
|
|||||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
|
novelScoped.MapGet("/", async (Guid novelId, ChapterService service, CancellationToken ct) =>
|
||||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToSummaryResponse())))
|
{
|
||||||
|
var chapters = await service.ListAsync(novelId, ct);
|
||||||
|
var displayNumbers = ChapterNumbering.DisplayNumbers(chapters);
|
||||||
|
return Results.Ok(chapters.Select(c =>
|
||||||
|
c.ToSummaryResponse(displayNumbers.TryGetValue(c.Id, out var n) ? n : null)));
|
||||||
|
})
|
||||||
.WithSummary("List a novel's chapters in manuscript order.");
|
.WithSummary("List a novel's chapters in manuscript order.");
|
||||||
|
|
||||||
novelScoped.MapPost("/", async (
|
novelScoped.MapPost("/", async (
|
||||||
@@ -24,7 +29,8 @@ public static class ChapterEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var created = chapter.ToResponse();
|
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
|
||||||
|
var created = chapter.ToResponse(displayNumber);
|
||||||
return Results.Created($"/api/chapters/{created.Id}", created);
|
return Results.Created($"/api/chapters/{created.Id}", created);
|
||||||
})
|
})
|
||||||
.WithSummary("Add a chapter.");
|
.WithSummary("Add a chapter.");
|
||||||
@@ -34,12 +40,30 @@ public static class ChapterEndpoints
|
|||||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var chapter = await service.GetAsync(id, ct);
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
|
||||||
|
return chapter.ToResponse(displayNumber).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Read a chapter with its beats and prose.");
|
.WithSummary("Read a chapter with its beats and prose.");
|
||||||
|
|
||||||
chapters.MapPatch("/{id:guid}", async (
|
chapters.MapPatch("/{id:guid}", async (
|
||||||
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||||
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var chapter = await service.UpdateAsync(id, request, ct);
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumber = await service.DisplayNumberAsync(chapter, ct);
|
||||||
|
return chapter.ToResponse(displayNumber).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Update a chapter.");
|
.WithSummary("Update a chapter.");
|
||||||
|
|
||||||
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Novelly.Api.Chapters;
|
||||||
|
|
||||||
|
public enum ChapterKind
|
||||||
|
{
|
||||||
|
FrontMatter,
|
||||||
|
Body,
|
||||||
|
BackMatter
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
namespace Novelly.Api.Chapters;
|
||||||
|
|
||||||
|
public static class ChapterNumbering
|
||||||
|
{
|
||||||
|
public static IReadOnlyDictionary<Guid, int> DisplayNumbers(IEnumerable<Chapter> novelChapters)
|
||||||
|
{
|
||||||
|
var displayNumbers = new Dictionary<Guid, int>();
|
||||||
|
var next = 1;
|
||||||
|
|
||||||
|
foreach (var chapter in novelChapters.OrderBy(c => c.Number))
|
||||||
|
{
|
||||||
|
if (chapter.Kind != ChapterKind.Body)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
displayNumbers[chapter.Id] = next++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return displayNumbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Label(ChapterKind kind, int? displayNumber, string title) =>
|
||||||
|
kind == ChapterKind.Body && displayNumber is { } number
|
||||||
|
? $"Chapter {number}: {title}"
|
||||||
|
: title;
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ public class ChapterService(
|
|||||||
NovelAccessService access,
|
NovelAccessService access,
|
||||||
TagService tags,
|
TagService tags,
|
||||||
LocationService locations,
|
LocationService locations,
|
||||||
|
ChapterDisplayNumberLookup displayNumbers,
|
||||||
ActivityLog activity,
|
ActivityLog activity,
|
||||||
ILogger<ChapterService> logger,
|
ILogger<ChapterService> logger,
|
||||||
IModelValidator<CreateChapterRequest> createValidator,
|
IModelValidator<CreateChapterRequest> createValidator,
|
||||||
@@ -73,6 +74,7 @@ public class ChapterService(
|
|||||||
NovelId = novelId,
|
NovelId = novelId,
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
||||||
|
Kind = request.Kind,
|
||||||
Summary = request.Summary,
|
Summary = request.Summary,
|
||||||
Notes = request.Notes,
|
Notes = request.Notes,
|
||||||
Status = request.Status,
|
Status = request.Status,
|
||||||
@@ -116,6 +118,7 @@ public class ChapterService(
|
|||||||
|
|
||||||
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
||||||
chapter.Number = request.Number ?? chapter.Number;
|
chapter.Number = request.Number ?? chapter.Number;
|
||||||
|
chapter.Kind = request.Kind ?? chapter.Kind;
|
||||||
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
|
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
|
||||||
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
||||||
chapter.Status = request.Status ?? chapter.Status;
|
chapter.Status = request.Status ?? chapter.Status;
|
||||||
@@ -166,6 +169,9 @@ public class ChapterService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default) =>
|
||||||
|
displayNumbers.ForChapterAsync(chapter, ct);
|
||||||
|
|
||||||
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
|
private async Task<int> NextChapterNumberAsync(Guid novelId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ public record CharacterResponse(
|
|||||||
string? SameCharacterAsName,
|
string? SameCharacterAsName,
|
||||||
Guid? RevealedInChapterId,
|
Guid? RevealedInChapterId,
|
||||||
int? RevealedInChapterNumber,
|
int? RevealedInChapterNumber,
|
||||||
|
string? RevealedInChapterLabel,
|
||||||
string? IdentityNote,
|
string? IdentityNote,
|
||||||
IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
|
IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
|
||||||
IReadOnlyList<RelationshipResponse> Relationships,
|
IReadOnlyList<RelationshipResponse> Relationships,
|
||||||
@@ -200,6 +202,7 @@ public record ArcStageResponse(
|
|||||||
Guid? ChapterId,
|
Guid? ChapterId,
|
||||||
int? ChapterNumber,
|
int? ChapterNumber,
|
||||||
string? ChapterTitle,
|
string? ChapterTitle,
|
||||||
|
string? ChapterLabel,
|
||||||
IReadOnlyList<CharacterBeatResponse> Beats,
|
IReadOnlyList<CharacterBeatResponse> Beats,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
@@ -286,7 +289,7 @@ public class SetArcStageBeatsRequestValidator : IModelValidator<SetArcStageBeats
|
|||||||
|
|
||||||
public static class CharacterMapping
|
public static class CharacterMapping
|
||||||
{
|
{
|
||||||
public static CharacterResponse ToResponse(this Character c) => new(
|
public static CharacterResponse ToResponse(this Character c, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
|
||||||
c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
|
c.Id, c.NovelId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
|
||||||
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
|
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
|
||||||
[.. c.Aliases],
|
[.. c.Aliases],
|
||||||
@@ -294,6 +297,7 @@ public static class CharacterMapping
|
|||||||
c.SameCharacterAs?.Name,
|
c.SameCharacterAs?.Name,
|
||||||
c.RevealedInChapterId,
|
c.RevealedInChapterId,
|
||||||
c.RevealedInChapter?.Number,
|
c.RevealedInChapter?.Number,
|
||||||
|
c.RevealedInChapter is { } revealedInChapter ? ChapterLabel(revealedInChapter, displayNumbers) : null,
|
||||||
c.IdentityNote,
|
c.IdentityNote,
|
||||||
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
|
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
|
||||||
[.. c.Relationships.Select(r => new RelationshipResponse(
|
[.. c.Relationships.Select(r => new RelationshipResponse(
|
||||||
@@ -303,10 +307,10 @@ public static class CharacterMapping
|
|||||||
r.RelationshipType,
|
r.RelationshipType,
|
||||||
r.Description))],
|
r.Description))],
|
||||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())],
|
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
|
||||||
c.UpdatedAt);
|
c.UpdatedAt);
|
||||||
|
|
||||||
public static ArcStageResponse ToResponse(this CharacterArcStage s) => new(
|
public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
|
||||||
s.Id,
|
s.Id,
|
||||||
s.CharacterId,
|
s.CharacterId,
|
||||||
s.SortOrder,
|
s.SortOrder,
|
||||||
@@ -315,9 +319,13 @@ public static class CharacterMapping
|
|||||||
s.ChapterId,
|
s.ChapterId,
|
||||||
s.Chapter?.Number,
|
s.Chapter?.Number,
|
||||||
s.Chapter?.Title,
|
s.Chapter?.Title,
|
||||||
|
s.Chapter is { } chapter ? ChapterLabel(chapter, displayNumbers) : null,
|
||||||
[.. s.Beats
|
[.. s.Beats
|
||||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
.OrderBy(b => b.Chapter?.Number ?? 0)
|
||||||
.ThenBy(b => b.SortOrder)
|
.ThenBy(b => b.SortOrder)
|
||||||
.Select(b => b.ToCharacterBeatResponse(s.CharacterId))],
|
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, b.Chapter is { } beatChapter ? ChapterLabel(beatChapter, displayNumbers) : null))],
|
||||||
s.UpdatedAt);
|
s.UpdatedAt);
|
||||||
|
|
||||||
|
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
|
||||||
|
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
|
|
||||||
@@ -5,18 +6,57 @@ namespace Novelly.Api.Characters;
|
|||||||
|
|
||||||
public static class CharacterEndpoints
|
public static class CharacterEndpoints
|
||||||
{
|
{
|
||||||
|
private static IEnumerable<Chapter> ChaptersOf(Character c)
|
||||||
|
{
|
||||||
|
if (c.RevealedInChapter is { } revealed)
|
||||||
|
{
|
||||||
|
yield return revealed;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var chapter in c.ArcStages.SelectMany(ChaptersOf))
|
||||||
|
{
|
||||||
|
yield return chapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerable<Chapter> ChaptersOf(CharacterArcStage stage)
|
||||||
|
{
|
||||||
|
if (stage.Chapter is { } chapter)
|
||||||
|
{
|
||||||
|
yield return chapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var beat in stage.Beats)
|
||||||
|
{
|
||||||
|
if (beat.Chapter is { } beatChapter)
|
||||||
|
{
|
||||||
|
yield return beatChapter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapCharacterEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
|
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/characters").WithTags("Characters")
|
||||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
novelScoped.MapGet("/", async (Guid novelId, CharacterService service, CancellationToken ct) =>
|
novelScoped.MapGet("/", async (Guid novelId, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
Results.Ok((await service.ListAsync(novelId, ct)).Select(c => c.ToResponse())))
|
{
|
||||||
|
var list = await service.ListAsync(novelId, ct);
|
||||||
|
var responses = new List<CharacterResponse>();
|
||||||
|
foreach (var character in list)
|
||||||
|
{
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
|
||||||
|
responses.Add(character.ToResponse(displayNumbers));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(responses);
|
||||||
|
})
|
||||||
.WithSummary("List a novel's character dossiers.");
|
.WithSummary("List a novel's character dossiers.");
|
||||||
|
|
||||||
novelScoped.MapPost("/", async (
|
novelScoped.MapPost("/", async (
|
||||||
Guid novelId, CreateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
Guid novelId, CreateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var character = await service.CreateAsync(novelId, request, ct);
|
var character = await service.CreateAsync(novelId, request, ct);
|
||||||
if (character is null)
|
if (character is null)
|
||||||
@@ -24,7 +64,8 @@ public static class CharacterEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var created = character.ToResponse();
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
|
||||||
|
var created = character.ToResponse(displayNumbers);
|
||||||
return Results.Created($"/api/characters/{created.Id}", created);
|
return Results.Created($"/api/characters/{created.Id}", created);
|
||||||
})
|
})
|
||||||
.WithSummary("Add a character dossier.");
|
.WithSummary("Add a character dossier.");
|
||||||
@@ -33,13 +74,31 @@ public static class CharacterEndpoints
|
|||||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
characters.MapGet("/{id:guid}", async (Guid id, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var character = await service.GetAsync(id, ct);
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
|
||||||
|
return character.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Read a character dossier.");
|
.WithSummary("Read a character dossier.");
|
||||||
|
|
||||||
characters.MapPatch("/{id:guid}", async (
|
characters.MapPatch("/{id:guid}", async (
|
||||||
Guid id, UpdateCharacterRequest request, CharacterService service, CancellationToken ct) =>
|
Guid id, UpdateCharacterRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var character = await service.UpdateAsync(id, request, ct);
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
|
||||||
|
return character.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Update a character dossier.");
|
.WithSummary("Update a character dossier.");
|
||||||
|
|
||||||
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
|
||||||
@@ -47,8 +106,17 @@ public static class CharacterEndpoints
|
|||||||
.WithSummary("Delete a character.");
|
.WithSummary("Delete a character.");
|
||||||
|
|
||||||
characters.MapPost("/{id:guid}/relationships", async (
|
characters.MapPost("/{id:guid}/relationships", async (
|
||||||
Guid id, CreateRelationshipRequest request, CharacterService service, CancellationToken ct) =>
|
Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.AddRelationshipAsync(id, request, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var character = await service.AddRelationshipAsync(id, request, ct);
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
|
||||||
|
return character.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Relate this character to another in the same novel.");
|
.WithSummary("Relate this character to another in the same novel.");
|
||||||
|
|
||||||
characters.MapDelete("/relationships/{relationshipId:guid}", async (
|
characters.MapDelete("/relationships/{relationshipId:guid}", async (
|
||||||
@@ -57,8 +125,17 @@ public static class CharacterEndpoints
|
|||||||
.WithSummary("Remove a relationship.");
|
.WithSummary("Remove a relationship.");
|
||||||
|
|
||||||
characters.MapPut("/{id:guid}/identity", async (
|
characters.MapPut("/{id:guid}/identity", async (
|
||||||
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
|
Guid id, LinkCharacterIdentityRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var character = await service.LinkIdentityAsync(id, request, ct);
|
||||||
|
if (character is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(character), ct);
|
||||||
|
return character.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Link this character as another identity of a character in the same novel.");
|
.WithSummary("Link this character as another identity of a character in the same novel.");
|
||||||
|
|
||||||
characters.MapDelete("/{id:guid}/identity", async (
|
characters.MapDelete("/{id:guid}/identity", async (
|
||||||
@@ -67,12 +144,22 @@ public static class CharacterEndpoints
|
|||||||
.WithSummary("Remove this character's identity link.");
|
.WithSummary("Remove this character's identity link.");
|
||||||
|
|
||||||
characters.MapGet("/{id:guid}/arc", async (
|
characters.MapGet("/{id:guid}/arc", async (
|
||||||
Guid id, CharacterArcService service, CancellationToken ct) =>
|
Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse())))
|
{
|
||||||
|
var stages = await service.ListAsync(id, ct);
|
||||||
|
var responses = new List<ArcStageResponse>();
|
||||||
|
foreach (var stage in stages)
|
||||||
|
{
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
|
||||||
|
responses.Add(stage.ToResponse(displayNumbers));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(responses);
|
||||||
|
})
|
||||||
.WithSummary("Read a character's arc: its stages, in order.");
|
.WithSummary("Read a character's arc: its stages, in order.");
|
||||||
|
|
||||||
characters.MapPost("/{id:guid}/arc", async (
|
characters.MapPost("/{id:guid}/arc", async (
|
||||||
Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
|
Guid id, CreateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var stage = await service.CreateAsync(id, request, ct);
|
var stage = await service.CreateAsync(id, request, ct);
|
||||||
if (stage is null)
|
if (stage is null)
|
||||||
@@ -80,27 +167,61 @@ public static class CharacterEndpoints
|
|||||||
return Results.NotFound();
|
return Results.NotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
var created = stage.ToResponse();
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
|
||||||
|
var created = stage.ToResponse(displayNumbers);
|
||||||
return Results.Created($"/api/arc-stages/{created.Id}", created);
|
return Results.Created($"/api/arc-stages/{created.Id}", created);
|
||||||
})
|
})
|
||||||
.WithSummary("Add a stage to a character's arc.");
|
.WithSummary("Add a stage to a character's arc.");
|
||||||
|
|
||||||
characters.MapPost("/{id:guid}/arc/reorder", async (
|
characters.MapPost("/{id:guid}/arc/reorder", async (
|
||||||
Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) =>
|
Guid id, ReorderArcStagesRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.ReorderAsync(id, request, ct))?.Select(s => s.ToResponse()).ToList().ToApiResult())
|
{
|
||||||
|
var stages = await service.ReorderAsync(id, request, ct);
|
||||||
|
if (stages is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var responses = new List<ArcStageResponse>();
|
||||||
|
foreach (var stage in stages)
|
||||||
|
{
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
|
||||||
|
responses.Add(stage.ToResponse(displayNumbers));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(responses);
|
||||||
|
})
|
||||||
.WithSummary("Renumber a character's arc to match the order given.");
|
.WithSummary("Renumber a character's arc to match the order given.");
|
||||||
|
|
||||||
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
|
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters")
|
||||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||||
|
|
||||||
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var stage = await service.GetAsync(id, ct);
|
||||||
|
if (stage is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
|
||||||
|
return stage.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Read one arc stage.");
|
.WithSummary("Read one arc stage.");
|
||||||
|
|
||||||
arcStages.MapPatch("/{id:guid}", async (
|
arcStages.MapPatch("/{id:guid}", async (
|
||||||
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
|
Guid id, UpdateArcStageRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var stage = await service.UpdateAsync(id, request, ct);
|
||||||
|
if (stage is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
|
||||||
|
return stage.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Update an arc stage.");
|
.WithSummary("Update an arc stage.");
|
||||||
|
|
||||||
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||||
@@ -108,8 +229,17 @@ public static class CharacterEndpoints
|
|||||||
.WithSummary("Delete an arc stage.");
|
.WithSummary("Delete an arc stage.");
|
||||||
|
|
||||||
arcStages.MapPost("/{id:guid}/beats", async (
|
arcStages.MapPost("/{id:guid}/beats", async (
|
||||||
Guid id, SetArcStageBeatsRequest request, CharacterArcService service, CancellationToken ct) =>
|
Guid id, SetArcStageBeatsRequest request, CharacterArcService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
|
||||||
(await service.SetBeatsAsync(id, request, ct))?.ToResponse().ToApiResult())
|
{
|
||||||
|
var stage = await service.SetBeatsAsync(id, request, ct);
|
||||||
|
if (stage is null)
|
||||||
|
{
|
||||||
|
return Results.NotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumbers = await chapterLabels.ForChaptersAsync(ChaptersOf(stage), ct);
|
||||||
|
return stage.ToResponse(displayNumbers).ToApiResult();
|
||||||
|
})
|
||||||
.WithSummary("Set which beats belong to this arc stage, replacing its current set. "
|
.WithSummary("Set which beats belong to this arc stage, replacing its current set. "
|
||||||
+ "A beat moved into this stage leaves any other stage of the same character it was in.");
|
+ "A beat moved into this stage leaves any other stage of the same character it was in.");
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ public static class NovellyServiceRegistration
|
|||||||
services.AddScoped<LocationService>();
|
services.AddScoped<LocationService>();
|
||||||
services.AddScoped<GenreService>();
|
services.AddScoped<GenreService>();
|
||||||
services.AddScoped<ChapterService>();
|
services.AddScoped<ChapterService>();
|
||||||
|
services.AddScoped<ChapterDisplayNumberLookup>();
|
||||||
services.AddScoped<OpenQuestionService>();
|
services.AddScoped<OpenQuestionService>();
|
||||||
services.AddScoped<NovelAgentToolset>();
|
services.AddScoped<NovelAgentToolset>();
|
||||||
services.AddScoped<NovelAgentService>();
|
services.AddScoped<NovelAgentService>();
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Novelly.Api.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChapterKind : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<string>(
|
||||||
|
name: "Kind",
|
||||||
|
table: "Chapters",
|
||||||
|
type: "TEXT",
|
||||||
|
maxLength: 32,
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: "Body");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Kind",
|
||||||
|
table: "Chapters");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -319,6 +319,11 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<long>("CreatedAt")
|
b.Property<long>("CreatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<string>("Kind")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(32)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Notes")
|
b.Property<string>("Notes")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -274,10 +274,12 @@ public class ImportAgentToolset(
|
|||||||
|
|
||||||
yield return new ImportAgentTool(
|
yield return new ImportAgentTool(
|
||||||
"create_chapter",
|
"create_chapter",
|
||||||
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one.",
|
"Add a chapter. Its number is appended to the end of the manuscript unless you supply one. "
|
||||||
|
+ "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("title", "Chapter title.", required: true)
|
.Str("title", "Chapter title.", required: true)
|
||||||
.Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.")
|
.Int("number", "Position in the manuscript, 1-based, matching the outline's chapter number.")
|
||||||
|
.Enum("kind", "Front matter, a numbered body chapter, or back matter. Defaults to a body chapter.", System.Enum.GetNames<ChapterKind>())
|
||||||
.Str("summary", "The chapter's prose summary paragraph(s).")
|
.Str("summary", "The chapter's prose summary paragraph(s).")
|
||||||
.Str("notes", "The chapter file's ## Notes section, if present.")
|
.Str("notes", "The chapter file's ## Notes section, if present.")
|
||||||
.StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].")
|
.StringArray("tags", "The Part value and the raw Thread text, e.g. ['Part I', 'thread:Logen'].")
|
||||||
@@ -288,6 +290,7 @@ public class ImportAgentToolset(
|
|||||||
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
var created = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||||
JsonInput.RequiredString(input, "title"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
JsonInput.Int(input, "number"),
|
JsonInput.Int(input, "number"),
|
||||||
|
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
|
||||||
JsonInput.String(input, "summary"),
|
JsonInput.String(input, "summary"),
|
||||||
Notes: JsonInput.String(input, "notes"),
|
Notes: JsonInput.String(input, "notes"),
|
||||||
Tags: JsonInput.Strings(input, "tags")), ct);
|
Tags: JsonInput.Strings(input, "tags")), ct);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
|
|
||||||
namespace Novelly.Api.Locations;
|
namespace Novelly.Api.Locations;
|
||||||
@@ -36,17 +37,20 @@ public class UpdateLocationRequestValidator : IModelValidator<UpdateLocationRequ
|
|||||||
|
|
||||||
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
|
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
|
||||||
|
|
||||||
public record LocatedChapterResponse(Guid Id, int Number, string Title, string? Summary);
|
public record LocatedChapterResponse(Guid Id, int Number, ChapterKind Kind, int? DisplayNumber, string Title, string? Summary);
|
||||||
|
|
||||||
public static class LocationMapping
|
public static class LocationMapping
|
||||||
{
|
{
|
||||||
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
|
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
|
||||||
|
|
||||||
public static LocationReferencesResponse ToReferencesResponse(this Location location) => new(
|
public static LocationReferencesResponse ToReferencesResponse(this Location location, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new(
|
||||||
location.ToResponse(),
|
location.ToResponse(),
|
||||||
[.. location.Chapters
|
[.. location.Chapters
|
||||||
.OrderBy(c => c.Number)
|
.OrderBy(c => c.Number)
|
||||||
.Select(c => new LocatedChapterResponse(c.Id, c.Number, c.Title, c.Summary))]);
|
.Select(c => new LocatedChapterResponse(
|
||||||
|
c.Id, c.Number, c.Kind,
|
||||||
|
displayNumbers is not null && displayNumbers.TryGetValue(c.Id, out var n) ? n : null,
|
||||||
|
c.Title, c.Summary))]);
|
||||||
|
|
||||||
public static string Normalise(string name) => name.Trim();
|
public static string Normalise(string name) => name.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -24,13 +24,15 @@ public static class ManuscriptTools
|
|||||||
api.GetAsync($"/api/chapters/{chapterId}", ct);
|
api.GetAsync($"/api/chapters/{chapterId}", ct);
|
||||||
|
|
||||||
[McpServerTool(Name = "create_chapter")]
|
[McpServerTool(Name = "create_chapter")]
|
||||||
[Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number.")]
|
[Description("Add a chapter to a novel. It goes at the end of the manuscript unless you supply a number. "
|
||||||
|
+ "Use 'kind' for a foreword, prologue, afterword, or other unnumbered front/back matter.")]
|
||||||
public static Task<CallToolResult> CreateChapter(
|
public static Task<CallToolResult> CreateChapter(
|
||||||
NovelApiClient api,
|
NovelApiClient api,
|
||||||
[Description("The novel's id.")] Guid novelId,
|
[Description("The novel's id.")] Guid novelId,
|
||||||
[Description("Chapter title.")] string title,
|
[Description("Chapter title.")] string title,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
[Description("Position in the manuscript, 1-based.")] int? number = null,
|
[Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null,
|
||||||
|
[Description("FrontMatter, Body, or BackMatter. Defaults to Body.")] string? kind = null,
|
||||||
[Description("The chapter's outline summary paragraph.")] string? summary = null,
|
[Description("The chapter's outline summary paragraph.")] string? summary = null,
|
||||||
[Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null,
|
[Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null,
|
||||||
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
||||||
@@ -41,6 +43,7 @@ public static class ManuscriptTools
|
|||||||
{
|
{
|
||||||
title,
|
title,
|
||||||
number,
|
number,
|
||||||
|
kind = kind ?? "Body",
|
||||||
summary,
|
summary,
|
||||||
locations,
|
locations,
|
||||||
status = status ?? "Planned",
|
status = status ?? "Planned",
|
||||||
@@ -50,7 +53,7 @@ public static class ManuscriptTools
|
|||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
[McpServerTool(Name = "update_chapter")]
|
[McpServerTool(Name = "update_chapter")]
|
||||||
[Description("Revise a chapter's title, number, summary, locations, notes, status "
|
[Description("Revise a chapter's title, number, kind, summary, locations, notes, status "
|
||||||
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
|
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
|
||||||
+ "markdown; the word count is recomputed automatically.")]
|
+ "markdown; the word count is recomputed automatically.")]
|
||||||
public static Task<CallToolResult> UpdateChapter(
|
public static Task<CallToolResult> UpdateChapter(
|
||||||
@@ -58,7 +61,8 @@ public static class ManuscriptTools
|
|||||||
[Description("The chapter's id.")] Guid chapterId,
|
[Description("The chapter's id.")] Guid chapterId,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
[Description("New title.")] string? title = null,
|
[Description("New title.")] string? title = null,
|
||||||
[Description("Position in the manuscript.")] int? number = null,
|
[Description("Manuscript position, 1-based, counting front and back matter.")] int? number = null,
|
||||||
|
[Description("FrontMatter, Body, or BackMatter.")] string? kind = null,
|
||||||
[Description("The chapter's outline summary paragraph.")] string? summary = null,
|
[Description("The chapter's outline summary paragraph.")] string? summary = null,
|
||||||
[Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null,
|
[Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null,
|
||||||
[Description("Anything else worth recording.")] string? notes = null,
|
[Description("Anything else worth recording.")] string? notes = null,
|
||||||
@@ -67,5 +71,5 @@ public static class ManuscriptTools
|
|||||||
[Description("The chapter's drafted text, in markdown.")] string? prose = null,
|
[Description("The chapter's drafted text, in markdown.")] string? prose = null,
|
||||||
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
||||||
api.PatchAsync($"/api/chapters/{chapterId}",
|
api.PatchAsync($"/api/chapters/{chapterId}",
|
||||||
new { title, number, summary, locations, notes, status, targetWordCount, prose, tags }, ct);
|
new { title, number, kind, summary, locations, notes, status, targetWordCount, prose, tags }, ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import type { ChapterKind } from './types'
|
||||||
|
|
||||||
|
export function chapterLabel(kind: ChapterKind, displayNumber: number | null, title: string): string {
|
||||||
|
if (kind === 'Body' && displayNumber !== null) return `Chapter ${displayNumber}: ${title}`
|
||||||
|
return title
|
||||||
|
}
|
||||||
@@ -28,6 +28,10 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina
|
|||||||
|
|
||||||
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
|
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
|
||||||
|
|
||||||
|
export type ChapterKind = 'FrontMatter' | 'Body' | 'BackMatter'
|
||||||
|
|
||||||
|
export const chapterKinds: ChapterKind[] = ['FrontMatter', 'Body', 'BackMatter']
|
||||||
|
|
||||||
export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
|
export type NovelPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
|
||||||
|
|
||||||
export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
|
export const novelPhases: NovelPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete']
|
||||||
@@ -112,12 +116,13 @@ export interface TagSummary extends Tag {
|
|||||||
export interface TagReferences {
|
export interface TagReferences {
|
||||||
tag: Tag
|
tag: Tag
|
||||||
characters: { id: string; name: string; role: string }[]
|
characters: { id: string; name: string; role: string }[]
|
||||||
chapters: { id: string; number: number; title: string; summary: string | null }[]
|
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
|
||||||
beats: {
|
beats: {
|
||||||
id: string
|
id: string
|
||||||
chapterId: string
|
chapterId: string
|
||||||
chapterNumber: number
|
chapterNumber: number
|
||||||
chapterTitle: string
|
chapterTitle: string
|
||||||
|
chapterLabel: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
title: string
|
title: string
|
||||||
characterName: string | null
|
characterName: string | null
|
||||||
@@ -136,7 +141,7 @@ export interface LocationSummary extends Location {
|
|||||||
|
|
||||||
export interface LocationReferences {
|
export interface LocationReferences {
|
||||||
location: Location
|
location: Location
|
||||||
chapters: { id: string; number: number; title: string; summary: string | null }[]
|
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string; summary: string | null }[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BeatCharacter {
|
export interface BeatCharacter {
|
||||||
@@ -173,6 +178,7 @@ export interface ArcStage {
|
|||||||
chapterId: string | null
|
chapterId: string | null
|
||||||
chapterNumber: number | null
|
chapterNumber: number | null
|
||||||
chapterTitle: string | null
|
chapterTitle: string | null
|
||||||
|
chapterLabel: string | null
|
||||||
beats: CharacterBeat[]
|
beats: CharacterBeat[]
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -182,6 +188,7 @@ export interface CharacterBeat {
|
|||||||
chapterId: string
|
chapterId: string
|
||||||
chapterNumber: number
|
chapterNumber: number
|
||||||
chapterTitle: string
|
chapterTitle: string
|
||||||
|
chapterLabel: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
title: string
|
title: string
|
||||||
whatHappened: string | null
|
whatHappened: string | null
|
||||||
@@ -210,6 +217,7 @@ export interface Character {
|
|||||||
sameCharacterAsName: string | null
|
sameCharacterAsName: string | null
|
||||||
revealedInChapterId: string | null
|
revealedInChapterId: string | null
|
||||||
revealedInChapterNumber: number | null
|
revealedInChapterNumber: number | null
|
||||||
|
revealedInChapterLabel: string | null
|
||||||
identityNote: string | null
|
identityNote: string | null
|
||||||
otherIdentities: CharacterIdentity[]
|
otherIdentities: CharacterIdentity[]
|
||||||
relationships: Relationship[]
|
relationships: Relationship[]
|
||||||
@@ -227,6 +235,8 @@ export interface ChapterSummary {
|
|||||||
id: string
|
id: string
|
||||||
novelId: string
|
novelId: string
|
||||||
number: number
|
number: number
|
||||||
|
kind: ChapterKind
|
||||||
|
displayNumber: number | null
|
||||||
title: string
|
title: string
|
||||||
summary: string | null
|
summary: string | null
|
||||||
locations: Location[]
|
locations: Location[]
|
||||||
@@ -254,6 +264,7 @@ export interface OpenQuestion {
|
|||||||
chapterId: string | null
|
chapterId: string | null
|
||||||
chapterNumber: number | null
|
chapterNumber: number | null
|
||||||
chapterTitle: string | null
|
chapterTitle: string | null
|
||||||
|
chapterLabel: string | null
|
||||||
characterId: string | null
|
characterId: string | null
|
||||||
characterName: string | null
|
characterName: string | null
|
||||||
resolution: string | null
|
resolution: string | null
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ import {
|
|||||||
useSetArcStageBeats,
|
useSetArcStageBeats,
|
||||||
useUpdateArcStage,
|
useUpdateArcStage,
|
||||||
} from '../api/hooks'
|
} from '../api/hooks'
|
||||||
import type { ArcStage, Character } from '../api/types'
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
|
import type { ArcStage, Character, ChapterKind } from '../api/types'
|
||||||
import { AutoField, ErrorNote } from './ui'
|
import { AutoField, ErrorNote } from './ui'
|
||||||
|
|
||||||
export function CharacterArc({
|
export function CharacterArc({
|
||||||
@@ -127,7 +128,7 @@ function ArcStageRow({
|
|||||||
}: {
|
}: {
|
||||||
novelId: string
|
novelId: string
|
||||||
stage: ArcStage
|
stage: ArcStage
|
||||||
chapters: { id: string; number: number; title: string }[]
|
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
|
||||||
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
unassignedBeats: { id: string; chapterNumber: number; sortOrder: number; title: string }[]
|
||||||
canMoveUp: boolean
|
canMoveUp: boolean
|
||||||
canMoveDown: boolean
|
canMoveDown: boolean
|
||||||
@@ -226,7 +227,7 @@ function ArcStageRow({
|
|||||||
<option value="">Not pinned to a chapter</option>
|
<option value="">Not pinned to a chapter</option>
|
||||||
{chapters.map((chapter) => (
|
{chapters.map((chapter) => (
|
||||||
<option key={chapter.id} value={chapter.id}>
|
<option key={chapter.id} value={chapter.id}>
|
||||||
Ch. {chapter.number} — {chapter.title}
|
{chapterLabel(chapter.kind, chapter.displayNumber, chapter.title)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -182,7 +182,7 @@ function QuestionRow({
|
|||||||
|
|
||||||
{(showsChapter || showsCharacter) && (
|
{(showsChapter || showsCharacter) && (
|
||||||
<p className="mt-1 text-xs muted">
|
<p className="mt-1 text-xs muted">
|
||||||
{showsChapter && `Ch. ${question.chapterNumber} ${question.chapterTitle}`}
|
{showsChapter && question.chapterLabel}
|
||||||
{showsChapter && showsCharacter && ' · '}
|
{showsChapter && showsCharacter && ' · '}
|
||||||
{showsCharacter && question.characterName}
|
{showsCharacter && question.characterName}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -139,11 +139,13 @@ export function AutoField({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function Select<T extends string>({
|
export function Select<T extends string>({
|
||||||
|
id,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
options,
|
options,
|
||||||
onChange,
|
onChange,
|
||||||
}: {
|
}: {
|
||||||
|
id?: string
|
||||||
label?: string
|
label?: string
|
||||||
value: T
|
value: T
|
||||||
options: readonly T[]
|
options: readonly T[]
|
||||||
@@ -152,7 +154,7 @@ export function Select<T extends string>({
|
|||||||
return (
|
return (
|
||||||
<label className="block">
|
<label className="block">
|
||||||
{label && <span className="label">{label}</span>}
|
{label && <span className="label">{label}</span>}
|
||||||
<select className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
<select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
|
||||||
{options.map((option) => (
|
{options.map((option) => (
|
||||||
<option key={option} value={option}>
|
<option key={option} value={option}>
|
||||||
{option}
|
{option}
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import {
|
|||||||
useUpdateBeat,
|
useUpdateBeat,
|
||||||
useUpdateChapter,
|
useUpdateChapter,
|
||||||
} from '../api/hooks'
|
} from '../api/hooks'
|
||||||
import { draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
|
import { chapterKinds, draftStatuses, type Beat, type Chapter, type ChapterSummary } from '../api/types'
|
||||||
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -97,9 +98,9 @@ export default function ChapterPage() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
|
to={`/novels/${novelId}/chapters/${prevChapter.id}`}
|
||||||
className="muted hover:underline"
|
className="muted hover:underline"
|
||||||
title={`Chapter ${prevChapter.number}: ${prevChapter.title}`}
|
title={chapterLabel(prevChapter.kind, prevChapter.displayNumber, prevChapter.title)}
|
||||||
>
|
>
|
||||||
← Ch. {prevChapter.number}
|
← {prevChapter.kind === 'Body' ? `Ch. ${prevChapter.displayNumber}` : prevChapter.title}
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted" style={{ opacity: 0.4 }}>
|
<span className="muted" style={{ opacity: 0.4 }}>
|
||||||
@@ -110,9 +111,9 @@ export default function ChapterPage() {
|
|||||||
<Link
|
<Link
|
||||||
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
|
to={`/novels/${novelId}/chapters/${nextChapter.id}`}
|
||||||
className="muted hover:underline"
|
className="muted hover:underline"
|
||||||
title={`Chapter ${nextChapter.number}: ${nextChapter.title}`}
|
title={chapterLabel(nextChapter.kind, nextChapter.displayNumber, nextChapter.title)}
|
||||||
>
|
>
|
||||||
Ch. {nextChapter.number} →
|
{nextChapter.kind === 'Body' ? `Ch. ${nextChapter.displayNumber}` : nextChapter.title} →
|
||||||
</Link>
|
</Link>
|
||||||
) : (
|
) : (
|
||||||
<span className="muted" style={{ opacity: 0.4 }}>
|
<span className="muted" style={{ opacity: 0.4 }}>
|
||||||
@@ -145,10 +146,11 @@ export default function ChapterPage() {
|
|||||||
|
|
||||||
{tab === 'outline' && (
|
{tab === 'outline' && (
|
||||||
<section className="card mb-6 p-5">
|
<section className="card mb-6 p-5">
|
||||||
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_10rem]">
|
<div className="grid gap-4 sm:grid-cols-[4rem_1fr_9rem_10rem]">
|
||||||
<label className="block">
|
<label className="block">
|
||||||
<span className="label">No.</span>
|
<span className="label">No.</span>
|
||||||
<input
|
<input
|
||||||
|
id="chapter-number-input"
|
||||||
key={chapter.id}
|
key={chapter.id}
|
||||||
className="input"
|
className="input"
|
||||||
type="number"
|
type="number"
|
||||||
@@ -168,6 +170,14 @@ export default function ChapterPage() {
|
|||||||
readOnly={!canWrite}
|
readOnly={!canWrite}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
|
id="chapter-kind-select"
|
||||||
|
label="Kind"
|
||||||
|
value={chapter.kind}
|
||||||
|
options={chapterKinds}
|
||||||
|
onChange={(kind) => canWrite && patch({ kind })}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
id="chapter-status-select"
|
||||||
label="Status"
|
label="Status"
|
||||||
value={chapter.status}
|
value={chapter.status}
|
||||||
options={draftStatuses}
|
options={draftStatuses}
|
||||||
@@ -515,7 +525,7 @@ function BeatTable({
|
|||||||
<option value="">Move to chapter…</option>
|
<option value="">Move to chapter…</option>
|
||||||
{otherChapters.map((c) => (
|
{otherChapters.map((c) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={c.id} value={c.id}>
|
||||||
{c.number}. {c.title}
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
|
<option value={MOVE_TO_NEW_CHAPTER}>New chapter…</option>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function ChaptersPage() {
|
|||||||
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
|
className="card flex items-center gap-4 px-5 py-3 transition hover:shadow-md"
|
||||||
>
|
>
|
||||||
<span className="w-8 shrink-0 text-right text-sm font-semibold muted">
|
<span className="w-8 shrink-0 text-right text-sm font-semibold muted">
|
||||||
{chapter.number}
|
{chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium">{chapter.title}</div>
|
<div className="truncate font-medium">{chapter.title}</div>
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
useUnlinkCharacterIdentity,
|
useUnlinkCharacterIdentity,
|
||||||
useUpdateCharacter,
|
useUpdateCharacter,
|
||||||
} from '../api/hooks'
|
} from '../api/hooks'
|
||||||
import { characterImportances, characterRoles, type Character } from '../api/types'
|
import { characterImportances, characterRoles, type Character, type ChapterKind } from '../api/types'
|
||||||
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
|
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -467,7 +468,7 @@ function IdentitySection({
|
|||||||
}: {
|
}: {
|
||||||
character: Character
|
character: Character
|
||||||
allCharacters: Character[]
|
allCharacters: Character[]
|
||||||
chapters: { id: string; number: number; title: string }[]
|
chapters: { id: string; number: number; kind: ChapterKind; displayNumber: number | null; title: string }[]
|
||||||
canWrite: boolean
|
canWrite: boolean
|
||||||
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
|
onLink: (sameCharacterAsId: string, revealedInChapterId: string | null, note: string | null) => void
|
||||||
onUnlink: () => void
|
onUnlink: () => void
|
||||||
@@ -497,8 +498,8 @@ function IdentitySection({
|
|||||||
<h3 className="label">Identity</h3>
|
<h3 className="label">Identity</h3>
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
Really <span className="font-medium">{character.sameCharacterAsName}</span>
|
Really <span className="font-medium">{character.sameCharacterAsName}</span>
|
||||||
{character.revealedInChapterNumber != null && (
|
{character.revealedInChapterLabel != null && (
|
||||||
<span className="muted"> — revealed in Chapter {character.revealedInChapterNumber}</span>
|
<span className="muted"> — revealed in {character.revealedInChapterLabel}</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
|
{character.identityNote && <p className="muted text-sm">{character.identityNote}</p>}
|
||||||
@@ -560,7 +561,7 @@ function IdentitySection({
|
|||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{chapters.map((ch) => (
|
{chapters.map((ch) => (
|
||||||
<option key={ch.id} value={ch.id}>
|
<option key={ch.id} value={ch.id}>
|
||||||
Ch. {ch.number} — {ch.title}
|
{chapterLabel(ch.kind, ch.displayNumber, ch.title)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ function OutliningDashboard({ novelId }: { novelId: string }) {
|
|||||||
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
className="card flex items-center gap-3 px-4 py-2.5 transition hover:shadow-sm"
|
||||||
>
|
>
|
||||||
<span className="w-6 shrink-0 text-right text-sm font-semibold muted">
|
<span className="w-6 shrink-0 text-right text-sm font-semibold muted">
|
||||||
{chapter.number}
|
{chapter.kind === 'Body' ? chapter.displayNumber : chapter.kind === 'FrontMatter' ? 'FM' : 'BM'}
|
||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium">{chapter.title}</div>
|
<div className="truncate font-medium">{chapter.title}</div>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
|
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
|
||||||
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -151,7 +152,7 @@ function LocationReferencePanel({
|
|||||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{c.number}. {c.title}
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||||
</Link>
|
</Link>
|
||||||
{c.summary && <span className="muted"> — {c.summary}</span>}
|
{c.summary && <span className="muted"> — {c.summary}</span>}
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
import { Link, useParams, useSearchParams } from 'react-router-dom'
|
||||||
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
import { useDeleteTag, useNovel, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
|
||||||
|
import { chapterLabel } from '../api/chapterLabel'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
|
||||||
import { ConfirmModal } from '../components/ConfirmModal'
|
import { ConfirmModal } from '../components/ConfirmModal'
|
||||||
@@ -175,7 +176,7 @@ function TagReferencePanel({
|
|||||||
to={`/novels/${novelId}/chapters/${c.id}`}
|
to={`/novels/${novelId}/chapters/${c.id}`}
|
||||||
className="font-medium hover:underline"
|
className="font-medium hover:underline"
|
||||||
>
|
>
|
||||||
{c.number}. {c.title}
|
{chapterLabel(c.kind, c.displayNumber, c.title)}
|
||||||
</Link>
|
</Link>
|
||||||
{c.summary && <span className="muted"> — {c.summary}</span>}
|
{c.summary && <span className="muted"> — {c.summary}</span>}
|
||||||
</li>
|
</li>
|
||||||
@@ -198,7 +199,7 @@ function TagReferencePanel({
|
|||||||
</Link>
|
</Link>
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
{' '}
|
{' '}
|
||||||
— ch. {b.chapterNumber} “{b.chapterTitle}”, beat {b.sortOrder}
|
— {b.chapterLabel}, beat {b.sortOrder}
|
||||||
{b.characterName && `, ${b.characterName}`}
|
{b.characterName && `, ${b.characterName}`}
|
||||||
</span>
|
</span>
|
||||||
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
|
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
|
||||||
|
|||||||
@@ -78,4 +78,62 @@ public class ChapterServiceTests : ServiceTestFixture
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
|
public async Task Deleting_a_missing_chapter_returns_false_rather_than_throwing() =>
|
||||||
Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False);
|
Assert.That(await Chapters.DeleteAsync(Guid.NewGuid()), Is.False);
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task A_chapter_defaults_to_a_body_chapter()
|
||||||
|
{
|
||||||
|
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
|
||||||
|
|
||||||
|
Assert.That(chapter!.Kind, Is.EqualTo(ChapterKind.Body));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Front_matter_does_not_consume_a_chapter_number()
|
||||||
|
{
|
||||||
|
var foreword = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Foreword", Number: 1, Kind: ChapterKind.FrontMatter));
|
||||||
|
var first = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 2));
|
||||||
|
var second = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("The Harbour", Number: 3));
|
||||||
|
|
||||||
|
var displayNumbers = ChapterNumbering.DisplayNumbers(await Chapters.ListAsync(_novelId));
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(displayNumbers.ContainsKey(foreword!.Id), Is.False);
|
||||||
|
Assert.That(displayNumbers[first!.Id], Is.EqualTo(1));
|
||||||
|
Assert.That(displayNumbers[second!.Id], Is.EqualTo(2));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Back_matter_is_listed_last_but_carries_no_chapter_number()
|
||||||
|
{
|
||||||
|
var body = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 1));
|
||||||
|
var afterword = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Afterword", Number: 2, Kind: ChapterKind.BackMatter));
|
||||||
|
|
||||||
|
var afterwordDisplayNumber = await Chapters.DisplayNumberAsync(afterword!);
|
||||||
|
var bodyDisplayNumber = await Chapters.DisplayNumberAsync(body!);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(afterwordDisplayNumber, Is.Null);
|
||||||
|
Assert.That(bodyDisplayNumber, Is.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Changing_a_chapter_to_front_matter_drops_it_from_the_chapter_count()
|
||||||
|
{
|
||||||
|
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Prologue", Number: 1));
|
||||||
|
var other = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Number: 2));
|
||||||
|
|
||||||
|
var updated = await Chapters.UpdateAsync(chapter!.Id, new UpdateChapterRequest(Kind: ChapterKind.FrontMatter));
|
||||||
|
var updatedDisplayNumber = await Chapters.DisplayNumberAsync(updated!);
|
||||||
|
var otherDisplayNumber = await Chapters.DisplayNumberAsync(other!);
|
||||||
|
|
||||||
|
Assert.Multiple(() =>
|
||||||
|
{
|
||||||
|
Assert.That(updatedDisplayNumber, Is.Null);
|
||||||
|
Assert.That(otherDisplayNumber, Is.EqualTo(1));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user