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:
@@ -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())]);
|
||||
}
|
||||
Reference in New Issue
Block a user