Replace the outline tree with chapter beat tables and tags

The self-nesting outline tree was more structure than chapter outlining needs.
A chapter outline is now a paragraph plus a flat, ordered table of beats, and
tags do the cross-referencing that nesting was doing badly.

A beat is one row: a three-to-five word title, an optional character, what
happened, and what's next. Ordering is a SortOrder column within the chapter —
no parent pointers, no cycle guards, no recursive tree building. Reordering is
one call taking beat ids in the order wanted; ids left out keep their relative
position at the end rather than jumping to the front.

Beats plan, scenes carry prose. The two layers stay separate and a beat's
SceneId is the optional link between them, nullable in both directions —
deleting a scene ungroups its beats rather than deleting the plan, since that
is a decision about prose and not about the outline.

Tags are project-scoped, unique by name case-insensitively, and attach to
characters, chapters and beats through three join tables so cascade deletes are
the database's job rather than ours. Applying an unknown tag by name creates it,
which keeps tagging a single action; GET /api/tags/{id}/references returns
everything carrying a tag across all three kinds at once.

Removed: OutlineNode, OutlineService, its endpoints, agent and MCP tools, and
the Outline tab. Added: Beat and Tag with their services, endpoints, 5 agent
tools and 10 MCP tools, a beat table on the chapter page, a tag editor used in
three places, and a Tags tab for cross-referencing.

Migration drops OutlineNodes — the scaffolder's data-loss warning is the
intended removal, not an accident.

44 tests, up from 31.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
James Wampler
2026-08-06 12:11:20 -07:00
co-authored by Claude Opus 5
parent 0d7b7a6f30
commit 7678cc7275
51 changed files with 3189 additions and 948 deletions
+38 -13
View File
@@ -48,7 +48,7 @@ and everything else keeps working.
### Tests ### Tests
```bash ```bash
dotnet test # 31 tests dotnet test # 44 tests
cd src/NovelSoftware.Web && npm run build # typecheck + bundle cd src/NovelSoftware.Web && npm run build # typecheck + bundle
``` ```
@@ -79,23 +79,43 @@ out of `appsettings.json` and use user-secrets or the environment.
``` ```
Project ──┬── Character ── CharacterRelationship Project ──┬── Character ── CharacterRelationship
├── OutlineNode (self-nesting: Part > Act > Sequence > Beat) ├── Chapter ──┬── Beat (the outline: flat, ordered)
├── Chapter ── Scene (goal / conflict / outcome, prose, word count) │ └── Scene (the prose)
├── Tag (applied to characters, chapters and beats)
└── AgentConversation ── AgentMessage └── AgentConversation ── AgentMessage
``` ```
The outline tree is deliberately loose — nest acts under parts, beats under sequences, or **A chapter outline is a paragraph plus a table.** The paragraph is the chapter's
keep a flat list of beats. An outline node can link to the chapter that realises it. `Summary`; the table is its beats. Each beat is one row:
Scenes carry the goal/conflict/outcome trio because that is the unit the agent works from | Column | What goes in it |
when turning an outline into prose. Word counts are recomputed on every save. |---|---|
| Beat | A three-to-five word handle — "she burns the atlas", not a sentence |
| Character | Whose beat it is. Optional; not every beat belongs to one person |
| What happened | The event itself |
| What's next | What it sets in motion — the hook into the following beat |
| Scene | Optional grouping: which scene will carry this beat's prose |
Beats are flat and ordered by `SortOrder` within their chapter. There is no nesting and
no tree — reordering is one call that takes the beat ids in the order wanted.
**Beats plan; scenes carry prose.** The two layers are deliberately separate: an outline
is for working out what happens, and a scene is where you write it. A beat's `SceneId` is
the optional link between them, and it is nullable in both directions — deleting a scene
ungroups its beats rather than deleting the plan.
**Tags cross-reference the book.** A tag is scoped to one project, unique by name
(case-insensitively), and can be attached to any character, chapter or beat. Applying an
unknown tag by name creates it, so tagging is one action rather than two. `GET
/api/tags/{id}/references` returns everything carrying a tag, which is how you trace a
motif or a thread across all three kinds at once.
## The embedded agent ## The embedded agent
`NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools `NovelAgentService` runs the tool-use loop: it calls the Messages API, executes any tools
Claude asks for, feeds every result back in a single user turn, and repeats until Claude Claude asks for, feeds every result back in a single user turn, and repeats until Claude
stops asking. It has 15 tools covering the brief, characters, the outline tree, chapters stops asking. It has 18 tools covering the brief, characters, chapter outlines (beats), scenes and
and scenes — all of them going through the same application services the REST API uses. tags — all of them going through the same application services the REST API uses.
A few deliberate choices worth knowing about: A few deliberate choices worth knowing about:
@@ -113,7 +133,7 @@ A few deliberate choices worth knowing about:
## The MCP server ## The MCP server
A stdio MCP server exposing 21 tools over the same REST API. It holds no domain logic of A stdio MCP server exposing 26 tools over the same REST API. It holds no domain logic of
its own — it is a second front end, not a second implementation. its own — it is a second front end, not a second implementation.
Build it, then point your MCP client at the produced binary: Build it, then point your MCP client at the produced binary:
@@ -146,12 +166,15 @@ rather than failing opaquely.
|---|---| |---|---|
| Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` | | Projects | `GET\|POST /api/projects`, `GET\|PATCH\|DELETE /api/projects/{id}` |
| Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` | | Characters | `GET\|POST /api/projects/{id}/characters`, `GET\|PATCH\|DELETE /api/characters/{id}`, `POST /api/characters/{id}/relationships` |
| Outline | `GET\|POST /api/projects/{id}/outline`, `GET\|PATCH\|DELETE /api/outline/{id}`, `POST /api/outline/{id}/move` |
| Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` | | Chapters | `GET\|POST /api/projects/{id}/chapters`, `GET\|PATCH\|DELETE /api/chapters/{id}` |
| Beats | `GET\|POST /api/chapters/{id}/beats`, `POST /api/chapters/{id}/beats/reorder`, `GET\|PATCH\|DELETE /api/beats/{id}` |
| Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` | | Scenes | `GET\|POST /api/chapters/{id}/scenes`, `GET\|PATCH\|DELETE /api/scenes/{id}` |
| Tags | `GET\|POST /api/projects/{id}/tags`, `GET /api/tags/{id}/references`, `PATCH\|DELETE /api/tags/{id}` |
| Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` | | Agent | `GET /api/projects/{id}/agent/conversations`, `POST /api/projects/{id}/agent/messages`, `GET\|DELETE /api/conversations/{id}` |
`PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. `PATCH` bodies are partial: an omitted field is left alone, an empty string clears it. A
`tags` array replaces that item's tags outright and creates any names the project has not
seen; omitting it leaves tags untouched.
Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the Enums travel as names (`"Protagonist"`, `"Drafted"`), never ordinals. In development the
OpenAPI document is at `/openapi/v1.json`. OpenAPI document is at `/openapi/v1.json`.
@@ -168,7 +191,9 @@ OpenAPI document is at `/openapi/v1.json`.
The vertical slice is complete but thin in places. The obvious next steps: The vertical slice is complete but thin in places. The obvious next steps:
- Stream agent responses over SSE instead of returning the finished turn. - Stream agent responses over SSE instead of returning the finished turn.
- Drag-and-drop reordering in the outline (the `move` endpoint is already there). - Drag-and-drop beat reordering (the reorder endpoint is already there; the UI uses
up/down buttons).
- Filter chapters and characters by tag from the list views, not just the Tags tab.
- A manuscript export (Markdown, DOCX) built from chapters and scenes in order. - A manuscript export (Markdown, DOCX) built from chapters and scenes in order.
- Revision history for scene prose. - Revision history for scene prose.
- Authentication, if this is ever going to run anywhere but localhost. - Authentication, if this is ever going to run anywhere but localhost.
@@ -0,0 +1,49 @@
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Api.Endpoints;
public static class BeatEndpoints
{
public static IEndpointRouteBuilder MapBeatEndpoints(this IEndpointRouteBuilder app)
{
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/beats").WithTags("Beats");
chapterScoped.MapGet("/", async (Guid chapterId, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(chapterId, ct)))
.WithSummary("Read a chapter's outline: its beats, in order.");
chapterScoped.MapPost("/", async (
Guid chapterId, CreateBeatRequest request, BeatService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(chapterId, request, ct);
return Results.Created($"/api/beats/{created.Id}", created);
})
.WithSummary("Add a beat to a chapter's outline.");
chapterScoped.MapPost("/reorder", async (
Guid chapterId, ReorderBeatsRequest request, BeatService service, CancellationToken ct) =>
Results.Ok(await service.ReorderAsync(chapterId, request, ct)))
.WithSummary("Renumber a chapter's beats to match the order given.");
var beats = app.MapGroup("/api/beats").WithTags("Beats");
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
.WithSummary("Read one beat.");
beats.MapPatch("/{id:guid}", async (
Guid id, UpdateBeatRequest request, BeatService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct)))
.WithSummary("Update a beat. Sending a tag list replaces the beat's tags.");
beats.MapDelete("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a beat.");
return app;
}
}
@@ -1,49 +0,0 @@
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Api.Endpoints;
public static class OutlineEndpoints
{
public static IEndpointRouteBuilder MapOutlineEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/outline").WithTags("Outline");
projectScoped.MapGet("/", async (Guid projectId, OutlineService service, CancellationToken ct) =>
Results.Ok(await service.GetTreeAsync(projectId, ct)))
.WithSummary("Read the project's outline as a nested tree.");
projectScoped.MapPost("/", async (
Guid projectId, CreateOutlineNodeRequest request, OutlineService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
return Results.Created($"/api/outline/{created.Id}", created);
})
.WithSummary("Add an outline node.");
var nodes = app.MapGroup("/api/outline").WithTags("Outline");
nodes.MapGet("/{id:guid}", async (Guid id, OutlineService service, CancellationToken ct) =>
Results.Ok(await service.GetAsync(id, ct)))
.WithSummary("Read one outline node and its subtree.");
nodes.MapPatch("/{id:guid}", async (
Guid id, UpdateOutlineNodeRequest request, OutlineService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct)))
.WithSummary("Update an outline node.");
nodes.MapPost("/{id:guid}/move", async (
Guid id, MoveOutlineNodeRequest request, OutlineService service, CancellationToken ct) =>
Results.Ok(await service.MoveAsync(id, request, ct)))
.WithSummary("Reparent or reorder an outline node.");
nodes.MapDelete("/{id:guid}", async (Guid id, OutlineService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete an outline node and everything beneath it.");
return app;
}
}
@@ -0,0 +1,44 @@
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Api.Endpoints;
public static class TagEndpoints
{
public static IEndpointRouteBuilder MapTagEndpoints(this IEndpointRouteBuilder app)
{
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/tags").WithTags("Tags");
projectScoped.MapGet("/", async (Guid projectId, TagService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(projectId, ct)))
.WithSummary("List a project's tags with usage counts.");
projectScoped.MapPost("/", async (
Guid projectId, CreateTagRequest request, TagService service, CancellationToken ct) =>
{
var created = await service.CreateAsync(projectId, request, ct);
return Results.Created($"/api/tags/{created.Id}", created);
})
.WithSummary("Create a tag. Tags are also created on demand when applied by name.");
var tags = app.MapGroup("/api/tags").WithTags("Tags");
tags.MapGet("/{id:guid}/references", async (Guid id, TagService service, CancellationToken ct) =>
Results.Ok(await service.GetReferencesAsync(id, ct)))
.WithSummary("Cross-reference: every character, chapter and beat carrying this tag.");
tags.MapPatch("/{id:guid}", async (
Guid id, UpdateTagRequest request, TagService service, CancellationToken ct) =>
Results.Ok(await service.UpdateAsync(id, request, ct)))
.WithSummary("Rename or recolour a tag.");
tags.MapDelete("/{id:guid}", async (Guid id, TagService service, CancellationToken ct) =>
{
await service.DeleteAsync(id, ct);
return Results.NoContent();
})
.WithSummary("Delete a tag. Whatever carried it is left alone.");
return app;
}
}
+2 -1
View File
@@ -67,9 +67,10 @@ app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Hea
app.MapProjectEndpoints() app.MapProjectEndpoints()
.MapCharacterEndpoints() .MapCharacterEndpoints()
.MapOutlineEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints()
.MapSceneEndpoints() .MapSceneEndpoints()
.MapTagEndpoints()
.MapAgentEndpoints(); .MapAgentEndpoints();
app.Run(); app.Run();
@@ -21,6 +21,23 @@ public sealed class JsonSchemaBuilder
public JsonSchemaBuilder Bool(string name, string description, bool required = false) => public JsonSchemaBuilder Bool(string name, string description, bool required = false) =>
Add(name, "boolean", description, required); Add(name, "boolean", description, required);
public JsonSchemaBuilder StringArray(string name, string description, bool required = false)
{
_properties[name] = new JsonObject
{
["type"] = "array",
["description"] = description,
["items"] = new JsonObject { ["type"] = "string" }
};
if (required)
{
_required.Add(name);
}
return this;
}
public JsonSchemaBuilder Enum(string name, string description, IEnumerable<string> values, bool required = false) public JsonSchemaBuilder Enum(string name, string description, IEnumerable<string> values, bool required = false)
{ {
var node = new JsonObject var node = new JsonObject
@@ -97,6 +114,25 @@ public static class JsonInput
}; };
} }
/// <summary>
/// Reads an array of strings. Returns null when the property is absent, which the
/// services read as "leave the existing list alone" — distinct from an empty array,
/// which clears it.
/// </summary>
public static IReadOnlyList<string>? Strings(JsonElement input, string name)
{
if (input.ValueKind != JsonValueKind.Object
|| !input.TryGetProperty(name, out var value)
|| value.ValueKind != JsonValueKind.Array)
{
return null;
}
return [.. value.EnumerateArray()
.Where(item => item.ValueKind == JsonValueKind.String)
.Select(item => item.GetString()!)];
}
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum => public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null; System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
} }
@@ -20,9 +20,10 @@ public sealed record AgentTool(
public class NovelAgentToolset( public class NovelAgentToolset(
ProjectService projects, ProjectService projects,
CharacterService characters, CharacterService characters,
OutlineService outlines,
ChapterService chapters, ChapterService chapters,
SceneService scenes) BeatService beats,
SceneService scenes,
TagService tags)
{ {
private static readonly JsonSerializerOptions SerializerOptions = new() private static readonly JsonSerializerOptions SerializerOptions = new()
{ {
@@ -127,7 +128,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "external_conflict"), JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"), JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes")), ct)); JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool( yield return new AgentTool(
"update_character", "update_character",
@@ -152,66 +154,99 @@ public class NovelAgentToolset(
JsonInput.String(input, "external_conflict"), JsonInput.String(input, "external_conflict"),
JsonInput.String(input, "arc_summary"), JsonInput.String(input, "arc_summary"),
JsonInput.String(input, "voice"), JsonInput.String(input, "voice"),
JsonInput.String(input, "notes")), ct)); JsonInput.String(input, "notes"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool( yield return new AgentTool(
"get_outline", "get_chapter_outline",
"Read the project's outline as a nested tree of parts, acts, sequences and beats.", "Read a chapter's outline: its summary paragraph and its beat table, in order. "
new JsonSchemaBuilder().Build(), + "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.",
async (projectId, _, ct) => await outlines.GetTreeAsync(projectId, ct));
yield return new AgentTool(
"create_outline_node",
"Add a node to the outline. Pass parent_id to nest it; omit it for a top-level node.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("title", "Short label for the node.", required: true) .Str("chapter_id", "Id of the chapter whose outline to read.", required: true)
.Enum("node_type", "Structural level of the node.", System.Enum.GetNames<OutlineNodeType>())
.Str("parent_id", "Id of the parent node, if nesting.")
.Str("summary", "What happens here, in a sentence or two.")
.Int("sort_order", "Position among siblings. Appended to the end when omitted.")
.Str("chapter_id", "Id of the chapter that realises this node, if one exists.")
.Build(), .Build(),
async (projectId, input, ct) => await outlines.CreateAsync(projectId, new CreateOutlineNodeRequest( async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct));
JsonInput.RequiredString(input, "title"),
JsonInput.Enum<OutlineNodeType>(input, "node_type") ?? OutlineNodeType.Beat,
JsonInput.Guid(input, "parent_id"),
JsonInput.String(input, "summary"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "chapter_id")), ct));
yield return new AgentTool( yield return new AgentTool(
"update_outline_node", "create_beat",
"Revise an outline node's title, type, summary, position or linked chapter.", "Add a beat to a chapter's outline. Keep the title to three to five words — it is a "
new JsonSchemaBuilder() + "handle, not a sentence; the detail belongs in what_happened and whats_next.",
.Str("node_id", "Id of the node to update.", required: true) BeatSchema()
.Str("title", "New title.") .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
.Enum("node_type", "Structural level of the node.", System.Enum.GetNames<OutlineNodeType>()) .Str("title", "Three to five words naming the beat.", required: true)
.Str("summary", "What happens here.")
.Int("sort_order", "Position among siblings.")
.Str("chapter_id", "Id of the chapter that realises this node.")
.Build(), .Build(),
async (_, input, ct) => await outlines.UpdateAsync( async (_, input, ct) => await beats.CreateAsync(
JsonInput.RequiredGuid(input, "node_id"), JsonInput.RequiredGuid(input, "chapter_id"),
new UpdateOutlineNodeRequest( new CreateBeatRequest(
JsonInput.String(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Enum<OutlineNodeType>(input, "node_type"),
JsonInput.String(input, "summary"),
JsonInput.Int(input, "sort_order"), JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "chapter_id")), ct)); JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool( yield return new AgentTool(
"delete_outline_node", "update_beat",
"Remove an outline node and everything nested beneath it. This cannot be undone, " "Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
+ "so confirm with the writer before calling it.", + "the beat's tags outright, so include the ones you want to keep.",
BeatSchema()
.Str("beat_id", "Id of the beat to update.", required: true)
.Str("title", "Three to five words naming the beat.")
.Build(),
async (_, input, ct) => await beats.UpdateAsync(
JsonInput.RequiredGuid(input, "beat_id"),
new UpdateBeatRequest(
JsonInput.String(input, "title"),
JsonInput.Int(input, "sort_order"),
JsonInput.Guid(input, "character_id"),
JsonInput.String(input, "what_happened"),
JsonInput.String(input, "whats_next"),
JsonInput.Guid(input, "scene_id"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool(
"delete_beat",
"Remove a beat from a chapter's outline. Confirm with the writer before calling it.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Str("node_id", "Id of the node to delete.", required: true) .Str("beat_id", "Id of the beat to delete.", required: true)
.Build(), .Build(),
async (_, input, ct) => async (_, input, ct) =>
{ {
await outlines.DeleteAsync(JsonInput.RequiredGuid(input, "node_id"), ct); await beats.DeleteAsync(JsonInput.RequiredGuid(input, "beat_id"), ct);
return new { deleted = true }; return new { deleted = true };
}); });
yield return new AgentTool(
"reorder_beats",
"Renumber a chapter's beats to match the order given. List every beat id in the "
+ "order you want; any you leave out keep their relative position at the end.",
new JsonSchemaBuilder()
.Str("chapter_id", "Id of the chapter whose beats to reorder.", required: true)
.StringArray("beat_ids", "Beat ids in their new order.", required: true)
.Build(),
async (_, input, ct) => await beats.ReorderAsync(
JsonInput.RequiredGuid(input, "chapter_id"),
new ReorderBeatsRequest(
[.. (JsonInput.Strings(input, "beat_ids") ?? [])
.Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty)
.Where(g => g != Guid.Empty)]), ct));
yield return new AgentTool(
"list_tags",
"List the project's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(),
async (projectId, _, ct) => await tags.ListAsync(projectId, ct));
yield return new AgentTool(
"get_tag_references",
"Cross-reference a tag: every character, chapter and beat carrying it. Use this to "
+ "trace a motif or a thread through the book.",
new JsonSchemaBuilder()
.Str("tag_id", "Id of the tag to trace.", required: true)
.Build(),
async (_, input, ct) => await tags.GetReferencesAsync(JsonInput.RequiredGuid(input, "tag_id"), ct));
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_chapters",
"List the project's chapters in manuscript order with scene and word counts.", "List the project's chapters in manuscript order with scene and word counts.",
@@ -238,6 +273,7 @@ public class NovelAgentToolset(
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest( async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
@@ -247,7 +283,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "setting"), JsonInput.String(input, "setting"),
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")), ct)); JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
@@ -262,6 +299,7 @@ public class NovelAgentToolset(
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
.Build(), .Build(),
async (_, input, ct) => await chapters.UpdateAsync( async (_, input, ct) => await chapters.UpdateAsync(
JsonInput.RequiredGuid(input, "chapter_id"), JsonInput.RequiredGuid(input, "chapter_id"),
@@ -273,7 +311,8 @@ public class NovelAgentToolset(
JsonInput.String(input, "setting"), JsonInput.String(input, "setting"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count")), ct)); JsonInput.Int(input, "target_word_count"),
JsonInput.Strings(input, "tags")), ct));
yield return new AgentTool( yield return new AgentTool(
"create_scene", "create_scene",
@@ -343,9 +382,19 @@ public class NovelAgentToolset(
.Str("external_conflict", "What in the world opposes them.") .Str("external_conflict", "What in the world opposes them.")
.Str("arc_summary", "How they change over the course of the book.") .Str("arc_summary", "How they change over the course of the book.")
.Str("voice", "Speech patterns and register that make their dialogue theirs.") .Str("voice", "Speech patterns and register that make their dialogue theirs.")
.Str("notes", "Anything else worth recording."); .Str("notes", "Anything else worth recording.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
} }
private static JsonSchemaBuilder BeatSchema() =>
new JsonSchemaBuilder()
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
.Str("character_id", "Id of the character whose beat this is.")
.Str("what_happened", "The event itself.")
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
private static JsonSchemaBuilder SceneSchema() => private static JsonSchemaBuilder SceneSchema() =>
new JsonSchemaBuilder() new JsonSchemaBuilder()
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.") .Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
@@ -0,0 +1,59 @@
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Dtos;
public record BeatDto(
Guid Id,
Guid ChapterId,
int SortOrder,
string Title,
Guid? CharacterId,
string? CharacterName,
string? WhatHappened,
string? WhatsNext,
Guid? SceneId,
string? SceneTitle,
IReadOnlyList<TagDto> Tags,
DateTimeOffset UpdatedAt);
public record CreateBeatRequest(
string Title,
int? SortOrder = null,
Guid? CharacterId = null,
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null);
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the beat's tags outright.
/// </summary>
public record UpdateBeatRequest(
string? Title = null,
int? SortOrder = null,
Guid? CharacterId = null,
string? WhatHappened = null,
string? WhatsNext = null,
Guid? SceneId = null,
IReadOnlyList<string>? Tags = null);
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
public static class BeatMapping
{
public static BeatDto ToDto(this Beat b) => new(
b.Id,
b.ChapterId,
b.SortOrder,
b.Title,
b.CharacterId,
b.Character?.Name,
b.WhatHappened,
b.WhatsNext,
b.SceneId,
b.Scene?.Title,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
b.UpdatedAt);
}
@@ -14,9 +14,15 @@ public record ChapterSummaryDto(
string? Setting, string? Setting,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
int BeatCount,
int SceneCount, int SceneCount,
int WordCount); int WordCount,
IReadOnlyList<TagDto> Tags);
/// <summary>
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
/// and the prose layer (scenes).
/// </summary>
public record ChapterDto( public record ChapterDto(
Guid Id, Guid Id,
Guid ProjectId, Guid ProjectId,
@@ -29,7 +35,9 @@ public record ChapterDto(
string? Notes, string? Notes,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
IReadOnlyList<BeatDto> Beats,
IReadOnlyList<SceneDto> Scenes, IReadOnlyList<SceneDto> Scenes,
IReadOnlyList<TagDto> Tags,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record CreateChapterRequest( public record CreateChapterRequest(
@@ -40,8 +48,13 @@ public record CreateChapterRequest(
string? Setting = null, string? Setting = null,
string? Notes = null, string? Notes = null,
DraftStatus Status = DraftStatus.Planned, DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null); int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null);
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the chapter's tags outright.
/// </summary>
public record UpdateChapterRequest( public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
@@ -50,7 +63,8 @@ public record UpdateChapterRequest(
string? Setting = null, string? Setting = null,
string? Notes = null, string? Notes = null,
DraftStatus? Status = null, DraftStatus? Status = null,
int? TargetWordCount = null); int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null);
public static class ChapterMapping public static class ChapterMapping
{ {
@@ -58,11 +72,14 @@ public static class ChapterMapping
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToDto())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())], [.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
c.UpdatedAt); c.UpdatedAt);
public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new( public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
c.Scenes.Count, c.Scenes.Sum(s => s.WordCount)); c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())]);
} }
@@ -22,6 +22,7 @@ public record CharacterDto(
string? Voice, string? Voice,
string? Notes, string? Notes,
IReadOnlyList<RelationshipDto> Relationships, IReadOnlyList<RelationshipDto> Relationships,
IReadOnlyList<TagDto> Tags,
DateTimeOffset UpdatedAt); DateTimeOffset UpdatedAt);
public record RelationshipDto( public record RelationshipDto(
@@ -46,8 +47,13 @@ public record CreateCharacterRequest(
string? ExternalConflict = null, string? ExternalConflict = null,
string? ArcSummary = null, string? ArcSummary = null,
string? Voice = null, string? Voice = null,
string? Notes = null); string? Notes = null,
IReadOnlyList<string>? Tags = null);
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the character's tags outright.
/// </summary>
public record UpdateCharacterRequest( public record UpdateCharacterRequest(
string? Name = null, string? Name = null,
CharacterRole? Role = null, CharacterRole? Role = null,
@@ -63,7 +69,8 @@ public record UpdateCharacterRequest(
string? ExternalConflict = null, string? ExternalConflict = null,
string? ArcSummary = null, string? ArcSummary = null,
string? Voice = null, string? Voice = null,
string? Notes = null); string? Notes = null,
IReadOnlyList<string>? Tags = null);
public record CreateRelationshipRequest( public record CreateRelationshipRequest(
Guid RelatedCharacterId, Guid RelatedCharacterId,
@@ -82,5 +89,6 @@ public static class CharacterMapping
r.RelatedCharacter?.Name ?? "(unknown)", r.RelatedCharacter?.Name ?? "(unknown)",
r.RelationshipType, r.RelationshipType,
r.Description))], r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
c.UpdatedAt); c.UpdatedAt);
} }
@@ -1,33 +0,0 @@
using NovelSoftware.Domain;
namespace NovelSoftware.Application.Dtos;
/// <summary>An outline node with its subtree inlined — the shape the outline view renders.</summary>
public record OutlineNodeDto(
Guid Id,
Guid ProjectId,
Guid? ParentId,
OutlineNodeType NodeType,
string Title,
string? Summary,
int SortOrder,
Guid? ChapterId,
IReadOnlyList<OutlineNodeDto> Children);
public record CreateOutlineNodeRequest(
string Title,
OutlineNodeType NodeType = OutlineNodeType.Beat,
Guid? ParentId = null,
string? Summary = null,
int? SortOrder = null,
Guid? ChapterId = null);
public record UpdateOutlineNodeRequest(
string? Title = null,
OutlineNodeType? NodeType = null,
string? Summary = null,
int? SortOrder = null,
Guid? ChapterId = null);
/// <summary>Moves a node to a new parent and/or position. A null <see cref="ParentId"/> means root level.</summary>
public record MoveOutlineNodeRequest(Guid? ParentId, int SortOrder);
@@ -0,0 +1,56 @@
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Dtos;
public record TagDto(Guid Id, string Name, string? Color);
public record TagSummaryDto(
Guid Id,
string Name,
string? Color,
int CharacterCount,
int ChapterCount,
int BeatCount)
{
public int TotalCount => CharacterCount + ChapterCount + BeatCount;
}
public record CreateTagRequest(string Name, string? Color = null);
public record UpdateTagRequest(string? Name = null, string? Color = null);
/// <summary>
/// Everything carrying one tag, gathered in a single response. This is the whole point of
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
/// makes them worth maintaining.
/// </summary>
public record TagReferencesDto(
TagDto Tag,
IReadOnlyList<TaggedCharacterDto> Characters,
IReadOnlyList<TaggedChapterDto> Chapters,
IReadOnlyList<TaggedBeatDto> Beats);
public record TaggedCharacterDto(Guid Id, string Name, string Role);
public record TaggedChapterDto(Guid Id, int Number, string Title, string? Summary);
public record TaggedBeatDto(
Guid Id,
Guid ChapterId,
int ChapterNumber,
string ChapterTitle,
int SortOrder,
string Title,
string? CharacterName,
string? WhatHappened);
public static class TagMapping
{
public static TagDto ToDto(this Tag t) => new(t.Id, t.Name, t.Color);
/// <summary>
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
/// "betrayal" resolve to one tag rather than quietly becoming two.
/// </summary>
public static string Normalise(string name) => name.Trim();
}
@@ -12,7 +12,8 @@ public interface INovelDbContext
DbSet<Project> Projects { get; } DbSet<Project> Projects { get; }
DbSet<Character> Characters { get; } DbSet<Character> Characters { get; }
DbSet<CharacterRelationship> CharacterRelationships { get; } DbSet<CharacterRelationship> CharacterRelationships { get; }
DbSet<OutlineNode> OutlineNodes { get; } DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; }
DbSet<Chapter> Chapters { get; } DbSet<Chapter> Chapters { get; }
DbSet<Scene> Scenes { get; } DbSet<Scene> Scenes { get; }
DbSet<AgentConversation> Conversations { get; } DbSet<AgentConversation> Conversations { get; }
@@ -0,0 +1,163 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
/// <summary>
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
/// here is scoped to one chapter.
/// </summary>
public class BeatService(INovelDbContext db, TagService tags)
{
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
{
var beats = await Query()
.Where(b => b.ChapterId == chapterId)
.OrderBy(b => b.SortOrder)
.ToListAsync(ct);
return [.. beats.Select(b => b.ToDto())];
}
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
(await FindAsync(id, ct)).ToDto();
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
{
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
?? throw new NotFoundException(nameof(Chapter), chapterId);
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
var beat = new Beat
{
ChapterId = chapterId,
Title = request.Title,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
CharacterId = request.CharacterId,
WhatHappened = request.WhatHappened,
WhatsNext = request.WhatsNext,
SceneId = request.SceneId
};
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
}
db.Beats.Add(beat);
await db.SaveChangesAsync(ct);
return (await FindAsync(beat.Id, ct)).ToDto();
}
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
{
var beat = await FindAsync(id, ct);
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct)
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId);
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
beat.CharacterId = request.CharacterId ?? beat.CharacterId;
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
beat.SceneId = request.SceneId ?? beat.SceneId;
beat.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
{
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto();
}
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var beat = await FindAsync(id, ct);
db.Beats.Remove(beat);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
/// </summary>
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
{
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
if (missing.Count > 0)
{
throw new NotFoundException(nameof(Beat), missing[0]);
}
// Listed beats take the order given; anything omitted keeps its relative position
// after them rather than silently jumping to the front.
var order = 1;
foreach (var id in request.BeatIds)
{
beats.Single(b => b.Id == id).SortOrder = order++;
}
foreach (var beat in beats.Where(b => !request.BeatIds.Contains(b.Id)).OrderBy(b => b.SortOrder))
{
beat.SortOrder = order++;
}
await db.SaveChangesAsync(ct);
return await ListAsync(chapterId, ct);
}
private async Task ValidateReferencesAsync(
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
{
if (characterId is { } cid)
{
var belongs = await db.Characters
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
if (!belongs)
{
throw new InvalidOperationException(
"A beat's character must belong to the same project as its chapter.");
}
}
if (sceneId is { } sid)
{
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
if (!belongs)
{
throw new InvalidOperationException(
"A beat can only be grouped under a scene in the same chapter.");
}
}
}
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
{
var max = await db.Beats
.Where(b => b.ChapterId == chapterId)
.MaxAsync(b => (int?)b.SortOrder, ct);
return (max ?? 0) + 1;
}
private IQueryable<Beat> Query() =>
db.Beats
.Include(b => b.Character)
.Include(b => b.Scene)
.Include(b => b.Tags);
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(b => b.Id == id, ct)
?? throw new NotFoundException(nameof(Beat), id);
}
@@ -4,13 +4,15 @@ using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services; namespace NovelSoftware.Application.Services;
public class ChapterService(INovelDbContext db) public class ChapterService(INovelDbContext db, TagService tags)
{ {
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
var chapters = await db.Chapters var chapters = await db.Chapters
.Include(c => c.PovCharacter) .Include(c => c.PovCharacter)
.Include(c => c.Beats)
.Include(c => c.Scenes) .Include(c => c.Scenes)
.Include(c => c.Tags)
.Where(c => c.ProjectId == projectId) .Where(c => c.ProjectId == projectId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
@@ -41,6 +43,11 @@ public class ChapterService(INovelDbContext db)
TargetWordCount = request.TargetWordCount TargetWordCount = request.TargetWordCount
}; };
if (request.Tags is { } names)
{
chapter.Tags = await tags.ResolveAsync(projectId, names, ct);
}
db.Chapters.Add(chapter); db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(chapter.Id, ct)).ToDto(); return (await FindAsync(chapter.Id, ct)).ToDto();
@@ -60,6 +67,11 @@ public class ChapterService(INovelDbContext db)
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
chapter.UpdatedAt = DateTimeOffset.UtcNow; chapter.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
{
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
}
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct)).ToDto(); return (await FindAsync(id, ct)).ToDto();
} }
@@ -83,8 +95,11 @@ public class ChapterService(INovelDbContext db)
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) => private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) =>
await db.Chapters await db.Chapters
.Include(c => c.PovCharacter) .Include(c => c.PovCharacter)
.Include(c => c.Scenes) .Include(c => c.Beats).ThenInclude(b => b.Character)
.ThenInclude(s => s.PovCharacter) .Include(c => c.Beats).ThenInclude(b => b.Scene)
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
.Include(c => c.Tags)
.FirstOrDefaultAsync(c => c.Id == id, ct) .FirstOrDefaultAsync(c => c.Id == id, ct)
?? throw new NotFoundException(nameof(Chapter), id); ?? throw new NotFoundException(nameof(Chapter), id);
} }
@@ -4,7 +4,7 @@ using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services; namespace NovelSoftware.Application.Services;
public class CharacterService(INovelDbContext db) public class CharacterService(INovelDbContext db, TagService tags)
{ {
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default) public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
{ {
@@ -44,9 +44,14 @@ public class CharacterService(INovelDbContext db)
Notes = request.Notes Notes = request.Notes
}; };
if (request.Tags is { } names)
{
character.Tags = await tags.ResolveAsync(projectId, names, ct);
}
db.Characters.Add(character); db.Characters.Add(character);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return character.ToDto(); return (await FindAsync(character.Id, ct)).ToDto();
} }
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default) public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
@@ -70,8 +75,13 @@ public class CharacterService(INovelDbContext db)
character.Notes = Patch.Apply(character.Notes, request.Notes); character.Notes = Patch.Apply(character.Notes, request.Notes);
character.UpdatedAt = DateTimeOffset.UtcNow; character.UpdatedAt = DateTimeOffset.UtcNow;
if (request.Tags is { } names)
{
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
}
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return character.ToDto(); return (await FindAsync(id, ct)).ToDto();
} }
public async Task DeleteAsync(Guid id, CancellationToken ct = default) public async Task DeleteAsync(Guid id, CancellationToken ct = default)
@@ -120,7 +130,8 @@ public class CharacterService(INovelDbContext db)
private IQueryable<Character> Query() => private IQueryable<Character> Query() =>
db.Characters db.Characters
.Include(c => c.Relationships) .Include(c => c.Relationships)
.ThenInclude(r => r.RelatedCharacter); .ThenInclude(r => r.RelatedCharacter)
.Include(c => c.Tags);
private async Task<Character> FindAsync(Guid id, CancellationToken ct) => private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
await Query().FirstOrDefaultAsync(c => c.Id == id, ct) await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
@@ -1,166 +0,0 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class OutlineService(INovelDbContext db)
{
/// <summary>Returns the project's outline as a tree of root nodes with children inlined.</summary>
public async Task<IReadOnlyList<OutlineNodeDto>> GetTreeAsync(Guid projectId, CancellationToken ct = default)
{
var nodes = await db.OutlineNodes
.Where(n => n.ProjectId == projectId)
.ToListAsync(ct);
return BuildTree(nodes, parentId: null);
}
public async Task<OutlineNodeDto> GetAsync(Guid id, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
var siblings = await db.OutlineNodes.Where(n => n.ProjectId == node.ProjectId).ToListAsync(ct);
return BuildNode(node, siblings);
}
public async Task<OutlineNodeDto> CreateAsync(
Guid projectId, CreateOutlineNodeRequest request, CancellationToken ct = default)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
if (request.ParentId is { } parentId && !await db.OutlineNodes.AnyAsync(n => n.Id == parentId, ct))
{
throw new NotFoundException(nameof(OutlineNode), parentId);
}
var node = new OutlineNode
{
ProjectId = projectId,
ParentId = request.ParentId,
NodeType = request.NodeType,
Title = request.Title,
Summary = request.Summary,
ChapterId = request.ChapterId,
SortOrder = request.SortOrder ?? await NextSortOrderAsync(projectId, request.ParentId, ct)
};
db.OutlineNodes.Add(node);
await db.SaveChangesAsync(ct);
return BuildNode(node, []);
}
public async Task<OutlineNodeDto> UpdateAsync(
Guid id, UpdateOutlineNodeRequest request, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
node.Title = Patch.Apply(node.Title, request.Title) ?? node.Title;
node.NodeType = request.NodeType ?? node.NodeType;
node.Summary = Patch.Apply(node.Summary, request.Summary);
node.SortOrder = request.SortOrder ?? node.SortOrder;
node.ChapterId = request.ChapterId ?? node.ChapterId;
node.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return await GetAsync(id, ct);
}
/// <summary>
/// Reparents a node. Refuses to move a node under one of its own descendants, which
/// would detach the subtree from the tree entirely.
/// </summary>
public async Task<OutlineNodeDto> MoveAsync(Guid id, MoveOutlineNodeRequest request, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
if (request.ParentId == id)
{
throw new InvalidOperationException("An outline node cannot be its own parent.");
}
if (request.ParentId is { } newParentId)
{
var allNodes = await db.OutlineNodes
.Where(n => n.ProjectId == node.ProjectId)
.ToListAsync(ct);
if (!allNodes.Any(n => n.Id == newParentId))
{
throw new NotFoundException(nameof(OutlineNode), newParentId);
}
if (DescendantIds(allNodes, id).Contains(newParentId))
{
throw new InvalidOperationException("An outline node cannot be moved beneath its own descendant.");
}
}
node.ParentId = request.ParentId;
node.SortOrder = request.SortOrder;
node.UpdatedAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
return await GetAsync(id, ct);
}
/// <summary>Deletes a node and its entire subtree.</summary>
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
{
var node = await FindAsync(id, ct);
var allNodes = await db.OutlineNodes
.Where(n => n.ProjectId == node.ProjectId)
.ToListAsync(ct);
var doomed = DescendantIds(allNodes, id).Append(id).ToHashSet();
db.OutlineNodes.RemoveRange(allNodes.Where(n => doomed.Contains(n.Id)));
await db.SaveChangesAsync(ct);
}
private async Task<int> NextSortOrderAsync(Guid projectId, Guid? parentId, CancellationToken ct)
{
var max = await db.OutlineNodes
.Where(n => n.ProjectId == projectId && n.ParentId == parentId)
.MaxAsync(n => (int?)n.SortOrder, ct);
return (max ?? 0) + 1;
}
private async Task<OutlineNode> FindAsync(Guid id, CancellationToken ct) =>
await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct)
?? throw new NotFoundException(nameof(OutlineNode), id);
private static IReadOnlyList<OutlineNodeDto> BuildTree(List<OutlineNode> all, Guid? parentId) =>
[
.. all
.Where(n => n.ParentId == parentId)
.OrderBy(n => n.SortOrder)
.ThenBy(n => n.Title)
.Select(n => new OutlineNodeDto(
n.Id, n.ProjectId, n.ParentId, n.NodeType, n.Title, n.Summary,
n.SortOrder, n.ChapterId, BuildTree(all, n.Id)))
];
private static OutlineNodeDto BuildNode(OutlineNode node, List<OutlineNode> all) => new(
node.Id, node.ProjectId, node.ParentId, node.NodeType, node.Title, node.Summary,
node.SortOrder, node.ChapterId, BuildTree(all, node.Id));
private static IEnumerable<Guid> DescendantIds(List<OutlineNode> all, Guid rootId)
{
var frontier = new Queue<Guid>([rootId]);
while (frontier.Count > 0)
{
var current = frontier.Dequeue();
foreach (var child in all.Where(n => n.ParentId == current))
{
yield return child.Id;
frontier.Enqueue(child.Id);
}
}
}
}
@@ -0,0 +1,158 @@
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Domain.Entities;
namespace NovelSoftware.Application.Services;
public class TagService(INovelDbContext db)
{
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) =>
await db.Tags
.Where(t => t.ProjectId == projectId)
.OrderBy(t => t.Name)
.Select(t => new TagSummaryDto(
t.Id, t.Name, t.Color,
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
.ToListAsync(ct);
/// <summary>Everything in the project carrying this tag.</summary>
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
{
var tag = await db.Tags
.Include(t => t.Characters)
.Include(t => t.Chapters)
.Include(t => t.Beats).ThenInclude(b => b.Character)
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
return new TagReferencesDto(
tag.ToDto(),
[.. tag.Characters
.OrderBy(c => c.Name)
.Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))],
[.. tag.Chapters
.OrderBy(c => c.Number)
.Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))],
[.. tag.Beats
.OrderBy(b => b.Chapter?.Number ?? 0)
.ThenBy(b => b.SortOrder)
.Select(b => new TaggedBeatDto(
b.Id,
b.ChapterId,
b.Chapter?.Number ?? 0,
b.Chapter?.Title ?? "(unknown chapter)",
b.SortOrder,
b.Title,
b.Character?.Name,
b.WhatHappened))]);
}
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
{
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
{
throw new NotFoundException(nameof(Project), projectId);
}
var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("A tag needs a name.");
}
var existing = await FindByNameAsync(projectId, name, ct);
if (existing is not null)
{
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
}
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
db.Tags.Add(tag);
await db.SaveChangesAsync(ct);
return tag.ToDto();
}
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
if (request.Name is not null)
{
var name = TagMapping.Normalise(request.Name);
if (string.IsNullOrWhiteSpace(name))
{
throw new ArgumentException("A tag needs a name.");
}
var clash = await FindByNameAsync(tag.ProjectId, name, ct);
if (clash is not null && clash.Id != tag.Id)
{
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
}
tag.Name = name;
}
tag.Color = Patch.Apply(tag.Color, request.Color);
await db.SaveChangesAsync(ct);
return tag.ToDto();
}
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
{
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
?? throw new NotFoundException(nameof(Tag), tagId);
db.Tags.Remove(tag);
await db.SaveChangesAsync(ct);
}
/// <summary>
/// Turns a list of names into tag entities, creating any the project has not seen
/// before. Typing a new tag on a beat should just work rather than being a two-step
/// "create the tag, then apply it".
/// </summary>
internal async Task<List<Tag>> ResolveAsync(
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
{
var wanted = names
.Select(TagMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
.DistinctBy(n => n.ToLowerInvariant())
.ToList();
if (wanted.Count == 0)
{
return [];
}
var existing = await db.Tags
.Where(t => t.ProjectId == projectId)
.ToListAsync(ct);
var resolved = new List<Tag>();
foreach (var name in wanted)
{
var match = existing.FirstOrDefault(
t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
if (match is null)
{
match = new Tag { ProjectId = projectId, Name = name };
db.Tags.Add(match);
existing.Add(match);
}
resolved.Add(match);
}
return resolved;
}
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) =>
await db.Tags.FirstOrDefaultAsync(
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct);
}
+40
View File
@@ -0,0 +1,40 @@
namespace NovelSoftware.Domain.Entities;
/// <summary>
/// One row of a chapter's outline: a short label, who it belongs to, what happened, and
/// what it sets up. Beats are the planning layer — flat and ordered within a chapter,
/// with no nesting. A beat may optionally be grouped under the <see cref="Scene"/> that
/// will eventually carry its prose.
/// </summary>
public class Beat
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ChapterId { get; set; }
public Chapter? Chapter { get; set; }
/// <summary>Optional grouping: the scene this beat will be written into.</summary>
public Guid? SceneId { get; set; }
public Scene? Scene { get; set; }
/// <summary>Position within the chapter. Gaps are allowed.</summary>
public int SortOrder { get; set; }
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
public string Title { get; set; } = string.Empty;
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
public Guid? CharacterId { get; set; }
public Character? Character { get; set; }
/// <summary>The event itself.</summary>
public string? WhatHappened { get; set; }
/// <summary>What it sets in motion — the hook into the next beat.</summary>
public string? WhatsNext { get; set; }
public List<Tag> Tags { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
@@ -11,6 +11,10 @@ public class Chapter
public int Number { get; set; } public int Number { get; set; }
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
/// <summary>
/// The paragraph that opens the chapter's outline, above the beat table.
/// </summary>
public string? Summary { get; set; } public string? Summary { get; set; }
/// <summary>Whose head we are in for this chapter.</summary> /// <summary>Whose head we are in for this chapter.</summary>
@@ -26,5 +30,11 @@ public class Chapter
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
public List<Beat> Beats { get; set; } = [];
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
public List<Scene> Scenes { get; set; } = []; public List<Scene> Scenes { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
} }
@@ -42,6 +42,7 @@ public class Character
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public List<CharacterRelationship> Relationships { get; set; } = []; public List<CharacterRelationship> Relationships { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
} }
/// <summary>A directed relationship from one character to another.</summary> /// <summary>A directed relationship from one character to another.</summary>
@@ -1,31 +0,0 @@
namespace NovelSoftware.Domain.Entities;
/// <summary>
/// A node in the project's outline tree. Nodes are self-nesting, so the same structure
/// serves a three-act skeleton, a beat sheet, or a loose pile of scene ideas.
/// </summary>
public class OutlineNode
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
public Guid? ParentId { get; set; }
public OutlineNode? Parent { get; set; }
public List<OutlineNode> Children { get; set; } = [];
public OutlineNodeType NodeType { get; set; } = OutlineNodeType.Beat;
public string Title { get; set; } = string.Empty;
public string? Summary { get; set; }
/// <summary>Position among siblings. Gaps are allowed; ordering is by this value then title.</summary>
public int SortOrder { get; set; }
/// <summary>Optional link to the chapter that realises this outline node.</summary>
public Guid? ChapterId { get; set; }
public Chapter? Chapter { get; set; }
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+1 -1
View File
@@ -25,6 +25,6 @@ public class Project
public List<Character> Characters { get; set; } = []; public List<Character> Characters { get; set; } = [];
public List<Chapter> Chapters { get; set; } = []; public List<Chapter> Chapters { get; set; } = [];
public List<OutlineNode> OutlineNodes { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<AgentConversation> Conversations { get; set; } = []; public List<AgentConversation> Conversations { get; set; } = [];
} }
+25
View File
@@ -0,0 +1,25 @@
namespace NovelSoftware.Domain.Entities;
/// <summary>
/// A free-form label scoped to one project. Tags are the cross-reference mechanism:
/// attach the same tag to a character, a chapter and a beat, then ask what else carries it.
/// Names are unique within a project so "betrayal" always means the same tag.
/// </summary>
public class Tag
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid ProjectId { get; set; }
public Project? Project { get; set; }
public string Name { get; set; } = string.Empty;
/// <summary>Optional hex colour for the UI, e.g. "#9a4a2f".</summary>
public string? Color { get; set; }
public List<Character> Characters { get; set; } = [];
public List<Chapter> Chapters { get; set; } = [];
public List<Beat> Beats { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
-14
View File
@@ -13,20 +13,6 @@ public enum CharacterRole
Foil Foil
} }
/// <summary>
/// The kind of node in a project's outline tree. The tree is intentionally loose:
/// a writer can nest an Act under a Part, or skip straight to Beats.
/// </summary>
public enum OutlineNodeType
{
Part,
Act,
Sequence,
Chapter,
Beat,
Note
}
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary> /// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
public enum DraftStatus public enum DraftStatus
{ {
@@ -21,7 +21,8 @@ public static class DependencyInjection
services.AddScoped<ProjectService>(); services.AddScoped<ProjectService>();
services.AddScoped<CharacterService>(); services.AddScoped<CharacterService>();
services.AddScoped<OutlineService>(); services.AddScoped<BeatService>();
services.AddScoped<TagService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<SceneService>(); services.AddScoped<SceneService>();
services.AddScoped<NovelAgentToolset>(); services.AddScoped<NovelAgentToolset>();
@@ -0,0 +1,657 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using NovelSoftware.Infrastructure.Persistence;
#nullable disable
namespace NovelSoftware.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(NovelDbContext))]
[Migration("20260806031243_ReplaceOutlineWithBeatsAndTags")]
partial class ReplaceOutlineWithBeatsAndTags
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("BeatTags", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("ChapterTags", (string)null);
});
modelBuilder.Entity("CharacterTag", b =>
{
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("CharactersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Conversations");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("ConversationId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<int>("Sequence")
.HasColumnType("INTEGER");
b.Property<string>("ToolCallsJson")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ConversationId", "Sequence")
.IsUnique();
b.ToTable("AgentMessages");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Beat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("WhatHappened")
.HasColumnType("TEXT");
b.Property<string>("WhatsNext")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SceneId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<int>("Number")
.HasColumnType("INTEGER");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ProjectId", "Number");
b.ToTable("Chapters");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Age")
.HasColumnType("TEXT");
b.Property<string>("Appearance")
.HasColumnType("TEXT");
b.Property<string>("ArcSummary")
.HasColumnType("TEXT");
b.Property<string>("Backstory")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("ExternalConflict")
.HasColumnType("TEXT");
b.Property<string>("InternalConflict")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Need")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Occupation")
.HasColumnType("TEXT");
b.Property<string>("Personality")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<string>("Pronouns")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Voice")
.HasColumnType("TEXT");
b.Property<string>("Want")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId");
b.ToTable("Characters");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("CharacterId")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<Guid>("RelatedCharacterId")
.HasColumnType("TEXT");
b.Property<string>("RelationshipType")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("RelatedCharacterId");
b.ToTable("CharacterRelationships");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Author")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Genre")
.HasColumnType("TEXT");
b.Property<string>("Logline")
.HasColumnType("TEXT");
b.Property<string>("Notes")
.HasColumnType("TEXT");
b.Property<string>("Synopsis")
.HasColumnType("TEXT");
b.Property<int?>("TargetWordCount")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Projects");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<string>("Conflict")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Goal")
.HasColumnType("TEXT");
b.Property<string>("Location")
.HasColumnType("TEXT");
b.Property<string>("Outcome")
.HasColumnType("TEXT");
b.Property<Guid?>("PovCharacterId")
.HasColumnType("TEXT");
b.Property<string>("Prose")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<int>("WordCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PovCharacterId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Scenes");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("BeatTag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CharacterTag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
.WithMany("Conversations")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.AgentConversation", "Conversation")
.WithMany("Messages")
.HasForeignKey("ConversationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Conversation");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Beat", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
.WithMany("Beats")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Character", "Character")
.WithMany()
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("NovelSoftware.Domain.Entities.Scene", "Scene")
.WithMany()
.HasForeignKey("SceneId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
.WithMany("Chapters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("PovCharacter");
b.Navigation("Project");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
.WithMany("Characters")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Character", "Character")
.WithMany("Relationships")
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Character", "RelatedCharacter")
.WithMany()
.HasForeignKey("RelatedCharacterId")
.OnDelete(DeleteBehavior.Restrict)
.IsRequired();
b.Navigation("Character");
b.Navigation("RelatedCharacter");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
.WithMany("Scenes")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter")
.WithMany()
.HasForeignKey("PovCharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("PovCharacter");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Tag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
.WithMany("Tags")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
{
b.Navigation("Beats");
b.Navigation("Scenes");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Character", b =>
{
b.Navigation("Relationships");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b =>
{
b.Navigation("Chapters");
b.Navigation("Characters");
b.Navigation("Conversations");
b.Navigation("Tags");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,257 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace NovelSoftware.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class ReplaceOutlineWithBeatsAndTags : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "OutlineNodes");
migrationBuilder.CreateTable(
name: "Beats",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
SceneId = table.Column<Guid>(type: "TEXT", nullable: true),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
CharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
WhatHappened = table.Column<string>(type: "TEXT", nullable: true),
WhatsNext = table.Column<string>(type: "TEXT", nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Beats", x => x.Id);
table.ForeignKey(
name: "FK_Beats_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_Beats_Characters_CharacterId",
column: x => x.CharacterId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_Beats_Scenes_SceneId",
column: x => x.SceneId,
principalTable: "Scenes",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
});
migrationBuilder.CreateTable(
name: "Tags",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
Color = table.Column<string>(type: "TEXT", maxLength: 16, nullable: true),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tags", x => x.Id);
table.ForeignKey(
name: "FK_Tags_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BeatTags",
columns: table => new
{
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false),
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BeatTags", x => new { x.BeatsId, x.TagsId });
table.ForeignKey(
name: "FK_BeatTags_Beats_BeatsId",
column: x => x.BeatsId,
principalTable: "Beats",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BeatTags_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChapterTags",
columns: table => new
{
ChaptersId = table.Column<Guid>(type: "TEXT", nullable: false),
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChapterTags", x => new { x.ChaptersId, x.TagsId });
table.ForeignKey(
name: "FK_ChapterTags_Chapters_ChaptersId",
column: x => x.ChaptersId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChapterTags_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CharacterTags",
columns: table => new
{
CharactersId = table.Column<Guid>(type: "TEXT", nullable: false),
TagsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CharacterTags", x => new { x.CharactersId, x.TagsId });
table.ForeignKey(
name: "FK_CharacterTags_Characters_CharactersId",
column: x => x.CharactersId,
principalTable: "Characters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CharacterTags_Tags_TagsId",
column: x => x.TagsId,
principalTable: "Tags",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Beats_ChapterId_SortOrder",
table: "Beats",
columns: new[] { "ChapterId", "SortOrder" });
migrationBuilder.CreateIndex(
name: "IX_Beats_CharacterId",
table: "Beats",
column: "CharacterId");
migrationBuilder.CreateIndex(
name: "IX_Beats_SceneId",
table: "Beats",
column: "SceneId");
migrationBuilder.CreateIndex(
name: "IX_BeatTags_TagsId",
table: "BeatTags",
column: "TagsId");
migrationBuilder.CreateIndex(
name: "IX_ChapterTags_TagsId",
table: "ChapterTags",
column: "TagsId");
migrationBuilder.CreateIndex(
name: "IX_CharacterTags_TagsId",
table: "CharacterTags",
column: "TagsId");
migrationBuilder.CreateIndex(
name: "IX_Tags_ProjectId_Name",
table: "Tags",
columns: new[] { "ProjectId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BeatTags");
migrationBuilder.DropTable(
name: "ChapterTags");
migrationBuilder.DropTable(
name: "CharacterTags");
migrationBuilder.DropTable(
name: "Beats");
migrationBuilder.DropTable(
name: "Tags");
migrationBuilder.CreateTable(
name: "OutlineNodes",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
ParentId = table.Column<Guid>(type: "TEXT", nullable: true),
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
NodeType = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
Summary = table.Column<string>(type: "TEXT", nullable: true),
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_OutlineNodes", x => x.Id);
table.ForeignKey(
name: "FK_OutlineNodes_Chapters_ChapterId",
column: x => x.ChapterId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.SetNull);
table.ForeignKey(
name: "FK_OutlineNodes_OutlineNodes_ParentId",
column: x => x.ParentId,
principalTable: "OutlineNodes",
principalColumn: "Id",
onDelete: ReferentialAction.Restrict);
table.ForeignKey(
name: "FK_OutlineNodes_Projects_ProjectId",
column: x => x.ProjectId,
principalTable: "Projects",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ChapterId",
table: "OutlineNodes",
column: "ChapterId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ParentId",
table: "OutlineNodes",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_OutlineNodes_ProjectId_ParentId_SortOrder",
table: "OutlineNodes",
columns: new[] { "ProjectId", "ParentId", "SortOrder" });
}
}
}
@@ -17,6 +17,51 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("BeatTag", b =>
{
b.Property<Guid>("BeatsId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("BeatsId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("BeatTags", (string)null);
});
modelBuilder.Entity("ChapterTag", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("ChapterTags", (string)null);
});
modelBuilder.Entity("CharacterTag", b =>
{
b.Property<Guid>("CharactersId")
.HasColumnType("TEXT");
b.Property<Guid>("TagsId")
.HasColumnType("TEXT");
b.HasKey("CharactersId", "TagsId");
b.HasIndex("TagsId");
b.ToTable("CharacterTags", (string)null);
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -79,6 +124,52 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.ToTable("AgentMessages"); b.ToTable("AgentMessages");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.Beat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ChapterId")
.HasColumnType("TEXT");
b.Property<Guid?>("CharacterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("SceneId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<string>("WhatHappened")
.HasColumnType("TEXT");
b.Property<string>("WhatsNext")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("CharacterId");
b.HasIndex("SceneId");
b.HasIndex("ChapterId", "SortOrder");
b.ToTable("Beats");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -231,54 +322,6 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.ToTable("CharacterRelationships"); b.ToTable("CharacterRelationships");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ChapterId")
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("NodeType")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid?>("ParentId")
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<string>("Summary")
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChapterId");
b.HasIndex("ParentId");
b.HasIndex("ProjectId", "ParentId", "SortOrder");
b.ToTable("OutlineNodes");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -380,6 +423,80 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.ToTable("Scenes"); b.ToTable("Scenes");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.Tag", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Color")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ProjectId", "Name")
.IsUnique();
b.ToTable("Tags");
});
modelBuilder.Entity("BeatTag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Beat", null)
.WithMany()
.HasForeignKey("BeatsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("CharacterTag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Character", null)
.WithMany()
.HasForeignKey("CharactersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Tag", null)
.WithMany()
.HasForeignKey("TagsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
{ {
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project") b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
@@ -402,6 +519,31 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.Navigation("Conversation"); b.Navigation("Conversation");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.Beat", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
.WithMany("Beats")
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("NovelSoftware.Domain.Entities.Character", "Character")
.WithMany()
.HasForeignKey("CharacterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("NovelSoftware.Domain.Entities.Scene", "Scene")
.WithMany()
.HasForeignKey("SceneId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Chapter");
b.Navigation("Character");
b.Navigation("Scene");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
{ {
b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter") b.HasOne("NovelSoftware.Domain.Entities.Character", "PovCharacter")
@@ -450,31 +592,6 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.Navigation("RelatedCharacter"); b.Navigation("RelatedCharacter");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
.WithMany()
.HasForeignKey("ChapterId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("NovelSoftware.Domain.Entities.OutlineNode", "Parent")
.WithMany("Children")
.HasForeignKey("ParentId")
.OnDelete(DeleteBehavior.Restrict);
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
.WithMany("OutlineNodes")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Chapter");
b.Navigation("Parent");
b.Navigation("Project");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b =>
{ {
b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter") b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter")
@@ -493,6 +610,17 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.Navigation("PovCharacter"); b.Navigation("PovCharacter");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.Tag", b =>
{
b.HasOne("NovelSoftware.Domain.Entities.Project", "Project")
.WithMany("Tags")
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b =>
{ {
b.Navigation("Messages"); b.Navigation("Messages");
@@ -500,6 +628,8 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b =>
{ {
b.Navigation("Beats");
b.Navigation("Scenes"); b.Navigation("Scenes");
}); });
@@ -508,11 +638,6 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.Navigation("Relationships"); b.Navigation("Relationships");
}); });
modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b =>
{
b.Navigation("Children");
});
modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b =>
{ {
b.Navigation("Chapters"); b.Navigation("Chapters");
@@ -521,7 +646,7 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations
b.Navigation("Conversations"); b.Navigation("Conversations");
b.Navigation("OutlineNodes"); b.Navigation("Tags");
}); });
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
@@ -22,7 +22,8 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
public DbSet<Project> Projects => Set<Project>(); public DbSet<Project> Projects => Set<Project>();
public DbSet<Character> Characters => Set<Character>(); public DbSet<Character> Characters => Set<Character>();
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>(); public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
public DbSet<OutlineNode> OutlineNodes => Set<OutlineNode>(); public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Chapter> Chapters => Set<Chapter>(); public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<Scene> Scenes => Set<Scene>(); public DbSet<Scene> Scenes => Set<Scene>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
@@ -43,8 +44,8 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.OutlineNodes).WithOne(n => n.Project!) entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
.HasForeignKey(n => n.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!) entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
}); });
@@ -70,17 +71,38 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
}); });
builder.Entity<OutlineNode>(entity => builder.Entity<Beat>(entity =>
{ {
entity.Property(n => n.Title).IsRequired().HasMaxLength(300); entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.Property(n => n.NodeType).HasConversion<string>().HasMaxLength(32); entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasIndex(n => new { n.ProjectId, n.ParentId, n.SortOrder });
entity.HasOne(n => n.Parent).WithMany(n => n.Children) entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(n => n.ParentId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
entity.HasOne(n => n.Chapter).WithMany() // A beat outlives the scene it was grouped under: deleting a scene is a
.HasForeignKey(n => n.ChapterId).OnDelete(DeleteBehavior.SetNull); // decision about prose, not about the plan.
entity.HasOne(b => b.Scene).WithMany()
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
entity.HasOne(b => b.Character).WithMany()
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
});
builder.Entity<Tag>(entity =>
{
entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
entity.Property(t => t.Color).HasMaxLength(16);
// One canonical tag per name per project, so "betrayal" always means the
// same tag no matter where it was typed.
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("CharacterTags"));
entity.HasMany(t => t.Chapters).WithMany(c => c.Tags)
.UsingEntity(join => join.ToTable("ChapterTags"));
entity.HasMany(t => t.Beats).WithMany(b => b.Tags)
.UsingEntity(join => join.ToTable("BeatTags"));
}); });
builder.Entity<Chapter>(entity => builder.Entity<Chapter>(entity =>
+71
View File
@@ -0,0 +1,71 @@
using System.ComponentModel;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace NovelSoftware.Mcp.Tools;
[McpServerToolType]
public static class BeatTools
{
[McpServerTool(Name = "get_chapter_outline")]
[Description("Read a chapter's outline: its beats in order. Each beat is one row — a short "
+ "title, whose beat it is, what happened, and what it sets up. The chapter's "
+ "summary paragraph sits on the chapter itself, via get_chapter.")]
public static Task<CallToolResult> GetChapterOutline(
NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId,
CancellationToken ct) =>
api.GetAsync($"/api/chapters/{chapterId}/beats", ct);
[McpServerTool(Name = "create_beat")]
[Description("Add a beat to a chapter's outline. Keep the title to three to five words — it "
+ "is a handle, not a sentence; detail belongs in whatHappened and whatsNext.")]
public static Task<CallToolResult> CreateBeat(
NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId,
[Description("Three to five words naming the beat.")] string title,
CancellationToken ct,
[Description("Position in the chapter. Appended to the end when omitted.")] int? sortOrder = null,
[Description("Id of the character whose beat this is.")] Guid? characterId = null,
[Description("The event itself.")] string? whatHappened = null,
[Description("What it sets in motion — the hook into the next beat.")] string? whatsNext = null,
[Description("Id of the scene this beat will be written into, if decided.")] Guid? sceneId = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/chapters/{chapterId}/beats",
new { title, sortOrder, characterId, whatHappened, whatsNext, sceneId, tags }, ct);
[McpServerTool(Name = "update_beat")]
[Description("Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
+ "the beat's tags outright, so include the ones you want to keep.")]
public static Task<CallToolResult> UpdateBeat(
NovelApiClient api,
[Description("The beat's id.")] Guid beatId,
CancellationToken ct,
[Description("Three to five words naming the beat.")] string? title = null,
[Description("Position in the chapter.")] int? sortOrder = null,
[Description("Id of the character whose beat this is.")] Guid? characterId = null,
[Description("The event itself.")] string? whatHappened = null,
[Description("What it sets in motion.")] string? whatsNext = null,
[Description("Id of the scene this beat will be written into.")] Guid? sceneId = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
api.PatchAsync($"/api/beats/{beatId}",
new { title, sortOrder, characterId, whatHappened, whatsNext, sceneId, tags }, ct);
[McpServerTool(Name = "delete_beat")]
[Description("Remove a beat from a chapter's outline. Confirm with the writer first.")]
public static Task<CallToolResult> DeleteBeat(
NovelApiClient api,
[Description("The beat's id.")] Guid beatId,
CancellationToken ct) =>
api.DeleteAsync($"/api/beats/{beatId}", ct);
[McpServerTool(Name = "reorder_beats")]
[Description("Renumber a chapter's beats to match the order given. List every beat id in the "
+ "order wanted; any left out keep their relative position at the end.")]
public static Task<CallToolResult> ReorderBeats(
NovelApiClient api,
[Description("The chapter's id.")] Guid chapterId,
[Description("Beat ids in their new order.")] Guid[] beatIds,
CancellationToken ct) =>
api.PostAsync($"/api/chapters/{chapterId}/beats/reorder", new { beatIds }, ct);
}
@@ -45,7 +45,8 @@ public static class CharacterTools
[Description("What in the world opposes them.")] string? externalConflict = null, [Description("What in the world opposes them.")] string? externalConflict = null,
[Description("How they change over the course of the book.")] string? arcSummary = null, [Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null, [Description("Speech patterns and register that make their dialogue theirs.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null) => [Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/projects/{projectId}/characters", new api.PostAsync($"/api/projects/{projectId}/characters", new
{ {
name, name,
@@ -62,7 +63,8 @@ public static class CharacterTools
externalConflict, externalConflict,
arcSummary, arcSummary,
voice, voice,
notes notes,
tags
}, ct); }, ct);
[McpServerTool(Name = "update_character")] [McpServerTool(Name = "update_character")]
@@ -86,7 +88,8 @@ public static class CharacterTools
[Description("What in the world opposes them.")] string? externalConflict = null, [Description("What in the world opposes them.")] string? externalConflict = null,
[Description("How they change over the course of the book.")] string? arcSummary = null, [Description("How they change over the course of the book.")] string? arcSummary = null,
[Description("Speech patterns and register.")] string? voice = null, [Description("Speech patterns and register.")] string? voice = null,
[Description("Anything else worth recording.")] string? notes = null) => [Description("Anything else worth recording.")] string? notes = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
api.PatchAsync($"/api/characters/{characterId}", new api.PatchAsync($"/api/characters/{characterId}", new
{ {
name, name,
@@ -103,7 +106,8 @@ public static class CharacterTools
externalConflict, externalConflict,
arcSummary, arcSummary,
voice, voice,
notes notes,
tags
}, ct); }, ct);
[McpServerTool(Name = "relate_characters")] [McpServerTool(Name = "relate_characters")]
@@ -31,11 +31,12 @@ public static class ManuscriptTools
[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("Position in the manuscript, 1-based.")] int? number = null,
[Description("What the chapter covers.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Id of the point-of-view character.")] Guid? povCharacterId = null, [Description("Id of the point-of-view character.")] Guid? povCharacterId = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place.")] string? setting = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null) => [Description("Target length in words.")] int? targetWordCount = null,
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
api.PostAsync($"/api/projects/{projectId}/chapters", new api.PostAsync($"/api/projects/{projectId}/chapters", new
{ {
title, title,
@@ -44,7 +45,8 @@ public static class ManuscriptTools
povCharacterId, povCharacterId,
setting, setting,
status = status ?? "Planned", status = status ?? "Planned",
targetWordCount targetWordCount,
tags
}, ct); }, ct);
[McpServerTool(Name = "update_chapter")] [McpServerTool(Name = "update_chapter")]
@@ -55,14 +57,15 @@ public static class ManuscriptTools
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("Position in the manuscript.")] int? number = null,
[Description("What the chapter covers.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Id of the point-of-view character.")] Guid? povCharacterId = null, [Description("Id of the point-of-view character.")] Guid? povCharacterId = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place.")] string? setting = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null) => [Description("Target length in words.")] int? targetWordCount = 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, povCharacterId, setting, notes, status, targetWordCount }, ct); new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, tags }, ct);
[McpServerTool(Name = "list_scenes")] [McpServerTool(Name = "list_scenes")]
[Description("List a chapter's scenes in order.")] [Description("List a chapter's scenes in order.")]
@@ -1,72 +0,0 @@
using System.ComponentModel;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace NovelSoftware.Mcp.Tools;
[McpServerToolType]
public static class OutlineTools
{
[McpServerTool(Name = "get_outline")]
[Description("Read a project's outline as a nested tree of parts, acts, sequences and beats.")]
public static Task<CallToolResult> GetOutline(
NovelApiClient api,
[Description("The project's id.")] Guid projectId,
CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/outline", ct);
[McpServerTool(Name = "create_outline_node")]
[Description("Add a node to a project's outline. Pass parentId to nest it under another node; "
+ "omit it for a top-level node.")]
public static Task<CallToolResult> CreateOutlineNode(
NovelApiClient api,
[Description("The project's id.")] Guid projectId,
[Description("Short label for the node.")] string title,
CancellationToken ct,
[Description("Part, Act, Sequence, Chapter, Beat or Note.")] string? nodeType = null,
[Description("Id of the parent node, if nesting.")] Guid? parentId = null,
[Description("What happens here, in a sentence or two.")] string? summary = null,
[Description("Position among siblings. Appended to the end when omitted.")] int? sortOrder = null,
[Description("Id of the chapter that realises this node, if one exists.")] Guid? chapterId = null) =>
api.PostAsync($"/api/projects/{projectId}/outline", new
{
title,
nodeType = nodeType ?? "Beat",
parentId,
summary,
sortOrder,
chapterId
}, ct);
[McpServerTool(Name = "update_outline_node")]
[Description("Revise an outline node's title, type, summary, position or linked chapter.")]
public static Task<CallToolResult> UpdateOutlineNode(
NovelApiClient api,
[Description("The node's id.")] Guid nodeId,
CancellationToken ct,
[Description("New title.")] string? title = null,
[Description("Part, Act, Sequence, Chapter, Beat or Note.")] string? nodeType = null,
[Description("What happens here.")] string? summary = null,
[Description("Position among siblings.")] int? sortOrder = null,
[Description("Id of the chapter that realises this node.")] Guid? chapterId = null) =>
api.PatchAsync($"/api/outline/{nodeId}", new { title, nodeType, summary, sortOrder, chapterId }, ct);
[McpServerTool(Name = "move_outline_node")]
[Description("Reparent or reorder an outline node. Pass a null parentId to move it to the top level.")]
public static Task<CallToolResult> MoveOutlineNode(
NovelApiClient api,
[Description("The node's id.")] Guid nodeId,
[Description("Position among its new siblings.")] int sortOrder,
CancellationToken ct,
[Description("Id of the new parent node, or null for the top level.")] Guid? parentId = null) =>
api.PostAsync($"/api/outline/{nodeId}/move", new { parentId, sortOrder }, ct);
[McpServerTool(Name = "delete_outline_node")]
[Description("Delete an outline node and everything nested beneath it. This cannot be undone — "
+ "confirm with the writer before calling it.")]
public static Task<CallToolResult> DeleteOutlineNode(
NovelApiClient api,
[Description("The node's id.")] Guid nodeId,
CancellationToken ct) =>
api.DeleteAsync($"/api/outline/{nodeId}", ct);
}
+56
View File
@@ -0,0 +1,56 @@
using System.ComponentModel;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace NovelSoftware.Mcp.Tools;
[McpServerToolType]
public static class TagTools
{
[McpServerTool(Name = "list_tags")]
[Description("List a project's tags with how many characters, chapters and beats carry each. "
+ "Read this before inventing a new tag so you reuse the writer's vocabulary.")]
public static Task<CallToolResult> ListTags(
NovelApiClient api,
[Description("The project's id.")] Guid projectId,
CancellationToken ct) =>
api.GetAsync($"/api/projects/{projectId}/tags", ct);
[McpServerTool(Name = "get_tag_references")]
[Description("Cross-reference a tag: every character, chapter and beat carrying it. Use this "
+ "to trace a motif, a thread, or a piece of setup through the book.")]
public static Task<CallToolResult> GetTagReferences(
NovelApiClient api,
[Description("The tag's id.")] Guid tagId,
CancellationToken ct) =>
api.GetAsync($"/api/tags/{tagId}/references", ct);
[McpServerTool(Name = "create_tag")]
[Description("Create a tag explicitly. Applying an unknown tag by name to a character, "
+ "chapter or beat also creates it, so this is only needed to set a colour up front.")]
public static Task<CallToolResult> CreateTag(
NovelApiClient api,
[Description("The project's id.")] Guid projectId,
[Description("The tag's name. Unique within the project, matched case-insensitively.")] string name,
CancellationToken ct,
[Description("Optional hex colour for the UI, e.g. \"#9a4a2f\".")] string? color = null) =>
api.PostAsync($"/api/projects/{projectId}/tags", new { name, color }, ct);
[McpServerTool(Name = "update_tag")]
[Description("Rename or recolour a tag. Renaming updates it everywhere it is applied.")]
public static Task<CallToolResult> UpdateTag(
NovelApiClient api,
[Description("The tag's id.")] Guid tagId,
CancellationToken ct,
[Description("New name.")] string? name = null,
[Description("Hex colour, e.g. \"#9a4a2f\".")] string? color = null) =>
api.PatchAsync($"/api/tags/{tagId}", new { name, color }, ct);
[McpServerTool(Name = "delete_tag")]
[Description("Delete a tag. Whatever carried it is left alone — only the label goes.")]
public static Task<CallToolResult> DeleteTag(
NovelApiClient api,
[Description("The tag's id.")] Guid tagId,
CancellationToken ct) =>
api.DeleteAsync($"/api/tags/{tagId}", ct);
}
+2 -2
View File
@@ -3,7 +3,7 @@ import ProjectsPage from './pages/ProjectsPage'
import ProjectLayout from './pages/ProjectLayout' import ProjectLayout from './pages/ProjectLayout'
import OverviewPage from './pages/OverviewPage' import OverviewPage from './pages/OverviewPage'
import CharactersPage from './pages/CharactersPage' import CharactersPage from './pages/CharactersPage'
import OutlinePage from './pages/OutlinePage' import TagsPage from './pages/TagsPage'
import ChaptersPage from './pages/ChaptersPage' import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage' import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage' import AgentPage from './pages/AgentPage'
@@ -15,9 +15,9 @@ export default function App() {
<Route path="/projects/:projectId" element={<ProjectLayout />}> <Route path="/projects/:projectId" element={<ProjectLayout />}>
<Route index element={<OverviewPage />} /> <Route index element={<OverviewPage />} />
<Route path="characters" element={<CharactersPage />} /> <Route path="characters" element={<CharactersPage />} />
<Route path="outline" element={<OutlinePage />} />
<Route path="chapters" element={<ChaptersPage />} /> <Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} /> <Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} />
<Route path="agent" element={<AgentPage />} /> <Route path="agent" element={<AgentPage />} />
</Route> </Route>
<Route path="*" element={<ProjectsPage />} /> <Route path="*" element={<ProjectsPage />} />
+77 -22
View File
@@ -7,17 +7,20 @@ import type {
Character, Character,
Conversation, Conversation,
ConversationSummary, ConversationSummary,
OutlineNode, Beat,
Project, Project,
ProjectSummary, ProjectSummary,
Scene, Scene,
TagReferences,
TagSummary,
} from './types' } from './types'
export const keys = { export const keys = {
projects: ['projects'] as const, projects: ['projects'] as const,
project: (id: string) => ['projects', id] as const, project: (id: string) => ['projects', id] as const,
characters: (projectId: string) => ['projects', projectId, 'characters'] as const, characters: (projectId: string) => ['projects', projectId, 'characters'] as const,
outline: (projectId: string) => ['projects', projectId, 'outline'] as const, tags: (projectId: string) => ['projects', projectId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const, chapters: (projectId: string) => ['projects', projectId, 'chapters'] as const,
chapter: (id: string) => ['chapters', id] as const, chapter: (id: string) => ['chapters', id] as const,
conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const,
@@ -73,16 +76,22 @@ export function useCreateCharacter(projectId: string) {
return useMutation({ return useMutation({
mutationFn: (body: Partial<Character> & { name: string }) => mutationFn: (body: Partial<Character> & { name: string }) =>
api.post<Character>(`/api/projects/${projectId}/characters`, body), api.post<Character>(`/api/projects/${projectId}/characters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
},
}) })
} }
export function useUpdateCharacter(projectId: string) { export function useUpdateCharacter(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Character> & { id: string }) => mutationFn: ({ id, ...body }: Partial<Omit<Character, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Character>(`/api/characters/${id}`, body), api.patch<Character>(`/api/characters/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.characters(projectId) }), onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
},
}) })
} }
@@ -94,37 +103,82 @@ export function useDeleteCharacter(projectId: string) {
}) })
} }
// --- Outline ---------------------------------------------------------------- // --- Tags --------------------------------------------------------------------
export const useOutline = (projectId: string) => export const useTags = (projectId: string) =>
useQuery({ useQuery({
queryKey: keys.outline(projectId), queryKey: keys.tags(projectId),
queryFn: () => api.get<OutlineNode[]>(`/api/projects/${projectId}/outline`), queryFn: () => api.get<TagSummary[]>(`/api/projects/${projectId}/tags`),
}) })
export function useCreateOutlineNode(projectId: string) { export const useTagReferences = (tagId: string | undefined) =>
useQuery({
queryKey: keys.tagRefs(tagId ?? ''),
queryFn: () => api.get<TagReferences>(`/api/tags/${tagId}/references`),
enabled: Boolean(tagId),
})
export function useUpdateTag(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<OutlineNode> & { title: string }) => mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) =>
api.post<OutlineNode>(`/api/projects/${projectId}/outline`, body), api.patch<TagSummary>(`/api/tags/${id}`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.tags(projectId) }),
}) })
} }
export function useUpdateOutlineNode(projectId: string) { /**
* Deleting a tag strips it from every character, chapter and beat that carried it, so
* this invalidates the whole cache rather than trying to enumerate what moved.
*/
export function useDeleteTag() {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<OutlineNode> & { id: string }) => mutationFn: (id: string) => api.delete(`/api/tags/${id}`),
api.patch<OutlineNode>(`/api/outline/${id}`, body), onSuccess: () => qc.invalidateQueries(),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }),
}) })
} }
export function useDeleteOutlineNode(projectId: string) { // --- Beats (a chapter's outline) ---------------------------------------------
export function useCreateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (id: string) => api.delete(`/api/outline/${id}`), mutationFn: (body: Partial<Beat> & { title: string }) =>
onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
},
})
}
export function useUpdateBeat(chapterId: string, projectId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Beat, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Beat>(`/api/beats/${id}`, body),
onSuccess: () => {
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
},
})
}
export function useDeleteBeat(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/beats/${id}`),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
})
}
export function useReorderBeats(chapterId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (beatIds: string[]) =>
api.post<Beat[]>(`/api/chapters/${chapterId}/beats/reorder`, { beatIds }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }),
}) })
} }
@@ -155,11 +209,12 @@ export function useCreateChapter(projectId: string) {
export function useUpdateChapter(projectId: string) { export function useUpdateChapter(projectId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Chapter> & { id: string }) => mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
api.patch<Chapter>(`/api/chapters/${id}`, body), api.patch<Chapter>(`/api/chapters/${id}`, body),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(keys.chapter(updated.id), updated) qc.setQueryData(keys.chapter(updated.id), updated)
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
}, },
}) })
} }
@@ -223,7 +278,7 @@ export function useSendAgentMessage(projectId: string) {
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) }) qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
// The agent edits project data through its tools, so anything on screen may be stale. // The agent edits project data through its tools, so anything on screen may be stale.
qc.invalidateQueries({ queryKey: keys.characters(projectId) }) qc.invalidateQueries({ queryKey: keys.characters(projectId) })
qc.invalidateQueries({ queryKey: keys.outline(projectId) }) qc.invalidateQueries({ queryKey: keys.tags(projectId) })
qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
qc.invalidateQueries({ queryKey: keys.project(projectId) }) qc.invalidateQueries({ queryKey: keys.project(projectId) })
}, },
+50 -24
View File
@@ -21,17 +21,6 @@ export const characterRoles: CharacterRole[] = [
'Foil', 'Foil',
] ]
export type OutlineNodeType = 'Part' | 'Act' | 'Sequence' | 'Chapter' | 'Beat' | 'Note'
export const outlineNodeTypes: OutlineNodeType[] = [
'Part',
'Act',
'Sequence',
'Chapter',
'Beat',
'Note',
]
export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Final' export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Final'
export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final']
@@ -62,6 +51,51 @@ export interface Project {
updatedAt: string updatedAt: string
} }
export interface Tag {
id: string
name: string
color: string | null
}
export interface TagSummary extends Tag {
characterCount: number
chapterCount: number
beatCount: number
totalCount: number
}
export interface TagReferences {
tag: Tag
characters: { id: string; name: string; role: string }[]
chapters: { id: string; number: number; title: string; summary: string | null }[]
beats: {
id: string
chapterId: string
chapterNumber: number
chapterTitle: string
sortOrder: number
title: string
characterName: string | null
whatHappened: string | null
}[]
}
/** One row of a chapter's outline. Flat and ordered — no nesting. */
export interface Beat {
id: string
chapterId: string
sortOrder: number
title: string
characterId: string | null
characterName: string | null
whatHappened: string | null
whatsNext: string | null
sceneId: string | null
sceneTitle: string | null
tags: Tag[]
updatedAt: string
}
export interface Relationship { export interface Relationship {
id: string id: string
relatedCharacterId: string relatedCharacterId: string
@@ -89,21 +123,10 @@ export interface Character {
voice: string | null voice: string | null
notes: string | null notes: string | null
relationships: Relationship[] relationships: Relationship[]
tags: Tag[]
updatedAt: string updatedAt: string
} }
export interface OutlineNode {
id: string
projectId: string
parentId: string | null
nodeType: OutlineNodeType
title: string
summary: string | null
sortOrder: number
chapterId: string | null
children: OutlineNode[]
}
export interface Scene { export interface Scene {
id: string id: string
chapterId: string chapterId: string
@@ -133,12 +156,15 @@ export interface ChapterSummary {
setting: string | null setting: string | null
status: DraftStatus status: DraftStatus
targetWordCount: number | null targetWordCount: number | null
beatCount: number
sceneCount: number sceneCount: number
wordCount: number wordCount: number
tags: Tag[]
} }
export interface Chapter extends Omit<ChapterSummary, 'sceneCount' | 'wordCount'> { export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
notes: string | null notes: string | null
beats: Beat[]
scenes: Scene[] scenes: Scene[]
updatedAt: string updatedAt: string
} }
@@ -0,0 +1,92 @@
import { useState } from 'react'
import type { Tag } from '../api/types'
export function TagChip({ tag, onRemove }: { tag: Tag; onRemove?: () => void }) {
const tone = tag.color ?? 'var(--accent)'
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: tone, background: `color-mix(in srgb, ${tone} 14%, transparent)` }}
>
{tag.name}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove tag ${tag.name}`}
>
</button>
)}
</span>
)
}
/**
* Shows a set of tags and lets you add or remove them by name. The API creates unknown
* tags on the fly, so typing a new one is a single action rather than "create the tag,
* then apply it".
*/
export function TagEditor({
tags,
suggestions = [],
onChange,
label,
}: {
tags: Tag[]
suggestions?: string[]
onChange: (names: string[]) => void
label?: string
}) {
const [draft, setDraft] = useState('')
const listId = `tag-suggestions-${label ?? 'default'}`
const add = () => {
const name = draft.trim()
if (!name) return
// Case-insensitive, matching how the API resolves tag names.
if (!tags.some((t) => t.name.toLowerCase() === name.toLowerCase())) {
onChange([...tags.map((t) => t.name), name])
}
setDraft('')
}
const remove = (name: string) =>
onChange(tags.filter((t) => t.name !== name).map((t) => t.name))
const unused = suggestions.filter(
(s) => !tags.some((t) => t.name.toLowerCase() === s.toLowerCase()),
)
return (
<div>
{label && <span className="label">{label}</span>}
<div className="flex flex-wrap items-center gap-1.5">
{tags.map((tag) => (
<TagChip key={tag.id} tag={tag} onRemove={() => remove(tag.name)} />
))}
<input
className="input w-32 flex-1 px-2 py-0.5 text-xs"
value={draft}
list={listId}
placeholder="Add tag…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
<datalist id={listId}>
{unused.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
</div>
)
}
+271 -84
View File
@@ -3,31 +3,40 @@ import { Link, useNavigate, useParams } from 'react-router-dom'
import { import {
useChapter, useChapter,
useCharacters, useCharacters,
useCreateBeat,
useCreateScene, useCreateScene,
useDeleteBeat,
useDeleteChapter, useDeleteChapter,
useDeleteScene, useDeleteScene,
useReorderBeats,
useTags,
useUpdateBeat,
useUpdateChapter, useUpdateChapter,
useUpdateScene, useUpdateScene,
} from '../api/hooks' } from '../api/hooks'
import { draftStatuses, type Chapter, type Scene } from '../api/types' import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
import { TagEditor } from '../components/TagEditor'
export default function ChapterPage() { export default function ChapterPage() {
const { projectId = '', chapterId } = useParams() const { projectId = '', chapterId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const { data: chapter, isPending, error } = useChapter(chapterId) const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: characters } = useCharacters(projectId) const { data: characters } = useCharacters(projectId)
const { data: allTags } = useTags(projectId)
const update = useUpdateChapter(projectId) const update = useUpdateChapter(projectId)
const remove = useDeleteChapter(projectId) const remove = useDeleteChapter(projectId)
const createScene = useCreateScene(chapterId ?? '') const createBeat = useCreateBeat(chapterId, projectId)
const createScene = useCreateScene(chapterId)
if (isPending) return <Spinner label="Loading chapter" /> if (isPending) return <Spinner label="Loading chapter" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
if (!chapter) return null if (!chapter) return null
const patch = (body: Partial<Chapter>) => update.mutate({ id: chapter.id, ...body }) const patch = (body: Partial<Omit<Chapter, 'tags'>> & { tags?: string[] }) =>
const povOptions = ['—', ...(characters?.map((c) => c.name) ?? [])] update.mutate({ id: chapter.id, ...body })
const povValue = chapter.povCharacterName ?? '—'
const suggestions = allTags?.map((t) => t.name) ?? []
return ( return (
<div> <div>
@@ -66,48 +75,47 @@ export default function ChapterPage() {
</div> </div>
<div className="mt-4 grid gap-4 sm:grid-cols-2"> <div className="mt-4 grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="label">POV character</span>
<select
className="input"
value={chapter.povCharacterName ?? '—'}
onChange={(e) => {
const match = characters?.find((c) => c.name === e.target.value)
patch({ povCharacterId: match?.id ?? null })
}}
>
{['—', ...(characters?.map((c) => c.name) ?? [])].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<AutoField <AutoField
label="Summary" label="Setting"
value={chapter.summary} value={chapter.setting}
multiline onCommit={(setting) => patch({ setting })}
rows={3} />
serif </div>
onCommit={(summary) => patch({ summary })}
<div className="mt-4">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => patch({ tags })}
/> />
<div className="grid content-start gap-4">
<label className="block">
<span className="label">POV character</span>
<select
className="input"
value={povValue}
onChange={(e) => {
const match = characters?.find((c) => c.name === e.target.value)
patch({ povCharacterId: match?.id ?? null })
}}
>
{povOptions.map((name) => (
<option key={name}>{name}</option>
))}
</select>
</label>
<AutoField
label="Setting"
value={chapter.setting}
onCommit={(setting) => patch({ setting })}
/>
</div>
</div> </div>
<div className="mt-4 flex items-end justify-between gap-4"> <div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted"> <div className="text-sm muted">
{chapter.scenes.length} scenes ·{' '} {chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '}
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words {chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
</div> </div>
<button <button
className="btn" className="btn"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
onClick={() => { onClick={() => {
if (confirm(`Delete chapter “${chapter.title}” and its scenes?`)) { if (confirm(`Delete chapter “${chapter.title}” and everything in it?`)) {
remove.mutate(chapter.id, { remove.mutate(chapter.id, {
onSuccess: () => navigate(`/projects/${projectId}/chapters`), onSuccess: () => navigate(`/projects/${projectId}/chapters`),
}) })
@@ -119,22 +127,232 @@ export default function ChapterPage() {
</div> </div>
</section> </section>
<div className="mb-3 flex items-center justify-between"> {/* The outline: a paragraph, then the beat table. */}
<h2 className="text-lg font-semibold">Scenes</h2> <section className="mb-8">
<button <h2 className="mb-1 text-lg font-semibold">Outline</h2>
className="btn btn-primary" <p className="mb-3 text-sm muted">
onClick={() => createScene.mutate({ title: 'New scene' })} A paragraph on what the chapter does, then the beats that carry it.
disabled={createScene.isPending} </p>
>
Add scene
</button>
</div>
<ul className="grid gap-3"> <div className="card mb-4 p-4">
{chapter.scenes.map((scene) => ( <AutoField
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} /> value={chapter.summary}
))} multiline
</ul> rows={5}
serif
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
onCommit={(summary) => patch({ summary })}
/>
</div>
<BeatTable
chapter={chapter}
projectId={projectId}
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
suggestions={suggestions}
/>
<button
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
Add beat
</button>
{createBeat.error && (
<div className="mt-2">
<ErrorNote error={createBeat.error} />
</div>
)}
</section>
{/* The prose layer. */}
<section>
<div className="mb-3 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">Scenes</h2>
<p className="text-sm muted">Where the prose lives. Beats can be grouped under these.</p>
</div>
<button
className="btn"
onClick={() => createScene.mutate({ title: 'New scene' })}
disabled={createScene.isPending}
>
Add scene
</button>
</div>
<ul className="grid gap-3">
{chapter.scenes.map((scene) => (
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} />
))}
</ul>
</section>
</div>
)
}
function BeatTable({
chapter,
projectId,
characters,
suggestions,
}: {
chapter: Chapter
projectId: string
characters: { id: string; name: string }[]
suggestions: string[]
}) {
const update = useUpdateBeat(chapter.id, projectId)
const remove = useDeleteBeat(chapter.id)
const reorder = useReorderBeats(chapter.id)
if (chapter.beats.length === 0) {
return (
<div className="card px-6 py-8 text-center text-sm muted">
No beats yet. Each one is a short handle she burns the atlas plus what happened and
what it sets up.
</div>
)
}
const move = (index: number, delta: number) => {
const ids = chapter.beats.map((b) => b.id)
const target = index + delta
if (target < 0 || target >= ids.length) return
;[ids[index], ids[target]] = [ids[target], ids[index]]
reorder.mutate(ids)
}
const patch = (id: string, body: Partial<Omit<Beat, 'tags'>> & { tags?: string[] }) =>
update.mutate({ id, ...body })
return (
<div className="card overflow-x-auto">
<table className="w-full min-w-[64rem] border-collapse text-sm">
<thead>
<tr style={{ borderBottom: '1px solid var(--line)' }}>
<th className="w-16 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
<th className="w-56 px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
<th className="w-36 px-2 py-2 text-left text-xs font-semibold uppercase muted">
Character
</th>
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">
What happened
</th>
<th className="px-2 py-2 text-left text-xs font-semibold uppercase muted">What&apos;s next</th>
<th className="w-32 px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
<th className="w-8" />
</tr>
</thead>
<tbody>
{chapter.beats.map((beat, index) => (
<tr key={beat.id} style={{ borderBottom: '1px solid var(--line)' }}>
<td className="px-2 py-2 align-top">
<div className="flex items-center gap-1">
<span className="w-4 text-xs muted">{index + 1}</span>
<div className="flex flex-col">
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, -1)}
disabled={index === 0 || reorder.isPending}
aria-label="Move beat up"
>
</button>
<button
className="text-xs leading-none muted disabled:opacity-25"
onClick={() => move(index, 1)}
disabled={index === chapter.beats.length - 1 || reorder.isPending}
aria-label="Move beat down"
>
</button>
</div>
</div>
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.title}
placeholder="Three to five words"
onCommit={(title) => title.trim() && patch(beat.id, { title })}
/>
<div className="mt-1.5">
<TagEditor
tags={beat.tags}
suggestions={suggestions}
onChange={(tags) => patch(beat.id, { tags })}
/>
</div>
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.characterName ?? '—'}
onChange={(e) => {
const match = characters.find((c) => c.name === e.target.value)
patch(beat.id, { characterId: match?.id ?? null })
}}
>
{['—', ...characters.map((c) => c.name)].map((name) => (
<option key={name}>{name}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.whatHappened}
multiline
rows={3}
serif
placeholder="The event itself."
onCommit={(whatHappened) => patch(beat.id, { whatHappened })}
/>
</td>
<td className="px-2 py-2 align-top">
<AutoField
value={beat.whatsNext}
multiline
rows={3}
serif
placeholder="What it sets in motion."
onCommit={(whatsNext) => patch(beat.id, { whatsNext })}
/>
</td>
<td className="px-2 py-2 align-top">
<select
className="input"
value={beat.sceneTitle ?? '—'}
onChange={(e) => {
const match = chapter.scenes.find((s) => s.title === e.target.value)
patch(beat.id, { sceneId: match?.id ?? null })
}}
>
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
<option key={title}>{title}</option>
))}
</select>
</td>
<td className="px-2 py-2 align-top">
<button
className="text-xs muted transition hover:opacity-100"
style={{ color: 'var(--accent)' }}
onClick={() => confirm(`Delete beat “${beat.title}”?`) && remove.mutate(beat.id)}
aria-label={`Delete beat ${beat.title}`}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div> </div>
) )
} }
@@ -148,45 +366,14 @@ function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) {
return ( return (
<li className="card p-4"> <li className="card p-4">
<div className="grid gap-3 sm:grid-cols-[1fr_9rem]"> <div className="grid gap-3 sm:grid-cols-[1fr_9rem]">
<AutoField <AutoField value={scene.title} onCommit={(title) => title.trim() && patch({ title })} />
value={scene.title}
onCommit={(title) => title.trim() && patch({ title })}
/>
<Select value={scene.status} options={draftStatuses} onChange={(status) => patch({ status })} /> <Select value={scene.status} options={draftStatuses} onChange={(status) => patch({ status })} />
</div> </div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<AutoField
label="Goal"
value={scene.goal}
multiline
rows={2}
placeholder="What they want here."
onCommit={(goal) => patch({ goal })}
/>
<AutoField
label="Conflict"
value={scene.conflict}
multiline
rows={2}
placeholder="What gets in the way."
onCommit={(conflict) => patch({ conflict })}
/>
<AutoField
label="Outcome"
value={scene.outcome}
multiline
rows={2}
placeholder="How it lands, and what it costs."
onCommit={(outcome) => patch({ outcome })}
/>
</div>
<div className="mt-3 flex items-center justify-between gap-3 text-xs muted"> <div className="mt-3 flex items-center justify-between gap-3 text-xs muted">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<StatusBadge status={scene.status} /> <StatusBadge status={scene.status} />
<span>{scene.wordCount.toLocaleString()} words</span> <span>{scene.wordCount.toLocaleString()} words</span>
{scene.povCharacterName && <span>POV: {scene.povCharacterName}</span>}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button className="btn px-2 py-1 text-xs" onClick={() => setShowProse((v) => !v)}> <button className="btn px-2 py-1 text-xs" onClick={() => setShowProse((v) => !v)}>
@@ -209,7 +396,7 @@ function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) {
multiline multiline
rows={16} rows={16}
serif serif
placeholder="The scene itself. Ask the agent to draft from the beats above if you would rather start from something." placeholder="The scene itself. The beats grouped under it are the plan; this is the prose."
onCommit={(prose) => patch({ prose })} onCommit={(prose) => patch({ prose })}
/> />
</div> </div>
@@ -1,6 +1,7 @@
import { Link, useParams } from 'react-router-dom' import { Link, useParams } from 'react-router-dom'
import { useChapters, useCreateChapter } from '../api/hooks' import { useChapters, useCreateChapter } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui' import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { TagChip } from '../components/TagEditor'
export default function ChaptersPage() { export default function ChaptersPage() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
@@ -28,7 +29,7 @@ export default function ChaptersPage() {
{chapters?.length === 0 ? ( {chapters?.length === 0 ? (
<EmptyState <EmptyState
title="No chapters yet" title="No chapters yet"
hint="Chapters hold scenes, and scenes hold the prose. Add one and start breaking it down." hint="A chapter is a summary paragraph plus a table of beats. Add one and start outlining."
/> />
) : ( ) : (
<ul className="grid gap-2"> <ul className="grid gap-2">
@@ -44,9 +45,17 @@ export default function ChaptersPage() {
<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>
{chapter.summary && <div className="truncate text-sm muted">{chapter.summary}</div>} {chapter.summary && <div className="truncate text-sm muted">{chapter.summary}</div>}
{chapter.tags.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{chapter.tags.map((tag) => (
<TagChip key={tag.id} tag={tag} />
))}
</div>
)}
</div> </div>
<div className="flex shrink-0 items-center gap-3 text-xs muted"> <div className="flex shrink-0 items-center gap-3 text-xs muted">
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>} {chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
<span>{chapter.beatCount} beats</span>
<span>{chapter.sceneCount} scenes</span> <span>{chapter.sceneCount} scenes</span>
<span>{chapter.wordCount.toLocaleString()} words</span> <span>{chapter.wordCount.toLocaleString()} words</span>
<StatusBadge status={chapter.status} /> <StatusBadge status={chapter.status} />
@@ -4,10 +4,12 @@ import {
useCharacters, useCharacters,
useCreateCharacter, useCreateCharacter,
useDeleteCharacter, useDeleteCharacter,
useTags,
useUpdateCharacter, useUpdateCharacter,
} from '../api/hooks' } from '../api/hooks'
import { characterRoles, type Character } from '../api/types' import { characterRoles, type Character } from '../api/types'
import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Modal, Select, Spinner } from '../components/ui'
import { TagEditor } from '../components/TagEditor'
export default function CharactersPage() { export default function CharactersPage() {
const { projectId = '' } = useParams() const { projectId = '' } = useParams()
@@ -66,9 +68,11 @@ export default function CharactersPage() {
} }
function CharacterSheet({ projectId, character }: { projectId: string; character: Character }) { function CharacterSheet({ projectId, character }: { projectId: string; character: Character }) {
const { data: allTags } = useTags(projectId)
const update = useUpdateCharacter(projectId) const update = useUpdateCharacter(projectId)
const remove = useDeleteCharacter(projectId) const remove = useDeleteCharacter(projectId)
const patch = (body: Partial<Character>) => update.mutate({ id: character.id, ...body }) const patch = (body: Partial<Omit<Character, 'tags'>> & { tags?: string[] }) =>
update.mutate({ id: character.id, ...body })
return ( return (
<div className="card p-5"> <div className="card p-5">
@@ -111,6 +115,15 @@ function CharacterSheet({ projectId, character }: { projectId: string; character
/> />
</div> </div>
<div className="mt-5">
<TagEditor
label="Tags"
tags={character.tags}
suggestions={allTags?.map((t) => t.name) ?? []}
onChange={(tags) => patch({ tags })}
/>
</div>
<div className="mt-6 grid gap-4 lg:grid-cols-2"> <div className="mt-6 grid gap-4 lg:grid-cols-2">
<AutoField <AutoField
label="Wants" label="Wants"
@@ -1,139 +0,0 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import {
useCreateOutlineNode,
useDeleteOutlineNode,
useOutline,
useUpdateOutlineNode,
} from '../api/hooks'
import { outlineNodeTypes, type OutlineNode, type OutlineNodeType } from '../api/types'
import { AutoField, EmptyState, ErrorNote, Select, Spinner } from '../components/ui'
export default function OutlinePage() {
const { projectId = '' } = useParams()
const { data: outline, isPending, error } = useOutline(projectId)
const create = useCreateOutlineNode(projectId)
if (isPending) return <Spinner label="Loading outline" />
if (error) return <ErrorNote error={error} />
return (
<div>
<div className="mb-5 flex items-center justify-between gap-4">
<div>
<h2 className="text-xl font-semibold">Outline</h2>
<p className="text-sm muted">
Nest freely acts under parts, beats under sequences, or a flat list of beats.
</p>
</div>
<button
className="btn btn-primary"
onClick={() => create.mutate({ title: 'New section', nodeType: 'Act' })}
>
Add top-level node
</button>
</div>
{create.error && <ErrorNote error={create.error} />}
{outline?.length === 0 ? (
<EmptyState
title="The outline is empty"
hint="Add three acts, then break each into the beats that carry it. The agent can draft a first pass if you ask it to."
/>
) : (
<ul className="grid gap-2">
{outline?.map((node) => (
<OutlineRow key={node.id} projectId={projectId} node={node} depth={0} />
))}
</ul>
)}
</div>
)
}
function OutlineRow({
projectId,
node,
depth,
}: {
projectId: string
node: OutlineNode
depth: number
}) {
const [expanded, setExpanded] = useState(depth < 2)
const update = useUpdateOutlineNode(projectId)
const remove = useDeleteOutlineNode(projectId)
const create = useCreateOutlineNode(projectId)
return (
<li style={{ marginLeft: depth * 20 }}>
<div className="card px-4 py-3">
<div className="flex items-start gap-3">
<button
className="mt-1 w-4 shrink-0 text-xs muted"
onClick={() => setExpanded((value) => !value)}
aria-label={expanded ? 'Collapse' : 'Expand'}
>
{node.children.length > 0 ? (expanded ? '▾' : '▸') : '·'}
</button>
<div className="grid flex-1 gap-2">
<div className="grid gap-2 sm:grid-cols-[1fr_9rem]">
<AutoField
value={node.title}
onCommit={(title) => title.trim() && update.mutate({ id: node.id, title })}
/>
<Select
value={node.nodeType}
options={outlineNodeTypes}
onChange={(nodeType: OutlineNodeType) => update.mutate({ id: node.id, nodeType })}
/>
</div>
<AutoField
value={node.summary}
multiline
rows={2}
serif
placeholder="What happens here, and what it changes."
onCommit={(summary) => update.mutate({ id: node.id, summary })}
/>
</div>
<div className="flex shrink-0 flex-col gap-1">
<button
className="btn px-2 py-1 text-xs"
title="Add a child node"
onClick={() =>
create.mutate({ title: 'New beat', nodeType: 'Beat', parentId: node.id })
}
>
+ Child
</button>
<button
className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }}
onClick={() => {
const warning =
node.children.length > 0
? `Delete “${node.title}” and its ${node.children.length} nested node(s)?`
: `Delete “${node.title}”?`
if (confirm(warning)) remove.mutate(node.id)
}}
>
Delete
</button>
</div>
</div>
</div>
{expanded && node.children.length > 0 && (
<ul className="mt-2 grid gap-2">
{node.children.map((child) => (
<OutlineRow key={child.id} projectId={projectId} node={child} depth={depth + 1} />
))}
</ul>
)}
</li>
)
}
@@ -4,9 +4,9 @@ import { ErrorNote, Spinner } from '../components/ui'
const tabs = [ const tabs = [
{ to: '', label: 'Overview', end: true }, { to: '', label: 'Overview', end: true },
{ to: 'outline', label: 'Outline' },
{ to: 'characters', label: 'Characters' }, { to: 'characters', label: 'Characters' },
{ to: 'chapters', label: 'Chapters' }, { to: 'chapters', label: 'Chapters' },
{ to: 'tags', label: 'Tags' },
{ to: 'agent', label: 'Agent' }, { to: 'agent', label: 'Agent' },
] ]
@@ -0,0 +1,180 @@
import { useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { useDeleteTag, useTagReferences, useTags, useUpdateTag } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { TagChip } from '../components/TagEditor'
export default function TagsPage() {
const { projectId = '' } = useParams()
const { data: tags, isPending, error } = useTags(projectId)
const [selectedId, setSelectedId] = useState<string | undefined>()
if (isPending) return <Spinner label="Loading tags" />
if (error) return <ErrorNote error={error} />
const selected = tags?.find((t) => t.id === selectedId) ?? tags?.[0]
return (
<div className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<aside className="grid content-start gap-2">
<div>
<h2 className="text-lg font-semibold">Tags</h2>
<p className="text-sm muted">
Applied from a character, chapter or beat. Pick one to see everything carrying it.
</p>
</div>
{tags?.length === 0 && (
<p className="mt-2 text-sm muted">
No tags yet. Add one from a character, chapter or beat and it will appear here.
</p>
)}
{tags?.map((tag) => (
<button
key={tag.id}
onClick={() => setSelectedId(tag.id)}
className="card flex items-center justify-between gap-2 px-3 py-2 text-left transition hover:shadow-sm"
style={
tag.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<TagChip tag={tag} />
<span className="text-xs muted">{tag.totalCount}</span>
</button>
))}
</aside>
<section>
{!selected ? (
<EmptyState
title="Nothing tagged yet"
hint="Tags cross-reference the book: attach one to a character, a chapter and a beat, then trace it from here."
/>
) : (
<TagReferencePanel key={selected.id} projectId={projectId} tagId={selected.id} />
)}
</section>
</div>
)
}
function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: string }) {
const { data, isPending, error } = useTagReferences(tagId)
const update = useUpdateTag(projectId)
const remove = useDeleteTag()
if (isPending) return <Spinner label="Loading references" />
if (error) return <ErrorNote error={error} />
if (!data) return null
const empty =
data.characters.length === 0 && data.chapters.length === 0 && data.beats.length === 0
return (
<div className="grid gap-4">
<div className="card flex flex-wrap items-end justify-between gap-3 p-4">
<label className="block">
<span className="label">Tag name</span>
<input
className="input w-64"
defaultValue={data.tag.name}
onBlur={(e) => {
const name = e.target.value.trim()
if (name && name !== data.tag.name) update.mutate({ id: tagId, name })
}}
/>
</label>
<label className="block">
<span className="label">Colour</span>
<input
className="input h-9 w-20 p-1"
type="color"
defaultValue={data.tag.color ?? '#9a4a2f'}
onBlur={(e) => update.mutate({ id: tagId, color: e.target.value })}
/>
</label>
<button
className="btn"
style={{ color: 'var(--accent)' }}
onClick={() =>
confirm(`Delete the tag “${data.tag.name}”? What carries it is left alone.`) &&
remove.mutate(tagId)
}
>
Delete tag
</button>
</div>
{update.error && <ErrorNote error={update.error} />}
{empty && (
<EmptyState
title="Nothing carries this tag"
hint="Apply it from a character, chapter or beat and it will show up here."
/>
)}
{data.characters.length > 0 && (
<div className="card p-4">
<h3 className="label">Characters</h3>
<ul className="grid gap-1 text-sm">
{data.characters.map((c) => (
<li key={c.id}>
<Link to={`/projects/${projectId}/characters`} className="hover:underline">
{c.name}
</Link>
<span className="muted"> {c.role}</span>
</li>
))}
</ul>
</div>
)}
{data.chapters.length > 0 && (
<div className="card p-4">
<h3 className="label">Chapters</h3>
<ul className="grid gap-1 text-sm">
{data.chapters.map((c) => (
<li key={c.id}>
<Link
to={`/projects/${projectId}/chapters/${c.id}`}
className="font-medium hover:underline"
>
{c.number}. {c.title}
</Link>
{c.summary && <span className="muted"> {c.summary}</span>}
</li>
))}
</ul>
</div>
)}
{data.beats.length > 0 && (
<div className="card p-4">
<h3 className="label">Beats</h3>
<ul className="grid gap-2 text-sm">
{data.beats.map((b) => (
<li key={b.id}>
<Link
to={`/projects/${projectId}/chapters/${b.chapterId}`}
className="font-medium hover:underline"
>
{b.title}
</Link>
<span className="muted">
{' '}
ch. {b.chapterNumber} {b.chapterTitle}, beat {b.sortOrder}
{b.characterName && `, ${b.characterName}`}
</span>
{b.whatHappened && <div className="prose-serif muted">{b.whatHappened}</div>}
</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -0,0 +1,159 @@
using FluentAssertions;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests;
public class BeatServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly BeatService _beats;
private readonly CharacterService _characters;
private readonly SceneService _scenes;
private readonly Guid _projectId;
private readonly Guid _chapterId;
public BeatServiceTests()
{
var tags = new TagService(_db.Context);
var projects = new ProjectService(_db.Context);
var chapters = new ChapterService(_db.Context, tags);
_characters = new CharacterService(_db.Context, tags);
_scenes = new SceneService(_db.Context);
_beats = new BeatService(_db.Context, tags);
_projectId = projects.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
_chapterId = chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall")).Result.Id;
}
[Fact]
public async Task Beats_are_appended_in_order_and_listed_that_way()
{
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("She finds the map"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("The harbour burns"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("She boards anyway"));
var listed = await _beats.ListAsync(_chapterId);
listed.Select(b => b.Title)
.Should().Equal("She finds the map", "The harbour burns", "She boards anyway");
listed.Select(b => b.SortOrder).Should().Equal(1, 2, 3);
}
[Fact]
public async Task Reordering_renumbers_to_match_the_order_given()
{
var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var second = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await _beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([third.Id, first.Id, second.Id]));
reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second");
}
[Fact]
public async Task Beats_left_out_of_a_reorder_keep_their_relative_position_at_the_end()
{
var first = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Second"));
var third = await _beats.CreateAsync(_chapterId, new CreateBeatRequest("Third"));
var reordered = await _beats.ReorderAsync(_chapterId, new ReorderBeatsRequest([third.Id, first.Id]));
reordered.Select(b => b.Title).Should().Equal("Third", "First", "Second");
}
[Fact]
public async Task Reordering_with_an_unknown_beat_is_refused()
{
await _beats.CreateAsync(_chapterId, new CreateBeatRequest("First"));
var reorder = async () => await _beats.ReorderAsync(
_chapterId, new ReorderBeatsRequest([Guid.NewGuid()]));
await reorder.Should().ThrowAsync<NotFoundException>();
}
[Fact]
public async Task A_beat_resolves_its_character_and_scene_names()
{
var ines = await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She burns the atlas",
CharacterId: ines.Id,
WhatHappened: "The pages go up faster than she expected.",
WhatsNext: "Nothing to navigate by but memory.",
SceneId: scene.Id));
beat.CharacterName.Should().Be("Ines");
beat.SceneTitle.Should().Be("The dock at dawn");
beat.WhatHappened.Should().Contain("faster than she expected");
}
[Fact]
public async Task A_beat_cannot_borrow_a_character_from_another_project()
{
var projects = new ProjectService(_db.Context);
var other = await projects.CreateAsync(new CreateProjectRequest("Other Book"));
var stranger = await _characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger"));
var create = async () => await _beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id));
await create.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*same project*");
}
[Fact]
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
{
var chapters = new ChapterService(_db.Context, new TagService(_db.Context));
var elsewhere = await chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
var scene = await _scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
var create = async () => await _beats.CreateAsync(
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id));
await create.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*same chapter*");
}
[Fact]
public async Task Deleting_a_scene_leaves_its_beats_alone()
{
var scene = await _scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
var beat = await _beats.CreateAsync(
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
await _scenes.DeleteAsync(scene.Id);
// The plan outlives a decision about prose — the beat is simply ungrouped.
var survivor = await _beats.GetAsync(beat.Id);
survivor.SceneId.Should().BeNull();
survivor.Title.Should().Be("She burns the atlas");
}
[Fact]
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
{
var beat = await _beats.CreateAsync(_chapterId, new CreateBeatRequest(
"She finds the map", WhatHappened: "Behind the lining of the case.", WhatsNext: "She books passage."));
var renamed = await _beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She finds it"));
renamed.WhatHappened.Should().Be("Behind the lining of the case.");
renamed.WhatsNext.Should().Be("She books passage.");
var cleared = await _beats.UpdateAsync(beat.Id, new UpdateBeatRequest(WhatsNext: ""));
cleared.WhatsNext.Should().BeNull();
cleared.WhatHappened.Should().Be("Behind the lining of the case.");
}
public void Dispose() => _db.Dispose();
}
+5 -3
View File
@@ -15,6 +15,7 @@ namespace NovelSoftware.Tests;
public class ListingTests : IDisposable public class ListingTests : IDisposable
{ {
private readonly TestDatabase _db = new(); private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly ProjectService _projects; private readonly ProjectService _projects;
private readonly ChapterService _chapters; private readonly ChapterService _chapters;
private readonly SceneService _scenes; private readonly SceneService _scenes;
@@ -22,10 +23,11 @@ public class ListingTests : IDisposable
public ListingTests() public ListingTests()
{ {
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context); _projects = new ProjectService(_db.Context);
_chapters = new ChapterService(_db.Context); _chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context); _scenes = new SceneService(_db.Context);
_characters = new CharacterService(_db.Context); _characters = new CharacterService(_db.Context, _tags);
} }
[Fact] [Fact]
@@ -97,7 +99,7 @@ public class ListingTests : IDisposable
var agent = new NovelAgentService( var agent = new NovelAgentService(
_db.Context, _db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(_projects, _characters, new OutlineService(_db.Context), _chapters, _scenes), new NovelAgentToolset(_projects, _characters, _chapters, new BeatService(_db.Context, _tags), _scenes, _tags),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance); NullLogger<NovelAgentService>.Instance);
@@ -11,20 +11,23 @@ namespace NovelSoftware.Tests;
public class NovelAgentServiceTests : IDisposable public class NovelAgentServiceTests : IDisposable
{ {
private readonly TestDatabase _db = new(); private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly ProjectService _projects; private readonly ProjectService _projects;
private readonly CharacterService _characters; private readonly CharacterService _characters;
private readonly NovelAgentToolset _toolset; private readonly NovelAgentToolset _toolset;
public NovelAgentServiceTests() public NovelAgentServiceTests()
{ {
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context); _projects = new ProjectService(_db.Context);
_characters = new CharacterService(_db.Context); _characters = new CharacterService(_db.Context, _tags);
_toolset = new NovelAgentToolset( _toolset = new NovelAgentToolset(
_projects, _projects,
_characters, _characters,
new OutlineService(_db.Context), new ChapterService(_db.Context, _tags),
new ChapterService(_db.Context), new BeatService(_db.Context, _tags),
new SceneService(_db.Context)); new SceneService(_db.Context),
_tags);
} }
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
@@ -1,121 +0,0 @@
using FluentAssertions;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
using NovelSoftware.Domain;
namespace NovelSoftware.Tests;
public class OutlineServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly OutlineService _outlines;
private readonly Guid _projectId;
public OutlineServiceTests()
{
_outlines = new OutlineService(_db.Context);
_projectId = new ProjectService(_db.Context)
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Fact]
public async Task Nested_nodes_come_back_as_a_tree()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Act One", OutlineNodeType.Act));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"She finds the map", OutlineNodeType.Beat, ParentId: act.Id));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"The harbour burns", OutlineNodeType.Beat, ParentId: act.Id));
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Should().ContainSingle();
tree[0].Title.Should().Be("Act One");
tree[0].Children.Select(c => c.Title)
.Should().Equal("She finds the map", "The harbour burns");
}
[Fact]
public async Task Sibling_order_follows_sort_order_not_insertion_order()
{
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Third", SortOrder: 30));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("First", SortOrder: 10));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Second", SortOrder: 20));
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Select(n => n.Title).Should().Equal("First", "Second", "Third");
}
[Fact]
public async Task Moving_a_node_under_its_own_descendant_is_rejected()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Sequence", ParentId: act.Id));
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Beat", ParentId: sequence.Id));
var move = async () => await _outlines.MoveAsync(act.Id, new MoveOutlineNodeRequest(beat.Id, 1));
await move.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*beneath its own descendant*");
}
[Fact]
public async Task A_node_cannot_be_its_own_parent()
{
var node = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var move = async () => await _outlines.MoveAsync(node.Id, new MoveOutlineNodeRequest(node.Id, 1));
await move.Should().ThrowAsync<InvalidOperationException>()
.WithMessage("*its own parent*");
}
[Fact]
public async Task Moving_to_the_root_detaches_from_the_old_parent()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var beat = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Beat", ParentId: act.Id));
await _outlines.MoveAsync(beat.Id, new MoveOutlineNodeRequest(null, 2));
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Should().HaveCount(2);
tree.Single(n => n.Title == "Act One").Children.Should().BeEmpty();
}
[Fact]
public async Task Deleting_a_node_takes_its_whole_subtree()
{
var act = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act One"));
var sequence = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest(
"Sequence", ParentId: act.Id));
await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Beat", ParentId: sequence.Id));
var survivor = await _outlines.CreateAsync(_projectId, new CreateOutlineNodeRequest("Act Two"));
await _outlines.DeleteAsync(act.Id);
var tree = await _outlines.GetTreeAsync(_projectId);
tree.Should().ContainSingle().Which.Id.Should().Be(survivor.Id);
using var verification = _db.CreateContext();
verification.OutlineNodes.Should().ContainSingle();
}
[Fact]
public async Task Creating_under_a_missing_parent_reports_not_found()
{
var create = async () => await _outlines.CreateAsync(_projectId,
new CreateOutlineNodeRequest("Orphan", ParentId: Guid.NewGuid()));
await create.Should().ThrowAsync<NotFoundException>();
}
public void Dispose() => _db.Dispose();
}
@@ -10,6 +10,7 @@ namespace NovelSoftware.Tests;
public class ProjectDataTests : IDisposable public class ProjectDataTests : IDisposable
{ {
private readonly TestDatabase _db = new(); private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly ProjectService _projects; private readonly ProjectService _projects;
private readonly CharacterService _characters; private readonly CharacterService _characters;
private readonly ChapterService _chapters; private readonly ChapterService _chapters;
@@ -17,9 +18,10 @@ public class ProjectDataTests : IDisposable
public ProjectDataTests() public ProjectDataTests()
{ {
_tags = new TagService(_db.Context);
_projects = new ProjectService(_db.Context); _projects = new ProjectService(_db.Context);
_characters = new CharacterService(_db.Context); _characters = new CharacterService(_db.Context, _tags);
_chapters = new ChapterService(_db.Context); _chapters = new ChapterService(_db.Context, _tags);
_scenes = new SceneService(_db.Context); _scenes = new SceneService(_db.Context);
} }
@@ -0,0 +1,174 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using NovelSoftware.Application;
using NovelSoftware.Application.Dtos;
using NovelSoftware.Application.Services;
namespace NovelSoftware.Tests;
public class TagServiceTests : IDisposable
{
private readonly TestDatabase _db = new();
private readonly TagService _tags;
private readonly CharacterService _characters;
private readonly ChapterService _chapters;
private readonly BeatService _beats;
private readonly Guid _projectId;
public TagServiceTests()
{
_tags = new TagService(_db.Context);
_characters = new CharacterService(_db.Context, _tags);
_chapters = new ChapterService(_db.Context, _tags);
_beats = new BeatService(_db.Context, _tags);
_projectId = new ProjectService(_db.Context)
.CreateAsync(new CreateProjectRequest("The Salt Road")).Result.Id;
}
[Fact]
public async Task Applying_an_unknown_tag_by_name_creates_it()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
character.Tags.Select(t => t.Name).Should().BeEquivalentTo(["betrayal", "the sea"]);
(await _tags.ListAsync(_projectId)).Should().HaveCount(2);
}
[Fact]
public async Task The_same_name_resolves_to_one_tag_regardless_of_casing()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["Betrayal"]));
var chapter = await _chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
var listed = await _tags.ListAsync(_projectId);
listed.Should().ContainSingle().Which.Name.Should().Be("Betrayal");
chapter.Tags.Should().ContainSingle().Which.Id.Should().Be(listed[0].Id);
}
[Fact]
public async Task Supplying_a_tag_list_replaces_the_existing_tags()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal", "the sea"]));
var updated = await _characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Tags: ["the sea", "maps"]));
updated.Tags.Select(t => t.Name).Should().BeEquivalentTo(["the sea", "maps"]);
}
[Fact]
public async Task Omitting_the_tag_list_leaves_tags_alone()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var updated = await _characters.UpdateAsync(
character.Id, new UpdateCharacterRequest(Occupation: "Cartographer"));
updated.Tags.Should().ContainSingle().Which.Name.Should().Be("betrayal");
updated.Occupation.Should().Be("Cartographer");
}
[Fact]
public async Task Cross_reference_gathers_everything_carrying_a_tag()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var chapter = await _chapters.CreateAsync(
_projectId, new CreateChapterRequest("Landfall", Tags: ["betrayal"]));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest(
"She burns the atlas", WhatHappened: "In the galley stove.", Tags: ["betrayal"]));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("Unrelated beat"));
var tagId = (await _tags.ListAsync(_projectId)).Single().Id;
var references = await _tags.GetReferencesAsync(tagId);
references.Characters.Should().ContainSingle().Which.Name.Should().Be("Ines");
references.Chapters.Should().ContainSingle().Which.Title.Should().Be("Landfall");
references.Beats.Should().ContainSingle();
references.Beats[0].Title.Should().Be("She burns the atlas");
references.Beats[0].ChapterTitle.Should().Be("Landfall");
references.Beats[0].ChapterNumber.Should().Be(1);
}
[Fact]
public async Task Usage_counts_are_reported_per_kind()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara", Tags: ["sea"]));
var chapter = await _chapters.CreateAsync(_projectId, new CreateChapterRequest("Landfall"));
await _beats.CreateAsync(chapter.Id, new CreateBeatRequest("A beat", Tags: ["sea"]));
var summary = (await _tags.ListAsync(_projectId)).Single();
summary.CharacterCount.Should().Be(2);
summary.ChapterCount.Should().Be(0);
summary.BeatCount.Should().Be(1);
summary.TotalCount.Should().Be(3);
}
[Fact]
public async Task Duplicate_tag_names_are_refused_on_create_and_rename()
{
await _tags.CreateAsync(_projectId, new CreateTagRequest("betrayal"));
var duplicate = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest("Betrayal"));
await duplicate.Should().ThrowAsync<InvalidOperationException>().WithMessage("*already has a tag*");
var other = await _tags.CreateAsync(_projectId, new CreateTagRequest("the sea"));
var rename = async () => await _tags.UpdateAsync(other.Id, new UpdateTagRequest(Name: "betrayal"));
await rename.Should().ThrowAsync<InvalidOperationException>().WithMessage("*already has a tag*");
}
[Fact]
public async Task Tags_are_scoped_to_their_project()
{
var otherProject = await new ProjectService(_db.Context)
.CreateAsync(new CreateProjectRequest("Other Book"));
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["sea"]));
await _characters.CreateAsync(otherProject.Id, new CreateCharacterRequest("Someone", Tags: ["sea"]));
(await _tags.ListAsync(_projectId)).Should().ContainSingle();
(await _tags.ListAsync(otherProject.Id)).Should().ContainSingle();
(await _db.CreateContext().Tags.CountAsync()).Should().Be(2);
}
[Fact]
public async Task Deleting_a_tag_leaves_what_carried_it_intact()
{
var character = await _characters.CreateAsync(
_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
var tagId = (await _tags.ListAsync(_projectId)).Single().Id;
await _tags.DeleteAsync(tagId);
var survivor = await _characters.GetAsync(character.Id);
survivor.Name.Should().Be("Ines");
survivor.Tags.Should().BeEmpty();
}
[Fact]
public async Task Deleting_a_project_takes_its_tags()
{
await _characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines", Tags: ["betrayal"]));
await new ProjectService(_db.Context).DeleteAsync(_projectId);
(await _db.CreateContext().Tags.CountAsync()).Should().Be(0);
}
[Fact]
public async Task A_blank_tag_name_is_refused()
{
var create = async () => await _tags.CreateAsync(_projectId, new CreateTagRequest(" "));
await create.Should().ThrowAsync<ArgumentException>();
}
public void Dispose() => _db.Dispose();
}