Remove Scenes, group beats by multiple characters; strip comments repo-wide
Drop the Scene entity/grouping in favor of chapters carrying prose directly and beats belonging to many characters. Add markdown editor + character multi-select components to the web client. Remove all XML doc and inline comments across the touched C#/TS/CSS files in favor of self-documenting names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP server config, no secrets) and ignore .idea/.
This commit is contained in:
@@ -3,10 +3,6 @@ using System.Text.Json;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// Small builder for the JSON Schema objects tool definitions need. Hand-writing these
|
||||
/// as string literals is where tool definitions usually rot, so build them structurally.
|
||||
/// </summary>
|
||||
public class JsonSchemaBuilder
|
||||
{
|
||||
private readonly JsonObject _properties = [];
|
||||
@@ -80,7 +76,6 @@ public class JsonSchemaBuilder
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Lenient readers for tool input, which arrives as untyped JSON.</summary>
|
||||
public static class JsonInput
|
||||
{
|
||||
public static string? String(JsonElement input, string name) =>
|
||||
@@ -114,10 +109,6 @@ public static class JsonInput
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a boolean flag. Models sometimes send <c>"true"</c> as a string even when the
|
||||
/// schema says boolean, so both spellings are accepted.
|
||||
/// </summary>
|
||||
public static bool? Bool(JsonElement input, string name)
|
||||
{
|
||||
if (input.ValueKind != JsonValueKind.Object || !input.TryGetProperty(name, out var value))
|
||||
@@ -134,11 +125,6 @@ public static class JsonInput
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads an array of strings. Returns null when the property is absent, which the
|
||||
/// services read as "leave the existing list alone" — distinct from an empty array,
|
||||
/// which clears it.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<string>? Strings(JsonElement input, string name)
|
||||
{
|
||||
if (input.ValueKind != JsonValueKind.Object
|
||||
@@ -155,4 +141,8 @@ public static class JsonInput
|
||||
|
||||
public static TEnum? Enum<TEnum>(JsonElement input, string name) where TEnum : struct, System.Enum =>
|
||||
System.Enum.TryParse<TEnum>(String(input, name), ignoreCase: true, out var parsed) ? parsed : null;
|
||||
|
||||
public static IReadOnlyList<Guid>? Guids(JsonElement input, string name) =>
|
||||
Strings(input, name)?.Select(s => System.Guid.TryParse(s, out var id) ? id : (Guid?)null)
|
||||
.Where(id => id is not null).Select(id => id!.Value).ToList();
|
||||
}
|
||||
|
||||
@@ -10,10 +10,6 @@ using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>
|
||||
/// The embedded writing agent. Runs the tool-use loop against the model, persists the
|
||||
/// conversation, and returns the finished turn together with a record of what it changed.
|
||||
/// </summary>
|
||||
public class NovelAgentService(
|
||||
INovelDbContext db,
|
||||
IAgentModelClient model,
|
||||
@@ -41,7 +37,6 @@ public class NovelAgentService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no conversation has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<AgentConversation?> GetConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(conversationId, nameof(conversationId));
|
||||
@@ -51,7 +46,6 @@ public class NovelAgentService(
|
||||
return await FindConversationAsync(conversationId, ct);
|
||||
}
|
||||
|
||||
/// <summary>True if a conversation was deleted; false if no conversation had this id.</summary>
|
||||
public async Task<bool> DeleteConversationAsync(Guid conversationId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(conversationId, nameof(conversationId));
|
||||
@@ -69,12 +63,6 @@ public class NovelAgentService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a message to the agent and runs it to completion, executing any tools it
|
||||
/// calls along the way. Returns the assistant's final turn. Null when no project has
|
||||
/// this id, or the request names a conversation that does not exist — a lookup miss
|
||||
/// is expected, not exceptional.
|
||||
/// </summary>
|
||||
public async Task<AgentMessage?> SendMessageAsync(
|
||||
Guid projectId, SendAgentMessageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
@@ -96,8 +84,6 @@ public class NovelAgentService(
|
||||
AgentConversation conversation;
|
||||
if (request.ConversationId is { } id)
|
||||
{
|
||||
// The id came from the request body, not the route — an unknown id here
|
||||
// is bad input to this call, not a direct "fetch conversation" lookup.
|
||||
var found = await FindConversationAsync(id, ct);
|
||||
if (found is null)
|
||||
{
|
||||
@@ -111,9 +97,6 @@ public class NovelAgentService(
|
||||
conversation = StartConversation(projectId, request.Message);
|
||||
}
|
||||
|
||||
// Persist the user's turn before running the loop. The tools save through the
|
||||
// same DbContext, so leaving this pending would entangle it with their writes —
|
||||
// and recording the question even if the model call fails is the behaviour we want.
|
||||
await AppendMessageAsync(conversation, AgentRole.User, request.Message, null, ct);
|
||||
|
||||
var systemPrompt = BuildSystemPrompt(project);
|
||||
@@ -141,9 +124,6 @@ public class NovelAgentService(
|
||||
break;
|
||||
}
|
||||
|
||||
// Echo the assistant's turn back verbatim, then answer every tool_use block in a
|
||||
// single user turn — splitting the results would train the model out of
|
||||
// requesting tools in parallel.
|
||||
transcript.Add(AgentChatMessage.Assistant(response.Content));
|
||||
|
||||
var results = new List<AgentContentBlock>();
|
||||
@@ -182,11 +162,6 @@ public class NovelAgentService(
|
||||
return reply;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Appends a turn and commits it. Messages are added to the set directly rather than
|
||||
/// through the parent's collection so their insert never depends on EF discovering
|
||||
/// the graph change at an inconvenient moment.
|
||||
/// </summary>
|
||||
private async Task<AgentMessage> AppendMessageAsync(
|
||||
AgentConversation conversation, AgentRole role, string content, string? toolCallsJson, CancellationToken ct)
|
||||
{
|
||||
@@ -206,9 +181,6 @@ public class NovelAgentService(
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// EF's relationship fixup normally puts the message into the parent's collection
|
||||
// once both are tracked. Guard rather than assume, since the sequence number of
|
||||
// the next turn is derived from it.
|
||||
if (!conversation.Messages.Contains(message))
|
||||
{
|
||||
conversation.Messages.Add(message);
|
||||
@@ -247,11 +219,6 @@ public class NovelAgentService(
|
||||
return conversation;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays the stored conversation as plain text turns. Tool calls are not replayed —
|
||||
/// the agent re-reads current state through its tools, which is more reliable than
|
||||
/// trusting a transcript of edits that may since have been changed in the UI.
|
||||
/// </summary>
|
||||
private static List<AgentChatMessage> BuildTranscript(AgentConversation conversation) =>
|
||||
[
|
||||
.. conversation.Messages
|
||||
@@ -273,8 +240,8 @@ public class NovelAgentService(
|
||||
return $"""
|
||||
You are a developmental editor and writing partner embedded in the software the
|
||||
writer is using to plan their novel. You have tools that read and write the
|
||||
project's real data: the brief, character dossiers, the outline tree, chapters
|
||||
and scenes.
|
||||
project's real data: the brief, character dossiers, the outline (beats) and each
|
||||
chapter's drafted prose.
|
||||
|
||||
The project you are working on:
|
||||
{brief}
|
||||
@@ -291,8 +258,8 @@ public class NovelAgentService(
|
||||
- Prefer structural help — where a beat lands, whether a want and a need are
|
||||
genuinely in tension, what the outline is missing — over line-level polish,
|
||||
unless the writer asks for prose.
|
||||
- When drafting prose into a scene, match the voice already established in the
|
||||
project. Write the scene, then stop; do not append notes about your choices.
|
||||
- When drafting a chapter's prose, match the voice already established in the
|
||||
project. Write the chapter, then stop; do not append notes about your choices.
|
||||
- Destructive operations (deleting outline nodes) need the writer's explicit
|
||||
go-ahead first.
|
||||
|
||||
@@ -300,7 +267,6 @@ public class NovelAgentService(
|
||||
""";
|
||||
}
|
||||
|
||||
/// <summary>Derives a conversation title from its opening message.</summary>
|
||||
private static string Summarise(string message)
|
||||
{
|
||||
var trimmed = message.Trim().ReplaceLineEndings(" ");
|
||||
|
||||
@@ -5,40 +5,26 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Agent;
|
||||
|
||||
/// <summary>The outcome of running a tool: what to hand back to the model, and whether it failed.</summary>
|
||||
public record AgentToolResult(string Content, bool IsError);
|
||||
|
||||
/// <summary>
|
||||
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
||||
/// already said so by returning null/false — just a value <see cref="NovelAgentToolset.ExecuteAsync"/>
|
||||
/// recognises and turns into the same error-result shape a caught exception would produce.
|
||||
/// </summary>
|
||||
internal record ToolNotFound(string Message);
|
||||
|
||||
/// <summary>A tool the agent can call, bound to a handler that runs against the project's data.</summary>
|
||||
public record AgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<Guid, JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// The tools the writing agent can reach for. Everything here goes through the same
|
||||
/// application services the REST API uses, so an edit made by the agent is
|
||||
/// indistinguishable from one made in the UI.
|
||||
/// </summary>
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
SceneService scenes,
|
||||
TagService tags,
|
||||
OpenQuestionService questions,
|
||||
ILogger<NovelAgentToolset> logger)
|
||||
@@ -56,10 +42,6 @@ public class NovelAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. Tools.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>
|
||||
/// Runs a tool and serialises its result. Failures come back as text rather than
|
||||
/// exceptions so the model can read the message and correct itself.
|
||||
/// </summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, Guid projectId, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
@@ -95,16 +77,13 @@ public class NovelAgentToolset(
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the value or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<T>(Task<T?> lookup, string entity, Guid id) where T : class =>
|
||||
await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a nullable lookup into either the mapped response or a <see cref="ToolNotFound"/> the model can read.</summary>
|
||||
private static async Task<object> OrNotFound<TEntity, TResponse>(
|
||||
Task<TEntity?> lookup, Func<TEntity, TResponse> map, string entity, Guid id) where TEntity : class =>
|
||||
await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
/// <summary>Turns a delete's success flag into either a confirmation or a <see cref="ToolNotFound"/>.</summary>
|
||||
private static async Task<object> DeletedOrNotFound(Task<bool> delete, string entity, Guid id) =>
|
||||
await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found.");
|
||||
|
||||
@@ -227,10 +206,9 @@ public class NovelAgentToolset(
|
||||
new CreateBeatRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.Guids(input, "character_ids"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
@@ -250,10 +228,9 @@ public class NovelAgentToolset(
|
||||
new UpdateBeatRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.Guids(input, "character_ids"),
|
||||
JsonInput.String(input, "what_happened"),
|
||||
JsonInput.String(input, "whats_next"),
|
||||
JsonInput.Guid(input, "scene_id"),
|
||||
JsonInput.Strings(input, "tags")), ct), b => b.ToResponse(), "Beat", beatId);
|
||||
});
|
||||
|
||||
@@ -310,13 +287,13 @@ public class NovelAgentToolset(
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_chapters",
|
||||
"List the project's chapters in manuscript order with scene and word counts.",
|
||||
"List the project's chapters in manuscript order with beat and word counts.",
|
||||
new JsonSchemaBuilder().Build(),
|
||||
async (projectId, _, ct) => (await chapters.ListAsync(projectId, ct)).Select(c => c.ToSummaryResponse()));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_chapter",
|
||||
"Read one chapter in full, including all of its scenes and any drafted prose.",
|
||||
"Read one chapter in full: its outline (beats) and its drafted prose.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to read.", required: true)
|
||||
.Build(),
|
||||
@@ -338,6 +315,7 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.Str("prose", "The chapter's drafted text, in markdown, if you are writing it now.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await OrNotFound(chapters.CreateAsync(projectId, new CreateChapterRequest(
|
||||
@@ -349,11 +327,14 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_chapter",
|
||||
"Revise a chapter's title, number, summary, POV, setting, notes or status.",
|
||||
"Revise a chapter's title, number, summary, POV, setting, notes, status or drafted "
|
||||
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
|
||||
+ "word count is recomputed automatically.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
@@ -364,6 +345,7 @@ public class NovelAgentToolset(
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
|
||||
.Int("target_word_count", "Target length in words.")
|
||||
.Str("prose", "The chapter's drafted text, in markdown.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
@@ -380,61 +362,10 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"create_scene",
|
||||
"Add a scene to a chapter. The goal/conflict/outcome trio is what makes a scene "
|
||||
+ "draftable later, so fill those in when the writer has given you enough to work with.",
|
||||
SceneSchema()
|
||||
.Str("chapter_id", "Id of the chapter the scene belongs to.", required: true)
|
||||
.Str("title", "Scene title.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||
return await OrNotFound(scenes.CreateAsync(
|
||||
chapterId,
|
||||
new CreateSceneRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned), ct), s => s.ToResponse(), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_scene",
|
||||
"Revise a scene. Use the 'prose' argument to write or replace the scene's draft text; "
|
||||
+ "the word count is recomputed automatically.",
|
||||
SceneSchema()
|
||||
.Str("scene_id", "Id of the scene to update.", required: true)
|
||||
.Str("title", "New title.")
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var sceneId = JsonInput.RequiredGuid(input, "scene_id");
|
||||
return await OrNotFound(scenes.UpdateAsync(
|
||||
sceneId,
|
||||
new UpdateSceneRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "summary"),
|
||||
JsonInput.String(input, "goal"),
|
||||
JsonInput.String(input, "conflict"),
|
||||
JsonInput.String(input, "outcome"),
|
||||
JsonInput.Guid(input, "pov_character_id"),
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct), s => s.ToResponse(), "Scene", sceneId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_character_beats",
|
||||
"Every beat this character appears in, across the whole book, in manuscript order. "
|
||||
@@ -651,21 +582,8 @@ public class NovelAgentToolset(
|
||||
private static JsonSchemaBuilder BeatSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position in the chapter. Appended to the end when omitted.")
|
||||
.Str("character_id", "Id of the character whose beat this is.")
|
||||
.StringArray("character_ids", "Ids of the characters whose beat this is. Replaces the existing list.")
|
||||
.Str("what_happened", "The event itself.")
|
||||
.Str("whats_next", "What it sets in motion — the hook into the next beat.")
|
||||
.Str("scene_id", "Id of the scene this beat will be written into, if decided.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||
|
||||
private static JsonSchemaBuilder SceneSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position within the chapter. Appended to the end when omitted.")
|
||||
.Str("summary", "What happens in the scene.")
|
||||
.Str("goal", "What the POV character is trying to achieve.")
|
||||
.Str("conflict", "What stands in the way.")
|
||||
.Str("outcome", "How it lands, and what it costs.")
|
||||
.Str("pov_character_id", "Id of the point-of-view character.")
|
||||
.Str("location", "Where the scene takes place.")
|
||||
.Str("prose", "The drafted prose for this scene.")
|
||||
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>());
|
||||
}
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// One row of a chapter's outline: a short label, who it belongs to, what happened, and
|
||||
/// what it sets up. Beats are the planning layer — flat and ordered within a chapter,
|
||||
/// with no nesting. A beat may optionally be grouped under the <see cref="Scene"/> that
|
||||
/// will eventually carry its prose.
|
||||
/// </summary>
|
||||
public class Beat
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -18,24 +11,14 @@ public class Beat
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Optional grouping: the scene this beat will be written into.</summary>
|
||||
public Guid? SceneId { get; set; }
|
||||
public Scene? Scene { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter. Gaps are allowed.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>A three-to-five word handle for the beat, not a sentence.</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Whose beat this is. Optional — not every beat belongs to one person.</summary>
|
||||
public Guid? CharacterId { get; set; }
|
||||
public Character? Character { get; set; }
|
||||
public List<Character> Characters { get; set; } = [];
|
||||
|
||||
/// <summary>The event itself.</summary>
|
||||
public string? WhatHappened { get; set; }
|
||||
|
||||
/// <summary>What it sets in motion — the hook into the next beat.</summary>
|
||||
public string? WhatsNext { get; set; }
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
|
||||
@@ -3,27 +3,25 @@ using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
public record BeatCharacterResponse(Guid Id, string Name);
|
||||
|
||||
public record BeatResponse(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
Guid? CharacterId,
|
||||
string? CharacterName,
|
||||
IReadOnlyList<BeatCharacterResponse> Characters,
|
||||
string? WhatHappened,
|
||||
string? WhatsNext,
|
||||
Guid? SceneId,
|
||||
string? SceneTitle,
|
||||
IReadOnlyList<TagResponse> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateBeatRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
Guid? CharacterId = null,
|
||||
IReadOnlyList<Guid>? CharacterIds = null,
|
||||
string? WhatHappened = null,
|
||||
string? WhatsNext = null,
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
||||
@@ -43,22 +41,13 @@ public class CreateBeatRequestValidator : IModelValidator<CreateBeatRequest>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the beat's tags outright. Use <see cref="ClearCharacter"/> /
|
||||
/// <see cref="ClearScene"/> to detach a reference, since a null id already means "leave the
|
||||
/// association alone".
|
||||
/// </summary>
|
||||
public record UpdateBeatRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
Guid? CharacterId = null,
|
||||
IReadOnlyList<Guid>? CharacterIds = null,
|
||||
string? WhatHappened = null,
|
||||
string? WhatsNext = null,
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
bool ClearCharacter = false,
|
||||
bool ClearScene = false);
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class UpdateBeatRequestValidator : IModelValidator<UpdateBeatRequest>
|
||||
{
|
||||
@@ -98,10 +87,6 @@ file static class BeatValidation
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A beat this character appears in, carrying enough of its chapter to link straight to
|
||||
/// the row in that chapter's outline.
|
||||
/// </summary>
|
||||
public record CharacterBeatResponse(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
@@ -110,11 +95,8 @@ public record CharacterBeatResponse(
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? WhatHappened,
|
||||
string? WhatsNext,
|
||||
Guid? SceneId,
|
||||
string? SceneTitle);
|
||||
string? WhatsNext);
|
||||
|
||||
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
|
||||
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
public class ReorderBeatsRequestValidator : IModelValidator<ReorderBeatsRequest>
|
||||
@@ -137,12 +119,9 @@ public static class BeatMapping
|
||||
b.ChapterId,
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.CharacterId,
|
||||
b.Character?.Name,
|
||||
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
|
||||
b.WhatHappened,
|
||||
b.WhatsNext,
|
||||
b.SceneId,
|
||||
b.Scene?.Title,
|
||||
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
b.UpdatedAt);
|
||||
|
||||
@@ -154,7 +133,5 @@ public static class BeatMapping
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.WhatHappened,
|
||||
b.WhatsNext,
|
||||
b.SceneId,
|
||||
b.Scene?.Title);
|
||||
b.WhatsNext);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,6 @@ using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Beats;
|
||||
|
||||
/// <summary>
|
||||
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
|
||||
/// here is scoped to one chapter.
|
||||
/// </summary>
|
||||
public class BeatService(
|
||||
INovelDbContext db,
|
||||
TagService tags,
|
||||
@@ -32,7 +28,6 @@ public class BeatService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no beat has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Beat?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -41,12 +36,6 @@ public class BeatService(
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Every beat this character appears in, in manuscript order. This is the character
|
||||
/// page's view onto the outlines: each row carries its chapter so the UI can link
|
||||
/// straight to the beat in that chapter's outline. Null when no character has this id;
|
||||
/// an empty list means the character exists but has no beats yet.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<Beat>?> ListForCharacterAsync(
|
||||
Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -62,8 +51,7 @@ public class BeatService(
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Chapter)
|
||||
.Include(b => b.Scene)
|
||||
.Where(b => b.CharacterId == characterId)
|
||||
.Where(b => b.Characters.Any(c => c.Id == characterId))
|
||||
.ToListAsync(ct);
|
||||
|
||||
return
|
||||
@@ -74,7 +62,6 @@ public class BeatService(
|
||||
];
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Beat?> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
@@ -90,19 +77,20 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||
CharacterId = request.CharacterId,
|
||||
WhatHappened = request.WhatHappened,
|
||||
WhatsNext = request.WhatsNext,
|
||||
SceneId = request.SceneId
|
||||
WhatsNext = request.WhatsNext
|
||||
};
|
||||
|
||||
if (request.CharacterIds is { } characterIds)
|
||||
{
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||
}
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
@@ -111,7 +99,6 @@ public class BeatService(
|
||||
db.Beats.Add(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(beat.Id, ct))!;
|
||||
}
|
||||
|
||||
@@ -132,22 +119,21 @@ public class BeatService(
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
// The beat's own chapter should always exist via the FK — an invariant
|
||||
// failing, not a caller mistake, but still not found so still just null.
|
||||
logger.LogError("Beat {BeatId} references chapter {ChapterId} which does not exist", id, beat.ChapterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
||||
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
beat.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? beat.CharacterId;
|
||||
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
||||
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
||||
beat.SceneId = request.ClearScene ? null : request.SceneId ?? beat.SceneId;
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.CharacterIds is { } characterIds)
|
||||
{
|
||||
beat.Characters = await ResolveCharactersAsync(chapter.ProjectId, characterIds, ct);
|
||||
}
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
@@ -157,7 +143,6 @@ public class BeatService(
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a beat was deleted; false if no beat had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -175,11 +160,6 @@ public class BeatService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
|
||||
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
|
||||
/// </summary>
|
||||
/// <summary>Null when the chapter carries a beat id it does not own — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<IReadOnlyList<Beat>?> ReorderAsync(
|
||||
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
@@ -198,8 +178,6 @@ public class BeatService(
|
||||
return null;
|
||||
}
|
||||
|
||||
// Listed beats take the order given; anything omitted keeps its relative position
|
||||
// after them rather than silently jumping to the front.
|
||||
var order = 1;
|
||||
foreach (var id in request.BeatIds)
|
||||
{
|
||||
@@ -215,35 +193,26 @@ public class BeatService(
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(
|
||||
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
|
||||
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Validating beat references for chapter {ChapterId}: character {CharacterId}, scene {SceneId}", chapter.Id, characterId, sceneId);
|
||||
|
||||
if (characterId is { } cid)
|
||||
var distinct = characterIds.Distinct().ToList();
|
||||
if (distinct.Count == 0)
|
||||
{
|
||||
var belongs = await db.Characters
|
||||
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: character {CharacterId} does not belong to project {ProjectId}", cid, chapter.ProjectId);
|
||||
throw new InvalidOperationException(
|
||||
"A beat's character must belong to the same project as its chapter.");
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
if (sceneId is { } sid)
|
||||
{
|
||||
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
|
||||
var found = await db.Characters
|
||||
.Where(c => c.ProjectId == projectId && distinct.Contains(c.Id))
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: scene {SceneId} does not belong to chapter {ChapterId}", sid, chapter.Id);
|
||||
throw new InvalidOperationException(
|
||||
"A beat can only be grouped under a scene in the same chapter.");
|
||||
}
|
||||
if (found.Count != distinct.Count)
|
||||
{
|
||||
logger.LogWarning("Rejected beat reference: one or more characters do not belong to project {ProjectId}", projectId);
|
||||
throw new InvalidOperationException(
|
||||
"A beat's characters must belong to the same project as its chapter.");
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
@@ -259,8 +228,7 @@ public class BeatService(
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
db.Beats
|
||||
.Include(b => b.Character)
|
||||
.Include(b => b.Scene)
|
||||
.Include(b => b.Characters)
|
||||
.Include(b => b.Tags);
|
||||
|
||||
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
|
||||
|
||||
@@ -2,29 +2,22 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
/// <summary>Position in the manuscript, 1-based.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The paragraph that opens the chapter's outline, above the beat table.
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>Whose head we are in for this chapter.</summary>
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
@@ -34,14 +27,14 @@ public class Chapter
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
public int? TargetWordCount { get; set; }
|
||||
|
||||
public string? Prose { get; set; }
|
||||
|
||||
public int WordCount { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
|
||||
public List<Scene> Scenes { get; set; } = [];
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
@@ -18,15 +17,10 @@ public record ChapterSummaryResponse(
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
int BeatCount,
|
||||
int SceneCount,
|
||||
int WordCount,
|
||||
IReadOnlyList<TagResponse> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
|
||||
/// and the prose layer (scenes).
|
||||
/// </summary>
|
||||
public record ChapterResponse(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
@@ -40,7 +34,8 @@ public record ChapterResponse(
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
IReadOnlyList<BeatResponse> Beats,
|
||||
IReadOnlyList<SceneResponse> Scenes,
|
||||
string? Prose,
|
||||
int WordCount,
|
||||
IReadOnlyList<TagResponse> Tags,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
@@ -53,6 +48,7 @@ public record CreateChapterRequest(
|
||||
string? Notes = null,
|
||||
DraftStatus Status = DraftStatus.Planned,
|
||||
int? TargetWordCount = null,
|
||||
string? Prose = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
|
||||
@@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
|
||||
/// <see cref="Tags"/> list replaces the chapter's tags outright.
|
||||
/// </summary>
|
||||
public record UpdateChapterRequest(
|
||||
string? Title = null,
|
||||
int? Number = null,
|
||||
@@ -85,6 +77,7 @@ public record UpdateChapterRequest(
|
||||
string? Notes = null,
|
||||
DraftStatus? Status = null,
|
||||
int? TargetWordCount = null,
|
||||
string? Prose = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
|
||||
@@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -110,7 +103,8 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
file static class ChapterValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result)
|
||||
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose,
|
||||
IReadOnlyList<string>? tags, ValidationResult result)
|
||||
{
|
||||
if (number is <= 0)
|
||||
result.AddError("Number", "'Number' must be greater than zero.");
|
||||
@@ -127,6 +121,9 @@ file static class ChapterValidation
|
||||
if (targetWordCount is < 0)
|
||||
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
|
||||
|
||||
if (prose is { Length: > 200000 })
|
||||
result.AddError("Prose", "'Prose' must be 200,000 characters or fewer.");
|
||||
|
||||
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
||||
}
|
||||
@@ -139,14 +136,19 @@ public static class ChapterMapping
|
||||
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
|
||||
c.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())],
|
||||
c.Prose, c.WordCount,
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
c.UpdatedAt);
|
||||
|
||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
||||
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
|
||||
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
|
||||
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
|
||||
c.Beats.Count, c.WordCount,
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
c.UpdatedAt);
|
||||
|
||||
public static int CountWords(string? prose) =>
|
||||
string.IsNullOrWhiteSpace(prose)
|
||||
? 0
|
||||
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class ChapterEndpoints
|
||||
|
||||
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Read a chapter with all of its scenes.");
|
||||
.WithSummary("Read a chapter with its beats and prose.");
|
||||
|
||||
chapters.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
@@ -44,7 +44,7 @@ public static class ChapterEndpoints
|
||||
|
||||
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a chapter and its scenes.");
|
||||
.WithSummary("Delete a chapter.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -22,14 +22,12 @@ public class ChapterService(
|
||||
return await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Scenes)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -38,7 +36,6 @@ public class ChapterService(
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
@@ -63,7 +60,9 @@ public class ChapterService(
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
Status = request.Status,
|
||||
TargetWordCount = request.TargetWordCount
|
||||
TargetWordCount = request.TargetWordCount,
|
||||
Prose = request.Prose,
|
||||
WordCount = ChapterMapping.CountWords(request.Prose)
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
@@ -74,7 +73,6 @@ public class ChapterService(
|
||||
db.Chapters.Add(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(chapter.Id, ct))!;
|
||||
}
|
||||
|
||||
@@ -100,6 +98,13 @@ public class ChapterService(
|
||||
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
||||
chapter.Status = request.Status ?? chapter.Status;
|
||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||
|
||||
if (request.Prose is not null)
|
||||
{
|
||||
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
|
||||
chapter.WordCount = ChapterMapping.CountWords(chapter.Prose);
|
||||
}
|
||||
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
@@ -111,7 +116,6 @@ public class ChapterService(
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -148,10 +152,8 @@ public class ChapterService(
|
||||
|
||||
var chapter = await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Character)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Scene)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
|
||||
.Include(c => c.Tags)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>
|
||||
/// A character dossier. Every field beyond <see cref="Name"/> is optional so a writer can
|
||||
/// start with a name and fill the sheet in as the character comes into focus.
|
||||
/// </summary>
|
||||
public class Character
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -16,10 +13,6 @@ public class Character
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public CharacterRole Role { get; set; } = CharacterRole.Supporting;
|
||||
|
||||
/// <summary>
|
||||
/// Whether this character carries the book or supports it. New characters start as
|
||||
/// supporting — a writer promotes the few who turn out to be main.
|
||||
/// </summary>
|
||||
public CharacterImportance Importance { get; set; } = CharacterImportance.Supporting;
|
||||
|
||||
public string? Age { get; set; }
|
||||
@@ -30,22 +23,15 @@ public class Character
|
||||
public string? Personality { get; set; }
|
||||
public string? Backstory { get; set; }
|
||||
|
||||
/// <summary>What the character consciously wants.</summary>
|
||||
public string? Want { get; set; }
|
||||
|
||||
/// <summary>What the character actually needs — usually at odds with <see cref="Want"/>.</summary>
|
||||
public string? Need { get; set; }
|
||||
|
||||
public string? InternalConflict { get; set; }
|
||||
public string? ExternalConflict { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// How the character changes over the course of the book, in a sentence or two.
|
||||
/// <see cref="ArcStages"/> breaks the same change into ordered steps.
|
||||
/// </summary>
|
||||
public string? ArcSummary { get; set; }
|
||||
|
||||
/// <summary>Speech patterns, verbal tics, register — anything that makes dialogue sound like them.</summary>
|
||||
public string? Voice { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
@@ -56,11 +42,11 @@ public class Character
|
||||
public List<CharacterRelationship> Relationships { get; set; } = [];
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
|
||||
/// <summary>The character's arc, in order. Kept mainly for main characters.</summary>
|
||||
public List<CharacterArcStage> ArcStages { get; set; } = [];
|
||||
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A directed relationship from one character to another.</summary>
|
||||
public class CharacterRelationship
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
@@ -71,7 +57,6 @@ public class CharacterRelationship
|
||||
public Guid RelatedCharacterId { get; set; }
|
||||
public Character? RelatedCharacter { get; set; }
|
||||
|
||||
/// <summary>e.g. "sister", "rival", "former mentor".</summary>
|
||||
public string RelationshipType { get; set; } = string.Empty;
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>How far along a chapter or scene is in the drafting pipeline.</summary>
|
||||
public enum DraftStatus
|
||||
{
|
||||
Planned,
|
||||
|
||||
@@ -9,16 +9,10 @@ using Novelly.Api.Data;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Wires up every feature's services in one place. Endpoints, the embedded agent and the
|
||||
/// MCP server all resolve the same instances, so a capability added here is available to
|
||||
/// all three.
|
||||
/// </summary>
|
||||
public static class NovellyServiceRegistration
|
||||
{
|
||||
public static IServiceCollection AddNovelly(this IServiceCollection services, IConfiguration configuration)
|
||||
@@ -35,7 +29,6 @@ public static class NovellyServiceRegistration
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<SceneService>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
services.AddScoped<NovelAgentService>();
|
||||
@@ -43,8 +36,6 @@ public static class NovellyServiceRegistration
|
||||
services.Configure<AgentOptions>(configuration.GetSection(AgentOptions.SectionName));
|
||||
services.AddScoped<IAgentModelClient, AnthropicAgentModelClient>();
|
||||
|
||||
// A single unbounded queue shared by the request path (writer, in ImportService)
|
||||
// and the background runner (reader) — the only background-job infra in the app.
|
||||
services.AddSingleton(Channel.CreateUnbounded<Guid>());
|
||||
services.AddScoped<ImportService>();
|
||||
services.AddScoped<ImportAgentToolset>();
|
||||
|
||||
@@ -6,15 +6,10 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// The persistence surface the application services depend on. Infrastructure supplies
|
||||
/// the EF Core implementation; tests can point it at an in-memory SQLite connection.
|
||||
/// </summary>
|
||||
public interface INovelDbContext
|
||||
{
|
||||
DbSet<Project> Projects { get; }
|
||||
@@ -24,7 +19,6 @@ public interface INovelDbContext
|
||||
DbSet<Beat> Beats { get; }
|
||||
DbSet<Tag> Tags { get; }
|
||||
DbSet<Chapter> Chapters { get; }
|
||||
DbSet<Scene> Scenes { get; }
|
||||
DbSet<OpenQuestion> OpenQuestions { get; }
|
||||
DbSet<AgentConversation> Conversations { get; }
|
||||
DbSet<AgentMessage> AgentMessages { get; }
|
||||
|
||||
+768
@@ -0,0 +1,768 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
[Migration("20260812035308_RemoveScenesAddChapterProseAndMultiCharacterBeats")]
|
||||
partial class RemoveScenesAddChapterProseAndMultiCharacterBeats
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "CharactersId");
|
||||
|
||||
b.HasIndex("CharactersId");
|
||||
|
||||
b.ToTable("BeatCharacters", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("BeatTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("ChaptersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("ChapterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("TagsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CharactersId", "TagsId");
|
||||
|
||||
b.HasIndex("TagsId");
|
||||
|
||||
b.ToTable("CharacterTags", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Conversations");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ConversationId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Sequence")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ToolCallsJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ConversationId", "Sequence")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AgentMessages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("WhatHappened")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("WhatsNext")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Beats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Number")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ProjectId", "Number");
|
||||
|
||||
b.ToTable("Chapters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ArcSummary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Backstory")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Importance")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Need")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Occupation")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Personality")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Voice")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Want")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.HasIndex("CharacterId", "SortOrder");
|
||||
|
||||
b.ToTable("CharacterArcStages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("RelatedCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RelationshipType")
|
||||
.IsRequired()
|
||||
.HasMaxLength(120)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("RelatedCharacterId");
|
||||
|
||||
b.ToTable("CharacterRelationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ChaptersCompleted")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("ChaptersTotal")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("SourceRoot")
|
||||
.IsRequired()
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("StatusMessage")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SourceRoot");
|
||||
|
||||
b.ToTable("ImportJobs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Author")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Logline")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Phase")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Synopsis")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("TargetWordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Projects");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Detail")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Question")
|
||||
.IsRequired()
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Resolution")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long?>("ResolvedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ChapterId");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.ToTable("OpenQuestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Color")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProjectId", "Name")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BeatsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CharactersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BeatsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("ChapterTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("ChaptersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("CharacterTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CharactersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Tags.Tag", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("TagsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Conversations")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation")
|
||||
.WithMany("Messages")
|
||||
.HasForeignKey("ConversationId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Conversation");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Beats.Beat", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Beats")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Chapter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Chapters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Characters")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany("ArcStages")
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Character");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany("Relationships")
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("RelatedCharacterId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("RelatedCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany()
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
.WithMany("Tags")
|
||||
.HasForeignKey("ProjectId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b =>
|
||||
{
|
||||
b.Navigation("Messages");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Navigation("Beats");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Navigation("ArcStages");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Projects.Project", b =>
|
||||
{
|
||||
b.Navigation("Chapters");
|
||||
|
||||
b.Navigation("Characters");
|
||||
|
||||
b.Navigation("Conversations");
|
||||
|
||||
b.Navigation("Tags");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class RemoveScenesAddChapterProseAndMultiCharacterBeats : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Beats_Characters_CharacterId",
|
||||
table: "Beats");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Beats_Scenes_SceneId",
|
||||
table: "Beats");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Scenes");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Beats_CharacterId",
|
||||
table: "Beats");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Beats_SceneId",
|
||||
table: "Beats");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CharacterId",
|
||||
table: "Beats");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SceneId",
|
||||
table: "Beats");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Prose",
|
||||
table: "Chapters",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "WordCount",
|
||||
table: "Chapters",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 0);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "BeatCharacters",
|
||||
columns: table => new
|
||||
{
|
||||
BeatsId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CharactersId = table.Column<Guid>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_BeatCharacters", x => new { x.BeatsId, x.CharactersId });
|
||||
table.ForeignKey(
|
||||
name: "FK_BeatCharacters_Beats_BeatsId",
|
||||
column: x => x.BeatsId,
|
||||
principalTable: "Beats",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_BeatCharacters_Characters_CharactersId",
|
||||
column: x => x.CharactersId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_BeatCharacters_CharactersId",
|
||||
table: "BeatCharacters",
|
||||
column: "CharactersId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "BeatCharacters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Prose",
|
||||
table: "Chapters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "WordCount",
|
||||
table: "Chapters");
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "CharacterId",
|
||||
table: "Beats",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "SceneId",
|
||||
table: "Beats",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Scenes",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PovCharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Conflict = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
Goal = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Location = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Outcome = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Prose = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
|
||||
Summary = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 300, nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
WordCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Scenes", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Scenes_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Scenes_Characters_PovCharacterId",
|
||||
column: x => x.PovCharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Beats_CharacterId",
|
||||
table: "Beats",
|
||||
column: "CharacterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Beats_SceneId",
|
||||
table: "Beats",
|
||||
column: "SceneId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Scenes_ChapterId_SortOrder",
|
||||
table: "Scenes",
|
||||
columns: new[] { "ChapterId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Scenes_PovCharacterId",
|
||||
table: "Scenes",
|
||||
column: "PovCharacterId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Beats_Characters_CharacterId",
|
||||
table: "Beats",
|
||||
column: "CharacterId",
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Beats_Scenes_SceneId",
|
||||
table: "Beats",
|
||||
column: "SceneId",
|
||||
principalTable: "Scenes",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,21 @@ namespace Novelly.Api.Data.Migrations
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("CharactersId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BeatsId", "CharactersId");
|
||||
|
||||
b.HasIndex("CharactersId");
|
||||
|
||||
b.ToTable("BeatCharacters", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.Property<Guid>("BeatsId")
|
||||
@@ -133,15 +148,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SceneId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -161,10 +170,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CharacterId");
|
||||
|
||||
b.HasIndex("SceneId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Beats");
|
||||
@@ -191,6 +196,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -213,6 +221,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
@@ -497,67 +508,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.ToTable("OpenQuestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("ChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Conflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Goal")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Location")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Outcome")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("PovCharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Status")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Summary")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(300)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("WordCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("PovCharacterId");
|
||||
|
||||
b.HasIndex("ChapterId", "SortOrder");
|
||||
|
||||
b.ToTable("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -587,6 +537,21 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.ToTable("Tags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatCharacter", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("BeatsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("CharactersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("BeatTag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Beats.Beat", null)
|
||||
@@ -662,21 +627,7 @@ namespace Novelly.Api.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "Character")
|
||||
.WithMany()
|
||||
.HasForeignKey("CharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Scenes.Scene", "Scene")
|
||||
.WithMany()
|
||||
.HasForeignKey("SceneId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("Character");
|
||||
|
||||
b.Navigation("Scene");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
@@ -770,24 +721,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Navigation("Project");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Scenes.Scene", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
.WithMany("Scenes")
|
||||
.HasForeignKey("ChapterId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "PovCharacter")
|
||||
.WithMany()
|
||||
.HasForeignKey("PovCharacterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Chapter");
|
||||
|
||||
b.Navigation("PovCharacter");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Tags.Tag", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Projects.Project", "Project")
|
||||
@@ -807,8 +740,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b =>
|
||||
{
|
||||
b.Navigation("Beats");
|
||||
|
||||
b.Navigation("Scenes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
|
||||
@@ -7,17 +7,10 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Data;
|
||||
|
||||
/// <summary>
|
||||
/// Stores a <see cref="DateTimeOffset"/> as UTC ticks. SQLite has no native type for it
|
||||
/// and refuses to ORDER BY the default text form, which every "most recently updated
|
||||
/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC
|
||||
/// loses nothing.
|
||||
/// </summary>
|
||||
internal class UtcTicksConverter()
|
||||
: ValueConverter<DateTimeOffset, long>(
|
||||
value => value.UtcTicks,
|
||||
@@ -33,7 +26,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
public DbSet<Beat> Beats => Set<Beat>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
public DbSet<Scene> Scenes => Set<Scene>();
|
||||
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
||||
@@ -80,8 +72,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
|
||||
entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
|
||||
|
||||
// An arc stage outlives the chapter it was pinned to: deleting a chapter is a
|
||||
// decision about the manuscript, not about how the character changes.
|
||||
entity.HasOne(s => s.Chapter).WithMany()
|
||||
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
@@ -90,9 +80,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
{
|
||||
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
|
||||
|
||||
// Restrict on the inverse side: deleting a character should not silently take
|
||||
// the other character's relationship rows with it via a second cascade path,
|
||||
// which SQLite rejects as a multiple-cascade cycle.
|
||||
entity.HasOne(r => r.RelatedCharacter).WithMany()
|
||||
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
@@ -105,13 +92,8 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
|
||||
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// A beat outlives the scene it was grouped under: deleting a scene is a
|
||||
// decision about prose, not about the plan.
|
||||
entity.HasOne(b => b.Scene).WithMany()
|
||||
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(b => b.Character).WithMany()
|
||||
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasMany(b => b.Characters).WithMany(c => c.Beats)
|
||||
.UsingEntity(join => join.ToTable("BeatCharacters"));
|
||||
});
|
||||
|
||||
builder.Entity<Tag>(entity =>
|
||||
@@ -119,8 +101,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
|
||||
entity.Property(t => t.Color).HasMaxLength(16);
|
||||
|
||||
// One canonical tag per name per project, so "betrayal" always means the
|
||||
// same tag no matter where it was typed.
|
||||
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
|
||||
|
||||
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
|
||||
@@ -139,19 +119,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
|
||||
entity.HasOne(c => c.PovCharacter).WithMany()
|
||||
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!)
|
||||
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<Scene>(entity =>
|
||||
{
|
||||
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
|
||||
entity.Property(s => s.Status).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(s => new { s.ChapterId, s.SortOrder });
|
||||
|
||||
entity.HasOne(s => s.PovCharacter).WithMany()
|
||||
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<OpenQuestion>(entity =>
|
||||
@@ -159,15 +126,11 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
entity.Property(q => q.Question).IsRequired().HasMaxLength(500);
|
||||
entity.Ignore(q => q.IsResolved);
|
||||
|
||||
// Open questions are listed per project and filtered to a chapter or character,
|
||||
// so index the project and let the filters narrow from there.
|
||||
entity.HasIndex(q => q.ProjectId);
|
||||
|
||||
entity.HasOne(q => q.Project).WithMany()
|
||||
.HasForeignKey(q => q.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// A question survives what it was about. Deleting a chapter or character should
|
||||
// not quietly take an unresolved decision with it.
|
||||
entity.HasOne(q => q.Chapter).WithMany()
|
||||
.HasForeignKey(q => q.ChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||
entity.HasOne(q => q.Character).WithMany()
|
||||
@@ -192,8 +155,6 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
entity.Property(j => j.SourceRoot).IsRequired().HasMaxLength(1000);
|
||||
entity.Property(j => j.Status).HasConversion<string>().HasMaxLength(16);
|
||||
|
||||
// No FK to Project: a job outlives the project it created, including the
|
||||
// force-restart path where that project is deleted out from under it.
|
||||
entity.HasIndex(j => j.SourceRoot);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,30 +8,14 @@ using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Imports;
|
||||
|
||||
/// <summary>
|
||||
/// A lookup a tool performed came back empty. Not an exception — the underlying service
|
||||
/// already said so by returning null — just a value <see cref="ImportAgentToolset.ExecuteAsync"/>
|
||||
/// recognises and turns into the same error-result shape a caught exception would produce.
|
||||
/// </summary>
|
||||
internal record ImportToolNotFound(string Message);
|
||||
|
||||
/// <summary>A tool the import agent can call, bound to a handler that runs against this run's state.</summary>
|
||||
internal record ImportAgentTool(
|
||||
string Name,
|
||||
string Description,
|
||||
JsonElement InputSchema,
|
||||
Func<JsonElement, CancellationToken, Task<object?>> Handler);
|
||||
|
||||
/// <summary>
|
||||
/// The tools the outline-import agent can reach for: read-only, root-scoped filesystem
|
||||
/// access to the source folder, a write capability limited to exactly the resume ledger,
|
||||
/// and the same application services the chat agent and REST API use for everything else.
|
||||
///
|
||||
/// Deliberately a separate toolset from <see cref="NovelAgentToolset"/> rather than an
|
||||
/// extension of it — filesystem access must never be reachable from a normal chat
|
||||
/// conversation. One instance is built per import run (see <see cref="Initialize"/>), so
|
||||
/// the current project id lives here rather than being threaded through every call.
|
||||
/// </summary>
|
||||
public class ImportAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
@@ -54,17 +38,14 @@ public class ImportAgentToolset(
|
||||
public IReadOnlyList<AgentToolDefinition> Definitions =>
|
||||
[.. ByName.Values.Select(t => new AgentToolDefinition(t.Name, t.Description, t.InputSchema))];
|
||||
|
||||
/// <summary>Binds this instance to one run. Must be called before any tool executes.</summary>
|
||||
public void Initialize(string sourceRoot, Guid? existingProjectId)
|
||||
{
|
||||
_sourceRoot = sourceRoot;
|
||||
ProjectId = existingProjectId;
|
||||
}
|
||||
|
||||
/// <summary>Reads the ledger directly — the run driver's ground truth for "is this done", not the model's say-so.</summary>
|
||||
public ImportLedger? ReadLedgerOrNull() => ImportPaths.ReadLedger(_sourceRoot);
|
||||
|
||||
/// <summary>Runs a tool and serialises its result. Failures come back as text so the model can read and self-correct.</summary>
|
||||
public async Task<AgentToolResult> ExecuteAsync(string name, JsonElement input, CancellationToken ct = default)
|
||||
{
|
||||
if (!ByName.TryGetValue(name, out var tool))
|
||||
@@ -196,8 +177,6 @@ public class ImportAgentToolset(
|
||||
{
|
||||
var json = JsonInput.RequiredString(input, "json");
|
||||
|
||||
// Fail loudly on malformed JSON now rather than writing garbage the next
|
||||
// run's read_ledger can't parse.
|
||||
using var _ = JsonDocument.Parse(json);
|
||||
|
||||
File.WriteAllText(ImportPaths.LedgerPath(_sourceRoot), json);
|
||||
@@ -345,7 +324,7 @@ public class ImportAgentToolset(
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the chapter the beat belongs to.", required: true)
|
||||
.Str("title", "The Beat column — three to five words.", required: true)
|
||||
.Str("character_id", "Id of the character named in the Character column, if it resolves.")
|
||||
.StringArray("character_ids", "Ids of the characters named in the Character column, if they resolve.")
|
||||
.Str("what_happened", "The What column.")
|
||||
.Str("whats_next", "The Why column.")
|
||||
.Build(),
|
||||
@@ -356,7 +335,7 @@ public class ImportAgentToolset(
|
||||
chapterId,
|
||||
new CreateBeatRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
CharacterId: JsonInput.Guid(input, "character_id"),
|
||||
CharacterIds: JsonInput.Guids(input, "character_ids"),
|
||||
WhatHappened: JsonInput.String(input, "what_happened"),
|
||||
WhatsNext: JsonInput.String(input, "whats_next")), ct);
|
||||
|
||||
|
||||
@@ -10,14 +10,11 @@ using Novelly.Api.Data;
|
||||
using Novelly.Api.Imports;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
using Serilog;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// AddSerilog (not UseSerilog) so it becomes an additional logging provider rather than
|
||||
// replacing the one AddServiceDefaults wires up for the Aspire dashboard.
|
||||
builder.Services.AddSerilog((services, config) => config
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.ReadFrom.Services(services)
|
||||
@@ -28,8 +25,6 @@ builder.Services.AddNovelly(builder.Configuration);
|
||||
builder.Services.AddOpenApi();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
// Enums travel as their names, so the React client and the MCP server both read
|
||||
// "Protagonist" rather than an ordinal that shifts whenever the enum is reordered.
|
||||
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||
|
||||
@@ -43,16 +38,11 @@ builder.Services.AddCors(options => options.AddDefaultPolicy(policy => policy
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Local-first tool: bring the SQLite file up to date on boot rather than making the
|
||||
// writer run a migration command before they can open the app.
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
await scope.ServiceProvider.GetRequiredService<NovelDbContext>().Database.MigrateAsync();
|
||||
}
|
||||
|
||||
// Serilog's request logging wraps the exception handler (registered first = outermost)
|
||||
// so it reads the status code the handler already resolved, rather than seeing the raw
|
||||
// exception fly past and misreporting a handled 404 as a 500.
|
||||
app.UseSerilogRequestLogging();
|
||||
|
||||
app.UseExceptionHandler(handler => handler.Run(async context =>
|
||||
@@ -95,7 +85,6 @@ app.MapProjectEndpoints()
|
||||
.MapCharacterEndpoints()
|
||||
.MapChapterEndpoints()
|
||||
.MapBeatEndpoints()
|
||||
.MapSceneEndpoints()
|
||||
.MapTagEndpoints()
|
||||
.MapOpenQuestionEndpoints()
|
||||
.MapAgentEndpoints()
|
||||
@@ -103,5 +92,4 @@ app.MapProjectEndpoints()
|
||||
|
||||
app.Run();
|
||||
|
||||
/// <summary>Exposed so the tests can spin the API up with WebApplicationFactory.</summary>
|
||||
public partial class Program;
|
||||
|
||||
@@ -27,12 +27,11 @@ public class ProjectService(
|
||||
p.Phase,
|
||||
p.Characters.Count,
|
||||
p.Chapters.Count,
|
||||
p.Chapters.SelectMany(c => c.Scenes).Sum(s => (int?)s.WordCount) ?? 0,
|
||||
p.Chapters.Sum(c => (int?)c.WordCount) ?? 0,
|
||||
p.UpdatedAt))
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Project?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -92,7 +91,6 @@ public class ProjectService(
|
||||
return project;
|
||||
}
|
||||
|
||||
/// <summary>True if a project was deleted; false if no project had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
/// <summary>
|
||||
/// A scene inside a chapter. The goal/conflict/outcome trio is the unit the agent
|
||||
/// works with when turning an outline into prose.
|
||||
/// </summary>
|
||||
public class Scene
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>Position within the chapter, 1-based.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>What the POV character is trying to achieve.</summary>
|
||||
public string? Goal { get; set; }
|
||||
|
||||
/// <summary>What stands in the way.</summary>
|
||||
public string? Conflict { get; set; }
|
||||
|
||||
/// <summary>How it lands — and what it costs.</summary>
|
||||
public string? Outcome { get; set; }
|
||||
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
public string? Location { get; set; }
|
||||
|
||||
/// <summary>The drafted prose, if any.</summary>
|
||||
public string? Prose { get; set; }
|
||||
|
||||
public int WordCount { get; set; }
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public record SceneResponse(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Goal,
|
||||
string? Conflict,
|
||||
string? Outcome,
|
||||
Guid? PovCharacterId,
|
||||
string? PovCharacterName,
|
||||
string? Location,
|
||||
string? Prose,
|
||||
int WordCount,
|
||||
DraftStatus Status,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateSceneRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
string? Summary = null,
|
||||
string? Goal = null,
|
||||
string? Conflict = null,
|
||||
string? Outcome = null,
|
||||
Guid? PovCharacterId = null,
|
||||
string? Location = null,
|
||||
string? Prose = null,
|
||||
DraftStatus Status = DraftStatus.Planned);
|
||||
|
||||
public class CreateSceneRequestValidator : IModelValidator<CreateSceneRequest>
|
||||
{
|
||||
public ValidationResult Validate(CreateSceneRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(model.Title))
|
||||
result.AddError("Title", "'Title' must not be empty.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
|
||||
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record UpdateSceneRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
string? Summary = null,
|
||||
string? Goal = null,
|
||||
string? Conflict = null,
|
||||
string? Outcome = null,
|
||||
Guid? PovCharacterId = null,
|
||||
string? Location = null,
|
||||
string? Prose = null,
|
||||
DraftStatus? Status = null);
|
||||
|
||||
public class UpdateSceneRequestValidator : IModelValidator<UpdateSceneRequest>
|
||||
{
|
||||
public ValidationResult Validate(UpdateSceneRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.Title is not null)
|
||||
{
|
||||
if (model.Title.Length == 0)
|
||||
result.AddError("Title", "'Title' can not be cleared — a scene always needs one.");
|
||||
else if (model.Title.Length > 200)
|
||||
result.AddError("Title", "'Title' must be 200 characters or fewer.");
|
||||
}
|
||||
|
||||
SceneValidation.OptionalFields(model.SortOrder, model.Summary, model.Goal, model.Conflict, model.Outcome, model.Location, model.Prose, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
file static class SceneValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
int? sortOrder, string? summary, string? goal, string? conflict, string? outcome, string? location, string? prose, ValidationResult result)
|
||||
{
|
||||
if (sortOrder is < 0)
|
||||
result.AddError("SortOrder", "'Sort Order' must be zero or greater.");
|
||||
|
||||
if (summary is { Length: > 20000 })
|
||||
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
|
||||
|
||||
if (goal is { Length: > 20000 })
|
||||
result.AddError("Goal", "'Goal' must be 20,000 characters or fewer.");
|
||||
|
||||
if (conflict is { Length: > 20000 })
|
||||
result.AddError("Conflict", "'Conflict' must be 20,000 characters or fewer.");
|
||||
|
||||
if (outcome is { Length: > 20000 })
|
||||
result.AddError("Outcome", "'Outcome' must be 20,000 characters or fewer.");
|
||||
|
||||
if (location is { Length: > 500 })
|
||||
result.AddError("Location", "'Location' must be 500 characters or fewer.");
|
||||
|
||||
if (prose is { Length: > 100000 })
|
||||
result.AddError("Prose", "'Prose' must be 100,000 characters or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
public static class SceneMapping
|
||||
{
|
||||
public static SceneResponse ToResponse(this Scene s) => new(
|
||||
s.Id, s.ChapterId, s.SortOrder, s.Title, s.Summary,
|
||||
s.Goal, s.Conflict, s.Outcome,
|
||||
s.PovCharacterId, s.PovCharacter?.Name, s.Location,
|
||||
s.Prose, s.WordCount, s.Status, s.UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Whitespace-delimited word count. Good enough for progress tracking, and it costs
|
||||
/// nothing to recompute on every save.
|
||||
/// </summary>
|
||||
public static int CountWords(string? prose) =>
|
||||
string.IsNullOrWhiteSpace(prose)
|
||||
? 0
|
||||
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public static class SceneEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapSceneEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var chapterScoped = app.MapGroup("/api/chapters/{chapterId:guid}/scenes").WithTags("Scenes")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
chapterScoped.MapGet("/", async (Guid chapterId, SceneService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(chapterId, ct)).Select(s => s.ToResponse())))
|
||||
.WithSummary("List a chapter's scenes in order.");
|
||||
|
||||
chapterScoped.MapPost("/", async (
|
||||
Guid chapterId, CreateSceneRequest request, SceneService service, CancellationToken ct) =>
|
||||
{
|
||||
var scene = await service.CreateAsync(chapterId, request, ct);
|
||||
if (scene is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var created = scene.ToResponse();
|
||||
return Results.Created($"/api/scenes/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Add a scene to a chapter.");
|
||||
|
||||
var scenes = app.MapGroup("/api/scenes").WithTags("Scenes")
|
||||
.AddEndpointFilter<RequestLoggingEndpointFilter>()
|
||||
.AddEndpointFilter<ValidationEndpointFilter>();
|
||||
|
||||
scenes.MapGet("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
|
||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Read a scene, including its prose.");
|
||||
|
||||
scenes.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateSceneRequest request, SceneService service, CancellationToken ct) =>
|
||||
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Update a scene. Sending prose recomputes the word count.");
|
||||
|
||||
scenes.MapDelete("/{id:guid}", async (Guid id, SceneService service, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a scene.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Scenes;
|
||||
|
||||
public class SceneService(
|
||||
INovelDbContext db,
|
||||
ILogger<SceneService> logger,
|
||||
IModelValidator<CreateSceneRequest> createValidator,
|
||||
IModelValidator<UpdateSceneRequest> updateValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Scene>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
|
||||
logger.LogInformation("Listing scenes for chapter {ChapterId}", chapterId);
|
||||
|
||||
return await Query()
|
||||
.Where(s => s.ChapterId == chapterId)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no scene has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Scene?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Getting scene {SceneId}", id);
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Scene?> CreateAsync(Guid chapterId, CreateSceneRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
createValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Creating scene {Title} for chapter {ChapterId}, prose length {ProseLength}", request.Title, chapterId, request.Prose?.Length ?? 0);
|
||||
|
||||
if (!await db.Chapters.AnyAsync(c => c.Id == chapterId, ct))
|
||||
{
|
||||
logger.LogInformation("Rejected scene creation: chapter {ChapterId} not found", chapterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var scene = new Scene
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||
Summary = request.Summary,
|
||||
Goal = request.Goal,
|
||||
Conflict = request.Conflict,
|
||||
Outcome = request.Outcome,
|
||||
PovCharacterId = request.PovCharacterId,
|
||||
Location = request.Location,
|
||||
Prose = request.Prose,
|
||||
WordCount = SceneMapping.CountWords(request.Prose),
|
||||
Status = request.Status
|
||||
};
|
||||
|
||||
db.Scenes.Add(scene);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(scene.Id, ct))!;
|
||||
}
|
||||
|
||||
public async Task<Scene?> UpdateAsync(Guid id, UpdateSceneRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
Guard.Null(request, nameof(request));
|
||||
updateValidator.Validate(request).ThrowIfInvalid();
|
||||
|
||||
logger.LogInformation("Updating scene {SceneId}, prose length {ProseLength}", id, request.Prose?.Length ?? 0);
|
||||
|
||||
var scene = await FindAsync(id, ct);
|
||||
if (scene is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
scene.Title = Patch.Apply(scene.Title, request.Title) ?? scene.Title;
|
||||
scene.SortOrder = request.SortOrder ?? scene.SortOrder;
|
||||
scene.Summary = Patch.Apply(scene.Summary, request.Summary);
|
||||
scene.Goal = Patch.Apply(scene.Goal, request.Goal);
|
||||
scene.Conflict = Patch.Apply(scene.Conflict, request.Conflict);
|
||||
scene.Outcome = Patch.Apply(scene.Outcome, request.Outcome);
|
||||
scene.PovCharacterId = request.PovCharacterId ?? scene.PovCharacterId;
|
||||
scene.Location = Patch.Apply(scene.Location, request.Location);
|
||||
scene.Status = request.Status ?? scene.Status;
|
||||
|
||||
if (request.Prose is not null)
|
||||
{
|
||||
scene.Prose = Patch.Apply(scene.Prose, request.Prose);
|
||||
scene.WordCount = SceneMapping.CountWords(scene.Prose);
|
||||
}
|
||||
|
||||
scene.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a scene was deleted; false if no scene had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
|
||||
logger.LogInformation("Deleting scene {SceneId}", id);
|
||||
|
||||
var scene = await FindAsync(id, ct);
|
||||
if (scene is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
db.Scenes.Remove(scene);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Computing next sort order for chapter {ChapterId}", chapterId);
|
||||
|
||||
var max = await db.Scenes
|
||||
.Where(s => s.ChapterId == chapterId)
|
||||
.MaxAsync(s => (int?)s.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private IQueryable<Scene> Query() => db.Scenes.Include(s => s.PovCharacter);
|
||||
|
||||
private async Task<Scene?> FindAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Finding scene {SceneId}", id);
|
||||
|
||||
var scene = await Query().FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
if (scene is null)
|
||||
{
|
||||
logger.LogInformation("Scene {SceneId} not found", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogDebug("Found scene {SceneId}", id);
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
}
|
||||
@@ -58,11 +58,6 @@ public class UpdateTagRequestValidator : IModelValidator<UpdateTagRequest>
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Everything carrying one tag, gathered in a single response. This is the whole point of
|
||||
/// tags — seeing that a motif touches two characters, a chapter and four beats is what
|
||||
/// makes them worth maintaining.
|
||||
/// </summary>
|
||||
public record TagReferencesResponse(
|
||||
TagResponse Tag,
|
||||
IReadOnlyList<TaggedCharacterResponse> Characters,
|
||||
@@ -105,12 +100,8 @@ public static class TagMapping
|
||||
b.Chapter?.Title ?? "(unknown chapter)",
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.Character?.Name,
|
||||
b.Characters.Count > 0 ? string.Join(", ", b.Characters.OrderBy(c => c.Name).Select(c => c.Name)) : null,
|
||||
b.WhatHappened))]);
|
||||
|
||||
/// <summary>
|
||||
/// Tags are matched case-insensitively but stored as first typed, so "Betrayal" and
|
||||
/// "betrayal" resolve to one tag rather than quietly becoming two.
|
||||
/// </summary>
|
||||
public static string Normalise(string name) => name.Trim();
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ public class TagService(
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Everything in the project carrying this tag. Null when no tag has this id.</summary>
|
||||
public async Task<Tag?> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(tagId, nameof(tagId));
|
||||
@@ -36,7 +35,7 @@ public class TagService(
|
||||
var tag = await db.Tags
|
||||
.Include(t => t.Characters)
|
||||
.Include(t => t.Chapters)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Character)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Characters)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
|
||||
.FirstOrDefaultAsync(t => t.Id == tagId, ct);
|
||||
|
||||
@@ -46,7 +45,6 @@ public class TagService(
|
||||
return tag;
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Tag?> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
@@ -110,7 +108,6 @@ public class TagService(
|
||||
return tag;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes. True if deleted.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(tagId, nameof(tagId));
|
||||
@@ -129,11 +126,6 @@ public class TagService(
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a list of names into tag entities, creating any the project has not seen
|
||||
/// before. Typing a new tag on a beat should just work rather than being a two-step
|
||||
/// "create the tag, then apply it".
|
||||
/// </summary>
|
||||
internal async Task<List<Tag>> ResolveAsync(
|
||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user