Add soft delete + trash, keyboard-first web overhaul, move chapter tags to bottom
CI / build-and-push (push) Failing after 31s
CI / deploy (push) Has been skipped

Adds SoftDelete/Trash across characters, chapters, locations, beats with a
purge schedule and Trash page. Reworks the web client for keyboard-driven
navigation (focus helpers, help overlay, keyboard.md doc). Moves the
ChapterPage tag editor to the bottom of the page to match CharacterDetailPage.
This commit is contained in:
James Wampler
2026-08-20 16:39:09 -07:00
parent 7df1fffdca
commit aca26588f9
59 changed files with 2913 additions and 170 deletions
+1
View File
@@ -59,6 +59,7 @@ Serilog console via `AddSerilog` (not `UseSerilog` — keeps OTel provider for A
- `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`. - `PATCH` requests partial: null field = leave alone, empty string = clear. Keep new update endpoints consistent with `Patch.Apply`.
- Enums cross wire as names, never ordinals - Enums cross wire as names, never ordinals
- All frontend components should have an id attribute that identifies them uniquely. - All frontend components should have an id attribute that identifies them uniquely.
- Web client is keyboard-first: read `docs/keyboard.md` before adding any interactive UI (forms, editable rows, create flows).
## Testing ## Testing
+60
View File
@@ -0,0 +1,60 @@
# Keyboard conventions
Novelly's web client is built to be driven entirely from the keyboard. New interactive
components should follow these rules so the app stays consistent as it grows.
## Escape cancels or closes — it never destroys already-saved work
In an editor that commits per field (a beat row, a chapter's title), Escape reverts only the
field you're currently in and then closes the editor. Fields you already tabbed past and
committed stay saved — Escape is honest about this, not a full undo. Anywhere a component *can*
offer a true "discard everything" cancel (a create form that hasn't saved anything yet), do
that instead.
## Enter commits a single-line field and advances
Pressing Enter in a single-line field is equivalent to Tab: it commits the field's value and
moves focus to the next field. Shift+Enter moves to the previous field. This is what
`AutoField` (`src/components/ui.tsx`) does by default — reuse it rather than hand-rolling a
text input's key handling.
## mod+Enter commits a multiline field or completes a record
A `<textarea>` needs plain Enter to insert a newline, so multiline fields commit on
`mod+Enter` (Cmd or Ctrl) instead. The same combo, handled at the row/form level, means "I'm
done with this record" — closing a beat row, submitting a question. This mirrors the app's
original convention in `AgentPanel.tsx` (`mod+Enter` sends a message).
## Bare single letters create the primary thing on the page
`n` is the default create-hotkey across the app (new character, new chapter, new location). A
page with a second creatable thing uses a mnemonic instead (`b` for beat, `q` for question, `a`
for arc stage). Register these with `useHotkey` from the component that owns the create action,
so the shortcut is scoped to that page/section and unregisters when it unmounts — never
register a bare letter globally.
## Creating something puts focus in its first editable field
A create action that leaves the user hunting for the thing they just made is a bug. Land focus
in the new item's first field (or, when a mutation's response id isn't the field's DOM node
yet, request focus for that id and let it land once the row/page actually renders — see the
`focusRequestId`/`onAutoFocused` pattern used for beats and arc stages).
## Chip inputs commit on Enter, comma, or blur
`TagEditor`, `LocationEditor`, `CharacterMultiSelect`, and `AliasEditor` all add their draft
value to the list on Enter, comma, or losing focus. Follow the same shape for any new
chip-style input.
## Destructive confirmations use `ConfirmModal`
Never use the native `confirm()`/`alert()` dialogs — they're not stylable, not consistent with
the rest of the app, and (depending on browser) can be genuinely awkward to dismiss from the
keyboard. Use `ConfirmModal` (`src/components/ConfirmModal.tsx`), which wraps `Modal` and gets
focus-trapping and Escape-to-close for free.
## The exception, not the rule: `allowInInputs`
`useHotkey` shortcuts don't fire while a text field is focused, unless registered with
`allowInInputs: true`. Reserve that for shortcuts that make sense mid-typing (`mod+Enter` to
submit, `Escape` to close) — never a bare letter.
+2 -1
View File
@@ -21,7 +21,8 @@ public enum ActivityAction
{ {
Created, Created,
Updated, Updated,
Deleted Deleted,
Restored
} }
public class ActivityEvent public class ActivityEvent
+1
View File
@@ -37,6 +37,7 @@ public class BeatEntityTypeConfiguration : IEntityTypeConfiguration<Beat>
{ {
entity.Property(b => b.Title).IsRequired().HasMaxLength(200); entity.Property(b => b.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(b => new { b.ChapterId, b.SortOrder }); entity.HasIndex(b => new { b.ChapterId, b.SortOrder });
entity.HasQueryFilter(b => b.Chapter!.DeletedAt == null);
entity.HasOne(b => b.Chapter).WithMany(c => c.Beats) entity.HasOne(b => b.Chapter).WithMany(c => c.Beats)
.HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(b => b.ChapterId).OnDelete(DeleteBehavior.Cascade);
+1 -1
View File
@@ -146,7 +146,7 @@ public static class BeatMapping
b.ChapterId, b.ChapterId,
b.SortOrder, b.SortOrder,
b.Title, b.Title,
[.. b.Characters.OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))], [.. b.Characters.Where(c => c.DeletedAt is null).OrderBy(c => c.Name).Select(c => new BeatCharacterResponse(c.Id, c.Name))],
b.WhatHappened, b.WhatHappened,
b.WhatsNext, b.WhatsNext,
[.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. b.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
+2 -2
View File
@@ -70,7 +70,7 @@ public class BeatService(
var beats = await db.Beats var beats = await db.Beats
.Include(b => b.Chapter) .Include(b => b.Chapter)
.Include(b => b.ArcStages) .Include(b => b.ArcStages.Where(s => s.Character!.DeletedAt == null))
.Where(b => b.Characters.Any(c => c.Id == characterId)) .Where(b => b.Characters.Any(c => c.Id == characterId))
.ToListAsync(ct); .ToListAsync(ct);
@@ -395,7 +395,7 @@ public class BeatService(
private IQueryable<Beat> Query() => private IQueryable<Beat> Query() =>
db.Beats db.Beats
.Include(b => b.Characters) .Include(b => b.Characters.Where(c => c.DeletedAt == null))
.Include(b => b.Tags); .Include(b => b.Tags);
private async Task<Beat?> FindAsync(Guid id, CancellationToken ct) private async Task<Beat?> FindAsync(Guid id, CancellationToken ct)
+3 -1
View File
@@ -8,7 +8,7 @@ using Novelly.Api.Tags;
namespace Novelly.Api.Chapters; namespace Novelly.Api.Chapters;
public class Chapter public class Chapter : ISoftDeletable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; } public Guid NovelId { get; set; }
@@ -33,6 +33,7 @@ public class Chapter
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
public List<Beat> Beats { get; set; } = []; public List<Beat> Beats { get; set; } = [];
@@ -48,5 +49,6 @@ public class ChapterEntityTypeConfiguration : IEntityTypeConfiguration<Chapter>
entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Status).HasConversion<string>().HasMaxLength(32);
entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Kind).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => new { c.NovelId, c.Number }); entity.HasIndex(c => new { c.NovelId, c.Number });
entity.HasQueryFilter(c => c.DeletedAt == null);
} }
} }
+2 -2
View File
@@ -123,7 +123,7 @@ public static class ChapterMapping
{ {
public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new( public static ChapterResponse ToResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], [.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Notes, c.Notes,
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
[.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())], [.. c.Beats.OrderBy(b => b.SortOrder).Select(b => b.ToResponse())],
@@ -133,7 +133,7 @@ public static class ChapterMapping
public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new( public static ChapterSummaryResponse ToSummaryResponse(this Chapter c, int? displayNumber = null) => new(
c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary, c.Id, c.NovelId, c.Number, c.Kind, displayNumber, c.Title, c.Summary,
[.. c.Locations.OrderBy(l => l.Name).Select(l => l.ToResponse())], [.. c.Locations.Where(l => l.DeletedAt is null).OrderBy(l => l.Name).Select(l => l.ToResponse())],
c.Status, c.TargetWordCount, c.Status, c.TargetWordCount,
c.Beats.Count, c.WordCount, c.Beats.Count, c.WordCount,
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
+1 -1
View File
@@ -68,7 +68,7 @@ public static class ChapterEndpoints
chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) => chapters.MapDelete("/{id:guid}", async (Guid id, ChapterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a chapter."); .WithSummary("Move a chapter to the trash.");
return app; return app;
} }
+8 -6
View File
@@ -31,7 +31,7 @@ public class ChapterService(
return await db.Chapters return await db.Chapters
.Include(c => c.Beats) .Include(c => c.Beats)
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.Locations) .Include(c => c.Locations.Where(l => l.DeletedAt == null))
.Where(c => c.NovelId == novelId) .Where(c => c.NovelId == novelId)
.OrderBy(c => c.Number) .OrderBy(c => c.Number)
.ToListAsync(ct); .ToListAsync(ct);
@@ -153,17 +153,18 @@ public class ChapterService(
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
logger.LogInformation("Deleting chapter {ChapterId}", id); logger.LogInformation("Moving chapter {ChapterId} to trash", id);
var chapter = await FindAsync(id, ct); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null) if (chapter is null)
{ {
logger.LogWarning("Chapter {ChapterId} not found", id);
return false; return false;
} }
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct); await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
db.Chapters.Remove(chapter); chapter.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount); activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Deleted, chapter.Id, -chapter.WordCount);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
@@ -177,6 +178,7 @@ public class ChapterService(
logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId); logger.LogDebug("Computing next chapter number for novel {NovelId}", novelId);
var max = await db.Chapters var max = await db.Chapters
.IgnoreQueryFilters()
.Where(c => c.NovelId == novelId) .Where(c => c.NovelId == novelId)
.MaxAsync(c => (int?)c.Number, ct); .MaxAsync(c => (int?)c.Number, ct);
@@ -190,10 +192,10 @@ public class ChapterService(
logger.LogDebug("Finding chapter {ChapterId}", id); logger.LogDebug("Finding chapter {ChapterId}", id);
var chapter = await db.Chapters var chapter = await db.Chapters
.Include(c => c.Beats).ThenInclude(b => b.Characters) .Include(c => c.Beats).ThenInclude(b => b.Characters.Where(ch => ch.DeletedAt == null))
.Include(c => c.Beats).ThenInclude(b => b.Tags) .Include(c => c.Beats).ThenInclude(b => b.Tags)
.Include(c => c.Tags) .Include(c => c.Tags)
.Include(c => c.Locations) .Include(c => c.Locations.Where(l => l.DeletedAt == null))
.FirstOrDefaultAsync(c => c.Id == id, ct); .FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null) if (chapter is null)
+6 -1
View File
@@ -2,12 +2,13 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Tags; using Novelly.Api.Tags;
namespace Novelly.Api.Characters; namespace Novelly.Api.Characters;
public class Character public class Character : ISoftDeletable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
public Guid NovelId { get; set; } public Guid NovelId { get; set; }
@@ -46,6 +47,7 @@ public class Character
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
public List<CharacterRelationship> Relationships { get; set; } = []; public List<CharacterRelationship> Relationships { get; set; } = [];
public List<Tag> Tags { get; set; } = []; public List<Tag> Tags { get; set; } = [];
@@ -79,6 +81,7 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32); entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
entity.HasIndex(c => c.NovelId); entity.HasIndex(c => c.NovelId);
entity.HasIndex(c => c.SameCharacterAsId); entity.HasIndex(c => c.SameCharacterAsId);
entity.HasQueryFilter(c => c.DeletedAt == null);
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!) entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade); .HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
@@ -102,5 +105,7 @@ public class CharacterRelationshipEntityTypeConfiguration : IEntityTypeConfigura
entity.HasOne(r => r.RelatedCharacter).WithMany() entity.HasOne(r => r.RelatedCharacter).WithMany()
.HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict); .HasForeignKey(r => r.RelatedCharacterId).OnDelete(DeleteBehavior.Restrict);
entity.HasQueryFilter(r => r.Character!.DeletedAt == null && r.RelatedCharacter!.DeletedAt == null);
} }
} }
@@ -283,7 +283,7 @@ public class CharacterArcService(
private IQueryable<CharacterArcStage> Query() => private IQueryable<CharacterArcStage> Query() =>
db.CharacterArcStages db.CharacterArcStages
.Include(s => s.Chapter) .Include(s => s.Chapter)
.Include(s => s.Beats).ThenInclude(b => b.Chapter); .Include(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null)).ThenInclude(b => b.Chapter);
private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct) private async Task<CharacterArcStage?> FindAsync(Guid id, CancellationToken ct)
{ {
@@ -33,6 +33,7 @@ public class CharacterArcStageEntityTypeConfiguration : IEntityTypeConfiguration
{ {
entity.Property(s => s.Title).IsRequired().HasMaxLength(200); entity.Property(s => s.Title).IsRequired().HasMaxLength(200);
entity.HasIndex(s => new { s.CharacterId, s.SortOrder }); entity.HasIndex(s => new { s.CharacterId, s.SortOrder });
entity.HasQueryFilter(s => s.Character!.DeletedAt == null);
entity.HasOne(s => s.Chapter).WithMany() entity.HasOne(s => s.Chapter).WithMany()
.HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull); .HasForeignKey(s => s.ChapterId).OnDelete(DeleteBehavior.SetNull);
@@ -294,37 +294,45 @@ public static class CharacterMapping
c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes, c.Appearance, c.Personality, c.Backstory, c.Motivation, c.Conflict, c.Voice, c.Notes,
[.. c.Aliases], [.. c.Aliases],
c.SameCharacterAsId, c.SameCharacterAsId,
c.SameCharacterAs?.Name, c.SameCharacterAs is { DeletedAt: null } canonical ? canonical.Name : null,
c.RevealedInChapterId, c.RevealedInChapterId,
c.RevealedInChapter?.Number, c.RevealedInChapter is { DeletedAt: null } revealedInChapter ? revealedInChapter.Number : null,
c.RevealedInChapter is { } revealedInChapter ? ChapterLabel(revealedInChapter, displayNumbers) : null, c.RevealedInChapter is { DeletedAt: null } revealedInChapter2 ? ChapterLabel(revealedInChapter2, displayNumbers) : null,
c.IdentityNote, c.IdentityNote,
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))], [.. c.OtherIdentities.Where(o => o.DeletedAt is null).OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
[.. c.Relationships.Select(r => new RelationshipResponse( [.. c.Relationships
.Where(r => r.RelatedCharacter is { DeletedAt: null })
.Select(r => new RelationshipResponse(
r.Id, r.Id,
r.RelatedCharacterId, r.RelatedCharacterId,
r.RelatedCharacter?.Name ?? "(unknown)", r.RelatedCharacter!.Name,
r.RelationshipType, r.RelationshipType,
r.Description))], r.Description))],
[.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())], [.. c.Tags.OrderBy(t => t.Name).Select(t => t.ToResponse())],
[.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))], [.. c.ArcStages.OrderBy(s => s.SortOrder).Select(s => s.ToResponse(displayNumbers))],
c.UpdatedAt); c.UpdatedAt);
public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new( public static ArcStageResponse ToResponse(this CharacterArcStage s, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
{
var chapter = s.Chapter is { DeletedAt: null } ? s.Chapter : null;
return new(
s.Id, s.Id,
s.CharacterId, s.CharacterId,
s.SortOrder, s.SortOrder,
s.Title, s.Title,
s.Result, s.Result,
s.ChapterId, s.ChapterId,
s.Chapter?.Number, chapter?.Number,
s.Chapter?.Title, chapter?.Title,
s.Chapter is { } chapter ? ChapterLabel(chapter, displayNumbers) : null, chapter is not null ? ChapterLabel(chapter, displayNumbers) : null,
[.. s.Beats [.. s.Beats
.OrderBy(b => b.Chapter?.Number ?? 0) .Where(b => b.Chapter is { DeletedAt: null })
.OrderBy(b => b.Chapter!.Number)
.ThenBy(b => b.SortOrder) .ThenBy(b => b.SortOrder)
.Select(b => b.ToCharacterBeatResponse(s.CharacterId, b.Chapter is { } beatChapter ? ChapterLabel(beatChapter, displayNumbers) : null))], .Select(b => b.ToCharacterBeatResponse(s.CharacterId, ChapterLabel(b.Chapter!, displayNumbers)))],
s.UpdatedAt); s.UpdatedAt);
}
private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) => private static string ChapterLabel(Chapter chapter, IReadOnlyDictionary<Guid, int>? displayNumbers) =>
ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title); ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title);
@@ -103,7 +103,7 @@ public static class CharacterEndpoints
characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) => characters.MapDelete("/{id:guid}", async (Guid id, CharacterService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a character."); .WithSummary("Move a character to the trash.");
characters.MapPost("/{id:guid}/relationships", async ( characters.MapPost("/{id:guid}/relationships", async (
Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) => Guid id, CreateRelationshipRequest request, CharacterService service, ChapterDisplayNumberLookup chapterLabels, CancellationToken ct) =>
@@ -159,7 +159,7 @@ public class CharacterService(
{ {
Guard.Default(id, nameof(id)); Guard.Default(id, nameof(id));
logger.LogInformation("Deleting character {CharacterId}", id); logger.LogInformation("Moving character {CharacterId} to trash", id);
var character = await FindAsync(id, ct); var character = await FindAsync(id, ct);
if (character is null) if (character is null)
@@ -169,7 +169,7 @@ public class CharacterService(
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct); await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
db.Characters.Remove(character); character.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id); activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Deleted, character.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
@@ -351,7 +351,7 @@ public class CharacterService(
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Chapter) .ThenInclude(s => s.Chapter)
.Include(c => c.ArcStages) .Include(c => c.ArcStages)
.ThenInclude(s => s.Beats) .ThenInclude(s => s.Beats.Where(b => b.Chapter!.DeletedAt == null))
.ThenInclude(b => b.Chapter) .ThenInclude(b => b.Chapter)
.Include(c => c.SameCharacterAs) .Include(c => c.SameCharacterAs)
.Include(c => c.OtherIdentities) .Include(c => c.OtherIdentities)
@@ -18,6 +18,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Common; namespace Novelly.Api.Common;
@@ -103,6 +104,11 @@ public static class NovellyServiceRegistration
services.AddScoped<ImportAgentService>(); services.AddScoped<ImportAgentService>();
services.AddHostedService<ImportJobRunner>(); services.AddHostedService<ImportJobRunner>();
services.Configure<TrashOptions>(configuration.GetSection(TrashOptions.SectionName));
services.AddScoped<TrashService>();
services.AddSingleton(TimeProvider.System);
services.AddHostedService<TrashPurgeRunner>();
services.AddModelValidatorsFromAssemblyContaining<Program>(); services.AddModelValidatorsFromAssemblyContaining<Program>();
return services; return services;
+6
View File
@@ -0,0 +1,6 @@
namespace Novelly.Api.Common;
public interface ISoftDeletable
{
DateTimeOffset? DeletedAt { get; set; }
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Novelly.Api.Data.Migrations
{
/// <inheritdoc />
public partial class AddSoftDelete : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations");
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Locations",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Characters",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "DeletedAt",
table: "Chapters",
type: "INTEGER",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true,
filter: "\"DeletedAt\" IS NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Locations");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Characters");
migrationBuilder.DropColumn(
name: "DeletedAt",
table: "Chapters");
migrationBuilder.CreateIndex(
name: "IX_Locations_NovelId_Name",
table: "Locations",
columns: new[] { "NovelId", "Name" },
unique: true);
}
}
}
@@ -319,6 +319,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind") b.Property<string>("Kind")
.IsRequired() .IsRequired()
.HasMaxLength(32) .HasMaxLength(32)
@@ -390,6 +393,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("IdentityNote") b.Property<string>("IdentityNote")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -680,6 +686,9 @@ namespace Novelly.Api.Data.Migrations
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<long?>("DeletedAt")
.HasColumnType("INTEGER");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(120) .HasMaxLength(120)
@@ -691,7 +700,8 @@ namespace Novelly.Api.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("NovelId", "Name") b.HasIndex("NovelId", "Name")
.IsUnique(); .IsUnique()
.HasFilter("\"DeletedAt\" IS NULL");
b.ToTable("Locations"); b.ToTable("Locations");
}); });
+5 -2
View File
@@ -1,11 +1,12 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders; using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
using Novelly.Api.Common;
using Novelly.Api.Novels; using Novelly.Api.Novels;
namespace Novelly.Api.Locations; namespace Novelly.Api.Locations;
public class Location public class Location : ISoftDeletable
{ {
public Guid Id { get; set; } = Guid.NewGuid(); public Guid Id { get; set; } = Guid.NewGuid();
@@ -17,6 +18,7 @@ public class Location
public List<Chapter> Chapters { get; set; } = []; public List<Chapter> Chapters { get; set; } = [];
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset? DeletedAt { get; set; }
} }
public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location> public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location>
@@ -25,7 +27,8 @@ public class LocationEntityTypeConfiguration : IEntityTypeConfiguration<Location
{ {
entity.Property(l => l.Name).IsRequired().HasMaxLength(120); entity.Property(l => l.Name).IsRequired().HasMaxLength(120);
entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique(); entity.HasIndex(l => new { l.NovelId, l.Name }).IsUnique().HasFilter("\"DeletedAt\" IS NULL");
entity.HasQueryFilter(l => l.DeletedAt == null);
entity.HasMany(l => l.Chapters).WithMany(c => c.Locations) entity.HasMany(l => l.Chapters).WithMany(c => c.Locations)
.UsingEntity(join => join.ToTable("ChapterLocations")); .UsingEntity(join => join.ToTable("ChapterLocations"));
@@ -54,7 +54,7 @@ public static class LocationEndpoints
locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) => locations.MapDelete("/{id:guid}", async (Guid id, LocationService service, CancellationToken ct) =>
await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound()) await service.DeleteAsync(id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Delete a location. Whatever carried it is left alone."); .WithSummary("Move a location to the trash. Whatever carried it is left alone.");
return app; return app;
} }
+2 -2
View File
@@ -122,7 +122,7 @@ public class LocationService(
{ {
Guard.Default(locationId, nameof(locationId)); Guard.Default(locationId, nameof(locationId));
logger.LogInformation("Deleting location {LocationId}", locationId); logger.LogInformation("Moving location {LocationId} to trash", locationId);
var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct); var location = await db.Locations.FirstOrDefaultAsync(l => l.Id == locationId, ct);
if (location is null) if (location is null)
@@ -133,7 +133,7 @@ public class LocationService(
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct); await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
db.Locations.Remove(location); location.DeletedAt = DateTimeOffset.UtcNow;
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id); activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Deleted, location.Id);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
return true; return true;
+3 -1
View File
@@ -15,6 +15,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users; using Novelly.Api.Users;
using Serilog; using Serilog;
@@ -128,7 +129,8 @@ app.MapNovelEndpoints()
.MapOpenQuestionEndpoints() .MapOpenQuestionEndpoints()
.MapAgentEndpoints() .MapAgentEndpoints()
.MapImportEndpoints() .MapImportEndpoints()
.MapActivityEndpoints(); .MapActivityEndpoints()
.MapTrashEndpoints();
app.Run(); app.Run();
@@ -76,22 +76,28 @@ public class ResolveOpenQuestionRequestValidator : IModelValidator<ResolveOpenQu
public static class OpenQuestionMapping public static class OpenQuestionMapping
{ {
public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null) => new( public static OpenQuestionResponse ToResponse(this OpenQuestion q, IReadOnlyDictionary<Guid, int>? displayNumbers = null)
{
var chapter = q.Chapter is { DeletedAt: null } ? q.Chapter : null;
var character = q.Character is { DeletedAt: null } ? q.Character : null;
return new(
q.Id, q.Id,
q.NovelId, q.NovelId,
q.Question, q.Question,
q.Detail, q.Detail,
q.ChapterId, q.ChapterId,
q.Chapter?.Number, chapter?.Number,
q.Chapter?.Title, chapter?.Title,
q.Chapter is { } chapter chapter is not null
? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title) ? ChapterNumbering.Label(chapter.Kind, displayNumbers is not null && displayNumbers.TryGetValue(chapter.Id, out var n) ? n : null, chapter.Title)
: null, : null,
q.CharacterId, q.CharacterId,
q.Character?.Name, character?.Name,
q.Resolution, q.Resolution,
q.IsResolved, q.IsResolved,
q.ResolvedAt, q.ResolvedAt,
q.CreatedAt, q.CreatedAt,
q.UpdatedAt); q.UpdatedAt);
} }
}
+9
View File
@@ -0,0 +1,9 @@
namespace Novelly.Api.Trash;
public record TrashedItemResponse(
Guid Id,
TrashEntityKind Kind,
string Label,
string? Detail,
DateTimeOffset DeletedAt,
DateTimeOffset PurgeAfter);
+38
View File
@@ -0,0 +1,38 @@
using Novelly.Api.Common;
using Novelly.Api.Common.Validation;
namespace Novelly.Api.Trash;
public static class TrashEndpoints
{
public static IEndpointRouteBuilder MapTrashEndpoints(this IEndpointRouteBuilder app)
{
var novelScoped = app.MapGroup("/api/novels/{novelId:guid}/trash").WithTags("Trash")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
novelScoped.MapGet("/", async (Guid novelId, TrashService service, CancellationToken ct) =>
Results.Ok(await service.ListAsync(novelId, ct)))
.WithSummary("List everything in a novel's trash.");
novelScoped.MapDelete("/", async (Guid novelId, TrashService service, CancellationToken ct) =>
Results.Ok(new { purged = await service.EmptyAsync(novelId, ct) }))
.WithSummary("Empty a novel's trash, permanently deleting everything in it.");
var trash = app.MapGroup("/api/trash").WithTags("Trash")
.AddEndpointFilter<RequestLoggingEndpointFilter>()
.AddEndpointFilter<ValidationEndpointFilter>();
trash.MapPost("/{kind}/{id:guid}/restore", async (
TrashEntityKind kind, Guid id, TrashService service, CancellationToken ct) =>
await service.RestoreAsync(kind, id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Restore a trashed item.");
trash.MapDelete("/{kind}/{id:guid}", async (
TrashEntityKind kind, Guid id, TrashService service, CancellationToken ct) =>
await service.PurgeAsync(kind, id, ct) ? Results.NoContent() : Results.NotFound())
.WithSummary("Permanently delete a trashed item.");
return app;
}
}
+8
View File
@@ -0,0 +1,8 @@
namespace Novelly.Api.Trash;
public enum TrashEntityKind
{
Character,
Chapter,
Location
}
+10
View File
@@ -0,0 +1,10 @@
namespace Novelly.Api.Trash;
public class TrashOptions
{
public const string SectionName = "Trash";
public bool Enabled { get; set; } = true;
public int RetentionDays { get; set; } = 30;
public TimeOnly PurgeAtLocalTime { get; set; } = new(2, 0);
}
+57
View File
@@ -0,0 +1,57 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Data;
namespace Novelly.Api.Trash;
internal static class TrashPurge
{
public static async Task<bool> RemoveAsync(INovelDbContext db, TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch
{
TrashEntityKind.Character => await RemoveCharacterAsync(db, id, ct),
TrashEntityKind.Chapter => await RemoveChapterAsync(db, id, ct),
TrashEntityKind.Location => await RemoveLocationAsync(db, id, ct),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
private static async Task<bool> RemoveCharacterAsync(INovelDbContext db, Guid id, CancellationToken ct)
{
var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id, ct);
if (character is null)
{
return false;
}
var inboundRelationships = await db.CharacterRelationships
.IgnoreQueryFilters()
.Where(r => r.RelatedCharacterId == id)
.ToListAsync(ct);
db.CharacterRelationships.RemoveRange(inboundRelationships);
db.Characters.Remove(character);
return true;
}
private static async Task<bool> RemoveChapterAsync(INovelDbContext db, Guid id, CancellationToken ct)
{
var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id, ct);
if (chapter is null)
{
return false;
}
db.Chapters.Remove(chapter);
return true;
}
private static async Task<bool> RemoveLocationAsync(INovelDbContext db, Guid id, CancellationToken ct)
{
var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id, ct);
if (location is null)
{
return false;
}
db.Locations.Remove(location);
return true;
}
}
+84
View File
@@ -0,0 +1,84 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Data;
namespace Novelly.Api.Trash;
public class TrashPurgeRunner(
IServiceScopeFactory scopeFactory,
IOptions<TrashOptions> options,
TimeProvider clock,
ILogger<TrashPurgeRunner> logger) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (!options.Value.Enabled)
{
logger.LogInformation("Trash purge is disabled");
return;
}
await RunPurgePassAsync(stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
var now = clock.GetLocalNow();
var delay = TrashPurgeSchedule.NextRunAfter(now, options.Value.PurgeAtLocalTime) - now;
try
{
await Task.Delay(delay, clock, stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
await RunPurgePassAsync(stoppingToken);
}
}
private async Task RunPurgePassAsync(CancellationToken ct)
{
try
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<INovelDbContext>();
var cutoff = clock.GetUtcNow().AddDays(-options.Value.RetentionDays);
var (characterCount, chapterCount, locationCount) = await SweepAsync(db, cutoff, ct);
logger.LogInformation(
"Trash purge removed {CharacterCount} characters, {ChapterCount} chapters, {LocationCount} locations",
characterCount, chapterCount, locationCount);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "Trash purge pass failed");
}
}
public static async Task<(int Characters, int Chapters, int Locations)> SweepAsync(
INovelDbContext db, DateTimeOffset cutoff, CancellationToken ct)
{
var characterIds = await db.Characters.IgnoreQueryFilters()
.Where(c => c.DeletedAt != null && c.DeletedAt < cutoff).Select(c => c.Id).ToListAsync(ct);
var chapterIds = await db.Chapters.IgnoreQueryFilters()
.Where(c => c.DeletedAt != null && c.DeletedAt < cutoff).Select(c => c.Id).ToListAsync(ct);
var locationIds = await db.Locations.IgnoreQueryFilters()
.Where(l => l.DeletedAt != null && l.DeletedAt < cutoff).Select(l => l.Id).ToListAsync(ct);
foreach (var id in characterIds)
await TrashPurge.RemoveAsync(db, TrashEntityKind.Character, id, ct);
foreach (var id in chapterIds)
await TrashPurge.RemoveAsync(db, TrashEntityKind.Chapter, id, ct);
foreach (var id in locationIds)
await TrashPurge.RemoveAsync(db, TrashEntityKind.Location, id, ct);
await db.SaveChangesAsync(ct);
return (characterIds.Count, chapterIds.Count, locationIds.Count);
}
}
@@ -0,0 +1,11 @@
namespace Novelly.Api.Trash;
public static class TrashPurgeSchedule
{
public static DateTimeOffset NextRunAfter(DateTimeOffset now, TimeOnly runAt)
{
var candidate = new DateTimeOffset(now.Year, now.Month, now.Day, runAt.Hour, runAt.Minute, runAt.Second, now.Offset);
return candidate > now ? candidate : candidate.AddDays(1);
}
}
+186
View File
@@ -0,0 +1,186 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Novelly.Api.Activity;
using Novelly.Api.Common;
using Novelly.Api.Data;
using Novelly.Api.Users;
namespace Novelly.Api.Trash;
public class TrashService(
INovelDbContext db,
NovelAccessService access,
ActivityLog activity,
IOptions<TrashOptions> options,
ILogger<TrashService> logger)
{
public async Task<IReadOnlyList<TrashedItemResponse>> ListAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Listing trash for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.Read, ct);
var retentionDays = options.Value.RetentionDays;
var characters = await db.Characters
.IgnoreQueryFilters()
.Where(c => c.NovelId == novelId && c.DeletedAt != null)
.Select(c => new TrashedItemResponse(
c.Id, TrashEntityKind.Character, c.Name,
c.ArcStages.Count > 0 ? $"{c.ArcStages.Count} arc stages" : null,
c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays)))
.ToListAsync(ct);
var chapters = await db.Chapters
.IgnoreQueryFilters()
.Where(c => c.NovelId == novelId && c.DeletedAt != null)
.Select(c => new TrashedItemResponse(
c.Id, TrashEntityKind.Chapter, c.Title,
c.Beats.Count > 0 ? $"{c.Beats.Count} beats" : null,
c.DeletedAt!.Value, c.DeletedAt!.Value.AddDays(retentionDays)))
.ToListAsync(ct);
var locations = await db.Locations
.IgnoreQueryFilters()
.Where(l => l.NovelId == novelId && l.DeletedAt != null)
.Select(l => new TrashedItemResponse(
l.Id, TrashEntityKind.Location, l.Name,
l.Chapters.Count > 0 ? $"used by {l.Chapters.Count} chapters" : null,
l.DeletedAt!.Value, l.DeletedAt!.Value.AddDays(retentionDays)))
.ToListAsync(ct);
return [.. characters.Concat(chapters).Concat(locations).OrderByDescending(i => i.DeletedAt)];
}
public async Task<bool> RestoreAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Restoring {Kind} {ItemId} from trash", kind, id);
return kind switch
{
TrashEntityKind.Character => await RestoreCharacterAsync(id, ct),
TrashEntityKind.Chapter => await RestoreChapterAsync(id, ct),
TrashEntityKind.Location => await RestoreLocationAsync(id, ct),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
public async Task<bool> PurgeAsync(TrashEntityKind kind, Guid id, CancellationToken ct = default)
{
Guard.Default(id, nameof(id));
logger.LogInformation("Purging {Kind} {ItemId} from trash", kind, id);
var novelId = await NovelIdOfTrashedAsync(kind, id, ct);
if (novelId is null)
{
return false;
}
await access.RequireAsync(novelId.Value, NovelPermission.DeleteContent, ct);
var removed = await TrashPurge.RemoveAsync(db, kind, id, ct);
if (!removed)
{
return false;
}
await db.SaveChangesAsync(ct);
return true;
}
public async Task<int> EmptyAsync(Guid novelId, CancellationToken ct = default)
{
Guard.Default(novelId, nameof(novelId));
logger.LogInformation("Emptying trash for novel {NovelId}", novelId);
await access.RequireAsync(novelId, NovelPermission.DeleteContent, ct);
var items = await ListAsync(novelId, ct);
var purged = 0;
foreach (var item in items)
{
if (await TrashPurge.RemoveAsync(db, item.Kind, item.Id, ct))
{
purged++;
}
}
await db.SaveChangesAsync(ct);
logger.LogInformation("Emptied {Count} items from trash for novel {NovelId}", purged, novelId);
return purged;
}
private async Task<bool> RestoreCharacterAsync(Guid id, CancellationToken ct)
{
var character = await db.Characters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct);
if (character is null)
{
logger.LogWarning("Trashed character {CharacterId} not found", id);
return false;
}
await access.RequireAsync(character.NovelId, NovelPermission.DeleteContent, ct);
character.DeletedAt = null;
activity.Record(character.NovelId, ActivityEntityKind.Character, ActivityAction.Restored, character.Id);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<bool> RestoreChapterAsync(Guid id, CancellationToken ct)
{
var chapter = await db.Chapters.IgnoreQueryFilters().FirstOrDefaultAsync(c => c.Id == id && c.DeletedAt != null, ct);
if (chapter is null)
{
logger.LogWarning("Trashed chapter {ChapterId} not found", id);
return false;
}
await access.RequireAsync(chapter.NovelId, NovelPermission.DeleteContent, ct);
chapter.DeletedAt = null;
activity.Record(chapter.NovelId, ActivityEntityKind.Chapter, ActivityAction.Restored, chapter.Id, chapter.WordCount);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<bool> RestoreLocationAsync(Guid id, CancellationToken ct)
{
var location = await db.Locations.IgnoreQueryFilters().FirstOrDefaultAsync(l => l.Id == id && l.DeletedAt != null, ct);
if (location is null)
{
logger.LogWarning("Trashed location {LocationId} not found", id);
return false;
}
await access.RequireAsync(location.NovelId, NovelPermission.DeleteContent, ct);
var clash = await db.Locations.FirstOrDefaultAsync(
l => l.NovelId == location.NovelId && EF.Functions.Like(l.Name, location.Name), ct);
if (clash is not null)
{
logger.LogWarning("Rejected restore of location {LocationId}: '{Name}' already exists as {ClashLocationId}", id, location.Name, clash.Id);
throw new InvalidOperationException($"The novel already has a location called '{clash.Name}'.");
}
location.DeletedAt = null;
activity.Record(location.NovelId, ActivityEntityKind.Location, ActivityAction.Restored, location.Id);
await db.SaveChangesAsync(ct);
return true;
}
private async Task<Guid?> NovelIdOfTrashedAsync(TrashEntityKind kind, Guid id, CancellationToken ct) => kind switch
{
TrashEntityKind.Character => (await db.Characters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)),
TrashEntityKind.Chapter => (await db.Chapters.IgnoreQueryFilters().Where(c => c.Id == id && c.DeletedAt != null).Select(c => (Guid?)c.NovelId).FirstOrDefaultAsync(ct)),
TrashEntityKind.Location => (await db.Locations.IgnoreQueryFilters().Where(l => l.Id == id && l.DeletedAt != null).Select(l => (Guid?)l.NovelId).FirstOrDefaultAsync(ct)),
_ => throw new ArgumentOutOfRangeException(nameof(kind), kind, null)
};
}
+5
View File
@@ -38,5 +38,10 @@
}, },
"Imports": { "Imports": {
"RootPath": null "RootPath": null
},
"Trash": {
"Enabled": true,
"RetentionDays": 30,
"PurgeAtLocalTime": "02:00"
} }
} }
+2 -1
View File
@@ -44,7 +44,8 @@ public static class LocationTools
api.PatchAsync($"/api/locations/{locationId}", new { name }, ct); api.PatchAsync($"/api/locations/{locationId}", new { name }, ct);
[McpServerTool(Name = "delete_location")] [McpServerTool(Name = "delete_location")]
[Description("Delete a location. Whatever carried it is left alone — only the label goes.")] [Description("Move a location to the trash. Whatever carried it is left alone — only the label goes. "
+ "It can be restored from the Trash page within the retention window.")]
public static Task<CallToolResult> DeleteLocation( public static Task<CallToolResult> DeleteLocation(
NovelApiClient api, NovelApiClient api,
[Description("The location's id.")] Guid locationId, [Description("The location's id.")] Guid locationId,
@@ -21,17 +21,12 @@ public static class Extensions
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
{ {
builder.ConfigureOpenTelemetry(); builder.ConfigureOpenTelemetry();
builder.AddDefaultHealthChecks(); builder.AddDefaultHealthChecks();
builder.Services.AddServiceDiscovery(); builder.Services.AddServiceDiscovery();
builder.Services.ConfigureHttpClientDefaults(http => builder.Services.ConfigureHttpClientDefaults(http =>
{ {
// Turn on resilience by default
http.AddStandardResilienceHandler(); http.AddStandardResilienceHandler();
// Turn on service discovery by default
http.AddServiceDiscovery(); http.AddServiceDiscovery();
}); });
+2
View File
@@ -8,6 +8,7 @@ import TagsPage from './pages/TagsPage'
import LocationsPage from './pages/LocationsPage' import LocationsPage from './pages/LocationsPage'
import ChaptersPage from './pages/ChaptersPage' import ChaptersPage from './pages/ChaptersPage'
import ChapterPage from './pages/ChapterPage' import ChapterPage from './pages/ChapterPage'
import TrashPage from './pages/TrashPage'
import SettingsPage from './pages/SettingsPage' import SettingsPage from './pages/SettingsPage'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import { AuthProvider, useAuth } from './auth/AuthContext' import { AuthProvider, useAuth } from './auth/AuthContext'
@@ -43,6 +44,7 @@ export default function App() {
<Route path="chapters/:chapterId" element={<ChapterPage />} /> <Route path="chapters/:chapterId" element={<ChapterPage />} />
<Route path="tags" element={<TagsPage />} /> <Route path="tags" element={<TagsPage />} />
<Route path="locations" element={<LocationsPage />} /> <Route path="locations" element={<LocationsPage />} />
<Route path="trash" element={<TrashPage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
</Route> </Route>
<Route path="*" element={<NovelsPage />} /> <Route path="*" element={<NovelsPage />} />
+54
View File
@@ -18,6 +18,7 @@ import type {
ImportJobStatus, ImportJobStatus,
ImportUpload, ImportUpload,
OpenQuestion, OpenQuestion,
Location,
LocationReferences, LocationReferences,
LocationSummary, LocationSummary,
Novel, Novel,
@@ -26,6 +27,8 @@ import type {
NovelSummary, NovelSummary,
TagReferences, TagReferences,
TagSummary, TagSummary,
TrashedItem,
TrashEntityKind,
UiSettings, UiSettings,
User, User,
} from './types' } from './types'
@@ -52,6 +55,7 @@ export const keys = {
importBrowse: (path: string) => ['imports', 'browse', path] as const, importBrowse: (path: string) => ['imports', 'browse', path] as const,
novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const, novelActivity: (novelId: string) => ['novels', novelId, 'activity'] as const,
myActivity: ['activity'] as const, myActivity: ['activity'] as const,
trash: (novelId: string) => ['novels', novelId, 'trash'] as const,
} }
export const useUiSettings = () => export const useUiSettings = () =>
@@ -202,6 +206,7 @@ export function useDeleteCharacter(novelId: string) {
qc.invalidateQueries({ queryKey: keys.characters(novelId) }) qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) }) qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity }) qc.invalidateQueries({ queryKey: keys.myActivity })
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
}, },
}) })
} }
@@ -433,6 +438,14 @@ export const useLocationReferences = (locationId: string | undefined) =>
enabled: Boolean(locationId), enabled: Boolean(locationId),
}) })
export function useCreateLocation(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: (name: string) => api.post<Location>(`/api/novels/${novelId}/locations`, { name }),
onSuccess: () => qc.invalidateQueries({ queryKey: keys.locations(novelId) }),
})
}
export function useUpdateLocation(novelId: string) { export function useUpdateLocation(novelId: string) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
@@ -579,6 +592,7 @@ export function useDeleteChapter(novelId: string) {
qc.invalidateQueries({ queryKey: keys.chapters(novelId) }) qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) }) qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity }) qc.invalidateQueries({ queryKey: keys.myActivity })
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
}, },
}) })
} }
@@ -625,6 +639,46 @@ export const useMyActivity = () =>
queryFn: () => api.get<ActivityCalendar>('/api/activity'), queryFn: () => api.get<ActivityCalendar>('/api/activity'),
}) })
export const useTrash = (novelId: string) =>
useQuery({
queryKey: keys.trash(novelId),
queryFn: () => api.get<TrashedItem[]>(`/api/novels/${novelId}/trash`),
})
function invalidateAfterTrashChange(qc: ReturnType<typeof useQueryClient>, novelId: string) {
qc.invalidateQueries({ queryKey: keys.trash(novelId) })
qc.invalidateQueries({ queryKey: keys.characters(novelId) })
qc.invalidateQueries({ queryKey: keys.chapters(novelId) })
qc.invalidateQueries({ queryKey: keys.locations(novelId) })
qc.invalidateQueries({ queryKey: keys.novelActivity(novelId) })
qc.invalidateQueries({ queryKey: keys.myActivity })
}
export function useRestoreTrashed(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ kind, id }: { kind: TrashEntityKind; id: string }) =>
api.post(`/api/trash/${kind}/${id}/restore`, {}),
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
})
}
export function usePurgeTrashed(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ kind, id }: { kind: TrashEntityKind; id: string }) => api.delete(`/api/trash/${kind}/${id}`),
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
})
}
export function useEmptyTrash(novelId: string) {
const qc = useQueryClient()
return useMutation({
mutationFn: () => api.delete(`/api/novels/${novelId}/trash`),
onSuccess: () => invalidateAfterTrashChange(qc, novelId),
})
}
export function useInspectImport() { export function useInspectImport() {
return useMutation({ return useMutation({
mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }), mutationFn: (sourceRoot: string) => api.post<ImportInspection>('/api/imports/inspect', { sourceRoot }),
+13
View File
@@ -350,6 +350,19 @@ export interface ImportUpload {
markdownFileCount: number markdownFileCount: number
} }
export type TrashEntityKind = 'Character' | 'Chapter' | 'Location'
export const trashEntityKinds: TrashEntityKind[] = ['Character', 'Chapter', 'Location']
export interface TrashedItem {
id: string
kind: TrashEntityKind
label: string
detail: string | null
deletedAt: string
purgeAfter: string
}
export interface ActivityDay { export interface ActivityDay {
date: string date: string
words: number words: number
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { import {
useCharacterBeats, useCharacterBeats,
@@ -12,6 +12,8 @@ import {
import { chapterLabel } from '../api/chapterLabel' import { chapterLabel } from '../api/chapterLabel'
import type { ArcStage, Character, ChapterKind } from '../api/types' import type { ArcStage, Character, ChapterKind } from '../api/types'
import { AutoField, ErrorNote } from './ui' import { AutoField, ErrorNote } from './ui'
import { ConfirmModal } from './ConfirmModal'
import { useHotkey } from '../keyboard/HotkeysContext'
export function CharacterArc({ export function CharacterArc({
novelId, novelId,
@@ -32,6 +34,8 @@ export function CharacterArc({
const reorder = useReorderArcStages(novelId) const reorder = useReorderArcStages(novelId)
const [title, setTitle] = useState('') const [title, setTitle] = useState('')
const [pendingStageId, setPendingStageId] = useState<string | null>(null)
const titleInputRef = useRef<HTMLInputElement>(null)
const stages = character.arcStages const stages = character.arcStages
const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null) const unassignedBeats = (beats ?? []).filter((b) => b.arcStageId === null)
@@ -41,10 +45,15 @@ export function CharacterArc({
if (!title.trim()) return if (!title.trim()) return
create.mutate( create.mutate(
{ characterId: character.id, title: title.trim() }, { characterId: character.id, title: title.trim() },
{ onSuccess: () => setTitle('') }, { onSuccess: (stage) => { setTitle(''); setPendingStageId(stage.id) } },
) )
} }
useHotkey('a', 'Add arc stage', () => titleInputRef.current?.focus(), {
group: 'Character',
enabled: canCreate,
})
const move = (index: number, delta: number) => { const move = (index: number, delta: number) => {
const next = [...stages] const next = [...stages]
const [moved] = next.splice(index, 1) const [moved] = next.splice(index, 1)
@@ -80,6 +89,8 @@ export function CharacterArc({
onMove={(delta) => move(index, delta)} onMove={(delta) => move(index, delta)}
canWrite={canWrite} canWrite={canWrite}
canDelete={canDelete} canDelete={canDelete}
autoFocusTitle={pendingStageId === stage.id}
onTitleAutoFocused={() => setPendingStageId(null)}
/> />
))} ))}
</ol> </ol>
@@ -95,10 +106,14 @@ export function CharacterArc({
{canCreate && ( {canCreate && (
<form onSubmit={submit} className="mt-3 flex gap-2"> <form onSubmit={submit} className="mt-3 flex gap-2">
<input <input
ref={titleInputRef}
className="input flex-1" className="input flex-1"
placeholder="Add a section — a short title, e.g. “spoiled noble”" placeholder="Add a section — a short title, e.g. “spoiled noble”"
value={title} value={title}
onChange={(e) => setTitle(e.target.value)} onChange={(e) => setTitle(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setTitle('')
}}
/> />
<button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}> <button className="btn btn-primary shrink-0" disabled={!title.trim() || create.isPending}>
Add Add
@@ -125,6 +140,8 @@ function ArcStageRow({
onMove, onMove,
canWrite, canWrite,
canDelete, canDelete,
autoFocusTitle,
onTitleAutoFocused,
}: { }: {
novelId: string novelId: string
stage: ArcStage stage: ArcStage
@@ -135,10 +152,13 @@ function ArcStageRow({
onMove: (delta: number) => void onMove: (delta: number) => void
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
autoFocusTitle: boolean
onTitleAutoFocused: () => void
}) { }) {
const update = useUpdateArcStage(novelId) const update = useUpdateArcStage(novelId)
const remove = useDeleteArcStage(novelId) const remove = useDeleteArcStage(novelId)
const setBeats = useSetArcStageBeats(novelId, stage.characterId) const setBeats = useSetArcStageBeats(novelId, stage.characterId)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const addBeat = (beatId: string) => { const addBeat = (beatId: string) => {
if (!beatId) return if (!beatId) return
@@ -162,6 +182,9 @@ function ArcStageRow({
value={stage.title} value={stage.title}
onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })} onCommit={(title) => title.trim() && update.mutate({ id: stage.id, title })}
readOnly={!canWrite} readOnly={!canWrite}
autoFocus={autoFocusTitle}
selectOnFocus
onAutoFocused={onTitleAutoFocused}
/> />
<AutoField <AutoField
value={stage.result} value={stage.result}
@@ -266,9 +289,7 @@ function ArcStageRow({
<button <button
className="btn px-2 py-0.5 text-xs" className="btn px-2 py-0.5 text-xs"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
onClick={() => { onClick={() => setConfirmingDelete(true)}
if (confirm(`Delete “${stage.title}” from the arc?`)) remove.mutate(stage.id)
}}
aria-label="Delete stage" aria-label="Delete stage"
> >
@@ -277,6 +298,15 @@ function ArcStageRow({
</div> </div>
)} )}
</div> </div>
{confirmingDelete && (
<ConfirmModal
title="Delete stage"
message={`Delete "${stage.title}" from the arc?`}
onConfirm={() => remove.mutate(stage.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</li> </li>
) )
} }
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, type KeyboardEvent } from 'react'
import { import {
useDeleteQuestion, useDeleteQuestion,
useOpenQuestions, useOpenQuestions,
@@ -8,6 +8,10 @@ import {
} from '../api/hooks' } from '../api/hooks'
import type { OpenQuestion } from '../api/types' import type { OpenQuestion } from '../api/types'
import { ErrorNote, Spinner } from './ui' import { ErrorNote, Spinner } from './ui'
import { ConfirmModal } from './ConfirmModal'
import { useHotkey } from '../keyboard/HotkeysContext'
const isSubmitCombo = (e: KeyboardEvent) => (e.metaKey || e.ctrlKey) && e.key === 'Enter'
export function OpenQuestions({ export function OpenQuestions({
novelId, novelId,
@@ -34,8 +38,7 @@ export function OpenQuestions({
const [question, setQuestion] = useState('') const [question, setQuestion] = useState('')
const [detail, setDetail] = useState('') const [detail, setDetail] = useState('')
const submit = (e: React.FormEvent) => { const raiseQuestion = () => {
e.preventDefault()
if (!question.trim()) return if (!question.trim()) return
raise.mutate( raise.mutate(
{ question: question.trim(), detail: detail.trim() || undefined, ...scope }, { question: question.trim(), detail: detail.trim() || undefined, ...scope },
@@ -49,6 +52,14 @@ export function OpenQuestions({
) )
} }
useHotkey('q', 'Raise a question', () => setAsking(true), { group: 'Questions', enabled: canCreate })
const cancelAsking = () => {
setAsking(false)
setQuestion('')
setDetail('')
}
const openCount = questions?.filter((q) => !q.isResolved).length ?? 0 const openCount = questions?.filter((q) => !q.isResolved).length ?? 0
return ( return (
@@ -68,7 +79,7 @@ export function OpenQuestions({
Show resolved Show resolved
</label> </label>
{canCreate && ( {canCreate && (
<button className="btn" onClick={() => setAsking((open) => !open)}> <button className="btn" onClick={() => (asking ? cancelAsking() : setAsking(true))}>
{asking ? 'Cancel' : 'Ask'} {asking ? 'Cancel' : 'Ask'}
</button> </button>
)} )}
@@ -76,7 +87,23 @@ export function OpenQuestions({
</div> </div>
{asking && ( {asking && (
<form onSubmit={submit} className="mb-4 grid gap-2"> <form
onSubmit={(e) => {
e.preventDefault()
raiseQuestion()
}}
className="mb-4 grid gap-2"
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelAsking()
return
}
if (isSubmitCombo(e)) {
e.preventDefault()
raiseQuestion()
}
}}
>
<input <input
className="input" className="input"
autoFocus autoFocus
@@ -147,9 +174,14 @@ function QuestionRow({
const [resolving, setResolving] = useState(false) const [resolving, setResolving] = useState(false)
const [resolution, setResolution] = useState('') const [resolution, setResolution] = useState('')
const [appendToNotes, setAppendToNotes] = useState(true) const [appendToNotes, setAppendToNotes] = useState(true)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const submit = (e: React.FormEvent) => { const cancelResolving = () => {
e.preventDefault() setResolving(false)
setResolution('')
}
const submitResolution = () => {
if (!resolution.trim()) return if (!resolution.trim()) return
resolve.mutate( resolve.mutate(
{ id: question.id, resolution: resolution.trim(), appendToNotes }, { id: question.id, resolution: resolution.trim(), appendToNotes },
@@ -204,7 +236,7 @@ function QuestionRow({
) : ( ) : (
<button <button
className="btn px-2 py-1 text-xs" className="btn px-2 py-1 text-xs"
onClick={() => setResolving((open) => !open)} onClick={() => (resolving ? cancelResolving() : setResolving(true))}
> >
{resolving ? 'Cancel' : 'Resolve'} {resolving ? 'Cancel' : 'Resolve'}
</button> </button>
@@ -214,11 +246,7 @@ function QuestionRow({
<button <button
className="btn px-2 py-1 text-xs" className="btn px-2 py-1 text-xs"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
onClick={() => { onClick={() => setConfirmingDelete(true)}
if (confirm('Delete this question? Resolving keeps the decision; deleting does not.')) {
remove.mutate(question.id)
}
}}
> >
Delete Delete
</button> </button>
@@ -227,7 +255,23 @@ function QuestionRow({
</div> </div>
{resolving && ( {resolving && (
<form onSubmit={submit} className="mt-2 grid gap-2"> <form
onSubmit={(e) => {
e.preventDefault()
submitResolution()
}}
className="mt-2 grid gap-2"
onKeyDown={(e) => {
if (e.key === 'Escape') {
cancelResolving()
return
}
if (isSubmitCombo(e)) {
e.preventDefault()
submitResolution()
}
}}
>
<textarea <textarea
className="input" className="input"
rows={2} rows={2}
@@ -252,6 +296,15 @@ function QuestionRow({
{resolve.error && <ErrorNote error={resolve.error} />} {resolve.error && <ErrorNote error={resolve.error} />}
</form> </form>
)} )}
{confirmingDelete && (
<ConfirmModal
title="Delete question"
message="Delete this question? Resolving keeps the decision; deleting does not."
onConfirm={() => remove.mutate(question.id)}
onClose={() => setConfirmingDelete(false)}
/>
)}
</li> </li>
) )
} }
+11
View File
@@ -75,6 +75,17 @@ export function IconAgent(props: SVGProps<SVGSVGElement>) {
) )
} }
export function IconTrash(props: SVGProps<SVGSVGElement>) {
return (
<Icon {...props}>
<path d="M4.5 7h15" />
<path d="M9.5 7V4.8a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1V7" />
<path d="M6.5 7l1 12.2a1 1 0 0 0 1 .8h7a1 1 0 0 0 1-.8L17.5 7" />
<path d="M10 11v6M14 11v6" />
</Icon>
)
}
export function IconSettings(props: SVGProps<SVGSVGElement>) { export function IconSettings(props: SVGProps<SVGSVGElement>) {
return ( return (
<Icon {...props}> <Icon {...props}>
+58 -3
View File
@@ -1,6 +1,7 @@
import { useEffect, useId, useRef, useState, type MouseEvent, type ReactNode } from 'react' import { useEffect, useId, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import { draftStatusColor } from '../api/stage' import { draftStatusColor } from '../api/stage'
import type { DraftStatus } from '../api/types' import type { DraftStatus } from '../api/types'
import { focusNextTabbable } from '../keyboard/focus'
export function Spinner({ label = 'Loading' }: { label?: string }) { export function Spinner({ label = 'Loading' }: { label?: string }) {
return ( return (
@@ -59,6 +60,9 @@ export function AutoField({
suggestions, suggestions,
onContextMenu, onContextMenu,
readOnly, readOnly,
autoFocus,
selectOnFocus,
onAutoFocused,
}: { }: {
label?: string label?: string
value: string | null | undefined value: string | null | undefined
@@ -70,10 +74,17 @@ export function AutoField({
suggestions?: readonly string[] suggestions?: readonly string[]
onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void onContextMenu?: (e: MouseEvent<HTMLTextAreaElement>) => void
readOnly?: boolean readOnly?: boolean
autoFocus?: boolean
selectOnFocus?: boolean
onAutoFocused?: () => void
}) { }) {
const [draft, setDraft] = useState(value ?? '') const [draft, setDraft] = useState(value ?? '')
const committed = useRef(value ?? '') const committed = useRef(value ?? '')
const suggestionsId = useId() const suggestionsId = useId()
const inputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const onAutoFocusedRef = useRef(onAutoFocused)
onAutoFocusedRef.current = onAutoFocused
useEffect(() => { useEffect(() => {
const incoming = value ?? '' const incoming = value ?? ''
@@ -83,6 +94,14 @@ export function AutoField({
} }
}, [value]) }, [value])
useEffect(() => {
if (!autoFocus) return
const field = multiline ? textareaRef.current : inputRef.current
field?.focus()
if (selectOnFocus) field?.select()
onAutoFocusedRef.current?.()
}, [autoFocus, multiline, selectOnFocus])
const commit = () => { const commit = () => {
if (draft !== committed.current) { if (draft !== committed.current) {
committed.current = draft committed.current = draft
@@ -90,6 +109,31 @@ export function AutoField({
} }
} }
const revert = () => setDraft(committed.current)
const onSingleLineKeyDown = (e: ReactKeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
revert()
return
}
if (e.key !== 'Enter') return
e.preventDefault()
commit()
if (e.metaKey || e.ctrlKey) return
focusNextTabbable(e.currentTarget, e.shiftKey ? -1 : 1)
}
const onMultilineKeyDown = (e: ReactKeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape') {
revert()
return
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault()
commit()
}
}
const className = `input ${serif ? 'prose-serif' : ''}` const className = `input ${serif ? 'prose-serif' : ''}`
return ( return (
@@ -97,25 +141,28 @@ export function AutoField({
{label && <span className="label">{label}</span>} {label && <span className="label">{label}</span>}
{multiline ? ( {multiline ? (
<textarea <textarea
ref={textareaRef}
className={className} className={className}
rows={rows} rows={rows}
value={draft} value={draft}
placeholder={placeholder} placeholder={placeholder}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={onMultilineKeyDown}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
readOnly={readOnly} readOnly={readOnly}
/> />
) : ( ) : (
<> <>
<input <input
ref={inputRef}
className={className} className={className}
value={draft} value={draft}
placeholder={placeholder} placeholder={placeholder}
list={suggestions?.length ? suggestionsId : undefined} list={suggestions?.length ? suggestionsId : undefined}
onChange={(e) => setDraft(e.target.value)} onChange={(e) => setDraft(e.target.value)}
onBlur={commit} onBlur={commit}
onKeyDown={(e) => e.key === 'Enter' && e.currentTarget.blur()} onKeyDown={onSingleLineKeyDown}
readOnly={readOnly} readOnly={readOnly}
/> />
{suggestions?.length ? ( {suggestions?.length ? (
@@ -137,17 +184,25 @@ export function Select<T extends string>({
value, value,
options, options,
onChange, onChange,
autoFocus,
}: { }: {
id?: string id?: string
label?: string label?: string
value: T value: T
options: readonly T[] options: readonly T[]
onChange: (next: T) => void onChange: (next: T) => void
autoFocus?: boolean
}) { }) {
const selectRef = useRef<HTMLSelectElement>(null)
useEffect(() => {
if (autoFocus) selectRef.current?.focus()
}, [autoFocus])
return ( return (
<label className="block"> <label className="block">
{label && <span className="label">{label}</span>} {label && <span className="label">{label}</span>}
<select id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}> <select ref={selectRef} id={id} className="input" value={value} onChange={(e) => onChange(e.target.value as T)}>
{options.map((option) => ( {options.map((option) => (
<option key={option} value={option}> <option key={option} value={option}>
{option} {option}
+20 -1
View File
@@ -1,6 +1,13 @@
import { useHelpOverlay } from './HelpOverlayContext' import { useHelpOverlay } from './HelpOverlayContext'
import { useHotkeysList } from './HotkeysContext' import { useHotkeysList } from './HotkeysContext'
const CONVENTIONS = [
'Escape cancels or closes. It reverts the field youre in; fields you already tabbed past stay saved.',
'Enter commits a single-line field and moves to the next one, like Tab.',
'mod+Enter commits a multiline field, or finishes the record youre editing.',
'Destructive actions always confirm through a dialog you can dismiss with Escape — never a browser popup.',
]
const formatToken = (token: string) => { const formatToken = (token: string) => {
if (token === 'mod') return '⌘/Ctrl' if (token === 'mod') return '⌘/Ctrl'
if (token.length === 1) return token.toUpperCase() if (token.length === 1) return token.toUpperCase()
@@ -80,13 +87,25 @@ export function HelpOverlay() {
<ul className="grid gap-2"> <ul className="grid gap-2">
{groupShortcuts.map((shortcut) => ( {groupShortcuts.map((shortcut) => (
<li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm"> <li key={shortcut.id} className="flex items-center justify-between gap-3 text-sm">
<span>{shortcut.description}</span> <span>
{shortcut.description}
{shortcut.allowInInputs && <span className="muted"> · works while typing</span>}
</span>
<KeySequence keys={shortcut.keys} /> <KeySequence keys={shortcut.keys} />
</li> </li>
))} ))}
</ul> </ul>
</div> </div>
))} ))}
<div>
<h3 className="label mb-2">Conventions</h3>
<ul className="grid gap-2 text-sm muted">
{CONVENTIONS.map((convention) => (
<li key={convention}>{convention}</li>
))}
</ul>
</div>
</div> </div>
</aside> </aside>
</div> </div>
+21
View File
@@ -0,0 +1,21 @@
const TABBABLE_SELECTOR = 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])'
export function focusNextTabbable(from: HTMLElement, direction: 1 | -1, within?: HTMLElement | null) {
const scope = within ?? document.body
const candidates = [...scope.querySelectorAll<HTMLElement>(TABBABLE_SELECTOR)]
const index = candidates.indexOf(from)
if (index === -1) return
const target = candidates[index + direction]
if (!target) return
target.focus()
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
target.select()
}
}
export function isWithin(container: HTMLElement | null, node: Node | null): boolean {
if (!container || !node) return false
return container.contains(node)
}
+104 -30
View File
@@ -1,5 +1,5 @@
import { useState, type MouseEvent } from 'react' import { useEffect, useRef, useState, type MouseEvent } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom' import { Link, useLocation, useNavigate, useParams } from 'react-router-dom'
import { import {
useAssignCharacterToBeats, useAssignCharacterToBeats,
useChapter, useChapter,
@@ -29,12 +29,14 @@ import { useCharacterContextMenu } from '../components/CharacterContextMenu'
import { MarkdownEditor } from '../components/MarkdownEditor' import { MarkdownEditor } from '../components/MarkdownEditor'
import { OpenQuestions } from '../components/OpenQuestions' import { OpenQuestions } from '../components/OpenQuestions'
import { useHotkey } from '../keyboard/HotkeysContext' import { useHotkey } from '../keyboard/HotkeysContext'
import { isWithin } from '../keyboard/focus'
type ChapterTab = 'outline' | 'prose' type ChapterTab = 'outline' | 'prose'
export default function ChapterPage() { export default function ChapterPage() {
const { novelId = '', chapterId = '' } = useParams() const { novelId = '', chapterId = '' } = useParams()
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation()
const { data: chapter, isPending, error } = useChapter(chapterId) const { data: chapter, isPending, error } = useChapter(chapterId)
const { data: novel } = useNovel(novelId) const { data: novel } = useNovel(novelId)
const { data: characters } = useCharacters(novelId) const { data: characters } = useCharacters(novelId)
@@ -47,13 +49,32 @@ export default function ChapterPage() {
const createBeat = useCreateBeat(chapterId, novelId) const createBeat = useCreateBeat(chapterId, novelId)
const [tab, setTab] = useState<ChapterTab>('outline') const [tab, setTab] = useState<ChapterTab>('outline')
const [confirmingDelete, setConfirmingDelete] = useState(false) const [confirmingDelete, setConfirmingDelete] = useState(false)
const [editingBeatId, setEditingBeatId] = useState<string | null>(null)
const [focusBeatId, setFocusBeatId] = useState<string | null>(null)
const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId) const { handleContextMenu, menuElement } = useCharacterContextMenu(novelId)
const { can } = useAuth() const { can } = useAuth()
const canWrite = can('Write', novel) const canWrite = can('Write', novel)
const canCreate = can('CreateContent', novel) const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', novel) const canDelete = can('DeleteContent', novel)
useHotkey('b', 'Add beat', () => canCreate && createBeat.mutate({ title: 'New beat' }), { group: 'Chapter' }) const focusTitleOnArrival = Boolean((location.state as { focusTitle?: boolean } | null)?.focusTitle)
const clearFocusTitleState = () => navigate(location.pathname, { replace: true, state: null })
const addBeat = () => {
if (!canCreate) return
createBeat.mutate(
{ title: 'New beat' },
{
onSuccess: (beat) => {
setEditingBeatId(beat.id)
setFocusBeatId(beat.id)
},
},
)
}
useHotkey('b', 'Add beat', addBeat, { group: 'Chapter' })
const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1 const currentIndex = chapters?.findIndex((c) => c.id === chapterId) ?? -1
const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined const prevChapter = currentIndex > 0 ? chapters?.[currentIndex - 1] : undefined
@@ -168,6 +189,9 @@ export default function ChapterPage() {
value={chapter.title} value={chapter.title}
onCommit={(title) => title.trim() && patch({ title })} onCommit={(title) => title.trim() && patch({ title })}
readOnly={!canWrite} readOnly={!canWrite}
autoFocus={focusTitleOnArrival}
selectOnFocus
onAutoFocused={clearFocusTitleState}
/> />
<Select <Select
id="chapter-kind-select" id="chapter-kind-select"
@@ -197,22 +221,13 @@ export default function ChapterPage() {
/> />
</div> </div>
<div className="mt-4">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</div>
<div className="mt-4 flex items-end justify-between gap-4"> <div className="mt-4 flex items-end justify-between gap-4">
<div className="text-sm muted"> <div className="text-sm muted">
{chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words {chapter.beats.length} beats · {chapter.wordCount.toLocaleString()} words
</div> </div>
{canDelete && ( {canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}> <button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete chapter Move to trash
</button> </button>
)} )}
</div> </div>
@@ -235,8 +250,9 @@ export default function ChapterPage() {
{confirmingDelete && ( {confirmingDelete && (
<ConfirmModal <ConfirmModal
title="Delete chapter" title="Move to trash"
message={`Delete chapter "${chapter.title}" and everything in it? This cannot be undone.`} message={`Move chapter "${chapter.title}" and its beats to the trash? You can restore it from the Trash page.`}
confirmLabel="Move to trash"
onConfirm={() => onConfirm={() =>
remove.mutate(chapter.id, { remove.mutate(chapter.id, {
onSuccess: () => navigate(`/novels/${novelId}/chapters`), onSuccess: () => navigate(`/novels/${novelId}/chapters`),
@@ -274,14 +290,14 @@ export default function ChapterPage() {
onCharacterContextMenu={handleContextMenu} onCharacterContextMenu={handleContextMenu}
canWrite={canWrite} canWrite={canWrite}
canDelete={canDelete} canDelete={canDelete}
editingId={editingBeatId}
setEditingId={setEditingBeatId}
focusBeatId={focusBeatId}
setFocusBeatId={setFocusBeatId}
/> />
{canCreate && ( {canCreate && (
<button <button className="btn btn-primary mt-3" onClick={addBeat} disabled={createBeat.isPending}>
className="btn btn-primary mt-3"
onClick={() => createBeat.mutate({ title: 'New beat' })}
disabled={createBeat.isPending}
>
Add beat Add beat
</button> </button>
)} )}
@@ -328,6 +344,15 @@ export default function ChapterPage() {
</section> </section>
)} )}
<section id="chapter-tags" className="card mt-6 p-5">
<TagEditor
label="Tags"
tags={chapter.tags}
suggestions={suggestions}
onChange={(tags) => canWrite && patch({ tags })}
/>
</section>
{menuElement} {menuElement}
</div> </div>
) )
@@ -345,6 +370,10 @@ function BeatTable({
onCharacterContextMenu, onCharacterContextMenu,
canWrite, canWrite,
canDelete, canDelete,
editingId,
setEditingId,
focusBeatId,
setFocusBeatId,
}: { }: {
chapter: Chapter chapter: Chapter
novelId: string novelId: string
@@ -358,13 +387,16 @@ function BeatTable({
) => void ) => void
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
editingId: string | null
setEditingId: (id: string | null) => void
focusBeatId: string | null
setFocusBeatId: (id: string | null) => void
}) { }) {
const update = useUpdateBeat(chapter.id, novelId) const update = useUpdateBeat(chapter.id, novelId)
const remove = useDeleteBeat(chapter.id, novelId) const remove = useDeleteBeat(chapter.id, novelId)
const reorder = useReorderBeats(chapter.id) const reorder = useReorderBeats(chapter.id)
const assignCharacter = useAssignCharacterToBeats(chapter.id) const assignCharacter = useAssignCharacterToBeats(chapter.id)
const moveBeats = useMoveBeats(chapter.id) const moveBeats = useMoveBeats(chapter.id)
const [editingId, setEditingId] = useState<string | null>(null)
const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null) const [deletingBeat, setDeletingBeat] = useState<Beat | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([]) const [selectedIds, setSelectedIds] = useState<string[]>([])
const [assignCharacterId, setAssignCharacterId] = useState('') const [assignCharacterId, setAssignCharacterId] = useState('')
@@ -372,6 +404,25 @@ function BeatTable({
const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null) const [focusedBeatId, setFocusedBeatId] = useState<string | null>(null)
const [dragBeatId, setDragBeatId] = useState<string | null>(null) const [dragBeatId, setDragBeatId] = useState<string | null>(null)
const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null) const [dragOverBeatId, setDragOverBeatId] = useState<string | null>(null)
const [returnFocusBeatId, setReturnFocusBeatId] = useState<string | null>(null)
const rowRefs = useRef(new Map<string, HTMLTableRowElement>())
useEffect(() => {
if (!returnFocusBeatId) return
document.getElementById(`beat-${returnFocusBeatId}`)?.focus()
setReturnFocusBeatId(null)
}, [returnFocusBeatId])
const openEdit = (beatId: string) => {
if (!canWrite) return
setEditingId(beatId)
setFocusBeatId(beatId)
}
const closeEdit = (beatId: string) => {
setEditingId(null)
setReturnFocusBeatId(beatId)
}
const toggleSelected = (id: string) => const toggleSelected = (id: string) =>
setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id])) setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]))
@@ -449,7 +500,7 @@ function BeatTable({
const moveButtonClass = const moveButtonClass =
'flex h-6 w-6 items-center justify-center rounded text-base leading-none transition hover:bg-[var(--accent-soft)] disabled:opacity-25 disabled:hover:bg-transparent' 'flex h-6 w-6 items-center justify-center rounded text-base leading-none transition hover:bg-[var(--accent-soft)] disabled:opacity-25 disabled:hover:bg-transparent'
const renderMoveButtons = (beat: Beat, index: number) => ( const renderMoveButtons = (beat: Beat, index: number, focusable: boolean) => (
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span <span
className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'} className={canWrite ? 'cursor-grab text-base muted' : 'text-base muted'}
@@ -463,6 +514,7 @@ function BeatTable({
<button <button
id={`move-beat-up-${beat.id}`} id={`move-beat-up-${beat.id}`}
className={moveButtonClass} className={moveButtonClass}
tabIndex={focusable ? undefined : -1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
move(index, -1) move(index, -1)
@@ -476,6 +528,7 @@ function BeatTable({
<button <button
id={`move-beat-down-${beat.id}`} id={`move-beat-down-${beat.id}`}
className={moveButtonClass} className={moveButtonClass}
tabIndex={focusable ? undefined : -1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
move(index, 1) move(index, 1)
@@ -575,28 +628,48 @@ function BeatTable({
<tr <tr
key={beat.id} key={beat.id}
id={`beat-${beat.id}`} id={`beat-${beat.id}`}
ref={(el) => {
if (el) rowRefs.current.set(beat.id, el)
else rowRefs.current.delete(beat.id)
}}
style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }} style={{ borderBottom: '1px solid var(--line)', background: 'var(--accent-soft)' }}
onBlur={(e) => { onBlur={() => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setEditingId(null) window.setTimeout(() => {
if (!isWithin(rowRefs.current.get(beat.id) ?? null, document.activeElement)) {
setEditingId(null)
}
}, 0)
}}
onKeyDown={(e) => {
if (e.key === 'Escape') {
closeEdit(beat.id)
return
}
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
closeEdit(beat.id)
}
}} }}
onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)}
> >
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<input <input
type="checkbox" type="checkbox"
tabIndex={-1}
aria-label={`Select beat ${beat.title}`} aria-label={`Select beat ${beat.title}`}
checked={selectedIds.includes(beat.id)} checked={selectedIds.includes(beat.id)}
onChange={() => toggleSelected(beat.id)} onChange={() => toggleSelected(beat.id)}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
</td> </td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td> <td className="px-2 py-2 align-top">{renderMoveButtons(beat, index, false)}</td>
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<AutoField <AutoField
value={beat.title} value={beat.title}
placeholder="Three to five words" placeholder="Three to five words"
onCommit={(title) => title.trim() && patch(beat.id, { title })} onCommit={(title) => title.trim() && patch(beat.id, { title })}
autoFocus={focusBeatId === beat.id}
selectOnFocus
onAutoFocused={() => setFocusBeatId(null)}
/> />
<div className="mt-1.5"> <div className="mt-1.5">
<TagEditor <TagEditor
@@ -651,8 +724,9 @@ function BeatTable({
<button <button
className="text-xs leading-none" className="text-xs leading-none"
style={{ color: 'var(--accent)' }} style={{ color: 'var(--accent)' }}
onClick={() => setEditingId(null)} onClick={() => closeEdit(beat.id)}
aria-label="Done editing beat" aria-label="Done editing beat"
title="Done (mod+Enter)"
> >
</button> </button>
@@ -687,14 +761,14 @@ function BeatTable({
opacity: dragBeatId === beat.id ? 0.4 : 1, opacity: dragBeatId === beat.id ? 0.4 : 1,
boxShadow: dragOverBeatId === beat.id && dragBeatId !== beat.id ? 'inset 0 2px 0 0 var(--accent)' : undefined, boxShadow: dragOverBeatId === beat.id && dragBeatId !== beat.id ? 'inset 0 2px 0 0 var(--accent)' : undefined,
}} }}
onClick={canWrite ? () => setEditingId(beat.id) : undefined} onClick={canWrite ? () => openEdit(beat.id) : undefined}
onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined} onFocus={canWrite ? () => setFocusedBeatId(beat.id) : undefined}
onKeyDown={ onKeyDown={
canWrite canWrite
? (e) => { ? (e) => {
if (e.key === 'Enter' || e.key === ' ') { if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault() e.preventDefault()
setEditingId(beat.id) openEdit(beat.id)
} }
} }
: undefined : undefined
@@ -737,7 +811,7 @@ function BeatTable({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
</td> </td>
<td className="px-2 py-2 align-top">{renderMoveButtons(beat, index)}</td> <td className="px-2 py-2 align-top">{renderMoveButtons(beat, index, true)}</td>
<td className="px-2 py-2 align-top"> <td className="px-2 py-2 align-top">
<div className="font-medium">{beat.title}</div> <div className="font-medium">{beat.title}</div>
+15 -7
View File
@@ -1,4 +1,4 @@
import { Link, useParams } from 'react-router-dom' import { Link, useNavigate, useParams } from 'react-router-dom'
import { useChapters, useCreateChapter, useNovel } from '../api/hooks' import { useChapters, useCreateChapter, useNovel } from '../api/hooks'
import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui' import { EmptyState, ErrorNote, Spinner, StatusBadge } from '../components/ui'
import { TagChip } from '../components/TagEditor' import { TagChip } from '../components/TagEditor'
@@ -7,13 +7,25 @@ import { useHotkey } from '../keyboard/HotkeysContext'
export default function ChaptersPage() { export default function ChaptersPage() {
const { novelId = '' } = useParams() const { novelId = '' } = useParams()
const navigate = useNavigate()
const { data: chapters, isPending, error } = useChapters(novelId) const { data: chapters, isPending, error } = useChapters(novelId)
const { data: novel } = useNovel(novelId) const { data: novel } = useNovel(novelId)
const { can } = useAuth() const { can } = useAuth()
const canCreate = can('CreateContent', novel) const canCreate = can('CreateContent', novel)
const create = useCreateChapter(novelId) const create = useCreateChapter(novelId)
useHotkey('n', 'Add chapter', () => canCreate && create.mutate({ title: 'Untitled chapter' }), { group: 'Chapters' }) const addChapter = () => {
if (!canCreate) return
create.mutate(
{ title: 'Untitled chapter' },
{
onSuccess: (chapter) =>
navigate(`/novels/${novelId}/chapters/${chapter.id}`, { state: { focusTitle: true } }),
},
)
}
useHotkey('n', 'Add chapter', addChapter, { group: 'Chapters' })
if (isPending) return <Spinner label="Loading chapters" /> if (isPending) return <Spinner label="Loading chapters" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -23,11 +35,7 @@ export default function ChaptersPage() {
<div className="mb-5 flex items-center justify-between gap-4"> <div className="mb-5 flex items-center justify-between gap-4">
<h2 className="text-xl font-semibold">Chapters</h2> <h2 className="text-xl font-semibold">Chapters</h2>
{canCreate && ( {canCreate && (
<button <button className="btn btn-primary" onClick={addChapter} disabled={create.isPending}>
className="btn btn-primary"
onClick={() => create.mutate({ title: 'Untitled chapter' })}
disabled={create.isPending}
>
Add chapter Add chapter
</button> </button>
)} )}
@@ -292,8 +292,9 @@ function CharacterSheet({
{confirmingDelete && ( {confirmingDelete && (
<ConfirmModal <ConfirmModal
title="Delete character" title="Move to trash"
message={`Delete ${character.name}? This cannot be undone.`} message={`Move ${character.name} to the trash? You can restore it from the Trash page.`}
confirmLabel="Move to trash"
onConfirm={() => onConfirm={() =>
remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) }) remove.mutate(character.id, { onSuccess: () => navigate(`/novels/${novelId}/characters`) })
} }
+72 -23
View File
@@ -1,10 +1,18 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { Link, useParams, useSearchParams } from 'react-router-dom' import { Link, useParams, useSearchParams } from 'react-router-dom'
import { useDeleteLocation, useLocationReferences, useLocations, useNovel, useUpdateLocation } from '../api/hooks' import {
useCreateLocation,
useDeleteLocation,
useLocationReferences,
useLocations,
useNovel,
useUpdateLocation,
} from '../api/hooks'
import { chapterLabel } from '../api/chapterLabel' import { chapterLabel } from '../api/chapterLabel'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { EmptyState, ErrorNote, Spinner } from '../components/ui' import { AutoField, EmptyState, ErrorNote, Spinner } from '../components/ui'
import { ConfirmModal } from '../components/ConfirmModal' import { ConfirmModal } from '../components/ConfirmModal'
import { useHotkey } from '../keyboard/HotkeysContext'
export default function LocationsPage() { export default function LocationsPage() {
const { novelId = '' } = useParams() const { novelId = '' } = useParams()
@@ -12,9 +20,16 @@ export default function LocationsPage() {
const { data: novel } = useNovel(novelId) const { data: novel } = useNovel(novelId)
const { can } = useAuth() const { can } = useAuth()
const canWrite = can('Write', novel) const canWrite = can('Write', novel)
const canCreate = can('CreateContent', novel)
const canDelete = can('DeleteContent', novel) const canDelete = can('DeleteContent', novel)
const [searchParams, setSearchParams] = useSearchParams() const [searchParams, setSearchParams] = useSearchParams()
const selectedId = searchParams.get('location') ?? undefined const selectedId = searchParams.get('location') ?? undefined
const [name, setName] = useState('')
const [justCreatedId, setJustCreatedId] = useState<string | null>(null)
const nameInputRef = useRef<HTMLInputElement>(null)
const create = useCreateLocation(novelId)
useHotkey('n', 'Add location', () => canCreate && nameInputRef.current?.focus(), { group: 'Locations' })
if (isPending) return <Spinner label="Loading locations" /> if (isPending) return <Spinner label="Loading locations" />
if (error) return <ErrorNote error={error} /> if (error) return <ErrorNote error={error} />
@@ -27,20 +42,48 @@ export default function LocationsPage() {
return params return params
}) })
const submit = (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) return
create.mutate(name.trim(), {
onSuccess: (location) => {
setName('')
setJustCreatedId(location.id)
select(location.id)
},
})
}
return ( return (
<div id="locations-page" className="grid gap-6 lg:grid-cols-[18rem_1fr]"> <div id="locations-page" className="grid gap-6 lg:grid-cols-[18rem_1fr]">
<aside className="grid content-start gap-2"> <aside className="grid content-start gap-2">
<div> <div>
<h2 className="text-lg font-semibold">Locations</h2> <h2 className="text-lg font-semibold">Locations</h2>
<p className="text-sm muted"> <p className="text-sm muted">Pick one to see every chapter set there.</p>
Applied from a chapter. Pick one to see every chapter set there.
</p>
</div> </div>
{canCreate && (
<form id="location-create-form" onSubmit={submit} className="flex gap-2">
<input
id="location-create-input"
ref={nameInputRef}
className="input flex-1"
placeholder="Add a location…"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') setName('')
}}
/>
<button className="btn btn-primary shrink-0" disabled={!name.trim() || create.isPending}>
Add
</button>
</form>
)}
{create.error && <ErrorNote error={create.error} />}
{locations?.length === 0 && ( {locations?.length === 0 && (
<p className="mt-2 text-sm muted"> <p className="mt-2 text-sm muted">No locations yet.</p>
No locations yet. Add one from a chapter and it will appear here.
</p>
)} )}
{locations?.map((location) => ( {locations?.map((location) => (
@@ -64,7 +107,7 @@ export default function LocationsPage() {
{!selected ? ( {!selected ? (
<EmptyState <EmptyState
title="No locations yet" title="No locations yet"
hint="Locations cross-reference the book: attach one to a chapter, then trace it from here." hint="Add one on the left, or apply a new name to a chapter and it will appear here."
/> />
) : ( ) : (
<LocationReferencePanel <LocationReferencePanel
@@ -73,6 +116,8 @@ export default function LocationsPage() {
locationId={selected.id} locationId={selected.id}
canWrite={canWrite} canWrite={canWrite}
canDelete={canDelete} canDelete={canDelete}
autoFocusName={justCreatedId === selected.id}
onNameAutoFocused={() => setJustCreatedId(null)}
/> />
)} )}
</section> </section>
@@ -85,11 +130,15 @@ function LocationReferencePanel({
locationId, locationId,
canWrite, canWrite,
canDelete, canDelete,
autoFocusName,
onNameAutoFocused,
}: { }: {
novelId: string novelId: string
locationId: string locationId: string
canWrite: boolean canWrite: boolean
canDelete: boolean canDelete: boolean
autoFocusName: boolean
onNameAutoFocused: () => void
}) { }) {
const { data, isPending, error } = useLocationReferences(locationId) const { data, isPending, error } = useLocationReferences(locationId)
const update = useUpdateLocation(novelId) const update = useUpdateLocation(novelId)
@@ -105,21 +154,20 @@ function LocationReferencePanel({
return ( return (
<div className="grid gap-4"> <div className="grid gap-4">
<div className="card flex flex-wrap items-end justify-between gap-3 p-4"> <div className="card flex flex-wrap items-end justify-between gap-3 p-4">
<label className="block"> <div className="w-64">
<span className="label">Location name</span> <AutoField
<input label="Location name"
className="input w-64" value={data.location.name}
defaultValue={data.location.name}
readOnly={!canWrite} readOnly={!canWrite}
onBlur={(e) => { onCommit={(name) => name.trim() && update.mutate({ id: locationId, name: name.trim() })}
const name = e.target.value.trim() autoFocus={autoFocusName}
if (name && name !== data.location.name) update.mutate({ id: locationId, name }) selectOnFocus
}} onAutoFocused={onNameAutoFocused}
/> />
</label> </div>
{canDelete && ( {canDelete && (
<button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}> <button className="btn btn-danger" onClick={() => setConfirmingDelete(true)}>
Delete location Move to trash
</button> </button>
)} )}
</div> </div>
@@ -128,8 +176,9 @@ function LocationReferencePanel({
{confirmingDelete && ( {confirmingDelete && (
<ConfirmModal <ConfirmModal
title="Delete location" title="Move to trash"
message={`Delete the location "${data.location.name}"? What carries it is left alone.`} message={`Move the location "${data.location.name}" to the trash? What carries it is left alone. You can restore it from the Trash page.`}
confirmLabel="Move to trash"
onConfirm={() => remove.mutate(locationId)} onConfirm={() => remove.mutate(locationId)}
onClose={() => setConfirmingDelete(false)} onClose={() => setConfirmingDelete(false)}
/> />
+4 -1
View File
@@ -13,6 +13,7 @@ import {
IconLocations, IconLocations,
IconSettings, IconSettings,
IconTags, IconTags,
IconTrash,
} from '../components/icons' } from '../components/icons'
import { AgentPanel, type AgentContext } from '../components/AgentPanel' import { AgentPanel, type AgentContext } from '../components/AgentPanel'
import { HelpButton } from '../keyboard/HelpButton' import { HelpButton } from '../keyboard/HelpButton'
@@ -23,9 +24,10 @@ const sections: { to: string; label: string; end?: boolean; icon: ComponentType<
{ to: '', label: 'Dashboard', end: true, icon: IconDashboard }, { to: '', label: 'Dashboard', end: true, icon: IconDashboard },
{ to: 'chapters', label: 'Chapters', icon: IconChapters }, { to: 'chapters', label: 'Chapters', icon: IconChapters },
{ to: 'characters', label: 'Characters', icon: IconCharacters }, { to: 'characters', label: 'Characters', icon: IconCharacters },
{ to: 'tags', label: 'Tags', icon: IconTags },
{ to: 'locations', label: 'Locations', icon: IconLocations }, { to: 'locations', label: 'Locations', icon: IconLocations },
{ to: 'tags', label: 'Tags', icon: IconTags },
{ to: 'settings', label: 'Settings', icon: IconSettings }, { to: 'settings', label: 'Settings', icon: IconSettings },
{ to: 'trash', label: 'Trash', icon: IconTrash },
] ]
export default function NovelLayout() { export default function NovelLayout() {
@@ -46,6 +48,7 @@ export default function NovelLayout() {
useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' }) useHotkey('g c', 'Go to characters', () => goTo('characters'), { group: 'Navigate' })
useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' }) useHotkey('g t', 'Go to tags', () => goTo('tags'), { group: 'Navigate' })
useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' }) useHotkey('g l', 'Go to locations', () => goTo('locations'), { group: 'Navigate' })
useHotkey('g r', 'Go to trash', () => goTo('trash'), { group: 'Navigate' })
useHotkey('g a', 'Toggle agent', () => setAgentOpen((o) => !o), { group: 'Navigate' }) useHotkey('g a', 'Toggle agent', () => setAgentOpen((o) => !o), { group: 'Navigate' })
useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' }) useHotkey('g s', 'Go to settings', () => goTo('settings'), { group: 'Navigate' })
+127
View File
@@ -0,0 +1,127 @@
import { useState } from 'react'
import { useParams } from 'react-router-dom'
import { useEmptyTrash, useNovel, usePurgeTrashed, useRestoreTrashed, useTrash } from '../api/hooks'
import type { TrashedItem } from '../api/types'
import { useAuth } from '../auth/AuthContext'
import { ConfirmModal } from '../components/ConfirmModal'
import { EmptyState, ErrorNote, Spinner } from '../components/ui'
export default function TrashPage() {
const { novelId = '' } = useParams()
const { data: items, isPending, error } = useTrash(novelId)
const { data: novel } = useNovel(novelId)
const { can } = useAuth()
const canDelete = can('DeleteContent', novel)
const restore = useRestoreTrashed(novelId)
const purge = usePurgeTrashed(novelId)
const emptyTrash = useEmptyTrash(novelId)
const [purging, setPurging] = useState<TrashedItem | null>(null)
const [emptying, setEmptying] = useState(false)
if (isPending) return <Spinner label="Loading trash" />
if (error) return <ErrorNote error={error} />
return (
<div id="trash-page" className="grid gap-6">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Trash</h1>
<p className="text-sm muted">
Trashed characters, chapters, and locations. Restore them or delete them for good.
</p>
</div>
{canDelete && items && items.length > 0 && (
<button id="trash-empty" className="btn btn-danger" onClick={() => setEmptying(true)}>
Empty trash
</button>
)}
</div>
{(restore.error || purge.error || emptyTrash.error) && (
<ErrorNote error={restore.error ?? purge.error ?? emptyTrash.error} />
)}
{!items || items.length === 0 ? (
<EmptyState title="Trash is empty" hint="Deleted characters, chapters, and locations show up here." />
) : (
<div className="card overflow-x-auto" id="trash-table">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase muted">
<th className="py-2 px-3 font-semibold">Item</th>
<th className="py-2 px-3 font-semibold">Kind</th>
<th className="py-2 px-3 font-semibold">Deleted</th>
<th className="py-2 px-3 font-semibold">Purges in</th>
{canDelete && <th className="py-2 px-3 font-semibold" />}
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={`${item.kind}-${item.id}`} className="align-top" style={{ borderTop: '1px solid var(--line)' }}>
<td className="py-2 px-3">
<div className="font-medium">{item.label}</div>
{item.detail && <div className="text-xs muted">{item.detail}</div>}
</td>
<td className="py-2 px-3">{item.kind}</td>
<td className="py-2 px-3 muted">{new Date(item.deletedAt).toLocaleDateString()}</td>
<td className="py-2 px-3 muted">{daysUntil(item.purgeAfter)}</td>
{canDelete && (
<td className="py-2 px-3">
<div className="flex justify-end gap-2">
<button
id={`trash-restore-${item.id}`}
className="btn"
disabled={restore.isPending}
onClick={() => restore.mutate({ kind: item.kind, id: item.id })}
>
Restore
</button>
<button
id={`trash-purge-${item.id}`}
className="btn btn-danger"
onClick={() => setPurging(item)}
>
Delete forever
</button>
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{purging && (
<ConfirmModal
title="Delete forever"
message={`Permanently delete "${purging.label}"? This cannot be undone.`}
confirmLabel="Delete forever"
onConfirm={() => purge.mutate({ kind: purging.kind, id: purging.id })}
onClose={() => setPurging(null)}
/>
)}
{emptying && (
<ConfirmModal
title="Empty trash"
message="Permanently delete everything in the trash? This cannot be undone."
confirmLabel="Empty trash"
onConfirm={() => emptyTrash.mutate()}
onClose={() => setEmptying(false)}
/>
)}
</div>
)
}
function daysUntil(isoDate: string): string {
const ms = new Date(isoDate).getTime() - Date.now()
const days = Math.ceil(ms / (1000 * 60 * 60 * 24))
if (days <= 0) return 'any time now'
if (days === 1) return '1 day'
return `${days} days`
}
+6 -3
View File
@@ -152,7 +152,7 @@ public class CharacterArcTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Deleting_a_chapter_unpins_an_arc_stage_rather_than_deleting_it() public async Task Trashing_a_chapter_hides_it_from_an_arc_stage_without_unpinning_it()
{ {
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall")); var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var stage = await Arcs.CreateAsync( var stage = await Arcs.CreateAsync(
@@ -161,11 +161,14 @@ public class CharacterArcTests : ServiceTestFixture
await Chapters.DeleteAsync(chapter.Id); await Chapters.DeleteAsync(chapter.Id);
var survivor = (await Arcs.GetAsync(stage.Id))!; var survivor = (await Arcs.GetAsync(stage.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(survivor.ChapterId, Is.Null); Assert.That(survivor.ChapterId, Is.EqualTo(chapter.Id), "the pin survives so restoring the chapter restores the link");
Assert.That(survivor.Title, Is.EqualTo("The map is wrong")); Assert.That(response.ChapterNumber, Is.Null);
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(response.Title, Is.EqualTo("The map is wrong"));
}); });
} }
@@ -258,7 +258,7 @@ public class CharacterServiceTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Deleting_the_canonical_character_leaves_its_other_identities_alive() public async Task Trashing_the_canonical_character_keeps_the_link_so_restoring_brings_it_back()
{ {
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael")); var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger")); var stranger = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("The Stranger"));
@@ -268,7 +268,13 @@ public class CharacterServiceTests : ServiceTestFixture
using var verification = Db.CreateContext(); using var verification = Db.CreateContext();
var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id); var survivor = await verification.Characters.FirstAsync(c => c.Id == stranger.Id);
Assert.That(survivor.SameCharacterAsId, Is.Null); var response = (await Characters.GetAsync(stranger.Id))!.ToResponse();
Assert.Multiple(() =>
{
Assert.That(survivor.SameCharacterAsId, Is.EqualTo(kael.Id), "the pin survives so restoring Kael restores the identity link");
Assert.That(response.SameCharacterAsName, Is.Null, "a trashed canonical identity does not show up while it's in the trash");
});
} }
[Test] [Test]
@@ -151,7 +151,7 @@ public class LocationServiceTests : ServiceTestFixture
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(survivor.Title, Is.EqualTo("Landfall")); Assert.That(survivor.Title, Is.EqualTo("Landfall"));
Assert.That(survivor.Locations, Is.Empty); Assert.That(survivor.ToResponse().Locations, Is.Empty);
}); });
} }
+4 -2
View File
@@ -182,7 +182,7 @@ public class OpenQuestionTests : ServiceTestFixture
} }
[Test] [Test]
public async Task Deleting_a_chapter_leaves_its_questions_open_rather_than_taking_them() public async Task Trashing_a_chapter_leaves_its_questions_open_and_pinned_but_hides_the_chapter()
{ {
var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest( var question = await Questions.CreateAsync(_novelId, new CreateOpenQuestionRequest(
"Does she know about the letter?", ChapterId: _chapterId)); "Does she know about the letter?", ChapterId: _chapterId));
@@ -190,10 +190,12 @@ public class OpenQuestionTests : ServiceTestFixture
await Chapters.DeleteAsync(_chapterId); await Chapters.DeleteAsync(_chapterId);
var survivor = (await Questions.GetAsync(question.Id))!; var survivor = (await Questions.GetAsync(question.Id))!;
var response = survivor.ToResponse();
Assert.Multiple(() => Assert.Multiple(() =>
{ {
Assert.That(survivor.ChapterId, Is.Null); Assert.That(survivor.ChapterId, Is.EqualTo(_chapterId), "the pin survives so restoring the chapter restores the link");
Assert.That(response.ChapterTitle, Is.Null);
Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?")); Assert.That(survivor.Question, Is.EqualTo("Does she know about the letter?"));
}); });
} }
@@ -1,3 +1,4 @@
using Microsoft.Extensions.Options;
using Novelly.Api.Activity; using Novelly.Api.Activity;
using Novelly.Api.Beats; using Novelly.Api.Beats;
using Novelly.Api.Chapters; using Novelly.Api.Chapters;
@@ -7,6 +8,7 @@ using Novelly.Api.Locations;
using Novelly.Api.Novels; using Novelly.Api.Novels;
using Novelly.Api.Questions; using Novelly.Api.Questions;
using Novelly.Api.Tags; using Novelly.Api.Tags;
using Novelly.Api.Trash;
using Novelly.Api.Users; using Novelly.Api.Users;
namespace Novelly.Api.Tests; namespace Novelly.Api.Tests;
@@ -28,6 +30,8 @@ public abstract class ServiceTestFixture
protected CharacterArcService Arcs { get; private set; } = null!; protected CharacterArcService Arcs { get; private set; } = null!;
protected OpenQuestionService Questions { get; private set; } = null!; protected OpenQuestionService Questions { get; private set; } = null!;
protected GenreService Genres { get; private set; } = null!; protected GenreService Genres { get; private set; } = null!;
protected TrashService Trash { get; private set; } = null!;
protected TrashOptions TrashOptions { get; private set; } = null!;
protected CapturingLogger<NovelService> NovelLogs { get; private set; } = null!; protected CapturingLogger<NovelService> NovelLogs { get; private set; } = null!;
protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!; protected CapturingLogger<CharacterService> CharacterLogs { get; private set; } = null!;
@@ -38,6 +42,7 @@ public abstract class ServiceTestFixture
protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!; protected CapturingLogger<CharacterArcService> ArcLogs { get; private set; } = null!;
protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!; protected CapturingLogger<OpenQuestionService> QuestionLogs { get; private set; } = null!;
protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!; protected CapturingLogger<GenreService> GenreLogs { get; private set; } = null!;
protected CapturingLogger<TrashService> TrashLogs { get; private set; } = null!;
[SetUp] [SetUp]
public void SetUpFixture() public void SetUpFixture()
@@ -67,6 +72,7 @@ public abstract class ServiceTestFixture
ArcLogs = new CapturingLogger<CharacterArcService>(); ArcLogs = new CapturingLogger<CharacterArcService>();
QuestionLogs = new CapturingLogger<OpenQuestionService>(); QuestionLogs = new CapturingLogger<OpenQuestionService>();
GenreLogs = new CapturingLogger<GenreService>(); GenreLogs = new CapturingLogger<GenreService>();
TrashLogs = new CapturingLogger<TrashService>();
Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator()); Tags = new TagService(Db.Context, Access, ActivityLog, TagLogs, new CreateTagRequestValidator(), new UpdateTagRequestValidator());
Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator()); Locations = new LocationService(Db.Context, Access, ActivityLog, LocationLogs, new CreateLocationRequestValidator(), new UpdateLocationRequestValidator());
@@ -90,6 +96,8 @@ public abstract class ServiceTestFixture
Db.Context, Access, ActivityLog, QuestionLogs, Db.Context, Access, ActivityLog, QuestionLogs,
new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator()); new CreateOpenQuestionRequestValidator(), new UpdateOpenQuestionRequestValidator(), new ResolveOpenQuestionRequestValidator());
Genres = new GenreService(Db.Context, GenreLogs); Genres = new GenreService(Db.Context, GenreLogs);
TrashOptions = new TrashOptions();
Trash = new TrashService(Db.Context, Access, ActivityLog, Options.Create(TrashOptions), TrashLogs);
OnSetUp(); OnSetUp();
} }
@@ -0,0 +1,69 @@
using Microsoft.EntityFrameworkCore;
using Novelly.Api.Chapters;
using Novelly.Api.Novels;
using Novelly.Api.Trash;
namespace Novelly.Api.Tests;
[TestFixture]
public class TrashPurgeScheduleTests
{
[Test]
public void Before_the_run_time_the_next_run_is_today() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 1, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 20, 2, 0, 0, TimeSpan.Zero)));
[Test]
public void After_the_run_time_the_next_run_is_tomorrow() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 3, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 21, 2, 0, 0, TimeSpan.Zero)));
[Test]
public void Exactly_at_the_run_time_the_next_run_is_tomorrow() =>
Assert.That(
TrashPurgeSchedule.NextRunAfter(new DateTimeOffset(2026, 8, 20, 2, 0, 0, TimeSpan.Zero), new TimeOnly(2, 0)),
Is.EqualTo(new DateTimeOffset(2026, 8, 21, 2, 0, 0, TimeSpan.Zero)));
}
[TestFixture]
public class TrashPurgeSweepTests : ServiceTestFixture
{
private Guid _novelId;
protected override void OnSetUp() =>
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test]
public async Task A_sweep_removes_only_items_older_than_the_retention_window()
{
var old = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Old News"));
var recent = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Fresh Off The Press"));
await Chapters.DeleteAsync(old!.Id);
await Chapters.DeleteAsync(recent!.Id);
using (var context = Db.CreateContext())
{
var oldRow = await context.Chapters.IgnoreQueryFilters().SingleAsync(c => c.Id == old.Id);
oldRow.DeletedAt = DateTimeOffset.UtcNow.AddDays(-31);
await context.SaveChangesAsync();
}
var cutoff = DateTimeOffset.UtcNow.AddDays(-30);
var (characters, chapters, locations) = await TrashPurgeRunner.SweepAsync(Db.Context, cutoff, CancellationToken.None);
Assert.Multiple(() =>
{
Assert.That((characters, chapters, locations), Is.EqualTo((0, 1, 0)));
});
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == old.Id), Is.False);
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == recent.Id), Is.True);
});
}
}
@@ -0,0 +1,249 @@
using Microsoft.EntityFrameworkCore;
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.Trash;
using Novelly.Api.Users;
namespace Novelly.Api.Tests;
[TestFixture]
public class TrashServiceTests : ServiceTestFixture
{
private Guid _novelId;
protected override void OnSetUp() =>
_novelId = Novels.CreateAsync(new CreateNovelRequest("The Salt Road")).Result.Id;
[Test]
public async Task Trashing_a_chapter_hides_it_from_the_chapter_list_but_keeps_its_beats()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
await Chapters.DeleteAsync(chapter.Id);
var listed = await Chapters.ListAsync(_novelId);
Assert.That(listed, Is.Empty);
using var verification = Db.CreateContext();
var survivingBeats = await verification.Beats.IgnoreQueryFilters().Where(b => b.ChapterId == chapter.Id).ToListAsync();
Assert.That(survivingBeats, Has.Count.EqualTo(1));
}
[Test]
public async Task Restoring_a_chapter_brings_its_beats_back()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
await Chapters.DeleteAsync(chapter.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
var restored = await Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId);
Assert.That(restored, Is.True);
var survivor = (await Chapters.GetAsync(chapter.Id))!;
Assert.That(survivor.Beats.Select(b => b.Title), Is.EquivalentTo(new[] { "They spot the wreck" }));
}
[Test]
public async Task Restoring_a_chapter_restores_its_word_count_to_the_activity_feed()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Prose: "The tide came in slow and cold."));
await Chapters.DeleteAsync(chapter!.Id);
var afterDelete = await Activity.GetForNovelAsync(_novelId, days: 1);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
await Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId);
var afterRestore = await Activity.GetForNovelAsync(_novelId, days: 1);
Assert.Multiple(() =>
{
Assert.That(afterDelete.TotalWords, Is.EqualTo(0), "the create and the trash deltas cancel out");
Assert.That(afterRestore.TotalWords, Is.EqualTo(chapter.WordCount), "restoring adds the word count back");
});
}
[Test]
public async Task A_trashed_location_name_can_be_used_by_a_new_location()
{
var chapter = await Chapters.CreateAsync(
_novelId, new CreateChapterRequest("Landfall", Locations: ["the harbour"]));
var locationId = (await Locations.ListAsync(_novelId)).Single().Id;
await Locations.DeleteAsync(locationId);
var recreated = await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
Assert.That(recreated, Is.Not.Null);
Assert.That(chapter, Is.Not.Null);
}
[Test]
public async Task Restoring_a_location_whose_name_was_taken_reports_a_clash()
{
var locationId = (await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour")))!.Id;
await Locations.DeleteAsync(locationId);
await Locations.CreateAsync(_novelId, new CreateLocationRequest("the harbour"));
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Location).Id;
Assert.That(
() => Trash.RestoreAsync(TrashEntityKind.Location, trashedId),
Throws.TypeOf<InvalidOperationException>().With.Message.Contains("already has a location"));
}
[Test]
public async Task Trashed_characters_disappear_from_beat_listings()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var beat = await Beats.CreateAsync(
chapter!.Id, new CreateBeatRequest("Kael spots the wreck", CharacterIds: [character!.Id]));
await Characters.DeleteAsync(character.Id);
var survivor = (await Beats.GetAsync(beat!.Id))!;
Assert.That(survivor.ToResponse().Characters, Is.Empty);
}
[Test]
public async Task Restoring_a_trashed_character_brings_it_back()
{
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
await Characters.DeleteAsync(character!.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Character).Id;
var restored = await Trash.RestoreAsync(TrashEntityKind.Character, trashedId);
Assert.Multiple(async () =>
{
Assert.That(restored, Is.True);
Assert.That(await Characters.GetAsync(character.Id), Is.Not.Null);
Assert.That(await Trash.ListAsync(_novelId), Is.Empty);
});
}
[Test]
public async Task Emptying_trash_purges_everything_in_the_novel_but_leaves_other_novels_alone()
{
var otherNovelId = (await Novels.CreateAsync(new CreateNovelRequest("Elsewhere"))).Id;
var elsewhereChapter = await Chapters.CreateAsync(otherNovelId, new CreateChapterRequest("A Different Book"));
await Chapters.DeleteAsync(elsewhereChapter!.Id);
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
var character = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
await Chapters.DeleteAsync(chapter!.Id);
await Characters.DeleteAsync(character!.Id);
var purged = await Trash.EmptyAsync(_novelId);
Assert.Multiple(async () =>
{
Assert.That(purged, Is.EqualTo(2));
Assert.That(await Trash.ListAsync(_novelId), Is.Empty);
Assert.That((await Trash.ListAsync(otherNovelId)).Select(i => i.Id), Is.EquivalentTo(new[] { elsewhereChapter.Id }));
});
using var verification = Db.CreateContext();
Assert.Multiple(async () =>
{
Assert.That(await verification.Chapters.IgnoreQueryFilters().AnyAsync(c => c.Id == chapter.Id), Is.False);
Assert.That(await verification.Characters.IgnoreQueryFilters().AnyAsync(c => c.Id == character.Id), Is.False);
});
}
[Test]
public async Task Purging_a_character_that_others_relate_to_succeeds()
{
var kael = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Kael"));
var mira = await Characters.CreateAsync(_novelId, new CreateCharacterRequest("Mira"));
await Characters.AddRelationshipAsync(mira!.Id, new CreateRelationshipRequest(kael!.Id, "sibling"));
await Characters.DeleteAsync(kael.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Character).Id;
var purged = await Trash.PurgeAsync(TrashEntityKind.Character, trashedId);
Assert.That(purged, Is.True);
using var verification = Db.CreateContext();
Assert.That(await verification.Characters.IgnoreQueryFilters().AnyAsync(c => c.Id == kael.Id), Is.False);
}
[Test]
public async Task Purging_a_chapter_deletes_its_beats_for_good()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Beats.CreateAsync(chapter!.Id, new CreateBeatRequest("They spot the wreck"));
await Chapters.DeleteAsync(chapter.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single(i => i.Kind == TrashEntityKind.Chapter).Id;
await Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId);
using var verification = Db.CreateContext();
Assert.That(await verification.Beats.IgnoreQueryFilters().AnyAsync(b => b.ChapterId == chapter.Id), Is.False);
}
[Test]
public async Task An_editor_cannot_restore_or_purge()
{
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Chapters.DeleteAsync(chapter!.Id);
var trashedId = (await Trash.ListAsync(_novelId)).Single().Id;
var editorId = AsNewUser(GlobalRole.Reviewer);
GrantNovelRole(_novelId, editorId, NovelRole.Editor);
Assert.Multiple(() =>
{
Assert.That(() => Trash.RestoreAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf<NotAuthorizedException>());
Assert.That(() => Trash.PurgeAsync(TrashEntityKind.Chapter, trashedId), Throws.TypeOf<NotAuthorizedException>());
});
}
[Test]
public async Task Trash_lists_only_the_novels_own_items()
{
var otherNovelId = (await Novels.CreateAsync(new CreateNovelRequest("Elsewhere"))).Id;
var otherChapter = await Chapters.CreateAsync(otherNovelId, new CreateChapterRequest("A Different Book"));
await Chapters.DeleteAsync(otherChapter!.Id);
var chapter = await Chapters.CreateAsync(_novelId, new CreateChapterRequest("Landfall"));
await Chapters.DeleteAsync(chapter!.Id);
var listed = await Trash.ListAsync(_novelId);
Assert.That(listed.Select(i => i.Id), Is.EquivalentTo(new[] { chapter.Id }));
}
private Guid AsNewUser(GlobalRole globalRole)
{
var user = new NovellyUser
{
Id = Guid.NewGuid(),
UserName = $"{Guid.NewGuid()}@novelly.test",
Email = $"{Guid.NewGuid()}@novelly.test",
DisplayName = "Test User",
GlobalRole = globalRole
};
Db.Context.Users.Add(user);
Db.Context.SaveChanges();
UserContext.UserId = user.Id;
UserContext.GlobalRole = globalRole;
return user.Id;
}
private void GrantNovelRole(Guid novelId, Guid userId, NovelRole role)
{
Db.Context.NovelMembers.Add(new NovelMember { NovelId = novelId, UserId = userId, NovelRole = role, GrantedByUserId = userId });
Db.Context.SaveChanges();
}
}