Replace chapter setting with multi-select locations; add chapter character summary

Chapters now carry many Locations (new Tags-style entity with cross-referencing)
instead of a single free-text Setting field, with a Locations tab on the novel
for browsing them and seeing every chapter set at each one. Also surfaces the
distinct characters appearing in a chapter's beats, linked, under the beat/word
count on the outline tab.
This commit is contained in:
James Wampler
2026-08-18 11:36:13 -07:00
parent 4313c8f206
commit c620ddd626
27 changed files with 2380 additions and 44 deletions
+2 -1
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats;
using Novelly.Api.Common;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
using Novelly.Api.Tags;
@@ -19,7 +20,6 @@ public class Chapter
public string? Summary { get; set; }
public string? Setting { get; set; }
public string? Notes { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned;
@@ -35,6 +35,7 @@ public class Chapter
public List<Beat> Beats { get; set; } = [];
public List<Tag> Tags { get; set; } = [];
public List<Location> Locations { get; set; } = [];
}
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
+14 -11
View File
@@ -1,6 +1,7 @@
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;
@@ -11,7 +12,7 @@ public record ChapterSummaryResponse(
int Number,
string Title,
string? Summary,
string? Setting,
IReadOnlyList<LocationResponse> Locations,
DraftStatus Status,
int? TargetWordCount,
int BeatCount,
@@ -25,7 +26,7 @@ public record ChapterResponse(
int Number,
string Title,
string? Summary,
string? Setting,
IReadOnlyList<LocationResponse> Locations,
string? Notes,
DraftStatus Status,
int? TargetWordCount,
@@ -39,7 +40,7 @@ public record CreateChapterRequest(
string Title,
int? Number = null,
string? Summary = null,
string? Setting = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null,
@@ -53,7 +54,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
}
@@ -63,7 +64,7 @@ public record UpdateChapterRequest(
string? Title = null,
int? Number = null,
string? Summary = null,
string? Setting = null,
IReadOnlyList<string>? Locations = null,
string? Notes = null,
DraftStatus? Status = null,
int? TargetWordCount = null,
@@ -77,7 +78,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
return result;
}
@@ -86,7 +87,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation
{
public static void OptionalFields(
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose,
int? number, string? summary, IReadOnlyList<string>? locations, string? notes, int? targetWordCount, string? prose,
IReadOnlyList<string>? tags, ValidationResult result)
{
if (number is <= 0)
@@ -95,8 +96,8 @@ file static class ChapterValidation
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 (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.");
@@ -116,7 +117,8 @@ public static class ChapterMapping
{
public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Notes,
[.. 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,
@@ -125,7 +127,8 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
c.Setting, c.Status, c.TargetWordCount,
[.. 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);
+14 -2
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Locations;
using Novelly.Api.Tags;
using Novelly.Api.Users;
@@ -11,6 +12,7 @@ public class ChapterService(
INovelDbContext db,
NovelAccessService access,
TagService tags,
LocationService locations,
ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator)
@@ -26,6 +28,7 @@ public class ChapterService(
return await db.Chapters
.Include(c => c.Beats)
.Include(c => c.Tags)
.Include(c => c.Locations)
.Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number)
.ToListAsync(ct);
@@ -69,7 +72,6 @@ public class ChapterService(
Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Summary = request.Summary,
Setting = request.Setting,
Notes = request.Notes,
Status = request.Status,
TargetWordCount = request.TargetWordCount,
@@ -82,6 +84,11 @@ public class ChapterService(
chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
}
if (request.Locations is { } locationNames)
{
chapter.Locations = await locations.ResolveAsync(novelId, locationNames, ct);
}
db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct);
@@ -107,7 +114,6 @@ public class ChapterService(
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
@@ -125,6 +131,11 @@ public class ChapterService(
chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
}
if (request.Locations is { } locationNames)
{
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
}
await db.SaveChangesAsync(ct);
return (await FindAsync(id, ct))!;
}
@@ -169,6 +180,7 @@ public class ChapterService(
.Include(c => c.Beats).ThenInclude(b => b.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags)
.Include(c => c.Locations)
.FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null)