From ecc8d3c76ae595af993d8324ea6927aa3c393acc Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Mon, 15 Jun 2026 09:37:54 +0100 Subject: [PATCH] Phase 12J: Warning Investigation Workflow Improvements. --- .../ContinuityExplorerController.cs | 60 ++++++- PlotLine/Services/CoreServices.cs | 163 +++++++++++++++++- PlotLine/ViewModels/CoreViewModels.cs | 26 +++ PlotLine/Views/Warnings/Index.cshtml | 21 ++- 4 files changed, 256 insertions(+), 14 deletions(-) diff --git a/PlotLine/Controllers/ContinuityExplorerController.cs b/PlotLine/Controllers/ContinuityExplorerController.cs index 141a0a2..3ebf98c 100644 --- a/PlotLine/Controllers/ContinuityExplorerController.cs +++ b/PlotLine/Controllers/ContinuityExplorerController.cs @@ -8,8 +8,15 @@ namespace PlotLine.Controllers; [Authorize] public sealed class ContinuityExplorerController(IStoryStateService storyState) : Controller { - public async Task Index([FromQuery] ContinuityFilter filter) + public async Task Index( + [FromQuery] ContinuityFilter filter, + [FromQuery] string? mode, + [FromQuery] int? characterId, + [FromQuery] int? assetId, + [FromQuery] int? locationId, + [FromQuery] int? sceneId) { + ApplyDeepLinkAliases(filter, mode, characterId, assetId, locationId, sceneId); if (filter.ProjectID == 0) { return BadRequest("ProjectID is required."); @@ -25,4 +32,55 @@ public sealed class ContinuityExplorerController(IStoryStateService storyState) { return RedirectToAction(nameof(Index), filter); } + + private static void ApplyDeepLinkAliases(ContinuityFilter filter, string? mode, int? characterId, int? assetId, int? locationId, int? sceneId) + { + if (!string.IsNullOrWhiteSpace(mode)) + { + filter.DisplayMode = mode; + } + + var hasFriendlyEntityFilter = characterId.HasValue || assetId.HasValue || locationId.HasValue; + if (hasFriendlyEntityFilter) + { + filter.EntityTypes = []; + if (characterId.HasValue) + { + filter.EntityTypes.Add("Characters"); + } + + if (assetId.HasValue) + { + filter.EntityTypes.Add("Assets"); + } + + if (locationId.HasValue || string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase)) + { + filter.EntityTypes.Add("Locations"); + } + } + + if (characterId.HasValue && !filter.CharacterIDs.Contains(characterId.Value)) + { + filter.CharacterIDs.Add(characterId.Value); + } + + if (assetId.HasValue && !filter.AssetIDs.Contains(assetId.Value)) + { + filter.AssetIDs.Add(assetId.Value); + } + + if (locationId.HasValue && !filter.LocationIDs.Contains(locationId.Value)) + { + filter.LocationIDs.Add(locationId.Value); + } + + if (sceneId.HasValue && !filter.StartSceneID.HasValue && !filter.EndSceneID.HasValue && !filter.SelectedSceneID.HasValue) + { + filter.SceneRangeMode = "Custom"; + filter.StartSceneID = sceneId.Value; + filter.EndSceneID = sceneId.Value; + filter.SelectedSceneID = sceneId.Value; + } + } } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 64da3d8..fd3db9a 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -3029,11 +3029,17 @@ public sealed class StoryStateService( warning.KnowledgeDescription = knowledgeDescription; warning.PreviousKnowledgeState = previous.Item.KnowledgeStateName; warning.CurrentKnowledgeState = current.Item.KnowledgeStateName; + var previousSceneContext = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID); + var currentSceneContext = context.SceneContexts.GetValueOrDefault(current.Item.SceneID); + warning.PreviousBookId = previousSceneContext?.BookID; + warning.PreviousChapterId = previousSceneContext?.ChapterID; warning.PreviousSceneId = previous.Item.SceneID; - warning.PreviousSceneDisplayName = context.SceneContexts.GetValueOrDefault(previous.Item.SceneID)?.SceneDisplayName + warning.PreviousSceneDisplayName = previousSceneContext?.SceneDisplayName ?? context.SceneLabels.GetValueOrDefault(previous.Item.SceneID, SceneLabel(previous.Item.SceneNumber, previous.Item.SceneTitle)); + warning.CurrentBookId = currentSceneContext?.BookID; + warning.CurrentChapterId = currentSceneContext?.ChapterID; warning.CurrentSceneId = current.Item.SceneID; - warning.CurrentSceneDisplayName = context.SceneContexts.GetValueOrDefault(current.Item.SceneID)?.SceneDisplayName + warning.CurrentSceneDisplayName = currentSceneContext?.SceneDisplayName ?? context.SceneLabels.GetValueOrDefault(current.Item.SceneID, SceneLabel(current.Item.SceneNumber, current.Item.SceneTitle)); warnings.Add(warning); } @@ -3218,6 +3224,8 @@ public sealed class StoryStateService( Message = message, CharacterID = characterId, AssetID = assetId, + BookID = sceneContext?.BookID, + ChapterID = sceneContext?.ChapterID, SceneID = sceneId, SceneDisplayName = sceneContext?.SceneDisplayName ?? (sceneId.HasValue ? context.SceneLabels.GetValueOrDefault(sceneId.Value, string.Empty) : string.Empty), BookDisplayName = sceneContext?.BookDisplayName ?? string.Empty, @@ -4008,7 +4016,10 @@ public sealed class ContinuityValidationService( .Where(x => !chapterId.HasValue || x.ChapterID == chapterId.Value) .ToList(); var continuityWarnings = await storyState.GetContinuityWarningsAsync(projectId, BuildWarningContinuityFilter(projectId, bookId, chapterId, sceneId)); - var dashboardWarnings = rows.Select(ToProjectWarning).Concat(continuityWarnings.Select(ToProjectWarning)).ToList(); + var booksById = projectBooks.ToDictionary(x => x.BookID); + var chaptersById = projectChapters.ToDictionary(x => x.ChapterID); + var dashboardWarnings = rows.Select(x => ToProjectWarning(x, booksById, chaptersById)).Concat(continuityWarnings.Select(ToProjectWarning)).ToList(); + ApplyWarningDisplayContext(dashboardWarnings, bookId, chapterId, booksById, chaptersById); var availableWarnings = dashboardWarnings.ToList(); var hasAnyWarnings = availableWarnings.Any(); @@ -4083,8 +4094,20 @@ public sealed class ContinuityValidationService( return filter; } - private static ProjectWarningViewModel ToProjectWarning(ContinuityWarning warning) + private static ProjectWarningViewModel ToProjectWarning( + ContinuityWarning warning, + IReadOnlyDictionary booksById, + IReadOnlyDictionary chaptersById) { + var bookDisplayName = warning.BookID.HasValue && booksById.TryGetValue(warning.BookID.Value, out var book) + ? $"Book {book.BookNumber:g}: {book.BookTitle}" + : warning.BookTitle ?? string.Empty; + var chapterDisplayName = warning.ChapterID.HasValue && chaptersById.TryGetValue(warning.ChapterID.Value, out var chapter) + ? $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}" + : warning.ChapterNumber.HasValue + ? $"Chapter {warning.ChapterNumber:g}: {warning.ChapterTitle}" + : string.Empty; + return new ProjectWarningViewModel { Source = "Persisted", @@ -4096,11 +4119,9 @@ public sealed class ContinuityValidationService( Message = warning.Message, Details = warning.Details, BookID = warning.BookID, - BookDisplayName = string.IsNullOrWhiteSpace(warning.BookTitle) ? string.Empty : warning.BookTitle, + BookDisplayName = bookDisplayName, ChapterID = warning.ChapterID, - ChapterDisplayName = warning.ChapterNumber.HasValue - ? $"Ch {warning.ChapterNumber:g}: {warning.ChapterTitle}" - : string.Empty, + ChapterDisplayName = chapterDisplayName, SceneID = warning.SceneID, SceneDisplayName = warning.SceneID.HasValue ? SceneLabel(warning.SceneNumber ?? 0, warning.SceneTitle ?? string.Empty) @@ -4122,7 +4143,9 @@ public sealed class ContinuityValidationService( Category = WarningCategoryForGenerated(warning), Severity = warning.Severity, Message = warning.Message, + BookID = warning.BookID, BookDisplayName = warning.BookDisplayName, + ChapterID = warning.ChapterID, ChapterDisplayName = warning.ChapterDisplayName, SceneID = warning.SceneID, SceneDisplayName = warning.SceneDisplayName, @@ -4138,10 +4161,16 @@ public sealed class ContinuityValidationService( KnowledgeDescription = warning.KnowledgeDescription, PreviousKnowledgeState = warning.PreviousKnowledgeState, CurrentKnowledgeState = warning.CurrentKnowledgeState, + PreviousBookId = warning.PreviousBookId, + PreviousChapterId = warning.PreviousChapterId, PreviousSceneId = warning.PreviousSceneId, PreviousSceneDisplayName = warning.PreviousSceneDisplayName, + PreviousContextSceneDisplayName = warning.PreviousContextSceneDisplayName, + CurrentBookId = warning.CurrentBookId, + CurrentChapterId = warning.CurrentChapterId, CurrentSceneId = warning.CurrentSceneId, CurrentSceneDisplayName = warning.CurrentSceneDisplayName, + CurrentContextSceneDisplayName = warning.CurrentContextSceneDisplayName, EstablishedSceneId = warning.EstablishedSceneId, EstablishedSceneDisplayName = warning.EstablishedSceneDisplayName, ReferencedSceneId = warning.ReferencedSceneId, @@ -4150,6 +4179,124 @@ public sealed class ContinuityValidationService( }; } + private static void ApplyWarningDisplayContext( + List warnings, + int? scopedBookId, + int? scopedChapterId, + IReadOnlyDictionary booksById, + IReadOnlyDictionary chaptersById) + { + foreach (var warning in warnings) + { + warning.ContextSceneDisplayName = FormatWarningSceneContext( + warning.BookID, + warning.BookDisplayName, + warning.ChapterID, + warning.ChapterDisplayName, + warning.SceneDisplayName, + scopedBookId, + scopedChapterId, + booksById, + chaptersById); + warning.PreviousContextSceneDisplayName = FormatWarningSceneContext( + warning.PreviousBookId, + string.Empty, + warning.PreviousChapterId, + string.Empty, + warning.PreviousSceneDisplayName, + scopedBookId, + scopedChapterId, + booksById, + chaptersById); + warning.CurrentContextSceneDisplayName = FormatWarningSceneContext( + warning.CurrentBookId, + string.Empty, + warning.CurrentChapterId, + string.Empty, + warning.CurrentSceneDisplayName, + scopedBookId, + scopedChapterId, + booksById, + chaptersById); + warning.EstablishedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.EstablishedSceneDisplayName) + ? string.Empty + : warning.EstablishedSceneDisplayName; + warning.ReferencedContextSceneDisplayName = string.IsNullOrWhiteSpace(warning.ReferencedSceneDisplayName) + ? string.Empty + : warning.ReferencedSceneDisplayName; + ApplyInvestigationTarget(warning); + } + } + + private static string FormatWarningSceneContext( + int? bookId, + string bookDisplayName, + int? chapterId, + string chapterDisplayName, + string sceneDisplayName, + int? scopedBookId, + int? scopedChapterId, + IReadOnlyDictionary booksById, + IReadOnlyDictionary chaptersById) + { + if (string.IsNullOrWhiteSpace(sceneDisplayName)) + { + return string.Empty; + } + + if (string.IsNullOrWhiteSpace(bookDisplayName) && bookId.HasValue && booksById.TryGetValue(bookId.Value, out var book)) + { + bookDisplayName = $"Book {book.BookNumber:g}: {book.BookTitle}"; + } + + if (string.IsNullOrWhiteSpace(chapterDisplayName) && chapterId.HasValue && chaptersById.TryGetValue(chapterId.Value, out var chapter)) + { + chapterDisplayName = $"Chapter {chapter.ChapterNumber:g}: {chapter.ChapterTitle}"; + } + + if (scopedChapterId.HasValue) + { + return sceneDisplayName; + } + + if (scopedBookId.HasValue || (bookId.HasValue && scopedBookId == bookId)) + { + return string.IsNullOrWhiteSpace(chapterDisplayName) + ? sceneDisplayName + : $"{chapterDisplayName} • {sceneDisplayName}"; + } + + var parts = new List(); + if (!string.IsNullOrWhiteSpace(bookDisplayName)) + { + parts.Add(bookDisplayName); + } + + if (!string.IsNullOrWhiteSpace(chapterDisplayName)) + { + parts.Add(chapterDisplayName); + } + + parts.Add(sceneDisplayName); + return string.Join(" • ", parts); + } + + private static void ApplyInvestigationTarget(ProjectWarningViewModel warning) + { + if (warning.Source != "Generated") + { + return; + } + + warning.CanInvestigate = true; + warning.InvestigationDisplayMode = warning.WarningTypeDisplayName switch + { + "Multiple Character Locations" => "LocationActivity", + _ => "Journey" + }; + warning.InvestigationSceneId = warning.CurrentSceneId ?? warning.ReferencedSceneId ?? warning.SceneID; + } + private static string WarningCategoryForPersisted(ContinuityWarning warning) { return warning.EntityType switch diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 09e4400..d44c10e 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -325,8 +325,11 @@ public sealed class ContinuityWarningViewModel public int? CharacterID { get; set; } public string CharacterName { get; set; } = string.Empty; public int? AssetID { get; set; } + public int? BookID { get; set; } + public int? ChapterID { get; set; } public int? SceneID { get; set; } public string SceneDisplayName { get; set; } = string.Empty; + public string ContextSceneDisplayName { get; set; } = string.Empty; public string BookDisplayName { get; set; } = string.Empty; public string ChapterDisplayName { get; set; } = string.Empty; public bool IsAcknowledged { get; set; } @@ -336,14 +339,25 @@ public sealed class ContinuityWarningViewModel public string KnowledgeDescription { get; set; } = string.Empty; public string PreviousKnowledgeState { get; set; } = string.Empty; public string CurrentKnowledgeState { get; set; } = string.Empty; + public int? PreviousBookId { get; set; } + public int? PreviousChapterId { get; set; } public int? PreviousSceneId { get; set; } public string PreviousSceneDisplayName { get; set; } = string.Empty; + public string PreviousContextSceneDisplayName { get; set; } = string.Empty; + public int? CurrentBookId { get; set; } + public int? CurrentChapterId { get; set; } public int? CurrentSceneId { get; set; } public string CurrentSceneDisplayName { get; set; } = string.Empty; + public string CurrentContextSceneDisplayName { get; set; } = string.Empty; public int? EstablishedSceneId { get; set; } public string EstablishedSceneDisplayName { get; set; } = string.Empty; + public string EstablishedContextSceneDisplayName { get; set; } = string.Empty; public int? ReferencedSceneId { get; set; } public string ReferencedSceneDisplayName { get; set; } = string.Empty; + public string ReferencedContextSceneDisplayName { get; set; } = string.Empty; + public string InvestigationDisplayMode { get; set; } = string.Empty; + public int? InvestigationSceneId { get; set; } + public bool CanInvestigate { get; set; } } public sealed class CharacterJourneyViewModel @@ -1685,6 +1699,7 @@ public sealed class ProjectWarningViewModel public string ChapterDisplayName { get; set; } = string.Empty; public int? SceneID { get; set; } public string SceneDisplayName { get; set; } = string.Empty; + public string ContextSceneDisplayName { get; set; } = string.Empty; public int? CharacterID { get; set; } public string CharacterName { get; set; } = string.Empty; public int? AssetID { get; set; } @@ -1699,14 +1714,25 @@ public sealed class ProjectWarningViewModel public string KnowledgeDescription { get; set; } = string.Empty; public string PreviousKnowledgeState { get; set; } = string.Empty; public string CurrentKnowledgeState { get; set; } = string.Empty; + public int? PreviousBookId { get; set; } + public int? PreviousChapterId { get; set; } public int? PreviousSceneId { get; set; } public string PreviousSceneDisplayName { get; set; } = string.Empty; + public string PreviousContextSceneDisplayName { get; set; } = string.Empty; + public int? CurrentBookId { get; set; } + public int? CurrentChapterId { get; set; } public int? CurrentSceneId { get; set; } public string CurrentSceneDisplayName { get; set; } = string.Empty; + public string CurrentContextSceneDisplayName { get; set; } = string.Empty; public int? EstablishedSceneId { get; set; } public string EstablishedSceneDisplayName { get; set; } = string.Empty; + public string EstablishedContextSceneDisplayName { get; set; } = string.Empty; public int? ReferencedSceneId { get; set; } public string ReferencedSceneDisplayName { get; set; } = string.Empty; + public string ReferencedContextSceneDisplayName { get; set; } = string.Empty; + public string InvestigationDisplayMode { get; set; } = string.Empty; + public int? InvestigationSceneId { get; set; } + public bool CanInvestigate { get; set; } public DateTime LastDetectedDate { get; set; } } diff --git a/PlotLine/Views/Warnings/Index.cshtml b/PlotLine/Views/Warnings/Index.cshtml index f83dd5f..ebdbe80 100644 --- a/PlotLine/Views/Warnings/Index.cshtml +++ b/PlotLine/Views/Warnings/Index.cshtml @@ -183,28 +183,28 @@ { Previous: - @warning.PreviousSceneDisplayName + @warning.PreviousContextSceneDisplayName } @if (warning.CurrentSceneId.HasValue) { Current: - @warning.CurrentSceneDisplayName + @warning.CurrentContextSceneDisplayName } @if (warning.EstablishedSceneId.HasValue) { Established: - @warning.EstablishedSceneDisplayName + @(string.IsNullOrWhiteSpace(warning.EstablishedContextSceneDisplayName) ? warning.EstablishedSceneDisplayName : warning.EstablishedContextSceneDisplayName) } @if (warning.ReferencedSceneId.HasValue) { Earlier reference: - @warning.ReferencedSceneDisplayName + @(string.IsNullOrWhiteSpace(warning.ReferencedContextSceneDisplayName) ? warning.ReferencedSceneDisplayName : warning.ReferencedContextSceneDisplayName) } @if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes)) @@ -217,7 +217,7 @@ @if (warning.SceneID.HasValue) { - @warning.SceneDisplayName + @warning.ContextSceneDisplayName } else { @@ -239,6 +239,17 @@ } + @if (warning.CanInvestigate) + { + Investigate + } @if (warning.Source == "Persisted" && warning.ContinuityWarningID.HasValue) { @if (warning.IsDismissed || warning.IsIntentional)