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.
147 lines
4.9 KiB
C#
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;
|
|
}
|