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(); +}