Add character aliases/identity links, move-beats, keyboard help overlay
- Characters can carry aliases and be linked as the same underlying person (canonical SameCharacterAsId, optional reveal chapter/note), surfaced through the API, MCP tools, agent toolset, and web UI. - Characters page redesigned as a filterable/sortable table (name+ aliases, role, importance, occupation, tags) instead of a sidebar list, to stay usable as the cast grows. - Beats can be moved between chapters (BeatService.MoveAsync + MCP/ agent tool + endpoint). - Add a keyboard-shortcuts help overlay (HelpButton/HelpOverlayContext) wired into the project layout. - CLAUDE.md: require every frontend component to carry a unique id attribute; apply it to CharacterMultiSelect and MarkdownEditor.
This commit is contained in:
@@ -151,7 +151,8 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Project", projectId));
|
||||
JsonInput.Strings(input, "tags"),
|
||||
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Project", projectId));
|
||||
|
||||
yield return new AgentTool(
|
||||
"update_character",
|
||||
@@ -181,7 +182,42 @@ public class NovelAgentToolset(
|
||||
JsonInput.String(input, "arc_summary"),
|
||||
JsonInput.String(input, "voice"),
|
||||
JsonInput.String(input, "notes"),
|
||||
JsonInput.Strings(input, "tags")), ct), c => c.ToResponse(), "Character", characterId);
|
||||
JsonInput.Strings(input, "tags"),
|
||||
JsonInput.Strings(input, "aliases")), ct), c => c.ToResponse(), "Character", characterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"link_character_identity",
|
||||
"Record that a character is really another character — e.g. one introduced under one name "
|
||||
+ "who is later revealed to be a character already in the project under another name. Both "
|
||||
+ "keep their own dossier and beats; the canonical identity is whichever character you link to.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character being revealed as someone else.", required: true)
|
||||
.Str("same_character_as_id", "Id of the character this one really is.", required: true)
|
||||
.Str("revealed_in_chapter_id", "Id of the chapter where the reveal happens, if any.")
|
||||
.Str("note", "Context on the reveal, e.g. how and why the disguise held.")
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
||||
return await OrNotFound(characters.LinkIdentityAsync(
|
||||
characterId,
|
||||
new LinkCharacterIdentityRequest(
|
||||
JsonInput.RequiredGuid(input, "same_character_as_id"),
|
||||
JsonInput.Guid(input, "revealed_in_chapter_id"),
|
||||
JsonInput.String(input, "note")), ct), c => c.ToResponse(), "Character", characterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"unlink_character_identity",
|
||||
"Remove a character's identity link, restoring it to its own separate identity.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("character_id", "Id of the character to unlink.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var characterId = JsonInput.RequiredGuid(input, "character_id");
|
||||
return await DeletedOrNotFound(characters.UnlinkIdentityAsync(characterId, ct), "Character", characterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
@@ -289,6 +325,27 @@ public class NovelAgentToolset(
|
||||
.Where(g => g != Guid.Empty)]), ct), list => list.Select(b => b.ToResponse()), "Chapter", chapterId);
|
||||
});
|
||||
|
||||
yield return new AgentTool(
|
||||
"move_beats",
|
||||
"Move one or more beats from one chapter to another, appending them to the target "
|
||||
+ "chapter's end in the order given.",
|
||||
new JsonSchemaBuilder()
|
||||
.Str("chapter_id", "Id of the beats' current chapter.", required: true)
|
||||
.Str("target_chapter_id", "Id of the chapter to move the beats into.", required: true)
|
||||
.StringArray("beat_ids", "Ids of the beats to move.", required: true)
|
||||
.Build(),
|
||||
async (_, input, ct) =>
|
||||
{
|
||||
var chapterId = JsonInput.RequiredGuid(input, "chapter_id");
|
||||
return await OrNotFound(beats.MoveAsync(
|
||||
chapterId,
|
||||
new MoveBeatsRequest(
|
||||
JsonInput.RequiredGuid(input, "target_chapter_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. "
|
||||
@@ -596,7 +653,8 @@ public class NovelAgentToolset(
|
||||
.Str("arc_summary", "How they change over the course of the book.")
|
||||
.Str("voice", "Speech patterns and register that make their dialogue theirs.")
|
||||
.Str("notes", "Anything else worth recording.")
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.");
|
||||
.StringArray("tags", "Tags for cross-referencing. Replaces the existing tags.")
|
||||
.StringArray("aliases", "Other names this character is known by. Replaces the existing aliases.");
|
||||
}
|
||||
|
||||
private static JsonSchemaBuilder BeatSchema() =>
|
||||
|
||||
@@ -119,6 +119,24 @@ public class AssignCharacterToBeatsRequestValidator : IModelValidator<AssignChar
|
||||
}
|
||||
}
|
||||
|
||||
public record MoveBeatsRequest(Guid TargetChapterId, IReadOnlyList<Guid> BeatIds);
|
||||
|
||||
public class MoveBeatsRequestValidator : IModelValidator<MoveBeatsRequest>
|
||||
{
|
||||
public ValidationResult Validate(MoveBeatsRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.TargetChapterId == Guid.Empty)
|
||||
result.AddError("TargetChapterId", "'Target Chapter 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(
|
||||
|
||||
@@ -39,6 +39,11 @@ public static class BeatEndpoints
|
||||
(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.");
|
||||
|
||||
chapterScoped.MapPost("/move", async (
|
||||
Guid chapterId, MoveBeatsRequest request, BeatService service, CancellationToken ct) =>
|
||||
(await service.MoveAsync(chapterId, request, ct))?.Select(b => b.ToResponse()).ToList().ToApiResult())
|
||||
.WithSummary("Move one or more beats to another chapter, appending them to its end.");
|
||||
|
||||
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())
|
||||
|
||||
@@ -17,7 +17,8 @@ public class BeatService(
|
||||
IModelValidator<CreateBeatRequest> createValidator,
|
||||
IModelValidator<UpdateBeatRequest> updateValidator,
|
||||
IModelValidator<ReorderBeatsRequest> reorderValidator,
|
||||
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator)
|
||||
IModelValidator<AssignCharacterToBeatsRequest> assignCharacterValidator,
|
||||
IModelValidator<MoveBeatsRequest> moveValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Beat>> ListAsync(Guid chapterId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -266,6 +267,62 @@ public class BeatService(
|
||||
return await ListAsync(chapterId, ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Beat>?> MoveAsync(
|
||||
Guid chapterId, MoveBeatsRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(chapterId, nameof(chapterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
moveValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Moving {Count} beats from chapter {ChapterId} to chapter {TargetChapterId}",
|
||||
request.BeatIds.Count, chapterId, request.TargetChapterId);
|
||||
|
||||
var chapter = await db.Chapters.FirstOrDefaultAsync(c => c.Id == chapterId, ct);
|
||||
if (chapter is null)
|
||||
{
|
||||
logger.LogWarning("Rejected beat move: chapter {ChapterId} not found", chapterId);
|
||||
return null;
|
||||
}
|
||||
|
||||
var targetChapter = await db.Chapters.FirstOrDefaultAsync(
|
||||
c => c.Id == request.TargetChapterId && c.ProjectId == chapter.ProjectId, ct);
|
||||
if (targetChapter is null)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected beat move: target chapter {TargetChapterId} not found in project {ProjectId}",
|
||||
request.TargetChapterId, chapter.ProjectId);
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(chapter.ProjectId, ProjectPermission.Write, ct);
|
||||
|
||||
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 beat move: chapter {ChapterId} referenced missing beat {BeatId}", chapterId, missing[0]);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chapterId == request.TargetChapterId)
|
||||
{
|
||||
return beats;
|
||||
}
|
||||
|
||||
var nextSortOrder = await NextSortOrderAsync(request.TargetChapterId, ct);
|
||||
foreach (var beatId in request.BeatIds)
|
||||
{
|
||||
var beat = beats.Single(b => b.Id == beatId);
|
||||
beat.ChapterId = request.TargetChapterId;
|
||||
beat.SortOrder = nextSortOrder++;
|
||||
beat.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return beats;
|
||||
}
|
||||
|
||||
private async Task<List<Character>> ResolveCharactersAsync(Guid projectId, IReadOnlyList<Guid> characterIds, CancellationToken ct)
|
||||
{
|
||||
logger.LogDebug("Resolving {Count} characters for project {ProjectId}", characterIds.Count, projectId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using Novelly.Api.Beats;
|
||||
using Novelly.Api.Chapters;
|
||||
using Novelly.Api.Projects;
|
||||
using Novelly.Api.Tags;
|
||||
|
||||
@@ -38,6 +39,16 @@ public class Character
|
||||
|
||||
public string? Notes { get; set; }
|
||||
|
||||
public List<string> Aliases { get; set; } = [];
|
||||
|
||||
public Guid? SameCharacterAsId { get; set; }
|
||||
public Character? SameCharacterAs { get; set; }
|
||||
public List<Character> OtherIdentities { get; set; } = [];
|
||||
|
||||
public Guid? RevealedInChapterId { get; set; }
|
||||
public Chapter? RevealedInChapter { get; set; }
|
||||
public string? IdentityNote { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
@@ -72,12 +83,19 @@ public class CharacterEntityTypeConfiguration : IEntityTypeConfiguration<Charact
|
||||
entity.Property(c => c.Role).HasConversion<string>().HasMaxLength(32);
|
||||
entity.Property(c => c.Importance).HasConversion<string>().HasMaxLength(32);
|
||||
entity.HasIndex(c => c.ProjectId);
|
||||
entity.HasIndex(c => c.SameCharacterAsId);
|
||||
|
||||
entity.HasMany(c => c.Relationships).WithOne(r => r.Character!)
|
||||
.HasForeignKey(r => r.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasMany(c => c.ArcStages).WithOne(s => s.Character!)
|
||||
.HasForeignKey(s => s.CharacterId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(c => c.SameCharacterAs).WithMany(c => c.OtherIdentities)
|
||||
.HasForeignKey(c => c.SameCharacterAsId).OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
entity.HasOne(c => c.RevealedInChapter).WithMany()
|
||||
.HasForeignKey(c => c.RevealedInChapterId).OnDelete(DeleteBehavior.SetNull);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,20 @@ public record CharacterResponse(
|
||||
string? ArcSummary,
|
||||
string? Voice,
|
||||
string? Notes,
|
||||
IReadOnlyList<string> Aliases,
|
||||
Guid? SameCharacterAsId,
|
||||
string? SameCharacterAsName,
|
||||
Guid? RevealedInChapterId,
|
||||
int? RevealedInChapterNumber,
|
||||
string? IdentityNote,
|
||||
IReadOnlyList<CharacterIdentityResponse> OtherIdentities,
|
||||
IReadOnlyList<RelationshipResponse> Relationships,
|
||||
IReadOnlyList<TagResponse> Tags,
|
||||
IReadOnlyList<ArcStageResponse> ArcStages,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
public record CharacterIdentityResponse(Guid Id, string Name);
|
||||
|
||||
public record RelationshipResponse(
|
||||
Guid Id,
|
||||
Guid RelatedCharacterId,
|
||||
@@ -51,7 +60,8 @@ public record CreateCharacterRequest(
|
||||
string? ArcSummary = null,
|
||||
string? Voice = null,
|
||||
string? Notes = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
IReadOnlyList<string>? Aliases = null);
|
||||
|
||||
public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRequest>
|
||||
{
|
||||
@@ -63,7 +73,7 @@ public class CreateCharacterRequestValidator : IModelValidator<CreateCharacterRe
|
||||
CharacterValidation.OptionalFields(
|
||||
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
|
||||
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
|
||||
model.Notes, model.Tags, result);
|
||||
model.Notes, model.Tags, model.Aliases, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -86,7 +96,8 @@ public record UpdateCharacterRequest(
|
||||
string? ArcSummary = null,
|
||||
string? Voice = null,
|
||||
string? Notes = null,
|
||||
IReadOnlyList<string>? Tags = null);
|
||||
IReadOnlyList<string>? Tags = null,
|
||||
IReadOnlyList<string>? Aliases = null);
|
||||
|
||||
public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRequest>
|
||||
{
|
||||
@@ -98,7 +109,7 @@ public class UpdateCharacterRequestValidator : IModelValidator<UpdateCharacterRe
|
||||
CharacterValidation.OptionalFields(
|
||||
model.Age, model.Pronouns, model.Occupation, model.Appearance, model.Personality, model.Backstory,
|
||||
model.Want, model.Need, model.InternalConflict, model.ExternalConflict, model.ArcSummary, model.Voice,
|
||||
model.Notes, model.Tags, result);
|
||||
model.Notes, model.Tags, model.Aliases, result);
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -109,7 +120,7 @@ file static class CharacterValidation
|
||||
public static void OptionalFields(
|
||||
string? age, string? pronouns, string? occupation, string? appearance, string? personality, string? backstory,
|
||||
string? want, string? need, string? internalConflict, string? externalConflict, string? arcSummary, string? voice,
|
||||
string? notes, IReadOnlyList<string>? tags, ValidationResult result)
|
||||
string? notes, IReadOnlyList<string>? tags, IReadOnlyList<string>? aliases, ValidationResult result)
|
||||
{
|
||||
Cap(age, "Age", 100, result);
|
||||
Cap(pronouns, "Pronouns", 100, result);
|
||||
@@ -127,6 +138,18 @@ file static class CharacterValidation
|
||||
|
||||
if (tags is not null && tags.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Tags", "'Tags' must not contain blank entries.");
|
||||
|
||||
if (aliases is not null)
|
||||
{
|
||||
if (aliases.Any(string.IsNullOrWhiteSpace))
|
||||
result.AddError("Aliases", "'Aliases' must not contain blank entries.");
|
||||
|
||||
if (aliases.Any(a => a.Length > 200))
|
||||
result.AddError("Aliases", "'Aliases' entries must be 200 characters or fewer.");
|
||||
|
||||
if (aliases.Count > 25)
|
||||
result.AddError("Aliases", "'Aliases' must contain 25 entries or fewer.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Cap(string? value, string field, int max, ValidationResult result)
|
||||
@@ -157,6 +180,26 @@ public class CreateRelationshipRequestValidator : IModelValidator<CreateRelation
|
||||
}
|
||||
}
|
||||
|
||||
public record LinkCharacterIdentityRequest(
|
||||
Guid SameCharacterAsId,
|
||||
Guid? RevealedInChapterId = null,
|
||||
string? Note = null);
|
||||
|
||||
public class LinkCharacterIdentityRequestValidator : IModelValidator<LinkCharacterIdentityRequest>
|
||||
{
|
||||
public ValidationResult Validate(LinkCharacterIdentityRequest model)
|
||||
{
|
||||
var result = new ValidationResult();
|
||||
|
||||
if (model.SameCharacterAsId == Guid.Empty)
|
||||
result.AddError("SameCharacterAsId", "'Same Character As Id' must not be empty.");
|
||||
|
||||
result.AddOptionalTextErrors("Note", "Note", model.Note, 2000);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
public record ArcStageResponse(
|
||||
Guid Id,
|
||||
Guid CharacterId,
|
||||
@@ -240,6 +283,13 @@ public static class CharacterMapping
|
||||
c.Id, c.ProjectId, c.Name, c.Role, c.Importance, c.Age, c.Pronouns, c.Occupation,
|
||||
c.Appearance, c.Personality, c.Backstory, c.Want, c.Need,
|
||||
c.InternalConflict, c.ExternalConflict, c.ArcSummary, c.Voice, c.Notes,
|
||||
[.. c.Aliases],
|
||||
c.SameCharacterAsId,
|
||||
c.SameCharacterAs?.Name,
|
||||
c.RevealedInChapterId,
|
||||
c.RevealedInChapter?.Number,
|
||||
c.IdentityNote,
|
||||
[.. c.OtherIdentities.OrderBy(o => o.Name).Select(o => new CharacterIdentityResponse(o.Id, o.Name))],
|
||||
[.. c.Relationships.Select(r => new RelationshipResponse(
|
||||
r.Id,
|
||||
r.RelatedCharacterId,
|
||||
|
||||
@@ -56,6 +56,16 @@ public static class CharacterEndpoints
|
||||
await service.RemoveRelationshipAsync(relationshipId, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Remove a relationship.");
|
||||
|
||||
characters.MapPut("/{id:guid}/identity", async (
|
||||
Guid id, LinkCharacterIdentityRequest request, CharacterService service, CancellationToken ct) =>
|
||||
(await service.LinkIdentityAsync(id, request, ct))?.ToResponse().ToApiResult())
|
||||
.WithSummary("Link this character as another identity of a character in the same project.");
|
||||
|
||||
characters.MapDelete("/{id:guid}/identity", async (
|
||||
Guid id, CharacterService service, CancellationToken ct) =>
|
||||
await service.UnlinkIdentityAsync(id, ct) ? Results.NoContent() : Results.NotFound())
|
||||
.WithSummary("Remove this character's identity link.");
|
||||
|
||||
characters.MapGet("/{id:guid}/arc", async (
|
||||
Guid id, CharacterArcService service, CancellationToken ct) =>
|
||||
Results.Ok((await service.ListAsync(id, ct)).Select(s => s.ToResponse())))
|
||||
|
||||
@@ -14,7 +14,8 @@ public class CharacterService(
|
||||
ILogger<CharacterService> logger,
|
||||
IModelValidator<CreateCharacterRequest> createValidator,
|
||||
IModelValidator<UpdateCharacterRequest> updateValidator,
|
||||
IModelValidator<CreateRelationshipRequest> relationshipValidator)
|
||||
IModelValidator<CreateRelationshipRequest> relationshipValidator,
|
||||
IModelValidator<LinkCharacterIdentityRequest> identityValidator)
|
||||
{
|
||||
public async Task<IReadOnlyList<Character>> ListAsync(Guid projectId, CancellationToken ct = default)
|
||||
{
|
||||
@@ -97,6 +98,11 @@ public class CharacterService(
|
||||
character.Tags = await tags.ResolveAsync(projectId, names, ct);
|
||||
}
|
||||
|
||||
if (request.Aliases is { } aliases)
|
||||
{
|
||||
character.Aliases = [.. aliases];
|
||||
}
|
||||
|
||||
db.Characters.Add(character);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
@@ -142,6 +148,11 @@ public class CharacterService(
|
||||
character.Tags = await tags.ResolveAsync(character.ProjectId, names, ct);
|
||||
}
|
||||
|
||||
if (request.Aliases is { } aliases)
|
||||
{
|
||||
character.Aliases = [.. aliases];
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(id, ct))!;
|
||||
}
|
||||
@@ -229,13 +240,107 @@ public class CharacterService(
|
||||
return true;
|
||||
}
|
||||
|
||||
public async Task<Character?> LinkIdentityAsync(
|
||||
Guid characterId, LinkCharacterIdentityRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
Guard.Null(request, nameof(request));
|
||||
identityValidator.Validate(request).ThrowIfInvalid(logger);
|
||||
|
||||
logger.LogInformation(
|
||||
"Linking character {CharacterId} to identity {SameCharacterAsId}", characterId, request.SameCharacterAsId);
|
||||
|
||||
var character = await FindAsync(characterId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
|
||||
if (request.SameCharacterAsId == characterId)
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: character {CharacterId} cannot be linked to itself", characterId);
|
||||
throw new InvalidOperationException("A character cannot be linked to itself.");
|
||||
}
|
||||
|
||||
var target = await db.Characters
|
||||
.Include(c => c.SameCharacterAs)
|
||||
.FirstOrDefaultAsync(c => c.Id == request.SameCharacterAsId, ct);
|
||||
if (target is null)
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: target character {SameCharacterAsId} not found", request.SameCharacterAsId);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (target.ProjectId != character.ProjectId)
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Rejected identity link: character {CharacterId} and {SameCharacterAsId} belong to different projects",
|
||||
characterId, request.SameCharacterAsId);
|
||||
throw new InvalidOperationException("Characters must belong to the same project to be linked.");
|
||||
}
|
||||
|
||||
if (await db.Characters.AnyAsync(c => c.SameCharacterAsId == characterId, ct))
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: character {CharacterId} already has other identities pointing to it", characterId);
|
||||
throw new InvalidOperationException("This character already has other identities linked to it and cannot itself be an alias.");
|
||||
}
|
||||
|
||||
var canonical = target.SameCharacterAsId ?? target.Id;
|
||||
|
||||
if (request.RevealedInChapterId is { } chapterId)
|
||||
{
|
||||
var chapterInProject = await db.Chapters.AnyAsync(c => c.Id == chapterId && c.ProjectId == character.ProjectId, ct);
|
||||
if (!chapterInProject)
|
||||
{
|
||||
logger.LogWarning("Rejected identity link: chapter {ChapterId} not in project {ProjectId}", chapterId, character.ProjectId);
|
||||
throw new InvalidOperationException("The reveal chapter must belong to the same project.");
|
||||
}
|
||||
}
|
||||
|
||||
character.SameCharacterAsId = canonical;
|
||||
character.RevealedInChapterId = request.RevealedInChapterId;
|
||||
character.IdentityNote = request.Note;
|
||||
character.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return (await FindAsync(characterId, ct))!;
|
||||
}
|
||||
|
||||
public async Task<bool> UnlinkIdentityAsync(Guid characterId, CancellationToken ct = default)
|
||||
{
|
||||
Guard.Default(characterId, nameof(characterId));
|
||||
|
||||
logger.LogInformation("Unlinking identity for character {CharacterId}", characterId);
|
||||
|
||||
var character = await FindAsync(characterId, ct);
|
||||
if (character is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
await access.RequireAsync(character.ProjectId, ProjectPermission.Write, ct);
|
||||
|
||||
character.SameCharacterAsId = null;
|
||||
character.RevealedInChapterId = null;
|
||||
character.IdentityNote = null;
|
||||
character.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
return true;
|
||||
}
|
||||
|
||||
private IQueryable<Character> Query() =>
|
||||
db.Characters
|
||||
.Include(c => c.Relationships)
|
||||
.ThenInclude(r => r.RelatedCharacter)
|
||||
.Include(c => c.Tags)
|
||||
.Include(c => c.ArcStages)
|
||||
.ThenInclude(s => s.Chapter);
|
||||
.ThenInclude(s => s.Chapter)
|
||||
.Include(c => c.SameCharacterAs)
|
||||
.Include(c => c.OtherIdentities)
|
||||
.Include(c => c.RevealedInChapter);
|
||||
|
||||
private async Task<Character?> FindAsync(Guid id, CancellationToken ct)
|
||||
{
|
||||
|
||||
Generated
+1139
File diff suppressed because it is too large
Load Diff
+102
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCharacterAliasesAndIdentityLinks : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Aliases",
|
||||
table: "Characters",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "[]");
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "IdentityNote",
|
||||
table: "Characters",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "RevealedInChapterId",
|
||||
table: "Characters",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<Guid>(
|
||||
name: "SameCharacterAsId",
|
||||
table: "Characters",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Characters_RevealedInChapterId",
|
||||
table: "Characters",
|
||||
column: "RevealedInChapterId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Characters_SameCharacterAsId",
|
||||
table: "Characters",
|
||||
column: "SameCharacterAsId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Characters_Chapters_RevealedInChapterId",
|
||||
table: "Characters",
|
||||
column: "RevealedInChapterId",
|
||||
principalTable: "Chapters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Characters_Characters_SameCharacterAsId",
|
||||
table: "Characters",
|
||||
column: "SameCharacterAsId",
|
||||
principalTable: "Characters",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.SetNull);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Characters_Chapters_RevealedInChapterId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Characters_Characters_SameCharacterAsId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Characters_RevealedInChapterId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Characters_SameCharacterAsId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Aliases",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IdentityNote",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RevealedInChapterId",
|
||||
table: "Characters");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "SameCharacterAsId",
|
||||
table: "Characters");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,10 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Age")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.PrimitiveCollection<string>("Aliases")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Appearance")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -314,6 +318,9 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("ExternalConflict")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("IdentityNote")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Importance")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
@@ -345,11 +352,17 @@ namespace Novelly.Api.Data.Migrations
|
||||
b.Property<string>("Pronouns")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("RevealedInChapterId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Role")
|
||||
.IsRequired()
|
||||
.HasMaxLength(32)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("SameCharacterAsId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("UpdatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -363,6 +376,10 @@ namespace Novelly.Api.Data.Migrations
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("RevealedInChapterId");
|
||||
|
||||
b.HasIndex("SameCharacterAsId");
|
||||
|
||||
b.ToTable("Characters");
|
||||
});
|
||||
|
||||
@@ -963,7 +980,21 @@ namespace Novelly.Api.Data.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("Novelly.Api.Chapters.Chapter", "RevealedInChapter")
|
||||
.WithMany()
|
||||
.HasForeignKey("RevealedInChapterId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("Novelly.Api.Characters.Character", "SameCharacterAs")
|
||||
.WithMany("OtherIdentities")
|
||||
.HasForeignKey("SameCharacterAsId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.Navigation("Project");
|
||||
|
||||
b.Navigation("RevealedInChapter");
|
||||
|
||||
b.Navigation("SameCharacterAs");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Novelly.Api.Characters.CharacterArcStage", b =>
|
||||
@@ -1082,6 +1113,8 @@ namespace Novelly.Api.Data.Migrations
|
||||
{
|
||||
b.Navigation("ArcStages");
|
||||
|
||||
b.Navigation("OtherIdentities");
|
||||
|
||||
b.Navigation("Relationships");
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user