Files
novelly/src/Novelly.Api/Chapters/ChapterContracts.cs
T
James Wampler ef5260a111 Add ChapterKind for front/back matter chapters
Chapters can now be marked FrontMatter/Body/BackMatter. Number stays
the manuscript sort key for every chapter; the author-facing display
number is now computed per-request as the chapter's ordinal among
Body chapters only, so a foreword or afterword no longer shifts the
numbering of the rest of the book. Surfaced through the API, agent
toolset, and import toolset.
2026-08-19 17:54:20 -07:00

147 lines
4.9 KiB
C#

using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Locations;
using Novelly.Api.Tags;
namespace Novelly.Api.Chapters;
public record ChapterSummaryResponse(
Guid Id,
Guid NovelId,
int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title,
string? Summary,
IReadOnlyList<LocationResponse> Locations,
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
int WordCount,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record ChapterResponse(
Guid Id,
Guid NovelId,
int Number,
ChapterKind Kind,
int? DisplayNumber,
string Title,
string? Summary,
IReadOnlyList<LocationResponse> Locations,
string? Notes,
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatResponse> Beats,
string? Prose,
int WordCount,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
public record CreateChapterRequest(
string Title,
int? Number = null,
ChapterKind Kind = ChapterKind.Body,
string? Summary = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null);
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
{
public ValidationResult Validate(CreateChapterRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
}
}
public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
ChapterKind? Kind = null,
string? Summary = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
DraftStatus? Status = null,
int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null);
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
{
public ValidationResult Validate(UpdateChapterRequest model)
{
var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
}
}
file static class ChapterValidation
{
public static void OptionalFields(
int? number, string? summary, IReadOnlyList<string>? locations, string? notes, int? targetWordCount, string? prose,
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 (locations is not null && locations.Any(string.IsNullOrWhiteSpace))
result.AddError("Locations", "'Locations' must not contain blank entries.");
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 (prose is { Length: > 200000 })
result.AddError("Prose", "'Prose' must be 200,000 characters or fewer.");
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
result.AddError("Tags", "'Tags' must not contain blank entries.");
}
}
public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes,
c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt);
public static int CountWords(string? prose) =>
string.IsNullOrWhiteSpace(prose)
? 0
: prose.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length;
}