Add ChapterKind for front/back matter chapters
Chapters can now be marked FrontMatter/Body/BackMatter. Number stays the manuscript sort key for every chapter; the author-facing display number is now computed per-request as the chapter's ordinal among Body chapters only, so a foreword or afterword no longer shifts the numbering of the rest of the book. Surfaced through the API, agent toolset, and import toolset.
This commit is contained in:
@@ -385,7 +385,12 @@ public class NovelAgentToolset(
|
|||||||
"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 +401,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 +428,39 @@ public class NovelAgentToolset(
|
|||||||
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
||||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (novelId, input, ct) => await OrNotFound(chapters.CreateAsync(novelId, new CreateChapterRequest(
|
async (novelId, input, ct) =>
|
||||||
JsonInput.RequiredString(input, "title"),
|
{
|
||||||
JsonInput.Int(input, "number"),
|
var chapter = await chapters.CreateAsync(novelId, new CreateChapterRequest(
|
||||||
JsonInput.String(input, "summary"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
JsonInput.Strings(input, "locations"),
|
JsonInput.Int(input, "number"),
|
||||||
JsonInput.String(input, "notes"),
|
JsonInput.Enum<ChapterKind>(input, "kind") ?? ChapterKind.Body,
|
||||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
JsonInput.String(input, "summary"),
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Strings(input, "locations"),
|
||||||
JsonInput.String(input, "prose"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Novel", novelId));
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||||
|
JsonInput.Int(input, "target_word_count"),
|
||||||
|
JsonInput.String(input, "prose"),
|
||||||
|
JsonInput.Strings(input, "tags")), ct);
|
||||||
|
|
||||||
|
if (chapter is null)
|
||||||
|
{
|
||||||
|
return new ToolNotFound("Novel", novelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayNumber = await chapters.DisplayNumberAsync(chapter, ct);
|
||||||
|
return chapter.ToResponse(displayNumber);
|
||||||
|
});
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_chapter",
|
"update_chapter",
|
||||||
"Revise a chapter's title, number, summary, locations, notes, status or drafted "
|
"Revise a chapter's title, number, kind, summary, locations, notes, status or drafted "
|
||||||
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
||||||
+ "word count is recomputed automatically.",
|
+ "word count is recomputed automatically.",
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||||
.Str("title", "New title.")
|
.Str("title", "New title.")
|
||||||
.Int("number", "Position in the manuscript.")
|
.Int("number", "Manuscript position, 1-based, counting front and back matter.")
|
||||||
|
.Enum("kind", "Front matter, a numbered body chapter, or back matter.", System.Enum.GetNames<ChapterKind>())
|
||||||
.Str("summary", "What the chapter covers.")
|
.Str("summary", "What the chapter covers.")
|
||||||
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
|
.StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
|
||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
@@ -444,18 +472,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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -73,6 +73,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 +117,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 +168,15 @@ public class ChapterService(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<int?> DisplayNumberAsync(Chapter chapter, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (chapter.Kind != ChapterKind.Body)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return await db.Chapters.CountAsync(
|
||||||
|
c => c.NovelId == chapter.NovelId && c.Kind == ChapterKind.Body && c.Number <= chapter.Number, ct);
|
||||||
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
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);
|
||||||
|
|||||||
@@ -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));
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user