Replace the outline tree with chapter beat tables and tags
The self-nesting outline tree was more structure than chapter outlining needs.
A chapter outline is now a paragraph plus a flat, ordered table of beats, and
tags do the cross-referencing that nesting was doing badly.
A beat is one row: a three-to-five word title, an optional character, what
happened, and what's next. Ordering is a SortOrder column within the chapter —
no parent pointers, no cycle guards, no recursive tree building. Reordering is
one call taking beat ids in the order wanted; ids left out keep their relative
position at the end rather than jumping to the front.
Beats plan, scenes carry prose. The two layers stay separate and a beat's
SceneId is the optional link between them, nullable in both directions —
deleting a scene ungroups its beats rather than deleting the plan, since that
is a decision about prose and not about the outline.
Tags are project-scoped, unique by name case-insensitively, and attach to
characters, chapters and beats through three join tables so cascade deletes are
the database's job rather than ours. Applying an unknown tag by name creates it,
which keeps tagging a single action; GET /api/tags/{id}/references returns
everything carrying a tag across all three kinds at once.
Removed: OutlineNode, OutlineService, its endpoints, agent and MCP tools, and
the Outline tab. Added: Beat and Tag with their services, endpoints, 5 agent
tools and 10 MCP tools, a beat table on the chapter page, a tag editor used in
three places, and a Tags tab for cross-referencing.
Migration drops OutlineNodes — the scaffolder's data-loss warning is the
intended removal, not an accident.
44 tests, up from 31.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S56bfZMGe1hnhpWP4CjjNw
This commit is contained in:
co-authored by
Claude Opus 5
parent
0d7b7a6f30
commit
7678cc7275
@@ -0,0 +1,163 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Beats are a chapter's outline: a flat, ordered table rather than a tree. Everything
|
||||
/// here is scoped to one chapter.
|
||||
/// </summary>
|
||||
public class BeatService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
public async Task<IReadOnlyList<BeatDto>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
var beats = await Query()
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
.OrderBy(b => b.SortOrder)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return [.. beats.Select(b => b.ToDto())];
|
||||
}
|
||||
|
||||
public async Task<BeatDto> GetAsync(Guid id, CancellationToken ct = default) =>
|
||||
(await FindAsync(id, ct)).ToDto();
|
||||
|
||||
public async Task<BeatDto> CreateAsync(Guid chapterId, CreateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct)
|
||||
?? throw new NotFoundException(nameof(Chapter), chapterId);
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
var beat = new Beat
|
||||
{
|
||||
ChapterId = chapterId,
|
||||
Title = request.Title,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(chapterId, ct),
|
||||
CharacterId = request.CharacterId,
|
||||
WhatHappened = request.WhatHappened,
|
||||
WhatsNext = request.WhatsNext,
|
||||
SceneId = request.SceneId
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
db.Beats.Add(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(beat.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<BeatDto> UpdateAsync(Guid id, UpdateBeatRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var beat = await FindAsync(id, ct);
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == beat.ChapterId, ct)
|
||||
?? throw new NotFoundException(nameof(Chapter), beat.ChapterId);
|
||||
|
||||
await ValidateReferencesAsync(chapter, request.CharacterId, request.SceneId, ct);
|
||||
|
||||
beat.Title = Patch.Apply(beat.Title, request.Title) ?? beat.Title;
|
||||
beat.SortOrder = request.SortOrder ?? beat.SortOrder;
|
||||
beat.CharacterId = request.CharacterId ?? beat.CharacterId;
|
||||
beat.WhatHappened = Patch.Apply(beat.WhatHappened, request.WhatHappened);
|
||||
beat.WhatsNext = Patch.Apply(beat.WhatsNext, request.WhatsNext);
|
||||
beat.SceneId = request.SceneId ?? beat.SceneId;
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
beat.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var beat = await FindAsync(id, ct);
|
||||
db.Beats.Remove(beat);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Renumbers a chapter's beats to match the order given. Sending the whole list beats
|
||||
/// patching sort orders one at a time, which is fiddly to get right from a drag handle.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<BeatDto>> ReorderAsync(
|
||||
Guid chapterId, ReorderBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var beats = await db.Beats.Where(b => b.ChapterId == chapterId).ToListAsync(ct);
|
||||
|
||||
var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList();
|
||||
if (missing.Count > 0)
|
||||
{
|
||||
throw new NotFoundException(nameof(Beat), missing[0]);
|
||||
}
|
||||
|
||||
// Listed beats take the order given; anything omitted keeps its relative position
|
||||
// after them rather than silently jumping to the front.
|
||||
var order = 1;
|
||||
foreach (var id in request.BeatIds)
|
||||
{
|
||||
beats.Single(b => b.Id == id).SortOrder = order++;
|
||||
}
|
||||
|
||||
foreach (var beat in beats.Where(b => !request.BeatIds.Contains(b.Id)).OrderBy(b => b.SortOrder))
|
||||
{
|
||||
beat.SortOrder = order++;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
private async Task ValidateReferencesAsync(
|
||||
Chapter chapter, Guid? characterId, Guid? sceneId, CancellationToken ct)
|
||||
{
|
||||
if (characterId is { } cid)
|
||||
{
|
||||
var belongs = await db.Characters
|
||||
.AnyAsync(c => c.Id == cid && c.ProjectId == chapter.ProjectId, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A beat's character must belong to the same project as its chapter.");
|
||||
}
|
||||
}
|
||||
|
||||
if (sceneId is { } sid)
|
||||
{
|
||||
var belongs = await db.Scenes.AnyAsync(s => s.Id == sid && s.ChapterId == chapter.Id, ct);
|
||||
|
||||
if (!belongs)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"A beat can only be grouped under a scene in the same chapter.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid chapterId, CancellationToken ct)
|
||||
{
|
||||
var max = await db.Beats
|
||||
.Where(b => b.ChapterId == chapterId)
|
||||
.MaxAsync(b => (int?)b.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private IQueryable<Beat> Query() =>
|
||||
db.Beats
|
||||
.Include(b => b.Character)
|
||||
.Include(b => b.Scene)
|
||||
.Include(b => b.Tags);
|
||||
|
||||
private async Task<Beat> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(b => b.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Beat), id);
|
||||
}
|
||||
@@ -4,13 +4,15 @@ using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application.Services;
|
||||
|
||||
public class ChapterService(INovelDbContext db)
|
||||
public class ChapterService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
public async Task<IReadOnlyList<ChapterSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var chapters = await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Beats)
|
||||
.Include(c => c.Scenes)
|
||||
.Include(c => c.Tags)
|
||||
.Where(c => c.ProjectId == projectId)
|
||||
.OrderBy(c => c.Number)
|
||||
.ToListAsync(ct);
|
||||
@@ -41,6 +43,11 @@ public class ChapterService(INovelDbContext db)
|
||||
TargetWordCount = request.TargetWordCount
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
}
|
||||
|
||||
db.Chapters.Add(chapter);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(chapter.Id, ct)).ToDto();
|
||||
@@ -60,6 +67,11 @@ public class ChapterService(INovelDbContext db)
|
||||
chapter.TargetWordCount = request.TargetWordCount ?? chapter.TargetWordCount;
|
||||
chapter.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
chapter.Tags = await tags.ResolveAsync(chapter.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
@@ -83,8 +95,11 @@ public class ChapterService(INovelDbContext db)
|
||||
private async Task<Chapter> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await db.Chapters
|
||||
.Include(c => c.PovCharacter)
|
||||
.Include(c => c.Scenes)
|
||||
.ThenInclude(s => s.PovCharacter)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Character)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Scene)
|
||||
.Include(c => c.Beats).ThenInclude(b => b.Tags)
|
||||
.Include(c => c.Scenes).ThenInclude(s => s.PovCharacter)
|
||||
.Include(c => c.Tags)
|
||||
.FirstOrDefaultAsync(c => c.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(Chapter), id);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application.Services;
|
||||
|
||||
public class CharacterService(INovelDbContext db)
|
||||
public class CharacterService(INovelDbContext db, TagService tags)
|
||||
{
|
||||
public async Task<IReadOnlyList<CharacterDto>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -44,9 +44,14 @@ public class CharacterService(INovelDbContext db)
|
||||
Notes = request.Notes
|
||||
};
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
character.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
}
|
||||
|
||||
db.Characters.Add(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return character.ToDto();
|
||||
return (await FindAsync(character.Id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task<CharacterDto> UpdateAsync(Guid id, UpdateCharacterRequest request, CancellationToken ct = default)
|
||||
@@ -70,8 +75,13 @@ public class CharacterService(INovelDbContext db)
|
||||
character.Notes = Patch.Apply(character.Notes, request.Notes);
|
||||
character.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
if (request.Tags is { } names)
|
||||
{
|
||||
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return character.ToDto();
|
||||
return (await FindAsync(id, ct)).ToDto();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
@@ -120,7 +130,8 @@ public class CharacterService(INovelDbContext db)
|
||||
private IQueryable<Character> Query() =>
|
||||
db.Characters
|
||||
.Include(c => c.Relationships)
|
||||
.ThenInclude(r => r.RelatedCharacter);
|
||||
.ThenInclude(r => r.RelatedCharacter)
|
||||
.Include(c => c.Tags);
|
||||
|
||||
private async Task<Character> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await Query().FirstOrDefaultAsync(c => c.Id == id, ct)
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application.Services;
|
||||
|
||||
public class OutlineService(INovelDbContext db)
|
||||
{
|
||||
/// <summary>Returns the project's outline as a tree of root nodes with children inlined.</summary>
|
||||
public async Task<IReadOnlyList<OutlineNodeDto>> GetTreeAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
var nodes = await db.OutlineNodes
|
||||
.Where(n => n.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return BuildTree(nodes, parentId: null);
|
||||
}
|
||||
|
||||
public async Task<OutlineNodeDto> GetAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var node = await FindAsync(id, ct);
|
||||
var siblings = await db.OutlineNodes.Where(n => n.ProjectId == node.ProjectId).ToListAsync(ct);
|
||||
return BuildNode(node, siblings);
|
||||
}
|
||||
|
||||
public async Task<OutlineNodeDto> CreateAsync(
|
||||
Guid projectId, CreateOutlineNodeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
if (request.ParentId is { } parentId && !await db.OutlineNodes.AnyAsync(n => n.Id == parentId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(OutlineNode), parentId);
|
||||
}
|
||||
|
||||
var node = new OutlineNode
|
||||
{
|
||||
ProjectId = projectId,
|
||||
ParentId = request.ParentId,
|
||||
NodeType = request.NodeType,
|
||||
Title = request.Title,
|
||||
Summary = request.Summary,
|
||||
ChapterId = request.ChapterId,
|
||||
SortOrder = request.SortOrder ?? await NextSortOrderAsync(projectId, request.ParentId, ct)
|
||||
};
|
||||
|
||||
db.OutlineNodes.Add(node);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return BuildNode(node, []);
|
||||
}
|
||||
|
||||
public async Task<OutlineNodeDto> UpdateAsync(
|
||||
Guid id, UpdateOutlineNodeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var node = await FindAsync(id, ct);
|
||||
|
||||
node.Title = Patch.Apply(node.Title, request.Title) ?? node.Title;
|
||||
node.NodeType = request.NodeType ?? node.NodeType;
|
||||
node.Summary = Patch.Apply(node.Summary, request.Summary);
|
||||
node.SortOrder = request.SortOrder ?? node.SortOrder;
|
||||
node.ChapterId = request.ChapterId ?? node.ChapterId;
|
||||
node.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await GetAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reparents a node. Refuses to move a node under one of its own descendants, which
|
||||
/// would detach the subtree from the tree entirely.
|
||||
/// </summary>
|
||||
public async Task<OutlineNodeDto> MoveAsync(Guid id, MoveOutlineNodeRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var node = await FindAsync(id, ct);
|
||||
|
||||
if (request.ParentId == id)
|
||||
{
|
||||
throw new InvalidOperationException("An outline node cannot be its own parent.");
|
||||
}
|
||||
|
||||
if (request.ParentId is { } newParentId)
|
||||
{
|
||||
var allNodes = await db.OutlineNodes
|
||||
.Where(n => n.ProjectId == node.ProjectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
if (!allNodes.Any(n => n.Id == newParentId))
|
||||
{
|
||||
throw new NotFoundException(nameof(OutlineNode), newParentId);
|
||||
}
|
||||
|
||||
if (DescendantIds(allNodes, id).Contains(newParentId))
|
||||
{
|
||||
throw new InvalidOperationException("An outline node cannot be moved beneath its own descendant.");
|
||||
}
|
||||
}
|
||||
|
||||
node.ParentId = request.ParentId;
|
||||
node.SortOrder = request.SortOrder;
|
||||
node.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return await GetAsync(id, ct);
|
||||
}
|
||||
|
||||
/// <summary>Deletes a node and its entire subtree.</summary>
|
||||
public async Task DeleteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
var node = await FindAsync(id, ct);
|
||||
|
||||
var allNodes = await db.OutlineNodes
|
||||
.Where(n => n.ProjectId == node.ProjectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var doomed = DescendantIds(allNodes, id).Append(id).ToHashSet();
|
||||
db.OutlineNodes.RemoveRange(allNodes.Where(n => doomed.Contains(n.Id)));
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<int> NextSortOrderAsync(Guid projectId, Guid? parentId, CancellationToken ct)
|
||||
{
|
||||
var max = await db.OutlineNodes
|
||||
.Where(n => n.ProjectId == projectId && n.ParentId == parentId)
|
||||
.MaxAsync(n => (int?)n.SortOrder, ct);
|
||||
|
||||
return (max ?? 0) + 1;
|
||||
}
|
||||
|
||||
private async Task<OutlineNode> FindAsync(Guid id, CancellationToken ct) =>
|
||||
await db.OutlineNodes.FirstOrDefaultAsync(n => n.Id == id, ct)
|
||||
?? throw new NotFoundException(nameof(OutlineNode), id);
|
||||
|
||||
private static IReadOnlyList<OutlineNodeDto> BuildTree(List<OutlineNode> all, Guid? parentId) =>
|
||||
[
|
||||
.. all
|
||||
.Where(n => n.ParentId == parentId)
|
||||
.OrderBy(n => n.SortOrder)
|
||||
.ThenBy(n => n.Title)
|
||||
.Select(n => new OutlineNodeDto(
|
||||
n.Id, n.ProjectId, n.ParentId, n.NodeType, n.Title, n.Summary,
|
||||
n.SortOrder, n.ChapterId, BuildTree(all, n.Id)))
|
||||
];
|
||||
|
||||
private static OutlineNodeDto BuildNode(OutlineNode node, List<OutlineNode> all) => new(
|
||||
node.Id, node.ProjectId, node.ParentId, node.NodeType, node.Title, node.Summary,
|
||||
node.SortOrder, node.ChapterId, BuildTree(all, node.Id));
|
||||
|
||||
private static IEnumerable<Guid> DescendantIds(List<OutlineNode> all, Guid rootId)
|
||||
{
|
||||
var frontier = new Queue<Guid>([rootId]);
|
||||
|
||||
while (frontier.Count > 0)
|
||||
{
|
||||
var current = frontier.Dequeue();
|
||||
foreach (var child in all.Where(n => n.ParentId == current))
|
||||
{
|
||||
yield return child.Id;
|
||||
frontier.Enqueue(child.Id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NovelSoftware.Application.Dtos;
|
||||
using NovelSoftware.Domain.Entities;
|
||||
|
||||
namespace NovelSoftware.Application.Services;
|
||||
|
||||
public class TagService(INovelDbContext db)
|
||||
{
|
||||
public async Task<IReadOnlyList<TagSummaryDto>> ListAsync(Guid projectId, CancellationToken ct = default) =>
|
||||
await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.OrderBy(t => t.Name)
|
||||
.Select(t => new TagSummaryDto(
|
||||
t.Id, t.Name, t.Color,
|
||||
t.Characters.Count, t.Chapters.Count, t.Beats.Count))
|
||||
.ToListAsync(ct);
|
||||
|
||||
/// <summary>Everything in the project carrying this tag.</summary>
|
||||
public async Task<TagReferencesDto> GetReferencesAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags
|
||||
.Include(t => t.Characters)
|
||||
.Include(t => t.Chapters)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Character)
|
||||
.Include(t => t.Beats).ThenInclude(b => b.Chapter)
|
||||
.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new NotFoundException(nameof(Tag), tagId);
|
||||
|
||||
return new TagReferencesDto(
|
||||
tag.ToDto(),
|
||||
[.. tag.Characters
|
||||
.OrderBy(c => c.Name)
|
||||
.Select(c => new TaggedCharacterDto(c.Id, c.Name, c.Role.ToString()))],
|
||||
[.. tag.Chapters
|
||||
.OrderBy(c => c.Number)
|
||||
.Select(c => new TaggedChapterDto(c.Id, c.Number, c.Title, c.Summary))],
|
||||
[.. tag.Beats
|
||||
.OrderBy(b => b.Chapter?.Number ?? 0)
|
||||
.ThenBy(b => b.SortOrder)
|
||||
.Select(b => new TaggedBeatDto(
|
||||
b.Id,
|
||||
b.ChapterId,
|
||||
b.Chapter?.Number ?? 0,
|
||||
b.Chapter?.Title ?? "(unknown chapter)",
|
||||
b.SortOrder,
|
||||
b.Title,
|
||||
b.Character?.Name,
|
||||
b.WhatHappened))]);
|
||||
}
|
||||
|
||||
public async Task<TagDto> CreateAsync(Guid projectId, CreateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!await db.Projects.AnyAsync(p => p.Id == projectId, ct))
|
||||
{
|
||||
throw new NotFoundException(nameof(Project), projectId);
|
||||
}
|
||||
|
||||
var name = TagMapping.Normalise(request.Name);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("A tag needs a name.");
|
||||
}
|
||||
|
||||
var existing = await FindByNameAsync(projectId, name, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
throw new InvalidOperationException($"The project already has a tag called '{existing.Name}'.");
|
||||
}
|
||||
|
||||
var tag = new Tag { ProjectId = projectId, Name = name, Color = request.Color };
|
||||
db.Tags.Add(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag.ToDto();
|
||||
}
|
||||
|
||||
public async Task<TagDto> UpdateAsync(Guid tagId, UpdateTagRequest request, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new NotFoundException(nameof(Tag), tagId);
|
||||
|
||||
if (request.Name is not null)
|
||||
{
|
||||
var name = TagMapping.Normalise(request.Name);
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
throw new ArgumentException("A tag needs a name.");
|
||||
}
|
||||
|
||||
var clash = await FindByNameAsync(tag.ProjectId, name, ct);
|
||||
if (clash is not null && clash.Id != tag.Id)
|
||||
{
|
||||
throw new InvalidOperationException($"The project already has a tag called '{clash.Name}'.");
|
||||
}
|
||||
|
||||
tag.Name = name;
|
||||
}
|
||||
|
||||
tag.Color = Patch.Apply(tag.Color, request.Color);
|
||||
await db.SaveChangesAsync(ct);
|
||||
return tag.ToDto();
|
||||
}
|
||||
|
||||
/// <summary>Deletes a tag. Whatever carried it keeps existing — only the label goes.</summary>
|
||||
public async Task DeleteAsync(Guid tagId, CancellationToken ct = default)
|
||||
{
|
||||
var tag = await db.Tags.FirstOrDefaultAsync(t => t.Id == tagId, ct)
|
||||
?? throw new NotFoundException(nameof(Tag), tagId);
|
||||
|
||||
db.Tags.Remove(tag);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Turns a list of names into tag entities, creating any the project has not seen
|
||||
/// before. Typing a new tag on a beat should just work rather than being a two-step
|
||||
/// "create the tag, then apply it".
|
||||
/// </summary>
|
||||
internal async Task<List<Tag>> ResolveAsync(
|
||||
Guid projectId, IReadOnlyList<string> names, CancellationToken ct)
|
||||
{
|
||||
var wanted = names
|
||||
.Select(TagMapping.Normalise)
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.DistinctBy(n => n.ToLowerInvariant())
|
||||
.ToList();
|
||||
|
||||
if (wanted.Count == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
var existing = await db.Tags
|
||||
.Where(t => t.ProjectId == projectId)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var resolved = new List<Tag>();
|
||||
foreach (var name in wanted)
|
||||
{
|
||||
var match = existing.FirstOrDefault(
|
||||
t => string.Equals(t.Name, name, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (match is null)
|
||||
{
|
||||
match = new Tag { ProjectId = projectId, Name = name };
|
||||
db.Tags.Add(match);
|
||||
existing.Add(match);
|
||||
}
|
||||
|
||||
resolved.Add(match);
|
||||
}
|
||||
|
||||
return resolved;
|
||||
}
|
||||
|
||||
private async Task<Tag?> FindByNameAsync(Guid projectId, string name, CancellationToken ct) =>
|
||||
await db.Tags.FirstOrDefaultAsync(
|
||||
t => t.ProjectId == projectId && EF.Functions.Like(t.Name, name), ct);
|
||||
}
|
||||
Reference in New Issue
Block a user