Add main/supporting characters, character arcs and open questions
Three things the outline could not express before:
Main vs supporting. A new CharacterImportance sits alongside CharacterRole
rather than inside it — role is the part a character plays (protagonist,
mentor, foil), importance is how much of the book they carry, and a mentor can
be either. Characters start Supporting and get promoted. Listings put main
characters first.
Character arcs. A main character's arc is a flat ordered list of stages, the
same shape as a chapter's beats and for the same reason: an arc is a sequence
of changes, not a tree. A stage can be pinned to the chapter where it lands.
Nothing refuses an arc on a supporting character — demoting someone should not
delete their work.
Open questions. What the writer has not decided yet, hanging off a chapter
outline, a character, both, or neither. They can be resolved, reopened or
deleted, and resolving can append the decision to the notes of whatever the
question was attached to, so it lands where the writer will re-read it.
Resolved questions drop off the list unless asked for.
Also adds GET /api/characters/{id}/beats — every beat a character appears in,
in manuscript order, carrying each beat's chapter so the character page can
link straight into that chapter's outline.
Deletes are deliberately asymmetric: deleting a chapter unpins arc stages and
detaches questions rather than taking them, because a plan outlives a decision
about where the chapter break falls. Deleting a character or project does take
their arcs and questions.
All three capabilities are surfaced in the REST API, the agent toolset and the
MCP server, per the one-source-of-truth rule.
Two things worth flagging in the migration: EF's generated default for the new
Importance column was an empty string, which does not parse back to a
CharacterImportance and would have faulted every read of an existing dossier —
it now defaults to Supporting, verified by migrating a database seeded on the
old schema and reading the row back through the API. And the earlier migrations
were renamed to the namespace EF derives from the output folder, so future
`migrations add` runs stop drifting.
72 tests pass (28 new). The endpoints were also exercised over curl end to end:
arc stages resolving their chapter, a character's beats across chapters, and a
question attached to both a chapter and a character resolving into both sets of
notes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
96021c5fee
commit
0358667679
@@ -114,6 +114,26 @@ 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))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.ValueKind switch
|
||||
{
|
||||
JsonValueKind.True => true,
|
||||
JsonValueKind.False => false,
|
||||
JsonValueKind.String when bool.TryParse(value.GetString(), out var flag) => flag,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
/// <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,
|
||||
|
||||
@@ -4,6 +4,7 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -27,10 +28,12 @@ public record AgentTool(
|
||||
public class NovelAgentToolset(
|
||||
ProjectService projects,
|
||||
CharacterService characters,
|
||||
CharacterArcService arcs,
|
||||
ChapterService chapters,
|
||||
BeatService beats,
|
||||
SceneService scenes,
|
||||
TagService tags)
|
||||
TagService tags,
|
||||
OpenQuestionService questions)
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
@@ -123,6 +126,7 @@ public class NovelAgentToolset(
|
||||
async (projectId, input, ct) => await characters.CreateAsync(projectId, new CreateCharacterRequest(
|
||||
JsonInput.RequiredString(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role") ?? CharacterRole.Supporting,
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance") ?? CharacterImportance.Supporting,
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
@@ -149,6 +153,7 @@ public class NovelAgentToolset(
|
||||
new UpdateCharacterRequest(
|
||||
JsonInput.String(input, "name"),
|
||||
JsonInput.Enum<CharacterRole>(input, "role"),
|
||||
JsonInput.Enum<CharacterImportance>(input, "importance"),
|
||||
JsonInput.String(input, "age"),
|
||||
JsonInput.String(input, "pronouns"),
|
||||
JsonInput.String(input, "occupation"),
|
||||
@@ -364,8 +369,162 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "location"),
|
||||
JsonInput.String(input, "prose"),
|
||||
JsonInput.Enum<DraftStatus>(input, "status")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_character_beats",
|
||||
"Every beat this character appears in, across the whole book, in manuscript order. "
|
||||
+ "Read this before revising a character — it is what they actually do on the page, "
|
||||
+ "as opposed to what the dossier claims about them.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await beats.ListForCharacterAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"get_character_arc",
|
||||
"Read a main character's arc: the ordered stages of how they change. Each stage may "
|
||||
+ "be pinned to the chapter where it lands.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await arcs.ListAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"add_arc_stage",
|
||||
"Add a stage to a character's arc. Arcs are for main characters — promote the "
|
||||
+ "character first with update_character if they are still Supporting.",
|
||||
ArcStageSchema()
|
||||
.Str("character_id", "Id of the character whose arc to add to.", required: true)
|
||||
.Str("title", "A short handle for the change, three to five words.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await arcs.CreateAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"),
|
||||
new CreateArcStageRequest(
|
||||
JsonInput.RequiredString(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "description"),
|
||||
JsonInput.Guid(input, "chapter_id")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_arc_stage",
|
||||
"Revise a stage of a character's arc. Only the fields you supply change.",
|
||||
ArcStageSchema()
|
||||
.Str("arc_stage_id", "Id of the arc stage to update.", required: true)
|
||||
.Str("title", "New title for the stage.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await arcs.UpdateAsync(
|
||||
JsonInput.RequiredGuid(input, "arc_stage_id"),
|
||||
new UpdateArcStageRequest(
|
||||
JsonInput.String(input, "title"),
|
||||
JsonInput.Int(input, "sort_order"),
|
||||
JsonInput.String(input, "description"),
|
||||
JsonInput.Guid(input, "chapter_id")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_arc_stage",
|
||||
"Remove a stage from a character's arc.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("arc_stage_id", "Id of the arc stage to delete.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await arcs.DeleteAsync(JsonInput.RequiredGuid(input, "arc_stage_id"), ct);
|
||||
return new { deleted = true };
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"reorder_arc_stages",
|
||||
"Renumber a character's arc to match the order given. Stages left out keep their "
|
||||
+ "relative position after the ones listed.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character whose arc to reorder.", required: true)
|
||||
.StringArray("stage_ids", "Arc stage ids in the order wanted.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await arcs.ReorderAsync(
|
||||
JsonInput.RequiredGuid(input, "character_id"),
|
||||
new ReorderArcStagesRequest(
|
||||
[.. JsonInput.Strings(input, "stage_ids")?.Select(Guid.Parse) ?? []]), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"list_open_questions",
|
||||
"The decisions the writer has not made yet. Read this before proposing changes — an "
|
||||
+ "open question is a place the writer is still thinking, not a gap to fill in for them.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Narrow to questions about one chapter outline.")
|
||||
.Str("character_id", "Narrow to questions about one character.")
|
||||
.Bool("include_resolved", "Include questions already settled. Defaults to false.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await questions.ListAsync(
|
||||
projectId,
|
||||
JsonInput.Guid(input, "chapter_id"),
|
||||
JsonInput.Guid(input, "character_id"),
|
||||
JsonInput.Bool(input, "include_resolved") ?? false,
|
||||
ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"raise_open_question",
|
||||
"Record a question the writer has not settled. Attach it to the chapter outline "
|
||||
+ "and/or the character it is about. Prefer raising a question over guessing when "
|
||||
+ "the writer has not decided something.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("question", "The question, in one line.", required: true)
|
||||
.Str("detail", "The thinking around it — options, and what each costs.")
|
||||
.Str("chapter_id", "The chapter outline this is about, if any.")
|
||||
.Str("character_id", "The character this is about, if any.")
|
||||
.Build(),
|
||||
async (projectId, input, ct) => await questions.CreateAsync(
|
||||
projectId,
|
||||
new CreateOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "question"),
|
||||
JsonInput.String(input, "detail"),
|
||||
JsonInput.Guid(input, "chapter_id"),
|
||||
JsonInput.Guid(input, "character_id")), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"resolve_open_question",
|
||||
"Settle a question with what the writer decided. Set append_to_notes to also write "
|
||||
+ "the resolution into the notes of the chapter and character it hangs off.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("question_id", "Id of the question to resolve.", required: true)
|
||||
.Str("resolution", "What was decided.", required: true)
|
||||
.Bool("append_to_notes", "Also append the resolution to the associated notes.")
|
||||
.Build(),
|
||||
async (_, input, ct) => await questions.ResolveAsync(
|
||||
JsonInput.RequiredGuid(input, "question_id"),
|
||||
new ResolveOpenQuestionRequest(
|
||||
JsonInput.RequiredString(input, "resolution"),
|
||||
JsonInput.Bool(input, "append_to_notes") ?? false), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"reopen_question",
|
||||
"Put a resolved question back on the list. Anything already appended to notes stays.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("question_id", "Id of the question to reopen.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) => await questions.ReopenAsync(
|
||||
JsonInput.RequiredGuid(input, "question_id"), ct));
|
||||
|
||||
yield return new AgentTool(
|
||||
"delete_open_question",
|
||||
"Delete a question outright. Resolving is usually better — it keeps the decision.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("question_id", "Id of the question to delete.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
await questions.DeleteAsync(JsonInput.RequiredGuid(input, "question_id"), ct);
|
||||
return new { deleted = true };
|
||||
});
|
||||
}
|
||||
|
||||
private static JsonSchemaBuilder ArcStageSchema() =>
|
||||
new JsonSchemaBuilder()
|
||||
.Int("sort_order", "Position in the arc. Appended to the end when omitted.")
|
||||
.Str("description", "What shifts in the character here, and what it costs them.")
|
||||
.Str("chapter_id", "The chapter where this stage lands, if it is pinned to one.");
|
||||
|
||||
private static JsonSchemaBuilder CharacterSchema(bool includeName, bool nameRequired)
|
||||
{
|
||||
var schema = new JsonSchemaBuilder();
|
||||
@@ -377,6 +536,11 @@ public class NovelAgentToolset(
|
||||
|
||||
return schema
|
||||
.Enum("role", "The part they play in the story.", System.Enum.GetNames<CharacterRole>())
|
||||
.Enum(
|
||||
"importance",
|
||||
"How much of the book they carry. Main characters are the few the story is "
|
||||
+ "about and are worth an arc; everyone else is Supporting.",
|
||||
System.Enum.GetNames<CharacterImportance>())
|
||||
.Str("age", "Age, exact or approximate.")
|
||||
.Str("pronouns", "The pronouns this character uses.")
|
||||
.Str("occupation", "What they do.")
|
||||
|
||||
@@ -38,6 +38,22 @@ public record UpdateBeatRequest(
|
||||
Guid? SceneId = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
|
||||
/// <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 CharacterBeatDto(
|
||||
Guid Id,
|
||||
Guid ChapterId,
|
||||
int ChapterNumber,
|
||||
string ChapterTitle,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? WhatHappened,
|
||||
string? WhatsNext,
|
||||
Guid? SceneId,
|
||||
string? SceneTitle);
|
||||
|
||||
/// <summary>Reorders a chapter's beats in one call, by listing their ids in the order wanted.</summary>
|
||||
public record ReorderBeatsRequest(IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@ public static class BeatEndpoints
|
||||
Results.Ok(await service.ReorderAsync(chapterId, request, ct)))
|
||||
.WithSummary("Renumber a chapter's beats to match the order given.");
|
||||
|
||||
app.MapGet("/api/characters/{characterId:guid}/beats", async (
|
||||
Guid characterId, BeatService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListForCharacterAsync(characterId, ct)))
|
||||
.WithTags("Beats")
|
||||
.WithSummary("Every beat this character appears in, in manuscript order.");
|
||||
|
||||
var beats = app.MapGroup("/api/beats").WithTags("Beats");
|
||||
|
||||
beats.MapGet("/{id:guid}", async (Guid id, BeatService service, CancellationToken ct) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Tags;
|
||||
@@ -25,6 +26,44 @@ public class BeatService(INovelDbContext db, TagService tags)
|
||||
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<CharacterBeatDto>> ListForCharacterAsync(
|
||||
Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Character), characterId);
|
||||
}
|
||||
|
||||
var beats = await db.Beats
|
||||
.Include(b => b.Chapter)
|
||||
.Include(b => b.Scene)
|
||||
.Where(b => b.CharacterId == characterId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return
|
||||
[
|
||||
.. beats
|
||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
||||
.ThenBy(b => b.SortOrder)
|
||||
.Select(b => new CharacterBeatDto(
|
||||
b.Id,
|
||||
b.ChapterId,
|
||||
b.Chapter?.Number ?? 0,
|
||||
b.Chapter?.Title ?? "(unknown chapter)",
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.WhatHappened,
|
||||
b.WhatsNext,
|
||||
b.SceneId,
|
||||
b.Scene?.Title))
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
|
||||
|
||||
@@ -16,6 +16,12 @@ 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; }
|
||||
public string? Pronouns { get; set; }
|
||||
public string? Occupation { get; set; }
|
||||
@@ -33,7 +39,10 @@ public class Character
|
||||
public string? InternalConflict { get; set; }
|
||||
public string? ExternalConflict { get; set; }
|
||||
|
||||
/// <summary>How the character changes over the course of the book.</summary>
|
||||
/// <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>
|
||||
@@ -46,6 +55,9 @@ 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; } = [];
|
||||
}
|
||||
|
||||
/// <summary>A directed relationship from one character to another.</summary>
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>
|
||||
/// A character's arc: a flat, ordered list of the changes they go through. Same shape as
|
||||
/// a chapter's beats, and for the same reason — an arc is a sequence, not a tree.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Arcs are only really worth keeping for main characters, but nothing here refuses one
|
||||
/// on a supporting character. Demoting someone should not delete work, and a character
|
||||
/// who turns out to matter gets promoted after the arc is already sketched.
|
||||
/// </remarks>
|
||||
public class CharacterArcService(INovelDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<ArcStageDto>> ListAsync(Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
var stages = await Query()
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
.OrderBy(s => s.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. stages.Select(s => s.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<ArcStageDto> CreateAsync(
|
||||
Guid characterId, CreateArcStageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct)
|
||||
?? throw new NotFoundException(nameof(Character), characterId);
|
||||
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
|
||||
var stage = new CharacterArcStage
|
||||
{
|
||||
CharacterId = characterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(characterId, ct),
|
||||
Description = request.Description,
|
||||
ChapterId = request.ChapterId
|
||||
};
|
||||
|
||||
db.CharacterArcStages.Add(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(stage.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<ArcStageDto> UpdateAsync(
|
||||
Guid id, UpdateArcStageRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var stage = await FindAsync(id, ct);
|
||||
|
||||
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == stage.CharacterId, ct)
|
||||
?? throw new NotFoundException(nameof(Character), stage.CharacterId);
|
||||
|
||||
await EnsureChapterIsInSameProjectAsync(character, request.ChapterId, ct);
|
||||
|
||||
stage.Title = Patch.Apply(stage.Title, request.Title) ?? stage.Title;
|
||||
stage.SortOrder = request.SortOrder ?? stage.SortOrder;
|
||||
stage.Description = Patch.Apply(stage.Description, request.Description);
|
||||
stage.ChapterId = request.ChapterId ?? stage.ChapterId;
|
||||
stage.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var stage = await FindAsync(id, ct);
|
||||
db.CharacterArcStages.Remove(stage);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers a character's arc to match the order given. Stages left out keep their
|
||||
/// relative position after the ones listed, exactly as beat reordering works.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<ArcStageDto>> ReorderAsync(
|
||||
Guid characterId, ReorderArcStagesRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var stages = await db.CharacterArcStages
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var missing = request.StageIds.Where(id => stages.All(s => s.Id != id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new NotFoundException(nameof(CharacterArcStage), missing[0]);
|
||||
}
|
||||
|
||||
var order = 1;
|
||||
foreach (var id in request.StageIds)
|
||||
{
|
||||
stages.Single(s => s.Id == id).SortOrder = order++;
|
||||
}
|
||||
|
||||
foreach (var stage in stages.Where(s => !request.StageIds.Contains(s.Id)).OrderBy(s => s.SortOrder))
|
||||
{
|
||||
stage.SortOrder = order++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await ListAsync(characterId, ct);
|
||||
}
|
||||
|
||||
private async Task EnsureChapterIsInSameProjectAsync(
|
||||
Character character, Guid? chapterId, CancellationToken ct)
|
||||
{
|
||||
if (chapterId is not { } id)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var belongs = await db.Chapters.AnyAsync(c => c.Id == id && c.ProjectId == character.ProjectId, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"An arc stage can only point at a chapter in the same project as its character.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid characterId, CancellationToken ct)
|
||||
{
|
||||
var max = await db.CharacterArcStages
|
||||
.Where(s => s.CharacterId == characterId)
|
||||
.MaxAsync(s => (int?)s.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private IQueryable<CharacterArcStage> Query() => db.CharacterArcStages.Include(s => s.Chapter);
|
||||
|
||||
private async Task<CharacterArcStage> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(s => s.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(CharacterArcStage), id);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Novelly.Api.Chapters;
|
||||
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>
|
||||
/// One step in a main character's arc. Flat and ordered by <see cref="SortOrder"/>, the
|
||||
/// same shape as a chapter's beats — an arc is a sequence of changes, not a tree.
|
||||
/// </summary>
|
||||
public class CharacterArcStage
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid CharacterId { get; set; }
|
||||
public Character? Character { get; set; }
|
||||
|
||||
/// <summary>Position in the arc, 1-based.</summary>
|
||||
public int SortOrder { get; set; }
|
||||
|
||||
/// <summary>A short handle for the change — "stops covering for her brother".</summary>
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>What shifts in the character here, and what it costs them.</summary>
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>Optionally, where in the manuscript this stage lands.</summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ public record CharacterDto(
|
||||
Guid ProjectId,
|
||||
string Name,
|
||||
CharacterRole Role,
|
||||
CharacterImportance Importance,
|
||||
string? Age,
|
||||
string? Pronouns,
|
||||
string? Occupation,
|
||||
@@ -22,6 +23,7 @@ public record CharacterDto(
|
||||
string? Notes,
|
||||
IReadOnlyList<RelationshipDto> Relationships,
|
||||
IReadOnlyList<TagDto> Tags,
|
||||
IReadOnlyList<ArcStageDto> ArcStages,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record RelationshipDto(
|
||||
@@ -34,6 +36,7 @@ public record RelationshipDto(
|
||||
public record CreateCharacterRequest(
|
||||
string Name,
|
||||
CharacterRole Role = CharacterRole.Supporting,
|
||||
CharacterImportance Importance = CharacterImportance.Supporting,
|
||||
string? Age = null,
|
||||
string? Pronouns = null,
|
||||
string? Occupation = null,
|
||||
@@ -56,6 +59,7 @@ public record CreateCharacterRequest(
|
||||
public record UpdateCharacterRequest(
|
||||
string? Name = null,
|
||||
CharacterRole? Role = null,
|
||||
CharacterImportance? Importance = null,
|
||||
string? Age = null,
|
||||
string? Pronouns = null,
|
||||
string? Occupation = null,
|
||||
@@ -76,10 +80,38 @@ public record CreateRelationshipRequest(
|
||||
string RelationshipType,
|
||||
string? Description = null);
|
||||
|
||||
public record ArcStageDto(
|
||||
Guid Id,
|
||||
Guid CharacterId,
|
||||
int SortOrder,
|
||||
string Title,
|
||||
string? Description,
|
||||
Guid? ChapterId,
|
||||
int? ChapterNumber,
|
||||
string? ChapterTitle,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateArcStageRequest(
|
||||
string Title,
|
||||
int? SortOrder = null,
|
||||
string? Description = null,
|
||||
Guid? ChapterId = null);
|
||||
|
||||
/// <summary>Patch-style update. A null field is left alone; an empty string clears it.</summary>
|
||||
public record UpdateArcStageRequest(
|
||||
string? Title = null,
|
||||
int? SortOrder = null,
|
||||
string? Description = null,
|
||||
Guid? ChapterId = null);
|
||||
|
||||
/// <summary>Reorders a character's arc in one call, by listing the stage ids in the order wanted.</summary>
|
||||
public record ReorderArcStagesRequest(IReadOnlyList<Guid> StageIds);
|
||||
|
||||
|
||||
public static class CharacterMapping
|
||||
{
|
||||
public static CharacterDto ToDto(this Character c) => new(
|
||||
c.Id, c.ProjectId, c.Name, c.Role, c.Age, c.Pronouns, c.Occupation,
|
||||
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
|
||||
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
|
||||
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
||||
[.. c.Relationships.Select(r => new RelationshipDto(
|
||||
@@ -89,5 +121,17 @@ public static class CharacterMapping
|
||||
r.RelationshipType,
|
||||
r.Description))],
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToDto())],
|
||||
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToDto())],
|
||||
c.UpdatedAt);
|
||||
|
||||
public static ArcStageDto ToDto(this CharacterArcStage s) => new(
|
||||
s.Id,
|
||||
s.CharacterId,
|
||||
s.SortOrder,
|
||||
s.Title,
|
||||
s.Description,
|
||||
s.ChapterId,
|
||||
s.Chapter?.Number,
|
||||
s.Chapter?.Title,
|
||||
s.UpdatedAt);
|
||||
}
|
||||
|
||||
@@ -49,6 +49,42 @@ public static class CharacterEndpoints
|
||||
})
|
||||
.WithSummary("Remove a relationship.");
|
||||
|
||||
characters.MapGet("/{id:guid}/arc", async (
|
||||
Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ListAsync(id, ct)))
|
||||
.WithSummary("Read a character's arc: its stages, in order.");
|
||||
|
||||
characters.MapPost("/{id:guid}/arc", async (
|
||||
Guid id, CreateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(id, request, ct);
|
||||
return Results.Created($"/api/arc-stages/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Add a stage to a character's arc.");
|
||||
|
||||
characters.MapPost("/{id:guid}/arc/reorder", async (
|
||||
Guid id, ReorderArcStagesRequest request, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ReorderAsync(id, request, ct)))
|
||||
.WithSummary("Renumber a character's arc to match the order given.");
|
||||
|
||||
var arcStages = app.MapGroup("/api/arc-stages").WithTags("Characters");
|
||||
|
||||
arcStages.MapGet("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read one arc stage.");
|
||||
|
||||
arcStages.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateArcStageRequest request, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update an arc stage.");
|
||||
|
||||
arcStages.MapDelete("/{id:guid}", async (Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete an arc stage.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace Novelly.Api.Characters;
|
||||
|
||||
/// <summary>
|
||||
/// How much of the book a character carries. This is separate from
|
||||
/// <see cref="CharacterRole"/>: role is the part they play in the story (protagonist,
|
||||
/// mentor, foil), importance is how much weight they take. A mentor can be either.
|
||||
/// Main characters are the ones worth tracking an arc for.
|
||||
/// </summary>
|
||||
public enum CharacterImportance
|
||||
{
|
||||
Main,
|
||||
Supporting
|
||||
}
|
||||
@@ -12,7 +12,8 @@ public class CharacterService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
var characters = await Query()
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderBy(c => c.Role)
|
||||
.OrderBy(c => c.Importance)
|
||||
.ThenBy(c => c.Role)
|
||||
.ThenBy(c => c.Name)
|
||||
.ToListAsync(ct);
|
||||
|
||||
@@ -31,6 +32,7 @@ public class CharacterService(INovelDbContext db, TagService tags)
|
||||
ProjectId = projectId,
|
||||
Name = request.Name,
|
||||
Role = request.Role,
|
||||
Importance = request.Importance,
|
||||
Age = request.Age,
|
||||
Pronouns = request.Pronouns,
|
||||
Occupation = request.Occupation,
|
||||
@@ -62,6 +64,7 @@ public class CharacterService(INovelDbContext db, TagService tags)
|
||||
|
||||
character.Name = Patch.Apply(character.Name, request.Name) ?? character.Name;
|
||||
character.Role = request.Role ?? character.Role;
|
||||
character.Importance = request.Importance ?? character.Importance;
|
||||
character.Age = Patch.Apply(character.Age, request.Age);
|
||||
character.Pronouns = Patch.Apply(character.Pronouns, request.Pronouns);
|
||||
character.Occupation = Patch.Apply(character.Occupation, request.Occupation);
|
||||
@@ -133,7 +136,9 @@ public class CharacterService(INovelDbContext db, TagService tags)
|
||||
db.Characters
|
||||
.Include(c => c.Relationships)
|
||||
.ThenInclude(r => r.RelatedCharacter)
|
||||
.Include(c => c.Tags);
|
||||
.Include(c => c.Tags)
|
||||
.Include(c => c.ArcStages)
|
||||
.ThenInclude(s => s.Chapter);
|
||||
|
||||
private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
|
||||
|
||||
@@ -5,6 +5,7 @@ using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -27,10 +28,12 @@ public static class NovellyServiceRegistration
|
||||
|
||||
services.AddScoped<ProjectService>();
|
||||
services.AddScoped<CharacterService>();
|
||||
services.AddScoped<CharacterArcService>();
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<SceneService>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
services.AddScoped<NovelAgentToolset>();
|
||||
services.AddScoped<NovelAgentService>();
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -18,10 +19,12 @@ public interface INovelDbContext
|
||||
DbSet<Project> Projects { get; }
|
||||
DbSet<Character> Characters { get; }
|
||||
DbSet<CharacterRelationship> CharacterRelationships { get; }
|
||||
DbSet<CharacterArcStage> CharacterArcStages { get; }
|
||||
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; }
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
[Migration("20260806023249_InitialSchema")]
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialSchema : Migration
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
[Migration("20260806031243_ReplaceOutlineWithBeatsAndTags")]
|
||||
|
||||
@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class ReplaceOutlineWithBeatsAndTags : Migration
|
||||
|
||||
+791
@@ -0,0 +1,791 @@
|
||||
// <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("20260806055755_AddCharacterImportanceArcsAndOpenQuestions")]
|
||||
partial class AddCharacterImportanceArcsAndOpenQuestions
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
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<Guid?>("CharacterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid?>("SceneId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
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("CharacterId");
|
||||
|
||||
b.HasIndex("SceneId");
|
||||
|
||||
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>("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.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.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>("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.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")
|
||||
.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("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.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 =>
|
||||
{
|
||||
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.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")
|
||||
.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");
|
||||
|
||||
b.Navigation("Scenes");
|
||||
});
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCharacterImportanceArcsAndOpenQuestions : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
// Existing characters predate the main/supporting distinction. EF's generated
|
||||
// default is an empty string, which does not parse back to a CharacterImportance
|
||||
// and would fault every read of an existing dossier — everyone starts Supporting.
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Importance",
|
||||
table: "Characters",
|
||||
type: "TEXT",
|
||||
maxLength: 32,
|
||||
nullable: false,
|
||||
defaultValue: "Supporting");
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "CharacterArcStages",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
CharacterId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
SortOrder = table.Column<int>(type: "INTEGER", nullable: false),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_CharacterArcStages", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_CharacterArcStages_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_CharacterArcStages_Characters_CharacterId",
|
||||
column: x => x.CharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "OpenQuestions",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
ProjectId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Question = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
|
||||
Detail = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ChapterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
CharacterId = table.Column<Guid>(type: "TEXT", nullable: true),
|
||||
Resolution = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ResolvedAt = table.Column<long>(type: "INTEGER", nullable: true),
|
||||
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||
UpdatedAt = table.Column<long>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_OpenQuestions", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_OpenQuestions_Chapters_ChapterId",
|
||||
column: x => x.ChapterId,
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_OpenQuestions_Characters_CharacterId",
|
||||
column: x => x.CharacterId,
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
table.ForeignKey(
|
||||
name: "FK_OpenQuestions_Projects_ProjectId",
|
||||
column: x => x.ProjectId,
|
||||
principalTable: "Projects",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CharacterArcStages_ChapterId",
|
||||
table: "CharacterArcStages",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_CharacterArcStages_CharacterId_SortOrder",
|
||||
table: "CharacterArcStages",
|
||||
columns: new[] { "CharacterId", "SortOrder" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OpenQuestions_ChapterId",
|
||||
table: "OpenQuestions",
|
||||
column: "ChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OpenQuestions_CharacterId",
|
||||
table: "OpenQuestions",
|
||||
column: "CharacterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_OpenQuestions_ProjectId",
|
||||
table: "OpenQuestions",
|
||||
column: "ProjectId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "CharacterArcStages");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "OpenQuestions");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Importance",
|
||||
table: "Characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Novelly.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Novelly.Api.Data;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Migrations
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(NovelDbContext))]
|
||||
partial class NovelDbContextModelSnapshot : ModelSnapshot
|
||||
@@ -246,6 +246,11 @@ namespace Novelly.Api.Migrations
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Importance")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("InternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -293,6 +298,44 @@ namespace Novelly.Api.Migrations
|
||||
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")
|
||||
@@ -362,6 +405,52 @@ namespace Novelly.Api.Migrations
|
||||
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.Scenes.Scene", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -573,6 +662,24 @@ namespace Novelly.Api.Migrations
|
||||
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")
|
||||
@@ -592,6 +699,31 @@ namespace Novelly.Api.Migrations
|
||||
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.Scenes.Scene", b =>
|
||||
{
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter")
|
||||
@@ -635,6 +767,8 @@ namespace Novelly.Api.Migrations
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.Character", b =>
|
||||
{
|
||||
b.Navigation("ArcStages");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -27,10 +28,12 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
public DbSet<Project> Projects => Set<Project>();
|
||||
public DbSet<Character> Characters => Set<Character>();
|
||||
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
||||
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
|
||||
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>();
|
||||
|
||||
@@ -59,10 +62,25 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
{
|
||||
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
||||
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
|
||||
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => c.ProjectId);
|
||||
|
||||
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
|
||||
.HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
builder.Entity<CharacterArcStage>(entity =>
|
||||
{
|
||||
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);
|
||||
});
|
||||
|
||||
builder.Entity<CharacterRelationship>(entity =>
|
||||
@@ -133,6 +151,26 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
||||
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<OpenQuestion>(entity =>
|
||||
{
|
||||
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()
|
||||
.HasForeignKey(q => q.CharacterId).OnDelete(DeleteBehavior.SetNull);
|
||||
});
|
||||
|
||||
builder.Entity<AgentConversation>(entity =>
|
||||
{
|
||||
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
|
||||
|
||||
@@ -8,6 +8,7 @@ using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Questions;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -79,6 +80,7 @@ app.MapProjectEndpoints()
|
||||
.MapBeatEndpoints()
|
||||
.MapSceneEndpoints()
|
||||
.MapTagEndpoints()
|
||||
.MapOpenQuestionEndpoints()
|
||||
.MapAgentEndpoints();
|
||||
|
||||
app.Run();
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
/// <summary>
|
||||
/// Something the writer has not decided yet — "does she know about the letter before the
|
||||
/// harbour?". Questions hang off the chapter outline or the character they belong to, or
|
||||
/// both, or neither when they are about the book as a whole.
|
||||
/// </summary>
|
||||
public class OpenQuestion
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
/// <summary>The question itself, in one line.</summary>
|
||||
public string Question { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Room for the thinking around it — options considered, what each costs.</summary>
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>The chapter outline this question is about, if it is about one.</summary>
|
||||
public Guid? ChapterId { get; set; }
|
||||
public Chapter? Chapter { get; set; }
|
||||
|
||||
/// <summary>The character this question is about, if it is about one.</summary>
|
||||
public Guid? CharacterId { get; set; }
|
||||
public Character? Character { get; set; }
|
||||
|
||||
/// <summary>What was decided. Set when the question is resolved, cleared when reopened.</summary>
|
||||
public string? Resolution { get; set; }
|
||||
|
||||
/// <summary>When it was decided. Null while the question is still open.</summary>
|
||||
public DateTimeOffset? ResolvedAt { get; set; }
|
||||
|
||||
public bool IsResolved => ResolvedAt is not null;
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
public record OpenQuestionDto(
|
||||
Guid Id,
|
||||
Guid ProjectId,
|
||||
string Question,
|
||||
string? Detail,
|
||||
Guid? ChapterId,
|
||||
int? ChapterNumber,
|
||||
string? ChapterTitle,
|
||||
Guid? CharacterId,
|
||||
string? CharacterName,
|
||||
string? Resolution,
|
||||
bool IsResolved,
|
||||
DateTimeOffset? ResolvedAt,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CreateOpenQuestionRequest(
|
||||
string Question,
|
||||
string? Detail = null,
|
||||
Guid? ChapterId = null,
|
||||
Guid? CharacterId = null);
|
||||
|
||||
/// <summary>
|
||||
/// Patch-style update. A null field is left alone; an empty string clears it. Use
|
||||
/// <see cref="ClearChapter"/> / <see cref="ClearCharacter"/> to detach a question, since a
|
||||
/// null id already means "leave the association alone".
|
||||
/// </summary>
|
||||
public record UpdateOpenQuestionRequest(
|
||||
string? Question = null,
|
||||
string? Detail = null,
|
||||
Guid? ChapterId = null,
|
||||
Guid? CharacterId = null,
|
||||
bool ClearChapter = false,
|
||||
bool ClearCharacter = false);
|
||||
|
||||
/// <summary>
|
||||
/// Settles a question. The resolution is kept on the question itself; setting
|
||||
/// <see cref="AppendToNotes"/> also appends it to the notes of whatever the question is
|
||||
/// attached to, so the decision lands where the writer will actually re-read it.
|
||||
/// </summary>
|
||||
public record ResolveOpenQuestionRequest(string Resolution, bool AppendToNotes = false);
|
||||
|
||||
public static class OpenQuestionMapping
|
||||
{
|
||||
public static OpenQuestionDto ToDto(this OpenQuestion q) => new(
|
||||
q.Id,
|
||||
q.ProjectId,
|
||||
q.Question,
|
||||
q.Detail,
|
||||
q.ChapterId,
|
||||
q.Chapter?.Number,
|
||||
q.Chapter?.Title,
|
||||
q.CharacterId,
|
||||
q.Character?.Name,
|
||||
q.Resolution,
|
||||
q.IsResolved,
|
||||
q.ResolvedAt,
|
||||
q.CreatedAt,
|
||||
q.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
public static class OpenQuestionEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapOpenQuestionEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var projectScoped = app.MapGroup("/api/projects/{projectId:guid}/questions").WithTags("Questions");
|
||||
|
||||
projectScoped.MapGet("/", async (
|
||||
Guid projectId,
|
||||
OpenQuestionService service,
|
||||
CancellationToken ct,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false) =>
|
||||
Results.Ok(await service.ListAsync(projectId, chapterId, characterId, includeResolved, ct)))
|
||||
.WithSummary("List a project's open questions, optionally narrowed to one chapter or character.");
|
||||
|
||||
projectScoped.MapPost("/", async (
|
||||
Guid projectId, CreateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
{
|
||||
var created = await service.CreateAsync(projectId, request, ct);
|
||||
return Results.Created($"/api/questions/{created.Id}", created);
|
||||
})
|
||||
.WithSummary("Raise an open question, optionally against a chapter outline and/or a character.");
|
||||
|
||||
var questions = app.MapGroup("/api/questions").WithTags("Questions");
|
||||
|
||||
questions.MapGet("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.GetAsync(id, ct)))
|
||||
.WithSummary("Read one question.");
|
||||
|
||||
questions.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.UpdateAsync(id, request, ct)))
|
||||
.WithSummary("Update a question or change what it is attached to.");
|
||||
|
||||
questions.MapPost("/{id:guid}/resolve", async (
|
||||
Guid id, ResolveOpenQuestionRequest request, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ResolveAsync(id, request, ct)))
|
||||
.WithSummary("Settle a question, optionally appending the resolution to the notes it hangs off.");
|
||||
|
||||
questions.MapPost("/{id:guid}/reopen", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
|
||||
Results.Ok(await service.ReopenAsync(id, ct)))
|
||||
.WithSummary("Put a resolved question back on the list.");
|
||||
|
||||
questions.MapDelete("/{id:guid}", async (Guid id, OpenQuestionService service, CancellationToken ct) =>
|
||||
{
|
||||
await service.DeleteAsync(id, ct);
|
||||
return Results.NoContent();
|
||||
})
|
||||
.WithSummary("Delete a question.");
|
||||
|
||||
return app;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Projects;
|
||||
|
||||
namespace Novelly.Api.Questions;
|
||||
|
||||
/// <summary>
|
||||
/// The project's open questions — the decisions still outstanding. A question can be
|
||||
/// attached to a chapter outline, a character, both, or neither.
|
||||
/// </summary>
|
||||
public class OpenQuestionService(INovelDbContext db)
|
||||
{
|
||||
/// <summary>
|
||||
/// Lists a project's questions, open ones first and newest first within each group.
|
||||
/// Filters narrow to what one page cares about; resolved questions are left out
|
||||
/// unless asked for, since the point of the list is what is still undecided.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<OpenQuestionDto>> ListAsync(
|
||||
Guid projectId,
|
||||
Guid? chapterId = null,
|
||||
Guid? characterId = null,
|
||||
bool includeResolved = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var query = Query().Where(q => q.ProjectId == projectId);
|
||||
|
||||
if (chapterId is { } cid)
|
||||
{
|
||||
query = query.Where(q => q.ChapterId == cid);
|
||||
}
|
||||
|
||||
if (characterId is { } chid)
|
||||
{
|
||||
query = query.Where(q => q.CharacterId == chid);
|
||||
}
|
||||
|
||||
if (!includeResolved)
|
||||
{
|
||||
query = query.Where(q => q.ResolvedAt == null);
|
||||
}
|
||||
|
||||
var questions = await query.ToListAsync(ct);
|
||||
|
||||
return
|
||||
[
|
||||
.. questions
|
||||
.OrderBy(q => q.ResolvedAt is not null)
|
||||
.ThenByDescending(q => q.CreatedAt)
|
||||
.Select(q => q.ToDto())
|
||||
];
|
||||
}
|
||||
|
||||
public async Task<OpenQuestionDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<OpenQuestionDto> CreateAsync(
|
||||
Guid projectId, CreateOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Question))
|
||||
{
|
||||
throw new ArgumentException("A question needs to say something.");
|
||||
}
|
||||
|
||||
await ValidateAssociationsAsync(projectId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
var question = new OpenQuestion
|
||||
{
|
||||
ProjectId = projectId,
|
||||
Question = request.Question.Trim(),
|
||||
Detail = request.Detail,
|
||||
ChapterId = request.ChapterId,
|
||||
CharacterId = request.CharacterId
|
||||
};
|
||||
|
||||
db.OpenQuestions.Add(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(question.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<OpenQuestionDto> UpdateAsync(
|
||||
Guid id, UpdateOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var question = await FindAsync(id, ct);
|
||||
|
||||
await ValidateAssociationsAsync(question.ProjectId, request.ChapterId, request.CharacterId, ct);
|
||||
|
||||
question.Question = Patch.Apply(question.Question, request.Question) ?? question.Question;
|
||||
question.Detail = Patch.Apply(question.Detail, request.Detail);
|
||||
question.ChapterId = request.ClearChapter ? null : request.ChapterId ?? question.ChapterId;
|
||||
question.CharacterId = request.ClearCharacter ? null : request.CharacterId ?? question.CharacterId;
|
||||
question.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Settles a question. With <c>AppendToNotes</c> the resolution is also appended to the
|
||||
/// notes of the chapter and character it hangs off, so the decision ends up where the
|
||||
/// writer reads rather than only in a list they have stopped looking at.
|
||||
/// </summary>
|
||||
public async Task<OpenQuestionDto> ResolveAsync(
|
||||
Guid id, ResolveOpenQuestionRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var question = await FindAsync(id, ct);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.Resolution))
|
||||
{
|
||||
throw new ArgumentException("A resolution needs to say what was decided.");
|
||||
}
|
||||
|
||||
question.Resolution = request.Resolution.Trim();
|
||||
question.ResolvedAt = DateTimeOffset.UtcNow;
|
||||
question.UpdatedAt = question.ResolvedAt.Value;
|
||||
|
||||
if (request.AppendToNotes)
|
||||
{
|
||||
var note = $"{question.Question} — {question.Resolution}";
|
||||
|
||||
if (question.ChapterId is { } chapterId)
|
||||
{
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
||||
if (chapter is not null)
|
||||
{
|
||||
chapter.Notes = AppendNote(chapter.Notes, note);
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
if (question.CharacterId is { } characterId)
|
||||
{
|
||||
var character = await db.Characters.FirstOrDefaultAsync(c => c.Id == characterId, ct);
|
||||
if (character is not null)
|
||||
{
|
||||
character.Notes = AppendNote(character.Notes, note);
|
||||
character.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
/// <summary>Puts a question back on the list. The resolution goes; anything already appended to notes stays.</summary>
|
||||
public async Task<OpenQuestionDto> ReopenAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var question = await FindAsync(id, ct);
|
||||
|
||||
question.Resolution = null;
|
||||
question.ResolvedAt = null;
|
||||
question.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var question = await FindAsync(id, ct);
|
||||
db.OpenQuestions.Remove(question);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Blank line between entries, so appended resolutions stay readable as notes accumulate.</summary>
|
||||
private static string AppendNote(string? existing, string note) =>
|
||||
string.IsNullOrWhiteSpace(existing) ? note : $"{existing.TrimEnd()}\n\n{note}";
|
||||
|
||||
private async Task ValidateAssociationsAsync(
|
||||
Guid projectId, Guid? chapterId, Guid? characterId, CancellationToken ct)
|
||||
{
|
||||
if (chapterId is { } cid
|
||||
&& !await db.Chapters.AnyAsync(c => c.Id == cid && c.ProjectId == projectId, ct))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A question can only be attached to a chapter in the same project.");
|
||||
}
|
||||
|
||||
if (characterId is { } chid
|
||||
&& !await db.Characters.AnyAsync(c => c.Id == chid && c.ProjectId == projectId, ct))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A question can only be attached to a character in the same project.");
|
||||
}
|
||||
}
|
||||
|
||||
private IQueryable<OpenQuestion> Query() =>
|
||||
db.OpenQuestions.Include(q => q.Chapter).Include(q => q.Character);
|
||||
|
||||
private async Task<OpenQuestion> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(q => q.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(OpenQuestion), id);
|
||||
}
|
||||
@@ -33,6 +33,8 @@ public static class CharacterTools
|
||||
CancellationToken ct,
|
||||
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
|
||||
string? role = null,
|
||||
[Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
|
||||
string? importance = null,
|
||||
[Description("Age, exact or approximate.")] string? age = null,
|
||||
[Description("The pronouns this character uses.")] string? pronouns = null,
|
||||
[Description("What they do.")] string? occupation = null,
|
||||
@@ -51,6 +53,7 @@ public static class CharacterTools
|
||||
{
|
||||
name,
|
||||
role = role ?? "Supporting",
|
||||
importance = importance ?? "Supporting",
|
||||
age,
|
||||
pronouns,
|
||||
occupation,
|
||||
@@ -76,6 +79,8 @@ public static class CharacterTools
|
||||
[Description("New name.")] string? name = null,
|
||||
[Description("Protagonist, Antagonist, Deuteragonist, Supporting, Minor, Mentor, LoveInterest or Foil.")]
|
||||
string? role = null,
|
||||
[Description("Main or Supporting. Main characters are the few the story is about and are worth tracking an arc for.")]
|
||||
string? importance = null,
|
||||
[Description("Age, exact or approximate.")] string? age = null,
|
||||
[Description("The pronouns this character uses.")] string? pronouns = null,
|
||||
[Description("What they do.")] string? occupation = null,
|
||||
@@ -94,6 +99,7 @@ public static class CharacterTools
|
||||
{
|
||||
name,
|
||||
role,
|
||||
importance,
|
||||
age,
|
||||
pronouns,
|
||||
occupation,
|
||||
@@ -110,6 +116,70 @@ public static class CharacterTools
|
||||
tags
|
||||
}, ct);
|
||||
|
||||
[McpServerTool(Name = "get_character_beats")]
|
||||
[Description("Every beat this character appears in, across the whole book, in manuscript order. "
|
||||
+ "This is what the character actually does on the page, as opposed to what the "
|
||||
+ "dossier claims about them — read it before revising a character.")]
|
||||
public static Task<CallToolResult> GetCharacterBeats(
|
||||
NovelApiClient api,
|
||||
[Description("The character's id.")] Guid characterId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/characters/{characterId}/beats", ct);
|
||||
|
||||
[McpServerTool(Name = "get_character_arc")]
|
||||
[Description("Read a main character's arc: the ordered stages of how they change, each "
|
||||
+ "optionally pinned to the chapter where it lands.")]
|
||||
public static Task<CallToolResult> GetCharacterArc(
|
||||
NovelApiClient api,
|
||||
[Description("The character's id.")] Guid characterId,
|
||||
CancellationToken ct) =>
|
||||
api.GetAsync($"/api/characters/{characterId}/arc", ct);
|
||||
|
||||
[McpServerTool(Name = "add_arc_stage")]
|
||||
[Description("Add a stage to a character's arc. Arcs are kept for main characters — promote "
|
||||
+ "the character with update_character first if they are still Supporting.")]
|
||||
public static Task<CallToolResult> AddArcStage(
|
||||
NovelApiClient api,
|
||||
[Description("Id of the character whose arc to add to.")] Guid characterId,
|
||||
[Description("A short handle for the change, three to five words.")] string title,
|
||||
CancellationToken ct,
|
||||
[Description("What shifts in the character here, and what it costs them.")] string? description = null,
|
||||
[Description("Id of the chapter where this stage lands, if it is pinned to one.")] Guid? chapterId = null,
|
||||
[Description("Position in the arc. Appended to the end when omitted.")] int? sortOrder = null) =>
|
||||
api.PostAsync($"/api/characters/{characterId}/arc",
|
||||
new { title, sortOrder, description, chapterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "update_arc_stage")]
|
||||
[Description("Revise a stage of a character's arc. Only the fields you supply change.")]
|
||||
public static Task<CallToolResult> UpdateArcStage(
|
||||
NovelApiClient api,
|
||||
[Description("The arc stage's id.")] Guid arcStageId,
|
||||
CancellationToken ct,
|
||||
[Description("New title for the stage.")] string? title = null,
|
||||
[Description("What shifts in the character here.")] string? description = null,
|
||||
[Description("Id of the chapter where this stage lands.")] Guid? chapterId = null,
|
||||
[Description("Position in the arc.")] int? sortOrder = null) =>
|
||||
api.PatchAsync($"/api/arc-stages/{arcStageId}",
|
||||
new { title, sortOrder, description, chapterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "delete_arc_stage")]
|
||||
[Description("Remove a stage from a character's arc.")]
|
||||
public static Task<CallToolResult> DeleteArcStage(
|
||||
NovelApiClient api,
|
||||
[Description("The arc stage's id.")] Guid arcStageId,
|
||||
CancellationToken ct) =>
|
||||
api.DeleteAsync($"/api/arc-stages/{arcStageId}", ct);
|
||||
|
||||
[McpServerTool(Name = "reorder_arc_stages")]
|
||||
[Description("Renumber a character's arc to match the order given. Stages left out keep their "
|
||||
+ "relative position after the ones listed.")]
|
||||
public static Task<CallToolResult> ReorderArcStages(
|
||||
NovelApiClient api,
|
||||
[Description("Id of the character whose arc to reorder.")] Guid characterId,
|
||||
[Description("Arc stage ids in the order wanted.")] string[] stageIds,
|
||||
CancellationToken ct) =>
|
||||
api.PostAsync($"/api/characters/{characterId}/arc/reorder", new { stageIds }, ct);
|
||||
|
||||
[McpServerTool(Name = "relate_characters")]
|
||||
[Description("Record a relationship from one character to another in the same project.")]
|
||||
public static Task<CallToolResult> RelateCharacters(
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
using System.ComponentModel;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
|
||||
namespace Novelly.Mcp.Tools;
|
||||
|
||||
[McpServerToolType]
|
||||
public static class QuestionTools
|
||||
{
|
||||
[McpServerTool(Name = "list_open_questions")]
|
||||
[Description("The decisions the writer has not made yet, newest first. Read this before "
|
||||
+ "proposing changes — an open question marks somewhere the writer is still "
|
||||
+ "thinking, not a gap to fill in for them.")]
|
||||
public static Task<CallToolResult> ListOpenQuestions(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
CancellationToken ct,
|
||||
[Description("Narrow to questions about one chapter outline.")] Guid? chapterId = null,
|
||||
[Description("Narrow to questions about one character.")] Guid? characterId = null,
|
||||
[Description("Include questions already settled. Defaults to false.")] bool includeResolved = false)
|
||||
{
|
||||
var query = new List<string> { $"includeResolved={includeResolved.ToString().ToLowerInvariant()}" };
|
||||
|
||||
if (chapterId is { } chapter)
|
||||
{
|
||||
query.Add($"chapterId={chapter}");
|
||||
}
|
||||
|
||||
if (characterId is { } character)
|
||||
{
|
||||
query.Add($"characterId={character}");
|
||||
}
|
||||
|
||||
return api.GetAsync($"/api/projects/{projectId}/questions?{string.Join('&', query)}", ct);
|
||||
}
|
||||
|
||||
[McpServerTool(Name = "raise_open_question")]
|
||||
[Description("Record a question the writer has not settled, attached to the chapter outline "
|
||||
+ "and/or the character it is about. Prefer raising a question over guessing.")]
|
||||
public static Task<CallToolResult> RaiseOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The project's id.")] Guid projectId,
|
||||
[Description("The question, in one line.")] string question,
|
||||
CancellationToken ct,
|
||||
[Description("The thinking around it — options considered, and what each costs.")] string? detail = null,
|
||||
[Description("Id of the chapter outline this is about, if any.")] Guid? chapterId = null,
|
||||
[Description("Id of the character this is about, if any.")] Guid? characterId = null) =>
|
||||
api.PostAsync($"/api/projects/{projectId}/questions",
|
||||
new { question, detail, chapterId, characterId }, ct);
|
||||
|
||||
[McpServerTool(Name = "update_open_question")]
|
||||
[Description("Revise a question or change what it is attached to. Only the fields you supply change.")]
|
||||
public static Task<CallToolResult> UpdateOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
CancellationToken ct,
|
||||
[Description("New wording for the question.")] string? question = null,
|
||||
[Description("New detail. Pass an empty string to clear it.")] string? detail = null,
|
||||
[Description("Attach to this chapter outline.")] Guid? chapterId = null,
|
||||
[Description("Attach to this character.")] Guid? characterId = null,
|
||||
[Description("Detach from its chapter.")] bool clearChapter = false,
|
||||
[Description("Detach from its character.")] bool clearCharacter = false) =>
|
||||
api.PatchAsync($"/api/questions/{questionId}",
|
||||
new { question, detail, chapterId, characterId, clearChapter, clearCharacter }, ct);
|
||||
|
||||
[McpServerTool(Name = "resolve_open_question")]
|
||||
[Description("Settle a question with what the writer decided. Set appendToNotes to also write "
|
||||
+ "the resolution into the notes of the chapter and character it hangs off.")]
|
||||
public static Task<CallToolResult> ResolveOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
[Description("What was decided.")] string resolution,
|
||||
CancellationToken ct,
|
||||
[Description("Also append the resolution to the associated notes.")] bool appendToNotes = false) =>
|
||||
api.PostAsync($"/api/questions/{questionId}/resolve", new { resolution, appendToNotes }, ct);
|
||||
|
||||
[McpServerTool(Name = "reopen_question")]
|
||||
[Description("Put a resolved question back on the list. Anything already appended to notes stays.")]
|
||||
public static Task<CallToolResult> ReopenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
CancellationToken ct) =>
|
||||
api.PostAsync($"/api/questions/{questionId}/reopen", new { }, ct);
|
||||
|
||||
[McpServerTool(Name = "delete_open_question")]
|
||||
[Description("Delete a question outright. Resolving is usually better — it keeps the decision.")]
|
||||
public static Task<CallToolResult> DeleteOpenQuestion(
|
||||
NovelApiClient api,
|
||||
[Description("The question's id.")] Guid questionId,
|
||||
CancellationToken ct) =>
|
||||
api.DeleteAsync($"/api/questions/{questionId}", ct);
|
||||
}
|
||||
Reference in New Issue
Block a user