From a305065f1a6b9dd2e3c8e7da046036fa5c5cd26d Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 30 Jun 2026 15:48:13 +0100 Subject: [PATCH] PlotDirector Phase 17J - Continuity Warning Review and Actions --- PlotLine/Controllers/WarningsController.cs | 147 +++++++++++++- PlotLine/ViewModels/CoreViewModels.cs | 6 + .../_SceneContinuityCard.cshtml | 2 +- .../_SceneContinuityEditor.cshtml | 190 ++++++++++++++++++ PlotLine/wwwroot/js/scene-inspector-v2.js | 8 +- 5 files changed, 344 insertions(+), 9 deletions(-) create mode 100644 PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml diff --git a/PlotLine/Controllers/WarningsController.cs b/PlotLine/Controllers/WarningsController.cs index 134cdb6..dba3044 100644 --- a/PlotLine/Controllers/WarningsController.cs +++ b/PlotLine/Controllers/WarningsController.cs @@ -1,5 +1,8 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Rendering; +using Microsoft.AspNetCore.Mvc.ViewEngines; +using Microsoft.AspNetCore.Mvc.ViewFeatures; using PlotLine.Models; using PlotLine.Services; using PlotLine.ViewModels; @@ -7,8 +10,20 @@ using PlotLine.ViewModels; namespace PlotLine.Controllers; [Authorize] -public sealed class WarningsController(IContinuityValidationService validation) : Controller +public sealed class WarningsController( + IContinuityValidationService validation, + ISceneService scenes, + ICompositeViewEngine viewEngine) : Controller { + [HttpGet] + public async Task SceneContinuityEditor(int sceneId) + { + var model = await BuildSceneContinuityEditorModelAsync(sceneId); + return model is null + ? NotFound() + : PartialView("~/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml", model); + } + public async Task Index( int projectId, int? bookId, @@ -49,6 +64,11 @@ public sealed class WarningsController(IContinuityValidationService validation) public async Task ValidateScene(int projectId, int? bookId, int sceneId, bool returnToTimeline = false) { await validation.ValidateSceneAsync(sceneId); + if (IsSceneInspectorAjaxRequest()) + { + return await SceneContinuityRefreshJsonAsync(sceneId); + } + if (TryGetLocalReturnUrl(out var returnUrl)) { return LocalRedirect(returnUrl); @@ -64,25 +84,43 @@ public sealed class WarningsController(IContinuityValidationService validation) [HttpPost] [ValidateAntiForgeryToken] - public async Task Dismiss(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false) + public async Task Dismiss(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false, int warningId = 0) { - await validation.DismissAsync(id); + var continuityWarningId = warningId > 0 ? warningId : id; + await validation.DismissAsync(continuityWarningId); + if (sceneId.HasValue && IsSceneInspectorAjaxRequest()) + { + return await SceneContinuityRefreshJsonAsync(sceneId.Value); + } + return RedirectAfterAction(projectId, bookId, sceneId, returnToTimeline); } [HttpPost] [ValidateAntiForgeryToken] - public async Task MarkIntentional(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false) + public async Task MarkIntentional(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false, int warningId = 0) { - await validation.MarkIntentionalAsync(id); + var continuityWarningId = warningId > 0 ? warningId : id; + await validation.MarkIntentionalAsync(continuityWarningId); + if (sceneId.HasValue && IsSceneInspectorAjaxRequest()) + { + return await SceneContinuityRefreshJsonAsync(sceneId.Value); + } + return RedirectAfterAction(projectId, bookId, sceneId, returnToTimeline); } [HttpPost] [ValidateAntiForgeryToken] - public async Task Restore(int id, int projectId, int? bookId, int? sceneId) + public async Task Restore(int id, int projectId, int? bookId, int? sceneId, int warningId = 0) { - await validation.RestoreAsync(id); + var continuityWarningId = warningId > 0 ? warningId : id; + await validation.RestoreAsync(continuityWarningId); + if (sceneId.HasValue && IsSceneInspectorAjaxRequest()) + { + return await SceneContinuityRefreshJsonAsync(sceneId.Value); + } + return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId, includeDismissed = true, includeIntentional = true }); } @@ -155,6 +193,101 @@ public sealed class WarningsController(IContinuityValidationService validation) return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId }); } + private bool IsSceneInspectorAjaxRequest() => + string.Equals(Request.Headers["X-Requested-With"].ToString(), "XMLHttpRequest", StringComparison.OrdinalIgnoreCase) + || Request.Headers.Accept.ToString().Contains("application/json", StringComparison.OrdinalIgnoreCase); + + private async Task SceneContinuityRefreshJsonAsync(int sceneId) + { + var scene = await scenes.GetEditAsync(sceneId); + var editorModel = scene is null ? null : await BuildSceneContinuityEditorModelAsync(scene); + if (scene is null || editorModel is null) + { + return NotFound(); + } + + return Json(new + { + success = true, + html = new + { + health = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneInspectorHealthStrip.cshtml", scene), + continuity = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml", scene) + }, + editorHtml = await RenderPartialViewToStringAsync("~/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml", editorModel), + scene = new + { + scene.SceneID + } + }); + } + + private async Task BuildSceneContinuityEditorModelAsync(int sceneId) + { + var scene = await scenes.GetEditAsync(sceneId); + return scene is null ? null : await BuildSceneContinuityEditorModelAsync(scene); + } + + private async Task BuildSceneContinuityEditorModelAsync(SceneEditViewModel scene) + { + var projectId = scene.ReturnProjectID ?? scene.Project?.ProjectID; + if (!projectId.HasValue) + { + return null; + } + + var dashboard = await validation.GetDashboardAsync( + projectId.Value, + scene.ReturnBookID ?? scene.Book?.BookID, + null, + scene.SceneID, + null, + null, + null, + null, + includeDismissed: true, + includeIntentional: true, + showAcknowledgedWarnings: true); + + return dashboard is null + ? null + : new SceneContinuityEditorViewModel + { + Scene = scene, + Dashboard = dashboard + }; + } + + private async Task RenderPartialViewToStringAsync(string viewName, object model) + { + var viewResult = viewEngine.GetView(executingFilePath: null, viewPath: viewName, isMainPage: false); + if (!viewResult.Success) + { + viewResult = viewEngine.FindView(ControllerContext, viewName, isMainPage: false); + } + + if (!viewResult.Success) + { + throw new InvalidOperationException($"Partial view '{viewName}' was not found."); + } + + await using var writer = new StringWriter(); + var viewData = new ViewDataDictionary(ViewData) + { + Model = model + }; + var viewContext = new ViewContext( + ControllerContext, + viewResult.View, + viewData, + TempData, + writer, + new HtmlHelperOptions()); + + await viewResult.View.RenderAsync(viewContext); + return writer.ToString(); + } + private bool TryGetLocalReturnUrl(out string returnUrl) { if (Request.HasFormContentType diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index a870653..dbd1364 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -2365,6 +2365,12 @@ public sealed class WarningDashboardViewModel public bool HasAnyWarnings { get; set; } } +public sealed class SceneContinuityEditorViewModel +{ + public SceneEditViewModel Scene { get; set; } = new(); + public WarningDashboardViewModel Dashboard { get; set; } = new(); +} + public sealed class WarningSeverityGroupViewModel { public string Severity { get; set; } = string.Empty; diff --git a/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml b/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml index f44af2c..b0c2d6e 100644 --- a/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml +++ b/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityCard.cshtml @@ -13,7 +13,7 @@

Continuity

Scene health

- +
diff --git a/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml b/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml new file mode 100644 index 0000000..2a85a37 --- /dev/null +++ b/PlotLine/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml @@ -0,0 +1,190 @@ +@model SceneContinuityEditorViewModel +@{ + var scene = Model.Scene; + var dashboard = Model.Dashboard; + var persistedWarnings = dashboard.ProjectWarnings + .Where(x => x.Source == "Persisted" && x.ContinuityWarningID.HasValue) + .ToList(); + var generatedWarnings = dashboard.ProjectWarnings + .Where(x => x.Source != "Persisted") + .ToList(); + var ageWarnings = scene.AgeContinuityWarnings; + var totalWarnings = persistedWarnings.Count + generatedWarnings.Count + ageWarnings.Count; +} + +
+
+
+
+ Continuity warnings + @totalWarnings +
+ +
+ @Html.AntiForgeryToken() + + + + + + + +

Run the existing scene validation checks and refresh this review list.

+
+ +
+
+
+ +
+
+ Review warnings + @totalWarnings +
+ + @if (totalWarnings == 0) + { +

No continuity warnings are recorded for this scene.

+ } + else + { +
+ @foreach (var warning in persistedWarnings) + { +
+
+
+

@warning.WarningTypeDisplayName

+

@warning.Category

+
+ @warning.Severity +
+ +

@warning.Message

+ @if (!string.IsNullOrWhiteSpace(warning.Details)) + { +

@warning.Details

+ } + +
+ @if (warning.IsDismissed) + { + Dismissed + } + else if (warning.IsIntentional) + { + Intentional + } + else + { + Active + } + @if (!string.IsNullOrWhiteSpace(warning.EntityType)) + { + @warning.EntityType + } +
+ + @if (warning.CanInvestigate) + { + + } + + @if (warning.IsDismissed || warning.IsIntentional) + { +
+ @Html.AntiForgeryToken() + + + + + + +
+ } + else + { +
+
+ @Html.AntiForgeryToken() + + + + + + + +
+ +
+ @Html.AntiForgeryToken() + + + + + + + +
+
+ } +
+ } + + @foreach (var warning in generatedWarnings) + { +
+
+
+

@warning.WarningTypeDisplayName

+

@warning.Category

+
+ @warning.Severity +
+

@warning.Message

+
+ Generated + Review only +
+
+ } + + @foreach (var warning in ageWarnings) + { +
+
+
+

@warning.WarningTypeName

+

Character age continuity

+
+ @warning.SeverityName +
+

@warning.Message

+ @if (!string.IsNullOrWhiteSpace(warning.Details)) + { +

@warning.Details

+ } +
+ Generated + Review only +
+
+ } +
+ } +
+
+ + +
diff --git a/PlotLine/wwwroot/js/scene-inspector-v2.js b/PlotLine/wwwroot/js/scene-inspector-v2.js index 8f36537..fba3a12 100644 --- a/PlotLine/wwwroot/js/scene-inspector-v2.js +++ b/PlotLine/wwwroot/js/scene-inspector-v2.js @@ -214,7 +214,7 @@ const response = await fetch(form.action, { method: form.method || "POST", body: new FormData(form), - headers: { "X-Requested-With": "XMLHttpRequest" }, + headers: { "X-Requested-With": "XMLHttpRequest", "Accept": "application/json" }, credentials: "same-origin" }); const contentType = response.headers.get("content-type") || ""; @@ -234,6 +234,12 @@ timelineHeader.textContent = title; } + if (payload.editorHtml) { + setEditorBody(overlay, payload.editorHtml); + overlay.querySelector("input, select, textarea, button")?.focus(); + return; + } + closeOverlay(overlay); } catch { setEditorError(form, "The editor could not be saved.");