From 7678cc72754903011f1c49889d00d425e062ab69 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Thu, 6 Aug 2026 03:16:36 +0000 Subject: [PATCH] Replace the outline tree with chapter beat tables and tags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw --- README.md | 51 +- .../Endpoints/BeatEndpoints.cs | 49 ++ .../Endpoints/OutlineEndpoints.cs | 49 -- .../Endpoints/TagEndpoints.cs | 44 ++ src/NovelSoftware.Api/Program.cs | 3 +- .../Agent/JsonSchema.cs | 36 + .../Agent/NovelAgentToolset.cs | 147 ++-- .../Dtos/BeatDtos.cs | 59 ++ .../Dtos/ChapterDtos.cs | 25 +- .../Dtos/CharacterDtos.cs | 12 +- .../Dtos/OutlineDtos.cs | 33 - src/NovelSoftware.Application/Dtos/TagDtos.cs | 56 ++ .../INovelDbContext.cs | 3 +- .../Services/BeatService.cs | 163 +++++ .../Services/ChapterService.cs | 21 +- .../Services/CharacterService.cs | 19 +- .../Services/OutlineService.cs | 166 ----- .../Services/TagService.cs | 158 +++++ src/NovelSoftware.Domain/Entities/Beat.cs | 40 ++ src/NovelSoftware.Domain/Entities/Chapter.cs | 10 + .../Entities/Character.cs | 1 + .../Entities/OutlineNode.cs | 31 - src/NovelSoftware.Domain/Entities/Project.cs | 2 +- src/NovelSoftware.Domain/Entities/Tag.cs | 25 + src/NovelSoftware.Domain/Enums.cs | 14 - .../DependencyInjection.cs | 3 +- ...ReplaceOutlineWithBeatsAndTags.Designer.cs | 657 ++++++++++++++++++ ...06031243_ReplaceOutlineWithBeatsAndTags.cs | 257 +++++++ .../Migrations/NovelDbContextModelSnapshot.cs | 283 +++++--- .../Persistence/NovelDbContext.cs | 44 +- src/NovelSoftware.Mcp/Tools/BeatTools.cs | 71 ++ src/NovelSoftware.Mcp/Tools/CharacterTools.cs | 12 +- .../Tools/ManuscriptTools.cs | 15 +- src/NovelSoftware.Mcp/Tools/OutlineTools.cs | 72 -- src/NovelSoftware.Mcp/Tools/TagTools.cs | 56 ++ src/NovelSoftware.Web/src/App.tsx | 4 +- src/NovelSoftware.Web/src/api/hooks.ts | 99 ++- src/NovelSoftware.Web/src/api/types.ts | 74 +- .../src/components/TagEditor.tsx | 92 +++ .../src/pages/ChapterPage.tsx | 355 +++++++--- .../src/pages/ChaptersPage.tsx | 11 +- .../src/pages/CharactersPage.tsx | 15 +- .../src/pages/OutlinePage.tsx | 139 ---- .../src/pages/ProjectLayout.tsx | 2 +- src/NovelSoftware.Web/src/pages/TagsPage.tsx | 180 +++++ tests/NovelSoftware.Tests/BeatServiceTests.cs | 159 +++++ tests/NovelSoftware.Tests/ListingTests.cs | 8 +- .../NovelAgentServiceTests.cs | 11 +- .../OutlineServiceTests.cs | 121 ---- tests/NovelSoftware.Tests/ProjectDataTests.cs | 6 +- tests/NovelSoftware.Tests/TagServiceTests.cs | 174 +++++ 51 files changed, 3189 insertions(+), 948 deletions(-) create mode 100644 src/NovelSoftware.Api/Endpoints/BeatEndpoints.cs delete mode 100644 src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs create mode 100644 src/NovelSoftware.Api/Endpoints/TagEndpoints.cs create mode 100644 src/NovelSoftware.Application/Dtos/BeatDtos.cs delete mode 100644 src/NovelSoftware.Application/Dtos/OutlineDtos.cs create mode 100644 src/NovelSoftware.Application/Dtos/TagDtos.cs create mode 100644 src/NovelSoftware.Application/Services/BeatService.cs delete mode 100644 src/NovelSoftware.Application/Services/OutlineService.cs create mode 100644 src/NovelSoftware.Application/Services/TagService.cs create mode 100644 src/NovelSoftware.Domain/Entities/Beat.cs delete mode 100644 src/NovelSoftware.Domain/Entities/OutlineNode.cs create mode 100644 src/NovelSoftware.Domain/Entities/Tag.cs create mode 100644 src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs create mode 100644 src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs create mode 100644 src/NovelSoftware.Mcp/Tools/BeatTools.cs delete mode 100644 src/NovelSoftware.Mcp/Tools/OutlineTools.cs create mode 100644 src/NovelSoftware.Mcp/Tools/TagTools.cs create mode 100644 src/NovelSoftware.Web/src/components/TagEditor.tsx delete mode 100644 src/NovelSoftware.Web/src/pages/OutlinePage.tsx create mode 100644 src/NovelSoftware.Web/src/pages/TagsPage.tsx create mode 100644 tests/NovelSoftware.Tests/BeatServiceTests.cs delete mode 100644 tests/NovelSoftware.Tests/OutlineServiceTests.cs create mode 100644 tests/NovelSoftware.Tests/TagServiceTests.cs diff --git a/README.md b/README.md index 8115a19..99d1b5a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ and everything else keeps working. ### Tests ```bash -dotnet test # 31 tests +dotnet test # 44 tests 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 - ├── OutlineNode (self-nesting: Part > Act > Sequence > Beat) - ├── Chapter ── Scene (goal / conflict / outcome, prose, word count) + ├── Chapter ──┬── Beat (the outline: flat, ordered) + │ └── Scene (the prose) + ├── Tag (applied to characters, chapters and beats) └── AgentConversation ── AgentMessage ``` -The outline tree is deliberately loose — nest acts under parts, beats under sequences, or -keep a flat list of beats. An outline node can link to the chapter that realises it. +**A chapter outline is a paragraph plus a table.** The paragraph is the chapter's +`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 -when turning an outline into prose. Word counts are recomputed on every save. +| Column | What goes in it | +|---|---| +| 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 `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 -stops asking. It has 15 tools covering the brief, characters, the outline tree, chapters -and scenes — all of them going through the same application services the REST API uses. +stops asking. It has 18 tools covering the brief, characters, chapter outlines (beats), scenes and +tags — all of them going through the same application services the REST API uses. A few deliberate choices worth knowing about: @@ -113,7 +133,7 @@ A few deliberate choices worth knowing about: ## 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. 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}` | | 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}` | +| 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}` | +| 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}` | -`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 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: - 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. - Revision history for scene prose. - Authentication, if this is ever going to run anywhere but localhost. diff --git a/src/NovelSoftware.Api/Endpoints/BeatEndpoints.cs b/src/NovelSoftware.Api/Endpoints/BeatEndpoints.cs new file mode 100644 index 0000000..5e2fda1 --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/BeatEndpoints.cs @@ -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; + } +} diff --git a/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs b/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs deleted file mode 100644 index 6f567a9..0000000 --- a/src/NovelSoftware.Api/Endpoints/OutlineEndpoints.cs +++ /dev/null @@ -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; - } -} diff --git a/src/NovelSoftware.Api/Endpoints/TagEndpoints.cs b/src/NovelSoftware.Api/Endpoints/TagEndpoints.cs new file mode 100644 index 0000000..ba9877b --- /dev/null +++ b/src/NovelSoftware.Api/Endpoints/TagEndpoints.cs @@ -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; + } +} diff --git a/src/NovelSoftware.Api/Program.cs b/src/NovelSoftware.Api/Program.cs index f1c39dd..5752c4a 100644 --- a/src/NovelSoftware.Api/Program.cs +++ b/src/NovelSoftware.Api/Program.cs @@ -67,9 +67,10 @@ app.MapGet("/api/health", () => Results.Ok(new { status = "ok" })).WithTags("Hea app.MapProjectEndpoints() .MapCharacterEndpoints() - .MapOutlineEndpoints() .MapChapterEndpoints() + .MapBeatEndpoints() .MapSceneEndpoints() + .MapTagEndpoints() .MapAgentEndpoints(); app.Run(); diff --git a/src/NovelSoftware.Application/Agent/JsonSchema.cs b/src/NovelSoftware.Application/Agent/JsonSchema.cs index 3c7a181..81ffe33 100644 --- a/src/NovelSoftware.Application/Agent/JsonSchema.cs +++ b/src/NovelSoftware.Application/Agent/JsonSchema.cs @@ -21,6 +21,23 @@ public sealed class JsonSchemaBuilder public JsonSchemaBuilder Bool(string name, string description, bool required = false) => 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 values, bool required = false) { var node = new JsonObject @@ -97,6 +114,25 @@ public static class JsonInput }; } + /// + /// 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. + /// + public static IReadOnlyList? 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(JsonElement input, string name) where TEnum : struct, System.Enum => System.Enum.TryParse(String(input, name), ignoreCase: true, out var parsed) ? parsed : null; } diff --git a/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs b/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs index 6b0b182..99d1261 100644 --- a/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs +++ b/src/NovelSoftware.Application/Agent/NovelAgentToolset.cs @@ -20,9 +20,10 @@ public sealed record AgentTool( public class NovelAgentToolset( ProjectService projects, CharacterService characters, - OutlineService outlines, ChapterService chapters, - SceneService scenes) + BeatService beats, + SceneService scenes, + TagService tags) { private static readonly JsonSerializerOptions SerializerOptions = new() { @@ -127,7 +128,8 @@ public class NovelAgentToolset( JsonInput.String(input, "external_conflict"), JsonInput.String(input, "arc_summary"), JsonInput.String(input, "voice"), - JsonInput.String(input, "notes")), ct)); + JsonInput.String(input, "notes"), + JsonInput.Strings(input, "tags")), ct)); yield return new AgentTool( "update_character", @@ -152,66 +154,99 @@ public class NovelAgentToolset( JsonInput.String(input, "external_conflict"), JsonInput.String(input, "arc_summary"), JsonInput.String(input, "voice"), - JsonInput.String(input, "notes")), ct)); + JsonInput.String(input, "notes"), + JsonInput.Strings(input, "tags")), ct)); yield return new AgentTool( - "get_outline", - "Read the project's outline as a nested tree of parts, acts, sequences and beats.", - new JsonSchemaBuilder().Build(), - 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.", + "get_chapter_outline", + "Read a chapter's outline: its summary paragraph and its beat table, in order. " + + "A beat is one row — a short title, whose beat it is, what happened, and what it sets up.", new JsonSchemaBuilder() - .Str("title", "Short label for the node.", required: true) - .Enum("node_type", "Structural level of the node.", System.Enum.GetNames()) - .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.") + .Str("chapter_id", "Id of the chapter whose outline to read.", required: true) .Build(), - async (projectId, input, ct) => await outlines.CreateAsync(projectId, new CreateOutlineNodeRequest( - JsonInput.RequiredString(input, "title"), - JsonInput.Enum(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)); + async (_, input, ct) => await beats.ListAsync(JsonInput.RequiredGuid(input, "chapter_id"), ct)); yield return new AgentTool( - "update_outline_node", - "Revise an outline node's title, type, summary, position or linked chapter.", - new JsonSchemaBuilder() - .Str("node_id", "Id of the node to update.", required: true) - .Str("title", "New title.") - .Enum("node_type", "Structural level of the node.", System.Enum.GetNames()) - .Str("summary", "What happens here.") - .Int("sort_order", "Position among siblings.") - .Str("chapter_id", "Id of the chapter that realises this node.") + "create_beat", + "Add a beat to a chapter's outline. Keep the title to three to five words — it is a " + + "handle, not a sentence; the detail belongs in what_happened and whats_next.", + BeatSchema() + .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) + .Str("title", "Three to five words naming the beat.", required: true) .Build(), - async (_, input, ct) => await outlines.UpdateAsync( - JsonInput.RequiredGuid(input, "node_id"), - new UpdateOutlineNodeRequest( - JsonInput.String(input, "title"), - JsonInput.Enum(input, "node_type"), - JsonInput.String(input, "summary"), + async (_, input, ct) => await beats.CreateAsync( + JsonInput.RequiredGuid(input, "chapter_id"), + new CreateBeatRequest( + JsonInput.RequiredString(input, "title"), 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( - "delete_outline_node", - "Remove an outline node and everything nested beneath it. This cannot be undone, " - + "so confirm with the writer before calling it.", + "update_beat", + "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.", + 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() - .Str("node_id", "Id of the node to delete.", required: true) + .Str("beat_id", "Id of the beat to delete.", required: true) .Build(), 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 }; }); + 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( "list_chapters", "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.") .Enum("status", "Drafting status.", System.Enum.GetNames()) .Int("target_word_count", "Target length in words.") + .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), async (projectId, input, ct) => await chapters.CreateAsync(projectId, new CreateChapterRequest( JsonInput.RequiredString(input, "title"), @@ -247,7 +283,8 @@ public class NovelAgentToolset( JsonInput.String(input, "setting"), JsonInput.String(input, "notes"), JsonInput.Enum(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( "update_chapter", @@ -262,6 +299,7 @@ public class NovelAgentToolset( .Str("notes", "Anything else worth recording.") .Enum("status", "Drafting status.", System.Enum.GetNames()) .Int("target_word_count", "Target length in words.") + .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), async (_, input, ct) => await chapters.UpdateAsync( JsonInput.RequiredGuid(input, "chapter_id"), @@ -273,7 +311,8 @@ public class NovelAgentToolset( JsonInput.String(input, "setting"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status"), - JsonInput.Int(input, "target_word_count")), ct)); + JsonInput.Int(input, "target_word_count"), + JsonInput.Strings(input, "tags")), ct)); yield return new AgentTool( "create_scene", @@ -343,9 +382,19 @@ public class NovelAgentToolset( .Str("external_conflict", "What in the world opposes them.") .Str("arc_summary", "How they change over the course of the book.") .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() => new JsonSchemaBuilder() .Int("sort_order", "Position within the chapter. Appended to the end when omitted.") diff --git a/src/NovelSoftware.Application/Dtos/BeatDtos.cs b/src/NovelSoftware.Application/Dtos/BeatDtos.cs new file mode 100644 index 0000000..ae1cef8 --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/BeatDtos.cs @@ -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 Tags, + DateTimeOffset UpdatedAt); + +public record CreateBeatRequest( + string Title, + int? SortOrder = null, + Guid? CharacterId = null, + string? WhatHappened = null, + string? WhatsNext = null, + Guid? SceneId = null, + IReadOnlyList? Tags = null); + +/// +/// Patch-style update. A null field is left alone; an empty string clears it. Passing a +/// list replaces the beat's tags outright. +/// +public record UpdateBeatRequest( + string? Title = null, + int? SortOrder = null, + Guid? CharacterId = null, + string? WhatHappened = null, + string? WhatsNext = null, + Guid? SceneId = null, + IReadOnlyList? Tags = null); + +/// Reorders a chapter's beats in one call, by listing their ids in the order wanted. +public record ReorderBeatsRequest(IReadOnlyList 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); +} diff --git a/src/NovelSoftware.Application/Dtos/ChapterDtos.cs b/src/NovelSoftware.Application/Dtos/ChapterDtos.cs index 566ba9c..d32ed4f 100644 --- a/src/NovelSoftware.Application/Dtos/ChapterDtos.cs +++ b/src/NovelSoftware.Application/Dtos/ChapterDtos.cs @@ -14,9 +14,15 @@ public record ChapterSummaryDto( string? Setting, DraftStatus Status, int? TargetWordCount, + int BeatCount, int SceneCount, - int WordCount); + int WordCount, + IReadOnlyList Tags); +/// +/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table) +/// and the prose layer (scenes). +/// public record ChapterDto( Guid Id, Guid ProjectId, @@ -29,7 +35,9 @@ public record ChapterDto( string? Notes, DraftStatus Status, int? TargetWordCount, + IReadOnlyList Beats, IReadOnlyList Scenes, + IReadOnlyList Tags, DateTimeOffset UpdatedAt); public record CreateChapterRequest( @@ -40,8 +48,13 @@ public record CreateChapterRequest( string? Setting = null, string? Notes = null, DraftStatus Status = DraftStatus.Planned, - int? TargetWordCount = null); + int? TargetWordCount = null, + IReadOnlyList? Tags = null); +/// +/// Patch-style update. A null field is left alone; an empty string clears it. Passing a +/// list replaces the chapter's tags outright. +/// public record UpdateChapterRequest( string? Title = null, int? Number = null, @@ -50,7 +63,8 @@ public record UpdateChapterRequest( string? Setting = null, string? Notes = null, DraftStatus? Status = null, - int? TargetWordCount = null); + int? TargetWordCount = null, + IReadOnlyList? Tags = null); public static class ChapterMapping { @@ -58,11 +72,14 @@ public static class ChapterMapping c.Id, c.ProjectId, c.Number, c.Title, c.Summary, c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, 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.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())], c.UpdatedAt); public static ChapterSummaryDto ToSummaryDto(this Chapter c) => new( c.Id, c.ProjectId, c.Number, c.Title, c.Summary, 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())]); } diff --git a/src/NovelSoftware.Application/Dtos/CharacterDtos.cs b/src/NovelSoftware.Application/Dtos/CharacterDtos.cs index 001812a..7ac61a5 100644 --- a/src/NovelSoftware.Application/Dtos/CharacterDtos.cs +++ b/src/NovelSoftware.Application/Dtos/CharacterDtos.cs @@ -22,6 +22,7 @@ public record CharacterDto( string? Voice, string? Notes, IReadOnlyList Relationships, + IReadOnlyList Tags, DateTimeOffset UpdatedAt); public record RelationshipDto( @@ -46,8 +47,13 @@ public record CreateCharacterRequest( string? ExternalConflict = null, string? ArcSummary = null, string? Voice = null, - string? Notes = null); + string? Notes = null, + IReadOnlyList? Tags = null); +/// +/// Patch-style update. A null field is left alone; an empty string clears it. Passing a +/// list replaces the character's tags outright. +/// public record UpdateCharacterRequest( string? Name = null, CharacterRole? Role = null, @@ -63,7 +69,8 @@ public record UpdateCharacterRequest( string? ExternalConflict = null, string? ArcSummary = null, string? Voice = null, - string? Notes = null); + string? Notes = null, + IReadOnlyList? Tags = null); public record CreateRelationshipRequest( Guid RelatedCharacterId, @@ -82,5 +89,6 @@ public static class CharacterMapping r.RelatedCharacter?.Name ?? "(unknown)", r.RelationshipType, r.Description))], + [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())], c.UpdatedAt); } diff --git a/src/NovelSoftware.Application/Dtos/OutlineDtos.cs b/src/NovelSoftware.Application/Dtos/OutlineDtos.cs deleted file mode 100644 index 7b283ab..0000000 --- a/src/NovelSoftware.Application/Dtos/OutlineDtos.cs +++ /dev/null @@ -1,33 +0,0 @@ -using NovelSoftware.Domain; - -namespace NovelSoftware.Application.Dtos; - -/// An outline node with its subtree inlined — the shape the outline view renders. -public record OutlineNodeDto( - Guid Id, - Guid ProjectId, - Guid? ParentId, - OutlineNodeType NodeType, - string Title, - string? Summary, - int SortOrder, - Guid? ChapterId, - IReadOnlyList 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); - -/// Moves a node to a new parent and/or position. A null means root level. -public record MoveOutlineNodeRequest(Guid? ParentId, int SortOrder); diff --git a/src/NovelSoftware.Application/Dtos/TagDtos.cs b/src/NovelSoftware.Application/Dtos/TagDtos.cs new file mode 100644 index 0000000..47fed27 --- /dev/null +++ b/src/NovelSoftware.Application/Dtos/TagDtos.cs @@ -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); + +/// +/// 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. +/// +public record TagReferencesDto( + TagDto Tag, + IReadOnlyList Characters, + IReadOnlyList Chapters, + IReadOnlyList 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); + + /// + /// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and + /// "betrayal" resolve to one tag rather than quietly becoming two. + /// + public static string Normalise(string name) => name.Trim(); +} diff --git a/src/NovelSoftware.Application/INovelDbContext.cs b/src/NovelSoftware.Application/INovelDbContext.cs index b2266d0..abc9dd9 100644 --- a/src/NovelSoftware.Application/INovelDbContext.cs +++ b/src/NovelSoftware.Application/INovelDbContext.cs @@ -12,7 +12,8 @@ public interface INovelDbContext DbSet Projects { get; } DbSet Characters { get; } DbSet CharacterRelationships { get; } - DbSet OutlineNodes { get; } + DbSet Beats { get; } + DbSet Tags { get; } DbSet Chapters { get; } DbSet Scenes { get; } DbSet Conversations { get; } diff --git a/src/NovelSoftware.Application/Services/BeatService.cs b/src/NovelSoftware.Application/Services/BeatService.cs new file mode 100644 index 0000000..2f0d4e6 --- /dev/null +++ b/src/NovelSoftware.Application/Services/BeatService.cs @@ -0,0 +1,163 @@ +using Microsoft.EntityFrameworkCore; +using NovelSoftware.Application.Dtos; +using NovelSoftware.Domain.Entities; + +namespace NovelSoftware.Application.Services; + +/// +/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything +/// here is scoped to one chapter. +/// +public class BeatService(INovelDbContext db, TagService tags) +{ + public async Task> 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 GetAsync(Guid id, CancellationToken ct = default) => + (await FindAsync(id, ct)).ToDto(); + + public async Task 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 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); + } + + /// + /// 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. + /// + public async Task> 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 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 Query() => + db.Beats + .Include(b => b.Character) + .Include(b => b.Scene) + .Include(b => b.Tags); + + private async Task FindAsync(Guid id, CancellationToken ct) => + await Query().FirstOrDefaultAsync(b => b.Id == id, ct) + ?? throw new NotFoundException(nameof(Beat), id); +} diff --git a/src/NovelSoftware.Application/Services/ChapterService.cs b/src/NovelSoftware.Application/Services/ChapterService.cs index 90b0191..bc3b879 100644 --- a/src/NovelSoftware.Application/Services/ChapterService.cs +++ b/src/NovelSoftware.Application/Services/ChapterService.cs @@ -4,13 +4,15 @@ using NovelSoftware.Domain.Entities; namespace NovelSoftware.Application.Services; -public class ChapterService(INovelDbContext db) +public class ChapterService(INovelDbContext db, TagService tags) { public async Task> ListAsync(Guid projectId, CancellationToken ct = default) { var chapters = await db.Chapters .Include(c => c.PovCharacter) + .Include(c => c.Beats) .Include(c => c.Scenes) + .Include(c => c.Tags) .Where(c => c.ProjectId == projectId) .OrderBy(c => c.Number) .ToListAsync(ct); @@ -41,6 +43,11 @@ public class ChapterService(INovelDbContext db) TargetWordCount = request.TargetWordCount }; + if (request.Tags is { } names) + { + chapter.Tags = await tags.ResolveAsync(projectId, names, ct); + } + db.Chapters.Add(chapter); await db.SaveChangesAsync(ct); return (await FindAsync(chapter.Id, ct)).ToDto(); @@ -60,6 +67,11 @@ public class ChapterService(INovelDbContext db) chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; chapter.UpdatedAt = DateTimeOffset.UtcNow; + if (request.Tags is { } names) + { + chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); + } + await db.SaveChangesAsync(ct); return (await FindAsync(id, ct)).ToDto(); } @@ -83,8 +95,11 @@ public class ChapterService(INovelDbContext db) private async Task FindAsync(Guid id, CancellationToken ct) => await db.Chapters .Include(c => c.PovCharacter) - .Include(c => c.Scenes) - .ThenInclude(s => s.PovCharacter) + .Include(c => c.Beats).ThenInclude(b => b.Character) + .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) ?? throw new NotFoundException(nameof(Chapter), id); } diff --git a/src/NovelSoftware.Application/Services/CharacterService.cs b/src/NovelSoftware.Application/Services/CharacterService.cs index 5b68c57..23b5665 100644 --- a/src/NovelSoftware.Application/Services/CharacterService.cs +++ b/src/NovelSoftware.Application/Services/CharacterService.cs @@ -4,7 +4,7 @@ using NovelSoftware.Domain.Entities; namespace NovelSoftware.Application.Services; -public class CharacterService(INovelDbContext db) +public class CharacterService(INovelDbContext db, TagService tags) { public async Task> ListAsync(Guid projectId, CancellationToken ct = default) { @@ -44,9 +44,14 @@ public class CharacterService(INovelDbContext db) Notes = request.Notes }; + if (request.Tags is { } names) + { + character.Tags = await tags.ResolveAsync(projectId, names, ct); + } + db.Characters.Add(character); await db.SaveChangesAsync(ct); - return character.ToDto(); + return (await FindAsync(character.Id, ct)).ToDto(); } public async Task 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.UpdatedAt = DateTimeOffset.UtcNow; + if (request.Tags is { } names) + { + character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct); + } + await db.SaveChangesAsync(ct); - return character.ToDto(); + return (await FindAsync(id, ct)).ToDto(); } public async Task DeleteAsync(Guid id, CancellationToken ct = default) @@ -120,7 +130,8 @@ public class CharacterService(INovelDbContext db) private IQueryable Query() => db.Characters .Include(c => c.Relationships) - .ThenInclude(r => r.RelatedCharacter); + .ThenInclude(r => r.RelatedCharacter) + .Include(c => c.Tags); private async Task FindAsync(Guid id, CancellationToken ct) => await Query().FirstOrDefaultAsync(c => c.Id == id, ct) diff --git a/src/NovelSoftware.Application/Services/OutlineService.cs b/src/NovelSoftware.Application/Services/OutlineService.cs deleted file mode 100644 index 3f3a5b6..0000000 --- a/src/NovelSoftware.Application/Services/OutlineService.cs +++ /dev/null @@ -1,166 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using NovelSoftware.Application.Dtos; -using NovelSoftware.Domain.Entities; - -namespace NovelSoftware.Application.Services; - -public class OutlineService(INovelDbContext db) -{ - /// Returns the project's outline as a tree of root nodes with children inlined. - public async Task> 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 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 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 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); - } - - /// - /// Reparents a node. Refuses to move a node under one of its own descendants, which - /// would detach the subtree from the tree entirely. - /// - public async Task 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); - } - - /// Deletes a node and its entire subtree. - 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 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 FindAsync(Guid id, CancellationToken ct) => - await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct) - ?? throw new NotFoundException(nameof(OutlineNode), id); - - private static IReadOnlyList BuildTree(List 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 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 DescendantIds(List all, Guid rootId) - { - var frontier = new Queue([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); - } - } - } -} diff --git a/src/NovelSoftware.Application/Services/TagService.cs b/src/NovelSoftware.Application/Services/TagService.cs new file mode 100644 index 0000000..06cc927 --- /dev/null +++ b/src/NovelSoftware.Application/Services/TagService.cs @@ -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> 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); + + /// Everything in the project carrying this tag. + public async Task 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 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 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(); + } + + /// Deletes a tag. Whatever carried it keeps existing — only the label goes. + 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); + } + + /// + /// 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". + /// + internal async Task> ResolveAsync( + Guid projectId, IReadOnlyList 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(); + 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 FindByNameAsync(Guid projectId, string name, CancellationToken ct) => + await db.Tags.FirstOrDefaultAsync( + t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct); +} diff --git a/src/NovelSoftware.Domain/Entities/Beat.cs b/src/NovelSoftware.Domain/Entities/Beat.cs new file mode 100644 index 0000000..3baae4c --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/Beat.cs @@ -0,0 +1,40 @@ +namespace NovelSoftware.Domain.Entities; + +/// +/// 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 that +/// will eventually carry its prose. +/// +public class Beat +{ + public Guid Id { get; set; } = Guid.NewGuid(); + + public Guid ChapterId { get; set; } + public Chapter? Chapter { get; set; } + + /// Optional grouping: the scene this beat will be written into. + public Guid? SceneId { get; set; } + public Scene? Scene { get; set; } + + /// Position within the chapter. Gaps are allowed. + public int SortOrder { get; set; } + + /// A three-to-five word handle for the beat, not a sentence. + public string Title { get; set; } = string.Empty; + + /// Whose beat this is. Optional — not every beat belongs to one person. + public Guid? CharacterId { get; set; } + public Character? Character { get; set; } + + /// The event itself. + public string? WhatHappened { get; set; } + + /// What it sets in motion — the hook into the next beat. + public string? WhatsNext { get; set; } + + public List Tags { get; set; } = []; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/NovelSoftware.Domain/Entities/Chapter.cs b/src/NovelSoftware.Domain/Entities/Chapter.cs index e31108c..7f019bc 100644 --- a/src/NovelSoftware.Domain/Entities/Chapter.cs +++ b/src/NovelSoftware.Domain/Entities/Chapter.cs @@ -11,6 +11,10 @@ public class Chapter public int Number { get; set; } public string Title { get; set; } = string.Empty; + + /// + /// The paragraph that opens the chapter's outline, above the beat table. + /// public string? Summary { get; set; } /// Whose head we are in for this chapter. @@ -26,5 +30,11 @@ public class Chapter public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; + /// The chapter's outline: an ordered, flat list of beats. + public List Beats { get; set; } = []; + + /// The prose layer. Beats may optionally be grouped under these. public List Scenes { get; set; } = []; + + public List Tags { get; set; } = []; } diff --git a/src/NovelSoftware.Domain/Entities/Character.cs b/src/NovelSoftware.Domain/Entities/Character.cs index 709e8b2..8ccb3c3 100644 --- a/src/NovelSoftware.Domain/Entities/Character.cs +++ b/src/NovelSoftware.Domain/Entities/Character.cs @@ -42,6 +42,7 @@ public class Character public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public List Relationships { get; set; } = []; + public List Tags { get; set; } = []; } /// A directed relationship from one character to another. diff --git a/src/NovelSoftware.Domain/Entities/OutlineNode.cs b/src/NovelSoftware.Domain/Entities/OutlineNode.cs deleted file mode 100644 index a5200f7..0000000 --- a/src/NovelSoftware.Domain/Entities/OutlineNode.cs +++ /dev/null @@ -1,31 +0,0 @@ -namespace NovelSoftware.Domain.Entities; - -/// -/// 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. -/// -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 Children { get; set; } = []; - - public OutlineNodeType NodeType { get; set; } = OutlineNodeType.Beat; - - public string Title { get; set; } = string.Empty; - public string? Summary { get; set; } - - /// Position among siblings. Gaps are allowed; ordering is by this value then title. - public int SortOrder { get; set; } - - /// Optional link to the chapter that realises this outline node. - public Guid? ChapterId { get; set; } - public Chapter? Chapter { get; set; } - - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; -} diff --git a/src/NovelSoftware.Domain/Entities/Project.cs b/src/NovelSoftware.Domain/Entities/Project.cs index 8ec6a55..44f174a 100644 --- a/src/NovelSoftware.Domain/Entities/Project.cs +++ b/src/NovelSoftware.Domain/Entities/Project.cs @@ -25,6 +25,6 @@ public class Project public List Characters { get; set; } = []; public List Chapters { get; set; } = []; - public List OutlineNodes { get; set; } = []; + public List Tags { get; set; } = []; public List Conversations { get; set; } = []; } diff --git a/src/NovelSoftware.Domain/Entities/Tag.cs b/src/NovelSoftware.Domain/Entities/Tag.cs new file mode 100644 index 0000000..c7e4ab8 --- /dev/null +++ b/src/NovelSoftware.Domain/Entities/Tag.cs @@ -0,0 +1,25 @@ +namespace NovelSoftware.Domain.Entities; + +/// +/// 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. +/// +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; + + /// Optional hex colour for the UI, e.g. "#9a4a2f". + public string? Color { get; set; } + + public List Characters { get; set; } = []; + public List Chapters { get; set; } = []; + public List Beats { get; set; } = []; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/src/NovelSoftware.Domain/Enums.cs b/src/NovelSoftware.Domain/Enums.cs index 22f5b5d..f6f8f80 100644 --- a/src/NovelSoftware.Domain/Enums.cs +++ b/src/NovelSoftware.Domain/Enums.cs @@ -13,20 +13,6 @@ public enum CharacterRole Foil } -/// -/// 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. -/// -public enum OutlineNodeType -{ - Part, - Act, - Sequence, - Chapter, - Beat, - Note -} - /// How far along a chapter or scene is in the drafting pipeline. public enum DraftStatus { diff --git a/src/NovelSoftware.Infrastructure/DependencyInjection.cs b/src/NovelSoftware.Infrastructure/DependencyInjection.cs index 4e82b90..b024735 100644 --- a/src/NovelSoftware.Infrastructure/DependencyInjection.cs +++ b/src/NovelSoftware.Infrastructure/DependencyInjection.cs @@ -21,7 +21,8 @@ public static class DependencyInjection services.AddScoped(); services.AddScoped(); - services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs new file mode 100644 index 0000000..1fc78ee --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.Designer.cs @@ -0,0 +1,657 @@ +// +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 + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SceneId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Setting") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ProjectId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Projects"); + }); + + modelBuilder.Entity("NovelSoftware.Domain.Entities.Scene", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("Conflict") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Goal") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Outcome") + .HasColumnType("TEXT"); + + b.Property("PovCharacterId") + .HasColumnType("TEXT"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs new file mode 100644 index 0000000..57c7e89 --- /dev/null +++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/20260806031243_ReplaceOutlineWithBeatsAndTags.cs @@ -0,0 +1,257 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace NovelSoftware.Infrastructure.Persistence.Migrations +{ + /// + public partial class ReplaceOutlineWithBeatsAndTags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "OutlineNodes"); + + migrationBuilder.CreateTable( + name: "Beats", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ChapterId = table.Column(type: "TEXT", nullable: false), + SceneId = table.Column(type: "TEXT", nullable: true), + SortOrder = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", maxLength: 200, nullable: false), + CharacterId = table.Column(type: "TEXT", nullable: true), + WhatHappened = table.Column(type: "TEXT", nullable: true), + WhatsNext = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + UpdatedAt = table.Column(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(type: "TEXT", nullable: false), + ProjectId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 64, nullable: false), + Color = table.Column(type: "TEXT", maxLength: 16, nullable: true), + CreatedAt = table.Column(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(type: "TEXT", nullable: false), + TagsId = table.Column(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(type: "TEXT", nullable: false), + TagsId = table.Column(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(type: "TEXT", nullable: false), + TagsId = table.Column(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); + } + + /// + 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(type: "TEXT", nullable: false), + ChapterId = table.Column(type: "TEXT", nullable: true), + ParentId = table.Column(type: "TEXT", nullable: true), + ProjectId = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + NodeType = table.Column(type: "TEXT", maxLength: 32, nullable: false), + SortOrder = table.Column(type: "INTEGER", nullable: false), + Summary = table.Column(type: "TEXT", nullable: true), + Title = table.Column(type: "TEXT", maxLength: 300, nullable: false), + UpdatedAt = table.Column(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" }); + } + } +} diff --git a/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs b/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs index 5e9fb14..ffcc6ca 100644 --- a/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/NovelSoftware.Infrastructure/Persistence/Migrations/NovelDbContextModelSnapshot.cs @@ -17,6 +17,51 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + modelBuilder.Entity("NovelSoftware.Domain.Entities.AgentConversation", b => { b.Property("Id") @@ -79,6 +124,52 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations b.ToTable("AgentMessages"); }); + modelBuilder.Entity("NovelSoftware.Domain.Entities.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SceneId") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("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("Id") @@ -231,54 +322,6 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations b.ToTable("CharacterRelationships"); }); - modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => - { - b.Property("Id") - .ValueGeneratedOnAdd() - .HasColumnType("TEXT"); - - b.Property("ChapterId") - .HasColumnType("TEXT"); - - b.Property("CreatedAt") - .HasColumnType("INTEGER"); - - b.Property("NodeType") - .IsRequired() - .HasMaxLength(32) - .HasColumnType("TEXT"); - - b.Property("ParentId") - .HasColumnType("TEXT"); - - b.Property("ProjectId") - .HasColumnType("TEXT"); - - b.Property("SortOrder") - .HasColumnType("INTEGER"); - - b.Property("Summary") - .HasColumnType("TEXT"); - - b.Property("Title") - .IsRequired() - .HasMaxLength(300) - .HasColumnType("TEXT"); - - b.Property("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 => { b.Property("Id") @@ -380,6 +423,80 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations b.ToTable("Scenes"); }); + modelBuilder.Entity("NovelSoftware.Domain.Entities.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("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") @@ -402,6 +519,31 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations 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") @@ -450,31 +592,6 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations 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 => { b.HasOne("NovelSoftware.Domain.Entities.Chapter", "Chapter") @@ -493,6 +610,17 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations 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"); @@ -500,6 +628,8 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations modelBuilder.Entity("NovelSoftware.Domain.Entities.Chapter", b => { + b.Navigation("Beats"); + b.Navigation("Scenes"); }); @@ -508,11 +638,6 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations b.Navigation("Relationships"); }); - modelBuilder.Entity("NovelSoftware.Domain.Entities.OutlineNode", b => - { - b.Navigation("Children"); - }); - modelBuilder.Entity("NovelSoftware.Domain.Entities.Project", b => { b.Navigation("Chapters"); @@ -521,7 +646,7 @@ namespace NovelSoftware.Infrastructure.Persistence.Migrations b.Navigation("Conversations"); - b.Navigation("OutlineNodes"); + b.Navigation("Tags"); }); #pragma warning restore 612, 618 } diff --git a/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs b/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs index d7423fa..7f1e5b8 100644 --- a/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs +++ b/src/NovelSoftware.Infrastructure/Persistence/NovelDbContext.cs @@ -22,7 +22,8 @@ public class NovelDbContext(DbContextOptions options) public DbSet Projects => Set(); public DbSet Characters => Set(); public DbSet CharacterRelationships => Set(); - public DbSet OutlineNodes => Set(); + public DbSet Beats => Set(); + public DbSet Tags => Set(); public DbSet Chapters => Set(); public DbSet Scenes => Set(); public DbSet Conversations => Set(); @@ -43,8 +44,8 @@ public class NovelDbContext(DbContextOptions options) .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); entity.HasMany(p => p.Chapters).WithOne(c => c.Project!) .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); - entity.HasMany(p => p.OutlineNodes).WithOne(n => n.Project!) - .HasForeignKey(n => n.ProjectId).OnDelete(DeleteBehavior.Cascade); + entity.HasMany(p => p.Tags).WithOne(t => t.Project!) + .HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade); entity.HasMany(p => p.Conversations).WithOne(c => c.Project!) .HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade); }); @@ -70,17 +71,38 @@ public class NovelDbContext(DbContextOptions options) .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); }); - builder.Entity(entity => + builder.Entity(entity => { - entity.Property(n => n.Title).IsRequired().HasMaxLength(300); - entity.Property(n => n.NodeType).HasConversion().HasMaxLength(32); - entity.HasIndex(n => new { n.ProjectId, n.ParentId, n.SortOrder }); + entity.Property(b => b.Title).IsRequired().HasMaxLength(200); + entity.HasIndex(b => new { b.ChapterId, b.SortOrder }); - entity.HasOne(n => n.Parent).WithMany(n => n.Children) - .HasForeignKey(n => n.ParentId).OnDelete(DeleteBehavior.Restrict); + entity.HasOne(b => b.Chapter).WithMany(c => c.Beats) + .HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade); - entity.HasOne(n => n.Chapter).WithMany() - .HasForeignKey(n => n.ChapterId).OnDelete(DeleteBehavior.SetNull); + // A beat outlives the scene it was grouped under: deleting a scene is a + // 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(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(entity => diff --git a/src/NovelSoftware.Mcp/Tools/BeatTools.cs b/src/NovelSoftware.Mcp/Tools/BeatTools.cs new file mode 100644 index 0000000..1068ae3 --- /dev/null +++ b/src/NovelSoftware.Mcp/Tools/BeatTools.cs @@ -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 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 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 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 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 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); +} diff --git a/src/NovelSoftware.Mcp/Tools/CharacterTools.cs b/src/NovelSoftware.Mcp/Tools/CharacterTools.cs index c79aef6..a9367a1 100644 --- a/src/NovelSoftware.Mcp/Tools/CharacterTools.cs +++ b/src/NovelSoftware.Mcp/Tools/CharacterTools.cs @@ -45,7 +45,8 @@ public static class CharacterTools [Description("What in the world opposes them.")] string? externalConflict = 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("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 { name, @@ -62,7 +63,8 @@ public static class CharacterTools externalConflict, arcSummary, voice, - notes + notes, + tags }, ct); [McpServerTool(Name = "update_character")] @@ -86,7 +88,8 @@ public static class CharacterTools [Description("What in the world opposes them.")] string? externalConflict = null, [Description("How they change over the course of the book.")] string? arcSummary = 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 { name, @@ -103,7 +106,8 @@ public static class CharacterTools externalConflict, arcSummary, voice, - notes + notes, + tags }, ct); [McpServerTool(Name = "relate_characters")] diff --git a/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs b/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs index e9e87aa..7062009 100644 --- a/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs +++ b/src/NovelSoftware.Mcp/Tools/ManuscriptTools.cs @@ -31,11 +31,12 @@ public static class ManuscriptTools [Description("Chapter title.")] string title, CancellationToken ct, [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("Where and when the chapter takes place.")] string? setting = 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 { title, @@ -44,7 +45,8 @@ public static class ManuscriptTools povCharacterId, setting, status = status ?? "Planned", - targetWordCount + targetWordCount, + tags }, ct); [McpServerTool(Name = "update_chapter")] @@ -55,14 +57,15 @@ public static class ManuscriptTools CancellationToken ct, [Description("New title.")] string? title = 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("Where and when the chapter takes place.")] string? setting = null, [Description("Anything else worth recording.")] string? notes = 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}", - 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")] [Description("List a chapter's scenes in order.")] diff --git a/src/NovelSoftware.Mcp/Tools/OutlineTools.cs b/src/NovelSoftware.Mcp/Tools/OutlineTools.cs deleted file mode 100644 index 090d246..0000000 --- a/src/NovelSoftware.Mcp/Tools/OutlineTools.cs +++ /dev/null @@ -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 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 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 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 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 DeleteOutlineNode( - NovelApiClient api, - [Description("The node's id.")] Guid nodeId, - CancellationToken ct) => - api.DeleteAsync($"/api/outline/{nodeId}", ct); -} diff --git a/src/NovelSoftware.Mcp/Tools/TagTools.cs b/src/NovelSoftware.Mcp/Tools/TagTools.cs new file mode 100644 index 0000000..cf8672e --- /dev/null +++ b/src/NovelSoftware.Mcp/Tools/TagTools.cs @@ -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 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 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 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 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 DeleteTag( + NovelApiClient api, + [Description("The tag's id.")] Guid tagId, + CancellationToken ct) => + api.DeleteAsync($"/api/tags/{tagId}", ct); +} diff --git a/src/NovelSoftware.Web/src/App.tsx b/src/NovelSoftware.Web/src/App.tsx index 739b91c..96b10f7 100644 --- a/src/NovelSoftware.Web/src/App.tsx +++ b/src/NovelSoftware.Web/src/App.tsx @@ -3,7 +3,7 @@ import ProjectsPage from './pages/ProjectsPage' import ProjectLayout from './pages/ProjectLayout' import OverviewPage from './pages/OverviewPage' import CharactersPage from './pages/CharactersPage' -import OutlinePage from './pages/OutlinePage' +import TagsPage from './pages/TagsPage' import ChaptersPage from './pages/ChaptersPage' import ChapterPage from './pages/ChapterPage' import AgentPage from './pages/AgentPage' @@ -15,9 +15,9 @@ export default function App() { }> } /> } /> - } /> } /> } /> + } /> } /> } /> diff --git a/src/NovelSoftware.Web/src/api/hooks.ts b/src/NovelSoftware.Web/src/api/hooks.ts index 9f6cd7e..6cda65d 100644 --- a/src/NovelSoftware.Web/src/api/hooks.ts +++ b/src/NovelSoftware.Web/src/api/hooks.ts @@ -7,17 +7,20 @@ import type { Character, Conversation, ConversationSummary, - OutlineNode, + Beat, Project, ProjectSummary, Scene, + TagReferences, + TagSummary, } from './types' export const keys = { projects: ['projects'] as const, project: (id: string) => ['projects', id] 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, chapter: (id: string) => ['chapters', id] as const, conversations: (projectId: string) => ['projects', projectId, 'conversations'] as const, @@ -73,16 +76,22 @@ export function useCreateCharacter(projectId: string) { return useMutation({ mutationFn: (body: Partial & { name: string }) => api.post(`/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) { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, ...body }: Partial & { id: string }) => + mutationFn: ({ id, ...body }: Partial> & { id: string; tags?: string[] }) => api.patch(`/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({ - queryKey: keys.outline(projectId), - queryFn: () => api.get(`/api/projects/${projectId}/outline`), + queryKey: keys.tags(projectId), + queryFn: () => api.get(`/api/projects/${projectId}/tags`), }) -export function useCreateOutlineNode(projectId: string) { +export const useTagReferences = (tagId: string | undefined) => + useQuery({ + queryKey: keys.tagRefs(tagId ?? ''), + queryFn: () => api.get(`/api/tags/${tagId}/references`), + enabled: Boolean(tagId), + }) + +export function useUpdateTag(projectId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: (body: Partial & { title: string }) => - api.post(`/api/projects/${projectId}/outline`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), + mutationFn: ({ id, ...body }: { id: string; name?: string; color?: string }) => + api.patch(`/api/tags/${id}`, body), + 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() return useMutation({ - mutationFn: ({ id, ...body }: Partial & { id: string }) => - api.patch(`/api/outline/${id}`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), + mutationFn: (id: string) => api.delete(`/api/tags/${id}`), + onSuccess: () => qc.invalidateQueries(), }) } -export function useDeleteOutlineNode(projectId: string) { +// --- Beats (a chapter's outline) --------------------------------------------- + +export function useCreateBeat(chapterId: string, projectId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: (id: string) => api.delete(`/api/outline/${id}`), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.outline(projectId) }), + mutationFn: (body: Partial & { title: string }) => + api.post(`/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> & { id: string; tags?: string[] }) => + api.patch(`/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(`/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) { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, ...body }: Partial & { id: string }) => + mutationFn: ({ id, ...body }: Partial> & { id: string; tags?: string[] }) => api.patch(`/api/chapters/${id}`, body), onSuccess: (updated) => { qc.setQueryData(keys.chapter(updated.id), updated) 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) }) // 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.outline(projectId) }) + qc.invalidateQueries({ queryKey: keys.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) qc.invalidateQueries({ queryKey: keys.project(projectId) }) }, diff --git a/src/NovelSoftware.Web/src/api/types.ts b/src/NovelSoftware.Web/src/api/types.ts index 19ee5a4..7489ff6 100644 --- a/src/NovelSoftware.Web/src/api/types.ts +++ b/src/NovelSoftware.Web/src/api/types.ts @@ -21,17 +21,6 @@ export const characterRoles: CharacterRole[] = [ '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 const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] @@ -62,6 +51,51 @@ export interface Project { 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 { id: string relatedCharacterId: string @@ -89,21 +123,10 @@ export interface Character { voice: string | null notes: string | null relationships: Relationship[] + tags: Tag[] 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 { id: string chapterId: string @@ -133,12 +156,15 @@ export interface ChapterSummary { setting: string | null status: DraftStatus targetWordCount: number | null + beatCount: number sceneCount: number wordCount: number + tags: Tag[] } -export interface Chapter extends Omit { +export interface Chapter extends Omit { notes: string | null + beats: Beat[] scenes: Scene[] updatedAt: string } diff --git a/src/NovelSoftware.Web/src/components/TagEditor.tsx b/src/NovelSoftware.Web/src/components/TagEditor.tsx new file mode 100644 index 0000000..5d4b71f --- /dev/null +++ b/src/NovelSoftware.Web/src/components/TagEditor.tsx @@ -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 ( + + {tag.name} + {onRemove && ( + + )} + + ) +} + +/** + * 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 ( +
+ {label && {label}} +
+ {tags.map((tag) => ( + remove(tag.name)} /> + ))} + setDraft(e.target.value)} + onBlur={add} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + add() + } + }} + /> + + {unused.map((name) => ( + +
+
+ ) +} diff --git a/src/NovelSoftware.Web/src/pages/ChapterPage.tsx b/src/NovelSoftware.Web/src/pages/ChapterPage.tsx index 078752a..a47de83 100644 --- a/src/NovelSoftware.Web/src/pages/ChapterPage.tsx +++ b/src/NovelSoftware.Web/src/pages/ChapterPage.tsx @@ -3,31 +3,40 @@ import { Link, useNavigate, useParams } from 'react-router-dom' import { useChapter, useCharacters, + useCreateBeat, useCreateScene, + useDeleteBeat, useDeleteChapter, useDeleteScene, + useReorderBeats, + useTags, + useUpdateBeat, useUpdateChapter, useUpdateScene, } 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 { TagEditor } from '../components/TagEditor' export default function ChapterPage() { - const { projectId = '', chapterId } = useParams() + const { projectId = '', chapterId = '' } = useParams() const navigate = useNavigate() const { data: chapter, isPending, error } = useChapter(chapterId) const { data: characters } = useCharacters(projectId) + const { data: allTags } = useTags(projectId) const update = useUpdateChapter(projectId) const remove = useDeleteChapter(projectId) - const createScene = useCreateScene(chapterId ?? '') + const createBeat = useCreateBeat(chapterId, projectId) + const createScene = useCreateScene(chapterId) if (isPending) return if (error) return if (!chapter) return null - const patch = (body: Partial) => update.mutate({ id: chapter.id, ...body }) - const povOptions = ['—', ...(characters?.map((c) => c.name) ?? [])] - const povValue = chapter.povCharacterName ?? '—' + const patch = (body: Partial> & { tags?: string[] }) => + update.mutate({ id: chapter.id, ...body }) + + const suggestions = allTags?.map((t) => t.name) ?? [] return (
@@ -66,48 +75,47 @@ export default function ChapterPage() {
+ patch({ summary })} + label="Setting" + value={chapter.setting} + onCommit={(setting) => patch({ setting })} + /> +
+ +
+ patch({ tags })} /> -
- - patch({ setting })} - /> -
- {chapter.scenes.length} scenes ·{' '} + {chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '} {chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
-
-

Scenes

- -
+ {/* The outline: a paragraph, then the beat table. */} +
+

Outline

+

+ A paragraph on what the chapter does, then the beats that carry it. +

-
    - {chapter.scenes.map((scene) => ( - - ))} -
+
+ patch({ summary })} + /> +
+ + ({ id: c.id, name: c.name })) ?? []} + suggestions={suggestions} + /> + + + {createBeat.error && ( +
+ +
+ )} +
+ + {/* The prose layer. */} +
+
+
+

Scenes

+

Where the prose lives. Beats can be grouped under these.

+
+ +
+ +
    + {chapter.scenes.map((scene) => ( + + ))} +
+
+ + ) +} + +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 ( +
+ No beats yet. Each one is a short handle — “she burns the atlas” — plus what happened and + what it sets up. +
+ ) + } + + 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> & { tags?: string[] }) => + update.mutate({ id, ...body }) + + return ( +
+ + + + + + + + + + + + + {chapter.beats.map((beat, index) => ( + + + + + + + + + + + + + + + + ))} + +
#Beat + Character + + What happened + What's nextScene +
+
+ {index + 1} +
+ + +
+
+
+ title.trim() && patch(beat.id, { title })} + /> +
+ patch(beat.id, { tags })} + /> +
+
+ + + patch(beat.id, { whatHappened })} + /> + + patch(beat.id, { whatsNext })} + /> + + + + +
) } @@ -148,45 +366,14 @@ function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) { return (
  • - title.trim() && patch({ title })} - /> + title.trim() && patch({ title })} /> update.mutate({ id: node.id, nodeType })} - /> -
    - update.mutate({ id: node.id, summary })} - /> - - -
    - - -
    - - - - {expanded && node.children.length > 0 && ( -
      - {node.children.map((child) => ( - - ))} -
    - )} -
  • - ) -} diff --git a/src/NovelSoftware.Web/src/pages/ProjectLayout.tsx b/src/NovelSoftware.Web/src/pages/ProjectLayout.tsx index e305c55..9ce92de 100644 --- a/src/NovelSoftware.Web/src/pages/ProjectLayout.tsx +++ b/src/NovelSoftware.Web/src/pages/ProjectLayout.tsx @@ -4,9 +4,9 @@ import { ErrorNote, Spinner } from '../components/ui' const tabs = [ { to: '', label: 'Overview', end: true }, - { to: 'outline', label: 'Outline' }, { to: 'characters', label: 'Characters' }, { to: 'chapters', label: 'Chapters' }, + { to: 'tags', label: 'Tags' }, { to: 'agent', label: 'Agent' }, ] diff --git a/src/NovelSoftware.Web/src/pages/TagsPage.tsx b/src/NovelSoftware.Web/src/pages/TagsPage.tsx new file mode 100644 index 0000000..41bfca3 --- /dev/null +++ b/src/NovelSoftware.Web/src/pages/TagsPage.tsx @@ -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() + + if (isPending) return + if (error) return + + const selected = tags?.find((t) => t.id === selectedId) ?? tags?.[0] + + return ( +
    + + +
    + {!selected ? ( + + ) : ( + + )} +
    +
    + ) +} + +function TagReferencePanel({ projectId, tagId }: { projectId: string; tagId: string }) { + const { data, isPending, error } = useTagReferences(tagId) + const update = useUpdateTag(projectId) + const remove = useDeleteTag() + + if (isPending) return + if (error) return + if (!data) return null + + const empty = + data.characters.length === 0 && data.chapters.length === 0 && data.beats.length === 0 + + return ( +
    +
    + + + +
    + + {update.error && } + + {empty && ( + + )} + + {data.characters.length > 0 && ( +
    +

    Characters

    +
      + {data.characters.map((c) => ( +
    • + + {c.name} + + — {c.role} +
    • + ))} +
    +
    + )} + + {data.chapters.length > 0 && ( +
    +

    Chapters

    +
      + {data.chapters.map((c) => ( +
    • + + {c.number}. {c.title} + + {c.summary && — {c.summary}} +
    • + ))} +
    +
    + )} + + {data.beats.length > 0 && ( +
    +

    Beats

    +
      + {data.beats.map((b) => ( +
    • + + {b.title} + + + {' '} + — ch. {b.chapterNumber} “{b.chapterTitle}”, beat {b.sortOrder} + {b.characterName && `, ${b.characterName}`} + + {b.whatHappened &&
      {b.whatHappened}
      } +
    • + ))} +
    +
    + )} +
    + ) +} diff --git a/tests/NovelSoftware.Tests/BeatServiceTests.cs b/tests/NovelSoftware.Tests/BeatServiceTests.cs new file mode 100644 index 0000000..b7412b3 --- /dev/null +++ b/tests/NovelSoftware.Tests/BeatServiceTests.cs @@ -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(); + } + + [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() + .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() + .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(); +} diff --git a/tests/NovelSoftware.Tests/ListingTests.cs b/tests/NovelSoftware.Tests/ListingTests.cs index 5586d83..d105337 100644 --- a/tests/NovelSoftware.Tests/ListingTests.cs +++ b/tests/NovelSoftware.Tests/ListingTests.cs @@ -15,6 +15,7 @@ namespace NovelSoftware.Tests; public class ListingTests : IDisposable { private readonly TestDatabase _db = new(); + private readonly TagService _tags; private readonly ProjectService _projects; private readonly ChapterService _chapters; private readonly SceneService _scenes; @@ -22,10 +23,11 @@ public class ListingTests : IDisposable public ListingTests() { + _tags = new TagService(_db.Context); _projects = new ProjectService(_db.Context); - _chapters = new ChapterService(_db.Context); + _chapters = new ChapterService(_db.Context, _tags); _scenes = new SceneService(_db.Context); - _characters = new CharacterService(_db.Context); + _characters = new CharacterService(_db.Context, _tags); } [Fact] @@ -97,7 +99,7 @@ public class ListingTests : IDisposable var agent = new NovelAgentService( _db.Context, 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()), NullLogger.Instance); diff --git a/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs b/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs index 7cc4b4e..ea4976c 100644 --- a/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs +++ b/tests/NovelSoftware.Tests/NovelAgentServiceTests.cs @@ -11,20 +11,23 @@ namespace NovelSoftware.Tests; public class NovelAgentServiceTests : IDisposable { private readonly TestDatabase _db = new(); + private readonly TagService _tags; private readonly ProjectService _projects; private readonly CharacterService _characters; private readonly NovelAgentToolset _toolset; public NovelAgentServiceTests() { + _tags = new TagService(_db.Context); _projects = new ProjectService(_db.Context); - _characters = new CharacterService(_db.Context); + _characters = new CharacterService(_db.Context, _tags); _toolset = new NovelAgentToolset( _projects, _characters, - new OutlineService(_db.Context), - new ChapterService(_db.Context), - new SceneService(_db.Context)); + new ChapterService(_db.Context, _tags), + new BeatService(_db.Context, _tags), + new SceneService(_db.Context), + _tags); } private NovelAgentService BuildAgent(ScriptedModelClient model) => new( diff --git a/tests/NovelSoftware.Tests/OutlineServiceTests.cs b/tests/NovelSoftware.Tests/OutlineServiceTests.cs deleted file mode 100644 index 31519db..0000000 --- a/tests/NovelSoftware.Tests/OutlineServiceTests.cs +++ /dev/null @@ -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() - .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() - .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(); - } - - public void Dispose() => _db.Dispose(); -} diff --git a/tests/NovelSoftware.Tests/ProjectDataTests.cs b/tests/NovelSoftware.Tests/ProjectDataTests.cs index e4932bd..3964b4d 100644 --- a/tests/NovelSoftware.Tests/ProjectDataTests.cs +++ b/tests/NovelSoftware.Tests/ProjectDataTests.cs @@ -10,6 +10,7 @@ namespace NovelSoftware.Tests; public class ProjectDataTests : IDisposable { private readonly TestDatabase _db = new(); + private readonly TagService _tags; private readonly ProjectService _projects; private readonly CharacterService _characters; private readonly ChapterService _chapters; @@ -17,9 +18,10 @@ public class ProjectDataTests : IDisposable public ProjectDataTests() { + _tags = new TagService(_db.Context); _projects = new ProjectService(_db.Context); - _characters = new CharacterService(_db.Context); - _chapters = new ChapterService(_db.Context); + _characters = new CharacterService(_db.Context, _tags); + _chapters = new ChapterService(_db.Context, _tags); _scenes = new SceneService(_db.Context); } diff --git a/tests/NovelSoftware.Tests/TagServiceTests.cs b/tests/NovelSoftware.Tests/TagServiceTests.cs new file mode 100644 index 0000000..55d2338 --- /dev/null +++ b/tests/NovelSoftware.Tests/TagServiceTests.cs @@ -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().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().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(); + } + + public void Dispose() => _db.Dispose(); +}