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/.
This commit is contained in:
@@ -3,10 +3,6 @@ using System.Text.Json;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class JsonSchemaBuilder
|
||||
{
|
||||
private readonly JsonObject _properties = [];
|
||||
@@ -80,7 +76,6 @@ public class JsonSchemaBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lenient readers for tool input, which arrives as untyped JSON.</summary>
|
||||
public static class JsonInput
|
||||
{
|
||||
public static string? String(JsonElement input, string name) =>
|
||||
@@ -114,10 +109,6 @@ public static class JsonInput
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a boolean flag. Models sometimes send <c>"true"</c> as a string even when the
|
||||
/// schema says boolean, so both spellings are accepted.
|
||||
/// </summary>
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an array of strings. Returns null when the property is absent, which the
|
||||
/// services read as "leave the existing list alone" — distinct from an empty array,
|
||||
/// which clears it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string>? Strings(JsonElement input, string name)
|
||||
{
|
||||
if (input.ValueKind != JsonValueKind.Object
|
||||
@@ -155,4 +141,8 @@ public static class JsonInput
|
||||
|
||||
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
|
||||
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
|
||||
|
||||
public static IReadOnlyList<Guid>? 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();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class NovelAgentService(
|
||||
INovelDbContext db,
|
||||
IAgentModelClient model,
|
||||
@@ -41,7 +37,6 @@ public class NovelAgentService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(conversationId, nameof(conversationId));
|
||||
@@ -51,7 +46,6 @@ public class NovelAgentService(
|
||||
return await FindConversationAsync(conversationId, ct);
|
||||
}
|
||||
|
||||
/// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
|
||||
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(conversationId, nameof(conversationId));
|
||||
@@ -69,12 +63,6 @@ public class NovelAgentService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<AgentMessage?> 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<AgentContentBlock>();
|
||||
@@ -182,11 +162,6 @@ public class NovelAgentService(
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private async Task<AgentMessage> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private static List<AgentChatMessage> 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(
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Derives a conversation title from its opening message.</summary>
|
||||
private static string Summarise(string message)
|
||||
{
|
||||
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
|
||||
public record AgentToolResult(string Content, bool IsError);
|
||||
|
||||
/// <summary>
|
||||
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
||||
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
|
||||
/// recognises and turns into the same error-result shape a caught exception would produce.
|
||||
/// </summary>
|
||||
internal record ToolNotFound(string Message);
|
||||
|
||||
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
||||
public record AgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
SceneService scenes,
|
||||
TagService tags,
|
||||
OpenQuestionService questions,
|
||||
ILogger<NovelAgentToolset> logger)
|
||||
@@ -56,10 +42,6 @@ public class NovelAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
@@ -95,16 +77,13 @@ public class NovelAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
|
||||
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<TEntity, TResponse>(
|
||||
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
|
||||
await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
|
||||
private static async Task<object> DeletedOrNotFound(Task<bool> 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<DraftStatus>())
|
||||
.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<DraftStatus>(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<DraftStatus>())
|
||||
.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<DraftStatus>(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<DraftStatus>(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<DraftStatus>(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<DraftStatus>());
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// One row of a chapter's outline: a short label, who it belongs to, what happened, and
|
||||
/// what it sets up. Beats are the planning layer — flat and ordered within a chapter,
|
||||
/// with no nesting. A beat may optionally be grouped under the <see cref="Scene"/> that
|
||||
/// will eventually carry its prose.
|
||||
/// </summary>
|
||||
public class Beat
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -18,24 +11,14 @@ public class Beat
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Optional grouping: the scene this beat will be written into.</summary>
|
||||
public Guid? SceneId { get; set; }
|
||||
public Scene? Scene { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter. Gaps are allowed.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
|
||||
public Guid? CharacterId { get; set; }
|
||||
public Character? Character { get; set; }
|
||||
public List<Character> Characters { get; set; } = [];
|
||||
|
||||
/// <summary>The event itself.</summary>
|
||||
public string? WhatHappened { get; set; }
|
||||
|
||||
/// <summary>What it sets in motion — the hook into the next beat.</summary>
|
||||
public string? WhatsNext { get; set; }
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
|
||||
@@ -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<BeatCharacterResponse> Characters,
|
||||
string? WhatHappened,
|
||||
string? WhatsNext,
|
||||
Guid? SceneId,
|
||||
string? SceneTitle,
|
||||
IReadOnlyList<TagResponse> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateBeatRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
Guid? CharacterId = null,
|
||||
IReadOnlyList<Guid>? CharacterIds = null,
|
||||
string? WhatHappened = null,
|
||||
string? WhatsNext = null,
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
||||
@@ -43,22 +41,13 @@ public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the beat's tags outright. Use <see cref="ClearCharacter"/> /
|
||||
/// <see cref="ClearScene"/> to detach a reference, since a null id already means "leave the
|
||||
/// association alone".
|
||||
/// </summary>
|
||||
public record UpdateBeatRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
Guid? CharacterId = null,
|
||||
IReadOnlyList<Guid>? CharacterIds = null,
|
||||
string? WhatHappened = null,
|
||||
string? WhatsNext = null,
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
bool ClearCharacter = false,
|
||||
bool ClearScene = false);
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
|
||||
{
|
||||
@@ -98,10 +87,6 @@ file static class BeatValidation
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A beat this character appears in, carrying enough of its chapter to link straight to
|
||||
/// the row in that chapter's outline.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
|
||||
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
|
||||
/// here is scoped to one chapter.
|
||||
/// </summary>
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
TagService tags,
|
||||
@@ -32,7 +28,6 @@ public class BeatService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -41,12 +36,6 @@ public class BeatService(
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Beat>?> 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(
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Beat?> 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))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -175,11 +160,6 @@ public class BeatService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
|
||||
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
|
||||
/// </summary>
|
||||
/// <summary>Null when the chapter carries a beat id it does not own — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<IReadOnlyList<Beat>?> 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<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> 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<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
@@ -259,8 +228,7 @@ public class BeatService(
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
db.Beats
|
||||
.Include(b => b.Character)
|
||||
.Include(b => b.Scene)
|
||||
.Include(b => b.Characters)
|
||||
.Include(b => b.Tags);
|
||||
|
||||
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
/// <summary>Position in the manuscript, 1-based.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The paragraph that opens the chapter's outline, above the beat table.
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>Whose head we are in for this chapter.</summary>
|
||||
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;
|
||||
|
||||
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
|
||||
public List<Scene> Scenes { get; set; } = [];
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -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<TagResponse> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
|
||||
/// and the prose layer (scenes).
|
||||
/// </summary>
|
||||
public record ChapterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
@@ -40,7 +34,8 @@ public record ChapterResponse(
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
IReadOnlyList<BeatResponse> Beats,
|
||||
IReadOnlyList<SceneResponse> Scenes,
|
||||
string? Prose,
|
||||
int WordCount,
|
||||
IReadOnlyList<TagResponse> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
@@ -53,6 +48,7 @@ public record CreateChapterRequest(
|
||||
string? Notes = null,
|
||||
DraftStatus Status = DraftStatus.Planned,
|
||||
int? TargetWordCount = null,
|
||||
string? Prose = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
|
||||
@@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
|
||||
else if (model.Title.Length > 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the chapter's tags outright.
|
||||
/// </summary>
|
||||
public record UpdateChapterRequest(
|
||||
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<string>? Tags = null);
|
||||
|
||||
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
|
||||
@@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
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;
|
||||
}
|
||||
@@ -110,7 +103,8 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
file static class ChapterValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result)
|
||||
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose,
|
||||
IReadOnlyList<string>? 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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -38,7 +36,6 @@ public class ChapterService(
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Chapter?> 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))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>
|
||||
public async Task<bool> 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);
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>
|
||||
/// A character dossier. Every field beyond <see cref="Name"/> is optional so a writer can
|
||||
/// start with a name and fill the sheet in as the character comes into focus.
|
||||
/// </summary>
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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; }
|
||||
|
||||
/// <summary>What the character consciously wants.</summary>
|
||||
public string? Want { get; set; }
|
||||
|
||||
/// <summary>What the character actually needs — usually at odds with <see cref="Want"/>.</summary>
|
||||
public string? Need { get; set; }
|
||||
|
||||
public string? InternalConflict { get; set; }
|
||||
public string? ExternalConflict { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the character changes over the course of the book, in a sentence or two.
|
||||
/// <see cref="ArcStages"/> breaks the same change into ordered steps.
|
||||
/// </summary>
|
||||
public string? ArcSummary { get; set; }
|
||||
|
||||
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
|
||||
public string? Voice { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
@@ -56,11 +42,11 @@ public class Character
|
||||
public List<CharacterRelationship> Relationships { get; set; } = [];
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>The character's arc, in order. Kept mainly for main characters.</summary>
|
||||
public List<CharacterArcStage> ArcStages { get; set; } = [];
|
||||
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A directed relationship from one character to another.</summary>
|
||||
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; }
|
||||
|
||||
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
|
||||
public string RelationshipType { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
|
||||
public enum DraftStatus
|
||||
{
|
||||
Planned,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public static class NovellyServiceRegistration
|
||||
{
|
||||
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
|
||||
@@ -35,7 +29,6 @@ public static class NovellyServiceRegistration
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<SceneService>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
services.AddScoped<NovelAgentService>();
|
||||
@@ -43,8 +36,6 @@ public static class NovellyServiceRegistration
|
||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
||||
|
||||
// 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<Guid>());
|
||||
services.AddScoped<ImportService>();
|
||||
services.AddScoped<ImportAgentToolset>();
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// The persistence surface the application services depend on. Infrastructure supplies
|
||||
/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
|
||||
/// </summary>
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
@@ -24,7 +19,6 @@ public interface INovelDbContext
|
||||
DbSet<Beat> Beats { get; }
|
||||
DbSet<Tag> Tags { get; }
|
||||
DbSet<Chapter> Chapters { get; }
|
||||
DbSet<Scene> Scenes { get; }
|
||||
DbSet<OpenQuestion> OpenQuestions { get; }
|
||||
DbSet<AgentConversation> Conversations { get; }
|
||||
DbSet<AgentMessage> AgentMessages { get; }
|
||||
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "CharactersId");
|
||||
|
||||
b.HasIndex("CharactersId");
|
||||
|
||||
b.ToTable("BeatCharacters", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("BeatTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ChaptersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CharactersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("CharacterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("WhatHappened")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WhatsNext")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Beats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Importance")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelationshipType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RelatedCharacterId");
|
||||
|
||||
b.ToTable("CharacterRelationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ChaptersCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChaptersTotal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SourceRoot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceRoot");
|
||||
|
||||
b.ToTable("ImportJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Question")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Resolution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolvedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("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
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveScenesAddChapterProseAndMultiCharacterBeats : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<string>(
|
||||
name: "Prose",
|
||||
table: "Chapters",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WordCount",
|
||||
table: "Chapters",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BeatCharacters",
|
||||
columns: table => new
|
||||
{
|
||||
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CharactersId = table.Column<Guid>(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");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BeatCharacters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Prose",
|
||||
table: "Chapters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WordCount",
|
||||
table: "Chapters");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CharacterId",
|
||||
table: "Beats",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "SceneId",
|
||||
table: "Beats",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Scenes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Conflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Goal = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Location = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Outcome = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Prose = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
WordCount = table.Column<int>(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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "CharactersId");
|
||||
|
||||
b.HasIndex("CharactersId");
|
||||
|
||||
b.ToTable("BeatCharacters", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
@@ -133,15 +148,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SceneId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -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<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -213,6 +221,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("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 =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a <see cref="DateTimeOffset"/> 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.
|
||||
/// </summary>
|
||||
internal class UtcTicksConverter()
|
||||
: ValueConverter<DateTimeOffset, long>(
|
||||
value => value.UtcTicks,
|
||||
@@ -33,7 +26,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
public DbSet<Beat> Beats => Set<Beat>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
public DbSet<Scene> Scenes => Set<Scene>();
|
||||
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
@@ -80,8 +72,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> 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<NovelDbContext> 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<NovelDbContext> 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<Tag>(entity =>
|
||||
@@ -119,8 +101,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> 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<NovelDbContext> 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<Scene>(entity =>
|
||||
{
|
||||
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(s => s.Status).HasConversion<string>().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<OpenQuestion>(entity =>
|
||||
@@ -159,15 +126,11 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> 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<NovelDbContext> options)
|
||||
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
|
||||
entity.Property(j => j.Status).HasConversion<string>().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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,30 +8,14 @@ using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
||||
/// already said so by returning null — just a value <see cref="ImportAgentToolset.ExecuteAsync"/>
|
||||
/// recognises and turns into the same error-result shape a caught exception would produce.
|
||||
/// </summary>
|
||||
internal record ImportToolNotFound(string Message);
|
||||
|
||||
/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary>
|
||||
internal record ImportAgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="NovelAgentToolset"/> 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 <see cref="Initialize"/>), so
|
||||
/// the current project id lives here rather than being threaded through every call.
|
||||
/// </summary>
|
||||
public class ImportAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
@@ -54,17 +38,14 @@ public class ImportAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>Binds this instance to one run. Must be called before any tool executes.</summary>
|
||||
public void Initialize(string sourceRoot, Guid? existingProjectId)
|
||||
{
|
||||
_sourceRoot = sourceRoot;
|
||||
ProjectId = existingProjectId;
|
||||
}
|
||||
|
||||
/// <summary>Reads the ledger directly — the run driver's ground truth for "is this done", not the model's say-so.</summary>
|
||||
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
|
||||
|
||||
/// <summary>Runs a tool and serialises its result. Failures come back as text so the model can read and self-correct.</summary>
|
||||
public async Task<AgentToolResult> 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);
|
||||
|
||||
|
||||
@@ -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<NovelDbContext>().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();
|
||||
|
||||
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
|
||||
public partial class Program;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -92,7 +91,6 @@ public class ProjectService(
|
||||
return project;
|
||||
}
|
||||
|
||||
/// <summary>True if a project was deleted; false if no project had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
|
||||
/// works with when turning an outline into prose.
|
||||
/// </summary>
|
||||
public class Scene
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter, 1-based.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>What the POV character is trying to achieve.</summary>
|
||||
public string? Goal { get; set; }
|
||||
|
||||
/// <summary>What stands in the way.</summary>
|
||||
public string? Conflict { get; set; }
|
||||
|
||||
/// <summary>How it lands — and what it costs.</summary>
|
||||
public string? Outcome { get; set; }
|
||||
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
public string? Location { get; set; }
|
||||
|
||||
/// <summary>The drafted prose, if any.</summary>
|
||||
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;
|
||||
}
|
||||
@@ -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<CreateSceneRequest>
|
||||
{
|
||||
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<UpdateSceneRequest>
|
||||
{
|
||||
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);
|
||||
|
||||
/// <summary>
|
||||
/// Whitespace-delimited word count. Good enough for progress tracking, and it costs
|
||||
/// nothing to recompute on every save.
|
||||
/// </summary>
|
||||
public static int CountWords(string? prose) =>
|
||||
string.IsNullOrWhiteSpace(prose)
|
||||
? 0
|
||||
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
}
|
||||
@@ -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<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<SceneService> logger,
|
||||
IModelValidator<CreateSceneRequest> createValidator,
|
||||
IModelValidator<UpdateSceneRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Scene>> 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);
|
||||
}
|
||||
|
||||
/// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Scene?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting scene {SceneId}", id);
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Scene?> 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<Scene?> 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))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a scene was deleted; false if no scene had this id.</summary>
|
||||
public async Task<bool> 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<int> 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<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
|
||||
|
||||
private async Task<Scene?> 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;
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,6 @@ public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything carrying one tag, gathered in a single response. This is the whole point of
|
||||
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
|
||||
/// makes them worth maintaining.
|
||||
/// </summary>
|
||||
public record TagReferencesResponse(
|
||||
TagResponse Tag,
|
||||
IReadOnlyList<TaggedCharacterResponse> 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))]);
|
||||
|
||||
/// <summary>
|
||||
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
|
||||
/// "betrayal" resolve to one tag rather than quietly becoming two.
|
||||
/// </summary>
|
||||
public static string Normalise(string name) => name.Trim();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ public class TagService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
|
||||
public async Task<Tag?> 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;
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
@@ -110,7 +108,6 @@ public class TagService(
|
||||
return tag;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(tagId, nameof(tagId));
|
||||
@@ -129,11 +126,6 @@ public class TagService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a list of names into tag entities, creating any the project has not seen
|
||||
/// before. Typing a new tag on a beat should just work rather than being a two-step
|
||||
/// "create the tag, then apply it".
|
||||
/// </summary>
|
||||
internal async Task<List<Tag>> ResolveAsync(
|
||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
{
|
||||
|
||||
@@ -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<CallToolResult> 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<CallToolResult> UpdateBeat(
|
||||
NovelApiClient api,
|
||||
[Description("The beat's id.")] Guid beatId,
|
||||
CancellationToken ct,
|
||||
[Description("Three to five words naming the beat.")] string? title = null,
|
||||
[Description("Position in the chapter.")] int? sortOrder = null,
|
||||
[Description("Id of the character whose beat this is.")] Guid? characterId = null,
|
||||
[Description("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.")]
|
||||
|
||||
@@ -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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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<CallToolResult> 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);
|
||||
}
|
||||
|
||||
Generated
+1122
-2
File diff suppressed because it is too large
Load Diff
@@ -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": {
|
||||
|
||||
@@ -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<ProjectSummary[]>('/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<Beat> & { title: string }) =>
|
||||
api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
|
||||
mutationFn: (
|
||||
body: Partial<Omit<Beat, 'tags' | 'characters'>> & { title: string; tags?: string[]; characterIds?: string[] },
|
||||
) => api.post<Beat>(`/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<Omit<Beat, 'tags'>> & { id: string; tags?: string[] }) =>
|
||||
mutationFn: ({
|
||||
id,
|
||||
...body
|
||||
}: Partial<Omit<Beat, 'tags' | 'characters'>> & { id: string; tags?: string[]; characterIds?: string[] }) =>
|
||||
api.patch<Beat>(`/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<Scene> & { title: string }) =>
|
||||
api.post<Scene>(`/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<Scene> & { id: string }) =>
|
||||
api.patch<Scene>(`/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<ImportInspection>('/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 ?? ''),
|
||||
|
||||
@@ -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<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
|
||||
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'> {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useState } from 'react'
|
||||
import type { BeatCharacter } from '../api/types'
|
||||
|
||||
export function CharacterChip({ character, onRemove }: { character: BeatCharacter; onRemove?: () => void }) {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
|
||||
>
|
||||
{character.name}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="opacity-60 transition hover:opacity-100"
|
||||
aria-label={`Remove ${character.name}`}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{selected.map((character) => (
|
||||
<CharacterChip key={character.id} character={character} onRemove={() => remove(character.id)} />
|
||||
))}
|
||||
<input
|
||||
className="input w-28 flex-1 px-2 py-0.5 text-xs"
|
||||
value={draft}
|
||||
list={listId}
|
||||
placeholder="Add character…"
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={add}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ',') {
|
||||
e.preventDefault()
|
||||
add()
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<datalist id={listId}>
|
||||
{unused.map((o) => (
|
||||
<option key={o.id} value={o.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
<div>
|
||||
<div className="mb-2 flex justify-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn px-2 py-1 text-xs ${mode === 'write' ? '' : 'opacity-60'}`}
|
||||
onClick={() => setMode('write')}
|
||||
>
|
||||
Write
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn px-2 py-1 text-xs ${mode === 'preview' ? '' : 'opacity-60'}`}
|
||||
onClick={() => {
|
||||
commit()
|
||||
setMode('preview')
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'write' ? (
|
||||
<textarea
|
||||
className="input font-mono text-sm"
|
||||
rows={rows}
|
||||
value={draft}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
/>
|
||||
) : (
|
||||
<div className="markdown-preview card min-h-[20rem] p-4">
|
||||
{draft.trim() ? <ReactMarkdown>{draft}</ReactMarkdown> : <p className="muted">Nothing written yet.</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,10 +6,6 @@
|
||||
--font-mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/*
|
||||
* Warm paper light theme, cool ink dark theme. Colours are declared as variables so
|
||||
* every surface, border and accent moves together when the scheme flips.
|
||||
*/
|
||||
:root {
|
||||
--paper: #faf7f0;
|
||||
--surface: #ffffff;
|
||||
@@ -132,4 +128,57 @@ body {
|
||||
font-family: var(--font-sans);
|
||||
@apply text-[1.0625rem] leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-preview {
|
||||
font-family: var(--font-sans);
|
||||
@apply text-[1.0625rem] leading-relaxed;
|
||||
}
|
||||
|
||||
.markdown-preview :is(h1, h2, h3, h4) {
|
||||
@apply mt-5 mb-2 font-semibold first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-preview h1 {
|
||||
@apply text-2xl;
|
||||
}
|
||||
|
||||
.markdown-preview h2 {
|
||||
@apply text-xl;
|
||||
}
|
||||
|
||||
.markdown-preview h3 {
|
||||
@apply text-lg;
|
||||
}
|
||||
|
||||
.markdown-preview p {
|
||||
@apply mb-4;
|
||||
}
|
||||
|
||||
.markdown-preview :is(ul, ol) {
|
||||
@apply mb-4 ml-5;
|
||||
}
|
||||
|
||||
.markdown-preview ul {
|
||||
@apply list-disc;
|
||||
}
|
||||
|
||||
.markdown-preview ol {
|
||||
@apply list-decimal;
|
||||
}
|
||||
|
||||
.markdown-preview blockquote {
|
||||
@apply my-4 border-l-2 pl-4 italic;
|
||||
border-color: var(--line);
|
||||
color: var(--ink-muted);
|
||||
}
|
||||
|
||||
.markdown-preview code {
|
||||
@apply rounded px-1 py-0.5 text-sm;
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
.markdown-preview hr {
|
||||
@apply my-6;
|
||||
border-color: var(--line);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,8 +75,8 @@ export default function AgentPage() {
|
||||
<div className="card p-6">
|
||||
<h2 className="text-lg font-semibold">Your writing partner</h2>
|
||||
<p className="mt-1 text-sm muted">
|
||||
It can read and edit the brief, the outline, character dossiers, chapters and
|
||||
scenes — the same data you see in the other tabs.
|
||||
It can read and edit the brief, the outline, character dossiers and chapter
|
||||
prose — the same data you see in the other tabs.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{starters.map((starter) => (
|
||||
|
||||
@@ -4,22 +4,23 @@ import {
|
||||
useChapter,
|
||||
useCharacters,
|
||||
useCreateBeat,
|
||||
useCreateScene,
|
||||
useDeleteBeat,
|
||||
useDeleteChapter,
|
||||
useDeleteScene,
|
||||
useReorderBeats,
|
||||
useTags,
|
||||
useUpdateBeat,
|
||||
useUpdateChapter,
|
||||
useUpdateScene,
|
||||
} from '../api/hooks'
|
||||
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
||||
import { draftStatuses, type Beat, type Chapter } from '../api/types'
|
||||
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
|
||||
import { MarkdownEditor } from '../components/MarkdownEditor'
|
||||
import { OpenQuestions } from '../components/OpenQuestions'
|
||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||
|
||||
type ChapterTab = 'outline' | 'prose'
|
||||
|
||||
export default function ChapterPage() {
|
||||
const { projectId = '', chapterId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
@@ -29,10 +30,9 @@ export default function ChapterPage() {
|
||||
const update = useUpdateChapter(projectId)
|
||||
const remove = useDeleteChapter(projectId)
|
||||
const createBeat = useCreateBeat(chapterId, projectId)
|
||||
const createScene = useCreateScene(chapterId)
|
||||
const [tab, setTab] = useState<ChapterTab>('outline')
|
||||
|
||||
useHotkey('b', 'Add beat', () => createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' })
|
||||
useHotkey('s', 'Add scene', () => createScene.mutate({ title: 'New scene' }), { group: 'Chapter' })
|
||||
|
||||
if (isPending) return <Spinner label="Loading chapter" />
|
||||
if (error) return <ErrorNote error={error} />
|
||||
@@ -113,8 +113,7 @@ export default function ChapterPage() {
|
||||
|
||||
<div className="mt-4 flex items-end justify-between gap-4">
|
||||
<div className="text-sm muted">
|
||||
{chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '}
|
||||
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
|
||||
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
||||
</div>
|
||||
<button
|
||||
className="btn"
|
||||
@@ -132,67 +131,74 @@ export default function ChapterPage() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The outline: a paragraph, then the beat table. */}
|
||||
<section className="mb-8">
|
||||
<h2 className="mb-1 text-lg font-semibold">Outline</h2>
|
||||
<p className="mb-3 text-sm muted">
|
||||
A paragraph on what the chapter does, then the beats that carry it.
|
||||
</p>
|
||||
|
||||
<div className="card mb-4 p-4">
|
||||
<AutoField
|
||||
value={chapter.summary}
|
||||
multiline
|
||||
rows={5}
|
||||
serif
|
||||
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
||||
onCommit={(summary) => patch({ summary })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BeatTable
|
||||
chapter={chapter}
|
||||
projectId={projectId}
|
||||
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => createBeat.mutate({ title: 'New beat' })}
|
||||
disabled={createBeat.isPending}
|
||||
>
|
||||
Add beat
|
||||
</button>
|
||||
{createBeat.error && (
|
||||
<div className="mt-2">
|
||||
<ErrorNote error={createBeat.error} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* The prose layer. */}
|
||||
<section>
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Scenes</h2>
|
||||
<p className="text-sm muted">Where the prose lives. Beats can be grouped under these.</p>
|
||||
</div>
|
||||
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
{(
|
||||
[
|
||||
['outline', 'Outline'],
|
||||
['prose', 'Text'],
|
||||
] as const
|
||||
).map(([value, tabLabel]) => (
|
||||
<button
|
||||
className="btn"
|
||||
onClick={() => createScene.mutate({ title: 'New scene' })}
|
||||
disabled={createScene.isPending}
|
||||
key={value}
|
||||
onClick={() => setTab(value)}
|
||||
className="border-b-2 px-3 py-2 text-sm font-medium transition"
|
||||
style={{
|
||||
borderColor: tab === value ? 'var(--accent)' : 'transparent',
|
||||
color: tab === value ? 'var(--accent)' : 'var(--ink-muted)',
|
||||
}}
|
||||
>
|
||||
Add scene
|
||||
{tabLabel}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ul className="grid gap-3">
|
||||
{chapter.scenes.map((scene) => (
|
||||
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
{tab === 'outline' ? (
|
||||
<section className="mb-8">
|
||||
<p className="mb-3 text-sm muted">
|
||||
A paragraph on what the chapter does, then the beats that carry it.
|
||||
</p>
|
||||
|
||||
<div className="card mb-4 p-4">
|
||||
<AutoField
|
||||
value={chapter.summary}
|
||||
multiline
|
||||
rows={5}
|
||||
serif
|
||||
placeholder="What this chapter is for: where it starts, what shifts, where it leaves the reader."
|
||||
onCommit={(summary) => patch({ summary })}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BeatTable
|
||||
chapter={chapter}
|
||||
projectId={projectId}
|
||||
characters={characters?.map((c) => ({ id: c.id, name: c.name })) ?? []}
|
||||
suggestions={suggestions}
|
||||
/>
|
||||
|
||||
<button
|
||||
className="btn btn-primary mt-3"
|
||||
onClick={() => createBeat.mutate({ title: 'New beat' })}
|
||||
disabled={createBeat.isPending}
|
||||
>
|
||||
Add beat
|
||||
</button>
|
||||
{createBeat.error && (
|
||||
<div className="mt-2">
|
||||
<ErrorNote error={createBeat.error} />
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
) : (
|
||||
<section className="mb-8">
|
||||
<p className="mb-3 text-sm muted">The chapter's drafted text, in markdown.</p>
|
||||
<MarkdownEditor
|
||||
value={chapter.prose}
|
||||
placeholder="Start writing the chapter."
|
||||
onCommit={(prose) => patch({ prose })}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="card mt-6 p-5">
|
||||
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
|
||||
@@ -247,7 +253,7 @@ function BeatTable({
|
||||
reorder.mutate(ids)
|
||||
}
|
||||
|
||||
const patch = (id: string, body: Partial<Omit<Beat, 'tags'>> & { tags?: string[] }) =>
|
||||
const patch = (id: string, body: Partial<Omit<Beat, 'tags' | 'characters'>> & { tags?: string[]; characterIds?: string[] }) =>
|
||||
update.mutate({ id, ...body })
|
||||
|
||||
return (
|
||||
@@ -256,15 +262,14 @@ function BeatTable({
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
<th className="w-10 px-2 py-2 text-left text-xs font-semibold uppercase muted">#</th>
|
||||
<th className="w-[14%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
|
||||
<th className="w-[10%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
Character
|
||||
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Beat</th>
|
||||
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
Characters
|
||||
</th>
|
||||
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
<th className="w-[32%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||
What happened
|
||||
</th>
|
||||
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
||||
<th className="w-[12%] px-2 py-2 text-left text-xs font-semibold uppercase muted">Scene</th>
|
||||
<th className="w-[32%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
||||
<th className="w-8" />
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -320,18 +325,11 @@ function BeatTable({
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select
|
||||
className="input"
|
||||
value={beat.characterName ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = characters.find((c) => c.name === e.target.value)
|
||||
patch(beat.id, { characterId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{['—', ...characters.map((c) => c.name)].map((name) => (
|
||||
<option key={name}>{name}</option>
|
||||
))}
|
||||
</select>
|
||||
<CharacterMultiSelect
|
||||
selected={beat.characters}
|
||||
options={characters}
|
||||
onChange={(characterIds) => patch(beat.id, { characterIds })}
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
@@ -356,21 +354,6 @@ function BeatTable({
|
||||
/>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<select
|
||||
className="input"
|
||||
value={beat.sceneTitle ?? '—'}
|
||||
onChange={(e) => {
|
||||
const match = chapter.scenes.find((s) => s.title === e.target.value)
|
||||
patch(beat.id, { sceneId: match?.id ?? null })
|
||||
}}
|
||||
>
|
||||
{['—', ...chapter.scenes.map((s) => s.title)].map((title) => (
|
||||
<option key={title}>{title}</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<button
|
||||
@@ -413,7 +396,17 @@ function BeatTable({
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">{beat.characterName ?? <span className="muted">—</span>}</td>
|
||||
<td className="px-2 py-2 align-top">
|
||||
{beat.characters.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{beat.characters.map((character) => (
|
||||
<CharacterChip key={character.id} character={character} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="muted">—</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
|
||||
{beat.whatHappened ?? <span className="muted">—</span>}
|
||||
@@ -423,8 +416,6 @@ function BeatTable({
|
||||
{beat.whatsNext ?? <span className="muted">—</span>}
|
||||
</td>
|
||||
|
||||
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted">—</span>}</td>
|
||||
|
||||
<td className="px-2 py-2 align-top" />
|
||||
</tr>
|
||||
),
|
||||
@@ -434,51 +425,3 @@ function BeatTable({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SceneCard({ chapterId, scene }: { chapterId: string; scene: Scene }) {
|
||||
const [showProse, setShowProse] = useState(Boolean(scene.prose))
|
||||
const update = useUpdateScene(chapterId)
|
||||
const remove = useDeleteScene(chapterId)
|
||||
const patch = (body: Partial<Scene>) => update.mutate({ id: scene.id, ...body })
|
||||
|
||||
return (
|
||||
<li className="card p-4">
|
||||
<div className="grid gap-3 sm:grid-cols-[1fr_9rem]">
|
||||
<AutoField value={scene.title} onCommit={(title) => title.trim() && patch({ title })} />
|
||||
<Select value={scene.status} options={draftStatuses} onChange={(status) => patch({ status })} />
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between gap-3 text-xs muted">
|
||||
<div className="flex items-center gap-3">
|
||||
<StatusBadge status={scene.status} />
|
||||
<span>{scene.wordCount.toLocaleString()} words</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="btn px-2 py-1 text-xs" onClick={() => setShowProse((v) => !v)}>
|
||||
{showProse ? 'Hide prose' : 'Write prose'}
|
||||
</button>
|
||||
<button
|
||||
className="btn px-2 py-1 text-xs"
|
||||
style={{ color: 'var(--accent)' }}
|
||||
onClick={() => confirm(`Delete scene “${scene.title}”?`) && remove.mutate(scene.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showProse && (
|
||||
<div className="mt-3">
|
||||
<AutoField
|
||||
value={scene.prose}
|
||||
multiline
|
||||
rows={16}
|
||||
serif
|
||||
placeholder="The scene itself. The beats grouped under it are the plan; this is the prose."
|
||||
onCommit={(prose) => patch({ prose })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ export default function ChaptersPage() {
|
||||
<div className="flex shrink-0 items-center gap-3 text-xs muted">
|
||||
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
|
||||
<span>{chapter.beatCount} beats</span>
|
||||
<span>{chapter.sceneCount} scenes</span>
|
||||
<span>{chapter.wordCount.toLocaleString()} words</span>
|
||||
<StatusBadge status={chapter.status} />
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user