diff --git a/src/Novelly.Api/Agent/NovelAgentToolset.cs b/src/Novelly.Api/Agent/NovelAgentToolset.cs index 0391637..9dedcb9 100644 --- a/src/Novelly.Api/Agent/NovelAgentToolset.cs +++ b/src/Novelly.Api/Agent/NovelAgentToolset.cs @@ -11,7 +11,10 @@ namespace Novelly.Api.Agent; public record AgentToolResult(string Content, bool IsError); -internal record ToolNotFound(string Message); +internal record ToolNotFound(string Entity, Guid Id) +{ + public string Message => $"{Entity} '{Id}' was not found."; +} public record AgentTool( string Name, @@ -58,7 +61,7 @@ public class NovelAgentToolset( if (result is ToolNotFound notFound) { - logger.LogInformation("Tool {Tool} for project {ProjectId} found nothing: {Message}", name, projectId, notFound.Message); + logger.LogWarning("Tool {Tool} for project {ProjectId} found no {Entity} {EntityId}", name, projectId, notFound.Entity, notFound.Id); return new AgentToolResult(notFound.Message, true); } @@ -78,14 +81,14 @@ public class NovelAgentToolset( } private static async Task OrNotFound(Task lookup, string entity, Guid id) where T : class => - await lookup as object ?? new ToolNotFound($"{entity} '{id}' was not found."); + await lookup as object ?? new ToolNotFound(entity, id); private static async Task OrNotFound( Task lookup, Func map, string entity, Guid id) where TEntity : class => - await lookup is { } value ? map(value)! : new ToolNotFound($"{entity} '{id}' was not found."); + await lookup is { } value ? map(value)! : new ToolNotFound(entity, id); private static async Task DeletedOrNotFound(Task delete, string entity, Guid id) => - await delete ? new { deleted = true } : new ToolNotFound($"{entity} '{id}' was not found."); + await delete ? new { deleted = true } : new ToolNotFound(entity, id); private Dictionary ByName => _byName ??= Build().ToDictionary(t => t.Name); @@ -265,6 +268,27 @@ public class NovelAgentToolset( .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); }); + yield return new AgentTool( + "assign_character_to_beats", + "Add a character to several beats at once. Leaves each beat's existing characters and " + + "other fields alone — this only adds, it never removes.", + new JsonSchemaBuilder() + .Str("chapter_id", "Id of the chapter the beats belong to.", required: true) + .Str("character_id", "Id of the character to add.", required: true) + .StringArray("beat_ids", "Ids of the beats to add the character to.", required: true) + .Build(), + async (_, input, ct) => + { + var chapterId = JsonInput.RequiredGuid(input, "chapter_id"); + return await OrNotFound(beats.AssignCharacterAsync( + chapterId, + new AssignCharacterToBeatsRequest( + JsonInput.RequiredGuid(input, "character_id"), + [.. (JsonInput.Strings(input, "beat_ids") ?? []) + .Select(id => Guid.TryParse(id, out var g) ? g : Guid.Empty) + .Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId); + }); + yield return new AgentTool( "list_tags", "List the project's tags with how many characters, chapters and beats carry each. " diff --git a/src/Novelly.Api/Beats/BeatContracts.cs b/src/Novelly.Api/Beats/BeatContracts.cs index aea2377..2825374 100644 --- a/src/Novelly.Api/Beats/BeatContracts.cs +++ b/src/Novelly.Api/Beats/BeatContracts.cs @@ -30,11 +30,7 @@ public class CreateBeatRequestValidator : IModelValidator { var result = new ValidationResult(); - if (string.IsNullOrWhiteSpace(model.Title)) - result.AddError("Title", "'Title' must not be empty."); - else if (model.Title.Length > 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - + result.AddRequiredTextErrors("Title", "Title", model.Title, 200); BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result); return result; @@ -55,14 +51,7 @@ public class UpdateBeatRequestValidator : IModelValidator { var result = new ValidationResult(); - if (model.Title is not null) - { - if (model.Title.Length == 0) - result.AddError("Title", "'Title' can not be cleared — a beat always needs one."); - else if (model.Title.Length > 200) - result.AddError("Title", "'Title' must be 200 characters or fewer."); - } - + result.AddUnclearableTextErrors("Title", "Title", model.Title, "a beat", 200); BeatValidation.OptionalFields(model.SortOrder, model.WhatHappened, model.WhatsNext, model.Tags, result); return result; @@ -112,6 +101,24 @@ public class ReorderBeatsRequestValidator : IModelValidator } } +public record AssignCharacterToBeatsRequest(Guid CharacterId, IReadOnlyList BeatIds); + +public class AssignCharacterToBeatsRequestValidator : IModelValidator +{ + public ValidationResult Validate(AssignCharacterToBeatsRequest model) + { + var result = new ValidationResult(); + + if (model.CharacterId == Guid.Empty) + result.AddError("CharacterId", "'Character Id' must not be empty."); + + if (model.BeatIds is null || model.BeatIds.Count == 0) + result.AddError("BeatIds", "'Beat Ids' must not be empty."); + + return result; + } +} + public static class BeatMapping { public static BeatResponse ToResponse(this Beat b) => new( diff --git a/src/Novelly.Api/Beats/BeatEndpoints.cs b/src/Novelly.Api/Beats/BeatEndpoints.cs index 49f1399..5f8aac3 100644 --- a/src/Novelly.Api/Beats/BeatEndpoints.cs +++ b/src/Novelly.Api/Beats/BeatEndpoints.cs @@ -34,6 +34,11 @@ public static class BeatEndpoints (await service.ReorderAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult()) .WithSummary("Renumber a chapter's beats to match the order given."); + chapterScoped.MapPost("/assign-character", async ( + Guid chapterId, AssignCharacterToBeatsRequest request, BeatService service, CancellationToken ct) => + (await service.AssignCharacterAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult()) + .WithSummary("Add a character to several beats at once, leaving each beat's existing characters alone."); + app.MapGet("/api/characters/{characterId:guid}/beats", async ( Guid characterId, BeatService service, CancellationToken ct) => (await service.ListForCharacterAsync(characterId, ct))?.Select(b => b.ToCharacterBeatResponse()).ToList().ToApiResult()) diff --git a/src/Novelly.Api/Beats/BeatService.cs b/src/Novelly.Api/Beats/BeatService.cs index 5059502..e9bc790 100644 --- a/src/Novelly.Api/Beats/BeatService.cs +++ b/src/Novelly.Api/Beats/BeatService.cs @@ -14,7 +14,8 @@ public class BeatService( ILogger logger, IModelValidator createValidator, IModelValidator updateValidator, - IModelValidator reorderValidator) + IModelValidator reorderValidator, + IModelValidator assignCharacterValidator) { public async Task> ListAsync(Guid chapterId, CancellationToken ct = default) { @@ -45,7 +46,7 @@ public class BeatService( if (!await db.Characters.AnyAsync(c => c.Id == characterId, ct)) { - logger.LogInformation("Character {CharacterId} not found", characterId); + logger.LogWarning("Character {CharacterId} not found", characterId); return null; } @@ -66,14 +67,14 @@ public class BeatService( { Guard.Default(chapterId, nameof(chapterId)); Guard.Null(request, nameof(request)); - createValidator.Validate(request).ThrowIfInvalid(); + createValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Creating beat {Title} for chapter {ChapterId}", request.Title, chapterId); var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); if (chapter is null) { - logger.LogInformation("Rejected beat creation: chapter {ChapterId} not found", chapterId); + logger.LogWarning("Rejected beat creation: chapter {ChapterId} not found", chapterId); return null; } @@ -106,7 +107,7 @@ public class BeatService( { Guard.Default(id, nameof(id)); Guard.Null(request, nameof(request)); - updateValidator.Validate(request).ThrowIfInvalid(); + updateValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Updating beat {BeatId}", id); @@ -165,7 +166,7 @@ public class BeatService( { Guard.Default(chapterId, nameof(chapterId)); Guard.Null(request, nameof(request)); - reorderValidator.Validate(request).ThrowIfInvalid(); + reorderValidator.Validate(request).ThrowIfInvalid(logger); logger.LogInformation("Reordering {Count} beats for chapter {ChapterId}", request.BeatIds.Count, chapterId); @@ -174,7 +175,7 @@ public class BeatService( var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); if (missing.Count > 0) { - logger.LogInformation("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); + logger.LogWarning("Reorder for chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); return null; } @@ -193,8 +194,57 @@ public class BeatService( return await ListAsync(chapterId, ct); } + public async Task?> AssignCharacterAsync( + Guid chapterId, AssignCharacterToBeatsRequest request, CancellationToken ct = default) + { + Guard.Default(chapterId, nameof(chapterId)); + Guard.Null(request, nameof(request)); + assignCharacterValidator.Validate(request).ThrowIfInvalid(logger); + + logger.LogInformation( + "Assigning character {CharacterId} to {Count} beats in chapter {ChapterId}", + request.CharacterId, request.BeatIds.Count, chapterId); + + var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct); + if (chapter is null) + { + logger.LogWarning("Rejected character assignment: chapter {ChapterId} not found", chapterId); + return null; + } + + var character = await db.Characters + .FirstOrDefaultAsync(c => c.Id == request.CharacterId && c.ProjectId == chapter.ProjectId, ct); + if (character is null) + { + logger.LogWarning( + "Rejected character assignment: character {CharacterId} not found in project {ProjectId}", + request.CharacterId, chapter.ProjectId); + return null; + } + + var beats = await Query().Where(b => b.ChapterId == chapterId && request.BeatIds.Contains(b.Id)).ToListAsync(ct); + var missing = request.BeatIds.Where(id => beats.All(b => b.Id != id)).ToList(); + if (missing.Count > 0) + { + logger.LogWarning( + "Rejected character assignment: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]); + return null; + } + + foreach (var beat in beats.Where(b => b.Characters.All(c => c.Id != character.Id))) + { + beat.Characters.Add(character); + beat.UpdatedAt = DateTimeOffset.UtcNow; + } + + await db.SaveChangesAsync(ct); + return await ListAsync(chapterId, ct); + } + private async Task> ResolveCharactersAsync(Guid projectId, IReadOnlyList characterIds, CancellationToken ct) { + logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId); + var distinct = characterIds.Distinct().ToList(); if (distinct.Count == 0) { @@ -212,6 +262,7 @@ public class BeatService( "A beat's characters must belong to the same project as its chapter."); } + logger.LogDebug("Resolved {Count} characters for project {ProjectId}", found.Count, projectId); return found; } @@ -223,7 +274,9 @@ public class BeatService( .Where(b => b.ChapterId == chapterId) .MaxAsync(b => (int?)b.SortOrder, ct); - return (max ?? 0) + 1; + var next = (max ?? 0) + 1; + logger.LogDebug("Next sort order for chapter {ChapterId} is {SortOrder}", chapterId, next); + return next; } private IQueryable Query() => @@ -238,13 +291,11 @@ public class BeatService( var beat = await Query().FirstOrDefaultAsync(b => b.Id == id, ct); if (beat is null) { - logger.LogInformation("Beat {BeatId} not found", id); - } - else - { - logger.LogDebug("Found beat {BeatId}", id); + logger.LogWarning("Beat {BeatId} not found", id); + return beat; } + logger.LogDebug("Found beat {BeatId}", id); return beat; } } diff --git a/src/Novelly.Mcp/Tools/BeatTools.cs b/src/Novelly.Mcp/Tools/BeatTools.cs index 06c7825..383a15a 100644 --- a/src/Novelly.Mcp/Tools/BeatTools.cs +++ b/src/Novelly.Mcp/Tools/BeatTools.cs @@ -58,6 +58,17 @@ public static class BeatTools CancellationToken ct) => api.DeleteAsync($"/api/beats/{beatId}", ct); + [McpServerTool(Name = "assign_character_to_beats")] + [Description("Add a character to several beats at once. Leaves each beat's existing characters " + + "and other fields alone — this only adds, it never removes.")] + public static Task AssignCharacterToBeats( + NovelApiClient api, + [Description("The chapter's id.")] Guid chapterId, + [Description("Id of the character to add.")] Guid characterId, + [Description("Ids of the beats to add the character to.")] Guid[] beatIds, + CancellationToken ct) => + api.PostAsync($"/api/chapters/{chapterId}/beats/assign-character", new { characterId, beatIds }, ct); + [McpServerTool(Name = "reorder_beats")] [Description("Renumber a chapter's beats to match the order given. List every beat id in the " + "order wanted; any left out keep their relative position at the end.")] diff --git a/src/Novelly.Web/src/api/hooks.ts b/src/Novelly.Web/src/api/hooks.ts index 3bbd927..8175aa3 100644 --- a/src/Novelly.Web/src/api/hooks.ts +++ b/src/Novelly.Web/src/api/hooks.ts @@ -293,6 +293,15 @@ export function useReorderBeats(chapterId: string) { }) } +export function useAssignCharacterToBeats(chapterId: string) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ characterId, beatIds }: { characterId: string; beatIds: string[] }) => + api.post(`/api/chapters/${chapterId}/beats/assign-character`, { characterId, beatIds }), + onSuccess: () => qc.invalidateQueries({ queryKey: keys.chapter(chapterId) }), + }) +} + export const useChapters = (projectId: string) => useQuery({ queryKey: keys.chapters(projectId), diff --git a/src/Novelly.Web/src/pages/ChapterPage.tsx b/src/Novelly.Web/src/pages/ChapterPage.tsx index ab3e670..58e8b0a 100644 --- a/src/Novelly.Web/src/pages/ChapterPage.tsx +++ b/src/Novelly.Web/src/pages/ChapterPage.tsx @@ -1,6 +1,7 @@ import { useState, type MouseEvent } from 'react' import { Link, useNavigate, useParams } from 'react-router-dom' import { + useAssignCharacterToBeats, useChapter, useCharacters, useCreateBeat, @@ -250,8 +251,25 @@ function BeatTable({ const update = useUpdateBeat(chapter.id, projectId) const remove = useDeleteBeat(chapter.id) const reorder = useReorderBeats(chapter.id) + const assignCharacter = useAssignCharacterToBeats(chapter.id) const [editingId, setEditingId] = useState(null) const [deletingBeat, setDeletingBeat] = useState(null) + const [selectedIds, setSelectedIds] = useState([]) + const [assignCharacterId, setAssignCharacterId] = useState('') + + const toggleSelected = (id: string) => + setSelectedIds((ids) => (ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id])) + + const toggleSelectAll = () => + setSelectedIds((ids) => (ids.length === chapter.beats.length ? [] : chapter.beats.map((b) => b.id))) + + const assignToSelected = () => { + if (!assignCharacterId || selectedIds.length === 0) return + assignCharacter.mutate( + { characterId: assignCharacterId, beatIds: selectedIds }, + { onSuccess: () => setSelectedIds([]) }, + ) + } if (chapter.beats.length === 0) { return ( @@ -274,10 +292,50 @@ function BeatTable({ update.mutate({ id, ...body }) return ( -
+
+ {selectedIds.length > 0 && ( +
+ + {selectedIds.length} beat{selectedIds.length === 1 ? '' : 's'} selected + + + + + {assignCharacter.error && } +
+ )} + +
+
+ + # Beat @@ -302,6 +360,15 @@ function BeatTable({ }} onKeyDown={(e) => e.key === 'Escape' && setEditingId(null)} > + + toggleSelected(beat.id)} + onClick={(e) => e.stopPropagation()} + /> +
{index + 1} @@ -419,6 +486,15 @@ function BeatTable({ } }} > +
+ toggleSelected(beat.id)} + onClick={(e) => e.stopPropagation()} + /> + {index + 1} @@ -467,6 +543,7 @@ function BeatTable({ onClose={() => setDeletingBeat(null)} /> )} + ) } diff --git a/tests/Novelly.Api.Tests/BeatServiceTests.cs b/tests/Novelly.Api.Tests/BeatServiceTests.cs index 87a40bf..48d08bf 100644 --- a/tests/Novelly.Api.Tests/BeatServiceTests.cs +++ b/tests/Novelly.Api.Tests/BeatServiceTests.cs @@ -103,6 +103,65 @@ public class BeatServiceTests : ServiceTestFixture Throws.TypeOf().With.Message.Contains("same project")); } + [Test] + public async Task Assigning_a_character_to_several_beats_leaves_their_other_characters_alone() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var mara = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Mara")); + var first = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); + var second = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("Second")); + + var assigned = await Beats.AssignCharacterAsync( + _chapterId, new AssignCharacterToBeatsRequest(mara.Id, [first.Id, second.Id])); + + Assert.Multiple(() => + { + Assert.That( + assigned!.Single(b => b.Id == first.Id).Characters.Select(c => c.Name), + Is.EquivalentTo(new[] { "Ines", "Mara" })); + Assert.That( + assigned.Single(b => b.Id == second.Id).Characters.Select(c => c.Name), + Is.EqualTo(new[] { "Mara" })); + }); + } + + [Test] + public async Task Assigning_a_character_already_on_a_beat_does_not_duplicate_it() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First", CharacterIds: [ines.Id])); + + var assigned = await Beats.AssignCharacterAsync( + _chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id])); + + Assert.That(assigned!.Single(b => b.Id == beat.Id).Characters.Select(c => c.Name), Is.EqualTo(new[] { "Ines" })); + } + + [Test] + public async Task Assigning_a_character_from_another_project_returns_null() + { + var other = await Projects.CreateAsync(new CreateProjectRequest("Other Book")); + var stranger = await Characters.CreateAsync(other.Id, new CreateCharacterRequest("Stranger")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); + + Assert.That( + await Beats.AssignCharacterAsync(_chapterId, new AssignCharacterToBeatsRequest(stranger.Id, [beat.Id])), + Is.Null); + } + + [Test] + public async Task Assigning_to_an_unknown_beat_returns_null_rather_than_partially_applying() + { + var ines = await Characters.CreateAsync(_projectId, new CreateCharacterRequest("Ines")); + var beat = await Beats.CreateAsync(_chapterId, new CreateBeatRequest("First")); + + var result = await Beats.AssignCharacterAsync( + _chapterId, new AssignCharacterToBeatsRequest(ines.Id, [beat.Id, Guid.NewGuid()])); + + Assert.That(result, Is.Null); + Assert.That((await Beats.GetAsync(beat.Id))!.Characters, Is.Empty); + } + [Test] public async Task Updating_leaves_omitted_fields_alone_and_clears_on_empty_string() { diff --git a/tests/Novelly.Api.Tests/ServiceTestFixture.cs b/tests/Novelly.Api.Tests/ServiceTestFixture.cs index 6768c46..03aa20d 100644 --- a/tests/Novelly.Api.Tests/ServiceTestFixture.cs +++ b/tests/Novelly.Api.Tests/ServiceTestFixture.cs @@ -51,7 +51,8 @@ public abstract class ServiceTestFixture Chapters = new ChapterService(Db.Context, Tags, ChapterLogs, new CreateChapterRequestValidator(), new UpdateChapterRequestValidator()); Beats = new BeatService( Db.Context, Tags, BeatLogs, - new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator()); + new CreateBeatRequestValidator(), new UpdateBeatRequestValidator(), new ReorderBeatsRequestValidator(), + new AssignCharacterToBeatsRequestValidator()); Arcs = new CharacterArcService( Db.Context, ArcLogs, new CreateArcStageRequestValidator(), new UpdateArcStageRequestValidator(), new ReorderArcStagesRequestValidator());