diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 6761e7a..ca45a92 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -138,6 +138,11 @@ var tests = new (string Name, Action Test)[] ("Story Intelligence resumed chapters use canonical labels", StoryIntelligenceResumedChaptersUseCanonicalLabels), ("Story Intelligence character review posts selected existing match", StoryIntelligenceCharacterReviewPostsSelectedExistingMatch), ("Story Intelligence character default changes preserve persisted results", StoryIntelligenceCharacterDefaultChangesPreservePersistedResults), + ("Structural POV rides existing chapter analysis", StructuralPovRidesExistingChapterAnalysis), + ("Structural POV persists through import resolution", StructuralPovPersistsThroughImportResolution), + ("Normal scene and chapter saves persist POV", NormalSceneAndChapterSavesPersistPov), + ("Character merge is a reusable stored procedure workflow", CharacterMergeIsReusableStoredProcedureWorkflow), + ("Story Intelligence character review suppresses resolved narrator POV", StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov), ("Story Intelligence character review reuses structured appearance aliases", StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases), ("Story Intelligence review evidence includes chapter scene labels", StoryIntelligenceReviewEvidenceIncludesChapterSceneLabels), ("Story Intelligence import enables project metric defaults before save", StoryIntelligenceImportEnablesProjectMetricDefaultsBeforeSave), @@ -3355,6 +3360,94 @@ static void StoryIntelligenceCharacterDefaultChangesPreservePersistedResults() Assert(!service.Contains("QueueAdminTextAsync", StringComparison.Ordinal), "Character review defaults must not queue new Story Intelligence work."); } +static void StructuralPovRidesExistingChapterAnalysis() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var prompt = File.ReadAllText(Path.Combine(root, "Docs/AI/Chapter-Structure-Prompt-V2.md")); + var models = File.ReadAllText(Path.Combine(root, "Models/ChapterStructureModels.cs")); + var service = File.ReadAllText(Path.Combine(root, "Services/ManuscriptStructureAnalysisService.cs")); + var normaliser = File.ReadAllText(Path.Combine(root, "Services/ChapterStructureBoundaryNormaliser.cs")); + + Assert(prompt.Contains("defaultPov", StringComparison.Ordinal), "Structural prompt should ask for default POV in the existing chapter pass."); + Assert(prompt.Contains("povOverride", StringComparison.Ordinal), "Structural prompt should allow scene-level POV overrides."); + Assert(prompt.Contains("Narrated by Maggie", StringComparison.Ordinal), "Structural prompt should treat explicit narrator labels as strong evidence."); + Assert(prompt.Contains("knownCharacters", StringComparison.Ordinal), "Structural prompt should prefer supplied canonical characters."); + Assert(models.Contains("ChapterStructurePovSuggestion", StringComparison.Ordinal), "Structural response model should carry compact POV suggestions."); + Assert(service.Contains("KnownCharacters { get; init; } = []", StringComparison.Ordinal), "Structural analysis request should carry known character context."); + Assert(service.Contains("SuggestedPovName", StringComparison.Ordinal), "Structural analysis should copy POV suggestions to scene boundaries."); + Assert(normaliser.Contains("PovOverride = boundary.PovOverride", StringComparison.Ordinal), "Boundary repair should preserve scene POV overrides."); + Assert(normaliser.Contains("DefaultPov = chapterStructure.DefaultPov", StringComparison.Ordinal), "Boundary repair should preserve chapter default POV."); +} + +static void StructuralPovPersistsThroughImportResolution() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql")); + + var mapping = onboarding.IndexOf("await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID)", StringComparison.Ordinal); + var pov = onboarding.IndexOf("await ApplyStructuralPovAsync(preview, structuralScenes, result)", StringComparison.Ordinal); + Assert(mapping >= 0 && pov > mapping, "Structural POV should resolve after deterministic canonical character mapping."); + Assert(onboarding.Contains("BuildCanonicalCharacterNameIndexAsync", StringComparison.Ordinal), "Import POV resolution should reuse canonical character names and aliases."); + Assert(onboarding.Contains("characters.ListAliasesAsync", StringComparison.Ordinal), "Import POV resolution should include accepted aliases."); + Assert(onboarding.Contains("\"StructuralInherited\"", StringComparison.Ordinal), "Inherited scene POV should carry provenance."); + Assert(onboarding.Contains("\"StructuralOverride\"", StringComparison.Ordinal), "Scene POV overrides should carry provenance."); + Assert(migration.Contains("POVSuggestedName", StringComparison.Ordinal), "Unresolved structural POV names should survive as suggested names."); + Assert(migration.Contains("@OverwriteAuthor bit = 0", StringComparison.Ordinal), "Structural POV updates should not overwrite author POV by default."); +} + +static void NormalSceneAndChapterSavesPersistPov() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); + var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); + var chaptersEdit = File.ReadAllText(Path.Combine(root, "Views/Chapters/Edit.cshtml")); + var chaptersDetails = File.ReadAllText(Path.Combine(root, "Views/Chapters/Details.cshtml")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql")); + + Assert(repository.Contains("chapter.POVCharacterID", StringComparison.Ordinal), "Chapter repository should pass POVCharacterID to Chapter_Save."); + Assert(repository.Contains("scene.POVCharacterID", StringComparison.Ordinal), "Scene repository should pass POVCharacterID to Scene_Save."); + Assert(services.Contains("POVCharacterID = model.POVCharacterID", StringComparison.Ordinal), "Normal scene/chapter services should persist submitted POV values."); + Assert(chaptersEdit.Contains("asp-for=\"POVCharacterID\"", StringComparison.Ordinal), "Chapter edit should expose a compact POV selector."); + Assert(chaptersDetails.Contains("Apply to scenes", StringComparison.Ordinal), "Chapter details should allow POV propagation to scenes."); + Assert(chaptersDetails.Contains("POV: @chapterPov", StringComparison.Ordinal), "Chapter details should display compact POV status."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Scene_Save", StringComparison.Ordinal), "New migration should own the Scene_Save parameter update."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Chapter_Save", StringComparison.Ordinal), "New migration should own the Chapter_Save parameter update."); +} + +static void CharacterMergeIsReusableStoredProcedureWorkflow() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs")); + var services = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs")); + var controller = File.ReadAllText(Path.Combine(root, "Controllers/CharactersController.cs")); + var details = File.ReadAllText(Path.Combine(root, "Views/Characters/Details.cshtml")); + var merge = File.ReadAllText(Path.Combine(root, "Views/Characters/Merge.cshtml")); + var siCharacters = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql")); + + Assert(repository.Contains("Task MergeAsync", StringComparison.Ordinal), "Character repository should expose a reusable merge operation."); + Assert(services.Contains("Task GetMergeAsync", StringComparison.Ordinal), "Character service should expose merge UI data."); + Assert(controller.Contains("public async Task Merge(int id)", StringComparison.Ordinal), "Normal Character UI should expose merge GET."); + Assert(controller.Contains("public async Task Merge(CharacterMergeForm form)", StringComparison.Ordinal), "Normal Character UI should expose merge POST."); + Assert(details.Contains("Merge with another character", StringComparison.Ordinal), "Character details should link to merge."); + Assert(merge.Contains("References, aliases, scene appearances, POV links, relationship events and knowledge are moved", StringComparison.Ordinal), "Merge UI should explain reference propagation."); + Assert(siCharacters.Contains("characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value", StringComparison.Ordinal), "Story Intelligence aliasing should reuse canonical merge when an existing duplicate is known."); + Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Character_Merge", StringComparison.Ordinal), "Character merge should be database-backed."); + Assert(migration.Contains("UPDATE dbo.RelationshipEvents", StringComparison.Ordinal), "Merge should move relationship events when relationships converge."); + Assert(migration.Contains("Archived during character merge to avoid a self-relationship", StringComparison.Ordinal), "Merge should archive self-relationships instead of flattening them."); +} + +static void StoryIntelligenceCharacterReviewSuppressesResolvedNarratorPov() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceCharacterImportService.cs")); + + Assert(service.Contains("!importedScene.POVCharacterID.HasValue", StringComparison.Ordinal), "Scene Intelligence POV candidates should not be reintroduced when canonical scene POV already exists."); + Assert(service.Contains("IsResolvedPovReference(cleanName, importedScene)", StringComparison.Ordinal), "Narrator-style observed names should be suppressed when scene POV is resolved."); + Assert(service.Contains("IgnoredNames.Contains(Normalise(name))", StringComparison.Ordinal), "Suppression should reuse existing generic/contextual reference filtering."); +} + static void StoryIntelligenceCharacterReviewReusesStructuredAppearanceAliases() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); diff --git a/PlotLine/Controllers/ChaptersController.cs b/PlotLine/Controllers/ChaptersController.cs index 0f4bdc8..0902560 100644 --- a/PlotLine/Controllers/ChaptersController.cs +++ b/PlotLine/Controllers/ChaptersController.cs @@ -51,6 +51,7 @@ public sealed class ChaptersController(IChapterService chapters) : Controller createModel.ChapterNumber = model.ChapterNumber; createModel.ChapterTitle = model.ChapterTitle; + createModel.POVCharacterID = model.POVCharacterID; createModel.Summary = model.Summary; createModel.RevisionStatusID = model.RevisionStatusID; return View("Edit", createModel); @@ -59,6 +60,15 @@ public sealed class ChaptersController(IChapterService chapters) : Controller return RedirectToAction(nameof(Details), new { id = chapterId }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task SetScenesPov(int chapterId, int? povCharacterId, bool overwriteAuthor) + { + var updated = await chapters.SetScenesPovAsync(chapterId, povCharacterId, overwriteAuthor); + TempData["ChapterMessage"] = $"POV updated on {updated:N0} scene{(updated == 1 ? string.Empty : "s")}."; + return RedirectToAction(nameof(Details), new { id = chapterId }); + } + [HttpPost] [ValidateAntiForgeryToken] public async Task Archive(int id, int bookId) diff --git a/PlotLine/Controllers/CharactersController.cs b/PlotLine/Controllers/CharactersController.cs index df019c9..1e74b40 100644 --- a/PlotLine/Controllers/CharactersController.cs +++ b/PlotLine/Controllers/CharactersController.cs @@ -32,6 +32,12 @@ public sealed class CharactersController(ICharacterService characters) : Control return model is null ? NotFound() : View(model); } + public async Task Merge(int id) + { + var model = await characters.GetMergeAsync(id); + return model is null ? NotFound() : View(model); + } + public async Task CropAvatar(int imageId) { var model = await characters.GetAvatarCropAsync(imageId); @@ -99,6 +105,35 @@ public sealed class CharactersController(ICharacterService characters) : Control : RedirectToAction(nameof(Details), new { id = saveResult.CharacterID }); } + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Merge(CharacterMergeForm form) + { + try + { + var result = await characters.MergeAsync(form); + if (result is null) + { + return NotFound(); + } + + TempData["CharacterMessage"] = $"{result.SourceCharacterName} merged into {result.TargetCharacterName}."; + return RedirectToAction(nameof(Details), new { id = result.TargetCharacterID }); + } + catch (InvalidOperationException ex) + { + ModelState.AddModelError(string.Empty, ex.Message); + var model = await characters.GetMergeAsync(form.SourceCharacterID); + if (model is null) + { + return NotFound(); + } + + model.Form = form; + return View(model); + } + } + private void ValidateSexWorkflow(CharacterEditViewModel model) { if (model.SexValueID == CharacterEditViewModel.CustomSexValue) diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 20b9736..a6c6467 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -75,6 +75,8 @@ public interface IChapterRepository Task> ListByBookAsync(int bookId); Task GetAsync(int chapterId); Task SaveAsync(Chapter chapter); + Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null); + Task SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor); Task ArchiveAsync(int chapterId); } @@ -84,7 +86,7 @@ public interface ISceneRepository Task> ListByFloorPlanAsync(int floorPlanId); Task GetAsync(int sceneId); Task SaveAsync(Scene scene); - Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId); + Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false); Task SaveFloorPlanLinkAsync(int sceneId, int? floorPlanId, int? initialFloorPlanFloorId); Task SavePurposesAsync(int sceneId, IEnumerable purposeIds); Task SaveMetricValuesAsync(int sceneId, IEnumerable metricValues); @@ -216,6 +218,7 @@ public interface ICharacterRepository Task AddAliasAsync(int characterId, string alias, int? sortOrder); Task UpdateAliasAsync(int characterAliasId, string alias, int? sortOrder); Task DeleteAliasAsync(int characterAliasId); + Task MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId); Task> ListSexValuesAsync(int ownerUserId); Task GetSexValueAsync(int characterSexValueId, int ownerUserId); Task GetOrCreateSexValueAsync(int ownerUserId, string sexName); @@ -2462,7 +2465,33 @@ public sealed class ChapterRepository(ISqlConnectionFactory connectionFactory) : using var connection = connectionFactory.CreateConnection(); return await connection.QuerySingleAsync( "dbo.Chapter_Save", - new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID }, + new { chapter.ChapterID, chapter.BookID, chapter.ChapterNumber, chapter.ChapterTitle, chapter.Summary, chapter.ChapterPurposeID, chapter.RevisionStatusID, chapter.POVCharacterID }, + commandType: CommandType.StoredProcedure); + } + + public async Task UpdatePovCharacterAsync(int chapterId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Chapter_UpdatePovCharacter", + new + { + ChapterID = chapterId, + POVCharacterID = povCharacterId, + POVSource = source, + POVConfidence = confidence, + POVEvidence = evidence, + POVSuggestedName = suggestedName + }, + commandType: CommandType.StoredProcedure); + } + + public async Task SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.Chapter_SetScenesPov", + new { ChapterID = chapterId, POVCharacterID = povCharacterId, OverwriteAuthor = overwriteAuthor }, commandType: CommandType.StoredProcedure); } @@ -2535,17 +2564,27 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I scene.RevisionStatusID, scene.PrimaryLocationID, scene.FloorPlanID, - scene.InitialFloorPlanFloorID + scene.InitialFloorPlanFloorID, + scene.POVCharacterID }, commandType: CommandType.StoredProcedure); } - public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) + public async Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Scene_UpdatePovCharacter", - new { SceneID = sceneId, POVCharacterID = povCharacterId }, + new + { + SceneID = sceneId, + POVCharacterID = povCharacterId, + POVSource = source, + POVConfidence = confidence, + POVEvidence = evidence, + POVSuggestedName = suggestedName, + OverwriteAuthor = overwriteAuthor + }, commandType: CommandType.StoredProcedure); } @@ -3696,6 +3735,22 @@ public sealed class CharacterRepository(ISqlConnectionFactory connectionFactory) commandType: CommandType.StoredProcedure); } + public async Task MergeAsync(int sourceCharacterId, int targetCharacterId, int? userId) + { + using var connection = connectionFactory.CreateConnection(); + try + { + return await connection.QuerySingleOrDefaultAsync( + "dbo.Character_Merge", + new { SourceCharacterID = sourceCharacterId, TargetCharacterID = targetCharacterId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } + catch (SqlException ex) when (ex.Number == 51000) + { + throw new InvalidOperationException(ex.Message, ex); + } + } + public async Task> ListSexValuesAsync(int ownerUserId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md index 5f81644..3d3a44c 100644 --- a/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md +++ b/PlotLine/Docs/AI/Chapter-Structure-Prompt-V2.md @@ -12,7 +12,7 @@ You are PlotDirector's Chapter Structure engine. You are an assistant archivist for a novelist. You observe chapter structure only. You do not critique, rewrite, create story entities or infer canon. -Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, and provide a light factual summary for each detected scene. +Your only task is to identify likely scene boundaries in one chapter, provide a short descriptive title for each detected scene, provide a light factual summary for each detected scene, and record compact point-of-view evidence when it is clear. ## Operating Principles @@ -92,6 +92,21 @@ The final scene must end at the highest paragraph number supplied. Do not create a separate final scene for a closing line unless it corresponds to an actual supplied paragraph number. If the final beat belongs to the previous scene, include it in the previous scene. +## Point of View Guidance + +Identify the default chapter point of view only when the supplied text provides a clear narrator/viewpoint signal. + +Explicit manuscript labels such as `Narrated by Maggie` are extremely strong evidence. +When `chapterContext.knownCharacters` contains a matching canonical character or alias, prefer that spelling for POV names. + +If no POV can be determined, return `defaultPov` as `null`. + +If the chapter has one default POV with a few clear scene-level changes, set `mixedPov` to `true` and add `povOverride` only on scenes where the POV differs from the chapter default. + +If the whole chapter is mixed and no default is clear, set `mixedPov` to `true`, return `defaultPov` as `null`, and add scene `povOverride` values only where clear. + +Use manuscript names exactly as presented. Do not invent canonical full names. + Do not create placeholder scenes or invalid ranges to "ensure coverage". If the previous scene already ends at the final supplied paragraph, stop there. Scene boundaries must: @@ -113,6 +128,8 @@ Return only these root properties: - `schemaVersion` - `chapterSummary` +- `defaultPov` +- `mixedPov` - `sceneBoundaries` Each `sceneBoundaries` item must contain exactly: @@ -122,6 +139,7 @@ Each `sceneBoundaries` item must contain exactly: - `endParagraph` - `suggestedTitle` - `structuralSummary` +- `povOverride` - `confidence` - `reason` @@ -134,6 +152,10 @@ Use `structuralSummary` for a factual, neutral summary of what happens in that s Use `suggestedTitle` for a concise, factual scene title. It must be 2 to 6 words, memorable without being dramatic, and grounded only in the supplied text. Avoid generic titles such as "Opening Scene", "Conversation", "Chapter Scene", or "Scene 1". Do not truncate or restate the structural summary. +Use `defaultPov` for the chapter's suggested default narrator/viewpoint character, or `null` when not clear. When present it must contain exactly `characterName`, `confidence`, `evidence`, and `isExplicit`. + +Use `povOverride` only where that scene clearly differs from `defaultPov`, or where there is no default but that scene's POV is clear. Otherwise return `null`. + Use short factual reasons, such as: `Location changes from house to train.` @@ -156,6 +178,13 @@ Do not output: { "schemaVersion": "1.0", "chapterSummary": "Structural analysis of the supplied chapter.", + "defaultPov": { + "characterName": "Maggie", + "confidence": 0.96, + "evidence": "The chapter begins with an explicit Narrated by Maggie label.", + "isExplicit": true + }, + "mixedPov": false, "sceneBoundaries": [ { "sceneNumber": 1, @@ -163,6 +192,7 @@ Do not output: "endParagraph": 3, "suggestedTitle": "Arrival at the Church", "structuralSummary": "The opening scene follows the same continuous action across the supplied paragraphs, keeping the characters in one time and place while the immediate situation is established.", + "povOverride": null, "confidence": 0.86, "reason": "Opening scene remains in the same place and time." } diff --git a/PlotLine/Models/ChapterStructureModels.cs b/PlotLine/Models/ChapterStructureModels.cs index bbde510..9ae00f4 100644 --- a/PlotLine/Models/ChapterStructureModels.cs +++ b/PlotLine/Models/ChapterStructureModels.cs @@ -7,6 +7,8 @@ public sealed class ChapterStructureModel { public string? SchemaVersion { get; init; } public string? ChapterSummary { get; init; } + public ChapterStructurePovSuggestion? DefaultPov { get; init; } + public bool? MixedPov { get; init; } public List? SceneBoundaries { get; init; } [JsonExtensionData] @@ -20,9 +22,18 @@ public sealed class ChapterSceneBoundary public int? EndParagraph { get; init; } public string? SuggestedTitle { get; init; } public string? StructuralSummary { get; init; } + public ChapterStructurePovSuggestion? PovOverride { get; init; } public decimal? Confidence { get; init; } public string? Reason { get; init; } [JsonExtensionData] public Dictionary? ExtensionData { get; init; } } + +public sealed class ChapterStructurePovSuggestion +{ + public string? CharacterName { get; init; } + public decimal? Confidence { get; init; } + public string? Evidence { get; init; } + public bool? IsExplicit { get; init; } +} diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index 7d742c4..14cff90 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -393,6 +393,11 @@ public sealed class Chapter public decimal ChapterNumber { get; set; } public string ChapterTitle { get; set; } = string.Empty; public int? POVCharacterID { get; set; } + public string? POVCharacterName { get; set; } + public string? POVSource { get; set; } + public decimal? POVConfidence { get; set; } + public string? POVEvidence { get; set; } + public string? POVSuggestedName { get; set; } public string? Summary { get; set; } public int? ChapterPurposeID { get; set; } public string? ChapterPurposeName { get; set; } @@ -429,6 +434,11 @@ public sealed class Scene public string? Summary { get; set; } public string? StructuralSummary { get; set; } public int? POVCharacterID { get; set; } + public string? POVCharacterName { get; set; } + public string? POVSource { get; set; } + public decimal? POVConfidence { get; set; } + public string? POVEvidence { get; set; } + public string? POVSuggestedName { get; set; } public int? PrimaryLocationID { get; set; } public int? FloorPlanID { get; set; } public int? InitialFloorPlanFloorID { get; set; } @@ -1001,6 +1011,14 @@ public sealed class Character public string? ArchivedReason { get; set; } } +public sealed class CharacterMergeResult +{ + public int SourceCharacterID { get; set; } + public int TargetCharacterID { get; set; } + public string SourceCharacterName { get; set; } = string.Empty; + public string TargetCharacterName { get; set; } = string.Empty; +} + public sealed class CharacterSexValue { public int CharacterSexValueID { get; set; } diff --git a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs index eab4fb5..b08c833 100644 --- a/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs +++ b/PlotLine/Services/ChapterStructureBoundaryNormaliser.cs @@ -91,6 +91,8 @@ public static class ChapterStructureBoundaryNormaliser { chapterStructure.SchemaVersion, chapterStructure.ChapterSummary, + chapterStructure.DefaultPov, + chapterStructure.MixedPov, chapterStructure.SceneBoundaries, Normalisation = normalisation.Issues.Select(issue => new { @@ -111,6 +113,7 @@ public static class ChapterStructureBoundaryNormaliser EndParagraph = boundary.EndParagraph, SuggestedTitle = boundary.SuggestedTitle, StructuralSummary = boundary.StructuralSummary, + PovOverride = boundary.PovOverride, Confidence = boundary.Confidence, Reason = boundary.Reason, ExtensionData = boundary.ExtensionData @@ -202,6 +205,7 @@ public static class ChapterStructureBoundaryNormaliser EndParagraph = end, SuggestedTitle = boundary.SuggestedTitle, StructuralSummary = boundary.StructuralSummary, + PovOverride = boundary.PovOverride, Confidence = boundary.Confidence, Reason = boundary.Reason, ExtensionData = boundary.ExtensionData @@ -226,6 +230,7 @@ public static class ChapterStructureBoundaryNormaliser EndParagraph = paragraphCount, SuggestedTitle = final.SuggestedTitle, StructuralSummary = final.StructuralSummary, + PovOverride = final.PovOverride, Confidence = final.Confidence, Reason = final.Reason, ExtensionData = final.ExtensionData @@ -242,6 +247,8 @@ public static class ChapterStructureBoundaryNormaliser { SchemaVersion = chapterStructure.SchemaVersion, ChapterSummary = chapterStructure.ChapterSummary, + DefaultPov = chapterStructure.DefaultPov, + MixedPov = chapterStructure.MixedPov, SceneBoundaries = repaired, ExtensionData = chapterStructure.ExtensionData }, issues); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index c9ddf31..9a70065 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -114,6 +114,7 @@ public interface IChapterService Task GetCreateAsync(int bookId); Task GetEditAsync(int chapterId); Task SaveAsync(ChapterEditViewModel model); + Task SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor); Task ArchiveAsync(int chapterId); } @@ -122,7 +123,7 @@ public interface ISceneService Task GetCreateAsync(int chapterId); Task GetEditAsync(int sceneId); Task SaveAsync(SceneEditViewModel model); - Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId); + Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false); Task ArchiveAsync(int sceneId); Task MoveAsync(int sceneId, string direction); Task MoveRelativeAsync(int sceneId, int anchorSceneId, string position); @@ -261,8 +262,10 @@ public interface ICharacterService Task GetCreateCharacterAsync(int projectId); Task GetEditCharacterAsync(int characterId); Task GetCharacterDetailAsync(int characterId); + Task GetMergeAsync(int sourceCharacterId); Task GetAvatarCropAsync(int characterImageId); Task SaveCharacterAsync(CharacterEditViewModel model); + Task MergeAsync(CharacterMergeForm form); Task UploadCharacterImageAsync(CharacterImageUploadViewModel model); Task SaveCharacterAvatarAsync(CharacterAvatarCropViewModel model); Task RemoveCharacterAvatarAsync(int characterId); @@ -1429,7 +1432,8 @@ public sealed class ChapterService( TextStatistics = manuscriptTextStatistics.Aggregate(sceneRows.Select(scene => scene.TextStatistics)), PendingCharacterSuggestionCount = sceneRows.Sum(x => x.PendingCharacterSuggestionCount), PendingAssetSuggestionCount = sceneRows.Sum(x => x.PendingAssetSuggestionCount), - PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount) + PendingLocationSuggestionCount = sceneRows.Sum(x => x.PendingLocationSuggestionCount), + CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName) }; } @@ -1452,7 +1456,8 @@ public sealed class ChapterService( ChapterNumber = existing.Count + 1, RevisionStatusID = DefaultRevisionStatusId(lookupData), RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName), - ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName) + ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName), + CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName) }; } @@ -1478,6 +1483,7 @@ public sealed class ChapterService( BookID = chapter.BookID, ChapterNumber = chapter.ChapterNumber, ChapterTitle = chapter.ChapterTitle, + POVCharacterID = chapter.POVCharacterID, Summary = chapter.Summary, ChapterPurposeID = chapter.ChapterPurposeID, RevisionStatusID = chapter.RevisionStatusID, @@ -1485,7 +1491,8 @@ public sealed class ChapterService( Book = book, Project = project, RevisionStatuses = ToSelectList(lookupData.RevisionStatuses, x => x.RevisionStatusID, x => x.StatusName), - ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName) + ChapterPurposes = ToOptionalSelectList(lookupData.ChapterPurposeTypes, x => x.ChapterPurposeID, x => x.PurposeName), + CharacterOptions = ToOptionalSelectList(await characters.ListCharactersAsync(project.ProjectID), x => x.CharacterID, x => x.CharacterName) }; } @@ -1503,6 +1510,7 @@ public sealed class ChapterService( BookID = model.BookID, ChapterNumber = model.ChapterNumber, ChapterTitle = model.ChapterTitle, + POVCharacterID = model.POVCharacterID, Summary = model.Summary, ChapterPurposeID = model.ChapterPurposeID, RevisionStatusID = model.RevisionStatusID @@ -1515,6 +1523,20 @@ public sealed class ChapterService( return chapterId; } + public async Task SetScenesPovAsync(int chapterId, int? povCharacterId, bool overwriteAuthor) + { + var chapter = await chapters.GetAsync(chapterId); + var book = chapter is null ? null : await books.GetAsync(chapter.BookID); + if (chapter is null || book is null) + { + throw new InvalidOperationException("Chapter could not be found."); + } + + var updated = await chapters.SetScenesPovAsync(chapterId, povCharacterId, overwriteAuthor); + await activity.RecordAsync(book.ProjectID, "Updated", "Chapter", chapterId, chapter.ChapterTitle, $"Updated POV on {updated:N0} scene{(updated == 1 ? string.Empty : "s")}."); + return updated; + } + public async Task ArchiveAsync(int chapterId) { var chapter = await chapters.GetAsync(chapterId); @@ -1669,6 +1691,7 @@ public sealed class SceneService( ScenePurposeNotes = model.ScenePurposeNotes, SceneOutcomeNotes = model.SceneOutcomeNotes, RevisionStatusID = model.RevisionStatusID, + POVCharacterID = model.POVCharacterID, PrimaryLocationID = model.PrimaryLocationID, FloorPlanID = model.FloorPlanID, InitialFloorPlanFloorID = model.InitialFloorPlanFloorID @@ -1690,8 +1713,8 @@ public sealed class SceneService( return sceneId; } - public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId) => - scenes.UpdatePovCharacterAsync(sceneId, povCharacterId); + public Task UpdatePovCharacterAsync(int sceneId, int? povCharacterId, string source = "Author", decimal? confidence = null, string? evidence = null, string? suggestedName = null, bool overwriteAuthor = false) => + scenes.UpdatePovCharacterAsync(sceneId, povCharacterId, source, confidence, evidence, suggestedName, overwriteAuthor); public async Task ArchiveAsync(int sceneId) { @@ -9171,6 +9194,35 @@ public sealed class CharacterService( }; } + public async Task GetMergeAsync(int sourceCharacterId) + { + var source = await characters.GetCharacterAsync(sourceCharacterId); + if (source is null) + { + return null; + } + + var project = await projects.GetAsync(source.ProjectID); + if (project is null) + { + return null; + } + + var targets = (await characters.ListCharactersAsync(source.ProjectID)) + .Where(character => character.CharacterID != source.CharacterID) + .OrderBy(character => character.CharacterName, StringComparer.OrdinalIgnoreCase) + .Select(character => new SelectListItem(character.CharacterName, character.CharacterID.ToString())) + .ToList(); + + return new CharacterMergeViewModel + { + Project = project, + SourceCharacter = source, + Form = new CharacterMergeForm { SourceCharacterID = source.CharacterID }, + TargetOptions = targets + }; + } + public async Task GetAvatarCropAsync(int characterImageId) { var image = await characters.GetCharacterImageAsync(characterImageId); @@ -9246,6 +9298,26 @@ public sealed class CharacterService( return new CharacterSaveResult(characterId, uploadedCharacterImageId); } + public async Task MergeAsync(CharacterMergeForm form) + { + if (!form.Confirm) + { + throw new InvalidOperationException("Confirm the merge before continuing."); + } + + var result = await characters.MergeAsync(form.SourceCharacterID, form.TargetCharacterID, currentUser.UserId); + if (result is not null) + { + var target = await characters.GetCharacterAsync(result.TargetCharacterID); + if (target is not null) + { + await activity.RecordAsync(target.ProjectID, "Merged", "Character", result.TargetCharacterID, result.TargetCharacterName, $"{result.SourceCharacterName} was merged into {result.TargetCharacterName}."); + } + } + + return result; + } + private async Task SyncCharacterAliasesAsync(int characterId, IEnumerable postedAliases) { var aliases = AliasInput.Clean(postedAliases); diff --git a/PlotLine/Services/ManuscriptStructureAnalysisService.cs b/PlotLine/Services/ManuscriptStructureAnalysisService.cs index fa7f676..0e8889d 100644 --- a/PlotLine/Services/ManuscriptStructureAnalysisService.cs +++ b/PlotLine/Services/ManuscriptStructureAnalysisService.cs @@ -136,6 +136,7 @@ public sealed class ManuscriptStructureAnalysisService( var sceneParagraphs = paragraphs.Skip(start - 1).Take(end - start + 1).ToList(); var sourceText = BuildSourceText(request.SourceParagraphs, startMap.WordParagraphIndex, endMap.WordParagraphIndex); var suggestedTitle = CleanSuggestedTitle(boundary.SuggestedTitle, sceneNumber); + var pov = CleanPovSuggestion(boundary.PovOverride) ?? CleanPovSuggestion(chapter.DefaultPov); scenes.Add(new ManuscriptStructureSceneBoundary { TemporarySceneKey = $"{request.TemporaryChapterKey}-ai-scene-{sceneNumber}", @@ -150,7 +151,11 @@ public sealed class ManuscriptStructureAnalysisService( WordEndPosition = endMap.WordParagraphIndex, StructuralSummary = Trim(boundary.StructuralSummary, 600), Reason = boundary.Reason, - Confidence = boundary.Confidence + Confidence = boundary.Confidence, + SuggestedPovName = pov?.CharacterName, + SuggestedPovConfidence = pov?.Confidence, + SuggestedPovEvidence = pov?.Evidence, + SuggestedPovIsOverride = boundary.PovOverride is not null && !string.IsNullOrWhiteSpace(boundary.PovOverride.CharacterName) }); } @@ -161,7 +166,15 @@ public sealed class ManuscriptStructureAnalysisService( request.TemporaryChapterKey, scenes.Count); - return ManuscriptStructureAnalysisResult.Succeeded(request.TemporaryChapterKey, scenes, persistedJson); + var defaultPov = CleanPovSuggestion(chapter.DefaultPov); + return ManuscriptStructureAnalysisResult.Succeeded( + request.TemporaryChapterKey, + scenes, + persistedJson, + defaultPov?.CharacterName, + defaultPov?.Confidence, + defaultPov?.Evidence, + chapter.MixedPov == true); } catch (Exception ex) when (ex is not OperationCanceledException) { @@ -204,7 +217,8 @@ public sealed class ManuscriptStructureAnalysisService( chapterNumber = request.ChapterNumber, sourceLabel = request.SourceLabel, storyEra = request.StoryEra, - primaryLocale = request.PrimaryLocale + primaryLocale = request.PrimaryLocale, + knownCharacters = request.KnownCharacters.Take(80) }, JsonOptions); private static string BuildChapterPrompt(string promptTemplate, string chapterContextJson, string chapterText) @@ -444,6 +458,25 @@ public sealed class ManuscriptStructureAnalysisService( var wordCount = clean.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries).Length; return wordCount is >= 2 and <= 6 ? clean : null; } + + private static ChapterStructurePovSuggestion? CleanPovSuggestion(ChapterStructurePovSuggestion? suggestion) + { + var name = Trim(suggestion?.CharacterName, 120); + if (string.IsNullOrWhiteSpace(name) + || string.Equals(name, "Unknown", StringComparison.OrdinalIgnoreCase) + || string.Equals(name, "Mixed", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + return new ChapterStructurePovSuggestion + { + CharacterName = name, + Confidence = suggestion?.Confidence, + Evidence = Trim(suggestion?.Evidence, 400), + IsExplicit = suggestion?.IsExplicit + }; + } } public sealed class ManuscriptStructureAnalysisRequest @@ -456,6 +489,7 @@ public sealed class ManuscriptStructureAnalysisRequest public string SourceLabel { get; init; } = string.Empty; public string? StoryEra { get; init; } public string? PrimaryLocale { get; init; } + public IReadOnlyList KnownCharacters { get; init; } = []; public string ChapterText { get; init; } = string.Empty; public IReadOnlyList ParagraphMap { get; init; } = []; public IReadOnlyList SourceParagraphs { get; init; } = []; @@ -467,10 +501,31 @@ public sealed class ManuscriptStructureAnalysisResult public bool Success { get; init; } public string? ErrorMessage { get; init; } public string? ParsedJson { get; init; } + public string? DefaultPovName { get; init; } + public decimal? DefaultPovConfidence { get; init; } + public string? DefaultPovEvidence { get; init; } + public bool MixedPov { get; init; } public IReadOnlyList Scenes { get; init; } = []; - public static ManuscriptStructureAnalysisResult Succeeded(string temporaryChapterKey, IReadOnlyList scenes, string? parsedJson) - => new() { TemporaryChapterKey = temporaryChapterKey, Success = true, Scenes = scenes, ParsedJson = parsedJson }; + public static ManuscriptStructureAnalysisResult Succeeded( + string temporaryChapterKey, + IReadOnlyList scenes, + string? parsedJson, + string? defaultPovName = null, + decimal? defaultPovConfidence = null, + string? defaultPovEvidence = null, + bool mixedPov = false) + => new() + { + TemporaryChapterKey = temporaryChapterKey, + Success = true, + Scenes = scenes, + ParsedJson = parsedJson, + DefaultPovName = defaultPovName, + DefaultPovConfidence = defaultPovConfidence, + DefaultPovEvidence = defaultPovEvidence, + MixedPov = mixedPov + }; public static ManuscriptStructureAnalysisResult Failed(string temporaryChapterKey, string errorMessage, string? parsedJson = null) => new() { TemporaryChapterKey = temporaryChapterKey, Success = false, ErrorMessage = errorMessage, ParsedJson = parsedJson }; @@ -492,4 +547,8 @@ public sealed class ManuscriptStructureSceneBoundary public string? StructuralSummary { get; init; } public string? Reason { get; init; } public decimal? Confidence { get; init; } + public string? SuggestedPovName { get; init; } + public decimal? SuggestedPovConfidence { get; init; } + public string? SuggestedPovEvidence { get; init; } + public bool SuggestedPovIsOverride { get; init; } } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index e4dc699..c4fb8e4 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -38,6 +38,8 @@ public sealed class OnboardingService( IWordCompanionPresenceService companionPresence, IManuscriptScanPreviewStore scanPreviews, ICharacterRepository characters, + IChapterRepository chapters, + ISceneRepository scenes, IOnboardingBuildRepository builds, IManuscriptStructureAnalysisService structureAnalysis, IManuscriptTextStatisticsService manuscriptTextStatistics, @@ -273,6 +275,7 @@ public sealed class OnboardingService( await SaveStructuralChapterSourcesAsync(preview, existingBuild); await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + await ApplyStructuralPovAsync(preview, recoveredScenes, existingBuild); } else { @@ -357,6 +360,7 @@ public sealed class OnboardingService( await SaveStructuralChapterSourcesAsync(preview, result); await SaveSceneManuscriptSourcesAsync(preview, structuralScenes, result); await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + await ApplyStructuralPovAsync(preview, structuralScenes, result); var canonicalSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(canonicalSceneSources); await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); @@ -374,6 +378,7 @@ public sealed class OnboardingService( { var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); var structuralScenes = new List(); + var knownCharacters = await BuildKnownCharacterContextAsync(preview.ProjectID); for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++) { var chapterDecision = chapterDecisions[chapterIndex]; @@ -406,6 +411,7 @@ public sealed class OnboardingService( SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}", StoryEra = BookContextOptions.StoryEraLabel(book.StoryEra), PrimaryLocale = BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode), + KnownCharacters = knownCharacters, ChapterText = chapterPreview.ChapterText, ParagraphMap = chapterPreview.ParagraphMap, SourceParagraphs = chapterPreview.SourceParagraphs @@ -422,6 +428,120 @@ public sealed class OnboardingService( return structuralScenes; } + private async Task> BuildKnownCharacterContextAsync(int projectId) + { + var names = new SortedSet(StringComparer.OrdinalIgnoreCase); + foreach (var character in await characters.ListCharactersAsync(projectId)) + { + AddKnownCharacterName(names, character.CharacterName); + AddKnownCharacterName(names, character.ShortName); + foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) + { + AddKnownCharacterName(names, alias.Alias); + } + } + + return names.ToList(); + } + + private static void AddKnownCharacterName(ISet names, string? name) + { + var clean = Clean(name); + if (!string.IsNullOrWhiteSpace(clean)) + { + names.Add(clean); + } + } + + private async Task ApplyStructuralPovAsync( + ManuscriptScanPreview preview, + IReadOnlyList structuralScenes, + OnboardingManuscriptBuildResult result) + { + if (structuralScenes.Count == 0 || result.SceneMappings.Count == 0) + { + return; + } + + var characterIndex = await BuildCanonicalCharacterNameIndexAsync(preview.ProjectID); + var chapterMap = result.ChapterMappings.ToDictionary(mapping => mapping.TemporaryChapterKey, StringComparer.Ordinal); + var sceneMap = result.SceneMappings.ToDictionary(mapping => mapping.TemporarySceneKey, StringComparer.Ordinal); + + foreach (var chapterScenes in structuralScenes.GroupBy(scene => scene.TemporaryChapterKey)) + { + if (!chapterMap.TryGetValue(chapterScenes.Key, out var chapterMapping)) + { + continue; + } + + var defaultSuggestion = chapterScenes + .Where(scene => !scene.SuggestedPovIsOverride && !string.IsNullOrWhiteSpace(scene.SuggestedPovName)) + .GroupBy(scene => Clean(scene.SuggestedPovName), StringComparer.OrdinalIgnoreCase) + .OrderByDescending(group => group.Count()) + .FirstOrDefault(); + if (defaultSuggestion is not null) + { + var sample = defaultSuggestion.First(); + characterIndex.TryGetValue(defaultSuggestion.Key, out var defaultCharacter); + await chapters.UpdatePovCharacterAsync( + chapterMapping.ChapterID, + defaultCharacter?.CharacterID, + "Structural", + sample.SuggestedPovConfidence, + sample.SuggestedPovEvidence, + defaultCharacter is null ? defaultSuggestion.Key : null); + } + + foreach (var scene in chapterScenes) + { + var suggestedName = Clean(scene.SuggestedPovName); + if (string.IsNullOrWhiteSpace(suggestedName) || !sceneMap.TryGetValue(scene.TemporarySceneKey, out var mappedScene)) + { + continue; + } + + characterIndex.TryGetValue(suggestedName, out var character); + await scenes.UpdatePovCharacterAsync( + mappedScene.SceneID, + character?.CharacterID, + scene.SuggestedPovIsOverride ? "StructuralOverride" : "StructuralInherited", + scene.SuggestedPovConfidence, + scene.SuggestedPovEvidence, + character is null ? suggestedName : null); + } + } + } + + private async Task> BuildCanonicalCharacterNameIndexAsync(int projectId) + { + var projectCharacters = await characters.ListCharactersAsync(projectId); + var index = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var character in projectCharacters) + { + AddCharacterName(index, character.CharacterName, character); + AddCharacterName(index, character.ShortName, character); + var firstName = character.CharacterName.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); + AddCharacterName(index, firstName, character); + + foreach (var alias in await characters.ListAliasesAsync(character.CharacterID)) + { + AddCharacterName(index, alias.Alias, character); + AddCharacterName(index, alias.Alias.Split(' ', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(), character); + } + } + + return index; + } + + private static void AddCharacterName(IDictionary index, string? name, Character character) + { + var clean = Clean(name); + if (!string.IsNullOrWhiteSpace(clean) && !index.ContainsKey(clean)) + { + index.Add(clean, character); + } + } + private async Task BuildExistingStructureResultAsync( int userId, ManuscriptScanPreview preview, diff --git a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs index fff10d0..9c823a5 100644 --- a/PlotLine/Services/StoryIntelligenceCharacterImportService.cs +++ b/PlotLine/Services/StoryIntelligenceCharacterImportService.cs @@ -19,6 +19,7 @@ public sealed class StoryIntelligenceCharacterImportService( IStoryIntelligencePipelineStateService pipelineState, INameIntelligenceService nameIntelligence, ICoreImportCharacterMappingService coreImportCharacterMapping, + ICurrentUserService currentUser, ILogger logger) : IStoryIntelligenceCharacterImportService { private static readonly JsonSerializerOptions JsonOptions = new() @@ -317,6 +318,11 @@ public sealed class StoryIntelligenceCharacterImportService( } targetName ??= $"Character {targetCharacterId.Value:N0}"; + if (candidate.ExistingCharacterID.HasValue && candidate.ExistingCharacterID.Value != targetCharacterId.Value) + { + await characters.MergeAsync(candidate.ExistingCharacterID.Value, targetCharacterId.Value, currentUser.UserId); + } + await TryAddAliasAsync(targetCharacterId.Value, candidate.DisplayName, targetName); AddResolvedName(resolvedCharacters, candidate.DisplayName, targetCharacterId.Value); foreach (var alias in candidate.Aliases) @@ -543,7 +549,7 @@ public sealed class StoryIntelligenceCharacterImportService( } var povName = CleanCharacterName(parsed.PointOfView?.CharacterName); - if (IsCharacterNameCandidate(povName)) + if (IsCharacterNameCandidate(povName) && !importedScene.POVCharacterID.HasValue) { AddMention(groups, existingIndex, importedScene, parsed, new SceneIntelligenceCharacter { @@ -619,7 +625,7 @@ public sealed class StoryIntelligenceCharacterImportService( IReadOnlyList existingAppearances) { var cleanName = CleanCharacterName(name); - if (!IsCharacterNameCandidate(cleanName)) + if (!IsCharacterNameCandidate(cleanName) || IsResolvedPovReference(cleanName, importedScene)) { return; } @@ -634,6 +640,10 @@ public sealed class StoryIntelligenceCharacterImportService( }, existingAppearances); } + private static bool IsResolvedPovReference(string name, Scene importedScene) + => importedScene.POVCharacterID.HasValue + && (IgnoredNames.Contains(Normalise(name)) || string.Equals(name, "Narrator", StringComparison.OrdinalIgnoreCase)); + private async Task BuildCharacterIndexAsync(int projectId) { var index = new CharacterIndex(); diff --git a/PlotLine/Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql b/PlotLine/Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql new file mode 100644 index 0000000..9bb0018 --- /dev/null +++ b/PlotLine/Sql/165_Phase26A_PovAndCharacterIdentityFoundation.sql @@ -0,0 +1,550 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF COL_LENGTH(N'dbo.Chapters', N'POVSource') IS NULL + ALTER TABLE dbo.Chapters ADD POVSource nvarchar(40) NULL; +GO + +IF COL_LENGTH(N'dbo.Chapters', N'POVConfidence') IS NULL + ALTER TABLE dbo.Chapters ADD POVConfidence decimal(5,2) NULL; +GO + +IF COL_LENGTH(N'dbo.Chapters', N'POVEvidence') IS NULL + ALTER TABLE dbo.Chapters ADD POVEvidence nvarchar(500) NULL; +GO + +IF COL_LENGTH(N'dbo.Chapters', N'POVSuggestedName') IS NULL + ALTER TABLE dbo.Chapters ADD POVSuggestedName nvarchar(200) NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'POVSource') IS NULL + ALTER TABLE dbo.Scenes ADD POVSource nvarchar(40) NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'POVConfidence') IS NULL + ALTER TABLE dbo.Scenes ADD POVConfidence decimal(5,2) NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'POVEvidence') IS NULL + ALTER TABLE dbo.Scenes ADD POVEvidence nvarchar(500) NULL; +GO + +IF COL_LENGTH(N'dbo.Scenes', N'POVSuggestedName') IS NULL + ALTER TABLE dbo.Scenes ADD POVSuggestedName nvarchar(200) NULL; +GO + +UPDATE dbo.Chapters +SET POVSource = N'Legacy' +WHERE POVCharacterID IS NOT NULL + AND POVSource IS NULL; +GO + +UPDATE dbo.Scenes +SET POVSource = N'Legacy' +WHERE POVCharacterID IS NOT NULL + AND POVSource IS NULL; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_ListByBook + @BookID int +AS +BEGIN + SET NOCOUNT ON; + SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, + pov.CharacterName AS POVCharacterName, c.POVSource, c.POVConfidence, c.POVEvidence, c.POVSuggestedName, + c.Summary, c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName, + c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName, + c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript, + c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc + FROM dbo.Chapters c + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID + LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID + LEFT JOIN dbo.Characters pov ON pov.CharacterID = c.POVCharacterID + WHERE c.BookID = @BookID AND c.IsArchived = 0 + ORDER BY c.SortOrder, c.ChapterNumber; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_Get + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT c.ChapterID, c.BookID, c.ChapterNumber, c.ChapterTitle, c.POVCharacterID, + pov.CharacterName AS POVCharacterName, c.POVSource, c.POVConfidence, c.POVEvidence, c.POVSuggestedName, + c.Summary, c.ChapterPurposeID, cpt.PurposeName AS ChapterPurposeName, + c.SortOrder, c.RevisionStatusID, rs.StatusName AS RevisionStatusName, + c.CreatedDate, c.UpdatedDate, c.IsArchived, c.IsLinkedToManuscript, + c.ManuscriptDocumentID, c.ExternalReference, c.LinkedUtc + FROM dbo.Chapters c + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = c.RevisionStatusID + LEFT JOIN dbo.ChapterPurposeTypes cpt ON cpt.ChapterPurposeID = c.ChapterPurposeID + LEFT JOIN dbo.Characters pov ON pov.CharacterID = c.POVCharacterID + WHERE c.ChapterID = @ChapterID AND c.IsArchived = 0; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_ListByChapter + @ChapterID int +AS +BEGIN + SET NOCOUNT ON; + SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.Summary, s.StructuralSummary, + s.POVCharacterID, pov.CharacterName AS POVCharacterName, s.POVSource, s.POVConfidence, s.POVEvidence, s.POVSuggestedName, + s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID, s.SortOrder, + s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, + s.DurationUnitID, du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, + tc.TimeConfidenceName, s.ScenePurposeNotes, s.SceneOutcomeNotes, s.RevisionStatusID, + rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived, + s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID, + s.ExternalReference, s.LinkedUtc, s.ImportSource, s.ImportRunID, + s.SourceStartParagraph, s.SourceEndParagraph + FROM dbo.Scenes s + INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID + LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID + INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID + LEFT JOIN dbo.Characters pov ON pov.CharacterID = s.POVCharacterID + WHERE s.ChapterID = @ChapterID AND s.IsArchived = 0 + ORDER BY s.SortOrder, s.SceneNumber, s.SceneTitle; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_Get + @SceneID int +AS +BEGIN + SET NOCOUNT ON; + EXEC dbo.SceneMetric_EnsureDefaults @SceneID; + SELECT s.SceneID, s.ChapterID, s.SceneNumber, s.SceneTitle, s.SceneTitleSource, s.Summary, s.StructuralSummary, + s.POVCharacterID, pov.CharacterName AS POVCharacterName, s.POVSource, s.POVConfidence, s.POVEvidence, s.POVSuggestedName, + s.PrimaryLocationID, s.FloorPlanID, s.InitialFloorPlanFloorID, + location.LocationName AS PrimaryLocationName, location.LocationPath AS PrimaryLocationPath, + s.SortOrder, s.TimeModeID, tm.TimeModeName, s.StartDateTime, s.EndDateTime, s.DurationAmount, s.DurationUnitID, + du.DurationUnitName, s.RelativeTimeText, s.TimeConfidenceID, tc.TimeConfidenceName, s.ScenePurposeNotes, + s.SceneOutcomeNotes, s.RevisionStatusID, rs.StatusName AS RevisionStatusName, s.CreatedDate, s.UpdatedDate, s.IsArchived, + s.ArchivedDate, s.ArchivedReason, s.IsLinkedToManuscript, s.ManuscriptDocumentID, s.ExternalReference, s.LinkedUtc, + s.ImportSource, s.ImportRunID, s.SourceStartParagraph, s.SourceEndParagraph + FROM dbo.Scenes s + INNER JOIN dbo.TimeModes tm ON tm.TimeModeID = s.TimeModeID + INNER JOIN dbo.TimeConfidences tc ON tc.TimeConfidenceID = s.TimeConfidenceID + INNER JOIN dbo.RevisionStatuses rs ON rs.RevisionStatusID = s.RevisionStatusID + LEFT JOIN dbo.DurationUnits du ON du.DurationUnitID = s.DurationUnitID + LEFT JOIN dbo.LocationPaths location ON location.LocationID = s.PrimaryLocationID + LEFT JOIN dbo.Characters pov ON pov.CharacterID = s.POVCharacterID + WHERE s.SceneID = @SceneID AND s.IsArchived = 0; + SELECT ScenePurposeTypeID FROM dbo.ScenePurposes WHERE SceneID = @SceneID; + EXEC dbo.SceneMetric_ListByScene @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_Save + @ChapterID int = NULL, + @BookID int, + @ChapterNumber decimal(9,2), + @ChapterTitle nvarchar(200), + @Summary nvarchar(max) = NULL, + @ChapterPurposeID int = NULL, + @RevisionStatusID int, + @POVCharacterID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF @POVCharacterID IS NOT NULL + AND NOT EXISTS + ( + SELECT 1 + FROM dbo.Characters ch + INNER JOIN dbo.Books b ON b.ProjectID = ch.ProjectID + WHERE ch.CharacterID = @POVCharacterID + AND b.BookID = @BookID + AND ch.IsArchived = 0 + ) + SET @POVCharacterID = NULL; + + IF @ChapterID IS NULL OR @ChapterID = 0 + BEGIN + DECLARE @NextChapterOrder int = ISNULL((SELECT MAX(SortOrder) FROM dbo.Chapters WHERE BookID = @BookID), 0) + 10; + INSERT dbo.Chapters (BookID, ChapterNumber, ChapterTitle, POVCharacterID, POVSource, Summary, ChapterPurposeID, SortOrder, RevisionStatusID) + VALUES (@BookID, @ChapterNumber, @ChapterTitle, @POVCharacterID, CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END, @Summary, @ChapterPurposeID, @NextChapterOrder, @RevisionStatusID); + SELECT CAST(SCOPE_IDENTITY() AS int) AS ChapterID; + RETURN; + END; + + UPDATE dbo.Chapters + SET ChapterNumber = @ChapterNumber, + ChapterTitle = @ChapterTitle, + POVCharacterID = @POVCharacterID, + POVSource = CASE + WHEN ISNULL(POVCharacterID, 0) <> ISNULL(@POVCharacterID, 0) THEN CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END + ELSE POVSource + END, + Summary = @Summary, + ChapterPurposeID = @ChapterPurposeID, + RevisionStatusID = @RevisionStatusID, + UpdatedDate = SYSUTCDATETIME() + WHERE ChapterID = @ChapterID; + + SELECT @ChapterID AS ChapterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_UpdatePovCharacter + @ChapterID int, + @POVCharacterID int = NULL, + @POVSource nvarchar(40) = N'Author', + @POVConfidence decimal(5,2) = NULL, + @POVEvidence nvarchar(500) = NULL, + @POVSuggestedName nvarchar(200) = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @BookID int, @ProjectID int; + SELECT @BookID = c.BookID, @ProjectID = b.ProjectID + FROM dbo.Chapters c + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE c.ChapterID = @ChapterID; + + IF @BookID IS NULL + THROW 51000, 'Chapter could not be found.', 1; + + IF @POVCharacterID IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0) + THROW 51000, 'POV character does not belong to this project.', 1; + + UPDATE dbo.Chapters + SET POVCharacterID = @POVCharacterID, + POVSource = CASE WHEN @POVCharacterID IS NULL AND NULLIF(@POVSuggestedName, N'') IS NULL THEN NULL ELSE @POVSource END, + POVConfidence = @POVConfidence, + POVEvidence = NULLIF(LTRIM(RTRIM(@POVEvidence)), N''), + POVSuggestedName = NULLIF(LTRIM(RTRIM(@POVSuggestedName)), N''), + UpdatedDate = SYSUTCDATETIME() + WHERE ChapterID = @ChapterID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_Save + @SceneID int = NULL, @ChapterID int, @SceneNumber decimal(10,2), @SceneTitle nvarchar(200), @Summary nvarchar(max) = NULL, + @TimeModeID int, @StartDateTime datetime2 = NULL, @EndDateTime datetime2 = NULL, @DurationAmount decimal(10,2) = NULL, + @DurationUnitID int = NULL, @RelativeTimeText nvarchar(200) = NULL, @TimeConfidenceID int, + @ScenePurposeNotes nvarchar(max) = NULL, @SceneOutcomeNotes nvarchar(max) = NULL, @RevisionStatusID int, + @PrimaryLocationID int = NULL, @FloorPlanID int = NULL, @InitialFloorPlanFloorID int = NULL, + @POVCharacterID int = NULL +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = b.ProjectID + FROM dbo.Chapters c + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE c.ChapterID = @ChapterID; + + IF @POVCharacterID IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0) + SET @POVCharacterID = NULL; + + DECLARE @NextSortOrder int; + DECLARE @CleanSceneTitle nvarchar(200) = ISNULL(LEFT(LTRIM(RTRIM(@SceneTitle)), 200), N''); + IF @SceneID IS NULL OR @SceneID = 0 + BEGIN + SELECT @NextSortOrder = ISNULL(MAX(SortOrder), 0) + 10 FROM dbo.Scenes WHERE ChapterID = @ChapterID; + INSERT dbo.Scenes (ChapterID, SceneNumber, SceneTitle, SceneTitleSource, Summary, POVCharacterID, POVSource, SortOrder, TimeModeID, StartDateTime, EndDateTime, + DurationAmount, DurationUnitID, RelativeTimeText, TimeConfidenceID, ScenePurposeNotes, SceneOutcomeNotes, + RevisionStatusID, PrimaryLocationID, FloorPlanID, InitialFloorPlanFloorID) + VALUES (@ChapterID, @SceneNumber, @CleanSceneTitle, CASE WHEN NULLIF(@CleanSceneTitle, N'') IS NULL THEN NULL ELSE N'Author' END, @Summary, @POVCharacterID, CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END, @NextSortOrder, @TimeModeID, @StartDateTime, @EndDateTime, + @DurationAmount, @DurationUnitID, @RelativeTimeText, @TimeConfidenceID, @ScenePurposeNotes, @SceneOutcomeNotes, + @RevisionStatusID, @PrimaryLocationID, @FloorPlanID, @InitialFloorPlanFloorID); + SET @SceneID = CAST(SCOPE_IDENTITY() AS int); + END + ELSE + BEGIN + UPDATE dbo.Scenes + SET SceneNumber = @SceneNumber, + SceneTitle = @CleanSceneTitle, + SceneTitleSource = CASE + WHEN ISNULL(SceneTitle, N'') <> @CleanSceneTitle THEN CASE WHEN NULLIF(@CleanSceneTitle, N'') IS NULL THEN NULL ELSE N'Author' END + ELSE SceneTitleSource + END, + Summary = @Summary, + POVCharacterID = @POVCharacterID, + POVSource = CASE + WHEN ISNULL(POVCharacterID, 0) <> ISNULL(@POVCharacterID, 0) THEN CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END + ELSE POVSource + END, + TimeModeID = @TimeModeID, + StartDateTime = @StartDateTime, + EndDateTime = @EndDateTime, + DurationAmount = @DurationAmount, + DurationUnitID = @DurationUnitID, + RelativeTimeText = @RelativeTimeText, + TimeConfidenceID = @TimeConfidenceID, + ScenePurposeNotes = @ScenePurposeNotes, + SceneOutcomeNotes = @SceneOutcomeNotes, + RevisionStatusID = @RevisionStatusID, + PrimaryLocationID = @PrimaryLocationID, + FloorPlanID = @FloorPlanID, + InitialFloorPlanFloorID = @InitialFloorPlanFloorID, + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; + END + SELECT @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Scene_UpdatePovCharacter + @SceneID int, + @POVCharacterID int = NULL, + @POVSource nvarchar(40) = N'Author', + @POVConfidence decimal(5,2) = NULL, + @POVEvidence nvarchar(500) = NULL, + @POVSuggestedName nvarchar(200) = NULL, + @OverwriteAuthor bit = 0 +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int, @ExistingSource nvarchar(40); + SELECT @ProjectID = b.ProjectID, @ExistingSource = s.POVSource + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE s.SceneID = @SceneID; + + IF @ProjectID IS NULL + THROW 51000, 'Scene could not be found.', 1; + + IF @POVCharacterID IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0) + THROW 51000, 'POV character does not belong to this project.', 1; + + IF @ExistingSource = N'Author' AND ISNULL(@POVSource, N'') <> N'Author' AND @OverwriteAuthor = 0 + RETURN; + + UPDATE dbo.Scenes + SET POVCharacterID = @POVCharacterID, + POVSource = CASE WHEN @POVCharacterID IS NULL AND NULLIF(@POVSuggestedName, N'') IS NULL THEN NULL ELSE @POVSource END, + POVConfidence = @POVConfidence, + POVEvidence = NULLIF(LTRIM(RTRIM(@POVEvidence)), N''), + POVSuggestedName = NULLIF(LTRIM(RTRIM(@POVSuggestedName)), N''), + UpdatedDate = SYSUTCDATETIME() + WHERE SceneID = @SceneID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Chapter_SetScenesPov + @ChapterID int, + @POVCharacterID int = NULL, + @OverwriteAuthor bit = 0 +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @ProjectID int; + SELECT @ProjectID = b.ProjectID + FROM dbo.Chapters c + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE c.ChapterID = @ChapterID; + + IF @ProjectID IS NULL + THROW 51000, 'Chapter could not be found.', 1; + + IF @POVCharacterID IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @POVCharacterID AND ProjectID = @ProjectID AND IsArchived = 0) + THROW 51000, 'POV character does not belong to this project.', 1; + + UPDATE dbo.Scenes + SET POVCharacterID = @POVCharacterID, + POVSource = CASE WHEN @POVCharacterID IS NULL THEN NULL ELSE N'Author' END, + POVConfidence = NULL, + POVEvidence = NULL, + POVSuggestedName = NULL, + UpdatedDate = SYSUTCDATETIME() + WHERE ChapterID = @ChapterID + AND IsArchived = 0 + AND (@OverwriteAuthor = 1 OR ISNULL(POVSource, N'') <> N'Author'); + + SELECT @@ROWCOUNT AS ScenesUpdated; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Character_Merge + @SourceCharacterID int, + @TargetCharacterID int, + @UserID int = NULL +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT ON; + + IF @SourceCharacterID IS NULL OR @TargetCharacterID IS NULL OR @SourceCharacterID = @TargetCharacterID + THROW 51000, 'Choose two different characters to merge.', 1; + + DECLARE @ProjectID int, @SourceName nvarchar(400), @TargetName nvarchar(400), @Now datetime2 = SYSUTCDATETIME(); + SELECT @ProjectID = ProjectID, @SourceName = CharacterName FROM dbo.Characters WHERE CharacterID = @SourceCharacterID AND IsArchived = 0; + SELECT @TargetName = CharacterName FROM dbo.Characters WHERE CharacterID = @TargetCharacterID AND IsArchived = 0; + + IF @ProjectID IS NULL OR @TargetName IS NULL + THROW 51000, 'Both characters must exist and be active before they can be merged.', 1; + + IF NOT EXISTS (SELECT 1 FROM dbo.Characters WHERE CharacterID = @TargetCharacterID AND ProjectID = @ProjectID) + THROW 51000, 'Characters must belong to the same project before they can be merged.', 1; + + BEGIN TRANSACTION; + + INSERT dbo.CharacterAliases (CharacterID, Alias, SortOrder) + SELECT @TargetCharacterID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.CharacterAliases WHERE CharacterID = @TargetCharacterID), 0) + ROW_NUMBER() OVER (ORDER BY AliasValue) * 10 + FROM + ( + SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue + UNION + SELECT LTRIM(RTRIM(ShortName)) FROM dbo.Characters WHERE CharacterID = @SourceCharacterID + UNION + SELECT LTRIM(RTRIM(Alias)) FROM dbo.CharacterAliases WHERE CharacterID = @SourceCharacterID + ) aliases + WHERE NULLIF(AliasValue, N'') IS NOT NULL + AND AliasValue <> @TargetName + AND NOT EXISTS + ( + SELECT 1 FROM dbo.CharacterAliases existing + WHERE existing.CharacterID = @TargetCharacterID + AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue) + ); + + UPDATE dbo.Chapters SET POVCharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE POVCharacterID = @SourceCharacterID; + UPDATE dbo.Scenes SET POVCharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE POVCharacterID = @SourceCharacterID; + + DELETE sourceRows + FROM dbo.SceneCharacters sourceRows + WHERE sourceRows.CharacterID = @SourceCharacterID + AND EXISTS + ( + SELECT 1 FROM dbo.SceneCharacters targetRows + WHERE targetRows.SceneID = sourceRows.SceneID + AND targetRows.CharacterID = @TargetCharacterID + ); + UPDATE dbo.SceneCharacters SET CharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE CharacterID = @SourceCharacterID; + + DELETE sourceRows + FROM dbo.SceneCharacterSuggestions sourceRows + WHERE sourceRows.CharacterID = @SourceCharacterID + AND EXISTS + ( + SELECT 1 FROM dbo.SceneCharacterSuggestions targetRows + WHERE targetRows.SceneID = sourceRows.SceneID + AND targetRows.CharacterID = @TargetCharacterID + AND targetRows.Status = sourceRows.Status + ); + UPDATE dbo.SceneCharacterSuggestions SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID; + + DELETE sourceRows + FROM dbo.SceneFloorPlanOccupancy sourceRows + WHERE sourceRows.CharacterID = @SourceCharacterID + AND EXISTS + ( + SELECT 1 FROM dbo.SceneFloorPlanOccupancy targetRows + WHERE targetRows.SceneID = sourceRows.SceneID + AND targetRows.CharacterID = @TargetCharacterID + ); + UPDATE dbo.SceneFloorPlanOccupancy SET CharacterID = @TargetCharacterID, ModifiedDate = @Now WHERE CharacterID = @SourceCharacterID; + + UPDATE dbo.AssetCustodyEventCharacters SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID; + UPDATE dbo.CharacterAttributeEvents SET CharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE CharacterID = @SourceCharacterID; + UPDATE dbo.CharacterKnowledge SET CharacterID = @TargetCharacterID, UpdatedDate = @Now WHERE CharacterID = @SourceCharacterID; + UPDATE dbo.CharacterImages SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID; + UPDATE dbo.ContinuityWarningAcknowledgements + SET CharacterID = @TargetCharacterID + WHERE CharacterID = @SourceCharacterID; + UPDATE dbo.OnboardingManuscriptBuildCharacterMap SET CharacterID = @TargetCharacterID WHERE CharacterID = @SourceCharacterID; + + DECLARE relationship_cursor CURSOR LOCAL FAST_FORWARD FOR + SELECT CharacterRelationshipID, + CASE WHEN CharacterAID = @SourceCharacterID THEN @TargetCharacterID ELSE CharacterAID END AS NewAID, + CASE WHEN CharacterBID = @SourceCharacterID THEN @TargetCharacterID ELSE CharacterBID END AS NewBID + FROM dbo.CharacterRelationships + WHERE IsArchived = 0 + AND (CharacterAID = @SourceCharacterID OR CharacterBID = @SourceCharacterID); + + DECLARE @RelationshipID int, @NewAID int, @NewBID int, @ExistingRelationshipID int; + OPEN relationship_cursor; + FETCH NEXT FROM relationship_cursor INTO @RelationshipID, @NewAID, @NewBID; + WHILE @@FETCH_STATUS = 0 + BEGIN + IF @NewAID = @NewBID + BEGIN + UPDATE dbo.CharacterRelationships + SET IsArchived = 1, + ArchivedDate = @Now, + ArchivedReason = N'Archived during character merge to avoid a self-relationship.', + UpdatedDate = @Now, + ModifiedByUserID = @UserID, + ModifiedDateUTC = @Now + WHERE CharacterRelationshipID = @RelationshipID; + END + ELSE + BEGIN + SELECT TOP (1) @ExistingRelationshipID = CharacterRelationshipID + FROM dbo.CharacterRelationships + WHERE ProjectID = @ProjectID + AND IsArchived = 0 + AND CharacterRelationshipID <> @RelationshipID + AND ((CharacterAID = @NewAID AND CharacterBID = @NewBID) OR (CharacterAID = @NewBID AND CharacterBID = @NewAID)) + ORDER BY CharacterRelationshipID; + + IF @ExistingRelationshipID IS NOT NULL + BEGIN + UPDATE dbo.RelationshipEvents + SET CharacterRelationshipID = @ExistingRelationshipID, + UpdatedDate = @Now, + ModifiedByUserID = @UserID, + ModifiedDateUTC = @Now + WHERE CharacterRelationshipID = @RelationshipID; + + UPDATE dbo.CharacterRelationships + SET IsArchived = 1, + ArchivedDate = @Now, + ArchivedReason = N'Merged into another relationship during character merge.', + UpdatedDate = @Now, + ModifiedByUserID = @UserID, + ModifiedDateUTC = @Now + WHERE CharacterRelationshipID = @RelationshipID; + END + ELSE + BEGIN + UPDATE dbo.CharacterRelationships + SET CharacterAID = @NewAID, + CharacterBID = @NewBID, + UpdatedDate = @Now, + ModifiedByUserID = @UserID, + ModifiedDateUTC = @Now + WHERE CharacterRelationshipID = @RelationshipID; + END + END + + SET @ExistingRelationshipID = NULL; + FETCH NEXT FROM relationship_cursor INTO @RelationshipID, @NewAID, @NewBID; + END + CLOSE relationship_cursor; + DEALLOCATE relationship_cursor; + + UPDATE dbo.Characters + SET IsArchived = 1, + ArchivedDate = @Now, + ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (CharacterID ', @TargetCharacterID, N').'), + UpdatedDate = @Now, + ModifiedByUserID = @UserID, + ModifiedDateUTC = @Now + WHERE CharacterID = @SourceCharacterID; + + COMMIT TRANSACTION; + + SELECT @TargetCharacterID AS TargetCharacterID, + @SourceCharacterID AS SourceCharacterID, + @SourceName AS SourceCharacterName, + @TargetName AS TargetCharacterName; +END; +GO diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index f3a5917..91f37c3 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -275,6 +275,9 @@ public sealed class ChapterEditViewModel [Display(Name = "Chapter purpose")] public int? ChapterPurposeID { get; set; } + [Display(Name = "POV character")] + public int? POVCharacterID { get; set; } + [Display(Name = "Revision status")] public int RevisionStatusID { get; set; } @@ -284,6 +287,7 @@ public sealed class ChapterEditViewModel public Project? Project { get; set; } public IReadOnlyList RevisionStatuses { get; set; } = []; public IReadOnlyList ChapterPurposes { get; set; } = []; + public IReadOnlyList CharacterOptions { get; set; } = []; } public sealed class ChapterDetailViewModel @@ -296,6 +300,7 @@ public sealed class ChapterDetailViewModel public int PendingCharacterSuggestionCount { get; set; } public int PendingAssetSuggestionCount { get; set; } public int PendingLocationSuggestionCount { get; set; } + public IReadOnlyList CharacterOptions { get; set; } = []; } public sealed class SceneEditViewModel @@ -1865,6 +1870,21 @@ public sealed class CharacterDetailViewModel public IReadOnlyList BookOptions { get; set; } = []; } +public sealed class CharacterMergeViewModel +{ + public Project Project { get; set; } = new(); + public Character SourceCharacter { get; set; } = new(); + public CharacterMergeForm Form { get; set; } = new(); + public IReadOnlyList TargetOptions { get; set; } = []; +} + +public sealed class CharacterMergeForm +{ + public int SourceCharacterID { get; set; } + public int TargetCharacterID { get; set; } + public bool Confirm { get; set; } +} + public sealed class CharacterImageUploadViewModel { public int CharacterID { get; set; } diff --git a/PlotLine/Views/Chapters/Details.cshtml b/PlotLine/Views/Chapters/Details.cshtml index 1dd0c3f..5730dca 100644 --- a/PlotLine/Views/Chapters/Details.cshtml +++ b/PlotLine/Views/Chapters/Details.cshtml @@ -17,6 +17,10 @@ {
@archiveMessage
} +@if (TempData["ChapterMessage"] is string chapterMessage) +{ +
@chapterMessage
+}
@@ -30,6 +34,12 @@ { @Model.Chapter.ChapterPurposeName } + @{ + var chapterPov = Model.CharacterOptions.FirstOrDefault(option => option.Value == Model.Chapter.POVCharacterID?.ToString())?.Text + ?? Model.Chapter.POVSuggestedName + ?? "Not detected"; + } + POV: @chapterPov @if (!string.IsNullOrWhiteSpace(Model.Chapter.Summary)) {

@Model.Chapter.Summary

@@ -76,6 +86,22 @@

Scenes

+
+ +
+ + +
+
+ +
+
+ +
+
@if (Model.TextStatistics is not null && Model.TextStatistics.TotalWordCount > 0) { @@ -94,6 +120,7 @@ Title Status Manuscript + POV Time Summary Order @@ -127,6 +154,7 @@ @scene.RevisionStatusName @(scene.IsLinkedToManuscript ? "🔗 Linked" : "📝 Planned") + @(scene.POVCharacterName ?? scene.POVSuggestedName ?? "Not detected") @scene.TimeLabel @scene.Summary diff --git a/PlotLine/Views/Chapters/Edit.cshtml b/PlotLine/Views/Chapters/Edit.cshtml index cae2079..378d0b8 100644 --- a/PlotLine/Views/Chapters/Edit.cshtml +++ b/PlotLine/Views/Chapters/Edit.cshtml @@ -33,6 +33,10 @@
+
+ + +
diff --git a/PlotLine/Views/Characters/Details.cshtml b/PlotLine/Views/Characters/Details.cshtml index 6b50ed8..04662c1 100644 --- a/PlotLine/Views/Characters/Details.cshtml +++ b/PlotLine/Views/Characters/Details.cshtml @@ -38,6 +38,7 @@