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.
187 lines
7.3 KiB
C#
187 lines
7.3 KiB
C#
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)
|
|
};
|
|
}
|