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; namespace PlotLine.Controllers; [Authorize] 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, int? chapterId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, string? category, bool includeDismissed = false, bool includeIntentional = false, bool showAcknowledgedWarnings = false) { var model = await validation.GetDashboardAsync(projectId, bookId, chapterId, sceneId, warningTypeId, warningSeverityId, entityType, category, includeDismissed, includeIntentional, showAcknowledgedWarnings); return model is null ? NotFound() : View(model); } [HttpPost] [ValidateAntiForgeryToken] public async Task ValidateProject(int projectId, int? bookId) { var summary = await validation.ValidateProjectAsync(projectId); var model = await validation.GetDashboardAsync(projectId, bookId, null, null, null, null, null, null, false, false, false, summary); return model is null ? NotFound() : View("Index", model); } [HttpPost] [ValidateAntiForgeryToken] public async Task ValidateBook(int projectId, int bookId) { var summary = await validation.ValidateBookAsync(bookId); var model = await validation.GetDashboardAsync(projectId, bookId, null, null, null, null, null, null, false, false, false, summary); return model is null ? NotFound() : View("Index", model); } [HttpPost] [ValidateAntiForgeryToken] 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); } if (returnToTimeline) { return RedirectToAction("Index", "Timeline", new { projectId, bookId, selectedSceneId = sceneId }); } return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId }); } [HttpPost] [ValidateAntiForgeryToken] public async Task Dismiss(int id, int projectId, int? bookId, int? sceneId, bool returnToTimeline = false, int warningId = 0) { 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, int warningId = 0) { 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, int warningId = 0) { 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 }); } [HttpPost] [ValidateAntiForgeryToken] public async Task AcknowledgeGeneratedWarning( string warningType, int? sceneId, int? characterId, int? assetId, string? notes, [Bind(Prefix = "ReturnFilter")] WarningDashboardReturnFilter returnFilter) { if (returnFilter.ProjectID == 0 || string.IsNullOrWhiteSpace(warningType)) { return BadRequest("Warning identity is required."); } await validation.AcknowledgeGeneratedWarningAsync(new ContinuityWarningAcknowledgement { ProjectID = returnFilter.ProjectID, WarningType = warningType, SceneID = sceneId, CharacterID = characterId, AssetID = assetId, Notes = notes }); return RedirectToDashboard(returnFilter); } [HttpPost] [ValidateAntiForgeryToken] public async Task RestoreGeneratedWarning( string warningType, int? sceneId, int? characterId, int? assetId, [Bind(Prefix = "ReturnFilter")] WarningDashboardReturnFilter returnFilter) { if (returnFilter.ProjectID == 0 || string.IsNullOrWhiteSpace(warningType)) { return BadRequest("Warning identity is required."); } await validation.RestoreGeneratedWarningAsync(new ContinuityWarningAcknowledgement { ProjectID = returnFilter.ProjectID, WarningType = warningType, SceneID = sceneId, CharacterID = characterId, AssetID = assetId }); return RedirectToDashboard(returnFilter); } private IActionResult RedirectAfterAction(int projectId, int? bookId, int? sceneId, bool returnToTimeline) { if (TryGetLocalReturnUrl(out var returnUrl)) { return LocalRedirect(returnUrl); } if (returnToTimeline && sceneId.HasValue) { return RedirectToAction("Index", "Timeline", new { projectId, bookId, selectedSceneId = sceneId.Value }); } 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 && Request.Form.TryGetValue("ReturnUrl", out var postedReturnUrl) && Url.IsLocalUrl(postedReturnUrl.ToString())) { returnUrl = postedReturnUrl.ToString(); return true; } returnUrl = string.Empty; return false; } private IActionResult RedirectToDashboard(WarningDashboardReturnFilter filter) { return RedirectToAction(nameof(Index), new { projectId = filter.ProjectID, bookId = filter.BookID, chapterId = filter.ChapterID, sceneId = filter.SceneID, warningTypeId = filter.WarningTypeID, warningSeverityId = filter.WarningSeverityID, entityType = filter.EntityType, category = filter.Category, includeDismissed = filter.IncludeDismissed, includeIntentional = filter.IncludeIntentional, showAcknowledgedWarnings = filter.ShowAcknowledgedWarnings }); } }