PlotDirector Phase 17J - Continuity Warning Review and Actions

This commit is contained in:
Nick Beckley 2026-06-30 15:48:13 +01:00
parent 2af1feb412
commit a305065f1a
5 changed files with 344 additions and 9 deletions

View File

@ -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<IActionResult> SceneContinuityEditor(int sceneId)
{
var model = await BuildSceneContinuityEditorModelAsync(sceneId);
return model is null
? NotFound()
: PartialView("~/Views/Shared/SceneInspectorV2/_SceneContinuityEditor.cshtml", model);
}
public async Task<IActionResult> Index(
int projectId,
int? bookId,
@ -49,6 +64,11 @@ public sealed class WarningsController(IContinuityValidationService validation)
public async Task<IActionResult> 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<IActionResult> Dismiss(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false)
public async Task<IActionResult> 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<IActionResult> MarkIntentional(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false)
public async Task<IActionResult> 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<IActionResult> Restore(int id, int projectId, int? bookId, int? sceneId)
public async Task<IActionResult> 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<IActionResult> 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<SceneContinuityEditorViewModel?> BuildSceneContinuityEditorModelAsync(int sceneId)
{
var scene = await scenes.GetEditAsync(sceneId);
return scene is null ? null : await BuildSceneContinuityEditorModelAsync(scene);
}
private async Task<SceneContinuityEditorViewModel?> 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<string> 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

View File

@ -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;

View File

@ -13,7 +13,7 @@
<p class="eyebrow">Continuity</p>
<h3>Scene health</h3>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="warnings" data-scene-id="@Model.SceneID">Review warnings</button>
<button type="button" class="btn btn-sm btn-outline-primary" data-scene-editor="warnings" data-scene-id="@Model.SceneID" data-scene-editor-url="@Url.Action("SceneContinuityEditor", "Warnings", new { sceneId = Model.SceneID })">Review warnings</button>
</div>
<div class="scene-inspector-v2-counts">

View File

@ -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;
}
<div class="scene-inspector-editor-form">
<div class="scene-inspector-editor-fields">
<section class="scene-structure-editor-section">
<div class="scene-structure-editor-heading">
<span>Continuity warnings</span>
<strong>@totalWarnings</strong>
</div>
<form asp-controller="Warnings" asp-action="ValidateScene" method="post" class="scene-structure-editor-card scene-structure-editor-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="projectId" value="@scene.ReturnProjectID" />
<input type="hidden" name="bookId" value="@scene.ReturnBookID" />
<input type="hidden" name="sceneId" value="@scene.SceneID" />
<input type="hidden" name="returnToTimeline" value="@scene.ReturnToTimeline.ToString().ToLowerInvariant()" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<p class="muted">Run the existing scene validation checks and refresh this review list.</p>
<div class="scene-structure-action-row">
<button class="btn btn-sm btn-primary" type="submit" data-scene-editor-save>Revalidate scene</button>
</div>
</form>
</section>
<section class="scene-structure-editor-section">
<div class="scene-structure-editor-heading">
<span>Review warnings</span>
<strong>@totalWarnings</strong>
</div>
@if (totalWarnings == 0)
{
<p class="muted">No continuity warnings are recorded for this scene.</p>
}
else
{
<div class="scene-structure-editor-list">
@foreach (var warning in persistedWarnings)
{
<article class="scene-structure-editor-card">
<div class="scene-structure-card-header">
<div>
<h4>@warning.WarningTypeDisplayName</h4>
<p>@warning.Category</p>
</div>
<span class="scene-structure-marker">@warning.Severity</span>
</div>
<p>@warning.Message</p>
@if (!string.IsNullOrWhiteSpace(warning.Details))
{
<p class="muted">@warning.Details</p>
}
<div class="scene-inspector-v2-chip-row">
@if (warning.IsDismissed)
{
<span class="status-pill">Dismissed</span>
}
else if (warning.IsIntentional)
{
<span class="status-pill">Intentional</span>
}
else
{
<span class="status-pill">Active</span>
}
@if (!string.IsNullOrWhiteSpace(warning.EntityType))
{
<span class="status-pill">@warning.EntityType</span>
}
</div>
@if (warning.CanInvestigate)
{
<div class="scene-structure-action-row">
<a class="btn btn-sm btn-outline-primary"
asp-controller="ContinuityExplorer"
asp-action="Index"
asp-route-projectId="@dashboard.Project.ProjectID"
asp-route-mode="@warning.InvestigationDisplayMode"
asp-route-characterId="@warning.CharacterID"
asp-route-assetId="@warning.AssetID"
asp-route-sceneId="@warning.InvestigationSceneId">Investigate</a>
</div>
}
@if (warning.IsDismissed || warning.IsIntentional)
{
<form asp-controller="Warnings" asp-action="Restore" method="post" class="scene-structure-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="warningId" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@dashboard.Project.ProjectID" />
<input type="hidden" name="bookId" value="@dashboard.BookID" />
<input type="hidden" name="sceneId" value="@scene.SceneID" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-secondary" type="submit" data-scene-editor-save>Restore warning</button>
</form>
}
else
{
<div class="scene-structure-action-row">
<form asp-controller="Warnings" asp-action="Dismiss" method="post" class="scene-structure-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="warningId" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@dashboard.Project.ProjectID" />
<input type="hidden" name="bookId" value="@dashboard.BookID" />
<input type="hidden" name="sceneId" value="@scene.SceneID" />
<input type="hidden" name="returnToTimeline" value="@scene.ReturnToTimeline.ToString().ToLowerInvariant()" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-secondary" type="submit" data-scene-editor-save>Dismiss</button>
</form>
<form asp-controller="Warnings" asp-action="MarkIntentional" method="post" class="scene-structure-delete-form" data-scene-editor-form>
@Html.AntiForgeryToken()
<input type="hidden" name="warningId" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@dashboard.Project.ProjectID" />
<input type="hidden" name="bookId" value="@dashboard.BookID" />
<input type="hidden" name="sceneId" value="@scene.SceneID" />
<input type="hidden" name="returnToTimeline" value="@scene.ReturnToTimeline.ToString().ToLowerInvariant()" />
<div class="scene-inspector-editor-error" data-scene-editor-error hidden></div>
<button class="btn btn-sm btn-outline-secondary" type="submit" data-scene-editor-save>Mark intentional</button>
</form>
</div>
}
</article>
}
@foreach (var warning in generatedWarnings)
{
<article class="scene-structure-editor-card">
<div class="scene-structure-card-header">
<div>
<h4>@warning.WarningTypeDisplayName</h4>
<p>@warning.Category</p>
</div>
<span class="scene-structure-marker">@warning.Severity</span>
</div>
<p>@warning.Message</p>
<div class="scene-inspector-v2-chip-row">
<span class="status-pill">Generated</span>
<span class="status-pill">Review only</span>
</div>
</article>
}
@foreach (var warning in ageWarnings)
{
<article class="scene-structure-editor-card">
<div class="scene-structure-card-header">
<div>
<h4>@warning.WarningTypeName</h4>
<p>Character age continuity</p>
</div>
<span class="scene-structure-marker">@warning.SeverityName</span>
</div>
<p>@warning.Message</p>
@if (!string.IsNullOrWhiteSpace(warning.Details))
{
<p class="muted">@warning.Details</p>
}
<div class="scene-inspector-v2-chip-row">
<span class="status-pill">Generated</span>
<span class="status-pill">Review only</span>
</div>
</article>
}
</div>
}
</section>
</div>
<div class="scene-inspector-editor-footer">
<button type="button" class="btn btn-sm btn-outline-secondary" data-scene-editor-close>Close</button>
</div>
</div>

View File

@ -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.");