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:
@@ -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<NovelAgentToolset> 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<DraftStatus>())
|
||||
.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<DraftStatus>(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<DraftStatus>())
|
||||
.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<DraftStatus>(input, "status"),
|
||||
JsonInput.Int(input, "target_word_count"),
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Novels;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -19,7 +20,6 @@ public class Chapter
|
||||
|
||||
public string? Summary { get; set; }
|
||||
|
||||
public string? Setting { get; set; }
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public DraftStatus Status { get; set; } = DraftStatus.Planned;
|
||||
@@ -35,6 +35,7 @@ public class Chapter
|
||||
public List<Beat> Beats { get; set; } = [];
|
||||
|
||||
public List<Tag> Tags { get; set; } = [];
|
||||
public List<Location> Locations { get; set; } = [];
|
||||
}
|
||||
|
||||
public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
namespace Novelly.Api.Chapters;
|
||||
@@ -11,7 +12,7 @@ public record ChapterSummaryResponse(
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Setting,
|
||||
IReadOnlyList<LocationResponse> Locations,
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
int BeatCount,
|
||||
@@ -25,7 +26,7 @@ public record ChapterResponse(
|
||||
int Number,
|
||||
string Title,
|
||||
string? Summary,
|
||||
string? Setting,
|
||||
IReadOnlyList<LocationResponse> Locations,
|
||||
string? Notes,
|
||||
DraftStatus Status,
|
||||
int? TargetWordCount,
|
||||
@@ -39,7 +40,7 @@ public record CreateChapterRequest(
|
||||
string Title,
|
||||
int? Number = null,
|
||||
string? Summary = null,
|
||||
string? Setting = null,
|
||||
IReadOnlyList<string>? Locations = null,
|
||||
string? Notes = null,
|
||||
DraftStatus Status = DraftStatus.Planned,
|
||||
int? TargetWordCount = null,
|
||||
@@ -53,7 +54,7 @@ public class CreateChapterRequestValidator : IModelValidator<CreateChapterReques
|
||||
var result = new ValidationResult();
|
||||
|
||||
result.AddRequiredTextErrors("Title", "Title", model.Title, 200);
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -63,7 +64,7 @@ public record UpdateChapterRequest(
|
||||
string? Title = null,
|
||||
int? Number = null,
|
||||
string? Summary = null,
|
||||
string? Setting = null,
|
||||
IReadOnlyList<string>? Locations = null,
|
||||
string? Notes = null,
|
||||
DraftStatus? Status = null,
|
||||
int? TargetWordCount = null,
|
||||
@@ -77,7 +78,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
var result = new ValidationResult();
|
||||
|
||||
result.AddUnclearableTextErrors("Title", "Title", model.Title, "a chapter", 200);
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Setting, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
|
||||
ChapterValidation.OptionalFields(model.Number, model.Summary, model.Locations, model.Notes, model.TargetWordCount, model.Prose, model.Tags, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -86,7 +87,7 @@ public class UpdateChapterRequestValidator : IModelValidator<UpdateChapterReques
|
||||
file static class ChapterValidation
|
||||
{
|
||||
public static void OptionalFields(
|
||||
int? number, string? summary, string? setting, string? notes, int? targetWordCount, string? prose,
|
||||
int? number, string? summary, IReadOnlyList<string>? locations, string? notes, int? targetWordCount, string? prose,
|
||||
IReadOnlyList<string>? tags, ValidationResult result)
|
||||
{
|
||||
if (number is <= 0)
|
||||
@@ -95,8 +96,8 @@ file static class ChapterValidation
|
||||
if (summary is { Length: > 20000 })
|
||||
result.AddError("Summary", "'Summary' must be 20,000 characters or fewer.");
|
||||
|
||||
if (setting is { Length: > 500 })
|
||||
result.AddError("Setting", "'Setting' must be 500 characters or fewer.");
|
||||
if (locations is not null && locations.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Locations", "'Locations' must not contain blank entries.");
|
||||
|
||||
if (notes is { Length: > 20000 })
|
||||
result.AddError("Notes", "'Notes' must be 20,000 characters or fewer.");
|
||||
@@ -116,7 +117,8 @@ public static class ChapterMapping
|
||||
{
|
||||
public static ChapterResponse ToResponse(this Chapter c) => new(
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Notes,
|
||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||
c.Notes,
|
||||
c.Status, c.TargetWordCount,
|
||||
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
|
||||
c.Prose, c.WordCount,
|
||||
@@ -125,7 +127,8 @@ public static class ChapterMapping
|
||||
|
||||
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c) => new(
|
||||
c.Id, c.NovelId, c.Number, c.Title, c.Summary,
|
||||
c.Setting, c.Status, c.TargetWordCount,
|
||||
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())],
|
||||
c.Status, c.TargetWordCount,
|
||||
c.Beats.Count, c.WordCount,
|
||||
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
|
||||
c.UpdatedAt);
|
||||
|
||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Novelly.Api.Common;
|
||||
using Novelly.Api.Common.Validation;
|
||||
using Novelly.Api.Data;
|
||||
using Novelly.Api.Locations;
|
||||
using Novelly.Api.Tags;
|
||||
using Novelly.Api.Users;
|
||||
|
||||
@@ -11,6 +12,7 @@ public class ChapterService(
|
||||
INovelDbContext db,
|
||||
NovelAccessService access,
|
||||
TagService tags,
|
||||
LocationService locations,
|
||||
ILogger<ChapterService> logger,
|
||||
IModelValidator<CreateChapterRequest> createValidator,
|
||||
IModelValidator<UpdateChapterRequest> updateValidator)
|
||||
@@ -26,6 +28,7 @@ public class ChapterService(
|
||||
return await db.Chapters
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Tags)
|
||||
.Include(c => c.Locations)
|
||||
.Where(c => c.NovelId == novelId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
@@ -69,7 +72,6 @@ public class ChapterService(
|
||||
Title = request.Title,
|
||||
Number = request.Number ?? await NextChapterNumberAsync(novelId, ct),
|
||||
Summary = request.Summary,
|
||||
Setting = request.Setting,
|
||||
Notes = request.Notes,
|
||||
Status = request.Status,
|
||||
TargetWordCount = request.TargetWordCount,
|
||||
@@ -82,6 +84,11 @@ public class ChapterService(
|
||||
chapter.Tags = await tags.ResolveAsync(novelId, names, ct);
|
||||
}
|
||||
|
||||
if (request.Locations is { } locationNames)
|
||||
{
|
||||
chapter.Locations = await locations.ResolveAsync(novelId, locationNames, ct);
|
||||
}
|
||||
|
||||
db.Chapters.Add(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -107,7 +114,6 @@ public class ChapterService(
|
||||
chapter.Title = Patch.Apply(chapter.Title, request.Title) ?? chapter.Title;
|
||||
chapter.Number = request.Number ?? chapter.Number;
|
||||
chapter.Summary = Patch.Apply(chapter.Summary, request.Summary);
|
||||
chapter.Setting = Patch.Apply(chapter.Setting, request.Setting);
|
||||
chapter.Notes = Patch.Apply(chapter.Notes, request.Notes);
|
||||
chapter.Status = request.Status ?? chapter.Status;
|
||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||
@@ -125,6 +131,11 @@ public class ChapterService(
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.NovelId, names, ct);
|
||||
}
|
||||
|
||||
if (request.Locations is { } locationNames)
|
||||
{
|
||||
chapter.Locations = await locations.ResolveAsync(chapter.NovelId, locationNames, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
@@ -169,6 +180,7 @@ public class ChapterService(
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Characters)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||
.Include(c => c.Tags)
|
||||
.Include(c => c.Locations)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, ct);
|
||||
|
||||
if (chapter is null)
|
||||
|
||||
@@ -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<CharacterArcService>();
|
||||
services.AddScoped<BeatService>();
|
||||
services.AddScoped<TagService>();
|
||||
services.AddScoped<LocationService>();
|
||||
services.AddScoped<GenreService>();
|
||||
services.AddScoped<ChapterService>();
|
||||
services.AddScoped<OpenQuestionService>();
|
||||
|
||||
+1232
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);
|
||||
});
|
||||
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("ChaptersId")
|
||||
@@ -273,9 +288,6 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Prose")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Setting")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("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<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 =>
|
||||
{
|
||||
b.Property<Guid>("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")
|
||||
|
||||
@@ -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<NovelDbContext> options) : Identity
|
||||
public DbSet<CharacterArcStage> CharacterArcStages => Set<CharacterArcStage>();
|
||||
public DbSet<Beat> Beats => Set<Beat>();
|
||||
public DbSet<Tag> Tags => Set<Tag>();
|
||||
public DbSet<Location> Locations => Set<Location>();
|
||||
public DbSet<Chapter> Chapters => Set<Chapter>();
|
||||
public DbSet<OpenQuestion> OpenQuestions => Set<OpenQuestion>();
|
||||
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
||||
@@ -51,6 +53,7 @@ public interface INovelDbContext
|
||||
DbSet<CharacterArcStage> CharacterArcStages { get; }
|
||||
DbSet<Beat> Beats { get; }
|
||||
DbSet<Tag> Tags { get; }
|
||||
DbSet<Location> Locations { get; }
|
||||
DbSet<Chapter> Chapters { get; }
|
||||
DbSet<OpenQuestion> OpenQuestions { get; }
|
||||
DbSet<AgentConversation> Conversations { get; }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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<CallToolResult> 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);
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<Route path="chapters" element={<ChaptersPage />} />
|
||||
<Route path="chapters/:chapterId" element={<ChapterPage />} />
|
||||
<Route path="tags" element={<TagsPage />} />
|
||||
<Route path="locations" element={<LocationsPage />} />
|
||||
<Route path="agent" element={<AgentPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -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<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) {
|
||||
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<Chapter> & { title: string }) =>
|
||||
api.post<Chapter>(`/api/novels/${novelId}/chapters`, body),
|
||||
mutationFn: (
|
||||
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) }),
|
||||
})
|
||||
}
|
||||
@@ -483,12 +521,14 @@ export function useCreateChapter(novelId: string) {
|
||||
export function useUpdateChapter(novelId: string) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: ({ id, ...body }: Partial<Omit<Chapter, 'tags'>> & { id: string; tags?: string[] }) =>
|
||||
api.patch<Chapter>(`/api/chapters/${id}`, body),
|
||||
mutationFn: (
|
||||
{ id, ...body }: Partial<Omit<Chapter, 'tags' | 'locations'>> & { id: string; tags?: string[]; locations?: string[] },
|
||||
) => api.patch<Chapter>(`/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) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 <ErrorNote error={error} />
|
||||
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 })
|
||||
|
||||
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 (
|
||||
<div>
|
||||
@@ -171,11 +176,13 @@ export default function ChapterPage() {
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<AutoField
|
||||
label="Setting"
|
||||
value={chapter.setting}
|
||||
onCommit={(setting) => patch({ setting })}
|
||||
suggestions={settingSuggestions}
|
||||
<LocationEditor
|
||||
id="chapter-locations"
|
||||
label="Locations"
|
||||
locations={chapter.locations}
|
||||
suggestions={locationSuggestions}
|
||||
novelId={novelId}
|
||||
onChange={(locations) => canWrite && patch({ locations })}
|
||||
readOnly={!canWrite}
|
||||
/>
|
||||
</div>
|
||||
@@ -199,6 +206,20 @@ export default function ChapterPage() {
|
||||
</button>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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: '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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user