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:
@@ -149,10 +149,10 @@ For each chapter file:
|
|||||||
Keep the Part tag exactly as written ("Part I", "Part II"); keep the thread tag as the raw
|
Keep the Part tag exactly as written ("Part I", "Part II"); keep the thread tag as the raw
|
||||||
Thread text so multi-POV chapters aren't lossy even though `povCharacterId` had to pick one or
|
Thread text so multi-POV chapters aren't lossy even though `povCharacterId` had to pick one or
|
||||||
none.
|
none.
|
||||||
4. For each beat table row, resolve the Character column the same way: a single clear name gets
|
4. For each beat table row, resolve the Character column the same way: a single clear name, or a
|
||||||
auto-created if it isn't in the ledger yet; a list ("Glokta, West, Jezal") or vague reference
|
list ("Glokta, West, Jezal"), gets each name auto-created if it isn't in the ledger yet; a
|
||||||
stays unresolved rather than guessing which one the beat belongs to. Then
|
vague reference stays unresolved rather than guessing who the beat belongs to. Then
|
||||||
`create_beat(chapterId, title: <Beat column>, whatHappened: <What column>, whatsNext: <Why column>, characterId: <resolved id, else omit>)`,
|
`create_beat(chapterId, title: <Beat column>, whatHappened: <What column>, whatsNext: <Why column>, characterIds: <resolved ids, else omit>)`,
|
||||||
in table order (the API appends in call order, so no explicit `sortOrder` needed).
|
in table order (the API appends in call order, so no explicit `sortOrder` needed).
|
||||||
5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`.
|
5. If `## Notes` is present, `update_chapter(chapterId, notes: ...)`.
|
||||||
6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`.
|
6. Record `chapters[number] = chapterId`, append `number` to `completedChapters`.
|
||||||
|
|||||||
@@ -439,3 +439,5 @@ mcp-server/
|
|||||||
# Toolchains installed locally by scripts/ci/lib.sh
|
# Toolchains installed locally by scripts/ci/lib.sh
|
||||||
.dotnet/
|
.dotnet/
|
||||||
.node/
|
.node/
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"novelly": {
|
||||||
|
"command": "/home/james/src/novelly/mcp-server/Novelly.Mcp",
|
||||||
|
"env": {
|
||||||
|
"NOVELLY_API_URL": "http://localhost:5080",
|
||||||
|
"DOTNET_ROOT": "/home/james/.dotnet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,8 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
|
|||||||
|
|
||||||
# Coding
|
# Coding
|
||||||
|
|
||||||
|
- No comments — no `///` XML doc, no `//` line comments, no `/* */` blocks, in C#, TS, or CSS. Unclear code → rename for
|
||||||
|
clarity or extract a well-named method instead of explaining it.
|
||||||
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
|
- Descriptive names all classes/methods. No generic: Provider, Manager, Helper
|
||||||
- Match formatting/style from `.editorconfig`
|
- Match formatting/style from `.editorconfig`
|
||||||
- Wrap lines at 220 chars, single line if fewer
|
- Wrap lines at 220 chars, single line if fewer
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ using System.Text.Json;
|
|||||||
|
|
||||||
namespace Novelly.Api.Agent;
|
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
|
public class JsonSchemaBuilder
|
||||||
{
|
{
|
||||||
private readonly JsonObject _properties = [];
|
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 class JsonInput
|
||||||
{
|
{
|
||||||
public static string? String(JsonElement input, string name) =>
|
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)
|
public static bool? Bool(JsonElement input, string name)
|
||||||
{
|
{
|
||||||
if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
|
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)
|
public static IReadOnlyList<string>? Strings(JsonElement input, string name)
|
||||||
{
|
{
|
||||||
if (input.ValueKind != JsonValueKind.Object
|
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 =>
|
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
|
||||||
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
|
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
|
||||||
|
|
||||||
|
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;
|
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(
|
public class NovelAgentService(
|
||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
IAgentModelClient model,
|
IAgentModelClient model,
|
||||||
@@ -41,7 +37,6 @@ public class NovelAgentService(
|
|||||||
.ToListAsync(ct);
|
.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)
|
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(conversationId, nameof(conversationId));
|
Guard.Default(conversationId, nameof(conversationId));
|
||||||
@@ -51,7 +46,6 @@ public class NovelAgentService(
|
|||||||
return await FindConversationAsync(conversationId, ct);
|
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)
|
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(conversationId, nameof(conversationId));
|
Guard.Default(conversationId, nameof(conversationId));
|
||||||
@@ -69,12 +63,6 @@ public class NovelAgentService(
|
|||||||
return true;
|
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(
|
public async Task<AgentMessage?> SendMessageAsync(
|
||||||
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -96,8 +84,6 @@ public class NovelAgentService(
|
|||||||
AgentConversation conversation;
|
AgentConversation conversation;
|
||||||
if (request.ConversationId is { } id)
|
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);
|
var found = await FindConversationAsync(id, ct);
|
||||||
if (found is null)
|
if (found is null)
|
||||||
{
|
{
|
||||||
@@ -111,9 +97,6 @@ public class NovelAgentService(
|
|||||||
conversation = StartConversation(projectId, request.Message);
|
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);
|
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||||
|
|
||||||
var systemPrompt = BuildSystemPrompt(project);
|
var systemPrompt = BuildSystemPrompt(project);
|
||||||
@@ -141,9 +124,6 @@ public class NovelAgentService(
|
|||||||
break;
|
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));
|
transcript.Add(AgentChatMessage.Assistant(response.Content));
|
||||||
|
|
||||||
var results = new List<AgentContentBlock>();
|
var results = new List<AgentContentBlock>();
|
||||||
@@ -182,11 +162,6 @@ public class NovelAgentService(
|
|||||||
return reply;
|
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(
|
private async Task<AgentMessage> AppendMessageAsync(
|
||||||
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
||||||
{
|
{
|
||||||
@@ -206,9 +181,6 @@ public class NovelAgentService(
|
|||||||
|
|
||||||
await db.SaveChangesAsync(ct);
|
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))
|
if (!conversation.Messages.Contains(message))
|
||||||
{
|
{
|
||||||
conversation.Messages.Add(message);
|
conversation.Messages.Add(message);
|
||||||
@@ -247,11 +219,6 @@ public class NovelAgentService(
|
|||||||
return conversation;
|
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) =>
|
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
|
||||||
[
|
[
|
||||||
.. conversation.Messages
|
.. conversation.Messages
|
||||||
@@ -273,8 +240,8 @@ public class NovelAgentService(
|
|||||||
return $"""
|
return $"""
|
||||||
You are a developmental editor and writing partner embedded in the software the
|
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
|
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
|
project's real data: the brief, character dossiers, the outline (beats) and each
|
||||||
and scenes.
|
chapter's drafted prose.
|
||||||
|
|
||||||
The project you are working on:
|
The project you are working on:
|
||||||
{brief}
|
{brief}
|
||||||
@@ -291,8 +258,8 @@ public class NovelAgentService(
|
|||||||
- Prefer structural help — where a beat lands, whether a want and a need are
|
- 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,
|
genuinely in tension, what the outline is missing — over line-level polish,
|
||||||
unless the writer asks for prose.
|
unless the writer asks for prose.
|
||||||
- When drafting prose into a scene, match the voice already established in the
|
- When drafting a chapter's prose, match the voice already established in the
|
||||||
project. Write the scene, then stop; do not append notes about your choices.
|
project. Write the chapter, then stop; do not append notes about your choices.
|
||||||
- Destructive operations (deleting outline nodes) need the writer's explicit
|
- Destructive operations (deleting outline nodes) need the writer's explicit
|
||||||
go-ahead first.
|
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)
|
private static string Summarise(string message)
|
||||||
{
|
{
|
||||||
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
||||||
|
|||||||
@@ -5,40 +5,26 @@ using Novelly.Api.Characters;
|
|||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Agent;
|
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);
|
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);
|
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(
|
public record AgentTool(
|
||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
JsonElement InputSchema,
|
JsonElement InputSchema,
|
||||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
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(
|
public class NovelAgentToolset(
|
||||||
ProjectService projects,
|
ProjectService projects,
|
||||||
CharacterService characters,
|
CharacterService characters,
|
||||||
CharacterArcService arcs,
|
CharacterArcService arcs,
|
||||||
ChapterService chapters,
|
ChapterService chapters,
|
||||||
BeatService beats,
|
BeatService beats,
|
||||||
SceneService scenes,
|
|
||||||
TagService tags,
|
TagService tags,
|
||||||
OpenQuestionService questions,
|
OpenQuestionService questions,
|
||||||
ILogger<NovelAgentToolset> logger)
|
ILogger<NovelAgentToolset> logger)
|
||||||
@@ -56,10 +42,6 @@ public class NovelAgentToolset(
|
|||||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
[.. 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)
|
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!ByName.TryGetValue(name, out var tool))
|
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 =>
|
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.");
|
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>(
|
private static async Task<object> OrNotFound<TEntity, TResponse>(
|
||||||
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
|
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.");
|
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) =>
|
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.");
|
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||||
|
|
||||||
@@ -227,10 +206,9 @@ public class NovelAgentToolset(
|
|||||||
new CreateBeatRequest(
|
new CreateBeatRequest(
|
||||||
JsonInput.RequiredString(input, "title"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
JsonInput.Int(input, "sort_order"),
|
JsonInput.Int(input, "sort_order"),
|
||||||
JsonInput.Guid(input, "character_id"),
|
JsonInput.Guids(input, "character_ids"),
|
||||||
JsonInput.String(input, "what_happened"),
|
JsonInput.String(input, "what_happened"),
|
||||||
JsonInput.String(input, "whats_next"),
|
JsonInput.String(input, "whats_next"),
|
||||||
JsonInput.Guid(input, "scene_id"),
|
|
||||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -250,10 +228,9 @@ public class NovelAgentToolset(
|
|||||||
new UpdateBeatRequest(
|
new UpdateBeatRequest(
|
||||||
JsonInput.String(input, "title"),
|
JsonInput.String(input, "title"),
|
||||||
JsonInput.Int(input, "sort_order"),
|
JsonInput.Int(input, "sort_order"),
|
||||||
JsonInput.Guid(input, "character_id"),
|
JsonInput.Guids(input, "character_ids"),
|
||||||
JsonInput.String(input, "what_happened"),
|
JsonInput.String(input, "what_happened"),
|
||||||
JsonInput.String(input, "whats_next"),
|
JsonInput.String(input, "whats_next"),
|
||||||
JsonInput.Guid(input, "scene_id"),
|
|
||||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -310,13 +287,13 @@ public class NovelAgentToolset(
|
|||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"list_chapters",
|
"list_chapters",
|
||||||
"List the project's chapters in manuscript order with scene and word counts.",
|
"List the project's chapters in manuscript order with beat and word counts.",
|
||||||
new JsonSchemaBuilder().Build(),
|
new JsonSchemaBuilder().Build(),
|
||||||
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
|
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"get_chapter",
|
"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()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
||||||
.Build(),
|
.Build(),
|
||||||
@@ -338,6 +315,7 @@ public class NovelAgentToolset(
|
|||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||||
.Int("target_word_count", "Target length in words.")
|
.Int("target_word_count", "Target length in words.")
|
||||||
|
.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.")
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
|
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||||
@@ -349,11 +327,14 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Int(input, "target_word_count"),
|
||||||
|
JsonInput.String(input, "prose"),
|
||||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
|
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
|
||||||
|
|
||||||
yield return new AgentTool(
|
yield return new AgentTool(
|
||||||
"update_chapter",
|
"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()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||||
.Str("title", "New title.")
|
.Str("title", "New title.")
|
||||||
@@ -364,6 +345,7 @@ public class NovelAgentToolset(
|
|||||||
.Str("notes", "Anything else worth recording.")
|
.Str("notes", "Anything else worth recording.")
|
||||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||||
.Int("target_word_count", "Target length in words.")
|
.Int("target_word_count", "Target length in words.")
|
||||||
|
.Str("prose", "The chapter's drafted text, in markdown.")
|
||||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||||
.Build(),
|
.Build(),
|
||||||
async (_, input, ct) =>
|
async (_, input, ct) =>
|
||||||
@@ -380,61 +362,10 @@ public class NovelAgentToolset(
|
|||||||
JsonInput.String(input, "notes"),
|
JsonInput.String(input, "notes"),
|
||||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||||
JsonInput.Int(input, "target_word_count"),
|
JsonInput.Int(input, "target_word_count"),
|
||||||
|
JsonInput.String(input, "prose"),
|
||||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
|
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(
|
yield return new AgentTool(
|
||||||
"get_character_beats",
|
"get_character_beats",
|
||||||
"Every beat this character appears in, across the whole book, in manuscript order. "
|
"Every beat this character appears in, across the whole book, in manuscript order. "
|
||||||
@@ -651,21 +582,8 @@ public class NovelAgentToolset(
|
|||||||
private static JsonSchemaBuilder BeatSchema() =>
|
private static JsonSchemaBuilder BeatSchema() =>
|
||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
.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("what_happened", "The event itself.")
|
||||||
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
.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.");
|
.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.Chapters;
|
||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Beats;
|
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 class Beat
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -18,24 +11,14 @@ public class Beat
|
|||||||
public Guid ChapterId { get; set; }
|
public Guid ChapterId { get; set; }
|
||||||
public Chapter? Chapter { 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; }
|
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;
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
|
public List<Character> Characters { get; set; } = [];
|
||||||
public Guid? CharacterId { get; set; }
|
|
||||||
public Character? Character { get; set; }
|
|
||||||
|
|
||||||
/// <summary>The event itself.</summary>
|
|
||||||
public string? WhatHappened { get; set; }
|
public string? WhatHappened { get; set; }
|
||||||
|
|
||||||
/// <summary>What it sets in motion — the hook into the next beat.</summary>
|
|
||||||
public string? WhatsNext { get; set; }
|
public string? WhatsNext { get; set; }
|
||||||
|
|
||||||
public List<Tag> Tags { get; set; } = [];
|
public List<Tag> Tags { get; set; } = [];
|
||||||
|
|||||||
@@ -3,27 +3,25 @@ using Novelly.Api.Tags;
|
|||||||
|
|
||||||
namespace Novelly.Api.Beats;
|
namespace Novelly.Api.Beats;
|
||||||
|
|
||||||
|
public record BeatCharacterResponse(Guid Id, string Name);
|
||||||
|
|
||||||
public record BeatResponse(
|
public record BeatResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ChapterId,
|
Guid ChapterId,
|
||||||
int SortOrder,
|
int SortOrder,
|
||||||
string Title,
|
string Title,
|
||||||
Guid? CharacterId,
|
IReadOnlyList<BeatCharacterResponse> Characters,
|
||||||
string? CharacterName,
|
|
||||||
string? WhatHappened,
|
string? WhatHappened,
|
||||||
string? WhatsNext,
|
string? WhatsNext,
|
||||||
Guid? SceneId,
|
|
||||||
string? SceneTitle,
|
|
||||||
IReadOnlyList<TagResponse> Tags,
|
IReadOnlyList<TagResponse> Tags,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
public record CreateBeatRequest(
|
public record CreateBeatRequest(
|
||||||
string Title,
|
string Title,
|
||||||
int? SortOrder = null,
|
int? SortOrder = null,
|
||||||
Guid? CharacterId = null,
|
IReadOnlyList<Guid>? CharacterIds = null,
|
||||||
string? WhatHappened = null,
|
string? WhatHappened = null,
|
||||||
string? WhatsNext = null,
|
string? WhatsNext = null,
|
||||||
Guid? SceneId = null,
|
|
||||||
IReadOnlyList<string>? Tags = null);
|
IReadOnlyList<string>? Tags = null);
|
||||||
|
|
||||||
public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
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(
|
public record UpdateBeatRequest(
|
||||||
string? Title = null,
|
string? Title = null,
|
||||||
int? SortOrder = null,
|
int? SortOrder = null,
|
||||||
Guid? CharacterId = null,
|
IReadOnlyList<Guid>? CharacterIds = null,
|
||||||
string? WhatHappened = null,
|
string? WhatHappened = null,
|
||||||
string? WhatsNext = null,
|
string? WhatsNext = null,
|
||||||
Guid? SceneId = null,
|
IReadOnlyList<string>? Tags = null);
|
||||||
IReadOnlyList<string>? Tags = null,
|
|
||||||
bool ClearCharacter = false,
|
|
||||||
bool ClearScene = false);
|
|
||||||
|
|
||||||
public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
|
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(
|
public record CharacterBeatResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ChapterId,
|
Guid ChapterId,
|
||||||
@@ -110,11 +95,8 @@ public record CharacterBeatResponse(
|
|||||||
int SortOrder,
|
int SortOrder,
|
||||||
string Title,
|
string Title,
|
||||||
string? WhatHappened,
|
string? WhatHappened,
|
||||||
string? WhatsNext,
|
string? WhatsNext);
|
||||||
Guid? SceneId,
|
|
||||||
string? SceneTitle);
|
|
||||||
|
|
||||||
/// <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 record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
|
||||||
|
|
||||||
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
|
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
|
||||||
@@ -137,12 +119,9 @@ public static class BeatMapping
|
|||||||
b.ChapterId,
|
b.ChapterId,
|
||||||
b.SortOrder,
|
b.SortOrder,
|
||||||
b.Title,
|
b.Title,
|
||||||
b.CharacterId,
|
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
|
||||||
b.Character?.Name,
|
|
||||||
b.WhatHappened,
|
b.WhatHappened,
|
||||||
b.WhatsNext,
|
b.WhatsNext,
|
||||||
b.SceneId,
|
|
||||||
b.Scene?.Title,
|
|
||||||
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
b.UpdatedAt);
|
b.UpdatedAt);
|
||||||
|
|
||||||
@@ -154,7 +133,5 @@ public static class BeatMapping
|
|||||||
b.SortOrder,
|
b.SortOrder,
|
||||||
b.Title,
|
b.Title,
|
||||||
b.WhatHappened,
|
b.WhatHappened,
|
||||||
b.WhatsNext,
|
b.WhatsNext);
|
||||||
b.SceneId,
|
|
||||||
b.Scene?.Title);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,10 +8,6 @@ using Novelly.Api.Tags;
|
|||||||
|
|
||||||
namespace Novelly.Api.Beats;
|
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(
|
public class BeatService(
|
||||||
INovelDbContext db,
|
INovelDbContext db,
|
||||||
TagService tags,
|
TagService tags,
|
||||||
@@ -32,7 +28,6 @@ public class BeatService(
|
|||||||
.ToListAsync(ct);
|
.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)
|
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
@@ -41,12 +36,6 @@ public class BeatService(
|
|||||||
return await FindAsync(id, ct);
|
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(
|
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
|
||||||
Guid characterId, CancellationToken ct = default)
|
Guid characterId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -62,8 +51,7 @@ public class BeatService(
|
|||||||
|
|
||||||
var beats = await db.Beats
|
var beats = await db.Beats
|
||||||
.Include(b => b.Chapter)
|
.Include(b => b.Chapter)
|
||||||
.Include(b => b.Scene)
|
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
||||||
.Where(b => b.CharacterId == characterId)
|
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
return
|
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)
|
public async Task<Beat?> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(chapterId, nameof(chapterId));
|
Guard.Default(chapterId, nameof(chapterId));
|
||||||
@@ -90,19 +77,20 @@ public class BeatService(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
|
||||||
|
|
||||||
var beat = new Beat
|
var beat = new Beat
|
||||||
{
|
{
|
||||||
ChapterId = chapterId,
|
ChapterId = chapterId,
|
||||||
Title = request.Title,
|
Title = request.Title,
|
||||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||||
CharacterId = request.CharacterId,
|
|
||||||
WhatHappened = request.WhatHappened,
|
WhatHappened = request.WhatHappened,
|
||||||
WhatsNext = request.WhatsNext,
|
WhatsNext = request.WhatsNext
|
||||||
SceneId = request.SceneId
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (request.CharacterIds is { } characterIds)
|
||||||
|
{
|
||||||
|
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||||
|
}
|
||||||
|
|
||||||
if (request.Tags is { } names)
|
if (request.Tags is { } names)
|
||||||
{
|
{
|
||||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||||
@@ -111,7 +99,6 @@ public class BeatService(
|
|||||||
db.Beats.Add(beat);
|
db.Beats.Add(beat);
|
||||||
await db.SaveChangesAsync(ct);
|
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))!;
|
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);
|
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
||||||
if (chapter is null)
|
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);
|
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
|
||||||
|
|
||||||
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
||||||
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||||
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
|
|
||||||
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
||||||
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
||||||
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
|
|
||||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
if (request.CharacterIds is { } characterIds)
|
||||||
|
{
|
||||||
|
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||||
|
}
|
||||||
|
|
||||||
if (request.Tags is { } names)
|
if (request.Tags is { } names)
|
||||||
{
|
{
|
||||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||||
@@ -157,7 +143,6 @@ public class BeatService(
|
|||||||
return (await FindAsync(id, ct))!;
|
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)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
@@ -175,11 +160,6 @@ public class BeatService(
|
|||||||
return true;
|
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(
|
public async Task<IReadOnlyList<Beat>?> ReorderAsync(
|
||||||
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
@@ -198,8 +178,6 @@ public class BeatService(
|
|||||||
return null;
|
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;
|
var order = 1;
|
||||||
foreach (var id in request.BeatIds)
|
foreach (var id in request.BeatIds)
|
||||||
{
|
{
|
||||||
@@ -215,35 +193,26 @@ public class BeatService(
|
|||||||
return await ListAsync(chapterId, ct);
|
return await ListAsync(chapterId, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ValidateReferencesAsync(
|
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||||
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId);
|
var distinct = characterIds.Distinct().ToList();
|
||||||
|
if (distinct.Count == 0)
|
||||||
if (characterId is { } cid)
|
|
||||||
{
|
{
|
||||||
var belongs = await db.Characters
|
return [];
|
||||||
.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.");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sceneId is { } sid)
|
var found = await db.Characters
|
||||||
{
|
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
|
||||||
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
if (!belongs)
|
if (found.Count != distinct.Count)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id);
|
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"A beat can only be grouped under a scene in the same chapter.");
|
"A beat's characters must belong to the same project as its chapter.");
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||||
@@ -259,8 +228,7 @@ public class BeatService(
|
|||||||
|
|
||||||
private IQueryable<Beat> Query() =>
|
private IQueryable<Beat> Query() =>
|
||||||
db.Beats
|
db.Beats
|
||||||
.Include(b => b.Character)
|
.Include(b => b.Characters)
|
||||||
.Include(b => b.Scene)
|
|
||||||
.Include(b => b.Tags);
|
.Include(b => b.Tags);
|
||||||
|
|
||||||
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
||||||
|
|||||||
@@ -2,29 +2,22 @@ using Novelly.Api.Beats;
|
|||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Chapters;
|
namespace Novelly.Api.Chapters;
|
||||||
|
|
||||||
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
|
|
||||||
public class Chapter
|
public class Chapter
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public Guid ProjectId { get; set; }
|
public Guid ProjectId { get; set; }
|
||||||
public Project? Project { get; set; }
|
public Project? Project { get; set; }
|
||||||
|
|
||||||
/// <summary>Position in the manuscript, 1-based.</summary>
|
|
||||||
public int Number { get; set; }
|
public int Number { get; set; }
|
||||||
|
|
||||||
public string Title { get; set; } = string.Empty;
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The paragraph that opens the chapter's outline, above the beat table.
|
|
||||||
/// </summary>
|
|
||||||
public string? Summary { get; set; }
|
public string? Summary { get; set; }
|
||||||
|
|
||||||
/// <summary>Whose head we are in for this chapter.</summary>
|
|
||||||
public Guid? PovCharacterId { get; set; }
|
public Guid? PovCharacterId { get; set; }
|
||||||
public Character? PovCharacter { get; set; }
|
public Character? PovCharacter { get; set; }
|
||||||
|
|
||||||
@@ -34,14 +27,14 @@ public class Chapter
|
|||||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||||
public int? TargetWordCount { get; set; }
|
public int? TargetWordCount { get; set; }
|
||||||
|
|
||||||
|
public string? Prose { get; set; }
|
||||||
|
|
||||||
|
public int WordCount { get; set; }
|
||||||
|
|
||||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
|
|
||||||
public List<Beat> Beats { get; set; } = [];
|
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; } = [];
|
public List<Tag> Tags { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Common.Validation;
|
using Novelly.Api.Common.Validation;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Chapters;
|
namespace Novelly.Api.Chapters;
|
||||||
@@ -18,15 +17,10 @@ public record ChapterSummaryResponse(
|
|||||||
DraftStatus Status,
|
DraftStatus Status,
|
||||||
int? TargetWordCount,
|
int? TargetWordCount,
|
||||||
int BeatCount,
|
int BeatCount,
|
||||||
int SceneCount,
|
|
||||||
int WordCount,
|
int WordCount,
|
||||||
IReadOnlyList<TagResponse> Tags,
|
IReadOnlyList<TagResponse> Tags,
|
||||||
DateTimeOffset UpdatedAt);
|
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(
|
public record ChapterResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
Guid ProjectId,
|
Guid ProjectId,
|
||||||
@@ -40,7 +34,8 @@ public record ChapterResponse(
|
|||||||
DraftStatus Status,
|
DraftStatus Status,
|
||||||
int? TargetWordCount,
|
int? TargetWordCount,
|
||||||
IReadOnlyList<BeatResponse> Beats,
|
IReadOnlyList<BeatResponse> Beats,
|
||||||
IReadOnlyList<SceneResponse> Scenes,
|
string? Prose,
|
||||||
|
int WordCount,
|
||||||
IReadOnlyList<TagResponse> Tags,
|
IReadOnlyList<TagResponse> Tags,
|
||||||
DateTimeOffset UpdatedAt);
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
@@ -53,6 +48,7 @@ public record CreateChapterRequest(
|
|||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
DraftStatus Status = DraftStatus.Planned,
|
DraftStatus Status = DraftStatus.Planned,
|
||||||
int? TargetWordCount = null,
|
int? TargetWordCount = null,
|
||||||
|
string? Prose = null,
|
||||||
IReadOnlyList<string>? Tags = null);
|
IReadOnlyList<string>? Tags = null);
|
||||||
|
|
||||||
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
|
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
|
||||||
@@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
|
|||||||
else if (model.Title.Length > 200)
|
else if (model.Title.Length > 200)
|
||||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
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;
|
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(
|
public record UpdateChapterRequest(
|
||||||
string? Title = null,
|
string? Title = null,
|
||||||
int? Number = null,
|
int? Number = null,
|
||||||
@@ -85,6 +77,7 @@ public record UpdateChapterRequest(
|
|||||||
string? Notes = null,
|
string? Notes = null,
|
||||||
DraftStatus? Status = null,
|
DraftStatus? Status = null,
|
||||||
int? TargetWordCount = null,
|
int? TargetWordCount = null,
|
||||||
|
string? Prose = null,
|
||||||
IReadOnlyList<string>? Tags = null);
|
IReadOnlyList<string>? Tags = null);
|
||||||
|
|
||||||
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
|
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
|
||||||
@@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
|||||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
@@ -110,7 +103,8 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
|||||||
file static class ChapterValidation
|
file static class ChapterValidation
|
||||||
{
|
{
|
||||||
public static void OptionalFields(
|
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)
|
if (number is <= 0)
|
||||||
result.AddError("Number", "'Number' must be greater than zero.");
|
result.AddError("Number", "'Number' must be greater than zero.");
|
||||||
@@ -127,6 +121,9 @@ file static class ChapterValidation
|
|||||||
if (targetWordCount is < 0)
|
if (targetWordCount is < 0)
|
||||||
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
|
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))
|
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
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.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
|
||||||
c.Status, c.TargetWordCount,
|
c.Status, c.TargetWordCount,
|
||||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
[.. 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.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
c.UpdatedAt);
|
c.UpdatedAt);
|
||||||
|
|
||||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
||||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||||
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
|
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
|
||||||
c.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.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||||
c.UpdatedAt);
|
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) =>
|
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
(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 (
|
chapters.MapPatch("/{id:guid}", async (
|
||||||
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
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) =>
|
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||||
.WithSummary("Delete a chapter and its scenes.");
|
.WithSummary("Delete a chapter.");
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,14 +22,12 @@ public class ChapterService(
|
|||||||
return await db.Chapters
|
return await db.Chapters
|
||||||
.Include(c => c.PovCharacter)
|
.Include(c => c.PovCharacter)
|
||||||
.Include(c => c.Beats)
|
.Include(c => c.Beats)
|
||||||
.Include(c => c.Scenes)
|
|
||||||
.Include(c => c.Tags)
|
.Include(c => c.Tags)
|
||||||
.Where(c => c.ProjectId == projectId)
|
.Where(c => c.ProjectId == projectId)
|
||||||
.OrderBy(c => c.Number)
|
.OrderBy(c => c.Number)
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <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)
|
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
@@ -38,7 +36,6 @@ public class ChapterService(
|
|||||||
return await FindAsync(id, ct);
|
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)
|
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(projectId, nameof(projectId));
|
Guard.Default(projectId, nameof(projectId));
|
||||||
@@ -63,7 +60,9 @@ public class ChapterService(
|
|||||||
Setting = request.Setting,
|
Setting = request.Setting,
|
||||||
Notes = request.Notes,
|
Notes = request.Notes,
|
||||||
Status = request.Status,
|
Status = request.Status,
|
||||||
TargetWordCount = request.TargetWordCount
|
TargetWordCount = request.TargetWordCount,
|
||||||
|
Prose = request.Prose,
|
||||||
|
WordCount = ChapterMapping.CountWords(request.Prose)
|
||||||
};
|
};
|
||||||
|
|
||||||
if (request.Tags is { } names)
|
if (request.Tags is { } names)
|
||||||
@@ -74,7 +73,6 @@ public class ChapterService(
|
|||||||
db.Chapters.Add(chapter);
|
db.Chapters.Add(chapter);
|
||||||
await db.SaveChangesAsync(ct);
|
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))!;
|
return (await FindAsync(chapter.Id, ct))!;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +98,13 @@ public class ChapterService(
|
|||||||
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
||||||
chapter.Status = request.Status ?? chapter.Status;
|
chapter.Status = request.Status ?? chapter.Status;
|
||||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
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;
|
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
if (request.Tags is { } names)
|
if (request.Tags is { } names)
|
||||||
@@ -111,7 +116,6 @@ public class ChapterService(
|
|||||||
return (await FindAsync(id, ct))!;
|
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)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
@@ -148,10 +152,8 @@ public class ChapterService(
|
|||||||
|
|
||||||
var chapter = await db.Chapters
|
var chapter = await db.Chapters
|
||||||
.Include(c => c.PovCharacter)
|
.Include(c => c.PovCharacter)
|
||||||
.Include(c => c.Beats).ThenInclude(b => b.Character)
|
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
||||||
.Include(c => c.Beats).ThenInclude(b => b.Scene)
|
|
||||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||||
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
|
|
||||||
.Include(c => c.Tags)
|
.Include(c => c.Tags)
|
||||||
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Characters;
|
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 class Character
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -16,10 +13,6 @@ public class Character
|
|||||||
public string Name { get; set; } = string.Empty;
|
public string Name { get; set; } = string.Empty;
|
||||||
public CharacterRole Role { get; set; } = CharacterRole.Supporting;
|
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 CharacterImportance Importance { get; set; } = CharacterImportance.Supporting;
|
||||||
|
|
||||||
public string? Age { get; set; }
|
public string? Age { get; set; }
|
||||||
@@ -30,22 +23,15 @@ public class Character
|
|||||||
public string? Personality { get; set; }
|
public string? Personality { get; set; }
|
||||||
public string? Backstory { get; set; }
|
public string? Backstory { get; set; }
|
||||||
|
|
||||||
/// <summary>What the character consciously wants.</summary>
|
|
||||||
public string? Want { get; set; }
|
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? Need { get; set; }
|
||||||
|
|
||||||
public string? InternalConflict { get; set; }
|
public string? InternalConflict { get; set; }
|
||||||
public string? ExternalConflict { 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; }
|
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? Voice { get; set; }
|
||||||
|
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
@@ -56,11 +42,11 @@ public class Character
|
|||||||
public List<CharacterRelationship> Relationships { get; set; } = [];
|
public List<CharacterRelationship> Relationships { get; set; } = [];
|
||||||
public List<Tag> Tags { 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<CharacterArcStage> ArcStages { get; set; } = [];
|
||||||
|
|
||||||
|
public List<Beat> Beats { get; set; } = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>A directed relationship from one character to another.</summary>
|
|
||||||
public class CharacterRelationship
|
public class CharacterRelationship
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; } = Guid.NewGuid();
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
@@ -71,7 +57,6 @@ public class CharacterRelationship
|
|||||||
public Guid RelatedCharacterId { get; set; }
|
public Guid RelatedCharacterId { get; set; }
|
||||||
public Character? RelatedCharacter { get; set; }
|
public Character? RelatedCharacter { get; set; }
|
||||||
|
|
||||||
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
|
|
||||||
public string RelationshipType { get; set; } = string.Empty;
|
public string RelationshipType { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
namespace Novelly.Api.Common;
|
namespace Novelly.Api.Common;
|
||||||
|
|
||||||
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
|
|
||||||
public enum DraftStatus
|
public enum DraftStatus
|
||||||
{
|
{
|
||||||
Planned,
|
Planned,
|
||||||
|
|||||||
@@ -9,16 +9,10 @@ using Novelly.Api.Data;
|
|||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Common;
|
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 class NovellyServiceRegistration
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
|
||||||
@@ -35,7 +29,6 @@ public static class NovellyServiceRegistration
|
|||||||
services.AddScoped<BeatService>();
|
services.AddScoped<BeatService>();
|
||||||
services.AddScoped<TagService>();
|
services.AddScoped<TagService>();
|
||||||
services.AddScoped<ChapterService>();
|
services.AddScoped<ChapterService>();
|
||||||
services.AddScoped<SceneService>();
|
|
||||||
services.AddScoped<OpenQuestionService>();
|
services.AddScoped<OpenQuestionService>();
|
||||||
services.AddScoped<NovelAgentToolset>();
|
services.AddScoped<NovelAgentToolset>();
|
||||||
services.AddScoped<NovelAgentService>();
|
services.AddScoped<NovelAgentService>();
|
||||||
@@ -43,8 +36,6 @@ public static class NovellyServiceRegistration
|
|||||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
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.AddSingleton(Channel.CreateUnbounded<Guid>());
|
||||||
services.AddScoped<ImportService>();
|
services.AddScoped<ImportService>();
|
||||||
services.AddScoped<ImportAgentToolset>();
|
services.AddScoped<ImportAgentToolset>();
|
||||||
|
|||||||
@@ -6,15 +6,10 @@ using Novelly.Api.Characters;
|
|||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Data;
|
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
|
public interface INovelDbContext
|
||||||
{
|
{
|
||||||
DbSet<Project> Projects { get; }
|
DbSet<Project> Projects { get; }
|
||||||
@@ -24,7 +19,6 @@ public interface INovelDbContext
|
|||||||
DbSet<Beat> Beats { get; }
|
DbSet<Beat> Beats { get; }
|
||||||
DbSet<Tag> Tags { get; }
|
DbSet<Tag> Tags { get; }
|
||||||
DbSet<Chapter> Chapters { get; }
|
DbSet<Chapter> Chapters { get; }
|
||||||
DbSet<Scene> Scenes { get; }
|
|
||||||
DbSet<OpenQuestion> OpenQuestions { get; }
|
DbSet<OpenQuestion> OpenQuestions { get; }
|
||||||
DbSet<AgentConversation> Conversations { get; }
|
DbSet<AgentConversation> Conversations { get; }
|
||||||
DbSet<AgentMessage> AgentMessages { 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
|
#pragma warning disable 612, 618
|
||||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
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 =>
|
modelBuilder.Entity("BeatTag", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("BeatsId")
|
b.Property<Guid>("BeatsId")
|
||||||
@@ -133,15 +148,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<Guid>("ChapterId")
|
b.Property<Guid>("ChapterId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid?>("CharacterId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<long>("CreatedAt")
|
b.Property<long>("CreatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<Guid?>("SceneId")
|
|
||||||
.HasColumnType("TEXT");
|
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
@@ -161,10 +170,6 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("CharacterId");
|
|
||||||
|
|
||||||
b.HasIndex("SceneId");
|
|
||||||
|
|
||||||
b.HasIndex("ChapterId", "SortOrder");
|
b.HasIndex("ChapterId", "SortOrder");
|
||||||
|
|
||||||
b.ToTable("Beats");
|
b.ToTable("Beats");
|
||||||
@@ -191,6 +196,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<Guid>("ProjectId")
|
b.Property<Guid>("ProjectId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("Prose")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<string>("Setting")
|
b.Property<string>("Setting")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -213,6 +221,9 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Property<long>("UpdatedAt")
|
b.Property<long>("UpdatedAt")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("WordCount")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("PovCharacterId");
|
b.HasIndex("PovCharacterId");
|
||||||
@@ -497,67 +508,6 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.ToTable("OpenQuestions");
|
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 =>
|
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -587,6 +537,21 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.ToTable("Tags");
|
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 =>
|
modelBuilder.Entity("BeatTag", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||||
@@ -662,21 +627,7 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
.OnDelete(DeleteBehavior.Cascade)
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
.IsRequired();
|
.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("Chapter");
|
||||||
|
|
||||||
b.Navigation("Character");
|
|
||||||
|
|
||||||
b.Navigation("Scene");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||||
@@ -770,24 +721,6 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
b.Navigation("Project");
|
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 =>
|
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||||
@@ -807,8 +740,6 @@ namespace Novelly.Api.Data.Migrations
|
|||||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||||
{
|
{
|
||||||
b.Navigation("Beats");
|
b.Navigation("Beats");
|
||||||
|
|
||||||
b.Navigation("Scenes");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||||
|
|||||||
@@ -7,17 +7,10 @@ using Novelly.Api.Characters;
|
|||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Data;
|
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()
|
internal class UtcTicksConverter()
|
||||||
: ValueConverter<DateTimeOffset, long>(
|
: ValueConverter<DateTimeOffset, long>(
|
||||||
value => value.UtcTicks,
|
value => value.UtcTicks,
|
||||||
@@ -33,7 +26,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
|||||||
public DbSet<Beat> Beats => Set<Beat>();
|
public DbSet<Beat> Beats => Set<Beat>();
|
||||||
public DbSet<Tag> Tags => Set<Tag>();
|
public DbSet<Tag> Tags => Set<Tag>();
|
||||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||||
public DbSet<Scene> Scenes => Set<Scene>();
|
|
||||||
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
|
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
|
||||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
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.Property(s => s.Title).IsRequired().HasMaxLength(200);
|
||||||
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
|
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()
|
entity.HasOne(s => s.Chapter).WithMany()
|
||||||
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
.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);
|
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()
|
entity.HasOne(r => r.RelatedCharacter).WithMany()
|
||||||
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
|
.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)
|
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
|
||||||
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
// A beat outlives the scene it was grouped under: deleting a scene is a
|
entity.HasMany(b => b.Characters).WithMany(c => c.Beats)
|
||||||
// decision about prose, not about the plan.
|
.UsingEntity(join => join.ToTable("BeatCharacters"));
|
||||||
entity.HasOne(b => b.Scene).WithMany()
|
|
||||||
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
|
|
||||||
|
|
||||||
entity.HasOne(b => b.Character).WithMany()
|
|
||||||
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Entity<Tag>(entity =>
|
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.Name).IsRequired().HasMaxLength(64);
|
||||||
entity.Property(t => t.Color).HasMaxLength(16);
|
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.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
|
||||||
|
|
||||||
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
|
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()
|
entity.HasOne(c => c.PovCharacter).WithMany()
|
||||||
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
.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 =>
|
builder.Entity<OpenQuestion>(entity =>
|
||||||
@@ -159,15 +126,11 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
|||||||
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
|
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
|
||||||
entity.Ignore(q => q.IsResolved);
|
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.HasIndex(q => q.ProjectId);
|
||||||
|
|
||||||
entity.HasOne(q => q.Project).WithMany()
|
entity.HasOne(q => q.Project).WithMany()
|
||||||
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
.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()
|
entity.HasOne(q => q.Chapter).WithMany()
|
||||||
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||||
entity.HasOne(q => q.Character).WithMany()
|
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.SourceRoot).IsRequired().HasMaxLength(1000);
|
||||||
entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16);
|
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);
|
entity.HasIndex(j => j.SourceRoot);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,30 +8,14 @@ using Novelly.Api.Projects;
|
|||||||
|
|
||||||
namespace Novelly.Api.Imports;
|
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);
|
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(
|
internal record ImportAgentTool(
|
||||||
string Name,
|
string Name,
|
||||||
string Description,
|
string Description,
|
||||||
JsonElement InputSchema,
|
JsonElement InputSchema,
|
||||||
Func<JsonElement, CancellationToken, Task<object?>> Handler);
|
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(
|
public class ImportAgentToolset(
|
||||||
ProjectService projects,
|
ProjectService projects,
|
||||||
CharacterService characters,
|
CharacterService characters,
|
||||||
@@ -54,17 +38,14 @@ public class ImportAgentToolset(
|
|||||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||||
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
[.. 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)
|
public void Initialize(string sourceRoot, Guid? existingProjectId)
|
||||||
{
|
{
|
||||||
_sourceRoot = sourceRoot;
|
_sourceRoot = sourceRoot;
|
||||||
ProjectId = existingProjectId;
|
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);
|
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)
|
public async Task<AgentToolResult> ExecuteAsync(string name, JsonElement input, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!ByName.TryGetValue(name, out var tool))
|
if (!ByName.TryGetValue(name, out var tool))
|
||||||
@@ -196,8 +177,6 @@ public class ImportAgentToolset(
|
|||||||
{
|
{
|
||||||
var json = JsonInput.RequiredString(input, "json");
|
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);
|
using var _ = JsonDocument.Parse(json);
|
||||||
|
|
||||||
File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json);
|
File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json);
|
||||||
@@ -345,7 +324,7 @@ public class ImportAgentToolset(
|
|||||||
new JsonSchemaBuilder()
|
new JsonSchemaBuilder()
|
||||||
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
|
.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("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("what_happened", "The What column.")
|
||||||
.Str("whats_next", "The Why column.")
|
.Str("whats_next", "The Why column.")
|
||||||
.Build(),
|
.Build(),
|
||||||
@@ -356,7 +335,7 @@ public class ImportAgentToolset(
|
|||||||
chapterId,
|
chapterId,
|
||||||
new CreateBeatRequest(
|
new CreateBeatRequest(
|
||||||
JsonInput.RequiredString(input, "title"),
|
JsonInput.RequiredString(input, "title"),
|
||||||
CharacterId: JsonInput.Guid(input, "character_id"),
|
CharacterIds: JsonInput.Guids(input, "character_ids"),
|
||||||
WhatHappened: JsonInput.String(input, "what_happened"),
|
WhatHappened: JsonInput.String(input, "what_happened"),
|
||||||
WhatsNext: JsonInput.String(input, "whats_next")), ct);
|
WhatsNext: JsonInput.String(input, "whats_next")), ct);
|
||||||
|
|
||||||
|
|||||||
@@ -10,14 +10,11 @@ using Novelly.Api.Data;
|
|||||||
using Novelly.Api.Imports;
|
using Novelly.Api.Imports;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
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
|
builder.Services.AddSerilog((services, config) => config
|
||||||
.ReadFrom.Configuration(builder.Configuration)
|
.ReadFrom.Configuration(builder.Configuration)
|
||||||
.ReadFrom.Services(services)
|
.ReadFrom.Services(services)
|
||||||
@@ -28,8 +25,6 @@ builder.Services.AddNovelly(builder.Configuration);
|
|||||||
builder.Services.AddOpenApi();
|
builder.Services.AddOpenApi();
|
||||||
builder.Services.AddProblemDetails();
|
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 =>
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
@@ -43,16 +38,11 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
|
|||||||
|
|
||||||
var app = builder.Build();
|
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())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
|
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.UseSerilogRequestLogging();
|
||||||
|
|
||||||
app.UseExceptionHandler(handler => handler.Run(async context =>
|
app.UseExceptionHandler(handler => handler.Run(async context =>
|
||||||
@@ -95,7 +85,6 @@ app.MapProjectEndpoints()
|
|||||||
.MapCharacterEndpoints()
|
.MapCharacterEndpoints()
|
||||||
.MapChapterEndpoints()
|
.MapChapterEndpoints()
|
||||||
.MapBeatEndpoints()
|
.MapBeatEndpoints()
|
||||||
.MapSceneEndpoints()
|
|
||||||
.MapTagEndpoints()
|
.MapTagEndpoints()
|
||||||
.MapOpenQuestionEndpoints()
|
.MapOpenQuestionEndpoints()
|
||||||
.MapAgentEndpoints()
|
.MapAgentEndpoints()
|
||||||
@@ -103,5 +92,4 @@ app.MapProjectEndpoints()
|
|||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
|
|
||||||
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
|
|
||||||
public partial class Program;
|
public partial class Program;
|
||||||
|
|||||||
@@ -27,12 +27,11 @@ public class ProjectService(
|
|||||||
p.Phase,
|
p.Phase,
|
||||||
p.Characters.Count,
|
p.Characters.Count,
|
||||||
p.Chapters.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))
|
p.UpdatedAt))
|
||||||
.ToListAsync(ct);
|
.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)
|
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
Guard.Default(id, nameof(id));
|
||||||
@@ -92,7 +91,6 @@ public class ProjectService(
|
|||||||
return project;
|
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)
|
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(id, nameof(id));
|
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(
|
public record TagReferencesResponse(
|
||||||
TagResponse Tag,
|
TagResponse Tag,
|
||||||
IReadOnlyList<TaggedCharacterResponse> Characters,
|
IReadOnlyList<TaggedCharacterResponse> Characters,
|
||||||
@@ -105,12 +100,8 @@ public static class TagMapping
|
|||||||
b.Chapter?.Title ?? "(unknown chapter)",
|
b.Chapter?.Title ?? "(unknown chapter)",
|
||||||
b.SortOrder,
|
b.SortOrder,
|
||||||
b.Title,
|
b.Title,
|
||||||
b.Character?.Name,
|
b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null,
|
||||||
b.WhatHappened))]);
|
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();
|
public static string Normalise(string name) => name.Trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ public class TagService(
|
|||||||
.ToListAsync(ct);
|
.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)
|
public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(tagId, nameof(tagId));
|
Guard.Default(tagId, nameof(tagId));
|
||||||
@@ -36,7 +35,7 @@ public class TagService(
|
|||||||
var tag = await db.Tags
|
var tag = await db.Tags
|
||||||
.Include(t => t.Characters)
|
.Include(t => t.Characters)
|
||||||
.Include(t => t.Chapters)
|
.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)
|
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
|
||||||
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
|
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
|
||||||
|
|
||||||
@@ -46,7 +45,6 @@ public class TagService(
|
|||||||
return tag;
|
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)
|
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(projectId, nameof(projectId));
|
Guard.Default(projectId, nameof(projectId));
|
||||||
@@ -110,7 +108,6 @@ public class TagService(
|
|||||||
return tag;
|
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)
|
public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
Guard.Default(tagId, nameof(tagId));
|
Guard.Default(tagId, nameof(tagId));
|
||||||
@@ -129,11 +126,6 @@ public class TagService(
|
|||||||
return true;
|
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(
|
internal async Task<List<Tag>> ResolveAsync(
|
||||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ public static class BeatTools
|
|||||||
{
|
{
|
||||||
[McpServerTool(Name = "get_chapter_outline")]
|
[McpServerTool(Name = "get_chapter_outline")]
|
||||||
[Description("Read a chapter's outline: its beats in order. Each beat is one row — a short "
|
[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 "
|
+ "title, who it belongs to, what happened, and what it sets up. The chapter's "
|
||||||
+ "summary paragraph sits on the chapter itself, via get_chapter.")]
|
+ "summary paragraph and drafted prose sit on the chapter itself, via get_chapter.")]
|
||||||
public static Task<CallToolResult> GetChapterOutline(
|
public static Task<CallToolResult> GetChapterOutline(
|
||||||
NovelApiClient api,
|
NovelApiClient api,
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
[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,
|
[Description("Three to five words naming the beat.")] string title,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
[Description("Position in the chapter. Appended to the end when omitted.")] int? sortOrder = null,
|
[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("The event itself.")] string? whatHappened = null,
|
||||||
[Description("What it sets in motion — the hook into the next beat.")] string? whatsNext = 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) =>
|
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
|
||||||
api.PostAsync($"/api/chapters/{chapterId}/beats",
|
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")]
|
[McpServerTool(Name = "update_beat")]
|
||||||
[Description("Revise a beat. Only the fields you supply change. Supplying a tag list replaces "
|
[Description("Revise a beat. Only the fields you supply change. Supplying a characterIds or "
|
||||||
+ "the beat's tags outright, so include the ones you want to keep. A character or "
|
+ "tag list replaces the beat's characters or tags outright — pass an empty list "
|
||||||
+ "scene id already set stays put unless you pass clearCharacter/clearScene — "
|
+ "to clear one, and include everything you want to keep.")]
|
||||||
+ "leaving the id null means 'don't touch it', not 'remove it'.")]
|
|
||||||
public static Task<CallToolResult> UpdateBeat(
|
public static Task<CallToolResult> UpdateBeat(
|
||||||
NovelApiClient api,
|
NovelApiClient api,
|
||||||
[Description("The beat's id.")] Guid beatId,
|
[Description("The beat's id.")] Guid beatId,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
[Description("Three to five words naming the beat.")] string? title = null,
|
[Description("Three to five words naming the beat.")] string? title = null,
|
||||||
[Description("Position in the chapter.")] int? sortOrder = 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("The event itself.")] string? whatHappened = null,
|
||||||
[Description("What it sets in motion.")] string? whatsNext = 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("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) =>
|
|
||||||
api.PatchAsync($"/api/beats/{beatId}",
|
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")]
|
[McpServerTool(Name = "delete_beat")]
|
||||||
[Description("Remove a beat from a chapter's outline. Confirm with the writer first.")]
|
[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
|
public static class ManuscriptTools
|
||||||
{
|
{
|
||||||
[McpServerTool(Name = "list_chapters")]
|
[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(
|
public static Task<CallToolResult> ListChapters(
|
||||||
NovelApiClient api,
|
NovelApiClient api,
|
||||||
[Description("The project's id.")] Guid projectId,
|
[Description("The project's id.")] Guid projectId,
|
||||||
@@ -16,7 +16,7 @@ public static class ManuscriptTools
|
|||||||
api.GetAsync($"/api/projects/{projectId}/chapters", ct);
|
api.GetAsync($"/api/projects/{projectId}/chapters", ct);
|
||||||
|
|
||||||
[McpServerTool(Name = "get_chapter")]
|
[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(
|
public static Task<CallToolResult> GetChapter(
|
||||||
NovelApiClient api,
|
NovelApiClient api,
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
[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("Where and when the chapter takes place.")] string? setting = null,
|
||||||
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
||||||
[Description("Target length in words.")] int? targetWordCount = null,
|
[Description("Target length in words.")] int? targetWordCount = null,
|
||||||
|
[Description("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) =>
|
[Description("Tags for cross-referencing. Unknown tags are created.")] string[]? tags = null) =>
|
||||||
api.PostAsync($"/api/projects/{projectId}/chapters", new
|
api.PostAsync($"/api/projects/{projectId}/chapters", new
|
||||||
{
|
{
|
||||||
@@ -46,11 +47,14 @@ public static class ManuscriptTools
|
|||||||
setting,
|
setting,
|
||||||
status = status ?? "Planned",
|
status = status ?? "Planned",
|
||||||
targetWordCount,
|
targetWordCount,
|
||||||
|
prose,
|
||||||
tags
|
tags
|
||||||
}, ct);
|
}, ct);
|
||||||
|
|
||||||
[McpServerTool(Name = "update_chapter")]
|
[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(
|
public static Task<CallToolResult> UpdateChapter(
|
||||||
NovelApiClient api,
|
NovelApiClient api,
|
||||||
[Description("The chapter's id.")] Guid chapterId,
|
[Description("The chapter's id.")] Guid chapterId,
|
||||||
@@ -63,77 +67,8 @@ public static class ManuscriptTools
|
|||||||
[Description("Anything else worth recording.")] string? notes = null,
|
[Description("Anything else worth recording.")] string? notes = null,
|
||||||
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
|
||||||
[Description("Target length in words.")] int? targetWordCount = null,
|
[Description("Target length in words.")] int? targetWordCount = null,
|
||||||
|
[Description("The chapter's drafted text, in markdown.")] string? prose = null,
|
||||||
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
|
||||||
api.PatchAsync($"/api/chapters/{chapterId}",
|
api.PatchAsync($"/api/chapters/{chapterId}",
|
||||||
new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, tags }, ct);
|
new { title, number, summary, povCharacterId, setting, notes, status, targetWordCount, prose, 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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1122
-2
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
|||||||
"@tanstack/react-query": "^5.101.4",
|
"@tanstack/react-query": "^5.101.4",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
"react-markdown": "^10.1.0",
|
||||||
"react-router-dom": "^7.18.2"
|
"react-router-dom": "^7.18.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import type {
|
|||||||
OpenQuestion,
|
OpenQuestion,
|
||||||
Project,
|
Project,
|
||||||
ProjectSummary,
|
ProjectSummary,
|
||||||
Scene,
|
|
||||||
TagReferences,
|
TagReferences,
|
||||||
TagSummary,
|
TagSummary,
|
||||||
} from './types'
|
} from './types'
|
||||||
@@ -36,8 +35,6 @@ export const keys = {
|
|||||||
importJob: (id: string) => ['imports', id] as const,
|
importJob: (id: string) => ['imports', id] as const,
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Projects ---------------------------------------------------------------
|
|
||||||
|
|
||||||
export const useProjects = () =>
|
export const useProjects = () =>
|
||||||
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
|
useQuery({ queryKey: keys.projects, queryFn: () => api.get<ProjectSummary[]>('/api/projects') })
|
||||||
|
|
||||||
@@ -72,8 +69,6 @@ export function useDeleteProject() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Characters -------------------------------------------------------------
|
|
||||||
|
|
||||||
export const useCharacters = (projectId: string) =>
|
export const useCharacters = (projectId: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.characters(projectId),
|
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) =>
|
export const useCharacterBeats = (characterId: string | undefined) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.characterBeats(characterId ?? ''),
|
queryKey: keys.characterBeats(characterId ?? ''),
|
||||||
@@ -123,8 +114,6 @@ export const useCharacterBeats = (characterId: string | undefined) =>
|
|||||||
enabled: Boolean(characterId),
|
enabled: Boolean(characterId),
|
||||||
})
|
})
|
||||||
|
|
||||||
// --- Character arcs ----------------------------------------------------------
|
|
||||||
|
|
||||||
export function useCreateArcStage(projectId: string) {
|
export function useCreateArcStage(projectId: string) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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 = (
|
export const useOpenQuestions = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
filter: { chapterId?: string; characterId?: string; includeResolved?: boolean } = {},
|
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) {
|
export function useResolveQuestion(projectId: string) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -234,8 +213,6 @@ export function useDeleteQuestion(projectId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Tags --------------------------------------------------------------------
|
|
||||||
|
|
||||||
export const useTags = (projectId: string) =>
|
export const useTags = (projectId: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.tags(projectId),
|
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() {
|
export function useDeleteTag() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
@@ -270,13 +243,12 @@ export function useDeleteTag() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Beats (a chapter's outline) ---------------------------------------------
|
|
||||||
|
|
||||||
export function useCreateBeat(chapterId: string, projectId: string) {
|
export function useCreateBeat(chapterId: string, projectId: string) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (body: Partial<Beat> & { title: string }) =>
|
mutationFn: (
|
||||||
api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
|
body: Partial<Omit<Beat, 'tags' | 'characters'>> & { title: string; tags?: string[]; characterIds?: string[] },
|
||||||
|
) => api.post<Beat>(`/api/chapters/${chapterId}/beats`, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||||
@@ -287,7 +259,10 @@ export function useCreateBeat(chapterId: string, projectId: string) {
|
|||||||
export function useUpdateBeat(chapterId: string, projectId: string) {
|
export function useUpdateBeat(chapterId: string, projectId: string) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
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),
|
api.patch<Beat>(`/api/beats/${id}`, body),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
qc.invalidateQueries({ queryKey: keys.chapter(chapterId) })
|
||||||
@@ -313,8 +288,6 @@ export function useReorderBeats(chapterId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Chapters and scenes ----------------------------------------------------
|
|
||||||
|
|
||||||
export const useChapters = (projectId: string) =>
|
export const useChapters = (projectId: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.chapters(projectId),
|
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) =>
|
export const useConversations = (projectId: string) =>
|
||||||
useQuery({
|
useQuery({
|
||||||
queryKey: keys.conversations(projectId),
|
queryKey: keys.conversations(projectId),
|
||||||
@@ -407,7 +352,6 @@ export function useSendAgentMessage(projectId: string) {
|
|||||||
onSuccess: (turn) => {
|
onSuccess: (turn) => {
|
||||||
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
|
qc.invalidateQueries({ queryKey: keys.conversations(projectId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
|
qc.invalidateQueries({ queryKey: keys.conversation(turn.conversationId) })
|
||||||
// The agent edits project data through its tools, so anything on screen may be stale.
|
|
||||||
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
qc.invalidateQueries({ queryKey: keys.characters(projectId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
qc.invalidateQueries({ queryKey: keys.tags(projectId) })
|
||||||
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
qc.invalidateQueries({ queryKey: keys.chapters(projectId) })
|
||||||
@@ -417,8 +361,6 @@ export function useSendAgentMessage(projectId: string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Outline import ----------------------------------------------------------
|
|
||||||
|
|
||||||
export function useInspectImport() {
|
export function useInspectImport() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
|
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
|
||||||
@@ -434,11 +376,6 @@ export function useStartImport() {
|
|||||||
|
|
||||||
const terminalImportStatuses: ImportJobStatus[] = ['Completed', 'Failed', 'Paused']
|
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) {
|
export function useImportJob(jobId: string | undefined) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: keys.importJob(jobId ?? ''),
|
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 =
|
export type CharacterRole =
|
||||||
| 'Protagonist'
|
| 'Protagonist'
|
||||||
@@ -21,7 +20,6 @@ export const characterRoles: CharacterRole[] = [
|
|||||||
'Foil',
|
'Foil',
|
||||||
]
|
]
|
||||||
|
|
||||||
/** How much of the book a character carries. Separate from the part they play. */
|
|
||||||
export type CharacterImportance = 'Main' | 'Supporting'
|
export type CharacterImportance = 'Main' | 'Supporting'
|
||||||
|
|
||||||
export const characterImportances: 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']
|
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 type ProjectPhase = 'Brainstorming' | 'Outlining' | 'Writing' | 'Editing' | 'Complete'
|
||||||
|
|
||||||
export const projectPhases: 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 {
|
export interface Beat {
|
||||||
id: string
|
id: string
|
||||||
chapterId: string
|
chapterId: string
|
||||||
sortOrder: number
|
sortOrder: number
|
||||||
title: string
|
title: string
|
||||||
characterId: string | null
|
characters: BeatCharacter[]
|
||||||
characterName: string | null
|
|
||||||
whatHappened: string | null
|
whatHappened: string | null
|
||||||
whatsNext: string | null
|
whatsNext: string | null
|
||||||
sceneId: string | null
|
|
||||||
sceneTitle: string | null
|
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -116,7 +114,6 @@ export interface Relationship {
|
|||||||
description: string | null
|
description: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
/** One step in a main character's arc. Flat and ordered, like a chapter's beats. */
|
|
||||||
export interface ArcStage {
|
export interface ArcStage {
|
||||||
id: string
|
id: string
|
||||||
characterId: string
|
characterId: string
|
||||||
@@ -129,7 +126,6 @@ export interface ArcStage {
|
|||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A beat a character appears in, carrying its chapter so the page can link into the outline. */
|
|
||||||
export interface CharacterBeat {
|
export interface CharacterBeat {
|
||||||
id: string
|
id: string
|
||||||
chapterId: string
|
chapterId: string
|
||||||
@@ -139,8 +135,6 @@ export interface CharacterBeat {
|
|||||||
title: string
|
title: string
|
||||||
whatHappened: string | null
|
whatHappened: string | null
|
||||||
whatsNext: string | null
|
whatsNext: string | null
|
||||||
sceneId: string | null
|
|
||||||
sceneTitle: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
@@ -168,24 +162,6 @@ export interface Character {
|
|||||||
updatedAt: string
|
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 {
|
export interface ChapterSummary {
|
||||||
id: string
|
id: string
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -198,20 +174,19 @@ export interface ChapterSummary {
|
|||||||
status: DraftStatus
|
status: DraftStatus
|
||||||
targetWordCount: number | null
|
targetWordCount: number | null
|
||||||
beatCount: number
|
beatCount: number
|
||||||
sceneCount: number
|
|
||||||
wordCount: number
|
wordCount: number
|
||||||
tags: Tag[]
|
tags: Tag[]
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'sceneCount' | 'wordCount'> {
|
export interface Chapter extends Omit<ChapterSummary, 'beatCount' | 'wordCount'> {
|
||||||
notes: string | null
|
notes: string | null
|
||||||
beats: Beat[]
|
beats: Beat[]
|
||||||
scenes: Scene[]
|
prose: string | null
|
||||||
|
wordCount: number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Something the writer has not decided yet, hung off a chapter outline and/or a character. */
|
|
||||||
export interface OpenQuestion {
|
export interface OpenQuestion {
|
||||||
id: string
|
id: string
|
||||||
projectId: string
|
projectId: string
|
||||||
@@ -260,8 +235,6 @@ export interface AgentTurn {
|
|||||||
message: AgentMessage
|
message: AgentMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Outline import -----------------------------------------------------------
|
|
||||||
|
|
||||||
export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused'
|
export type ImportJobStatus = 'Pending' | 'Running' | 'Completed' | 'Failed' | 'Paused'
|
||||||
|
|
||||||
export interface ImportJob {
|
export interface ImportJob {
|
||||||
@@ -276,7 +249,6 @@ export interface ImportJob {
|
|||||||
updatedAt: string
|
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 type ImportReadiness = 'Fresh' | 'Resumable' | 'Complete'
|
||||||
|
|
||||||
export interface ImportInspection {
|
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;
|
--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 {
|
:root {
|
||||||
--paper: #faf7f0;
|
--paper: #faf7f0;
|
||||||
--surface: #ffffff;
|
--surface: #ffffff;
|
||||||
@@ -132,4 +128,57 @@ body {
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
@apply text-[1.0625rem] leading-relaxed;
|
@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">
|
<div className="card p-6">
|
||||||
<h2 className="text-lg font-semibold">Your writing partner</h2>
|
<h2 className="text-lg font-semibold">Your writing partner</h2>
|
||||||
<p className="mt-1 text-sm muted">
|
<p className="mt-1 text-sm muted">
|
||||||
It can read and edit the brief, the outline, character dossiers, chapters and
|
It can read and edit the brief, the outline, character dossiers and chapter
|
||||||
scenes — the same data you see in the other tabs.
|
prose — the same data you see in the other tabs.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 grid gap-2">
|
<div className="mt-4 grid gap-2">
|
||||||
{starters.map((starter) => (
|
{starters.map((starter) => (
|
||||||
|
|||||||
@@ -4,22 +4,23 @@ import {
|
|||||||
useChapter,
|
useChapter,
|
||||||
useCharacters,
|
useCharacters,
|
||||||
useCreateBeat,
|
useCreateBeat,
|
||||||
useCreateScene,
|
|
||||||
useDeleteBeat,
|
useDeleteBeat,
|
||||||
useDeleteChapter,
|
useDeleteChapter,
|
||||||
useDeleteScene,
|
|
||||||
useReorderBeats,
|
useReorderBeats,
|
||||||
useTags,
|
useTags,
|
||||||
useUpdateBeat,
|
useUpdateBeat,
|
||||||
useUpdateChapter,
|
useUpdateChapter,
|
||||||
useUpdateScene,
|
|
||||||
} from '../api/hooks'
|
} from '../api/hooks'
|
||||||
import { draftStatuses, type Beat, type Chapter, type Scene } from '../api/types'
|
import { draftStatuses, type Beat, type Chapter } from '../api/types'
|
||||||
import { AutoField, ErrorNote, Select, Spinner, StatusBadge } from '../components/ui'
|
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
|
||||||
import { TagChip, TagEditor } from '../components/TagEditor'
|
import { TagChip, TagEditor } from '../components/TagEditor'
|
||||||
|
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
|
||||||
|
import { MarkdownEditor } from '../components/MarkdownEditor'
|
||||||
import { OpenQuestions } from '../components/OpenQuestions'
|
import { OpenQuestions } from '../components/OpenQuestions'
|
||||||
import { useHotkey } from '../keyboard/HotkeysContext'
|
import { useHotkey } from '../keyboard/HotkeysContext'
|
||||||
|
|
||||||
|
type ChapterTab = 'outline' | 'prose'
|
||||||
|
|
||||||
export default function ChapterPage() {
|
export default function ChapterPage() {
|
||||||
const { projectId = '', chapterId = '' } = useParams()
|
const { projectId = '', chapterId = '' } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@@ -29,10 +30,9 @@ export default function ChapterPage() {
|
|||||||
const update = useUpdateChapter(projectId)
|
const update = useUpdateChapter(projectId)
|
||||||
const remove = useDeleteChapter(projectId)
|
const remove = useDeleteChapter(projectId)
|
||||||
const createBeat = useCreateBeat(chapterId, 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('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 (isPending) return <Spinner label="Loading chapter" />
|
||||||
if (error) return <ErrorNote error={error} />
|
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="mt-4 flex items-end justify-between gap-4">
|
||||||
<div className="text-sm muted">
|
<div className="text-sm muted">
|
||||||
{chapter.beats.length} beats · {chapter.scenes.length} scenes ·{' '}
|
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
|
||||||
{chapter.scenes.reduce((sum, s) => sum + s.wordCount, 0).toLocaleString()} words
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="btn"
|
className="btn"
|
||||||
@@ -132,67 +131,74 @@ export default function ChapterPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* The outline: a paragraph, then the beat table. */}
|
<div className="mb-5 flex gap-1" style={{ borderBottom: '1px solid var(--line)' }}>
|
||||||
<section className="mb-8">
|
{(
|
||||||
<h2 className="mb-1 text-lg font-semibold">Outline</h2>
|
[
|
||||||
<p className="mb-3 text-sm muted">
|
['outline', 'Outline'],
|
||||||
A paragraph on what the chapter does, then the beats that carry it.
|
['prose', 'Text'],
|
||||||
</p>
|
] as const
|
||||||
|
).map(([value, tabLabel]) => (
|
||||||
<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>
|
|
||||||
<button
|
<button
|
||||||
className="btn"
|
key={value}
|
||||||
onClick={() => createScene.mutate({ title: 'New scene' })}
|
onClick={() => setTab(value)}
|
||||||
disabled={createScene.isPending}
|
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>
|
</button>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
<ul className="grid gap-3">
|
{tab === 'outline' ? (
|
||||||
{chapter.scenes.map((scene) => (
|
<section className="mb-8">
|
||||||
<SceneCard key={scene.id} chapterId={chapter.id} scene={scene} />
|
<p className="mb-3 text-sm muted">
|
||||||
))}
|
A paragraph on what the chapter does, then the beats that carry it.
|
||||||
</ul>
|
</p>
|
||||||
</section>
|
|
||||||
|
<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">
|
<section className="card mt-6 p-5">
|
||||||
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
|
<h3 className="mb-1 text-sm font-semibold">Notes</h3>
|
||||||
@@ -247,7 +253,7 @@ function BeatTable({
|
|||||||
reorder.mutate(ids)
|
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 })
|
update.mutate({ id, ...body })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -256,15 +262,14 @@ function BeatTable({
|
|||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
<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-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-[16%] 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">
|
<th className="w-[16%] px-2 py-2 text-left text-xs font-semibold uppercase muted">
|
||||||
Character
|
Characters
|
||||||
</th>
|
</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
|
What happened
|
||||||
</th>
|
</th>
|
||||||
<th className="w-[28%] px-2 py-2 text-left text-xs font-semibold uppercase muted">What's next</th>
|
<th className="w-[32%] 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-8" />
|
<th className="w-8" />
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -320,18 +325,11 @@ function BeatTable({
|
|||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
<select
|
<CharacterMultiSelect
|
||||||
className="input"
|
selected={beat.characters}
|
||||||
value={beat.characterName ?? '—'}
|
options={characters}
|
||||||
onChange={(e) => {
|
onChange={(characterIds) => patch(beat.id, { characterIds })}
|
||||||
const match = characters.find((c) => c.name === e.target.value)
|
/>
|
||||||
patch(beat.id, { characterId: match?.id ?? null })
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{['—', ...characters.map((c) => c.name)].map((name) => (
|
|
||||||
<option key={name}>{name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top">
|
<td className="px-2 py-2 align-top">
|
||||||
@@ -356,21 +354,6 @@ function BeatTable({
|
|||||||
/>
|
/>
|
||||||
</td>
|
</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">
|
<td className="px-2 py-2 align-top">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<button
|
<button
|
||||||
@@ -413,7 +396,17 @@ function BeatTable({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</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">
|
<td className="px-2 py-2 align-top whitespace-pre-wrap break-words">
|
||||||
{beat.whatHappened ?? <span className="muted">—</span>}
|
{beat.whatHappened ?? <span className="muted">—</span>}
|
||||||
@@ -423,8 +416,6 @@ function BeatTable({
|
|||||||
{beat.whatsNext ?? <span className="muted">—</span>}
|
{beat.whatsNext ?? <span className="muted">—</span>}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top">{beat.sceneTitle ?? <span className="muted">—</span>}</td>
|
|
||||||
|
|
||||||
<td className="px-2 py-2 align-top" />
|
<td className="px-2 py-2 align-top" />
|
||||||
</tr>
|
</tr>
|
||||||
),
|
),
|
||||||
@@ -434,51 +425,3 @@ function BeatTable({
|
|||||||
</div>
|
</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">
|
<div className="flex shrink-0 items-center gap-3 text-xs muted">
|
||||||
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
|
{chapter.povCharacterName && <span>POV: {chapter.povCharacterName}</span>}
|
||||||
<span>{chapter.beatCount} beats</span>
|
<span>{chapter.beatCount} beats</span>
|
||||||
<span>{chapter.sceneCount} scenes</span>
|
|
||||||
<span>{chapter.wordCount.toLocaleString()} words</span>
|
<span>{chapter.wordCount.toLocaleString()} words</span>
|
||||||
<StatusBadge status={chapter.status} />
|
<StatusBadge status={chapter.status} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,9 +10,6 @@ public class AnthropicClientTests
|
|||||||
{
|
{
|
||||||
[Test]
|
[Test]
|
||||||
public void Constructing_without_a_key_does_not_throw() =>
|
public void Constructing_without_a_key_does_not_throw() =>
|
||||||
// The agent service takes this as a dependency and also serves read-only endpoints
|
|
||||||
// (listing conversations, reading a transcript). Throwing at construction would
|
|
||||||
// take those down on any install that has not configured a key yet.
|
|
||||||
Assert.That(
|
Assert.That(
|
||||||
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance),
|
() => new AnthropicAgentModelClient(Options.Create(new AgentOptions()), NullLogger<AnthropicAgentModelClient>.Instance),
|
||||||
Throws.Nothing);
|
Throws.Nothing);
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
using Novelly.Api.Beats;
|
using Novelly.Api.Beats;
|
||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Common;
|
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
@@ -75,22 +73,20 @@ public class BeatServiceTests : ServiceTestFixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task A_beat_resolves_its_character_and_scene_names()
|
public async Task A_beat_can_carry_several_characters()
|
||||||
{
|
{
|
||||||
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
|
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
|
||||||
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
|
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
||||||
|
|
||||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
|
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
|
||||||
"She burns the atlas",
|
"She burns the atlas",
|
||||||
CharacterId: ines.Id,
|
CharacterIds: [ines.Id, mara.Id],
|
||||||
WhatHappened: "The pages go up faster than she expected.",
|
WhatHappened: "The pages go up faster than she expected.",
|
||||||
WhatsNext: "Nothing to navigate by but memory.",
|
WhatsNext: "Nothing to navigate by but memory."));
|
||||||
SceneId: scene.Id));
|
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(beat.Character!.Name, Is.EqualTo("Ines"));
|
Assert.That(beat.Characters.Select(c => c.Name), Is.EquivalentTo(new[] { "Ines", "Mara" }));
|
||||||
Assert.That(beat.Scene!.Title, Is.EqualTo("The dock at dawn"));
|
|
||||||
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
|
Assert.That(beat.WhatHappened, Does.Contain("faster than she expected"));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,41 +99,10 @@ public class BeatServiceTests : ServiceTestFixture
|
|||||||
|
|
||||||
Assert.That(
|
Assert.That(
|
||||||
async () => await Beats.CreateAsync(
|
async () => await Beats.CreateAsync(
|
||||||
_chapterId, new CreateBeatRequest("A beat", CharacterId: stranger.Id)),
|
_chapterId, new CreateBeatRequest("A beat", CharacterIds: [stranger.Id])),
|
||||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
|
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same project"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task A_beat_cannot_be_grouped_under_a_scene_from_another_chapter()
|
|
||||||
{
|
|
||||||
var elsewhere = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("Elsewhere"));
|
|
||||||
var scene = await Scenes.CreateAsync(elsewhere.Id, new CreateSceneRequest("Another scene"));
|
|
||||||
|
|
||||||
Assert.That(
|
|
||||||
async () => await Beats.CreateAsync(
|
|
||||||
_chapterId, new CreateBeatRequest("A beat", SceneId: scene.Id)),
|
|
||||||
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("same chapter"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
|
||||||
public async Task Deleting_a_scene_leaves_its_beats_alone()
|
|
||||||
{
|
|
||||||
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
|
|
||||||
var beat = await Beats.CreateAsync(
|
|
||||||
_chapterId, new CreateBeatRequest("She burns the atlas", SceneId: scene.Id));
|
|
||||||
|
|
||||||
await Scenes.DeleteAsync(scene.Id);
|
|
||||||
|
|
||||||
// The plan outlives a decision about prose — the beat is simply ungrouped.
|
|
||||||
var survivor = (await Beats.GetAsync(beat.Id))!;
|
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
|
||||||
{
|
|
||||||
Assert.That(survivor.SceneId, Is.Null);
|
|
||||||
Assert.That(survivor.Title, Is.EqualTo("She burns the atlas"));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string()
|
||||||
{
|
{
|
||||||
@@ -164,23 +129,17 @@ public class BeatServiceTests : ServiceTestFixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task ClearCharacter_and_ClearScene_detach_the_reference_since_a_null_id_means_leave_it_alone()
|
public async Task An_empty_CharacterIds_list_clears_a_beats_characters_since_null_means_leave_it_alone()
|
||||||
{
|
{
|
||||||
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
|
var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines"));
|
||||||
var scene = await Scenes.CreateAsync(_chapterId, new CreateSceneRequest("The dock at dawn"));
|
|
||||||
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
|
var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest(
|
||||||
"She burns the atlas", CharacterId: ines.Id, SceneId: scene.Id));
|
"She burns the atlas", CharacterIds: [ines.Id]));
|
||||||
|
|
||||||
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
|
var untouched = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(Title: "She burns it")))!;
|
||||||
Assert.That(untouched.Character!.Name, Is.EqualTo("Ines"));
|
Assert.That(untouched.Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" }));
|
||||||
|
|
||||||
var cleared = (await Beats.UpdateAsync(
|
var cleared = (await Beats.UpdateAsync(beat.Id, new UpdateBeatRequest(CharacterIds: [])))!;
|
||||||
beat.Id, new UpdateBeatRequest(ClearCharacter: true, ClearScene: true)))!;
|
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.That(cleared.Characters, Is.Empty);
|
||||||
{
|
|
||||||
Assert.That(cleared.Character, Is.Null);
|
|
||||||
Assert.That(cleared.Scene, Is.Null);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
/// <summary>Records every entry logged through it, so tests can assert on what a service logged.</summary>
|
|
||||||
public record CapturedLogEntry(LogLevel Level, string Message, Exception? Exception);
|
public record CapturedLogEntry(LogLevel Level, string Message, Exception? Exception);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A test double for <see cref="ILogger{TCategoryName}"/> that captures entries instead of
|
|
||||||
/// writing them anywhere, so tests can assert a service logged at the right level with the
|
|
||||||
/// right values without standing up a real sink.
|
|
||||||
/// </summary>
|
|
||||||
public class CapturingLogger<T> : ILogger<T>
|
public class CapturingLogger<T> : ILogger<T>
|
||||||
{
|
{
|
||||||
public List<CapturedLogEntry> Entries { get; } = [];
|
public List<CapturedLogEntry> Entries { get; } = [];
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ public class CharacterArcTests : ServiceTestFixture
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task Within_a_group_the_lead_comes_before_the_second_lead()
|
public async Task Within_a_group_the_lead_comes_before_the_second_lead()
|
||||||
{
|
{
|
||||||
// Both enums are stored as text, so ordering them in SQL orders the spelling and
|
|
||||||
// "Deuteragonist" beats "Protagonist" — burying the character the book is about.
|
|
||||||
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||||
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
|
"Mara", CharacterRole.Deuteragonist, CharacterImportance.Main));
|
||||||
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
await Characters.CreateAsync(_projectId, new CreateCharacterRequest(
|
||||||
@@ -162,7 +160,6 @@ public class CharacterArcTests : ServiceTestFixture
|
|||||||
|
|
||||||
await Chapters.DeleteAsync(chapter.Id);
|
await Chapters.DeleteAsync(chapter.Id);
|
||||||
|
|
||||||
// How a character changes outlives a decision about where the chapter break falls.
|
|
||||||
var survivor = (await Arcs.GetAsync(stage.Id))!;
|
var survivor = (await Arcs.GetAsync(stage.Id))!;
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
@@ -195,9 +192,9 @@ public class CharacterArcTests : ServiceTestFixture
|
|||||||
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1));
|
var first = await Chapters.CreateAsync(_projectId, new CreateChapterRequest("First", Number: 1));
|
||||||
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara"));
|
||||||
|
|
||||||
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterId: _characterId));
|
await Beats.CreateAsync(second.Id, new CreateBeatRequest("She boards anyway", CharacterIds: [_characterId]));
|
||||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterId: _characterId));
|
await Beats.CreateAsync(first.Id, new CreateBeatRequest("She finds the map", CharacterIds: [_characterId]));
|
||||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterId: mara.Id));
|
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Mara lies", CharacterIds: [mara.Id]));
|
||||||
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
|
await Beats.CreateAsync(first.Id, new CreateBeatRequest("Nobody's beat"));
|
||||||
|
|
||||||
var beats = (await Beats.ListForCharacterAsync(_characterId))!;
|
var beats = (await Beats.ListForCharacterAsync(_characterId))!;
|
||||||
|
|||||||
@@ -5,11 +5,6 @@ using Novelly.Api.Projects;
|
|||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Covers the exception-handling rework: a missing entity is an ordinary result, not a
|
|
||||||
/// thrown exception; <see cref="Guard"/> rejects missing required arguments; and a
|
|
||||||
/// service re-validates a request even when a direct caller skips the API's own filter.
|
|
||||||
/// </summary>
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class ExceptionHandlingTests : ServiceTestFixture
|
public class ExceptionHandlingTests : ServiceTestFixture
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -64,8 +64,6 @@ public class ImportAgentToolsetTests : ServiceTestFixture
|
|||||||
[Test]
|
[Test]
|
||||||
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
|
public async Task Write_ledger_can_only_ever_touch_the_ledger_file_no_matter_what_path_is_asked_for()
|
||||||
{
|
{
|
||||||
// The tool takes no path argument at all — this is the enforcement, not a check
|
|
||||||
// against a supplied path. Confirm the write always lands at exactly the ledger name.
|
|
||||||
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
|
await _toolset.ExecuteAsync("write_ledger", Input(new { json = """{"completedPasses": ["project"]}""" }));
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
|
|||||||
@@ -105,7 +105,6 @@ public class ImportServiceTests : ServiceTestFixture
|
|||||||
|
|
||||||
Assert.That(second.Id, Is.EqualTo(first.Id));
|
Assert.That(second.Id, Is.EqualTo(first.Id));
|
||||||
|
|
||||||
// Only one job was ever queued.
|
|
||||||
Assert.That(_queue.Reader.TryRead(out _), Is.True);
|
Assert.That(_queue.Reader.TryRead(out _), Is.True);
|
||||||
Assert.That(_queue.Reader.TryRead(out _), Is.False);
|
Assert.That(_queue.Reader.TryRead(out _), Is.False);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,9 @@ using Novelly.Api.Agent;
|
|||||||
using Novelly.Api.Chapters;
|
using Novelly.Api.Chapters;
|
||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Covers the list endpoints, which sort and aggregate in SQL rather than in memory.
|
|
||||||
/// SQLite is fussier than the in-memory provider about what it will translate — ordering
|
|
||||||
/// by a DateTimeOffset, for one — so these have to run against real SQLite to be worth anything.
|
|
||||||
/// </summary>
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class ListingTests : ServiceTestFixture
|
public class ListingTests : ServiceTestFixture
|
||||||
{
|
{
|
||||||
@@ -22,7 +16,6 @@ public class ListingTests : ServiceTestFixture
|
|||||||
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
|
var older = await Projects.CreateAsync(new CreateProjectRequest("Older Book"));
|
||||||
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
|
var newer = await Projects.CreateAsync(new CreateProjectRequest("Newer Book"));
|
||||||
|
|
||||||
// Touching the older project should float it to the top.
|
|
||||||
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
|
await Projects.UpdateAsync(older.Id, new UpdateProjectRequest(Logline: "Revised."));
|
||||||
|
|
||||||
var listed = await Projects.ListAsync();
|
var listed = await Projects.ListAsync();
|
||||||
@@ -41,10 +34,8 @@ public class ListingTests : ServiceTestFixture
|
|||||||
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
|
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Ines"));
|
||||||
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
|
await Characters.CreateAsync(project.Id, new CreateCharacterRequest("Mara"));
|
||||||
|
|
||||||
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall"));
|
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Landfall", Prose: "One two three"));
|
||||||
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour"));
|
await Chapters.CreateAsync(project.Id, new CreateChapterRequest("The Harbour", Prose: "Four five"));
|
||||||
await Scenes.CreateAsync(first.Id, new CreateSceneRequest("Dawn", Prose: "One two three"));
|
|
||||||
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("Dusk", Prose: "Four five"));
|
|
||||||
|
|
||||||
var summary = (await Projects.ListAsync()).Single();
|
var summary = (await Projects.ListAsync()).Single();
|
||||||
|
|
||||||
@@ -71,22 +62,19 @@ public class ListingTests : ServiceTestFixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Chapters_are_listed_in_manuscript_order_with_scene_totals()
|
public async Task Chapters_are_listed_in_manuscript_order_with_word_counts()
|
||||||
{
|
{
|
||||||
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
var project = await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"));
|
||||||
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2));
|
var second = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("Second", Number: 2, Prose: "One two three"));
|
||||||
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
|
var first = await Chapters.CreateAsync(project.Id, new CreateChapterRequest("First", Number: 1));
|
||||||
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("A", Prose: "One two"));
|
|
||||||
await Scenes.CreateAsync(second.Id, new CreateSceneRequest("B", Prose: "Three"));
|
|
||||||
|
|
||||||
var listed = await Chapters.ListAsync(project.Id);
|
var listed = await Chapters.ListAsync(project.Id);
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
|
Assert.That(listed.Select(c => c.Title), Is.EqualTo(new[] { "First", "Second" }));
|
||||||
Assert.That(listed.Single(c => c.Id == second.Id).Scenes, Has.Count.EqualTo(2));
|
Assert.That(listed.Single(c => c.Id == second.Id).WordCount, Is.EqualTo(3));
|
||||||
Assert.That(listed.Single(c => c.Id == second.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(3));
|
Assert.That(listed.Single(c => c.Id == first.Id).WordCount, Is.EqualTo(0));
|
||||||
Assert.That(listed.Single(c => c.Id == first.Id).Scenes.Sum(s => s.WordCount), Is.EqualTo(0));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,7 +85,7 @@ public class ListingTests : ServiceTestFixture
|
|||||||
var agent = new NovelAgentService(
|
var agent = new NovelAgentService(
|
||||||
Db.Context,
|
Db.Context,
|
||||||
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
|
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
|
||||||
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
|
new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance),
|
||||||
Options.Create(new AgentOptions()),
|
Options.Create(new AgentOptions()),
|
||||||
NullLogger<NovelAgentService>.Instance,
|
NullLogger<NovelAgentService>.Instance,
|
||||||
new SendAgentMessageRequestValidator());
|
new SendAgentMessageRequestValidator());
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ using Novelly.Api.Projects;
|
|||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Covers the logging behaviour added across the services: a warning fires before a
|
|
||||||
/// not-found is thrown, and prose bodies never leak into a log message.
|
|
||||||
/// </summary>
|
|
||||||
[TestFixture]
|
[TestFixture]
|
||||||
public class LoggingTests : ServiceTestFixture
|
public class LoggingTests : ServiceTestFixture
|
||||||
{
|
{
|
||||||
@@ -82,7 +78,7 @@ public class LoggingTests : ServiceTestFixture
|
|||||||
BeatLogs.Entries.Clear();
|
BeatLogs.Entries.Clear();
|
||||||
|
|
||||||
Assert.That(
|
Assert.That(
|
||||||
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterId: foreignCharacter.Id)),
|
() => Beats.CreateAsync(chapter.Id, new CreateBeatRequest("Arrival", CharacterIds: [foreignCharacter.Id])),
|
||||||
Throws.TypeOf<InvalidOperationException>());
|
Throws.TypeOf<InvalidOperationException>());
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
|||||||
private NovelAgentToolset _toolset = null!;
|
private NovelAgentToolset _toolset = null!;
|
||||||
|
|
||||||
protected override void OnSetUp() =>
|
protected override void OnSetUp() =>
|
||||||
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Scenes, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
|
_toolset = new NovelAgentToolset(Projects, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance);
|
||||||
|
|
||||||
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
|
||||||
Db.Context,
|
Db.Context,
|
||||||
@@ -143,7 +143,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
|||||||
{
|
{
|
||||||
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
var projectId = (await Projects.CreateAsync(new CreateProjectRequest("The Salt Road"))).Id;
|
||||||
|
|
||||||
// A model that only ever asks for more tools would otherwise loop forever.
|
|
||||||
var model = new ScriptedModelClient(
|
var model = new ScriptedModelClient(
|
||||||
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
|
Enumerable.Repeat<IReadOnlyList<AgentContentBlock>>(
|
||||||
[ToolUse("t", "list_characters", new { })], 20).ToList());
|
[ToolUse("t", "list_characters", new { })], 20).ToList());
|
||||||
@@ -178,7 +177,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
|||||||
{
|
{
|
||||||
Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId));
|
Assert.That(second.ConversationId, Is.EqualTo(first.ConversationId));
|
||||||
|
|
||||||
// The second request replays the earlier turns so the model has the history.
|
|
||||||
Assert.That(model.Transcripts[1], Has.Count.EqualTo(3));
|
Assert.That(model.Transcripts[1], Has.Count.EqualTo(3));
|
||||||
Assert.That(
|
Assert.That(
|
||||||
model.Transcripts[1].Select(m => m.Role),
|
model.Transcripts[1].Select(m => m.Role),
|
||||||
@@ -209,10 +207,6 @@ public class NovelAgentServiceTests : ServiceTestFixture
|
|||||||
new(id, name, JsonSerializer.SerializeToElement(input));
|
new(id, name, JsonSerializer.SerializeToElement(input));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A model stand-in that returns a fixed script of turns and records every transcript it
|
|
||||||
/// was sent, so tests can assert on what the loop actually put in front of the model.
|
|
||||||
/// </summary>
|
|
||||||
internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
|
internal class ScriptedModelClient(IReadOnlyList<IReadOnlyList<AgentContentBlock>> script)
|
||||||
: IAgentModelClient
|
: IAgentModelClient
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ public class OpenQuestionTests : ServiceTestFixture
|
|||||||
Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" }));
|
Assert.That(open.Select(q => q.Question), Is.EqualTo(new[] { "Is this one book or two?" }));
|
||||||
Assert.That(everything, Has.Count.EqualTo(2));
|
Assert.That(everything, Has.Count.EqualTo(2));
|
||||||
|
|
||||||
// Still-open questions come first, so the list stays about what is undecided.
|
|
||||||
Assert.That(everything[0].IsResolved, Is.False);
|
Assert.That(everything[0].IsResolved, Is.False);
|
||||||
Assert.That(everything[1].IsResolved, Is.True);
|
Assert.That(everything[1].IsResolved, Is.True);
|
||||||
});
|
});
|
||||||
@@ -128,7 +127,6 @@ public class OpenQuestionTests : ServiceTestFixture
|
|||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
// The existing note is kept and the decision lands underneath it.
|
|
||||||
Assert.That(chapter.Notes, Does.StartWith("Runs long."));
|
Assert.That(chapter.Notes, Does.StartWith("Runs long."));
|
||||||
Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns."));
|
Assert.That(chapter.Notes, Does.Contain("Where does the chapter break? — After the harbour burns."));
|
||||||
Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns."));
|
Assert.That(character.Notes, Is.EqualTo("Where does the chapter break? — After the harbour burns."));
|
||||||
@@ -179,7 +177,6 @@ public class OpenQuestionTests : ServiceTestFixture
|
|||||||
{
|
{
|
||||||
Assert.That(detached.ChapterId, Is.Null);
|
Assert.That(detached.ChapterId, Is.Null);
|
||||||
|
|
||||||
// Only the chapter was cleared — a null id means "leave alone", not "detach".
|
|
||||||
Assert.That(detached.CharacterId, Is.EqualTo(_characterId));
|
Assert.That(detached.CharacterId, Is.EqualTo(_characterId));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ using Novelly.Api.Chapters;
|
|||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Common;
|
using Novelly.Api.Common;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
@@ -69,19 +68,18 @@ public class ProjectDataTests : ServiceTestFixture
|
|||||||
public async Task Word_count_is_recomputed_whenever_prose_changes()
|
public async Task Word_count_is_recomputed_whenever_prose_changes()
|
||||||
{
|
{
|
||||||
var projectId = await NewProjectAsync();
|
var projectId = await NewProjectAsync();
|
||||||
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
|
||||||
|
|
||||||
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
|
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||||
"The dock at dawn", Prose: "Five words go right here"));
|
"Landfall", Prose: "Five words go right here"));
|
||||||
|
|
||||||
Assert.That(scene.WordCount, Is.EqualTo(5));
|
Assert.That(chapter.WordCount, Is.EqualTo(5));
|
||||||
|
|
||||||
var rewritten = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(
|
var rewritten = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(
|
||||||
Prose: "Now\nthere are seven words in total")))!;
|
Prose: "Now\nthere are seven words in total")))!;
|
||||||
|
|
||||||
Assert.That(rewritten.WordCount, Is.EqualTo(7));
|
Assert.That(rewritten.WordCount, Is.EqualTo(7));
|
||||||
|
|
||||||
var cleared = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Prose: "")))!;
|
var cleared = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Prose: "")))!;
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
@@ -91,14 +89,13 @@ public class ProjectDataTests : ServiceTestFixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Scene_updates_that_omit_prose_leave_the_draft_untouched()
|
public async Task Chapter_updates_that_omit_prose_leave_the_draft_untouched()
|
||||||
{
|
{
|
||||||
var projectId = await NewProjectAsync();
|
var projectId = await NewProjectAsync();
|
||||||
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||||
var scene = await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest(
|
"Landfall", Prose: "The tide came in slow."));
|
||||||
"The dock at dawn", Prose: "The tide came in slow."));
|
|
||||||
|
|
||||||
var updated = (await Scenes.UpdateAsync(scene.Id, new UpdateSceneRequest(Status: DraftStatus.Revised)))!;
|
var updated = (await Chapters.UpdateAsync(chapter.Id, new UpdateChapterRequest(Status: DraftStatus.Revised)))!;
|
||||||
|
|
||||||
Assert.Multiple(() =>
|
Assert.Multiple(() =>
|
||||||
{
|
{
|
||||||
@@ -109,12 +106,11 @@ public class ProjectDataTests : ServiceTestFixture
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[Test]
|
||||||
public async Task Deleting_a_project_takes_its_characters_chapters_and_scenes()
|
public async Task Deleting_a_project_takes_its_characters_and_chapters()
|
||||||
{
|
{
|
||||||
var projectId = await NewProjectAsync();
|
var projectId = await NewProjectAsync();
|
||||||
await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
|
await Characters.CreateAsync(projectId, new CreateCharacterRequest("Ines"));
|
||||||
var chapter = await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
await Chapters.CreateAsync(projectId, new CreateChapterRequest("Landfall"));
|
||||||
await Scenes.CreateAsync(chapter.Id, new CreateSceneRequest("The dock at dawn"));
|
|
||||||
|
|
||||||
await Projects.DeleteAsync(projectId);
|
await Projects.DeleteAsync(projectId);
|
||||||
|
|
||||||
@@ -125,7 +121,6 @@ public class ProjectDataTests : ServiceTestFixture
|
|||||||
Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0));
|
Assert.That(await verification.Projects.CountAsync(), Is.EqualTo(0));
|
||||||
Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0));
|
Assert.That(await verification.Characters.CountAsync(), Is.EqualTo(0));
|
||||||
Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0));
|
Assert.That(await verification.Chapters.CountAsync(), Is.EqualTo(0));
|
||||||
Assert.That(await verification.Scenes.CountAsync(), Is.EqualTo(0));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,10 @@ using Novelly.Api.Chapters;
|
|||||||
using Novelly.Api.Characters;
|
using Novelly.Api.Characters;
|
||||||
using Novelly.Api.Projects;
|
using Novelly.Api.Projects;
|
||||||
using Novelly.Api.Questions;
|
using Novelly.Api.Questions;
|
||||||
using Novelly.Api.Scenes;
|
|
||||||
using Novelly.Api.Tags;
|
using Novelly.Api.Tags;
|
||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Shared plumbing for the service tests: a fresh in-memory database and a matching set
|
|
||||||
/// of services per test.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// The setup lives in <c>[SetUp]</c> rather than a constructor or field initialisers
|
|
||||||
/// because NUnit builds one fixture instance for the whole class — anything created once
|
|
||||||
/// would leak state from one test into the next.
|
|
||||||
/// </remarks>
|
|
||||||
public abstract class ServiceTestFixture
|
public abstract class ServiceTestFixture
|
||||||
{
|
{
|
||||||
protected TestDatabase Db { get; private set; } = null!;
|
protected TestDatabase Db { get; private set; } = null!;
|
||||||
@@ -24,7 +14,6 @@ public abstract class ServiceTestFixture
|
|||||||
protected ProjectService Projects { get; private set; } = null!;
|
protected ProjectService Projects { get; private set; } = null!;
|
||||||
protected CharacterService Characters { get; private set; } = null!;
|
protected CharacterService Characters { get; private set; } = null!;
|
||||||
protected ChapterService Chapters { get; private set; } = null!;
|
protected ChapterService Chapters { get; private set; } = null!;
|
||||||
protected SceneService Scenes { get; private set; } = null!;
|
|
||||||
protected BeatService Beats { get; private set; } = null!;
|
protected BeatService Beats { get; private set; } = null!;
|
||||||
protected CharacterArcService Arcs { get; private set; } = null!;
|
protected CharacterArcService Arcs { get; private set; } = null!;
|
||||||
protected OpenQuestionService Questions { get; private set; } = null!;
|
protected OpenQuestionService Questions { get; private set; } = null!;
|
||||||
@@ -32,7 +21,6 @@ public abstract class ServiceTestFixture
|
|||||||
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!;
|
protected CapturingLogger<ProjectService> ProjectLogs { get; private set; } = null!;
|
||||||
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
|
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
|
||||||
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
|
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
|
||||||
protected CapturingLogger<SceneService> SceneLogs { get; private set; } = null!;
|
|
||||||
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
|
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
|
||||||
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
|
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
|
||||||
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
|
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
|
||||||
@@ -47,7 +35,6 @@ public abstract class ServiceTestFixture
|
|||||||
ProjectLogs = new CapturingLogger<ProjectService>();
|
ProjectLogs = new CapturingLogger<ProjectService>();
|
||||||
CharacterLogs = new CapturingLogger<CharacterService>();
|
CharacterLogs = new CapturingLogger<CharacterService>();
|
||||||
ChapterLogs = new CapturingLogger<ChapterService>();
|
ChapterLogs = new CapturingLogger<ChapterService>();
|
||||||
SceneLogs = new CapturingLogger<SceneService>();
|
|
||||||
BeatLogs = new CapturingLogger<BeatService>();
|
BeatLogs = new CapturingLogger<BeatService>();
|
||||||
ArcLogs = new CapturingLogger<CharacterArcService>();
|
ArcLogs = new CapturingLogger<CharacterArcService>();
|
||||||
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
QuestionLogs = new CapturingLogger<OpenQuestionService>();
|
||||||
@@ -58,7 +45,6 @@ public abstract class ServiceTestFixture
|
|||||||
Db.Context, Tags, CharacterLogs,
|
Db.Context, Tags, CharacterLogs,
|
||||||
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
|
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator());
|
||||||
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
|
||||||
Scenes = new SceneService(Db.Context, SceneLogs, new CreateSceneRequestValidator(), new UpdateSceneRequestValidator());
|
|
||||||
Beats = new BeatService(
|
Beats = new BeatService(
|
||||||
Db.Context, Tags, BeatLogs,
|
Db.Context, Tags, BeatLogs,
|
||||||
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
|
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator());
|
||||||
@@ -72,7 +58,6 @@ public abstract class ServiceTestFixture
|
|||||||
OnSetUp();
|
OnSetUp();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Runs after the services exist, for per-class seed data.</summary>
|
|
||||||
protected virtual void OnSetUp()
|
protected virtual void OnSetUp()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,6 @@ using Novelly.Api.Data;
|
|||||||
|
|
||||||
namespace Novelly.Api.Tests;
|
namespace Novelly.Api.Tests;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A throwaway SQLite database held in memory. Using real SQLite rather than the
|
|
||||||
/// in-memory provider means the tests exercise the same relational behaviour the app
|
|
||||||
/// ships with — cascade deletes, foreign keys and all.
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>
|
|
||||||
/// NUnit reuses one fixture instance across every test in a class, so this must be built
|
|
||||||
/// in <c>[SetUp]</c> and disposed in <c>[TearDown]</c>. A field initialiser would share
|
|
||||||
/// one database for the whole class and let tests see each other's rows.
|
|
||||||
/// </remarks>
|
|
||||||
public class TestDatabase : IDisposable
|
public class TestDatabase : IDisposable
|
||||||
{
|
{
|
||||||
private readonly SqliteConnection _connection;
|
private readonly SqliteConnection _connection;
|
||||||
@@ -29,7 +19,6 @@ public class TestDatabase : IDisposable
|
|||||||
|
|
||||||
public NovelDbContext Context { get; }
|
public NovelDbContext Context { get; }
|
||||||
|
|
||||||
/// <summary>A second context over the same database, for asserting on persisted state.</summary>
|
|
||||||
public NovelDbContext CreateContext() =>
|
public NovelDbContext CreateContext() =>
|
||||||
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);
|
new(new DbContextOptionsBuilder<NovelDbContext>().UseSqlite(_connection).Options);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user