Remove Scenes, group beats by multiple characters; strip comments repo-wide

Drop the Scene entity/grouping in favor of chapters carrying prose directly
and beats belonging to many characters. Add markdown editor + character
multi-select components to the web client. Remove all XML doc and inline
comments across the touched C#/TS/CSS files in favor of self-documenting
names, and record that convention in CLAUDE.md. Add .mcp.json (local MCP
server config, no secrets) and ignore .idea/.
This commit is contained in:
James Wampler
2026-08-11 21:05:13 -07:00
parent 1ce526019f
commit 23348327a9
57 changed files with 2600 additions and 1421 deletions
+18 -16
View File
@@ -1,7 +1,6 @@
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;
@@ -18,15 +17,10 @@ public record ChapterSummaryResponse(
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
int SceneCount,
int WordCount,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
/// <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,
@@ -40,7 +34,8 @@ public record ChapterResponse(
DraftStatus Status,
int? TargetWordCount,
IReadOnlyList<BeatResponse> Beats,
IReadOnlyList<SceneResponse> Scenes,
string? Prose,
int WordCount,
IReadOnlyList<TagResponse> Tags,
DateTimeOffset UpdatedAt);
@@ -53,6 +48,7 @@ public record CreateChapterRequest(
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null);
public class CreateChapterRequestValidator : IModelValidator<CreateChapterRequest>
@@ -66,16 +62,12 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
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);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, 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,
@@ -85,6 +77,7 @@ public record UpdateChapterRequest(
string? Notes = null,
DraftStatus? Status = null,
int? TargetWordCount = null,
string? Prose = null,
IReadOnlyList<string>? Tags = null);
public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterRequest>
@@ -101,7 +94,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
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);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
}
@@ -110,7 +103,8 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation
{
public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, IReadOnlyList<string>? tags, ValidationResult result)
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose,
IReadOnlyList<string>? tags, ValidationResult result)
{
if (number is <= 0)
result.AddError("Number", "'Number' must be greater than zero.");
@@ -127,6 +121,9 @@ file static class ChapterValidation
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.");
}
@@ -139,14 +136,19 @@ public static class ChapterMapping
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.Prose, c.WordCount,
[.. 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.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;
}