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:
@@ -2,29 +2,22 @@ using Novelly.Api.Beats;
|
||||
using Novelly.Api.Characters;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Scenes;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
|
||||
/// <summary>A chapter: an ordered container of scenes plus its own planning fields.</summary>
|
||||
public class Chapter
|
||||
{
|
||||
public Guid Id { get; set; } = Guid.NewGuid();
|
||||
public Guid ProjectId { get; set; }
|
||||
public Project? Project { get; set; }
|
||||
|
||||
/// <summary>Position in the manuscript, 1-based.</summary>
|
||||
public int Number { get; set; }
|
||||
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The paragraph that opens the chapter's outline, above the beat table.
|
||||
/// </summary>
|
||||
public string? Summary { get; set; }
|
||||
|
||||
/// <summary>Whose head we are in for this chapter.</summary>
|
||||
public Guid? PovCharacterId { get; set; }
|
||||
public Character? PovCharacter { get; set; }
|
||||
|
||||
@@ -34,14 +27,14 @@ public class Chapter
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
public int? TargetWordCount { get; set; }
|
||||
|
||||
public string? Prose { get; set; }
|
||||
|
||||
public int WordCount { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
/// <summary>The chapter's outline: an ordered, flat list of beats.</summary>
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
/// <summary>The prose layer. Beats may optionally be grouped under these.</summary>
|
||||
public List<Scene> Scenes { get; set; } = [];
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ public static class ChapterEndpoints
|
||||
|
||||
chapters.MapGet("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
(await service.GetAsync(id, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Read a chapter with all of its scenes.");
|
||||
.WithSummary("Read a chapter with its beats and prose.");
|
||||
|
||||
chapters.MapPatch("/{id:guid}", async (
|
||||
Guid id, UpdateChapterRequest request, ChapterService service, CancellationToken ct) =>
|
||||
@@ -44,7 +44,7 @@ public static class ChapterEndpoints
|
||||
|
||||
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
|
||||
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Delete a chapter and its scenes.");
|
||||
.WithSummary("Delete a chapter.");
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -22,14 +22,12 @@ public class ChapterService(
|
||||
return await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Scenes)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no chapter has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Chapter?> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -38,7 +36,6 @@ public class ChapterService(
|
||||
return await FindAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Null when no project has this id — a lookup miss is expected, not exceptional.</summary>
|
||||
public async Task<Chapter?> CreateAsync(Guid projectId, CreateChapterRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(projectId, nameof(projectId));
|
||||
@@ -63,7 +60,9 @@ public class ChapterService(
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
Status = request.Status,
|
||||
TargetWordCount = request.TargetWordCount
|
||||
TargetWordCount = request.TargetWordCount,
|
||||
Prose = request.Prose,
|
||||
WordCount = ChapterMapping.CountWords(request.Prose)
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
@@ -74,7 +73,6 @@ public class ChapterService(
|
||||
db.Chapters.Add(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
// Just created it — the reload is only to pick up includes, not to check existence.
|
||||
return (await FindAsync(chapter.Id, ct))!;
|
||||
}
|
||||
|
||||
@@ -100,6 +98,13 @@ public class ChapterService(
|
||||
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
||||
chapter.Status = request.Status ?? chapter.Status;
|
||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||
|
||||
if (request.Prose is not null)
|
||||
{
|
||||
chapter.Prose = Patch.Apply(chapter.Prose, request.Prose);
|
||||
chapter.WordCount = ChapterMapping.CountWords(chapter.Prose);
|
||||
}
|
||||
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
@@ -111,7 +116,6 @@ public class ChapterService(
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
|
||||
/// <summary>True if a chapter was deleted; false if no chapter had this id.</summary>
|
||||
public async Task<bool> DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(id, nameof(id));
|
||||
@@ -148,10 +152,8 @@ public class ChapterService(
|
||||
|
||||
var chapter = await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Character)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Scene)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
|
||||
.Include(c => c.Tags)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user