Add outline import feature; drop Dto naming, map entities at the API boundary

Services now return entities; endpoints (and the agent toolsets) map to
*Response records instead of services building wire DTOs themselves.
Also brings in the outline-import agent, MCP tool, ledger and web dialog
that were already in progress on disk.
This commit is contained in:
James Wampler
2026-08-06 18:36:40 -07:00
parent 40f93e40a8
commit 189ebf3237
66 changed files with 3310 additions and 364 deletions
@@ -0,0 +1,150 @@
using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Scenes;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public record ChapterSummaryResponse(
Guid Id,
Guid ProjectId,
int Number,
string Title,
string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting,
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagResponse> Tags);
/// <summary>
/// A chapter in full: the outline (a paragraph of summary plus an ordered beat table)
/// and the prose layer (scenes).
/// </summary>
public record ChapterResponse(
Guid Id,
Guid ProjectId,
int Number,
string Title,
string? Summary,
Guid? PovCharacterId,
string? PovCharacterName,
string? Setting,
string? Notes,
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatResponse> Beats,
IReadOnlyList<SceneResponse> Scenes,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record CreateChapterRequest(
string Title,
int? Number = null,
string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null,
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null);
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
{
public ValidationResult Validate(CreateChapterRequest model)
{
var result = new ValidationResult();
if (string.IsNullOrWhiteSpace(model.Title))
result.AddError("Title", "'Title' must not be empty.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
return result;
}
}
/// <summary>
/// Patch-style update. A null field is left alone; an empty string clears it. Passing a
/// <see cref="Tags"/> list replaces the chapter's tags outright.
/// </summary>
public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
string? Summary = null,
Guid? PovCharacterId = null,
string? Setting = null,
string? Notes = null,
DraftStatus? Status = null,
int? TargetWordCount = null,
IReadOnlyList<string>? Tags = null);
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
{
public ValidationResult Validate(UpdateChapterRequest model)
{
var result = new ValidationResult();
if (model.Title is not null)
{
if (model.Title.Length == 0)
result.AddError("Title", "'Title' can not be cleared — a chapter always needs one.");
else if (model.Title.Length > 200)
result.AddError("Title", "'Title' must be 200 characters or fewer.");
}
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Tags, result);
return result;
}
}
file static class ChapterValidation
{
public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result)
{
if (number is <= 0)
result.AddError("Number", "'Number' must be greater than zero.");
if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (setting is { Length: > 500 })
result.AddError("Setting", "'Setting' must be 500 characters or fewer.");
if (notes is { Length: > 20000 })
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
if (targetWordCount is < 0)
result.AddError("TargetWordCount", "'Target Word Count' must be zero or greater.");
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries.");
}
}
public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Notes,
c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
[.. c.Scenes.OrderBy(s => s.SortOrder).Select(s => s.ToResponse())],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.ProjectId, c.Number, c.Title, c.Summary,
c.PovCharacterId, c.PovCharacter?.Name, c.Setting, c.Status, c.TargetWordCount,
c.Beats.Count, c.Scenes.Count, c.Scenes.Sum(s => s.WordCount),
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())]);
}