From 23348327a9bc79a46236eb8bc7294429f60222b1 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Tue, 11 Aug 2026 21:05:13 -0700 Subject: [PATCH] Remove Scenes, group beats by multiple characters; strip comments repo-wide Drop the Scene entity/grouping in favor of chapters carrying prose directly and beats belonging to many characters. Add markdown editor + character multi-select components to the web client. Remove all XML doc and inline comments across the touched C#/TS/CSS files in favor of self-documenting names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP server config, no secrets) and ignore .idea/. --- .claude/agents/outline-importer.md | 8 +- .gitignore | 2 + .mcp.json | 11 + CLAUDE.md | 2 + src/Novelly.Api/Agent/JsonSchema.cs | 18 +- src/Novelly.Api/Agent/NovelAgentService.cs | 42 +- src/Novelly.Api/Agent/NovelAgentToolset.cs | 106 +- src/Novelly.Api/Beats/Beat.cs | 19 +- src/Novelly.Api/Beats/BeatContracts.cs | 41 +- src/Novelly.Api/Beats/BeatService.cs | 86 +- src/Novelly.Api/Chapters/Chapter.cs | 15 +- src/Novelly.Api/Chapters/ChapterContracts.cs | 34 +- src/Novelly.Api/Chapters/ChapterEndpoints.cs | 4 +- src/Novelly.Api/Chapters/ChapterService.cs | 20 +- src/Novelly.Api/Characters/Character.cs | 21 +- src/Novelly.Api/Common/DraftStatus.cs | 1 - .../Common/NovellyServiceRegistration.cs | 9 - src/Novelly.Api/Data/INovelDbContext.cs | 6 - ...terProseAndMultiCharacterBeats.Designer.cs | 768 +++++++++++ ...esAddChapterProseAndMultiCharacterBeats.cs | 184 +++ .../Migrations/NovelDbContextModelSnapshot.cs | 141 +-- src/Novelly.Api/Data/NovelDbContext.cs | 43 +- src/Novelly.Api/Imports/ImportAgentToolset.cs | 25 +- src/Novelly.Api/Program.cs | 12 - src/Novelly.Api/Projects/ProjectService.cs | 4 +- src/Novelly.Api/Scenes/Scene.cs | 45 - src/Novelly.Api/Scenes/SceneContracts.cs | 128 -- src/Novelly.Api/Scenes/SceneEndpoints.cs | 51 - src/Novelly.Api/Scenes/SceneService.cs | 156 --- src/Novelly.Api/Tags/TagContracts.cs | 11 +- src/Novelly.Api/Tags/TagService.cs | 10 +- src/Novelly.Mcp/Tools/BeatTools.cs | 25 +- src/Novelly.Mcp/Tools/ManuscriptTools.cs | 83 +- src/Novelly.Web/package-lock.json | 1124 ++++++++++++++++- src/Novelly.Web/package.json | 1 + src/Novelly.Web/src/api/hooks.ts | 77 +- src/Novelly.Web/src/api/types.ts | 46 +- .../src/components/CharacterMultiSelect.tsx | 78 ++ .../src/components/MarkdownEditor.tsx | 72 ++ src/Novelly.Web/src/index.css | 57 +- src/Novelly.Web/src/pages/AgentPage.tsx | 4 +- src/Novelly.Web/src/pages/ChapterPage.tsx | 245 ++-- src/Novelly.Web/src/pages/ChaptersPage.tsx | 1 - .../Novelly.Api.Tests/AnthropicClientTests.cs | 3 - tests/Novelly.Api.Tests/BeatServiceTests.cs | 63 +- tests/Novelly.Api.Tests/CapturingLogger.cs | 6 - tests/Novelly.Api.Tests/CharacterArcTests.cs | 9 +- .../ExceptionHandlingTests.cs | 5 - .../ImportAgentToolsetTests.cs | 2 - tests/Novelly.Api.Tests/ImportServiceTests.cs | 1 - tests/Novelly.Api.Tests/ListingTests.cs | 26 +- tests/Novelly.Api.Tests/LoggingTests.cs | 6 +- .../NovelAgentServiceTests.cs | 8 +- tests/Novelly.Api.Tests/OpenQuestionTests.cs | 3 - tests/Novelly.Api.Tests/ProjectDataTests.cs | 27 +- tests/Novelly.Api.Tests/ServiceTestFixture.cs | 15 - tests/Novelly.Api.Tests/TestDatabase.cs | 11 - 57 files changed, 2600 insertions(+), 1421 deletions(-) create mode 100644 .mcp.json create mode 100644 src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.cs delete mode 100644 src/Novelly.Api/Scenes/Scene.cs delete mode 100644 src/Novelly.Api/Scenes/SceneContracts.cs delete mode 100644 src/Novelly.Api/Scenes/SceneEndpoints.cs delete mode 100644 src/Novelly.Api/Scenes/SceneService.cs create mode 100644 src/Novelly.Web/src/components/CharacterMultiSelect.tsx create mode 100644 src/Novelly.Web/src/components/MarkdownEditor.tsx diff --git a/.claude/agents/outline-importer.md b/.claude/agents/outline-importer.md index 509b19d..4d226df 100644 --- a/.claude/agents/outline-importer.md +++ b/.claude/agents/outline-importer.md @@ -149,10 +149,10 @@ For each chapter file: Keep the Part tag exactly as written ("Part I", "Part II"); keep the thread tag as the raw Thread text so multi-POV chapters aren't lossy even though `povCharacterId` had to pick one or none. -4. For each beat table row, resolve the Character column the same way: a single clear name gets - auto-created if it isn't in the ledger yet; a list ("Glokta, West, Jezal") or vague reference - stays unresolved rather than guessing which one the beat belongs to. Then - `create_beat(chapterId, title: , whatHappened: , whatsNext: , characterId: )`, +4. For each beat table row, resolve the Character column the same way: a single clear name, or a + list ("Glokta, West, Jezal"), gets each name auto-created if it isn't in the ledger yet; a + vague reference stays unresolved rather than guessing who the beat belongs to. Then + `create_beat(chapterId, title: , whatHappened: , whatsNext: , characterIds: )`, in table order (the API appends in call order, so no explicit `sortOrder` needed). 5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`. 6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`. diff --git a/.gitignore b/.gitignore index 4f43f5b..43db234 100644 --- a/.gitignore +++ b/.gitignore @@ -439,3 +439,5 @@ mcp-server/ # Toolchains installed locally by scripts/ci/lib.sh .dotnet/ .node/ + +.idea/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 0000000..c755c96 --- /dev/null +++ b/.mcp.json @@ -0,0 +1,11 @@ +{ + "mcpServers": { + "novelly": { + "command": "/home/james/src/novelly/mcp-server/Novelly.Mcp", + "env": { + "NOVELLY_API_URL": "http://localhost:5080", + "DOTNET_ROOT": "/home/james/.dotnet" + } + } + } +} diff --git a/CLAUDE.md b/CLAUDE.md index d1e40a4..a5d8886 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,6 +47,8 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A # Coding +- No comments — no `///` XML doc, no `//` line comments, no `/* */` blocks, in C#, TS, or CSS. Unclear code → rename for + clarity or extract a well-named method instead of explaining it. - Descriptive names all classes/methods. No generic: Provider, Manager, Helper - Match formatting/style from `.editorconfig` - Wrap lines at 220 chars, single line if fewer diff --git a/src/Novelly.Api/Agent/JsonSchema.cs b/src/Novelly.Api/Agent/JsonSchema.cs index bc3323c..a72f2e6 100644 --- a/src/Novelly.Api/Agent/JsonSchema.cs +++ b/src/Novelly.Api/Agent/JsonSchema.cs @@ -3,10 +3,6 @@ using System.Text.Json; namespace Novelly.Api.Agent; -/// -/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these -/// as string literals is where tool definitions usually rot, so build them structurally. -/// public class JsonSchemaBuilder { private readonly JsonObject _properties = []; @@ -80,7 +76,6 @@ public class JsonSchemaBuilder } } -/// Lenient readers for tool input, which arrives as untyped JSON. public static class JsonInput { public static string? String(JsonElement input, string name) => @@ -114,10 +109,6 @@ public static class JsonInput }; } - /// - /// Reads a boolean flag. Models sometimes send "true" as a string even when the - /// schema says boolean, so both spellings are accepted. - /// public static bool? Bool(JsonElement input, string name) { if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value)) @@ -134,11 +125,6 @@ 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 @@ -155,4 +141,8 @@ public static class JsonInput 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; + + public static IReadOnlyList? Guids(JsonElement input, string name) => + Strings(input, name)?.Select(s => System.Guid.TryParse(s, out var id) ? id : (Guid?)null) + .Where(id => id is not null).Select(id => id!.Value).ToList(); } diff --git a/src/Novelly.Api/Agent/NovelAgentService.cs b/src/Novelly.Api/Agent/NovelAgentService.cs index acfff4c..2e8e20e 100644 --- a/src/Novelly.Api/Agent/NovelAgentService.cs +++ b/src/Novelly.Api/Agent/NovelAgentService.cs @@ -10,10 +10,6 @@ using Novelly.Api.Projects; namespace Novelly.Api.Agent; -/// -/// The embedded writing agent. Runs the tool-use loop against the model, persists the -/// conversation, and returns the finished turn together with a record of what it changed. -/// public class NovelAgentService( INovelDbContext db, IAgentModelClient model, @@ -41,7 +37,6 @@ public class NovelAgentService( .ToListAsync(ct); } - /// Null when no conversation has this id — a lookup miss is expected, not exceptional. public async Task GetConversationAsync(Guid conversationId, CancellationToken ct = default) { Guard.Default(conversationId, nameof(conversationId)); @@ -51,7 +46,6 @@ public class NovelAgentService( return await FindConversationAsync(conversationId, ct); } - /// True if a conversation was deleted; false if no conversation had this id. public async Task DeleteConversationAsync(Guid conversationId, CancellationToken ct = default) { Guard.Default(conversationId, nameof(conversationId)); @@ -69,12 +63,6 @@ public class NovelAgentService( return true; } - /// - /// Sends a message to the agent and runs it to completion, executing any tools it - /// calls along the way. Returns the assistant's final turn. Null when no project has - /// this id, or the request names a conversation that does not exist — a lookup miss - /// is expected, not exceptional. - /// public async Task SendMessageAsync( Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default) { @@ -96,8 +84,6 @@ public class NovelAgentService( AgentConversation conversation; if (request.ConversationId is { } id) { - // The id came from the request body, not the route — an unknown id here - // is bad input to this call, not a direct "fetch conversation" lookup. var found = await FindConversationAsync(id, ct); if (found is null) { @@ -111,9 +97,6 @@ public class NovelAgentService( conversation = StartConversation(projectId, request.Message); } - // Persist the user's turn before running the loop. The tools save through the - // same DbContext, so leaving this pending would entangle it with their writes — - // and recording the question even if the model call fails is the behaviour we want. await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct); var systemPrompt = BuildSystemPrompt(project); @@ -141,9 +124,6 @@ public class NovelAgentService( break; } - // Echo the assistant's turn back verbatim, then answer every tool_use block in a - // single user turn — splitting the results would train the model out of - // requesting tools in parallel. transcript.Add(AgentChatMessage.Assistant(response.Content)); var results = new List(); @@ -182,11 +162,6 @@ public class NovelAgentService( return reply; } - /// - /// Appends a turn and commits it. Messages are added to the set directly rather than - /// through the parent's collection so their insert never depends on EF discovering - /// the graph change at an inconvenient moment. - /// private async Task AppendMessageAsync( AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct) { @@ -206,9 +181,6 @@ public class NovelAgentService( await db.SaveChangesAsync(ct); - // EF's relationship fixup normally puts the message into the parent's collection - // once both are tracked. Guard rather than assume, since the sequence number of - // the next turn is derived from it. if (!conversation.Messages.Contains(message)) { conversation.Messages.Add(message); @@ -247,11 +219,6 @@ public class NovelAgentService( return conversation; } - /// - /// Replays the stored conversation as plain text turns. Tool calls are not replayed — - /// the agent re-reads current state through its tools, which is more reliable than - /// trusting a transcript of edits that may since have been changed in the UI. - /// private static List BuildTranscript(AgentConversation conversation) => [ .. conversation.Messages @@ -273,8 +240,8 @@ public class NovelAgentService( return $""" You are a developmental editor and writing partner embedded in the software the writer is using to plan their novel. You have tools that read and write the - project's real data: the brief, character dossiers, the outline tree, chapters - and scenes. + project's real data: the brief, character dossiers, the outline (beats) and each + chapter's drafted prose. The project you are working on: {brief} @@ -291,8 +258,8 @@ public class NovelAgentService( - Prefer structural help — where a beat lands, whether a want and a need are genuinely in tension, what the outline is missing — over line-level polish, unless the writer asks for prose. - - When drafting prose into a scene, match the voice already established in the - project. Write the scene, then stop; do not append notes about your choices. + - When drafting a chapter's prose, match the voice already established in the + project. Write the chapter, then stop; do not append notes about your choices. - Destructive operations (deleting outline nodes) need the writer's explicit go-ahead first. @@ -300,7 +267,6 @@ public class NovelAgentService( """; } - /// Derives a conversation title from its opening message. private static string Summarise(string message) { var trimmed = message.Trim().ReplaceLineEndings(" "); diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index f619998..0391637 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -5,40 +5,26 @@ using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Projects; using Novelly.Api.Questions; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Agent; -/// The outcome of running a tool: what to hand back to the model, and whether it failed. public record AgentToolResult(string Content, bool IsError); -/// -/// A lookup a tool performed came back empty. Not an exception — the underlying service -/// already said so by returning null/false — just a value -/// recognises and turns into the same error-result shape a caught exception would produce. -/// internal record ToolNotFound(string Message); -/// A tool the agent can call, bound to a handler that runs against the project's data. public record AgentTool( string Name, string Description, JsonElement InputSchema, Func> Handler); -/// -/// The tools the writing agent can reach for. Everything here goes through the same -/// application services the REST API uses, so an edit made by the agent is -/// indistinguishable from one made in the UI. -/// public class NovelAgentToolset( ProjectService projects, CharacterService characters, CharacterArcService arcs, ChapterService chapters, BeatService beats, - SceneService scenes, TagService tags, OpenQuestionService questions, ILogger logger) @@ -56,10 +42,6 @@ public class NovelAgentToolset( public IReadOnlyList Definitions => [.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; - /// - /// Runs a tool and serialises its result. Failures come back as text rather than - /// exceptions so the model can read the message and correct itself. - /// public async Task ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default) { if (!ByName.TryGetValue(name, out var tool)) @@ -95,16 +77,13 @@ public class NovelAgentToolset( } } - /// Turns a nullable lookup into either the value or a the model can read. private static async Task OrNotFound(Task lookup, string entity, Guid id) where T : class => await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found."); - /// Turns a nullable lookup into either the mapped response or a the model can read. private static async Task OrNotFound( Task lookup, Func map, string entity, Guid id) where TEntity : class => await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found."); - /// Turns a delete's success flag into either a confirmation or a . private static async Task DeletedOrNotFound(Task delete, string entity, Guid id) => await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found."); @@ -227,10 +206,9 @@ public class NovelAgentToolset( new CreateBeatRequest( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "sort_order"), - JsonInput.Guid(input, "character_id"), + JsonInput.Guids(input, "character_ids"), JsonInput.String(input, "what_happened"), JsonInput.String(input, "whats_next"), - JsonInput.Guid(input, "scene_id"), JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId); }); @@ -250,10 +228,9 @@ public class NovelAgentToolset( new UpdateBeatRequest( JsonInput.String(input, "title"), JsonInput.Int(input, "sort_order"), - JsonInput.Guid(input, "character_id"), + JsonInput.Guids(input, "character_ids"), JsonInput.String(input, "what_happened"), JsonInput.String(input, "whats_next"), - JsonInput.Guid(input, "scene_id"), JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId); }); @@ -310,13 +287,13 @@ public class NovelAgentToolset( yield return new AgentTool( "list_chapters", - "List the project's chapters in manuscript order with scene and word counts.", + "List the project's chapters in manuscript order with beat and word counts.", new JsonSchemaBuilder().Build(), async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse())); yield return new AgentTool( "get_chapter", - "Read one chapter in full, including all of its scenes and any drafted prose.", + "Read one chapter in full: its outline (beats) and its drafted prose.", new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter to read.", required: true) .Build(), @@ -338,6 +315,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.") + .Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest( @@ -349,11 +327,14 @@ public class NovelAgentToolset( JsonInput.String(input, "notes"), JsonInput.Enum(input, "status") ?? DraftStatus.Planned, JsonInput.Int(input, "target_word_count"), + JsonInput.String(input, "prose"), JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId)); yield return new AgentTool( "update_chapter", - "Revise a chapter's title, number, summary, POV, setting, notes or status.", + "Revise a chapter's title, number, summary, POV, setting, notes, status or drafted " + + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + + "word count is recomputed automatically.", new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter to update.", required: true) .Str("title", "New title.") @@ -364,6 +345,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.") + .Str("prose", "The chapter's drafted text, in markdown.") .StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.") .Build(), async (_, input, ct) => @@ -380,61 +362,10 @@ public class NovelAgentToolset( JsonInput.String(input, "notes"), JsonInput.Enum(input, "status"), JsonInput.Int(input, "target_word_count"), + JsonInput.String(input, "prose"), JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId); }); - yield return new AgentTool( - "create_scene", - "Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene " - + "draftable later, so fill those in when the writer has given you enough to work with.", - SceneSchema() - .Str("chapter_id", "Id of the chapter the scene belongs to.", required: true) - .Str("title", "Scene title.", required: true) - .Build(), - async (_, input, ct) => - { - var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); - return await OrNotFound(scenes.CreateAsync( - chapterId, - new CreateSceneRequest( - JsonInput.RequiredString(input, "title"), - JsonInput.Int(input, "sort_order"), - JsonInput.String(input, "summary"), - JsonInput.String(input, "goal"), - JsonInput.String(input, "conflict"), - JsonInput.String(input, "outcome"), - JsonInput.Guid(input, "pov_character_id"), - JsonInput.String(input, "location"), - JsonInput.String(input, "prose"), - JsonInput.Enum(input, "status") ?? DraftStatus.Planned), ct), s => s.ToResponse(), "Chapter", chapterId); - }); - - yield return new AgentTool( - "update_scene", - "Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; " - + "the word count is recomputed automatically.", - SceneSchema() - .Str("scene_id", "Id of the scene to update.", required: true) - .Str("title", "New title.") - .Build(), - async (_, input, ct) => - { - var sceneId = JsonInput.RequiredGuid(input, "scene_id"); - return await OrNotFound(scenes.UpdateAsync( - sceneId, - new UpdateSceneRequest( - JsonInput.String(input, "title"), - JsonInput.Int(input, "sort_order"), - JsonInput.String(input, "summary"), - JsonInput.String(input, "goal"), - JsonInput.String(input, "conflict"), - JsonInput.String(input, "outcome"), - JsonInput.Guid(input, "pov_character_id"), - JsonInput.String(input, "location"), - JsonInput.String(input, "prose"), - JsonInput.Enum(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId); - }); - yield return new AgentTool( "get_character_beats", "Every beat this character appears in, across the whole book, in manuscript order. " @@ -651,21 +582,8 @@ public class NovelAgentToolset( 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.") + .StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.") .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.") - .Str("summary", "What happens in the scene.") - .Str("goal", "What the POV character is trying to achieve.") - .Str("conflict", "What stands in the way.") - .Str("outcome", "How it lands, and what it costs.") - .Str("pov_character_id", "Id of the point-of-view character.") - .Str("location", "Where the scene takes place.") - .Str("prose", "The drafted prose for this scene.") - .Enum("status", "Drafting status.", System.Enum.GetNames()); } diff --git a/src/Novelly.Api/Beats/Beat.cs b/src/Novelly.Api/Beats/Beat.cs index 0d014d5..b313abf 100644 --- a/src/Novelly.Api/Beats/Beat.cs +++ b/src/Novelly.Api/Beats/Beat.cs @@ -1,16 +1,9 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Beats; -/// -/// 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(); @@ -18,24 +11,14 @@ public class Beat 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; } + public List Characters { 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; } = []; diff --git a/src/Novelly.Api/Beats/BeatContracts.cs b/src/Novelly.Api/Beats/BeatContracts.cs index 53757a8..aea2377 100644 --- a/src/Novelly.Api/Beats/BeatContracts.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -3,27 +3,25 @@ using Novelly.Api.Tags; namespace Novelly.Api.Beats; +public record BeatCharacterResponse(Guid Id, string Name); + public record BeatResponse( Guid Id, Guid ChapterId, int SortOrder, string Title, - Guid? CharacterId, - string? CharacterName, + IReadOnlyList Characters, string? WhatHappened, string? WhatsNext, - Guid? SceneId, - string? SceneTitle, IReadOnlyList Tags, DateTimeOffset UpdatedAt); public record CreateBeatRequest( string Title, int? SortOrder = null, - Guid? CharacterId = null, + IReadOnlyList? CharacterIds = null, string? WhatHappened = null, string? WhatsNext = null, - Guid? SceneId = null, IReadOnlyList? Tags = null); public class CreateBeatRequestValidator : IModelValidator @@ -43,22 +41,13 @@ public class CreateBeatRequestValidator : IModelValidator } } -/// -/// Patch-style update. A null field is left alone; an empty string clears it. Passing a -/// list replaces the beat's tags outright. Use / -/// to detach a reference, since a null id already means "leave the -/// association alone". -/// public record UpdateBeatRequest( string? Title = null, int? SortOrder = null, - Guid? CharacterId = null, + IReadOnlyList? CharacterIds = null, string? WhatHappened = null, string? WhatsNext = null, - Guid? SceneId = null, - IReadOnlyList? Tags = null, - bool ClearCharacter = false, - bool ClearScene = false); + IReadOnlyList? Tags = null); public class UpdateBeatRequestValidator : IModelValidator { @@ -98,10 +87,6 @@ file static class BeatValidation } } -/// -/// A beat this character appears in, carrying enough of its chapter to link straight to -/// the row in that chapter's outline. -/// public record CharacterBeatResponse( Guid Id, Guid ChapterId, @@ -110,11 +95,8 @@ public record CharacterBeatResponse( int SortOrder, string Title, string? WhatHappened, - string? WhatsNext, - Guid? SceneId, - string? SceneTitle); + string? WhatsNext); -/// Reorders a chapter's beats in one call, by listing their ids in the order wanted. public record ReorderBeatsRequest(IReadOnlyList BeatIds); public class ReorderBeatsRequestValidator : IModelValidator @@ -137,12 +119,9 @@ public static class BeatMapping b.ChapterId, b.SortOrder, b.Title, - b.CharacterId, - b.Character?.Name, + [.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))], b.WhatHappened, b.WhatsNext, - b.SceneId, - b.Scene?.Title, [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], b.UpdatedAt); @@ -154,7 +133,5 @@ public static class BeatMapping b.SortOrder, b.Title, b.WhatHappened, - b.WhatsNext, - b.SceneId, - b.Scene?.Title); + b.WhatsNext); } diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index 5fbe8c0..5059502 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -8,10 +8,6 @@ using Novelly.Api.Tags; namespace Novelly.Api.Beats; -/// -/// 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, @@ -32,7 +28,6 @@ public class BeatService( .ToListAsync(ct); } - /// Null when no beat has this id — a lookup miss is expected, not exceptional. public async Task GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -41,12 +36,6 @@ public class BeatService( return await FindAsync(id, ct); } - /// - /// Every beat this character appears in, in manuscript order. This is the character - /// page's view onto the outlines: each row carries its chapter so the UI can link - /// straight to the beat in that chapter's outline. Null when no character has this id; - /// an empty list means the character exists but has no beats yet. - /// public async Task?> ListForCharacterAsync( Guid characterId, CancellationToken ct = default) { @@ -62,8 +51,7 @@ public class BeatService( var beats = await db.Beats .Include(b => b.Chapter) - .Include(b => b.Scene) - .Where(b => b.CharacterId == characterId) + .Where(b => b.Characters.Any(c => c.Id == characterId)) .ToListAsync(ct); return @@ -74,7 +62,6 @@ public class BeatService( ]; } - /// Null when no chapter has this id — a lookup miss is expected, not exceptional. public async Task CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default) { Guard.Default(chapterId, nameof(chapterId)); @@ -90,19 +77,20 @@ public class BeatService( return null; } - 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 + WhatsNext = request.WhatsNext }; + if (request.CharacterIds is { } characterIds) + { + beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); + } + if (request.Tags is { } names) { beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); @@ -111,7 +99,6 @@ public class BeatService( db.Beats.Add(beat); await db.SaveChangesAsync(ct); - // Just created it — the reload is only to pick up includes, not to check existence. return (await FindAsync(beat.Id, ct))!; } @@ -132,22 +119,21 @@ public class BeatService( var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct); if (chapter is null) { - // The beat's own chapter should always exist via the FK — an invariant - // failing, not a caller mistake, but still not found so still just null. logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId); return null; } - 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.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId; beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened); beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext); - beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId; beat.UpdatedAt = DateTimeOffset.UtcNow; + if (request.CharacterIds is { } characterIds) + { + beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct); + } + if (request.Tags is { } names) { beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct); @@ -157,7 +143,6 @@ public class BeatService( return (await FindAsync(id, ct))!; } - /// True if a beat was deleted; false if no beat had this id. public async Task DeleteAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -175,11 +160,6 @@ public class BeatService( return true; } - /// - /// 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. - /// - /// Null when the chapter carries a beat id it does not own — a lookup miss is expected, not exceptional. public async Task?> ReorderAsync( Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default) { @@ -198,8 +178,6 @@ public class BeatService( return null; } - // 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) { @@ -215,35 +193,26 @@ public class BeatService( return await ListAsync(chapterId, ct); } - private async Task ValidateReferencesAsync( - Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct) + private async Task> ResolveCharactersAsync(Guid projectId, IReadOnlyList characterIds, CancellationToken ct) { - logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId); - - if (characterId is { } cid) + var distinct = characterIds.Distinct().ToList(); + if (distinct.Count == 0) { - var belongs = await db.Characters - .AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct); - - if (!belongs) - { - logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId); - throw new InvalidOperationException( - "A beat's character must belong to the same project as its chapter."); - } + return []; } - if (sceneId is { } sid) - { - var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct); + var found = await db.Characters + .Where(c => c.ProjectId == projectId && distinct.Contains(c.Id)) + .ToListAsync(ct); - if (!belongs) - { - logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id); - throw new InvalidOperationException( - "A beat can only be grouped under a scene in the same chapter."); - } + if (found.Count != distinct.Count) + { + logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId); + throw new InvalidOperationException( + "A beat's characters must belong to the same project as its chapter."); } + + return found; } private async Task NextSortOrderAsync(Guid chapterId, CancellationToken ct) @@ -259,8 +228,7 @@ public class BeatService( private IQueryable Query() => db.Beats - .Include(b => b.Character) - .Include(b => b.Scene) + .Include(b => b.Characters) .Include(b => b.Tags); private async Task FindAsync(Guid id, CancellationToken ct) diff --git a/src/Novelly.Api/Chapters/Chapter.cs b/src/Novelly.Api/Chapters/Chapter.cs index fb2f49a..740fab1 100644 --- a/src/Novelly.Api/Chapters/Chapter.cs +++ b/src/Novelly.Api/Chapters/Chapter.cs @@ -2,29 +2,22 @@ using Novelly.Api.Beats; using Novelly.Api.Characters; using Novelly.Api.Common; using Novelly.Api.Projects; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Chapters; -/// A chapter: an ordered container of scenes plus its own planning fields. public class Chapter { public Guid Id { get; set; } = Guid.NewGuid(); public Guid ProjectId { get; set; } public Project? Project { get; set; } - /// Position in the manuscript, 1-based. 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. public Guid? PovCharacterId { get; set; } public Character? PovCharacter { get; set; } @@ -34,14 +27,14 @@ public class Chapter public DraftStatus Status { get; set; } = DraftStatus.Planned; public int? TargetWordCount { get; set; } + public string? Prose { get; set; } + + public int WordCount { get; set; } + 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/Novelly.Api/Chapters/ChapterContracts.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs index b5ebbfc..0dff154 100644 --- a/src/Novelly.Api/Chapters/ChapterContracts.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -1,7 +1,6 @@ using Novelly.Api.Beats; using Novelly.Api.Common; using Novelly.Api.Common.Validation; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Chapters; @@ -18,15 +17,10 @@ public record ChapterSummaryResponse( DraftStatus Status, int? TargetWordCount, int BeatCount, - int SceneCount, int WordCount, IReadOnlyList Tags, DateTimeOffset UpdatedAt); -/// -/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table) -/// and the prose layer (scenes). -/// public record ChapterResponse( Guid Id, Guid ProjectId, @@ -40,7 +34,8 @@ public record ChapterResponse( DraftStatus Status, int? TargetWordCount, IReadOnlyList Beats, - IReadOnlyList Scenes, + string? Prose, + int WordCount, IReadOnlyList Tags, DateTimeOffset UpdatedAt); @@ -53,6 +48,7 @@ public record CreateChapterRequest( string? Notes = null, DraftStatus Status = DraftStatus.Planned, int? TargetWordCount = null, + string? Prose = null, IReadOnlyList? Tags = null); public class CreateChapterRequestValidator : IModelValidator @@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator 200) result.AddError("Title", "'Title' must be 200 characters or fewer."); - ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result); + ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result); return result; } } -/// -/// 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, @@ -85,6 +77,7 @@ public record UpdateChapterRequest( string? Notes = null, DraftStatus? Status = null, int? TargetWordCount = null, + string? Prose = null, IReadOnlyList? Tags = null); public class UpdateChapterRequestValidator : IModelValidator @@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator? tags, ValidationResult result) + int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose, + IReadOnlyList? tags, ValidationResult result) { if (number is <= 0) result.AddError("Number", "'Number' must be greater than zero."); @@ -127,6 +121,9 @@ file static class ChapterValidation if (targetWordCount is < 0) result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater."); + if (prose is { Length: > 200000 }) + result.AddError("Prose", "'Prose' must be 200,000 characters or fewer."); + if (tags is not null && tags.Any(string.IsNullOrWhiteSpace)) result.AddError("Tags", "'Tags' must not contain blank entries."); } @@ -139,14 +136,19 @@ public static class ChapterMapping c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes, c.Status, c.TargetWordCount, [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], - [.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())], + c.Prose, c.WordCount, [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], c.UpdatedAt); public static ChapterSummaryResponse ToSummaryResponse(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.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount), + c.Beats.Count, c.WordCount, [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], c.UpdatedAt); + + public static int CountWords(string? prose) => + string.IsNullOrWhiteSpace(prose) + ? 0 + : prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; } diff --git a/src/Novelly.Api/Chapters/ChapterEndpoints.cs b/src/Novelly.Api/Chapters/ChapterEndpoints.cs index 4244e87..5df7cf0 100644 --- a/src/Novelly.Api/Chapters/ChapterEndpoints.cs +++ b/src/Novelly.Api/Chapters/ChapterEndpoints.cs @@ -35,7 +35,7 @@ public static class ChapterEndpoints chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) - .WithSummary("Read a chapter with all of its scenes."); + .WithSummary("Read a chapter with its beats and prose."); chapters.MapPatch("/{id:guid}", async ( Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) => @@ -44,7 +44,7 @@ public static class ChapterEndpoints chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) - .WithSummary("Delete a chapter and its scenes."); + .WithSummary("Delete a chapter."); return app; } diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index de3c8ab..73763ec 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -22,14 +22,12 @@ public class ChapterService( return 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); } - /// Null when no chapter has this id — a lookup miss is expected, not exceptional. public async Task GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -38,7 +36,6 @@ public class ChapterService( return await FindAsync(id, ct); } - /// Null when no project has this id — a lookup miss is expected, not exceptional. public async Task CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); @@ -63,7 +60,9 @@ public class ChapterService( Setting = request.Setting, Notes = request.Notes, Status = request.Status, - TargetWordCount = request.TargetWordCount + TargetWordCount = request.TargetWordCount, + Prose = request.Prose, + WordCount = ChapterMapping.CountWords(request.Prose) }; if (request.Tags is { } names) @@ -74,7 +73,6 @@ public class ChapterService( db.Chapters.Add(chapter); await db.SaveChangesAsync(ct); - // Just created it — the reload is only to pick up includes, not to check existence. return (await FindAsync(chapter.Id, ct))!; } @@ -100,6 +98,13 @@ public class ChapterService( chapter.Notes = Patch.Apply(chapter.Notes, request.Notes); chapter.Status = request.Status ?? chapter.Status; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; + + if (request.Prose is not null) + { + chapter.Prose = Patch.Apply(chapter.Prose, request.Prose); + chapter.WordCount = ChapterMapping.CountWords(chapter.Prose); + } + chapter.UpdatedAt = DateTimeOffset.UtcNow; if (request.Tags is { } names) @@ -111,7 +116,6 @@ public class ChapterService( return (await FindAsync(id, ct))!; } - /// True if a chapter was deleted; false if no chapter had this id. public async Task DeleteAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -148,10 +152,8 @@ public class ChapterService( var chapter = await db.Chapters .Include(c => c.PovCharacter) - .Include(c => c.Beats).ThenInclude(b => b.Character) - .Include(c => c.Beats).ThenInclude(b => b.Scene) + .Include(c => c.Beats).ThenInclude(b => b.Characters) .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); diff --git a/src/Novelly.Api/Characters/Character.cs b/src/Novelly.Api/Characters/Character.cs index 35fb0d4..9c5ed5a 100644 --- a/src/Novelly.Api/Characters/Character.cs +++ b/src/Novelly.Api/Characters/Character.cs @@ -1,12 +1,9 @@ +using Novelly.Api.Beats; using Novelly.Api.Projects; using Novelly.Api.Tags; namespace Novelly.Api.Characters; -/// -/// A character dossier. Every field beyond is optional so a writer can -/// start with a name and fill the sheet in as the character comes into focus. -/// public class Character { public Guid Id { get; set; } = Guid.NewGuid(); @@ -16,10 +13,6 @@ public class Character public string Name { get; set; } = string.Empty; public CharacterRole Role { get; set; } = CharacterRole.Supporting; - /// - /// Whether this character carries the book or supports it. New characters start as - /// supporting — a writer promotes the few who turn out to be main. - /// public CharacterImportance Importance { get; set; } = CharacterImportance.Supporting; public string? Age { get; set; } @@ -30,22 +23,15 @@ public class Character public string? Personality { get; set; } public string? Backstory { get; set; } - /// What the character consciously wants. public string? Want { get; set; } - /// What the character actually needs — usually at odds with . public string? Need { get; set; } public string? InternalConflict { get; set; } public string? ExternalConflict { get; set; } - /// - /// How the character changes over the course of the book, in a sentence or two. - /// breaks the same change into ordered steps. - /// public string? ArcSummary { get; set; } - /// Speech patterns, verbal tics, register — anything that makes dialogue sound like them. public string? Voice { get; set; } public string? Notes { get; set; } @@ -56,11 +42,11 @@ public class Character public List Relationships { get; set; } = []; public List Tags { get; set; } = []; - /// The character's arc, in order. Kept mainly for main characters. public List ArcStages { get; set; } = []; + + public List Beats { get; set; } = []; } -/// A directed relationship from one character to another. public class CharacterRelationship { public Guid Id { get; set; } = Guid.NewGuid(); @@ -71,7 +57,6 @@ public class CharacterRelationship public Guid RelatedCharacterId { get; set; } public Character? RelatedCharacter { get; set; } - /// e.g. "sister", "rival", "former mentor". public string RelationshipType { get; set; } = string.Empty; public string? Description { get; set; } diff --git a/src/Novelly.Api/Common/DraftStatus.cs b/src/Novelly.Api/Common/DraftStatus.cs index 07b7d5c..f865aeb 100644 --- a/src/Novelly.Api/Common/DraftStatus.cs +++ b/src/Novelly.Api/Common/DraftStatus.cs @@ -1,6 +1,5 @@ namespace Novelly.Api.Common; -/// How far along a chapter or scene is in the drafting pipeline. public enum DraftStatus { Planned, diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index 4c06184..9f214a3 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -9,16 +9,10 @@ using Novelly.Api.Data; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Common; -/// -/// Wires up every feature's services in one place. Endpoints, the embedded agent and the -/// MCP server all resolve the same instances, so a capability added here is available to -/// all three. -/// public static class NovellyServiceRegistration { public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration) @@ -35,7 +29,6 @@ public static class NovellyServiceRegistration services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -43,8 +36,6 @@ public static class NovellyServiceRegistration services.Configure(configuration.GetSection(AgentOptions.SectionName)); services.AddScoped(); - // A single unbounded queue shared by the request path (writer, in ImportService) - // and the background runner (reader) — the only background-job infra in the app. services.AddSingleton(Channel.CreateUnbounded()); services.AddScoped(); services.AddScoped(); diff --git a/src/Novelly.Api/Data/INovelDbContext.cs b/src/Novelly.Api/Data/INovelDbContext.cs index 8071242..608220d 100644 --- a/src/Novelly.Api/Data/INovelDbContext.cs +++ b/src/Novelly.Api/Data/INovelDbContext.cs @@ -6,15 +6,10 @@ using Novelly.Api.Characters; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Data; -/// -/// The persistence surface the application services depend on. Infrastructure supplies -/// the EF Core implementation; tests can point it at an in-memory SQLite connection. -/// public interface INovelDbContext { DbSet Projects { get; } @@ -24,7 +19,6 @@ public interface INovelDbContext DbSet Beats { get; } DbSet Tags { get; } DbSet Chapters { get; } - DbSet Scenes { get; } DbSet OpenQuestions { get; } DbSet Conversations { get; } DbSet AgentMessages { get; } diff --git a/src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.Designer.cs b/src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.Designer.cs new file mode 100644 index 0000000..0ecb81f --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.Designer.cs @@ -0,0 +1,768 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats")] + partial class RemoveScenesAddChapterProseAndMultiCharacterBeats + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + 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("Novelly.Api.Agent.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("Novelly.Api.Agent.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("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + 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("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.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("Prose") + .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.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PovCharacterId"); + + b.HasIndex("ProjectId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.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("Importance") + .IsRequired() + .HasMaxLength(32) + .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("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.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("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.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("Phase") + .IsRequired() + .HasMaxLength(32) + .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("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("ProjectId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("ProjectId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.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("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Conversations") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") + .WithMany() + .HasForeignKey("PovCharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Chapters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PovCharacter"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Characters") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + b.Navigation("RelatedCharacter"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany() + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Projects.Project", "Project") + .WithMany("Tags") + .HasForeignKey("ProjectId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Project"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Projects.Project", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.cs b/src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.cs new file mode 100644 index 0000000..a1b769c --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats.cs @@ -0,0 +1,184 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class RemoveScenesAddChapterProseAndMultiCharacterBeats : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Beats_Characters_CharacterId", + table: "Beats"); + + migrationBuilder.DropForeignKey( + name: "FK_Beats_Scenes_SceneId", + table: "Beats"); + + migrationBuilder.DropTable( + name: "Scenes"); + + migrationBuilder.DropIndex( + name: "IX_Beats_CharacterId", + table: "Beats"); + + migrationBuilder.DropIndex( + name: "IX_Beats_SceneId", + table: "Beats"); + + migrationBuilder.DropColumn( + name: "CharacterId", + table: "Beats"); + + migrationBuilder.DropColumn( + name: "SceneId", + table: "Beats"); + + migrationBuilder.AddColumn( + name: "Prose", + table: "Chapters", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "WordCount", + table: "Chapters", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.CreateTable( + name: "BeatCharacters", + columns: table => new + { + BeatsId = table.Column(type: "TEXT", nullable: false), + CharactersId = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_BeatCharacters", x => new { x.BeatsId, x.CharactersId }); + table.ForeignKey( + name: "FK_BeatCharacters_Beats_BeatsId", + column: x => x.BeatsId, + principalTable: "Beats", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_BeatCharacters_Characters_CharactersId", + column: x => x.CharactersId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_BeatCharacters_CharactersId", + table: "BeatCharacters", + column: "CharactersId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "BeatCharacters"); + + migrationBuilder.DropColumn( + name: "Prose", + table: "Chapters"); + + migrationBuilder.DropColumn( + name: "WordCount", + table: "Chapters"); + + migrationBuilder.AddColumn( + name: "CharacterId", + table: "Beats", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "SceneId", + table: "Beats", + type: "TEXT", + nullable: true); + + migrationBuilder.CreateTable( + name: "Scenes", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + ChapterId = table.Column(type: "TEXT", nullable: false), + PovCharacterId = table.Column(type: "TEXT", nullable: true), + Conflict = table.Column(type: "TEXT", nullable: true), + CreatedAt = table.Column(type: "INTEGER", nullable: false), + Goal = table.Column(type: "TEXT", nullable: true), + Location = table.Column(type: "TEXT", nullable: true), + Outcome = table.Column(type: "TEXT", nullable: true), + Prose = table.Column(type: "TEXT", nullable: true), + SortOrder = table.Column(type: "INTEGER", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, 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), + WordCount = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Scenes", x => x.Id); + table.ForeignKey( + name: "FK_Scenes_Chapters_ChapterId", + column: x => x.ChapterId, + principalTable: "Chapters", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_Scenes_Characters_PovCharacterId", + column: x => x.PovCharacterId, + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + }); + + migrationBuilder.CreateIndex( + name: "IX_Beats_CharacterId", + table: "Beats", + column: "CharacterId"); + + migrationBuilder.CreateIndex( + name: "IX_Beats_SceneId", + table: "Beats", + column: "SceneId"); + + migrationBuilder.CreateIndex( + name: "IX_Scenes_ChapterId_SortOrder", + table: "Scenes", + columns: new[] { "ChapterId", "SortOrder" }); + + migrationBuilder.CreateIndex( + name: "IX_Scenes_PovCharacterId", + table: "Scenes", + column: "PovCharacterId"); + + migrationBuilder.AddForeignKey( + name: "FK_Beats_Characters_CharacterId", + table: "Beats", + column: "CharacterId", + principalTable: "Characters", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + + migrationBuilder.AddForeignKey( + name: "FK_Beats_Scenes_SceneId", + table: "Beats", + column: "SceneId", + principalTable: "Scenes", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 0769f4f..47577c6 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -17,6 +17,21 @@ namespace Novelly.Api.Data.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + modelBuilder.Entity("BeatTag", b => { b.Property("BeatsId") @@ -133,15 +148,9 @@ namespace Novelly.Api.Data.Migrations 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"); @@ -161,10 +170,6 @@ namespace Novelly.Api.Data.Migrations b.HasKey("Id"); - b.HasIndex("CharacterId"); - - b.HasIndex("SceneId"); - b.HasIndex("ChapterId", "SortOrder"); b.ToTable("Beats"); @@ -191,6 +196,9 @@ namespace Novelly.Api.Data.Migrations b.Property("ProjectId") .HasColumnType("TEXT"); + b.Property("Prose") + .HasColumnType("TEXT"); + b.Property("Setting") .HasColumnType("TEXT"); @@ -213,6 +221,9 @@ namespace Novelly.Api.Data.Migrations b.Property("UpdatedAt") .HasColumnType("INTEGER"); + b.Property("WordCount") + .HasColumnType("INTEGER"); + b.HasKey("Id"); b.HasIndex("PovCharacterId"); @@ -497,67 +508,6 @@ namespace Novelly.Api.Data.Migrations b.ToTable("OpenQuestions"); }); - modelBuilder.Entity("Novelly.Api.Scenes.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("Novelly.Api.Tags.Tag", b => { b.Property("Id") @@ -587,6 +537,21 @@ namespace Novelly.Api.Data.Migrations b.ToTable("Tags"); }); + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("BeatTag", b => { b.HasOne("Novelly.Api.Beats.Beat", null) @@ -662,21 +627,7 @@ namespace Novelly.Api.Data.Migrations .OnDelete(DeleteBehavior.Cascade) .IsRequired(); - b.HasOne("Novelly.Api.Characters.Character", "Character") - .WithMany() - .HasForeignKey("CharacterId") - .OnDelete(DeleteBehavior.SetNull); - - b.HasOne("Novelly.Api.Scenes.Scene", "Scene") - .WithMany() - .HasForeignKey("SceneId") - .OnDelete(DeleteBehavior.SetNull); - b.Navigation("Chapter"); - - b.Navigation("Character"); - - b.Navigation("Scene"); }); modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => @@ -770,24 +721,6 @@ namespace Novelly.Api.Data.Migrations b.Navigation("Project"); }); - modelBuilder.Entity("Novelly.Api.Scenes.Scene", b => - { - b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") - .WithMany("Scenes") - .HasForeignKey("ChapterId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); - - b.HasOne("Novelly.Api.Characters.Character", "PovCharacter") - .WithMany() - .HasForeignKey("PovCharacterId") - .OnDelete(DeleteBehavior.SetNull); - - b.Navigation("Chapter"); - - b.Navigation("PovCharacter"); - }); - modelBuilder.Entity("Novelly.Api.Tags.Tag", b => { b.HasOne("Novelly.Api.Projects.Project", "Project") @@ -807,8 +740,6 @@ namespace Novelly.Api.Data.Migrations modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => { b.Navigation("Beats"); - - b.Navigation("Scenes"); }); modelBuilder.Entity("Novelly.Api.Characters.Character", b => diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index dc502bf..fc13211 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -7,17 +7,10 @@ using Novelly.Api.Characters; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; -using Novelly.Api.Scenes; using Novelly.Api.Tags; namespace Novelly.Api.Data; -/// -/// Stores a as UTC ticks. SQLite has no native type for it -/// and refuses to ORDER BY the default text form, which every "most recently updated -/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC -/// loses nothing. -/// internal class UtcTicksConverter() : ValueConverter( value => value.UtcTicks, @@ -33,7 +26,6 @@ public class NovelDbContext(DbContextOptions options) public DbSet Beats => Set(); public DbSet Tags => Set(); public DbSet Chapters => Set(); - public DbSet Scenes => Set(); public DbSet OpenQuestions => Set(); public DbSet Conversations => Set(); public DbSet AgentMessages => Set(); @@ -80,8 +72,6 @@ public class NovelDbContext(DbContextOptions options) entity.Property(s => s.Title).IsRequired().HasMaxLength(200); entity.HasIndex(s => new { s.CharacterId, s.SortOrder }); - // An arc stage outlives the chapter it was pinned to: deleting a chapter is a - // decision about the manuscript, not about how the character changes. entity.HasOne(s => s.Chapter).WithMany() .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); }); @@ -90,9 +80,6 @@ public class NovelDbContext(DbContextOptions options) { entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120); - // Restrict on the inverse side: deleting a character should not silently take - // the other character's relationship rows with it via a second cascade path, - // which SQLite rejects as a multiple-cascade cycle. entity.HasOne(r => r.RelatedCharacter).WithMany() .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); }); @@ -105,13 +92,8 @@ public class NovelDbContext(DbContextOptions options) entity.HasOne(b => b.Chapter).WithMany(c => c.Beats) .HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade); - // 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); + entity.HasMany(b => b.Characters).WithMany(c => c.Beats) + .UsingEntity(join => join.ToTable("BeatCharacters")); }); builder.Entity(entity => @@ -119,8 +101,6 @@ public class NovelDbContext(DbContextOptions options) 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) @@ -139,19 +119,6 @@ public class NovelDbContext(DbContextOptions options) entity.HasOne(c => c.PovCharacter).WithMany() .HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull); - - entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!) - .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade); - }); - - builder.Entity(entity => - { - entity.Property(s => s.Title).IsRequired().HasMaxLength(300); - entity.Property(s => s.Status).HasConversion().HasMaxLength(32); - entity.HasIndex(s => new { s.ChapterId, s.SortOrder }); - - entity.HasOne(s => s.PovCharacter).WithMany() - .HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull); }); builder.Entity(entity => @@ -159,15 +126,11 @@ public class NovelDbContext(DbContextOptions options) entity.Property(q => q.Question).IsRequired().HasMaxLength(500); entity.Ignore(q => q.IsResolved); - // Open questions are listed per project and filtered to a chapter or character, - // so index the project and let the filters narrow from there. entity.HasIndex(q => q.ProjectId); entity.HasOne(q => q.Project).WithMany() .HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade); - // A question survives what it was about. Deleting a chapter or character should - // not quietly take an unresolved decision with it. entity.HasOne(q => q.Chapter).WithMany() .HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull); entity.HasOne(q => q.Character).WithMany() @@ -192,8 +155,6 @@ public class NovelDbContext(DbContextOptions options) entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000); entity.Property(j => j.Status).HasConversion().HasMaxLength(16); - // No FK to Project: a job outlives the project it created, including the - // force-restart path where that project is deleted out from under it. entity.HasIndex(j => j.SourceRoot); }); } diff --git a/src/Novelly.Api/Imports/ImportAgentToolset.cs b/src/Novelly.Api/Imports/ImportAgentToolset.cs index d40fb44..05615f0 100644 --- a/src/Novelly.Api/Imports/ImportAgentToolset.cs +++ b/src/Novelly.Api/Imports/ImportAgentToolset.cs @@ -8,30 +8,14 @@ using Novelly.Api.Projects; namespace Novelly.Api.Imports; -/// -/// A lookup a tool performed came back empty. Not an exception — the underlying service -/// already said so by returning null — just a value -/// recognises and turns into the same error-result shape a caught exception would produce. -/// internal record ImportToolNotFound(string Message); -/// A tool the import agent can call, bound to a handler that runs against this run's state. internal record ImportAgentTool( string Name, string Description, JsonElement InputSchema, Func> Handler); -/// -/// The tools the outline-import agent can reach for: read-only, root-scoped filesystem -/// access to the source folder, a write capability limited to exactly the resume ledger, -/// and the same application services the chat agent and REST API use for everything else. -/// -/// Deliberately a separate toolset from rather than an -/// extension of it — filesystem access must never be reachable from a normal chat -/// conversation. One instance is built per import run (see ), so -/// the current project id lives here rather than being threaded through every call. -/// public class ImportAgentToolset( ProjectService projects, CharacterService characters, @@ -54,17 +38,14 @@ public class ImportAgentToolset( public IReadOnlyList Definitions => [.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))]; - /// Binds this instance to one run. Must be called before any tool executes. public void Initialize(string sourceRoot, Guid? existingProjectId) { _sourceRoot = sourceRoot; ProjectId = existingProjectId; } - /// Reads the ledger directly — the run driver's ground truth for "is this done", not the model's say-so. public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot); - /// Runs a tool and serialises its result. Failures come back as text so the model can read and self-correct. public async Task ExecuteAsync(string name, JsonElement input, CancellationToken ct = default) { if (!ByName.TryGetValue(name, out var tool)) @@ -196,8 +177,6 @@ public class ImportAgentToolset( { var json = JsonInput.RequiredString(input, "json"); - // Fail loudly on malformed JSON now rather than writing garbage the next - // run's read_ledger can't parse. using var _ = JsonDocument.Parse(json); File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json); @@ -345,7 +324,7 @@ public class ImportAgentToolset( new JsonSchemaBuilder() .Str("chapter_id", "Id of the chapter the beat belongs to.", required: true) .Str("title", "The Beat column — three to five words.", required: true) - .Str("character_id", "Id of the character named in the Character column, if it resolves.") + .StringArray("character_ids", "Ids of the characters named in the Character column, if they resolve.") .Str("what_happened", "The What column.") .Str("whats_next", "The Why column.") .Build(), @@ -356,7 +335,7 @@ public class ImportAgentToolset( chapterId, new CreateBeatRequest( JsonInput.RequiredString(input, "title"), - CharacterId: JsonInput.Guid(input, "character_id"), + CharacterIds: JsonInput.Guids(input, "character_ids"), WhatHappened: JsonInput.String(input, "what_happened"), WhatsNext: JsonInput.String(input, "whats_next")), ct); diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 2843800..8ecb001 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -10,14 +10,11 @@ using Novelly.Api.Data; using Novelly.Api.Imports; using Novelly.Api.Projects; using Novelly.Api.Questions; -using Novelly.Api.Scenes; using Novelly.Api.Tags; using Serilog; var builder = WebApplication.CreateBuilder(args); -// AddSerilog (not UseSerilog) so it becomes an additional logging provider rather than -// replacing the one AddServiceDefaults wires up for the Aspire dashboard. builder.Services.AddSerilog((services, config) => config .ReadFrom.Configuration(builder.Configuration) .ReadFrom.Services(services) @@ -28,8 +25,6 @@ builder.Services.AddNovelly(builder.Configuration); builder.Services.AddOpenApi(); builder.Services.AddProblemDetails(); -// Enums travel as their names, so the React client and the MCP server both read -// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered. builder.Services.ConfigureHttpJsonOptions(options => options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); @@ -43,16 +38,11 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy var app = builder.Build(); -// Local-first tool: bring the SQLite file up to date on boot rather than making the -// writer run a migration command before they can open the app. using (var scope = app.Services.CreateScope()) { await scope.ServiceProvider.GetRequiredService().Database.MigrateAsync(); } -// Serilog's request logging wraps the exception handler (registered first = outermost) -// so it reads the status code the handler already resolved, rather than seeing the raw -// exception fly past and misreporting a handled 404 as a 500. app.UseSerilogRequestLogging(); app.UseExceptionHandler(handler => handler.Run(async context => @@ -95,7 +85,6 @@ app.MapProjectEndpoints() .MapCharacterEndpoints() .MapChapterEndpoints() .MapBeatEndpoints() - .MapSceneEndpoints() .MapTagEndpoints() .MapOpenQuestionEndpoints() .MapAgentEndpoints() @@ -103,5 +92,4 @@ app.MapProjectEndpoints() app.Run(); -/// Exposed so the tests can spin the API up with WebApplicationFactory. public partial class Program; diff --git a/src/Novelly.Api/Projects/ProjectService.cs b/src/Novelly.Api/Projects/ProjectService.cs index 8cd1b91..70d4917 100644 --- a/src/Novelly.Api/Projects/ProjectService.cs +++ b/src/Novelly.Api/Projects/ProjectService.cs @@ -27,12 +27,11 @@ public class ProjectService( p.Phase, p.Characters.Count, p.Chapters.Count, - p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0, + p.Chapters.Sum(c => (int?)c.WordCount) ?? 0, p.UpdatedAt)) .ToListAsync(ct); } - /// Null when no project has this id — a lookup miss is expected, not exceptional. public async Task GetAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); @@ -92,7 +91,6 @@ public class ProjectService( return project; } - /// True if a project was deleted; false if no project had this id. public async Task DeleteAsync(Guid id, CancellationToken ct = default) { Guard.Default(id, nameof(id)); diff --git a/src/Novelly.Api/Scenes/Scene.cs b/src/Novelly.Api/Scenes/Scene.cs deleted file mode 100644 index 6b3eaa4..0000000 --- a/src/Novelly.Api/Scenes/Scene.cs +++ /dev/null @@ -1,45 +0,0 @@ -using Novelly.Api.Chapters; -using Novelly.Api.Characters; -using Novelly.Api.Common; - -namespace Novelly.Api.Scenes; - -/// -/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent -/// works with when turning an outline into prose. -/// -public class Scene -{ - public Guid Id { get; set; } = Guid.NewGuid(); - public Guid ChapterId { get; set; } - public Chapter? Chapter { get; set; } - - /// Position within the chapter, 1-based. - public int SortOrder { get; set; } - - public string Title { get; set; } = string.Empty; - public string? Summary { get; set; } - - /// What the POV character is trying to achieve. - public string? Goal { get; set; } - - /// What stands in the way. - public string? Conflict { get; set; } - - /// How it lands — and what it costs. - public string? Outcome { get; set; } - - public Guid? PovCharacterId { get; set; } - public Character? PovCharacter { get; set; } - - public string? Location { get; set; } - - /// The drafted prose, if any. - public string? Prose { get; set; } - - public int WordCount { get; set; } - public DraftStatus Status { get; set; } = DraftStatus.Planned; - - public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; - public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; -} diff --git a/src/Novelly.Api/Scenes/SceneContracts.cs b/src/Novelly.Api/Scenes/SceneContracts.cs deleted file mode 100644 index e717212..0000000 --- a/src/Novelly.Api/Scenes/SceneContracts.cs +++ /dev/null @@ -1,128 +0,0 @@ -using Novelly.Api.Common; -using Novelly.Api.Common.Validation; - -namespace Novelly.Api.Scenes; - -public record SceneResponse( - Guid Id, - Guid ChapterId, - int SortOrder, - string Title, - string? Summary, - string? Goal, - string? Conflict, - string? Outcome, - Guid? PovCharacterId, - string? PovCharacterName, - string? Location, - string? Prose, - int WordCount, - DraftStatus Status, - DateTimeOffset UpdatedAt); - -public record CreateSceneRequest( - string Title, - int? SortOrder = null, - string? Summary = null, - string? Goal = null, - string? Conflict = null, - string? Outcome = null, - Guid? PovCharacterId = null, - string? Location = null, - string? Prose = null, - DraftStatus Status = DraftStatus.Planned); - -public class CreateSceneRequestValidator : IModelValidator -{ - public ValidationResult Validate(CreateSceneRequest model) - { - var result = new ValidationResult(); - - if (string.IsNullOrWhiteSpace(model.Title)) - result.AddError("Title", "'Title' must not be empty."); - else if (model.Title.Length > 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - - SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result); - - return result; - } -} - -public record UpdateSceneRequest( - string? Title = null, - int? SortOrder = null, - string? Summary = null, - string? Goal = null, - string? Conflict = null, - string? Outcome = null, - Guid? PovCharacterId = null, - string? Location = null, - string? Prose = null, - DraftStatus? Status = null); - -public class UpdateSceneRequestValidator : IModelValidator -{ - public ValidationResult Validate(UpdateSceneRequest model) - { - var result = new ValidationResult(); - - if (model.Title is not null) - { - if (model.Title.Length == 0) - result.AddError("Title", "'Title' can not be cleared — a scene always needs one."); - else if (model.Title.Length > 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - } - - SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result); - - return result; - } -} - -file static class SceneValidation -{ - public static void OptionalFields( - int? sortOrder, string? summary, string? goal, string? conflict, string? outcome, string? location, string? prose, ValidationResult result) - { - if (sortOrder is < 0) - result.AddError("SortOrder", "'Sort Order' must be zero or greater."); - - if (summary is { Length: > 20000 }) - result.AddError("Summary", "'Summary' must be 20,000 characters or fewer."); - - if (goal is { Length: > 20000 }) - result.AddError("Goal", "'Goal' must be 20,000 characters or fewer."); - - if (conflict is { Length: > 20000 }) - result.AddError("Conflict", "'Conflict' must be 20,000 characters or fewer."); - - if (outcome is { Length: > 20000 }) - result.AddError("Outcome", "'Outcome' must be 20,000 characters or fewer."); - - if (location is { Length: > 500 }) - result.AddError("Location", "'Location' must be 500 characters or fewer."); - - if (prose is { Length: > 100000 }) - result.AddError("Prose", "'Prose' must be 100,000 characters or fewer."); - } -} - -public static class SceneMapping -{ - public static SceneResponse ToResponse(this Scene s) => new( - s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary, - s.Goal, s.Conflict, s.Outcome, - s.PovCharacterId, s.PovCharacter?.Name, s.Location, - s.Prose, s.WordCount, s.Status, s.UpdatedAt); - - /// - /// Whitespace-delimited word count. Good enough for progress tracking, and it costs - /// nothing to recompute on every save. - /// - public static int CountWords(string? prose) => - string.IsNullOrWhiteSpace(prose) - ? 0 - : prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; -} diff --git a/src/Novelly.Api/Scenes/SceneEndpoints.cs b/src/Novelly.Api/Scenes/SceneEndpoints.cs deleted file mode 100644 index ba8a940..0000000 --- a/src/Novelly.Api/Scenes/SceneEndpoints.cs +++ /dev/null @@ -1,51 +0,0 @@ -using Novelly.Api.Common; -using Novelly.Api.Common.Validation; - -namespace Novelly.Api.Scenes; - -public static class SceneEndpoints -{ - public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app) - { - var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes") - .AddEndpointFilter() - .AddEndpointFilter(); - - chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) => - Results.Ok((await service.ListAsync(chapterId, ct)).Select(s => s.ToResponse()))) - .WithSummary("List a chapter's scenes in order."); - - chapterScoped.MapPost("/", async ( - Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) => - { - var scene = await service.CreateAsync(chapterId, request, ct); - if (scene is null) - { - return Results.NotFound(); - } - - var created = scene.ToResponse(); - return Results.Created($"/api/scenes/{created.Id}", created); - }) - .WithSummary("Add a scene to a chapter."); - - var scenes = app.MapGroup("/api/scenes").WithTags("Scenes") - .AddEndpointFilter() - .AddEndpointFilter(); - - scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => - (await service.GetAsync(id, ct))?.ToResponse().ToApiResult()) - .WithSummary("Read a scene, including its prose."); - - scenes.MapPatch("/{id:guid}", async ( - Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) => - (await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult()) - .WithSummary("Update a scene. Sending prose recomputes the word count."); - - scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) => - await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) - .WithSummary("Delete a scene."); - - return app; - } -} diff --git a/src/Novelly.Api/Scenes/SceneService.cs b/src/Novelly.Api/Scenes/SceneService.cs deleted file mode 100644 index 09016d2..0000000 --- a/src/Novelly.Api/Scenes/SceneService.cs +++ /dev/null @@ -1,156 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Novelly.Api.Common; -using Novelly.Api.Common.Validation; -using Novelly.Api.Data; - -namespace Novelly.Api.Scenes; - -public class SceneService( - INovelDbContext db, - ILogger logger, - IModelValidator createValidator, - IModelValidator updateValidator) -{ - public async Task> ListAsync(Guid chapterId, CancellationToken ct = default) - { - Guard.Default(chapterId, nameof(chapterId)); - - logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId); - - return await Query() - .Where(s => s.ChapterId == chapterId) - .OrderBy(s => s.SortOrder) - .ToListAsync(ct); - } - - /// Null when no scene has this id — a lookup miss is expected, not exceptional. - public async Task GetAsync(Guid id, CancellationToken ct = default) - { - Guard.Default(id, nameof(id)); - - logger.LogInformation("Getting scene {SceneId}", id); - return await FindAsync(id, ct); - } - - /// Null when no chapter has this id — a lookup miss is expected, not exceptional. - public async Task CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default) - { - Guard.Default(chapterId, nameof(chapterId)); - Guard.Null(request, nameof(request)); - createValidator.Validate(request).ThrowIfInvalid(); - - logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0); - - if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct)) - { - logger.LogInformation("Rejected scene creation: chapter {ChapterId} not found", chapterId); - return null; - } - - var scene = new Scene - { - ChapterId = chapterId, - Title = request.Title, - SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct), - Summary = request.Summary, - Goal = request.Goal, - Conflict = request.Conflict, - Outcome = request.Outcome, - PovCharacterId = request.PovCharacterId, - Location = request.Location, - Prose = request.Prose, - WordCount = SceneMapping.CountWords(request.Prose), - Status = request.Status - }; - - db.Scenes.Add(scene); - await db.SaveChangesAsync(ct); - - // Just created it — the reload is only to pick up includes, not to check existence. - return (await FindAsync(scene.Id, ct))!; - } - - public async Task UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default) - { - Guard.Default(id, nameof(id)); - Guard.Null(request, nameof(request)); - updateValidator.Validate(request).ThrowIfInvalid(); - - logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0); - - var scene = await FindAsync(id, ct); - if (scene is null) - { - return null; - } - - scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title; - scene.SortOrder = request.SortOrder ?? scene.SortOrder; - scene.Summary = Patch.Apply(scene.Summary, request.Summary); - scene.Goal = Patch.Apply(scene.Goal, request.Goal); - scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict); - scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome); - scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId; - scene.Location = Patch.Apply(scene.Location, request.Location); - scene.Status = request.Status ?? scene.Status; - - if (request.Prose is not null) - { - scene.Prose = Patch.Apply(scene.Prose, request.Prose); - scene.WordCount = SceneMapping.CountWords(scene.Prose); - } - - scene.UpdatedAt = DateTimeOffset.UtcNow; - - await db.SaveChangesAsync(ct); - return (await FindAsync(id, ct))!; - } - - /// True if a scene was deleted; false if no scene had this id. - public async Task DeleteAsync(Guid id, CancellationToken ct = default) - { - Guard.Default(id, nameof(id)); - - logger.LogInformation("Deleting scene {SceneId}", id); - - var scene = await FindAsync(id, ct); - if (scene is null) - { - return false; - } - - db.Scenes.Remove(scene); - await db.SaveChangesAsync(ct); - return true; - } - - private async Task NextSortOrderAsync(Guid chapterId, CancellationToken ct) - { - logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId); - - var max = await db.Scenes - .Where(s => s.ChapterId == chapterId) - .MaxAsync(s => (int?)s.SortOrder, ct); - - return (max ?? 0) + 1; - } - - private IQueryable Query() => db.Scenes.Include(s => s.PovCharacter); - - private async Task FindAsync(Guid id, CancellationToken ct) - { - logger.LogDebug("Finding scene {SceneId}", id); - - var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct); - if (scene is null) - { - logger.LogInformation("Scene {SceneId} not found", id); - } - else - { - logger.LogDebug("Found scene {SceneId}", id); - } - - return scene; - } -} diff --git a/src/Novelly.Api/Tags/TagContracts.cs b/src/Novelly.Api/Tags/TagContracts.cs index b496905..39d8fe3 100644 --- a/src/Novelly.Api/Tags/TagContracts.cs +++ b/src/Novelly.Api/Tags/TagContracts.cs @@ -58,11 +58,6 @@ public class UpdateTagRequestValidator : IModelValidator } } -/// -/// 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 TagReferencesResponse( TagResponse Tag, IReadOnlyList Characters, @@ -105,12 +100,8 @@ public static class TagMapping b.Chapter?.Title ?? "(unknown chapter)", b.SortOrder, b.Title, - b.Character?.Name, + b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null, b.WhatHappened))]); - /// - /// 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/Novelly.Api/Tags/TagService.cs b/src/Novelly.Api/Tags/TagService.cs index 6713ea2..c921827 100644 --- a/src/Novelly.Api/Tags/TagService.cs +++ b/src/Novelly.Api/Tags/TagService.cs @@ -26,7 +26,6 @@ public class TagService( .ToListAsync(ct); } - /// Everything in the project carrying this tag. Null when no tag has this id. public async Task GetReferencesAsync(Guid tagId, CancellationToken ct = default) { Guard.Default(tagId, nameof(tagId)); @@ -36,7 +35,7 @@ public class TagService( 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.Characters) .Include(t => t.Beats).ThenInclude(b => b.Chapter) .FirstOrDefaultAsync(t => t.Id == tagId, ct); @@ -46,7 +45,6 @@ public class TagService( return tag; } - /// Null when no project has this id — a lookup miss is expected, not exceptional. public async Task CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default) { Guard.Default(projectId, nameof(projectId)); @@ -110,7 +108,6 @@ public class TagService( return tag; } - /// Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted. public async Task DeleteAsync(Guid tagId, CancellationToken ct = default) { Guard.Default(tagId, nameof(tagId)); @@ -129,11 +126,6 @@ public class TagService( return true; } - /// - /// 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) { diff --git a/src/Novelly.Mcp/Tools/BeatTools.cs b/src/Novelly.Mcp/Tools/BeatTools.cs index 9c41fdb..06c7825 100644 --- a/src/Novelly.Mcp/Tools/BeatTools.cs +++ b/src/Novelly.Mcp/Tools/BeatTools.cs @@ -9,8 +9,8 @@ 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.")] + + "title, who it belongs to, what happened, and what it sets up. The chapter's " + + "summary paragraph and drafted prose sit on the chapter itself, via get_chapter.")] public static Task GetChapterOutline( NovelApiClient api, [Description("The chapter's id.")] Guid chapterId, @@ -26,34 +26,29 @@ public static class BeatTools [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("Ids of the characters whose beat this is.")] Guid[]? characterIds = 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); + new { title, sortOrder, characterIds, whatHappened, whatsNext, 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. A character or " - + "scene id already set stays put unless you pass clearCharacter/clearScene — " - + "leaving the id null means 'don't touch it', not 'remove it'.")] + [Description("Revise a beat. Only the fields you supply change. Supplying a characterIds or " + + "tag list replaces the beat's characters or tags outright — pass an empty list " + + "to clear one, and include everything 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("Ids of the characters whose beat this is. Replaces the existing list.")] Guid[]? characterIds = 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, - [Description("Detach this beat's character, leaving it unassigned.")] bool clearCharacter = false, - [Description("Detach this beat's scene, leaving it ungrouped.")] bool clearScene = false) => + [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, clearCharacter, clearScene }, ct); + new { title, sortOrder, characterIds, whatHappened, whatsNext, tags }, ct); [McpServerTool(Name = "delete_beat")] [Description("Remove a beat from a chapter's outline. Confirm with the writer first.")] diff --git a/src/Novelly.Mcp/Tools/ManuscriptTools.cs b/src/Novelly.Mcp/Tools/ManuscriptTools.cs index 40f7b5e..4f68102 100644 --- a/src/Novelly.Mcp/Tools/ManuscriptTools.cs +++ b/src/Novelly.Mcp/Tools/ManuscriptTools.cs @@ -8,7 +8,7 @@ namespace Novelly.Mcp.Tools; public static class ManuscriptTools { [McpServerTool(Name = "list_chapters")] - [Description("List a project's chapters in manuscript order, with scene and word counts.")] + [Description("List a project's chapters in manuscript order, with beat and word counts.")] public static Task ListChapters( NovelApiClient api, [Description("The project's id.")] Guid projectId, @@ -16,7 +16,7 @@ public static class ManuscriptTools api.GetAsync($"/api/projects/{projectId}/chapters", ct); [McpServerTool(Name = "get_chapter")] - [Description("Read one chapter in full, including every scene and any drafted prose.")] + [Description("Read one chapter in full: its outline (beats) and its drafted prose.")] public static Task GetChapter( NovelApiClient api, [Description("The chapter's id.")] Guid chapterId, @@ -36,6 +36,7 @@ public static class ManuscriptTools [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("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null, [Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) => api.PostAsync($"/api/projects/{projectId}/chapters", new { @@ -46,11 +47,14 @@ public static class ManuscriptTools setting, status = status ?? "Planned", targetWordCount, + prose, tags }, ct); [McpServerTool(Name = "update_chapter")] - [Description("Revise a chapter's title, number, summary, POV character, setting, notes or status.")] + [Description("Revise a chapter's title, number, summary, POV character, setting, notes, status " + + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in " + + "markdown; the word count is recomputed automatically.")] public static Task UpdateChapter( NovelApiClient api, [Description("The chapter's id.")] Guid chapterId, @@ -63,77 +67,8 @@ public static class ManuscriptTools [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("The chapter's drafted text, in markdown.")] string? prose = 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, tags }, ct); - - [McpServerTool(Name = "list_scenes")] - [Description("List a chapter's scenes in order.")] - public static Task ListScenes( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - CancellationToken ct) => - api.GetAsync($"/api/chapters/{chapterId}/scenes", ct); - - [McpServerTool(Name = "create_scene")] - [Description("Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene " - + "draftable later, so fill those in when there is enough to work with.")] - public static Task CreateScene( - NovelApiClient api, - [Description("The chapter's id.")] Guid chapterId, - [Description("Scene title.")] string title, - CancellationToken ct, - [Description("Position within the chapter. Appended to the end when omitted.")] int? sortOrder = null, - [Description("What happens in the scene.")] string? summary = null, - [Description("What the POV character is trying to achieve.")] string? goal = null, - [Description("What stands in the way.")] string? conflict = null, - [Description("How it lands, and what it costs.")] string? outcome = null, - [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, - [Description("Where the scene takes place.")] string? location = null, - [Description("Drafted prose for the scene, if you are writing it now.")] string? prose = null, - [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null) => - api.PostAsync($"/api/chapters/{chapterId}/scenes", new - { - title, - sortOrder, - summary, - goal, - conflict, - outcome, - povCharacterId, - location, - prose, - status = status ?? "Planned" - }, ct); - - [McpServerTool(Name = "update_scene")] - [Description("Revise a scene. Supplying 'prose' writes or replaces the scene's draft text and " - + "recomputes its word count.")] - public static Task UpdateScene( - NovelApiClient api, - [Description("The scene's id.")] Guid sceneId, - CancellationToken ct, - [Description("New title.")] string? title = null, - [Description("Position within the chapter.")] int? sortOrder = null, - [Description("What happens in the scene.")] string? summary = null, - [Description("What the POV character is trying to achieve.")] string? goal = null, - [Description("What stands in the way.")] string? conflict = null, - [Description("How it lands, and what it costs.")] string? outcome = null, - [Description("Id of the point-of-view character.")] Guid? povCharacterId = null, - [Description("Where the scene takes place.")] string? location = null, - [Description("Drafted prose for the scene.")] string? prose = null, - [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null) => - api.PatchAsync($"/api/scenes/{sceneId}", new - { - title, - sortOrder, - summary, - goal, - conflict, - outcome, - povCharacterId, - location, - prose, - status - }, ct); + new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, prose, tags }, ct); } diff --git a/src/Novelly.Web/package-lock.json b/src/Novelly.Web/package-lock.json index a9103ac..268d00e 100644 --- a/src/Novelly.Web/package-lock.json +++ b/src/Novelly.Web/package-lock.json @@ -11,6 +11,7 @@ "@tanstack/react-query": "^5.101.4", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-markdown": "^10.1.0", "react-router-dom": "^7.18.2" }, "devDependencies": { @@ -1213,6 +1214,48 @@ "react": "^18 || ^19" } }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz", + "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, "node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -1227,7 +1270,6 @@ "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==", - "dev": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -1243,6 +1285,16 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.3.tgz", + "integrity": "sha512-60YRaenCQcVjYEKOcG824+DRGGIQ3VKErcBoAEDJZz5bKIs2ZG+X/H9Nk+Q6EVkwJk5QNApxbrc5QtBSwtrXAg==" + }, "node_modules/@vitejs/plugin-react": { "version": "6.0.5", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz", @@ -1269,6 +1321,69 @@ } } }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -1286,9 +1401,44 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1299,6 +1449,18 @@ "node": ">=8" } }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/enhanced-resolve": { "version": "5.24.5", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", @@ -1313,6 +1475,20 @@ "node": ">=10.13.0" } }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1353,6 +1529,109 @@ "dev": true, "license": "ISC" }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -1624,6 +1903,15 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -1634,6 +1922,577 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, "node_modules/nanoid": { "version": "3.3.17", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz", @@ -1702,6 +2561,29 @@ } } }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1798,6 +2680,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/react": { "version": "19.2.8", "resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz", @@ -1819,6 +2710,32 @@ "react": "^19.2.8" } }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, "node_modules/react-router": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", @@ -1857,6 +2774,37 @@ "react-dom": ">=18" } }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/rolldown": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz", @@ -1912,6 +2860,44 @@ "node": ">=0.10.0" } }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tailwindcss": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", @@ -1950,6 +2936,24 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1971,6 +2975,113 @@ "dev": true, "license": "MIT" }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/vite": { "version": "8.2.0", "resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz", @@ -2048,6 +3159,15 @@ "optional": true } } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/src/Novelly.Web/package.json b/src/Novelly.Web/package.json index cbbda13..a8326e7 100644 --- a/src/Novelly.Web/package.json +++ b/src/Novelly.Web/package.json @@ -13,6 +13,7 @@ "@tanstack/react-query": "^5.101.4", "react": "^19.2.8", "react-dom": "^19.2.8", + "react-markdown": "^10.1.0", "react-router-dom": "^7.18.2" }, "devDependencies": { diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index ac529f9..94007af 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -16,7 +16,6 @@ import type { OpenQuestion, Project, ProjectSummary, - Scene, TagReferences, TagSummary, } from './types' @@ -36,8 +35,6 @@ export const keys = { importJob: (id: string) => ['imports', id] as const, } -// --- Projects --------------------------------------------------------------- - export const useProjects = () => useQuery({ queryKey: keys.projects, queryFn: () => api.get('/api/projects') }) @@ -72,8 +69,6 @@ export function useDeleteProject() { }) } -// --- Characters ------------------------------------------------------------- - export const useCharacters = (projectId: string) => useQuery({ queryKey: keys.characters(projectId), @@ -112,10 +107,6 @@ export function useDeleteCharacter(projectId: string) { }) } -/** - * Every beat this character appears in, across the whole book. Kept separate from the - * dossier because it is derived from the outlines — what they actually do on the page. - */ export const useCharacterBeats = (characterId: string | undefined) => useQuery({ queryKey: keys.characterBeats(characterId ?? ''), @@ -123,8 +114,6 @@ export const useCharacterBeats = (characterId: string | undefined) => enabled: Boolean(characterId), }) -// --- Character arcs ---------------------------------------------------------- - export function useCreateArcStage(projectId: string) { const qc = useQueryClient() return useMutation({ @@ -160,12 +149,6 @@ export function useReorderArcStages(projectId: string) { }) } -// --- Open questions ---------------------------------------------------------- - -/** - * The project's undecided questions. Filters narrow to one chapter outline or character; - * resolved ones are left out unless asked for, since the list is about what is still open. - */ export const useOpenQuestions = ( projectId: string, filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {}, @@ -201,10 +184,6 @@ export function useUpdateQuestion(projectId: string) { }) } -/** - * Resolving can append the decision to the notes of whatever the question hangs off, so - * this invalidates the chapter and character caches as well as the question list. - */ export function useResolveQuestion(projectId: string) { const qc = useQueryClient() return useMutation({ @@ -234,8 +213,6 @@ export function useDeleteQuestion(projectId: string) { }) } -// --- Tags -------------------------------------------------------------------- - export const useTags = (projectId: string) => useQuery({ queryKey: keys.tags(projectId), @@ -258,10 +235,6 @@ export function useUpdateTag(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({ @@ -270,13 +243,12 @@ export function useDeleteTag() { }) } -// --- Beats (a chapter's outline) --------------------------------------------- - export function useCreateBeat(chapterId: string, projectId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: (body: Partial & { title: string }) => - api.post(`/api/chapters/${chapterId}/beats`, body), + mutationFn: ( + body: Partial> & { title: string; tags?: string[]; characterIds?: string[] }, + ) => api.post(`/api/chapters/${chapterId}/beats`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) qc.invalidateQueries({ queryKey: keys.tags(projectId) }) @@ -287,7 +259,10 @@ export function useCreateBeat(chapterId: string, projectId: string) { export function useUpdateBeat(chapterId: string, projectId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, ...body }: Partial> & { id: string; tags?: string[] }) => + mutationFn: ({ + id, + ...body + }: Partial> & { id: string; tags?: string[]; characterIds?: string[] }) => api.patch(`/api/beats/${id}`, body), onSuccess: () => { qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }) @@ -313,8 +288,6 @@ export function useReorderBeats(chapterId: string) { }) } -// --- Chapters and scenes ---------------------------------------------------- - export const useChapters = (projectId: string) => useQuery({ queryKey: keys.chapters(projectId), @@ -358,34 +331,6 @@ export function useDeleteChapter(projectId: string) { }) } -export function useCreateScene(chapterId: string) { - const qc = useQueryClient() - return useMutation({ - mutationFn: (body: Partial & { title: string }) => - api.post(`/api/chapters/${chapterId}/scenes`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), - }) -} - -export function useUpdateScene(chapterId: string) { - const qc = useQueryClient() - return useMutation({ - mutationFn: ({ id, ...body }: Partial & { id: string }) => - api.patch(`/api/scenes/${id}`, body), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), - }) -} - -export function useDeleteScene(chapterId: string) { - const qc = useQueryClient() - return useMutation({ - mutationFn: (id: string) => api.delete(`/api/scenes/${id}`), - onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), - }) -} - -// --- Agent ------------------------------------------------------------------ - export const useConversations = (projectId: string) => useQuery({ queryKey: keys.conversations(projectId), @@ -407,7 +352,6 @@ export function useSendAgentMessage(projectId: string) { onSuccess: (turn) => { qc.invalidateQueries({ queryKey: keys.conversations(projectId) }) 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.tags(projectId) }) qc.invalidateQueries({ queryKey: keys.chapters(projectId) }) @@ -417,8 +361,6 @@ export function useSendAgentMessage(projectId: string) { }) } -// --- Outline import ---------------------------------------------------------- - export function useInspectImport() { return useMutation({ mutationFn: (sourceRoot: string) => api.post('/api/imports/inspect', { sourceRoot }), @@ -434,11 +376,6 @@ export function useStartImport() { const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused'] -/** - * Polls a running import job. This is the app's first polling hook — there's no - * SSE/websocket infrastructure to reuse — so it stops on its own once the job reaches a - * terminal status rather than depending on the caller to unmount it in time. - */ export function useImportJob(jobId: string | undefined) { return useQuery({ queryKey: keys.importJob(jobId ?? ''), diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 1ef0e7c..8886ec3 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -1,4 +1,3 @@ -// Mirrors the DTOs in the Novelly.Api feature folders. Enums travel as their names. export type CharacterRole = | 'Protagonist' @@ -21,7 +20,6 @@ export const characterRoles: CharacterRole[] = [ 'Foil', ] -/** How much of the book a character carries. Separate from the part they play. */ export type CharacterImportance = 'Main' | 'Supporting' export const characterImportances: CharacterImportance[] = ['Main', 'Supporting'] @@ -30,7 +28,6 @@ export type DraftStatus = 'Planned' | 'Outlined' | 'Drafted' | 'Revised' | 'Fina export const draftStatuses: DraftStatus[] = ['Planned', 'Outlined', 'Drafted', 'Revised', 'Final'] -/** Where a novel is in its lifecycle, from first notes to a finished manuscript. */ export type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete' export const projectPhases: ProjectPhase[] = ['Brainstorming', 'Outlining', 'Writing', 'Editing', 'Complete'] @@ -92,18 +89,19 @@ export interface TagReferences { }[] } -/** One row of a chapter's outline. Flat and ordered — no nesting. */ +export interface BeatCharacter { + id: string + name: string +} + export interface Beat { id: string chapterId: string sortOrder: number title: string - characterId: string | null - characterName: string | null + characters: BeatCharacter[] whatHappened: string | null whatsNext: string | null - sceneId: string | null - sceneTitle: string | null tags: Tag[] updatedAt: string } @@ -116,7 +114,6 @@ export interface Relationship { description: string | null } -/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */ export interface ArcStage { id: string characterId: string @@ -129,7 +126,6 @@ export interface ArcStage { updatedAt: string } -/** A beat a character appears in, carrying its chapter so the page can link into the outline. */ export interface CharacterBeat { id: string chapterId: string @@ -139,8 +135,6 @@ export interface CharacterBeat { title: string whatHappened: string | null whatsNext: string | null - sceneId: string | null - sceneTitle: string | null } export interface Character { @@ -168,24 +162,6 @@ export interface Character { updatedAt: string } -export interface Scene { - id: string - chapterId: string - sortOrder: number - title: string - summary: string | null - goal: string | null - conflict: string | null - outcome: string | null - povCharacterId: string | null - povCharacterName: string | null - location: string | null - prose: string | null - wordCount: number - status: DraftStatus - updatedAt: string -} - export interface ChapterSummary { id: string projectId: string @@ -198,20 +174,19 @@ export interface ChapterSummary { status: DraftStatus targetWordCount: number | null beatCount: number - sceneCount: number wordCount: number tags: Tag[] updatedAt: string } -export interface Chapter extends Omit { +export interface Chapter extends Omit { notes: string | null beats: Beat[] - scenes: Scene[] + prose: string | null + wordCount: number updatedAt: string } -/** Something the writer has not decided yet, hung off a chapter outline and/or a character. */ export interface OpenQuestion { id: string projectId: string @@ -260,8 +235,6 @@ export interface AgentTurn { message: AgentMessage } -// --- Outline import ----------------------------------------------------------- - export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused' export interface ImportJob { @@ -276,7 +249,6 @@ export interface ImportJob { updatedAt: string } -/** Whether a source folder is ready for a fresh import, has one to resume, or is already done. */ export type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete' export interface ImportInspection { diff --git a/src/Novelly.Web/src/components/CharacterMultiSelect.tsx b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx new file mode 100644 index 0000000..d826c4f --- /dev/null +++ b/src/Novelly.Web/src/components/CharacterMultiSelect.tsx @@ -0,0 +1,78 @@ +import { useState } from 'react' +import type { BeatCharacter } from '../api/types' + +export function CharacterChip({ character, onRemove }: { character: BeatCharacter; onRemove?: () => void }) { + return ( + + {character.name} + {onRemove && ( + + )} + + ) +} + +export function CharacterMultiSelect({ + selected, + options, + onChange, +}: { + selected: BeatCharacter[] + options: { id: string; name: string }[] + onChange: (ids: string[]) => void +}) { + const [draft, setDraft] = useState('') + const listId = 'character-multiselect-options' + + const add = () => { + const name = draft.trim() + setDraft('') + if (!name) return + + const match = options.find((o) => o.name.toLowerCase() === name.toLowerCase()) + if (match && !selected.some((c) => c.id === match.id)) { + onChange([...selected.map((c) => c.id), match.id]) + } + } + + const remove = (id: string) => onChange(selected.filter((c) => c.id !== id).map((c) => c.id)) + + const unused = options.filter((o) => !selected.some((c) => c.id === o.id)) + + return ( +
+ {selected.map((character) => ( + remove(character.id)} /> + ))} + setDraft(e.target.value)} + onBlur={add} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + add() + } + }} + /> + + {unused.map((o) => ( + +
+ ) +} diff --git a/src/Novelly.Web/src/components/MarkdownEditor.tsx b/src/Novelly.Web/src/components/MarkdownEditor.tsx new file mode 100644 index 0000000..1484158 --- /dev/null +++ b/src/Novelly.Web/src/components/MarkdownEditor.tsx @@ -0,0 +1,72 @@ +import { useEffect, useRef, useState } from 'react' +import ReactMarkdown from 'react-markdown' + +export function MarkdownEditor({ + value, + onCommit, + placeholder, + rows = 24, +}: { + value: string | null + onCommit: (next: string) => void + placeholder?: string + rows?: number +}) { + const [draft, setDraft] = useState(value ?? '') + const [mode, setMode] = useState<'write' | 'preview'>('write') + const committed = useRef(value ?? '') + + useEffect(() => { + const incoming = value ?? '' + if (incoming !== committed.current) { + committed.current = incoming + setDraft(incoming) + } + }, [value]) + + const commit = () => { + if (draft !== committed.current) { + committed.current = draft + onCommit(draft) + } + } + + return ( +
+
+ + +
+ + {mode === 'write' ? ( +