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
+26 -5
View File
@@ -3,6 +3,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -29,6 +30,7 @@ public class NovelAgentToolset(
ChapterService chapters, ChapterService chapters,
BeatService beats, BeatService beats,
TagService tags, TagService tags,
LocationService locations,
OpenQuestionService questions, OpenQuestionService questions,
ILogger<NovelAgentToolset> logger) ILogger<NovelAgentToolset> logger)
{ {
@@ -366,6 +368,25 @@ public class NovelAgentToolset(
return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId); return await OrNotFound(tags.GetReferencesAsync(tagId, ct), t => t.ToReferencesResponse(), "Tag", tagId);
}); });
yield return new AgentTool(
"list_locations",
"List the novel's locations with how many chapters are set there. "
+ "Read this before inventing a new location so you reuse the writer's vocabulary.",
new JsonSchemaBuilder().Build(),
async (novelId, _, ct) => await locations.ListAsync(novelId, ct));
yield return new AgentTool(
"get_location_references",
"Cross-reference a location: every chapter set there.",
new JsonSchemaBuilder()
.Str("location_id", "Id of the location to trace.", required: true)
.Build(),
async (_, input, ct) =>
{
var locationId = JsonInput.RequiredGuid(input, "location_id");
return await OrNotFound(locations.GetReferencesAsync(locationId, ct), l => l.ToReferencesResponse(), "Location", locationId);
});
yield return new AgentTool( yield return new AgentTool(
"list_chapters", "list_chapters",
"List the novel's chapters in manuscript order with beat and word counts.", "List the novel's chapters in manuscript order with beat and word counts.",
@@ -391,7 +412,7 @@ public class NovelAgentToolset(
.Str("title", "Chapter title.", required: true) .Str("title", "Chapter title.", required: true)
.Int("number", "Position in the manuscript, 1-based.") .Int("number", "Position in the manuscript, 1-based.")
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.Str("setting", "Where and when the chapter takes place.") .StringArray("locations", "Where and when the chapter takes place. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
@@ -402,7 +423,7 @@ public class NovelAgentToolset(
JsonInput.RequiredString(input, "title"), JsonInput.RequiredString(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.String(input, "setting"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned, JsonInput.Enum<DraftStatus>(input, "status") ?? DraftStatus.Planned,
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
@@ -411,7 +432,7 @@ public class NovelAgentToolset(
yield return new AgentTool( yield return new AgentTool(
"update_chapter", "update_chapter",
"Revise a chapter's title, number, summary, setting, notes, status or drafted " "Revise a chapter's title, number, summary, locations, notes, status or drafted "
+ "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the " + "prose. Use 'prose' to write or replace the chapter's draft text in markdown; the "
+ "word count is recomputed automatically.", + "word count is recomputed automatically.",
new JsonSchemaBuilder() new JsonSchemaBuilder()
@@ -419,7 +440,7 @@ public class NovelAgentToolset(
.Str("title", "New title.") .Str("title", "New title.")
.Int("number", "Position in the manuscript.") .Int("number", "Position in the manuscript.")
.Str("summary", "What the chapter covers.") .Str("summary", "What the chapter covers.")
.Str("setting", "Where and when the chapter takes place.") .StringArray("locations", "Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")
.Str("notes", "Anything else worth recording.") .Str("notes", "Anything else worth recording.")
.Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>()) .Enum("status", "Drafting status.", System.Enum.GetNames<DraftStatus>())
.Int("target_word_count", "Target length in words.") .Int("target_word_count", "Target length in words.")
@@ -435,7 +456,7 @@ public class NovelAgentToolset(
JsonInput.String(input, "title"), JsonInput.String(input, "title"),
JsonInput.Int(input, "number"), JsonInput.Int(input, "number"),
JsonInput.String(input, "summary"), JsonInput.String(input, "summary"),
JsonInput.String(input, "setting"), JsonInput.Strings(input, "locations"),
JsonInput.String(input, "notes"), JsonInput.String(input, "notes"),
JsonInput.Enum<DraftStatus>(input, "status"), JsonInput.Enum<DraftStatus>(input, "status"),
JsonInput.Int(input, "target_word_count"), JsonInput.Int(input, "target_word_count"),
+2 -1
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -19,7 +20,6 @@ public class Chapter
public string? Summary { get; set; } public string? Summary { get; set; }
public string? Setting { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public DraftStatus Status { get; set; } = DraftStatus.Planned; public DraftStatus Status { get; set; } = DraftStatus.Planned;
@@ -35,6 +35,7 @@ public class Chapter
public List<Beat> Beats { get; set; } = []; public List<Beat> Beats { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
public List<Location> Locations { get; set; } = [];
} }
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter> public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
+14 -11
View File
@@ -1,6 +1,7 @@
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Locations;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
@@ -11,7 +12,7 @@ public record ChapterSummaryResponse(
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
string? Setting, IReadOnlyList<LocationResponse> Locations,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
int BeatCount, int BeatCount,
@@ -25,7 +26,7 @@ public record ChapterResponse(
int Number, int Number,
string Title, string Title,
string? Summary, string? Summary,
string? Setting, IReadOnlyList<LocationResponse> Locations,
string? Notes, string? Notes,
DraftStatus Status, DraftStatus Status,
int? TargetWordCount, int? TargetWordCount,
@@ -39,7 +40,7 @@ public record CreateChapterRequest(
string Title, string Title,
int? Number = null, int? Number = null,
string? Summary = null, string? Summary = null,
string? Setting = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
DraftStatus Status = DraftStatus.Planned, DraftStatus Status = DraftStatus.Planned,
int? TargetWordCount = null, int? TargetWordCount = null,
@@ -53,7 +54,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
var result = new ValidationResult(); var result = new ValidationResult();
result.AddRequiredTextErrors("Title", "Title", model.Title, 200); 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; return result;
} }
@@ -63,7 +64,7 @@ public record UpdateChapterRequest(
string? Title = null, string? Title = null,
int? Number = null, int? Number = null,
string? Summary = null, string? Summary = null,
string? Setting = null, IReadOnlyList<string>? Locations = null,
string? Notes = null, string? Notes = null,
DraftStatus? Status = null, DraftStatus? Status = null,
int? TargetWordCount = null, int? TargetWordCount = null,
@@ -77,7 +78,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
var result = new ValidationResult(); var result = new ValidationResult();
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200); 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; return result;
} }
@@ -86,7 +87,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
file static class ChapterValidation file static class ChapterValidation
{ {
public static void OptionalFields( 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) IReadOnlyList<string>? tags, ValidationResult result)
{ {
if (number is <= 0) if (number is <= 0)
@@ -95,8 +96,8 @@ file static class ChapterValidation
if (summary is { Length: > 20000 }) if (summary is { Length: > 20000 })
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer."); result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
if (setting is { Length: > 500 }) if (locations is not null && locations.Any(string.IsNullOrWhiteSpace))
result.AddError("Setting", "'Setting' must be 500 characters or fewer."); result.AddError("Locations", "'Locations' must not contain blank entries.");
if (notes is { Length: > 20000 }) if (notes is { Length: > 20000 })
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer."); 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( public static ChapterResponse ToResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary, 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.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
c.Prose, c.WordCount, c.Prose, c.WordCount,
@@ -125,7 +127,8 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
c.Id, c.NovelId, c.Number, c.Title, c.Summary, 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.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
c.UpdatedAt); c.UpdatedAt);
+14 -2
View File
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common; using Novelly.Api.Common;
using Novelly.Api.Common.Validation; using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Locations;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Users; using Novelly.Api.Users;
@@ -11,6 +12,7 @@ public class ChapterService(
INovelDbContext db, INovelDbContext db,
NovelAccessService access, NovelAccessService access,
TagService tags, TagService tags,
LocationService locations,
ILogger<ChapterService> logger, ILogger<ChapterService> logger,
IModelValidator<CreateChapterRequest> createValidator, IModelValidator<CreateChapterRequest> createValidator,
IModelValidator<UpdateChapterRequest> updateValidator) IModelValidator<UpdateChapterRequest> updateValidator)
@@ -26,6 +28,7 @@ public class ChapterService(
return await db.Chapters return await db.Chapters
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.Locations)
.Where(c => c.NovelId == novelId) .Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
@@ -69,7 +72,6 @@ public class ChapterService(
Title = request.Title, Title = request.Title,
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct), Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
Summary = request.Summary, Summary = request.Summary,
Setting = request.Setting,
Notes = request.Notes, Notes = request.Notes,
Status = request.Status, Status = request.Status,
TargetWordCount = request.TargetWordCount, TargetWordCount = request.TargetWordCount,
@@ -82,6 +84,11 @@ public class ChapterService(
chapter.Tags = await tags.ResolveAsync(novelId, names, ct); 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); db.Chapters.Add(chapter);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -107,7 +114,6 @@ public class ChapterService(
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title; chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
chapter.Number = request.Number ?? chapter.Number; chapter.Number = request.Number ?? chapter.Number;
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary); 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.Notes = Patch.Apply(chapter.Notes, request.Notes);
chapter.Status = request.Status ?? chapter.Status; chapter.Status = request.Status ?? chapter.Status;
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount; chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
@@ -125,6 +131,11 @@ public class ChapterService(
chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct); 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); await db.SaveChangesAsync(ct);
return (await FindAsync(id, 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.Characters)
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.Locations)
.FirstOrDefaultAsync(c => c.Id == id, ct); .FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null) if (chapter is null)
@@ -13,6 +13,7 @@ using Novelly.Api.Common.Validation;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -77,6 +78,7 @@ public static class NovellyServiceRegistration
services.AddScoped<CharacterArcService>(); services.AddScoped<CharacterArcService>();
services.AddScoped<BeatService>(); services.AddScoped<BeatService>();
services.AddScoped<TagService>(); services.AddScoped<TagService>();
services.AddScoped<LocationService>();
services.AddScoped<GenreService>(); services.AddScoped<GenreService>();
services.AddScoped<ChapterService>(); services.AddScoped<ChapterService>();
services.AddScoped<OpenQuestionService>(); services.AddScoped<OpenQuestionService>();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,90 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class RenameSettingToLocations : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Setting",
table: "Chapters");
migrationBuilder.CreateTable(
name: "Locations",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
NovelId = table.Column<Guid>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 120, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Locations", x => x.Id);
table.ForeignKey(
name: "FK_Locations_Novels_NovelId",
column: x => x.NovelId,
principalTable: "Novels",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "ChapterLocations",
columns: table => new
{
ChaptersId = table.Column<Guid>(type: "TEXT", nullable: false),
LocationsId = table.Column<Guid>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_ChapterLocations", x => new { x.ChaptersId, x.LocationsId });
table.ForeignKey(
name: "FK_ChapterLocations_Chapters_ChaptersId",
column: x => x.ChaptersId,
principalTable: "Chapters",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_ChapterLocations_Locations_LocationsId",
column: x => x.LocationsId,
principalTable: "Locations",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_ChapterLocations_LocationsId",
table: "ChapterLocations",
column: "LocationsId");
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "ChapterLocations");
migrationBuilder.DropTable(
name: "Locations");
migrationBuilder.AddColumn<string>(
name: "Setting",
table: "Chapters",
type: "TEXT",
nullable: true);
}
}
}
@@ -62,6 +62,21 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("BeatTags", (string)null); b.ToTable("BeatTags", (string)null);
}); });
modelBuilder.Entity("ChapterLocation", b =>
{
b.Property<Guid>("ChaptersId")
.HasColumnType("TEXT");
b.Property<Guid>("LocationsId")
.HasColumnType("TEXT");
b.HasKey("ChaptersId", "LocationsId");
b.HasIndex("LocationsId");
b.ToTable("ChapterLocations", (string)null);
});
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
{ {
b.Property<Guid>("ChaptersId") b.Property<Guid>("ChaptersId")
@@ -273,9 +288,6 @@ namespace Novelly.Api.Data.Migrations
b.Property<string>("Prose") b.Property<string>("Prose")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("Setting")
.HasColumnType("TEXT");
b.Property<string>("Status") b.Property<string>("Status")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
@@ -620,6 +632,31 @@ namespace Novelly.Api.Data.Migrations
b.ToTable("ImportJobs"); b.ToTable("ImportJobs");
}); });
modelBuilder.Entity("Novelly.Api.Locations.Location", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("TEXT");
b.Property<Guid>("NovelId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("NovelId", "Name")
.IsUnique();
b.ToTable("Locations");
});
modelBuilder.Entity("Novelly.Api.Novels.Novel", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@@ -901,6 +938,21 @@ namespace Novelly.Api.Data.Migrations
.IsRequired(); .IsRequired();
}); });
modelBuilder.Entity("ChapterLocation", b =>
{
b.HasOne("Novelly.Api.Chapters.Chapter", null)
.WithMany()
.HasForeignKey("ChaptersId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Novelly.Api.Locations.Location", null)
.WithMany()
.HasForeignKey("LocationsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("ChapterTag", b => modelBuilder.Entity("ChapterTag", b =>
{ {
b.HasOne("Novelly.Api.Chapters.Chapter", null) b.HasOne("Novelly.Api.Chapters.Chapter", null)
@@ -1064,6 +1116,17 @@ namespace Novelly.Api.Data.Migrations
b.Navigation("RelatedCharacter"); b.Navigation("RelatedCharacter");
}); });
modelBuilder.Entity("Novelly.Api.Locations.Location", b =>
{
b.HasOne("Novelly.Api.Novels.Novel", "Novel")
.WithMany()
.HasForeignKey("NovelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Novel");
});
modelBuilder.Entity("Novelly.Api.Novels.Novel", b => modelBuilder.Entity("Novelly.Api.Novels.Novel", b =>
{ {
b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") b.HasOne("Novelly.Api.Users.NovellyUser", "Owner")
+3
View File
@@ -7,6 +7,7 @@ using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -24,6 +25,7 @@ public class NovelDbContext(DbContextOptions<NovelDbContext> options) : Identity
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>(); public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
public DbSet<Beat> Beats => Set<Beat>(); public DbSet<Beat> Beats => Set<Beat>();
public DbSet<Tag> Tags => Set<Tag>(); public DbSet<Tag> Tags => Set<Tag>();
public DbSet<Location> Locations => Set<Location>();
public DbSet<Chapter> Chapters => Set<Chapter>(); public DbSet<Chapter> Chapters => Set<Chapter>();
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>(); public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
public DbSet<AgentConversation> Conversations => Set<AgentConversation>(); public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
@@ -51,6 +53,7 @@ public interface INovelDbContext
DbSet<CharacterArcStage> CharacterArcStages { get; } DbSet<CharacterArcStage> CharacterArcStages { get; }
DbSet<Beat> Beats { get; } DbSet<Beat> Beats { get; }
DbSet<Tag> Tags { get; } DbSet<Tag> Tags { get; }
DbSet<Location> Locations { get; }
DbSet<Chapter> Chapters { get; } DbSet<Chapter> Chapters { get; }
DbSet<OpenQuestion> OpenQuestions { get; } DbSet<OpenQuestion> OpenQuestions { get; }
DbSet<AgentConversation> Conversations { get; } DbSet<AgentConversation> Conversations { get; }
+33
View File
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters;
using Novelly.Api.Novels;
namespace Novelly.Api.Locations;
public class Location
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; }
public Novel? Novel { get; set; }
public string Name { get; set; } = string.Empty;
public List<Chapter> Chapters { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
}
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
{
public void Configure(EntityTypeBuilder<Location> entity)
{
entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique();
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
.UsingEntity(join => join.ToTable("ChapterLocations"));
}
}
@@ -0,0 +1,52 @@
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public record LocationResponse(Guid Id, string Name);
public record LocationSummaryResponse(Guid Id, string Name, int ChapterCount);
public record CreateLocationRequest(string Name);
public class CreateLocationRequestValidator : IModelValidator<CreateLocationRequest>
{
public ValidationResult Validate(CreateLocationRequest model)
{
var result = new ValidationResult();
result.AddRequiredTextErrors("Name", "Name", model.Name, 120);
return result;
}
}
public record UpdateLocationRequest(string? Name = null);
public class UpdateLocationRequestValidator : IModelValidator<UpdateLocationRequest>
{
public ValidationResult Validate(UpdateLocationRequest model)
{
var result = new ValidationResult();
result.AddUnclearableTextErrors("Name", "Name", model.Name, "a location", 120);
return result;
}
}
public record LocationReferencesResponse(LocationResponse Location, IReadOnlyList<LocatedChapterResponse> Chapters);
public record LocatedChapterResponse(Guid Id, int Number, string Title, string? Summary);
public static class LocationMapping
{
public static LocationResponse ToResponse(this Location l) => new(l.Id, l.Name);
public static LocationReferencesResponse ToReferencesResponse(this Location location) => new(
location.ToResponse(),
[.. location.Chapters
.OrderBy(c => c.Number)
.Select(c => new LocatedChapterResponse(c.Id, c.Number, c.Title, c.Summary))]);
public static string Normalise(string name) => name.Trim();
}
@@ -0,0 +1,51 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Locations;
public static class LocationEndpoints
{
public static IEndpointRouteBuilder MapLocationEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, LocationService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List a novel's locations with usage counts.");
novelScoped.MapPost("/", async (
Guid novelId, CreateLocationRequest request, LocationService service, CancellationToken ct) =>
{
var location = await service.CreateAsync(novelId, request, ct);
if (location is null)
{
return Results.NotFound();
}
var created = location.ToResponse();
return Results.Created($"/api/locations/{created.Id}", created);
})
.WithSummary("Create a location. Locations are also created on demand when applied by name.");
var locations = app.MapGroup("/api/locations").WithTags("Locations")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
locations.MapGet("/{id:guid}/references", async (Guid id, LocationService service, CancellationToken ct) =>
(await service.GetReferencesAsync(id, ct))?.ToReferencesResponse().ToApiResult())
.WithSummary("Cross-reference: every chapter set at this location.");
locations.MapPatch("/{id:guid}", async (
Guid id, UpdateLocationRequest request, LocationService service, CancellationToken ct) =>
(await service.UpdateAsync(id, request, ct))?.ToResponse().ToApiResult())
.WithSummary("Rename a location.");
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a location. Whatever carried it is left alone.");
return app;
}
}
@@ -0,0 +1,184 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Locations;
public class LocationService(
INovelDbContext db,
NovelAccessService access,
ILogger<LocationService> logger,
IModelValidator<CreateLocationRequest> createValidator,
IModelValidator<UpdateLocationRequest> updateValidator)
{
public async Task<IReadOnlyList<LocationSummaryResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing locations for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
return await db.Locations
.Where(l => l.NovelId == novelId)
.OrderBy(l => l.Name)
.Select(l => new LocationSummaryResponse(l.Id, l.Name, l.Chapters.Count))
.ToListAsync(ct);
}
public async Task<Location?> GetReferencesAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Getting references for location {LocationId}", locationId);
var location = await db.Locations
.Include(l => l.Chapters)
.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return location;
}
await access.RequireAsync(location.NovelId, NovelPermission.Read, ct);
return location;
}
public async Task<Location?> CreateAsync(Guid novelId, CreateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(request, nameof(request));
createValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Creating location {Name} for novel {NovelId}", request.Name, novelId);
if (!await db.Novels.AnyAsync(p => p.Id == novelId, ct))
{
logger.LogWarning("Rejected location creation: novel {NovelId} not found", novelId);
return null;
}
await access.RequireAsync(novelId, NovelPermission.CreateContent, ct);
var name = LocationMapping.Normalise(request.Name);
var existing = await FindByNameAsync(novelId, name, ct);
if (existing is not null)
{
logger.LogWarning("Rejected location creation for novel {NovelId}: '{Name}' already exists", novelId, existing.Name);
throw new InvalidOperationException($"The novel already has a location called '{existing.Name}'.");
}
var location = new Location { NovelId = novelId, Name = name };
db.Locations.Add(location);
await db.SaveChangesAsync(ct);
return location;
}
public async Task<Location?> UpdateAsync(Guid locationId, UpdateLocationRequest request, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
Guard.Null(request, nameof(request));
updateValidator.Validate(request).ThrowIfInvalid(logger);
logger.LogInformation("Updating location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return null;
}
await access.RequireAsync(location.NovelId, NovelPermission.Write, ct);
if (request.Name is not null)
{
var name = LocationMapping.Normalise(request.Name);
var clash = await FindByNameAsync(location.NovelId, name, ct);
if (clash is not null && clash.Id != location.Id)
{
logger.LogWarning("Rejected update for location {LocationId}: '{Name}' already exists as {ClashLocationId}", locationId, clash.Name, clash.Id);
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
}
location.Name = name;
}
await db.SaveChangesAsync(ct);
return location;
}
public async Task<bool> DeleteAsync(Guid locationId, CancellationToken ct = default)
{
Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Deleting location {LocationId}", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null)
{
logger.LogWarning("Location {LocationId} not found", locationId);
return false;
}
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
db.Locations.Remove(location);
await db.SaveChangesAsync(ct);
return true;
}
internal async Task<List<Location>> ResolveAsync(
Guid novelId, IReadOnlyList<string> names, CancellationToken ct)
{
Guard.Default(novelId, nameof(novelId));
Guard.Null(names, nameof(names));
logger.LogDebug("Resolving {Count} location names for novel {NovelId}", names.Count, novelId);
var wanted = names
.Select(LocationMapping.Normalise)
.Where(n => !string.IsNullOrWhiteSpace(n))
.DistinctBy(n => n.ToLowerInvariant())
.ToList();
if (wanted.Count == 0)
{
logger.LogDebug("No usable location names for novel {NovelId}", novelId);
return [];
}
var existing = await db.Locations
.Where(l => l.NovelId == novelId)
.ToListAsync(ct);
var resolved = new List<Location>();
foreach (var name in wanted)
{
var match = existing.FirstOrDefault(
l => string.Equals(l.Name, name, StringComparison.OrdinalIgnoreCase));
if (match is null)
{
match = new Location { NovelId = novelId, Name = name };
db.Locations.Add(match);
existing.Add(match);
}
resolved.Add(match);
}
logger.LogDebug("Resolved {Count} locations for novel {NovelId}", resolved.Count, novelId);
return resolved;
}
private async Task<Location?> FindByNameAsync(Guid novelId, string name, CancellationToken ct) =>
await db.Locations.FirstOrDefaultAsync(
l => l.NovelId == novelId && EF.Functions.Like(l.Name, name), ct);
}
+2
View File
@@ -10,6 +10,7 @@ using Novelly.Api.Common;
using Novelly.Api.Data; using Novelly.Api.Data;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Imports; using Novelly.Api.Imports;
using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -95,6 +96,7 @@ app.MapNovelEndpoints()
.MapChapterEndpoints() .MapChapterEndpoints()
.MapBeatEndpoints() .MapBeatEndpoints()
.MapTagEndpoints() .MapTagEndpoints()
.MapLocationEndpoints()
.MapGenreEndpoints() .MapGenreEndpoints()
.MapOpenQuestionEndpoints() .MapOpenQuestionEndpoints()
.MapAgentEndpoints() .MapAgentEndpoints()
+53
View File
@@ -0,0 +1,53 @@
using System.ComponentModel;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Novelly.Mcp.Tools;
[McpServerToolType]
public static class LocationTools
{
[McpServerTool(Name = "list_locations")]
[Description("List a novel's locations with how many chapters are set there. "
+ "Read this before inventing a new location so you reuse the writer's vocabulary.")]
public static Task<CallToolResult> ListLocations(
NovelApiClient api,
[Description("The novel's id.")] Guid novelId,
CancellationToken ct) =>
api.GetAsync($"/api/novels/{novelId}/locations", ct);
[McpServerTool(Name = "get_location_references")]
[Description("Cross-reference a location: every chapter set there.")]
public static Task<CallToolResult> GetLocationReferences(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
CancellationToken ct) =>
api.GetAsync($"/api/locations/{locationId}/references", ct);
[McpServerTool(Name = "create_location")]
[Description("Create a location explicitly. Applying an unknown location by name to a chapter "
+ "also creates it, so this is only needed to set one up ahead of time.")]
public static Task<CallToolResult> CreateLocation(
NovelApiClient api,
[Description("The novel's id.")] Guid novelId,
[Description("The location's name. Unique within the novel, matched case-insensitively.")] string name,
CancellationToken ct) =>
api.PostAsync($"/api/novels/{novelId}/locations", new { name }, ct);
[McpServerTool(Name = "update_location")]
[Description("Rename a location. Renaming updates it everywhere it is applied.")]
public static Task<CallToolResult> UpdateLocation(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
[Description("New name.")] string name,
CancellationToken ct) =>
api.PatchAsync($"/api/locations/{locationId}", new { name }, ct);
[McpServerTool(Name = "delete_location")]
[Description("Delete a location. Whatever carried it is left alone — only the label goes.")]
public static Task<CallToolResult> DeleteLocation(
NovelApiClient api,
[Description("The location's id.")] Guid locationId,
CancellationToken ct) =>
api.DeleteAsync($"/api/locations/{locationId}", ct);
}
+5 -5
View File
@@ -32,7 +32,7 @@ public static class ManuscriptTools
CancellationToken ct, CancellationToken ct,
[Description("Position in the manuscript, 1-based.")] int? number = null, [Description("Position in the manuscript, 1-based.")] int? number = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place. Unknown locations are created.")] string[]? locations = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null, [Description("The chapter's drafted text, in markdown, if you are writing it now.")] string? prose = null,
@@ -42,7 +42,7 @@ public static class ManuscriptTools
title, title,
number, number,
summary, summary,
setting, locations,
status = status ?? "Planned", status = status ?? "Planned",
targetWordCount, targetWordCount,
prose, prose,
@@ -50,7 +50,7 @@ public static class ManuscriptTools
}, ct); }, ct);
[McpServerTool(Name = "update_chapter")] [McpServerTool(Name = "update_chapter")]
[Description("Revise a chapter's title, number, summary, setting, notes, status " [Description("Revise a chapter's title, number, summary, locations, notes, status "
+ "or drafted prose. Use 'prose' to write or replace the chapter's draft text in " + "or drafted prose. Use 'prose' to write or replace the chapter's draft text in "
+ "markdown; the word count is recomputed automatically.")] + "markdown; the word count is recomputed automatically.")]
public static Task<CallToolResult> UpdateChapter( public static Task<CallToolResult> UpdateChapter(
@@ -60,12 +60,12 @@ public static class ManuscriptTools
[Description("New title.")] string? title = null, [Description("New title.")] string? title = null,
[Description("Position in the manuscript.")] int? number = null, [Description("Position in the manuscript.")] int? number = null,
[Description("The chapter's outline summary paragraph.")] string? summary = null, [Description("The chapter's outline summary paragraph.")] string? summary = null,
[Description("Where and when the chapter takes place.")] string? setting = null, [Description("Where and when the chapter takes place. Replaces the existing locations. Unknown locations are created.")] string[]? locations = null,
[Description("Anything else worth recording.")] string? notes = null, [Description("Anything else worth recording.")] string? notes = null,
[Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Planned, Outlined, Drafted, Revised or Final.")] string? status = null,
[Description("Target length in words.")] int? targetWordCount = null, [Description("Target length in words.")] int? targetWordCount = null,
[Description("The chapter's drafted text, in markdown.")] string? prose = null, [Description("The chapter's drafted text, in markdown.")] string? prose = null,
[Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) =>
api.PatchAsync($"/api/chapters/{chapterId}", api.PatchAsync($"/api/chapters/{chapterId}",
new { title, number, summary, setting, notes, status, targetWordCount, prose, tags }, ct); new { title, number, summary, locations, notes, status, targetWordCount, prose, tags }, ct);
} }
+2
View File
@@ -5,6 +5,7 @@ import DashboardPage from './pages/DashboardPage'
import CharactersPage from './pages/CharactersPage' import CharactersPage from './pages/CharactersPage'
import CharacterDetailPage from './pages/CharacterDetailPage' import CharacterDetailPage from './pages/CharacterDetailPage'
import TagsPage from './pages/TagsPage' import TagsPage from './pages/TagsPage'
import LocationsPage from './pages/LocationsPage'
import ChaptersPage from './pages/ChaptersPage' import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage' import ChapterPage from './pages/ChapterPage'
import AgentPage from './pages/AgentPage' import AgentPage from './pages/AgentPage'
@@ -42,6 +43,7 @@ export default function App() {
<Route path="chapters" element={<ChaptersPage />} /> <Route path="chapters" element={<ChaptersPage />} />
<Route path="chapters/:chapterId" element={<ChapterPage />} /> <Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} /> <Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} />
<Route path="agent" element={<AgentPage />} /> <Route path="agent" element={<AgentPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
+44 -4
View File
@@ -15,6 +15,8 @@ import type {
ImportJob, ImportJob,
ImportJobStatus, ImportJobStatus,
OpenQuestion, OpenQuestion,
LocationReferences,
LocationSummary,
Novel, Novel,
NovelMember, NovelMember,
NovelRole, NovelRole,
@@ -33,6 +35,8 @@ export const keys = {
characters: (novelId: string) => ['novels', novelId, 'characters'] as const, characters: (novelId: string) => ['novels', novelId, 'characters'] as const,
tags: (novelId: string) => ['novels', novelId, 'tags'] as const, tags: (novelId: string) => ['novels', novelId, 'tags'] as const,
tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const, tagRefs: (tagId: string) => ['tags', tagId, 'references'] as const,
locations: (novelId: string) => ['novels', novelId, 'locations'] as const,
locationRefs: (locationId: string) => ['locations', locationId, 'references'] as const,
characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const, characterBeats: (characterId: string) => ['characters', characterId, 'beats'] as const,
chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const, chapters: (novelId: string) => ['novels', novelId, 'chapters'] as const,
questions: (novelId: string) => ['novels', novelId, 'questions'] as const, questions: (novelId: string) => ['novels', novelId, 'questions'] as const,
@@ -392,6 +396,39 @@ export function useDeleteTag() {
}) })
} }
export const useLocations = (novelId: string) =>
useQuery({
queryKey: keys.locations(novelId),
queryFn: () => api.get<LocationSummary[]>(`/api/novels/${novelId}/locations`),
})
export const useLocationReferences = (locationId: string | undefined) =>
useQuery({
queryKey: keys.locationRefs(locationId ?? ''),
queryFn: () => api.get<LocationReferences>(`/api/locations/${locationId}/references`),
enabled: Boolean(locationId),
})
export function useUpdateLocation(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, ...body }: { id: string; name?: string }) =>
api.patch<LocationSummary>(`/api/locations/${id}`, body),
onSuccess: (_, { id }) => {
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
qc.invalidateQueries({ queryKey: keys.locationRefs(id) })
},
})
}
export function useDeleteLocation() {
const qc = useQueryClient()
return useMutation({
mutationFn: (id: string) => api.delete(`/api/locations/${id}`),
onSuccess: () => qc.invalidateQueries(),
})
}
export function useCreateBeat(chapterId: string, novelId: string) { export function useCreateBeat(chapterId: string, novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -474,8 +511,9 @@ export const useChapter = (id: string | undefined) =>
export function useCreateChapter(novelId: string) { export function useCreateChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: (body: Partial<Chapter> & { title: string }) => mutationFn: (
api.post<Chapter>(`/api/novels/${novelId}/chapters`, body), body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { title: string; tags?: string[]; locations?: string[] },
) => api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }), onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }),
}) })
} }
@@ -483,12 +521,14 @@ export function useCreateChapter(novelId: string) {
export function useUpdateChapter(novelId: string) { export function useUpdateChapter(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) => mutationFn: (
api.patch<Chapter>(`/api/chapters/${id}`, body), { id, ...body }: Partial<Omit<Chapter, 'tags' | 'locations'>> & { id: string; tags?: string[]; locations?: string[] },
) => api.patch<Chapter>(`/api/chapters/${id}`, body),
onSuccess: (updated) => { onSuccess: (updated) => {
qc.setQueryData(keys.chapter(updated.id), updated) qc.setQueryData(keys.chapter(updated.id), updated)
qc.invalidateQueries({ queryKey: keys.chapters(novelId) }) qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.tags(novelId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) })
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
}, },
}) })
} }
+15 -1
View File
@@ -121,6 +121,20 @@ export interface TagReferences {
}[] }[]
} }
export interface Location {
id: string
name: string
}
export interface LocationSummary extends Location {
chapterCount: number
}
export interface LocationReferences {
location: Location
chapters: { id: string; number: number; title: string; summary: string | null }[]
}
export interface BeatCharacter { export interface BeatCharacter {
id: string id: string
name: string name: string
@@ -214,7 +228,7 @@ export interface ChapterSummary {
number: number number: number
title: string title: string
summary: string | null summary: string | null
setting: string | null locations: Location[]
status: DraftStatus status: DraftStatus
targetWordCount: number | null targetWordCount: number | null
beatCount: number beatCount: number
@@ -0,0 +1,112 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
import type { Location } from '../api/types'
export function LocationChip({
location,
novelId,
onRemove,
}: {
location: Location
novelId?: string
onRemove?: () => void
}) {
return (
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
style={{ color: 'var(--accent)', background: 'color-mix(in srgb, var(--accent) 14%, transparent)' }}
>
{novelId ? (
<Link to={`/novels/${novelId}/locations/${location.id}`} className="hover:underline">
{location.name}
</Link>
) : (
location.name
)}
{onRemove && (
<button
type="button"
onClick={onRemove}
className="opacity-60 transition hover:opacity-100"
aria-label={`Remove location ${location.name}`}
>
</button>
)}
</span>
)
}
export function LocationEditor({
id,
locations,
suggestions = [],
onChange,
label,
novelId,
readOnly = false,
}: {
id?: string
locations: Location[]
suggestions?: string[]
onChange: (names: string[]) => void
label?: string
novelId?: string
readOnly?: boolean
}) {
const [draft, setDraft] = useState('')
const listId = `location-suggestions-${label ?? 'default'}`
const add = () => {
const name = draft.trim()
if (!name) return
if (!locations.some((l) => l.name.toLowerCase() === name.toLowerCase())) {
onChange([...locations.map((l) => l.name), name])
}
setDraft('')
}
const remove = (name: string) =>
onChange(locations.filter((l) => l.name !== name).map((l) => l.name))
const unused = suggestions.filter(
(s) => !locations.some((l) => l.name.toLowerCase() === s.toLowerCase()),
)
return (
<div id={id}>
{label && <span className="label">{label}</span>}
<div className="flex flex-wrap items-center gap-1.5">
{locations.map((location) => (
<LocationChip
key={location.id}
location={location}
novelId={novelId}
onRemove={readOnly ? undefined : () => remove(location.name)}
/>
))}
{!readOnly && (
<input
className="input w-32 flex-1 px-2 py-0.5 text-xs"
value={draft}
list={listId}
placeholder="Add location…"
onChange={(e) => setDraft(e.target.value)}
onBlur={add}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault()
add()
}
}}
/>
)}
<datalist id={listId}>
{unused.map((name) => (
<option key={name} value={name} />
))}
</datalist>
</div>
</div>
)
}
+30 -9
View File
@@ -9,6 +9,7 @@ import {
useCreateChapter, useCreateChapter,
useDeleteBeat, useDeleteBeat,
useDeleteChapter, useDeleteChapter,
useLocations,
useMoveBeats, useMoveBeats,
useNovel, useNovel,
useReorderBeats, useReorderBeats,
@@ -21,6 +22,7 @@ import { useAuth } from '../auth/AuthContext'
import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
import { TagChip, TagEditor } from '../components/TagEditor' import { TagChip, TagEditor } from '../components/TagEditor'
import { LocationEditor } from '../components/LocationEditor'
import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect' import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect'
import { useCharacterContextMenu } from '../components/CharacterContextMenu' import { useCharacterContextMenu } from '../components/CharacterContextMenu'
import { MarkdownEditor } from '../components/MarkdownEditor' import { MarkdownEditor } from '../components/MarkdownEditor'
@@ -36,6 +38,7 @@ export default function ChapterPage() {
const { data: novel } = useNovel(novelId) const { data: novel } = useNovel(novelId)
const { data: characters } = useCharacters(novelId) const { data: characters } = useCharacters(novelId)
const { data: allTags } = useTags(novelId) const { data: allTags } = useTags(novelId)
const { data: allLocations } = useLocations(novelId)
const { data: chapters } = useChapters(novelId) const { data: chapters } = useChapters(novelId)
const createChapter = useCreateChapter(novelId) const createChapter = useCreateChapter(novelId)
const update = useUpdateChapter(novelId) const update = useUpdateChapter(novelId)
@@ -73,13 +76,15 @@ export default function ChapterPage() {
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
if (!chapter) return null if (!chapter) return null
const patch = (body: Partial<Omit<Chapter, 'tags'>> & { tags?: string[] }) => const patch = (body: Partial<Omit<Chapter, 'tags' | 'locations'>> & { tags?: string[]; locations?: string[] }) =>
update.mutate({ id: chapter.id, ...body }) update.mutate({ id: chapter.id, ...body })
const chapterCharacters = [...new Map(chapter.beats.flatMap((b) => b.characters).map((c) => [c.id, c])).values()].sort(
(a, b) => a.name.localeCompare(b.name),
)
const suggestions = allTags?.map((t) => t.name) ?? [] const suggestions = allTags?.map((t) => t.name) ?? []
const settingSuggestions = [ const locationSuggestions = allLocations?.map((l) => l.name) ?? []
...new Set((chapters ?? []).map((c) => c.setting).filter((s): s is string => Boolean(s?.trim()))),
].sort()
return ( return (
<div> <div>
@@ -171,11 +176,13 @@ export default function ChapterPage() {
</div> </div>
<div className="mt-4"> <div className="mt-4">
<AutoField <LocationEditor
label="Setting" id="chapter-locations"
value={chapter.setting} label="Locations"
onCommit={(setting) => patch({ setting })} locations={chapter.locations}
suggestions={settingSuggestions} suggestions={locationSuggestions}
novelId={novelId}
onChange={(locations) => canWrite && patch({ locations })}
readOnly={!canWrite} readOnly={!canWrite}
/> />
</div> </div>
@@ -199,6 +206,20 @@ export default function ChapterPage() {
</button> </button>
)} )}
</div> </div>
{chapterCharacters.length > 0 && (
<div className="mt-2 text-sm muted" id="chapter-outline-characters">
Characters:{' '}
{chapterCharacters.map((c, i) => (
<span key={c.id}>
{i > 0 && ', '}
<Link to={`/novels/${novelId}/characters/${c.id}`} className="hover:underline">
{c.name}
</Link>
</span>
))}
</div>
)}
</section> </section>
)} )}
+164
View File
@@ -0,0 +1,164 @@
import { useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks'
import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal'
export default function LocationsPage() {
const { novelId = '' } = useParams()
const { data: locations, isPending, error } = useLocations(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canWrite = can('Write', novel)
const canDelete = can('DeleteContent', novel)
const [searchParams, setSearchParams] = useSearchParams()
const selectedId = searchParams.get('location') ?? undefined
if (isPending) return <Spinner label="Loading locations" />
if (error) return <ErrorNote error={error} />
const selected = locations?.find((l) => l.id === selectedId) ?? locations?.[0]
const select = (id: string) =>
setSearchParams((params) => {
params.set('location', id)
return params
})
return (
<div id="locations-page" className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<aside className="grid content-start gap-2">
<div>
<h2 className="text-lg font-semibold">Locations</h2>
<p className="text-sm muted">
Applied from a chapter. Pick one to see every chapter set there.
</p>
</div>
{locations?.length === 0 && (
<p className="mt-2 text-sm muted">
No locations yet. Add one from a chapter and it will appear here.
</p>
)}
{locations?.map((location) => (
<button
key={location.id}
onClick={() => select(location.id)}
className="card flex items-center justify-between gap-2 px-3 py-2 text-left transition hover:shadow-sm"
style={
location.id === selected?.id
? { borderColor: 'var(--accent)', background: 'var(--accent-soft)' }
: undefined
}
>
<span>{location.name}</span>
<span className="text-xs muted">{location.chapterCount}</span>
</button>
))}
</aside>
<section>
{!selected ? (
<EmptyState
title="No locations yet"
hint="Locations cross-reference the book: attach one to a chapter, then trace it from here."
/>
) : (
<LocationReferencePanel
key={selected.id}
novelId={novelId}
locationId={selected.id}
canWrite={canWrite}
canDelete={canDelete}
/>
)}
</section>
</div>
)
}
function LocationReferencePanel({
novelId,
locationId,
canWrite,
canDelete,
}: {
novelId: string
locationId: string
canWrite: boolean
canDelete: boolean
}) {
const { data, isPending, error } = useLocationReferences(locationId)
const update = useUpdateLocation(novelId)
const remove = useDeleteLocation()
const [confirmingDelete, setConfirmingDelete] = useState(false)
if (isPending) return <Spinner label="Loading references" />
if (error) return <ErrorNote error={error} />
if (!data) return null
const empty = data.chapters.length === 0
return (
<div className="grid gap-4">
<div className="card flex flex-wrap items-end justify-between gap-3 p-4">
<label className="block">
<span className="label">Location name</span>
<input
className="input w-64"
defaultValue={data.location.name}
readOnly={!canWrite}
onBlur={(e) => {
const name = e.target.value.trim()
if (name && name !== data.location.name) update.mutate({ id: locationId, name })
}}
/>
</label>
{canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete location
</button>
)}
</div>
{update.error && <ErrorNote error={update.error} />}
{confirmingDelete && (
<ConfirmModal
title="Delete location"
message={`Delete the location "${data.location.name}"? What carries it is left alone.`}
onConfirm={() => remove.mutate(locationId)}
onClose={() => setConfirmingDelete(false)}
/>
)}
{empty && (
<EmptyState
title="No chapters set here"
hint="Apply this location to a chapter and it will show up here."
/>
)}
{data.chapters.length > 0 && (
<div className="card p-4">
<h3 className="label">Chapters</h3>
<ul className="grid gap-1 text-sm">
{data.chapters.map((c) => (
<li key={c.id}>
<Link
to={`/novels/${novelId}/chapters/${c.id}`}
className="font-medium hover:underline"
>
{c.number}. {c.title}
</Link>
{c.summary && <span className="muted"> {c.summary}</span>}
</li>
))}
</ul>
</div>
)}
</div>
)
}
@@ -11,6 +11,7 @@ const sections: { to: string; label: string; end?: boolean }[] = [
{ to: 'chapters', label: 'Chapters' }, { to: 'chapters', label: 'Chapters' },
{ to: 'characters', label: 'Characters' }, { to: 'characters', label: 'Characters' },
{ to: 'tags', label: 'Tags' }, { to: 'tags', label: 'Tags' },
{ to: 'locations', label: 'Locations' },
{ to: 'agent', label: 'Agent' }, { to: 'agent', label: 'Agent' },
{ to: 'settings', label: 'Settings' }, { to: 'settings', label: 'Settings' },
] ]
@@ -30,6 +31,7 @@ export default function NovelLayout() {
useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' }) useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' })
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' }) useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' }) useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' }) useHotkey('g a', 'Go to agent', () => goTo('agent'), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' }) useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
+1 -1
View File
@@ -85,7 +85,7 @@ public class ListingTests : ServiceTestFixture
var agent = new NovelAgentService( var agent = new NovelAgentService(
Db.Context, Db.Context,
new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), new ScriptedModelClient([[new AgentTextBlock("Reply.")]]),
new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance), new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance),
Options.Create(new AgentOptions()), Options.Create(new AgentOptions()),
NullLogger<NovelAgentService>.Instance, NullLogger<NovelAgentService>.Instance,
new SendAgentMessageRequestValidator()); new SendAgentMessageRequestValidator());
@@ -0,0 +1,174 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Locations;
using Novelly.Api.Novels;
namespace Novelly.Api.Tests;
[TestFixture]
public class LocationServiceTests : ServiceTestFixture
{
private Guid _novelId;
protected override void OnSetUp() =>
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test]
public async Task Applying_an_unknown_location_by_name_creates_it()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour", "the wreck"]));
Assert.Multiple(async () =>
{
Assert.That(
chapter!.Locations.Select(l => l.Name),
Is.EquivalentTo(new[] { "the harbour", "the wreck" }));
Assert.That(await Locations.ListAsync(_novelId), Has.Count.EqualTo(2));
});
}
[Test]
public async Task The_same_name_resolves_to_one_location_regardless_of_casing()
{
var first = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["The Harbour"]));
var second = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Departure", Locations: ["the harbour"]));
var listed = await Locations.ListAsync(_novelId);
Assert.Multiple(() =>
{
Assert.That(listed, Has.Count.EqualTo(1));
Assert.That(listed[0].Name, Is.EqualTo("The Harbour"));
Assert.That(first!.Locations, Has.Count.EqualTo(1));
Assert.That(second!.Locations[0].Id, Is.EqualTo(first.Locations[0].Id));
});
}
[Test]
public async Task Supplying_a_location_list_replaces_the_existing_locations()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour", "the wreck"]));
var updated = (await Chapters.UpdateAsync(
chapter!.Id, new UpdateChapterRequest(Locations: ["the wreck", "the cliffs"])))!;
Assert.That(updated.Locations.Select(l => l.Name), Is.EquivalentTo(new[] { "the wreck", "the cliffs" }));
}
[Test]
public async Task Omitting_the_location_list_leaves_locations_alone()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
var updated = (await Chapters.UpdateAsync(
chapter!.Id, new UpdateChapterRequest(Summary: "Ships come in.")))!;
Assert.Multiple(() =>
{
Assert.That(updated.Locations, Has.Count.EqualTo(1));
Assert.That(updated.Locations[0].Name, Is.EqualTo("the harbour"));
Assert.That(updated.Summary, Is.EqualTo("Ships come in."));
});
}
[Test]
public async Task Cross_reference_gathers_every_chapter_at_a_location()
{
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Unrelated chapter"));
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
var references = (await Locations.GetReferencesAsync(locationId))!;
Assert.Multiple(() =>
{
Assert.That(references.Chapters, Has.Count.EqualTo(1));
Assert.That(references.Chapters[0].Title, Is.EqualTo("Landfall"));
});
}
[Test]
public async Task Usage_counts_are_reported()
{
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Departure", Locations: ["the harbour"]));
var summary = (await Locations.ListAsync(_novelId)).Single();
Assert.That(summary.ChapterCount, Is.EqualTo(2));
}
[Test]
public async Task Duplicate_location_names_are_refused_on_create_and_rename()
{
await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
Assert.That(
async () => await Locations.CreateAsync(_novelId, new CreateLocationRequest("The Harbour")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
var other = await Locations.CreateAsync(_novelId, new CreateLocationRequest("the wreck"));
Assert.That(
async () => await Locations.UpdateAsync(other!.Id, new UpdateLocationRequest(Name: "the harbour")),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
}
[Test]
public async Task Locations_are_scoped_to_their_novel()
{
var otherNovel = await Novels.CreateAsync(new CreateNovelRequest("Other Book"));
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
await Chapters.CreateAsync(otherNovel.Id, new CreateChapterRequest("Somewhere", Locations: ["the harbour"]));
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await Locations.ListAsync(_novelId), Has.Count.EqualTo(1));
Assert.That(await Locations.ListAsync(otherNovel.Id), Has.Count.EqualTo(1));
Assert.That(await verification.Locations.CountAsync(), Is.EqualTo(2));
});
}
[Test]
public async Task Deleting_a_location_leaves_the_chapter_intact()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
await Locations.DeleteAsync(locationId);
var survivor = (await Chapters.GetAsync(chapter!.Id))!;
Assert.Multiple(() =>
{
Assert.That(survivor.Title, Is.EqualTo("Landfall"));
Assert.That(survivor.Locations, Is.Empty);
});
}
[Test]
public async Task Deleting_a_novel_takes_its_locations()
{
await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
await Novels.DeleteAsync(_novelId);
using var verification = Db.CreateContext();
Assert.That(await verification.Locations.CountAsync(), Is.EqualTo(0));
}
[Test]
public void A_blank_location_name_is_refused() =>
Assert.That(
async () => await Locations.CreateAsync(_novelId, new CreateLocationRequest(" ")),
Throws.TypeOf<ArgumentException>());
}
@@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture
private NovelAgentToolset _toolset = null!; private NovelAgentToolset _toolset = null!;
protected override void OnSetUp() => protected override void OnSetUp() =>
_toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger<NovelAgentToolset>.Instance); _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger<NovelAgentToolset>.Instance);
private NovelAgentService BuildAgent(ScriptedModelClient model) => new( private NovelAgentService BuildAgent(ScriptedModelClient model) => new(
Db.Context, Db.Context,
@@ -2,6 +2,7 @@ using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Characters; using Novelly.Api.Characters;
using Novelly.Api.Genres; using Novelly.Api.Genres;
using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
@@ -15,6 +16,7 @@ public abstract class ServiceTestFixture
protected TestUserContext UserContext { get; private set; } = null!; protected TestUserContext UserContext { get; private set; } = null!;
protected NovelAccessService Access { get; private set; } = null!; protected NovelAccessService Access { get; private set; } = null!;
protected TagService Tags { get; private set; } = null!; protected TagService Tags { get; private set; } = null!;
protected LocationService Locations { get; private set; } = null!;
protected NovelService Novels { get; private set; } = null!; protected NovelService Novels { get; private set; } = null!;
protected CharacterService Characters { get; private set; } = null!; protected CharacterService Characters { get; private set; } = null!;
protected ChapterService Chapters { get; private set; } = null!; protected ChapterService Chapters { get; private set; } = null!;
@@ -28,6 +30,7 @@ public abstract class ServiceTestFixture
protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!; protected CapturingLogger<ChapterService> ChapterLogs { get; private set; } = null!;
protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!; protected CapturingLogger<BeatService> BeatLogs { get; private set; } = null!;
protected CapturingLogger<TagService> TagLogs { get; private set; } = null!; protected CapturingLogger<TagService> TagLogs { get; private set; } = null!;
protected CapturingLogger<LocationService> LocationLogs { get; private set; } = null!;
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!; protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!; protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!; protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
@@ -50,6 +53,7 @@ public abstract class ServiceTestFixture
Db.Context.SaveChanges(); Db.Context.SaveChanges();
TagLogs = new CapturingLogger<TagService>(); TagLogs = new CapturingLogger<TagService>();
LocationLogs = new CapturingLogger<LocationService>();
NovelLogs = new CapturingLogger<NovelService>(); NovelLogs = new CapturingLogger<NovelService>();
CharacterLogs = new CapturingLogger<CharacterService>(); CharacterLogs = new CapturingLogger<CharacterService>();
ChapterLogs = new CapturingLogger<ChapterService>(); ChapterLogs = new CapturingLogger<ChapterService>();
@@ -59,13 +63,14 @@ public abstract class ServiceTestFixture
GenreLogs = new CapturingLogger<GenreService>(); GenreLogs = new CapturingLogger<GenreService>();
Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); Tags = new TagService(Db.Context, Access, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Locations = new LocationService(Db.Context, Access, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
Novels = new NovelService( Novels = new NovelService(
Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator()); Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator());
Characters = new CharacterService( Characters = new CharacterService(
Db.Context, Access, Tags, CharacterLogs, Db.Context, Access, Tags, CharacterLogs,
new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(),
new LinkCharacterIdentityRequestValidator()); new LinkCharacterIdentityRequestValidator());
Chapters = new ChapterService(Db.Context, Access, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator()); Chapters = new ChapterService(Db.Context, Access, Tags, Locations, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator());
Beats = new BeatService( Beats = new BeatService(
Db.Context, Access, Tags, BeatLogs, Db.Context, Access, Tags, BeatLogs,
new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(), new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),