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:
James Wampler
2026-08-11 21:05:13 -07:00
parent 1ce526019f
commit 23348327a9
57 changed files with 2600 additions and 1421 deletions
+4 -14
View File
@@ -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();
}
+4 -38
View File
@@ -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(" ");
+12 -94
View File
@@ -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 -18
View File
@@ -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; } = [];
+9 -32
View File
@@ -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);
}
+27 -59
View File
@@ -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)
+4 -11
View File
@@ -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; } = [];
}
+18 -16
View File
@@ -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;
}
+2 -2
View File
@@ -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;
}
+11 -9
View File
@@ -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);
+3 -18
View File
@@ -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
View File
@@ -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
View File
@@ -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; }
@@ -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
}
}
}
@@ -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 =>
+2 -41
View File
@@ -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);
});
}
+2 -23
View File
@@ -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);
-12
View File
@@ -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;
+1 -3
View File
@@ -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));
-45
View File
@@ -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;
}
-128
View File
@@ -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;
}
-51
View File
@@ -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;
}
}
-156
View File
@@ -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;
}
}
+1 -10
View File
@@ -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();
}
+1 -9
View File
@@ -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)
{