PlotDirector Phase 17J - Continuity Warning Review and Actions
This commit is contained in:
parent
2af1feb412
commit
a305065f1a
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
@ -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.");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user