From c620ddd6266339f7b59c91aa0098ed81a4b05152 Mon Sep 17 00:00:00 2001 From: James Wampler Date: Tue, 18 Aug 2026 11:36:13 -0700 Subject: [PATCH] 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. --- src/Novelly.Api/Agent/NovelAgentToolset.cs | 31 +- src/Novelly.Api/Chapters/Chapter.cs | 3 +- src/Novelly.Api/Chapters/ChapterContracts.cs | 25 +- src/Novelly.Api/Chapters/ChapterService.cs | 16 +- .../Common/NovellyServiceRegistration.cs | 2 + ...80738_RenameSettingToLocations.Designer.cs | 1232 +++++++++++++++++ ...20260818180738_RenameSettingToLocations.cs | 90 ++ .../Migrations/NovelDbContextModelSnapshot.cs | 69 +- src/Novelly.Api/Data/NovelDbContext.cs | 3 + src/Novelly.Api/Locations/Location.cs | 33 + .../Locations/LocationContracts.cs | 52 + .../Locations/LocationEndpoints.cs | 51 + src/Novelly.Api/Locations/LocationService.cs | 184 +++ src/Novelly.Api/Program.cs | 2 + src/Novelly.Mcp/Tools/LocationTools.cs | 53 + src/Novelly.Mcp/Tools/ManuscriptTools.cs | 10 +- src/Novelly.Web/src/App.tsx | 2 + src/Novelly.Web/src/api/hooks.ts | 48 +- src/Novelly.Web/src/api/types.ts | 16 +- .../src/components/LocationEditor.tsx | 112 ++ src/Novelly.Web/src/pages/ChapterPage.tsx | 39 +- src/Novelly.Web/src/pages/LocationsPage.tsx | 164 +++ src/Novelly.Web/src/pages/NovelLayout.tsx | 2 + tests/Novelly.Api.Tests/ListingTests.cs | 2 +- .../Novelly.Api.Tests/LocationServiceTests.cs | 174 +++ .../NovelAgentServiceTests.cs | 2 +- tests/Novelly.Api.Tests/ServiceTestFixture.cs | 7 +- 27 files changed, 2380 insertions(+), 44 deletions(-) create mode 100644 src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.Designer.cs create mode 100644 src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.cs create mode 100644 src/Novelly.Api/Locations/Location.cs create mode 100644 src/Novelly.Api/Locations/LocationContracts.cs create mode 100644 src/Novelly.Api/Locations/LocationEndpoints.cs create mode 100644 src/Novelly.Api/Locations/LocationService.cs create mode 100644 src/Novelly.Mcp/Tools/LocationTools.cs create mode 100644 src/Novelly.Web/src/components/LocationEditor.tsx create mode 100644 src/Novelly.Web/src/pages/LocationsPage.tsx create mode 100644 tests/Novelly.Api.Tests/LocationServiceTests.cs diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 084c15f..85c66c5 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -3,6 +3,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Common; +using Novelly.Api.Locations; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -29,6 +30,7 @@ public class NovelAgentToolset( ChapterService chapters, BeatService beats, TagService tags, + LocationService locations, OpenQuestionService questions, ILogger logger) { @@ -366,6 +368,25 @@ public class NovelAgentToolset( 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( "list_chapters", "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) .Int("number", "Position in the manuscript, 1-based.") .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.") .Enum("status", "Drafting status.", System.Enum.GetNames()) .Int("target_word_count", "Target length in words.") @@ -402,7 +423,7 @@ public class NovelAgentToolset( JsonInput.RequiredString(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), - JsonInput.String(input, "setting"), + JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status") ?? DraftStatus.Planned, JsonInput.Int(input, "target_word_count"), @@ -411,7 +432,7 @@ public class NovelAgentToolset( yield return new AgentTool( "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 " + "word count is recomputed automatically.", new JsonSchemaBuilder() @@ -419,7 +440,7 @@ public class NovelAgentToolset( .Str("title", "New title.") .Int("number", "Position in the manuscript.") .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.") .Enum("status", "Drafting status.", System.Enum.GetNames()) .Int("target_word_count", "Target length in words.") @@ -435,7 +456,7 @@ public class NovelAgentToolset( JsonInput.String(input, "title"), JsonInput.Int(input, "number"), JsonInput.String(input, "summary"), - JsonInput.String(input, "setting"), + JsonInput.Strings(input, "locations"), JsonInput.String(input, "notes"), JsonInput.Enum(input, "status"), JsonInput.Int(input, "target_word_count"), diff --git a/src/Novelly.Api/Chapters/Chapter.cs b/src/Novelly.Api/Chapters/Chapter.cs index 7cc495d..db0d58a 100644 --- a/src/Novelly.Api/Chapters/Chapter.cs +++ b/src/Novelly.Api/Chapters/Chapter.cs @@ -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 Beats { get; set; } = []; public List Tags { get; set; } = []; + public List Locations { get; set; } = []; } public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration diff --git a/src/Novelly.Api/Chapters/ChapterContracts.cs b/src/Novelly.Api/Chapters/ChapterContracts.cs index 0921268..6b4c7f6 100644 --- a/src/Novelly.Api/Chapters/ChapterContracts.cs +++ b/src/Novelly.Api/Chapters/ChapterContracts.cs @@ -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 Locations, DraftStatus Status, int? TargetWordCount, int BeatCount, @@ -25,7 +26,7 @@ public record ChapterResponse( int Number, string Title, string? Summary, - string? Setting, + IReadOnlyList 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? Locations = null, string? Notes = null, DraftStatus Status = DraftStatus.Planned, int? TargetWordCount = null, @@ -53,7 +54,7 @@ public class CreateChapterRequestValidator : IModelValidator? Locations = null, string? Notes = null, DraftStatus? Status = null, int? TargetWordCount = null, @@ -77,7 +78,7 @@ public class UpdateChapterRequestValidator : IModelValidator? locations, string? notes, int? targetWordCount, string? prose, IReadOnlyList? 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); diff --git a/src/Novelly.Api/Chapters/ChapterService.cs b/src/Novelly.Api/Chapters/ChapterService.cs index 8077b9b..9fb003b 100644 --- a/src/Novelly.Api/Chapters/ChapterService.cs +++ b/src/Novelly.Api/Chapters/ChapterService.cs @@ -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 logger, IModelValidator createValidator, IModelValidator 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) diff --git a/src/Novelly.Api/Common/NovellyServiceRegistration.cs b/src/Novelly.Api/Common/NovellyServiceRegistration.cs index 5e8fff0..9af1603 100644 --- a/src/Novelly.Api/Common/NovellyServiceRegistration.cs +++ b/src/Novelly.Api/Common/NovellyServiceRegistration.cs @@ -13,6 +13,7 @@ using Novelly.Api.Common.Validation; using Novelly.Api.Data; using Novelly.Api.Genres; using Novelly.Api.Imports; +using Novelly.Api.Locations; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -77,6 +78,7 @@ public static class NovellyServiceRegistration services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); diff --git a/src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.Designer.cs b/src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.Designer.cs new file mode 100644 index 0000000..b8cd3ac --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.Designer.cs @@ -0,0 +1,1232 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Novelly.Api.Data; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + [DbContext(typeof(NovelDbContext))] + [Migration("20260818180738_RenameSettingToLocations")] + partial class RenameSettingToLocations + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.10"); + + modelBuilder.Entity("BeatCharacter", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "CharactersId"); + + b.HasIndex("CharactersId"); + + b.ToTable("BeatCharacters", (string)null); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.Property("ArcStagesId") + .HasColumnType("TEXT"); + + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.HasKey("ArcStagesId", "BeatsId"); + + b.HasIndex("BeatsId"); + + b.ToTable("ArcStageBeats", (string)null); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.Property("BeatsId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("BeatsId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("BeatTags", (string)null); + }); + + modelBuilder.Entity("ChapterLocation", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("LocationsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "LocationsId"); + + b.HasIndex("LocationsId"); + + b.ToTable("ChapterLocations", (string)null); + }); + + modelBuilder.Entity("ChapterTag", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("ChapterTags", (string)null); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.Property("CharactersId") + .HasColumnType("TEXT"); + + b.Property("TagsId") + .HasColumnType("TEXT"); + + b.HasKey("CharactersId", "TagsId"); + + b.HasIndex("TagsId"); + + b.ToTable("CharacterTags", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.ToTable("Conversations"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ConversationId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Sequence") + .HasColumnType("INTEGER"); + + b.Property("ToolCallsJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ConversationId", "Sequence") + .IsUnique(); + + b.ToTable("AgentMessages"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WhatHappened") + .HasColumnType("TEXT"); + + b.Property("WhatsNext") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId", "SortOrder"); + + b.ToTable("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Number") + .HasColumnType("INTEGER"); + + b.Property("Prose") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Summary") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("WordCount") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Number"); + + b.ToTable("Chapters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Age") + .HasColumnType("TEXT"); + + b.PrimitiveCollection("Aliases") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Appearance") + .HasColumnType("TEXT"); + + b.Property("ArcSummary") + .HasColumnType("TEXT"); + + b.Property("Backstory") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExternalConflict") + .HasColumnType("TEXT"); + + b.Property("IdentityNote") + .HasColumnType("TEXT"); + + b.Property("Importance") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("InternalConflict") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Need") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Occupation") + .HasColumnType("TEXT"); + + b.Property("Personality") + .HasColumnType("TEXT"); + + b.Property("Pronouns") + .HasColumnType("TEXT"); + + b.Property("RevealedInChapterId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SameCharacterAsId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("Voice") + .HasColumnType("TEXT"); + + b.Property("Want") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId"); + + b.HasIndex("RevealedInChapterId"); + + b.HasIndex("SameCharacterAsId"); + + b.ToTable("Characters"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Result") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId", "SortOrder"); + + b.ToTable("CharacterArcStages"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("RelatedCharacterId") + .HasColumnType("TEXT"); + + b.Property("RelationshipType") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CharacterId"); + + b.HasIndex("RelatedCharacterId"); + + b.ToTable("CharacterRelationships"); + }); + + modelBuilder.Entity("Novelly.Api.Genres.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + + b.HasData( + new + { + Id = new Guid("b89aadb3-ee96-5a33-897d-94946b037f96"), + Name = "Adventure" + }, + new + { + Id = new Guid("1295b746-5de1-5724-aab8-186d4220c84f"), + Name = "Contemporary Fiction" + }, + new + { + Id = new Guid("786d6d01-be6c-5dff-ab53-17081d2979ed"), + Name = "Crime" + }, + new + { + Id = new Guid("800eea0a-52cb-5e03-8b6f-5e1ceaec8554"), + Name = "Dystopian" + }, + new + { + Id = new Guid("8dbe0291-1ab6-5045-b327-00f2025a7b0a"), + Name = "Fantasy" + }, + new + { + Id = new Guid("93face5a-9a61-5d63-9a8d-7fd5d49eab7d"), + Name = "Historical Fiction" + }, + new + { + Id = new Guid("4eba456f-b706-5f1f-bfc9-5d32cab0da62"), + Name = "Horror" + }, + new + { + Id = new Guid("d49c5adf-3ed9-5bc9-8652-1f7a9a098ecb"), + Name = "Literary Fiction" + }, + new + { + Id = new Guid("f72c6437-c8e7-519f-8d35-5aefeebbff9e"), + Name = "Magical Realism" + }, + new + { + Id = new Guid("1b670010-b4cc-5b22-a879-d36eb1bf3429"), + Name = "Memoir" + }, + new + { + Id = new Guid("03063bbf-de5d-5dd0-af06-0ee939de58bc"), + Name = "Middle Grade" + }, + new + { + Id = new Guid("c22ed045-52e5-54b0-8cdd-cd1d6a699c19"), + Name = "Mystery" + }, + new + { + Id = new Guid("abe2e8bc-a35e-5a30-a07f-7ae30a00d838"), + Name = "Non-Fiction" + }, + new + { + Id = new Guid("f8543db0-c519-56a0-996a-c6028176e57e"), + Name = "Poetry" + }, + new + { + Id = new Guid("b6251b9e-63a1-563f-94c0-834162fb580b"), + Name = "Romance" + }, + new + { + Id = new Guid("4f188842-488e-567a-b31d-831e0c551fa5"), + Name = "Science Fiction" + }, + new + { + Id = new Guid("ae67fc84-1ed9-55ae-8c9f-8a37adb52b57"), + Name = "Thriller" + }, + new + { + Id = new Guid("37956a94-e9c4-5d29-abbc-f121d687f997"), + Name = "Young Adult" + }); + }); + + modelBuilder.Entity("Novelly.Api.Imports.ImportJob", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChaptersCompleted") + .HasColumnType("INTEGER"); + + b.Property("ChaptersTotal") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .HasColumnType("TEXT"); + + b.Property("SourceRoot") + .IsRequired() + .HasMaxLength(1000) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("StatusMessage") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SourceRoot"); + + b.ToTable("ImportJobs"); + }); + + modelBuilder.Entity("Novelly.Api.Locations.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Locations"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Author") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Genre") + .HasColumnType("TEXT"); + + b.Property("Logline") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerId") + .HasColumnType("TEXT"); + + b.Property("Phase") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Synopsis") + .HasColumnType("TEXT"); + + b.Property("TargetWordCount") + .HasColumnType("INTEGER"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(300) + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerId"); + + b.ToTable("Novels"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ChapterId") + .HasColumnType("TEXT"); + + b.Property("CharacterId") + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Detail") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("Question") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("Resolution") + .HasColumnType("TEXT"); + + b.Property("ResolvedAt") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChapterId"); + + b.HasIndex("CharacterId"); + + b.HasIndex("NovelId"); + + b.ToTable("OpenQuestions"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Color") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Tags"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GrantedAt") + .HasColumnType("INTEGER"); + + b.Property("GrantedByUserId") + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.Property("NovelRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("NovelId", "UserId") + .IsUnique(); + + b.ToTable("NovelMembers"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovellyUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("GlobalRole") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("INTEGER"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("BeatCharacter", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatCharacterArcStage", b => + { + b.HasOne("Novelly.Api.Characters.CharacterArcStage", null) + .WithMany() + .HasForeignKey("ArcStagesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("BeatTag", b => + { + b.HasOne("Novelly.Api.Beats.Beat", null) + .WithMany() + .HasForeignKey("BeatsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .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 => + { + b.HasOne("Novelly.Api.Chapters.Chapter", null) + .WithMany() + .HasForeignKey("ChaptersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("CharacterTag", b => + { + b.HasOne("Novelly.Api.Characters.Character", null) + .WithMany() + .HasForeignKey("CharactersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Tags.Tag", null) + .WithMany() + .HasForeignKey("TagsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Novelly.Api.Users.NovellyUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Conversations") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentMessage", b => + { + b.HasOne("Novelly.Api.Agent.AgentConversation", "Conversation") + .WithMany("Messages") + .HasForeignKey("ConversationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Conversation"); + }); + + modelBuilder.Entity("Novelly.Api.Beats.Beat", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany("Beats") + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Chapters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Characters") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter") + .WithMany() + .HasForeignKey("RevealedInChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs") + .WithMany("OtherIdentities") + .HasForeignKey("SameCharacterAsId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Novel"); + + b.Navigation("RevealedInChapter"); + + b.Navigation("SameCharacterAs"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("ArcStages") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.CharacterRelationship", b => + { + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany("Relationships") + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Characters.Character", "RelatedCharacter") + .WithMany() + .HasForeignKey("RelatedCharacterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Character"); + + 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 => + { + b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") + .WithMany() + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Restrict); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("Novelly.Api.Questions.OpenQuestion", b => + { + b.HasOne("Novelly.Api.Chapters.Chapter", "Chapter") + .WithMany() + .HasForeignKey("ChapterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Characters.Character", "Character") + .WithMany() + .HasForeignKey("CharacterId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany() + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Chapter"); + + b.Navigation("Character"); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Tags.Tag", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Tags") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + }); + + modelBuilder.Entity("Novelly.Api.Users.NovelMember", b => + { + b.HasOne("Novelly.Api.Novels.Novel", "Novel") + .WithMany("Members") + .HasForeignKey("NovelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Novelly.Api.Users.NovellyUser", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Novel"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Novelly.Api.Agent.AgentConversation", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Novelly.Api.Chapters.Chapter", b => + { + b.Navigation("Beats"); + }); + + modelBuilder.Entity("Novelly.Api.Characters.Character", b => + { + b.Navigation("ArcStages"); + + b.Navigation("OtherIdentities"); + + b.Navigation("Relationships"); + }); + + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => + { + b.Navigation("Chapters"); + + b.Navigation("Characters"); + + b.Navigation("Conversations"); + + b.Navigation("Members"); + + b.Navigation("Tags"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.cs b/src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.cs new file mode 100644 index 0000000..52ff75a --- /dev/null +++ b/src/Novelly.Api/Data/Migrations/20260818180738_RenameSettingToLocations.cs @@ -0,0 +1,90 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Novelly.Api.Data.Migrations +{ + /// + public partial class RenameSettingToLocations : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Setting", + table: "Chapters"); + + migrationBuilder.CreateTable( + name: "Locations", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + NovelId = table.Column(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", maxLength: 120, nullable: false), + CreatedAt = table.Column(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(type: "TEXT", nullable: false), + LocationsId = table.Column(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); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "ChapterLocations"); + + migrationBuilder.DropTable( + name: "Locations"); + + migrationBuilder.AddColumn( + name: "Setting", + table: "Chapters", + type: "TEXT", + nullable: true); + } + } +} diff --git a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs index 182acbe..b0b6dc9 100644 --- a/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs +++ b/src/Novelly.Api/Data/Migrations/NovelDbContextModelSnapshot.cs @@ -62,6 +62,21 @@ namespace Novelly.Api.Data.Migrations b.ToTable("BeatTags", (string)null); }); + modelBuilder.Entity("ChapterLocation", b => + { + b.Property("ChaptersId") + .HasColumnType("TEXT"); + + b.Property("LocationsId") + .HasColumnType("TEXT"); + + b.HasKey("ChaptersId", "LocationsId"); + + b.HasIndex("LocationsId"); + + b.ToTable("ChapterLocations", (string)null); + }); + modelBuilder.Entity("ChapterTag", b => { b.Property("ChaptersId") @@ -273,9 +288,6 @@ namespace Novelly.Api.Data.Migrations b.Property("Prose") .HasColumnType("TEXT"); - b.Property("Setting") - .HasColumnType("TEXT"); - b.Property("Status") .IsRequired() .HasMaxLength(32) @@ -620,6 +632,31 @@ namespace Novelly.Api.Data.Migrations b.ToTable("ImportJobs"); }); + modelBuilder.Entity("Novelly.Api.Locations.Location", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(120) + .HasColumnType("TEXT"); + + b.Property("NovelId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NovelId", "Name") + .IsUnique(); + + b.ToTable("Locations"); + }); + modelBuilder.Entity("Novelly.Api.Novels.Novel", b => { b.Property("Id") @@ -901,6 +938,21 @@ namespace Novelly.Api.Data.Migrations .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 => { b.HasOne("Novelly.Api.Chapters.Chapter", null) @@ -1064,6 +1116,17 @@ namespace Novelly.Api.Data.Migrations 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 => { b.HasOne("Novelly.Api.Users.NovellyUser", "Owner") diff --git a/src/Novelly.Api/Data/NovelDbContext.cs b/src/Novelly.Api/Data/NovelDbContext.cs index b4572e0..93027a7 100644 --- a/src/Novelly.Api/Data/NovelDbContext.cs +++ b/src/Novelly.Api/Data/NovelDbContext.cs @@ -7,6 +7,7 @@ using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Genres; using Novelly.Api.Imports; +using Novelly.Api.Locations; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -24,6 +25,7 @@ public class NovelDbContext(DbContextOptions options) : Identity public DbSet CharacterArcStages => Set(); public DbSet Beats => Set(); public DbSet Tags => Set(); + public DbSet Locations => Set(); public DbSet Chapters => Set(); public DbSet OpenQuestions => Set(); public DbSet Conversations => Set(); @@ -51,6 +53,7 @@ public interface INovelDbContext DbSet CharacterArcStages { get; } DbSet Beats { get; } DbSet Tags { get; } + DbSet Locations { get; } DbSet Chapters { get; } DbSet OpenQuestions { get; } DbSet Conversations { get; } diff --git a/src/Novelly.Api/Locations/Location.cs b/src/Novelly.Api/Locations/Location.cs new file mode 100644 index 0000000..c46563e --- /dev/null +++ b/src/Novelly.Api/Locations/Location.cs @@ -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 Chapters { get; set; } = []; + + public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; +} + +public class LocationEntityTypeConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder 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")); + } +} diff --git a/src/Novelly.Api/Locations/LocationContracts.cs b/src/Novelly.Api/Locations/LocationContracts.cs new file mode 100644 index 0000000..4d6db31 --- /dev/null +++ b/src/Novelly.Api/Locations/LocationContracts.cs @@ -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 +{ + 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 +{ + 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 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(); +} diff --git a/src/Novelly.Api/Locations/LocationEndpoints.cs b/src/Novelly.Api/Locations/LocationEndpoints.cs new file mode 100644 index 0000000..59159d1 --- /dev/null +++ b/src/Novelly.Api/Locations/LocationEndpoints.cs @@ -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() + .AddEndpointFilter(); + + 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() + .AddEndpointFilter(); + + 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; + } +} diff --git a/src/Novelly.Api/Locations/LocationService.cs b/src/Novelly.Api/Locations/LocationService.cs new file mode 100644 index 0000000..2e15b68 --- /dev/null +++ b/src/Novelly.Api/Locations/LocationService.cs @@ -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 logger, + IModelValidator createValidator, + IModelValidator updateValidator) +{ + public async Task> 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 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 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 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 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> ResolveAsync( + Guid novelId, IReadOnlyList 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(); + 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 FindByNameAsync(Guid novelId, string name, CancellationToken ct) => + await db.Locations.FirstOrDefaultAsync( + l => l.NovelId == novelId && EF.Functions.Like(l.Name, name), ct); +} diff --git a/src/Novelly.Api/Program.cs b/src/Novelly.Api/Program.cs index 66648d8..34f6930 100644 --- a/src/Novelly.Api/Program.cs +++ b/src/Novelly.Api/Program.cs @@ -10,6 +10,7 @@ using Novelly.Api.Common; using Novelly.Api.Data; using Novelly.Api.Genres; using Novelly.Api.Imports; +using Novelly.Api.Locations; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -95,6 +96,7 @@ app.MapNovelEndpoints() .MapChapterEndpoints() .MapBeatEndpoints() .MapTagEndpoints() + .MapLocationEndpoints() .MapGenreEndpoints() .MapOpenQuestionEndpoints() .MapAgentEndpoints() diff --git a/src/Novelly.Mcp/Tools/LocationTools.cs b/src/Novelly.Mcp/Tools/LocationTools.cs new file mode 100644 index 0000000..1e91a1a --- /dev/null +++ b/src/Novelly.Mcp/Tools/LocationTools.cs @@ -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 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 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 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 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 DeleteLocation( + NovelApiClient api, + [Description("The location's id.")] Guid locationId, + CancellationToken ct) => + api.DeleteAsync($"/api/locations/{locationId}", ct); +} diff --git a/src/Novelly.Mcp/Tools/ManuscriptTools.cs b/src/Novelly.Mcp/Tools/ManuscriptTools.cs index 3911d16..056d2d4 100644 --- a/src/Novelly.Mcp/Tools/ManuscriptTools.cs +++ b/src/Novelly.Mcp/Tools/ManuscriptTools.cs @@ -32,7 +32,7 @@ public static class ManuscriptTools CancellationToken ct, [Description("Position in the manuscript, 1-based.")] int? number = 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("Target length in words.")] int? targetWordCount = 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, number, summary, - setting, + locations, status = status ?? "Planned", targetWordCount, prose, @@ -50,7 +50,7 @@ public static class ManuscriptTools }, ct); [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 " + "markdown; the word count is recomputed automatically.")] public static Task UpdateChapter( @@ -60,12 +60,12 @@ public static class ManuscriptTools [Description("New title.")] string? title = null, [Description("Position in the manuscript.")] int? number = 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("Planned, Outlined, Drafted, Revised or Final.")] string? status = null, [Description("Target length in words.")] int? targetWordCount = null, [Description("The chapter's drafted text, in markdown.")] string? prose = null, [Description("Tags for cross-referencing. Replaces the existing tags.")] string[]? tags = null) => 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); } diff --git a/src/Novelly.Web/src/App.tsx b/src/Novelly.Web/src/App.tsx index b76d771..88a8c0f 100644 --- a/src/Novelly.Web/src/App.tsx +++ b/src/Novelly.Web/src/App.tsx @@ -5,6 +5,7 @@ import DashboardPage from './pages/DashboardPage' import CharactersPage from './pages/CharactersPage' import CharacterDetailPage from './pages/CharacterDetailPage' import TagsPage from './pages/TagsPage' +import LocationsPage from './pages/LocationsPage' import ChaptersPage from './pages/ChaptersPage' import ChapterPage from './pages/ChapterPage' import AgentPage from './pages/AgentPage' @@ -42,6 +43,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 64acdf6..0cb818f 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -15,6 +15,8 @@ import type { ImportJob, ImportJobStatus, OpenQuestion, + LocationReferences, + LocationSummary, Novel, NovelMember, NovelRole, @@ -33,6 +35,8 @@ export const keys = { characters: (novelId: string) => ['novels', novelId, 'characters'] as const, tags: (novelId: string) => ['novels', novelId, 'tags'] 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, chapters: (novelId: string) => ['novels', novelId, 'chapters'] 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(`/api/novels/${novelId}/locations`), + }) + +export const useLocationReferences = (locationId: string | undefined) => + useQuery({ + queryKey: keys.locationRefs(locationId ?? ''), + queryFn: () => api.get(`/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(`/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) { const qc = useQueryClient() return useMutation({ @@ -474,8 +511,9 @@ export const useChapter = (id: string | undefined) => export function useCreateChapter(novelId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: (body: Partial & { title: string }) => - api.post(`/api/novels/${novelId}/chapters`, body), + mutationFn: ( + body: Partial> & { title: string; tags?: string[]; locations?: string[] }, + ) => api.post(`/api/novels/${novelId}/chapters`, body), onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapters(novelId) }), }) } @@ -483,12 +521,14 @@ export function useCreateChapter(novelId: string) { export function useUpdateChapter(novelId: string) { const qc = useQueryClient() return useMutation({ - mutationFn: ({ id, ...body }: Partial> & { id: string; tags?: string[] }) => - api.patch(`/api/chapters/${id}`, body), + mutationFn: ( + { id, ...body }: Partial> & { id: string; tags?: string[]; locations?: string[] }, + ) => api.patch(`/api/chapters/${id}`, body), onSuccess: (updated) => { qc.setQueryData(keys.chapter(updated.id), updated) qc.invalidateQueries({ queryKey: keys.chapters(novelId) }) qc.invalidateQueries({ queryKey: keys.tags(novelId) }) + qc.invalidateQueries({ queryKey: keys.locations(novelId) }) }, }) } diff --git a/src/Novelly.Web/src/api/types.ts b/src/Novelly.Web/src/api/types.ts index 122bebc..c4a14c0 100644 --- a/src/Novelly.Web/src/api/types.ts +++ b/src/Novelly.Web/src/api/types.ts @@ -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 { id: string name: string @@ -214,7 +228,7 @@ export interface ChapterSummary { number: number title: string summary: string | null - setting: string | null + locations: Location[] status: DraftStatus targetWordCount: number | null beatCount: number diff --git a/src/Novelly.Web/src/components/LocationEditor.tsx b/src/Novelly.Web/src/components/LocationEditor.tsx new file mode 100644 index 0000000..1ca4458 --- /dev/null +++ b/src/Novelly.Web/src/components/LocationEditor.tsx @@ -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 ( + + {novelId ? ( + + {location.name} + + ) : ( + location.name + )} + {onRemove && ( + + )} + + ) +} + +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 ( +
+ {label && {label}} +
+ {locations.map((location) => ( + remove(location.name)} + /> + ))} + {!readOnly && ( + setDraft(e.target.value)} + onBlur={add} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ',') { + e.preventDefault() + add() + } + }} + /> + )} + + {unused.map((name) => ( + +
+
+ ) +} diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index 94579dd..32616bc 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -9,6 +9,7 @@ import { useCreateChapter, useDeleteBeat, useDeleteChapter, + useLocations, useMoveBeats, useNovel, useReorderBeats, @@ -21,6 +22,7 @@ import { useAuth } from '../auth/AuthContext' import { AutoField, ErrorNote, Select, Spinner } from '../components/ui' import { ConfirmModal } from '../components/ConfirmModal' import { TagChip, TagEditor } from '../components/TagEditor' +import { LocationEditor } from '../components/LocationEditor' import { CharacterChip, CharacterMultiSelect } from '../components/CharacterMultiSelect' import { useCharacterContextMenu } from '../components/CharacterContextMenu' import { MarkdownEditor } from '../components/MarkdownEditor' @@ -36,6 +38,7 @@ export default function ChapterPage() { const { data: novel } = useNovel(novelId) const { data: characters } = useCharacters(novelId) const { data: allTags } = useTags(novelId) + const { data: allLocations } = useLocations(novelId) const { data: chapters } = useChapters(novelId) const createChapter = useCreateChapter(novelId) const update = useUpdateChapter(novelId) @@ -73,13 +76,15 @@ export default function ChapterPage() { if (error) return if (!chapter) return null - const patch = (body: Partial> & { tags?: string[] }) => + const patch = (body: Partial> & { tags?: string[]; locations?: string[] }) => 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 settingSuggestions = [ - ...new Set((chapters ?? []).map((c) => c.setting).filter((s): s is string => Boolean(s?.trim()))), - ].sort() + const locationSuggestions = allLocations?.map((l) => l.name) ?? [] return (
@@ -171,11 +176,13 @@ export default function ChapterPage() {
- patch({ setting })} - suggestions={settingSuggestions} + canWrite && patch({ locations })} readOnly={!canWrite} />
@@ -199,6 +206,20 @@ export default function ChapterPage() { )} + + {chapterCharacters.length > 0 && ( +
+ Characters:{' '} + {chapterCharacters.map((c, i) => ( + + {i > 0 && ', '} + + {c.name} + + + ))} +
+ )} )} diff --git a/src/Novelly.Web/src/pages/LocationsPage.tsx b/src/Novelly.Web/src/pages/LocationsPage.tsx new file mode 100644 index 0000000..5472ac2 --- /dev/null +++ b/src/Novelly.Web/src/pages/LocationsPage.tsx @@ -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 + if (error) return + + const selected = locations?.find((l) => l.id === selectedId) ?? locations?.[0] + + const select = (id: string) => + setSearchParams((params) => { + params.set('location', id) + return params + }) + + return ( +
+ + +
+ {!selected ? ( + + ) : ( + + )} +
+
+ ) +} + +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 + if (error) return + if (!data) return null + + const empty = data.chapters.length === 0 + + return ( +
+
+ + {canDelete && ( + + )} +
+ + {update.error && } + + {confirmingDelete && ( + remove.mutate(locationId)} + onClose={() => setConfirmingDelete(false)} + /> + )} + + {empty && ( + + )} + + {data.chapters.length > 0 && ( +
+

Chapters

+
    + {data.chapters.map((c) => ( +
  • + + {c.number}. {c.title} + + {c.summary && — {c.summary}} +
  • + ))} +
+
+ )} +
+ ) +} diff --git a/src/Novelly.Web/src/pages/NovelLayout.tsx b/src/Novelly.Web/src/pages/NovelLayout.tsx index 8c2bce5..717c679 100644 --- a/src/Novelly.Web/src/pages/NovelLayout.tsx +++ b/src/Novelly.Web/src/pages/NovelLayout.tsx @@ -11,6 +11,7 @@ const sections: { to: string; label: string; end?: boolean }[] = [ { to: 'chapters', label: 'Chapters' }, { to: 'characters', label: 'Characters' }, { to: 'tags', label: 'Tags' }, + { to: 'locations', label: 'Locations' }, { to: 'agent', label: 'Agent' }, { to: 'settings', label: 'Settings' }, ] @@ -30,6 +31,7 @@ export default function NovelLayout() { useHotkey('g o', 'Go to outline', () => goTo('chapters'), { group: 'Navigate' }) useHotkey('g c', 'Go to characters', () => goTo('characters'), { 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 s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' }) diff --git a/tests/Novelly.Api.Tests/ListingTests.cs b/tests/Novelly.Api.Tests/ListingTests.cs index 0241d54..8ddcd59 100644 --- a/tests/Novelly.Api.Tests/ListingTests.cs +++ b/tests/Novelly.Api.Tests/ListingTests.cs @@ -85,7 +85,7 @@ public class ListingTests : ServiceTestFixture var agent = new NovelAgentService( Db.Context, new ScriptedModelClient([[new AgentTextBlock("Reply.")]]), - new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger.Instance), + new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger.Instance), Options.Create(new AgentOptions()), NullLogger.Instance, new SendAgentMessageRequestValidator()); diff --git a/tests/Novelly.Api.Tests/LocationServiceTests.cs b/tests/Novelly.Api.Tests/LocationServiceTests.cs new file mode 100644 index 0000000..9a62ac1 --- /dev/null +++ b/tests/Novelly.Api.Tests/LocationServiceTests.cs @@ -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().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().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()); +} diff --git a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs index 038b48a..8770524 100644 --- a/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs +++ b/tests/Novelly.Api.Tests/NovelAgentServiceTests.cs @@ -12,7 +12,7 @@ public class NovelAgentServiceTests : ServiceTestFixture private NovelAgentToolset _toolset = null!; protected override void OnSetUp() => - _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Questions, NullLogger.Instance); + _toolset = new NovelAgentToolset(Novels, Characters, Arcs, Chapters, Beats, Tags, Locations, Questions, NullLogger.Instance); private NovelAgentService BuildAgent(ScriptedModelClient model) => new( Db.Context, diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index ec19bd1..80a40bb 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -2,6 +2,7 @@ using Novelly.Api.Beats; using Novelly.Api.Chapters; using Novelly.Api.Characters; using Novelly.Api.Genres; +using Novelly.Api.Locations; using Novelly.Api.Novels; using Novelly.Api.Questions; using Novelly.Api.Tags; @@ -15,6 +16,7 @@ public abstract class ServiceTestFixture protected TestUserContext UserContext { get; private set; } = null!; protected NovelAccessService Access { 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 CharacterService Characters { get; private set; } = null!; protected ChapterService Chapters { get; private set; } = null!; @@ -28,6 +30,7 @@ public abstract class ServiceTestFixture protected CapturingLogger ChapterLogs { get; private set; } = null!; protected CapturingLogger BeatLogs { get; private set; } = null!; protected CapturingLogger TagLogs { get; private set; } = null!; + protected CapturingLogger LocationLogs { get; private set; } = null!; protected CapturingLogger ArcLogs { get; private set; } = null!; protected CapturingLogger QuestionLogs { get; private set; } = null!; protected CapturingLogger GenreLogs { get; private set; } = null!; @@ -50,6 +53,7 @@ public abstract class ServiceTestFixture Db.Context.SaveChanges(); TagLogs = new CapturingLogger(); + LocationLogs = new CapturingLogger(); NovelLogs = new CapturingLogger(); CharacterLogs = new CapturingLogger(); ChapterLogs = new CapturingLogger(); @@ -59,13 +63,14 @@ public abstract class ServiceTestFixture GenreLogs = new CapturingLogger(); 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( Db.Context, Access, UserContext, NovelLogs, new CreateNovelRequestValidator(), new UpdateNovelRequestValidator()); Characters = new CharacterService( Db.Context, Access, Tags, CharacterLogs, new CreateCharacterRequestValidator(), new UpdateCharacterRequestValidator(), new CreateRelationshipRequestValidator(), 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( Db.Context, Access, Tags, BeatLogs, new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(),