The self-nesting outline tree was more structure than chapter outlining needs.
A chapter outline is now a paragraph plus a flat, ordered table of beats, and
tags do the cross-referencing that nesting was doing badly.
A beat is one row: a three-to-five word title, an optional character, what
happened, and what's next. Ordering is a SortOrder column within the chapter —
no parent pointers, no cycle guards, no recursive tree building. Reordering is
one call taking beat ids in the order wanted; ids left out keep their relative
position at the end rather than jumping to the front.
Beats plan, scenes carry prose. The two layers stay separate and a beat's
SceneId is the optional link between them, nullable in both directions —
deleting a scene ungroups its beats rather than deleting the plan, since that
is a decision about prose and not about the outline.
Tags are project-scoped, unique by name case-insensitively, and attach to
characters, chapters and beats through three join tables so cascade deletes are
the database's job rather than ours. Applying an unknown tag by name creates it,
which keeps tagging a single action; GET /api/tags/{id}/references returns
everything carrying a tag across all three kinds at once.
Removed: OutlineNode, OutlineService, its endpoints, agent and MCP tools, and
the Outline tab. Added: Beat and Tag with their services, endpoints, 5 agent
tools and 10 MCP tools, a beat table on the chapter page, a tag editor used in
three places, and a Tags tab for cross-referencing.
Migration drops OutlineNodes — the scaffolder's data-loss warning is the
intended removal, not an accident.
44 tests, up from 31.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
145 lines
6.5 KiB
C#
145 lines
6.5 KiB
C#
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
|
using NovelSoftware.Application;
|
|
using NovelSoftware.Domain.Entities;
|
|
|
|
namespace NovelSoftware.Infrastructure.Persistence;
|
|
|
|
/// <summary>
|
|
/// Stores a <see cref="DateTimeOffset"/> as UTC ticks. SQLite has no native type for it
|
|
/// and refuses to ORDER BY the default text form, which every "most recently updated
|
|
/// first" listing depends on. The domain only ever writes UtcNow, so normalising to UTC
|
|
/// loses nothing.
|
|
/// </summary>
|
|
internal sealed class UtcTicksConverter()
|
|
: ValueConverter<DateTimeOffset, long>(
|
|
value => value.UtcTicks,
|
|
ticks => new DateTimeOffset(ticks, TimeSpan.Zero));
|
|
|
|
public class NovelDbContext(DbContextOptions<NovelDbContext> options)
|
|
: DbContext(options), INovelDbContext
|
|
{
|
|
public DbSet<Project> Projects => Set<Project>();
|
|
public DbSet<Character> Characters => Set<Character>();
|
|
public DbSet<CharacterRelationship> CharacterRelationships => Set<CharacterRelationship>();
|
|
public DbSet<Beat> Beats => Set<Beat>();
|
|
public DbSet<Tag> Tags => Set<Tag>();
|
|
public DbSet<Chapter> Chapters => Set<Chapter>();
|
|
public DbSet<Scene> Scenes => Set<Scene>();
|
|
public DbSet<AgentConversation> Conversations => Set<AgentConversation>();
|
|
public DbSet<AgentMessage> AgentMessages => Set<AgentMessage>();
|
|
|
|
Task<int> INovelDbContext.SaveChangesAsync(CancellationToken cancellationToken) =>
|
|
base.SaveChangesAsync(cancellationToken);
|
|
|
|
protected override void ConfigureConventions(ModelConfigurationBuilder builder) =>
|
|
builder.Properties<DateTimeOffset>().HaveConversion<UtcTicksConverter>();
|
|
|
|
protected override void OnModelCreating(ModelBuilder builder)
|
|
{
|
|
builder.Entity<Project>(entity =>
|
|
{
|
|
entity.Property(p => p.Title).IsRequired().HasMaxLength(300);
|
|
entity.HasMany(p => p.Characters).WithOne(c => c.Project!)
|
|
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
|
entity.HasMany(p => p.Chapters).WithOne(c => c.Project!)
|
|
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
|
entity.HasMany(p => p.Tags).WithOne(t => t.Project!)
|
|
.HasForeignKey(t => t.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
|
entity.HasMany(p => p.Conversations).WithOne(c => c.Project!)
|
|
.HasForeignKey(c => c.ProjectId).OnDelete(DeleteBehavior.Cascade);
|
|
});
|
|
|
|
builder.Entity<Character>(entity =>
|
|
{
|
|
entity.Property(c => c.Name).IsRequired().HasMaxLength(200);
|
|
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
|
|
entity.HasIndex(c => c.ProjectId);
|
|
|
|
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
|
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
|
});
|
|
|
|
builder.Entity<CharacterRelationship>(entity =>
|
|
{
|
|
entity.Property(r => r.RelationshipType).IsRequired().HasMaxLength(120);
|
|
|
|
// Restrict on the inverse side: deleting a character should not silently take
|
|
// the other character's relationship rows with it via a second cascade path,
|
|
// which SQLite rejects as a multiple-cascade cycle.
|
|
entity.HasOne(r => r.RelatedCharacter).WithMany()
|
|
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
|
|
});
|
|
|
|
builder.Entity<Beat>(entity =>
|
|
{
|
|
entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
|
|
entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
|
|
|
|
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
|
|
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
|
|
|
// A beat outlives the scene it was grouped under: deleting a scene is a
|
|
// decision about prose, not about the plan.
|
|
entity.HasOne(b => b.Scene).WithMany()
|
|
.HasForeignKey(b => b.SceneId).OnDelete(DeleteBehavior.SetNull);
|
|
|
|
entity.HasOne(b => b.Character).WithMany()
|
|
.HasForeignKey(b => b.CharacterId).OnDelete(DeleteBehavior.SetNull);
|
|
});
|
|
|
|
builder.Entity<Tag>(entity =>
|
|
{
|
|
entity.Property(t => t.Name).IsRequired().HasMaxLength(64);
|
|
entity.Property(t => t.Color).HasMaxLength(16);
|
|
|
|
// One canonical tag per name per project, so "betrayal" always means the
|
|
// same tag no matter where it was typed.
|
|
entity.HasIndex(t => new { t.ProjectId, t.Name }).IsUnique();
|
|
|
|
entity.HasMany(t => t.Characters).WithMany(c => c.Tags)
|
|
.UsingEntity(join => join.ToTable("CharacterTags"));
|
|
entity.HasMany(t => t.Chapters).WithMany(c => c.Tags)
|
|
.UsingEntity(join => join.ToTable("ChapterTags"));
|
|
entity.HasMany(t => t.Beats).WithMany(b => b.Tags)
|
|
.UsingEntity(join => join.ToTable("BeatTags"));
|
|
});
|
|
|
|
builder.Entity<Chapter>(entity =>
|
|
{
|
|
entity.Property(c => c.Title).IsRequired().HasMaxLength(300);
|
|
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
|
|
entity.HasIndex(c => new { c.ProjectId, c.Number });
|
|
|
|
entity.HasOne(c => c.PovCharacter).WithMany()
|
|
.HasForeignKey(c => c.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
|
|
|
entity.HasMany(c => c.Scenes).WithOne(s => s.Chapter!)
|
|
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.Cascade);
|
|
});
|
|
|
|
builder.Entity<Scene>(entity =>
|
|
{
|
|
entity.Property(s => s.Title).IsRequired().HasMaxLength(300);
|
|
entity.Property(s => s.Status).HasConversion<string>().HasMaxLength(32);
|
|
entity.HasIndex(s => new { s.ChapterId, s.SortOrder });
|
|
|
|
entity.HasOne(s => s.PovCharacter).WithMany()
|
|
.HasForeignKey(s => s.PovCharacterId).OnDelete(DeleteBehavior.SetNull);
|
|
});
|
|
|
|
builder.Entity<AgentConversation>(entity =>
|
|
{
|
|
entity.Property(c => c.Title).IsRequired().HasMaxLength(200);
|
|
entity.HasMany(c => c.Messages).WithOne(m => m.Conversation!)
|
|
.HasForeignKey(m => m.ConversationId).OnDelete(DeleteBehavior.Cascade);
|
|
});
|
|
|
|
builder.Entity<AgentMessage>(entity =>
|
|
{
|
|
entity.Property(m => m.Role).HasConversion<string>().HasMaxLength(16);
|
|
entity.HasIndex(m => new { m.ConversationId, m.Sequence }).IsUnique();
|
|
});
|
|
}
|
|
}
|