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()
|
||||
|
||||
Reference in New Issue
Block a user