Phase 12H: Move Continuity Warnings into the Main Warnings Page.

This commit is contained in:
Nick Beckley 2026-06-14 21:14:22 +01:00
parent e008da0cf4
commit f401416fbd
6 changed files with 540 additions and 332 deletions

View File

@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
@ -26,58 +25,4 @@ public sealed class ContinuityExplorerController(IStoryStateService storyState)
{
return RedirectToAction(nameof(Index), filter);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AcknowledgeWarning(
string warningType,
int? sceneId,
int? characterId,
int? assetId,
string? notes,
[Bind(Prefix = "ReturnFilter")] ContinuityFilter returnFilter)
{
if (returnFilter.ProjectID == 0 || string.IsNullOrWhiteSpace(warningType))
{
return BadRequest("Warning identity is required.");
}
await storyState.AcknowledgeWarningAsync(new ContinuityWarningAcknowledgement
{
ProjectID = returnFilter.ProjectID,
WarningType = warningType,
SceneID = sceneId,
CharacterID = characterId,
AssetID = assetId,
Notes = notes
});
return RedirectToAction(nameof(Index), returnFilter);
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RestoreWarning(
string warningType,
int? sceneId,
int? characterId,
int? assetId,
[Bind(Prefix = "ReturnFilter")] ContinuityFilter returnFilter)
{
if (returnFilter.ProjectID == 0 || string.IsNullOrWhiteSpace(warningType))
{
return BadRequest("Warning identity is required.");
}
await storyState.RestoreWarningAsync(new ContinuityWarningAcknowledgement
{
ProjectID = returnFilter.ProjectID,
WarningType = warningType,
SceneID = sceneId,
CharacterID = characterId,
AssetID = assetId
});
return RedirectToAction(nameof(Index), returnFilter);
}
}

View File

@ -1,6 +1,8 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Authorization;
using PlotLine.Models;
using PlotLine.Services;
using PlotLine.ViewModels;
namespace PlotLine.Controllers;
@ -10,14 +12,17 @@ public sealed class WarningsController(IContinuityValidationService validation)
public async Task<IActionResult> Index(
int projectId,
int? bookId,
int? chapterId,
int? sceneId,
int? warningTypeId,
int? warningSeverityId,
string? entityType,
string? category,
bool includeDismissed = false,
bool includeIntentional = false)
bool includeIntentional = false,
bool showAcknowledgedWarnings = false)
{
var model = await validation.GetDashboardAsync(projectId, bookId, sceneId, warningTypeId, warningSeverityId, entityType, includeDismissed, includeIntentional);
var model = await validation.GetDashboardAsync(projectId, bookId, chapterId, sceneId, warningTypeId, warningSeverityId, entityType, category, includeDismissed, includeIntentional, showAcknowledgedWarnings);
return model is null ? NotFound() : View(model);
}
@ -26,7 +31,7 @@ public sealed class WarningsController(IContinuityValidationService validation)
public async Task<IActionResult> ValidateProject(int projectId, int? bookId)
{
var summary = await validation.ValidateProjectAsync(projectId);
var model = await validation.GetDashboardAsync(projectId, bookId, null, null, null, null, false, false, summary);
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);
}
@ -35,7 +40,7 @@ public sealed class WarningsController(IContinuityValidationService validation)
public async Task<IActionResult> ValidateBook(int projectId, int bookId)
{
var summary = await validation.ValidateBookAsync(bookId);
var model = await validation.GetDashboardAsync(projectId, bookId, null, null, null, null, false, false, summary);
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);
}
@ -76,6 +81,60 @@ public sealed class WarningsController(IContinuityValidationService validation)
return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId, includeDismissed = true, includeIntentional = true });
}
[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> 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<IActionResult> 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 (returnToTimeline && sceneId.HasValue)
@ -85,4 +144,22 @@ public sealed class WarningsController(IContinuityValidationService validation)
return RedirectToAction(nameof(Index), new { projectId, bookId, sceneId });
}
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
});
}
}

View File

@ -176,7 +176,7 @@ public interface ILocationService
public interface IContinuityValidationService
{
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, bool includeDismissed, bool includeIntentional, ContinuityValidationSummary? summary = null);
Task<WarningDashboardViewModel?> GetDashboardAsync(int projectId, int? bookId, int? chapterId, int? sceneId, int? warningTypeId, int? warningSeverityId, string? entityType, string? category, bool includeDismissed, bool includeIntentional, bool showAcknowledgedWarnings, ContinuityValidationSummary? summary = null);
Task<ContinuityValidationSummary> ValidateProjectAsync(int projectId);
Task<ContinuityValidationSummary> ValidateBookAsync(int bookId);
Task<ContinuityValidationSummary> ValidateSceneAsync(int sceneId);
@ -188,6 +188,8 @@ public interface IContinuityValidationService
Task DismissAsync(int warningId);
Task MarkIntentionalAsync(int warningId);
Task RestoreAsync(int warningId);
Task AcknowledgeGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
Task RestoreGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement);
}
public interface ICharacterService
@ -2343,10 +2345,6 @@ public sealed class StoryStateService(
.ThenBy(x => x.LocationName)
.ToList();
}
else if (string.Equals(filter.DisplayMode, "ContinuityWarnings", StringComparison.OrdinalIgnoreCase))
{
model.ContinuityWarnings = await GetContinuityWarningsAsync(filter.ProjectID, filter);
}
return model;
}
@ -3403,6 +3401,12 @@ public sealed class StoryStateService(
{
filter.DisplayMode = "StoryState";
}
else if (!string.Equals(filter.DisplayMode, "StoryState", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(filter.DisplayMode, "Journey", StringComparison.OrdinalIgnoreCase)
&& !string.Equals(filter.DisplayMode, "LocationActivity", StringComparison.OrdinalIgnoreCase))
{
filter.DisplayMode = "StoryState";
}
}
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
@ -3911,17 +3915,21 @@ public sealed class ContinuityValidationService(
IAssetRepository assets,
ICharacterRepository characters,
IWarningRepository warnings,
IDynamicsRepository dynamics) : IContinuityValidationService
IDynamicsRepository dynamics,
IStoryStateService storyState) : IContinuityValidationService
{
public async Task<WarningDashboardViewModel?> GetDashboardAsync(
int projectId,
int? bookId,
int? chapterId,
int? sceneId,
int? warningTypeId,
int? warningSeverityId,
string? entityType,
string? category,
bool includeDismissed,
bool includeIntentional,
bool showAcknowledgedWarnings,
ContinuityValidationSummary? summary = null)
{
var project = await projects.GetAsync(projectId);
@ -3931,28 +3939,217 @@ public sealed class ContinuityValidationService(
}
var lookupData = await warnings.GetLookupsAsync();
var rows = await warnings.ListAsync(projectId, bookId, sceneId, warningTypeId, warningSeverityId, entityType, includeDismissed, includeIntentional);
var projectBooks = (await books.ListByProjectAsync(projectId)).ToList();
var projectChapters = new List<Chapter>();
foreach (var book in projectBooks)
{
projectChapters.AddRange(await chapters.ListByBookAsync(book.BookID));
}
var severityName = warningSeverityId.HasValue
? lookupData.Severities.FirstOrDefault(x => x.WarningSeverityID == warningSeverityId.Value)?.SeverityName
: null;
var rows = (await warnings.ListAsync(projectId, bookId, sceneId, warningTypeId, null, entityType, includeDismissed, includeIntentional))
.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 availableWarnings = dashboardWarnings.ToList();
var hasAnyWarnings = availableWarnings.Any();
dashboardWarnings = dashboardWarnings
.Where(x => string.IsNullOrWhiteSpace(severityName) || string.Equals(x.Severity, severityName, StringComparison.OrdinalIgnoreCase))
.Where(x => string.IsNullOrWhiteSpace(entityType) || string.Equals(x.EntityType, entityType, StringComparison.OrdinalIgnoreCase))
.Where(x => string.IsNullOrWhiteSpace(category) || string.Equals(x.Category, category, StringComparison.OrdinalIgnoreCase))
.Where(x => showAcknowledgedWarnings || !x.IsAcknowledged)
.OrderBy(x => WarningSeveritySort(x.Severity))
.ThenBy(x => x.BookDisplayName)
.ThenBy(x => x.ChapterDisplayName)
.ThenBy(x => x.SceneID ?? int.MaxValue)
.ThenBy(x => x.Category)
.ThenBy(x => x.WarningTypeDisplayName)
.ThenBy(x => x.Message)
.ToList();
return new WarningDashboardViewModel
{
Project = project,
BookID = bookId,
ChapterID = chapterId,
SceneID = sceneId,
WarningTypeID = warningTypeId,
WarningSeverityID = warningSeverityId,
EntityType = entityType,
Category = category,
IncludeDismissed = includeDismissed,
IncludeIntentional = includeIntentional,
ShowAcknowledgedWarnings = showAcknowledgedWarnings,
Warnings = rows,
BookOptions = (await books.ListByProjectAsync(projectId)).Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
WarningTypeOptions = ChapterService.ToSelectList(lookupData.WarningTypes, x => x.WarningTypeID, x => x.TypeName),
SeverityOptions = ChapterService.ToSelectList(lookupData.Severities, x => x.WarningSeverityID, x => x.SeverityName),
EntityTypeOptions = rows.Select(x => x.EntityType).Distinct().OrderBy(x => x).Select(x => new SelectListItem(x, x, x == entityType)).ToList(),
CountsBySeverity = rows.GroupBy(x => x.SeverityName).ToDictionary(x => x.Key, x => x.Count()),
CountsByType = rows.GroupBy(x => x.WarningTypeName).OrderByDescending(x => x.Count()).ToDictionary(x => x.Key, x => x.Count()),
LastValidationSummary = summary
ProjectWarnings = dashboardWarnings,
BookOptions = projectBooks.Select(x => new SelectListItem($"Book {x.BookNumber}: {x.BookTitle}", x.BookID.ToString(), x.BookID == bookId)).ToList(),
ChapterOptions = projectChapters.Select(x => new SelectListItem($"Ch {x.ChapterNumber:g}: {x.ChapterTitle}", x.ChapterID.ToString(), x.ChapterID == chapterId)).ToList(),
WarningTypeOptions = lookupData.WarningTypes.Select(x => new SelectListItem(x.TypeName, x.WarningTypeID.ToString(), x.WarningTypeID == warningTypeId)).ToList(),
SeverityOptions = lookupData.Severities.Select(x => new SelectListItem(x.SeverityName, x.WarningSeverityID.ToString(), x.WarningSeverityID == warningSeverityId)).ToList(),
EntityTypeOptions = availableWarnings.Select(x => x.EntityType).Where(x => !string.IsNullOrWhiteSpace(x)).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Select(x => new SelectListItem(x, x, string.Equals(x, entityType, StringComparison.OrdinalIgnoreCase))).ToList(),
CategoryOptions = availableWarnings.Select(x => x.Category).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x).Select(x => new SelectListItem(x, x, string.Equals(x, category, StringComparison.OrdinalIgnoreCase))).ToList(),
CountsBySeverity = dashboardWarnings.GroupBy(x => x.Severity).ToDictionary(x => x.Key, x => x.Count()),
CountsByType = dashboardWarnings.GroupBy(x => x.WarningTypeDisplayName).OrderByDescending(x => x.Count()).ToDictionary(x => x.Key, x => x.Count()),
LastValidationSummary = summary,
HasAnyWarnings = hasAnyWarnings
};
}
private static ContinuityFilter BuildWarningContinuityFilter(int projectId, int? bookId, int? chapterId, int? sceneId)
{
var filter = new ContinuityFilter
{
ProjectID = projectId,
DisplayMode = "ContinuityWarnings",
ShowAcknowledgedWarnings = true
};
if (sceneId.HasValue)
{
filter.SceneRangeMode = "Custom";
filter.StartSceneID = sceneId;
filter.EndSceneID = sceneId;
}
else if (chapterId.HasValue)
{
filter.SceneRangeMode = "Chapter";
filter.ChapterID = chapterId;
}
else if (bookId.HasValue)
{
filter.SceneRangeMode = "Book";
filter.BookID = bookId;
}
return filter;
}
private static ProjectWarningViewModel ToProjectWarning(ContinuityWarning warning)
{
return new ProjectWarningViewModel
{
Source = "Persisted",
ContinuityWarningID = warning.ContinuityWarningID,
WarningType = warning.WarningTypeName,
WarningTypeDisplayName = warning.WarningTypeName,
Category = WarningCategoryForPersisted(warning),
Severity = warning.SeverityName,
Message = warning.Message,
Details = warning.Details,
BookID = warning.BookID,
BookDisplayName = string.IsNullOrWhiteSpace(warning.BookTitle) ? string.Empty : warning.BookTitle,
ChapterID = warning.ChapterID,
ChapterDisplayName = warning.ChapterNumber.HasValue
? $"Ch {warning.ChapterNumber:g}: {warning.ChapterTitle}"
: string.Empty,
SceneID = warning.SceneID,
SceneDisplayName = warning.SceneID.HasValue
? SceneLabel(warning.SceneNumber ?? 0, warning.SceneTitle ?? string.Empty)
: string.Empty,
EntityType = warning.EntityType,
IsDismissed = warning.IsDismissed,
IsIntentional = warning.IsIntentional,
LastDetectedDate = warning.LastDetectedDate
};
}
private static ProjectWarningViewModel ToProjectWarning(ContinuityWarningViewModel warning)
{
return new ProjectWarningViewModel
{
Source = "Generated",
WarningType = warning.WarningType,
WarningTypeDisplayName = warning.WarningTypeDisplayName,
Category = WarningCategoryForGenerated(warning),
Severity = warning.Severity,
Message = warning.Message,
BookDisplayName = warning.BookDisplayName,
ChapterDisplayName = warning.ChapterDisplayName,
SceneID = warning.SceneID,
SceneDisplayName = warning.SceneDisplayName,
CharacterID = warning.CharacterID,
CharacterName = warning.CharacterName,
AssetID = warning.AssetID,
EntityType = GeneratedEntityType(warning),
IsAcknowledged = warning.IsAcknowledged,
AcknowledgementNotes = warning.AcknowledgementNotes,
WarningKey = warning.WarningKey,
KnowledgeId = warning.KnowledgeId,
KnowledgeTitle = warning.KnowledgeTitle,
EstablishedSceneId = warning.EstablishedSceneId,
EstablishedSceneDisplayName = warning.EstablishedSceneDisplayName,
ReferencedSceneId = warning.ReferencedSceneId,
ReferencedSceneDisplayName = warning.ReferencedSceneDisplayName,
LastDetectedDate = DateTime.UtcNow
};
}
private static string WarningCategoryForPersisted(ContinuityWarning warning)
{
return warning.EntityType switch
{
"Character" => "Character",
"Asset" or "StoryAsset" => "Asset",
"Scene" => "Timeline",
"Relationship" => "Character",
"PlotThread" => "Plot Thread",
_ => "General"
};
}
private static string WarningCategoryForGenerated(ContinuityWarningViewModel warning)
{
if (string.Equals(warning.WarningTypeDisplayName, "Knowledge Chronology Violation", StringComparison.OrdinalIgnoreCase))
{
return "Knowledge";
}
return warning.WarningTypeDisplayName switch
{
"Multiple Character Locations" => "Character",
"Characters With Unknown Locations" => "Character",
"Asset Location Conflict" => "Asset",
"Assets With Unknown Locations" => "Asset",
_ => "Continuity"
};
}
private static string GeneratedEntityType(ContinuityWarningViewModel warning)
{
if (warning.CharacterID.HasValue)
{
return "Character";
}
if (warning.AssetID.HasValue)
{
return "Asset";
}
return "Continuity";
}
private static int WarningSeveritySort(string severity)
{
return severity switch
{
"Critical" => 0,
"Warning" => 1,
_ => 2
};
}
private static string SceneLabel(decimal sceneNumber, string sceneTitle)
{
return string.IsNullOrWhiteSpace(sceneTitle)
? $"Scene {sceneNumber:g}"
: $"Scene {sceneNumber:g}: {sceneTitle}";
}
public async Task<ContinuityValidationSummary> ValidateProjectAsync(int projectId)
{
var summary = await warnings.ValidateProjectAsync(projectId);
@ -4021,6 +4218,12 @@ public sealed class ContinuityValidationService(
public Task MarkIntentionalAsync(int warningId) => warnings.MarkIntentionalAsync(warningId);
public Task RestoreAsync(int warningId) => warnings.RestoreAsync(warningId);
public Task AcknowledgeGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement) =>
storyState.AcknowledgeWarningAsync(acknowledgement);
public Task RestoreGeneratedWarningAsync(ContinuityWarningAcknowledgement acknowledgement) =>
storyState.RestoreWarningAsync(acknowledgement);
}
public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMetricTypeRepository metricTypes) : ISceneMetricTypeService

View File

@ -1639,20 +1639,76 @@ public sealed class WarningDashboardViewModel
{
public Project Project { get; set; } = new();
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public int? WarningTypeID { get; set; }
public int? WarningSeverityID { get; set; }
public string? EntityType { get; set; }
public string? Category { get; set; }
public bool IncludeDismissed { get; set; }
public bool IncludeIntentional { get; set; }
public bool ShowAcknowledgedWarnings { get; set; }
public IReadOnlyList<ContinuityWarning> Warnings { get; set; } = [];
public IReadOnlyList<ProjectWarningViewModel> ProjectWarnings { get; set; } = [];
public IReadOnlyList<SelectListItem> BookOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> ChapterOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> WarningTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> SeverityOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> EntityTypeOptions { get; set; } = [];
public IReadOnlyList<SelectListItem> CategoryOptions { get; set; } = [];
public IReadOnlyDictionary<string, int> CountsBySeverity { get; set; } = new Dictionary<string, int>();
public IReadOnlyDictionary<string, int> CountsByType { get; set; } = new Dictionary<string, int>();
public ContinuityValidationSummary? LastValidationSummary { get; set; }
public bool HasAnyWarnings { get; set; }
}
public sealed class ProjectWarningViewModel
{
public string Source { get; set; } = "Persisted";
public int? ContinuityWarningID { get; set; }
public string WarningType { get; set; } = string.Empty;
public string WarningTypeDisplayName { get; set; } = string.Empty;
public string Category { get; set; } = "General";
public string Severity { get; set; } = "Information";
public string Message { get; set; } = string.Empty;
public string? Details { get; set; }
public int? BookID { get; set; }
public string BookDisplayName { get; set; } = string.Empty;
public int? ChapterID { get; set; }
public string ChapterDisplayName { get; set; } = string.Empty;
public int? SceneID { get; set; }
public string SceneDisplayName { get; set; } = string.Empty;
public int? CharacterID { get; set; }
public string CharacterName { get; set; } = string.Empty;
public int? AssetID { get; set; }
public string EntityType { get; set; } = string.Empty;
public bool IsDismissed { get; set; }
public bool IsIntentional { get; set; }
public bool IsAcknowledged { get; set; }
public string? AcknowledgementNotes { get; set; }
public string WarningKey { get; set; } = string.Empty;
public int? KnowledgeId { get; set; }
public string KnowledgeTitle { get; set; } = string.Empty;
public int? EstablishedSceneId { get; set; }
public string EstablishedSceneDisplayName { get; set; } = string.Empty;
public int? ReferencedSceneId { get; set; }
public string ReferencedSceneDisplayName { get; set; } = string.Empty;
public DateTime LastDetectedDate { get; set; }
}
public sealed class WarningDashboardReturnFilter
{
public int ProjectID { get; set; }
public int? BookID { get; set; }
public int? ChapterID { get; set; }
public int? SceneID { get; set; }
public int? WarningTypeID { get; set; }
public int? WarningSeverityID { get; set; }
public string? EntityType { get; set; }
public string? Category { get; set; }
public bool IncludeDismissed { get; set; }
public bool IncludeIntentional { get; set; }
public bool ShowAcknowledgedWarnings { get; set; }
}
public sealed class SceneDependencyCreateViewModel

View File

@ -151,20 +151,12 @@
<input class="form-check-input" type="radio" name="DisplayMode" value="LocationActivity" checked="@(Model.Filter.DisplayMode == "LocationActivity")" />
<span class="form-check-label">Location Activity</span>
</label>
<label class="form-check">
<input class="form-check-input" type="radio" name="DisplayMode" value="ContinuityWarnings" checked="@(Model.Filter.DisplayMode == "ContinuityWarnings")" />
<span class="form-check-label">Continuity Warnings</span>
</label>
</div>
</div>
<div class="col-lg-8">
<label class="form-label" for="SelectedSceneID">Story State scene</label>
<select class="form-select form-select-sm" id="SelectedSceneID" name="SelectedSceneID" asp-items="Model.SceneOptions"></select>
<label class="form-check mt-2">
<input class="form-check-input" type="checkbox" name="ShowAcknowledgedWarnings" value="true" checked="@Model.Filter.ShowAcknowledgedWarnings" />
<span class="form-check-label">Show acknowledged warnings</span>
</label>
</div>
</div>
@ -365,187 +357,6 @@ else if (Model.Filter.DisplayMode == "Journey")
}
</section>
}
else if (Model.Filter.DisplayMode == "ContinuityWarnings")
{
<section class="story-bible-section">
<h2>Continuity Warnings</h2>
@if (!Model.ContinuityWarnings.Any())
{
<p class="muted">No continuity concerns detected.</p>
<p class="muted">Continuity warnings highlight potential issues but cannot replace author judgement.</p>
}
else
{
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Severity</th>
<th>Status</th>
<th>Type</th>
<th>Character</th>
<th>Knowledge</th>
<th>Established</th>
<th>Earlier Reference</th>
<th>Message</th>
<th>Scene</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var warning in Model.ContinuityWarnings)
{
<tr>
<td><span class="badge @WarningBadgeClass(warning.Severity)">@warning.Severity</span></td>
<td>
@if (warning.IsAcknowledged)
{
<span class="badge bg-secondary">Acknowledged</span>
}
else
{
<span class="badge bg-warning text-dark">Active</span>
}
</td>
<td>@warning.WarningTypeDisplayName</td>
<td>
@if (!string.IsNullOrWhiteSpace(warning.CharacterName))
{
@warning.CharacterName
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (!string.IsNullOrWhiteSpace(warning.KnowledgeTitle))
{
@warning.KnowledgeTitle
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (warning.EstablishedSceneId.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@warning.EstablishedSceneDisplayName</a>
}
else
{
<span class="muted">-</span>
}
</td>
<td>
@if (warning.ReferencedSceneId.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@warning.ReferencedSceneDisplayName</a>
}
else
{
<span class="muted">-</span>
}
</td>
<td>
<div>@warning.Message</div>
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))
{
<small class="text-muted">Note: @warning.AcknowledgementNotes</small>
}
</td>
<td>
@if (warning.SceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.SceneDisplayName</a>
}
else
{
<span class="muted">Project-wide</span>
}
</td>
<td>
@if (warning.IsAcknowledged)
{
<form asp-action="RestoreWarning" method="post" class="d-inline">
<input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Filter.ProjectID" />
@foreach (var entityType in Model.Filter.EntityTypes)
{
<input type="hidden" name="ReturnFilter.EntityTypes" value="@entityType" />
}
@foreach (var characterId in Model.Filter.CharacterIDs)
{
<input type="hidden" name="ReturnFilter.CharacterIDs" value="@characterId" />
}
@foreach (var assetId in Model.Filter.AssetIDs)
{
<input type="hidden" name="ReturnFilter.AssetIDs" value="@assetId" />
}
@foreach (var locationId in Model.Filter.LocationIDs)
{
<input type="hidden" name="ReturnFilter.LocationIDs" value="@locationId" />
}
<input type="hidden" name="ReturnFilter.SceneRangeMode" value="@Model.Filter.SceneRangeMode" />
<input type="hidden" name="ReturnFilter.BookID" value="@Model.Filter.BookID" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.Filter.ChapterID" />
<input type="hidden" name="ReturnFilter.StartSceneID" value="@Model.Filter.StartSceneID" />
<input type="hidden" name="ReturnFilter.EndSceneID" value="@Model.Filter.EndSceneID" />
<input type="hidden" name="ReturnFilter.DisplayMode" value="@Model.Filter.DisplayMode" />
<input type="hidden" name="ReturnFilter.SelectedSceneID" value="@Model.Filter.SelectedSceneID" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.Filter.ShowAcknowledgedWarnings" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button>
</form>
}
else
{
<form asp-action="AcknowledgeWarning" method="post" class="d-flex gap-1 align-items-center">
<input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Filter.ProjectID" />
@foreach (var entityType in Model.Filter.EntityTypes)
{
<input type="hidden" name="ReturnFilter.EntityTypes" value="@entityType" />
}
@foreach (var characterId in Model.Filter.CharacterIDs)
{
<input type="hidden" name="ReturnFilter.CharacterIDs" value="@characterId" />
}
@foreach (var assetId in Model.Filter.AssetIDs)
{
<input type="hidden" name="ReturnFilter.AssetIDs" value="@assetId" />
}
@foreach (var locationId in Model.Filter.LocationIDs)
{
<input type="hidden" name="ReturnFilter.LocationIDs" value="@locationId" />
}
<input type="hidden" name="ReturnFilter.SceneRangeMode" value="@Model.Filter.SceneRangeMode" />
<input type="hidden" name="ReturnFilter.BookID" value="@Model.Filter.BookID" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.Filter.ChapterID" />
<input type="hidden" name="ReturnFilter.StartSceneID" value="@Model.Filter.StartSceneID" />
<input type="hidden" name="ReturnFilter.EndSceneID" value="@Model.Filter.EndSceneID" />
<input type="hidden" name="ReturnFilter.DisplayMode" value="@Model.Filter.DisplayMode" />
<input type="hidden" name="ReturnFilter.SelectedSceneID" value="@Model.Filter.SelectedSceneID" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.Filter.ShowAcknowledgedWarnings" />
<input class="form-control form-control-sm" type="text" name="Notes" maxlength="500" placeholder="Optional note" />
<button class="btn btn-outline-primary btn-sm" type="submit">Acknowledge</button>
</form>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
else
{
<section class="story-bible-section">

View File

@ -4,6 +4,15 @@
ViewData["ProjectSection"] = "Warnings";
}
@functions {
private static string SeverityBadgeClass(string severity) => severity switch
{
"Critical" => "bg-danger",
"Warning" => "bg-warning text-dark",
_ => "bg-info text-dark"
};
}
<nav class="breadcrumb-trail" aria-label="Breadcrumb">
<a asp-controller="Projects" asp-action="Index">Projects</a>
<a asp-controller="Projects" asp-action="Details" asp-route-id="@Model.Project.ProjectID">@Model.Project.ProjectName</a>
@ -53,6 +62,18 @@
<option value="">All books</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Chapter</label>
<select name="chapterId" asp-items="Model.ChapterOptions" class="form-select form-select-sm">
<option value="">All chapters</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label">Category</label>
<select name="category" asp-items="Model.CategoryOptions" class="form-select form-select-sm">
<option value="">All categories</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label">Type</label>
<select name="warningTypeId" asp-items="Model.WarningTypeOptions" class="form-select form-select-sm">
@ -80,6 +101,10 @@
<input class="form-check-input" name="includeIntentional" type="checkbox" value="true" checked="@Model.IncludeIntentional" />
<span class="form-check-label">Intentional</span>
</label>
<label class="form-check">
<input class="form-check-input" name="showAcknowledgedWarnings" type="checkbox" value="true" checked="@Model.ShowAcknowledgedWarnings" />
<span class="form-check-label">Acknowledged</span>
</label>
</div>
<div class="col-12">
<button class="btn btn-outline-primary btn-sm" type="submit">Filter warnings</button>
@ -112,80 +137,171 @@
<section class="list-section">
<h2>Warnings <help-icon key="warnings.list" /></h2>
@if (!Model.Warnings.Any())
@if (!Model.ProjectWarnings.Any())
{
<p class="muted">No warnings match this view.</p>
<p class="muted">@(Model.HasAnyWarnings ? "No warnings match the current filters." : "No project warnings detected.")</p>
}
else
{
<div class="warning-list">
@foreach (var warning in Model.Warnings)
{
<article class="warning-card">
<div>
<div class="warning-card-title">
<span class="severity-pill @warning.SeverityName.ToLowerInvariant()">@warning.SeverityName</span>
<strong>@warning.WarningTypeName</strong>
@if (warning.IsDismissed) { <span class="location-badge">dismissed</span> }
@if (warning.IsIntentional) { <span class="location-badge">intentional</span> }
</div>
<p>@warning.Message</p>
@if (!string.IsNullOrWhiteSpace(warning.Details))
{
<p class="muted">@warning.Details</p>
}
<p class="muted">
@(warning.BookTitle ?? "Project")
@if (warning.SceneID.HasValue)
{
<span>/ Scene @warning.SceneNumber: @warning.SceneTitle</span>
}
<span>/ @warning.EntityType</span>
<span>/ Last seen @warning.LastDetectedDate.ToString("dd MMM yyyy HH:mm")</span>
</p>
</div>
<div class="warning-actions">
@if (warning.SceneID.HasValue)
{
<a class="btn btn-outline-primary btn-sm"
asp-controller="Timeline"
asp-action="Index"
asp-route-ProjectID="@Model.Project.ProjectID"
asp-route-BookID="@warning.BookID"
asp-route-SelectedSceneID="@warning.SceneID"
asp-route-FocusType="scene"
asp-route-FocusID="@warning.SceneID">Open scene</a>
}
@if (warning.IsDismissed || warning.IsIntentional)
{
<form asp-action="Restore" method="post">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button>
</form>
}
else
{
<form asp-action="Dismiss" method="post">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Dismiss</button>
</form>
<form asp-action="MarkIntentional" method="post">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Intentional</button>
</form>
}
</div>
</article>
}
<div class="table-responsive">
<table class="table table-sm align-middle">
<thead>
<tr>
<th>Severity</th>
<th>Category</th>
<th>Warning Type</th>
<th>Message</th>
<th>Book</th>
<th>Chapter</th>
<th>Scene</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@foreach (var warning in Model.ProjectWarnings)
{
<tr>
<td><span class="badge @SeverityBadgeClass(warning.Severity)">@warning.Severity</span></td>
<td>@warning.Category</td>
<td>@warning.WarningTypeDisplayName</td>
<td>
<div>@warning.Message</div>
@if (!string.IsNullOrWhiteSpace(warning.Details))
{
<small class="text-muted d-block">@warning.Details</small>
}
@if (!string.IsNullOrWhiteSpace(warning.KnowledgeTitle))
{
<small class="text-muted d-block">Knowledge: @warning.KnowledgeTitle</small>
}
@if (warning.EstablishedSceneId.HasValue)
{
<small class="text-muted d-block">
Established:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.EstablishedSceneId">@warning.EstablishedSceneDisplayName</a>
</small>
}
@if (warning.ReferencedSceneId.HasValue)
{
<small class="text-muted d-block">
Earlier reference:
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.ReferencedSceneId">@warning.ReferencedSceneDisplayName</a>
</small>
}
@if (warning.IsAcknowledged && !string.IsNullOrWhiteSpace(warning.AcknowledgementNotes))
{
<small class="text-muted d-block">Note: @warning.AcknowledgementNotes</small>
}
</td>
<td>@(string.IsNullOrWhiteSpace(warning.BookDisplayName) ? "Project" : warning.BookDisplayName)</td>
<td>@(string.IsNullOrWhiteSpace(warning.ChapterDisplayName) ? "-" : warning.ChapterDisplayName)</td>
<td>
@if (warning.SceneID.HasValue)
{
<a asp-controller="Timeline" asp-action="Index" asp-route-projectId="@Model.Project.ProjectID" asp-route-selectedSceneId="@warning.SceneID">@warning.SceneDisplayName</a>
}
else
{
<span class="muted">Project-wide</span>
}
</td>
<td>
@if (warning.IsDismissed)
{
<span class="badge bg-secondary">Dismissed</span>
}
else if (warning.IsIntentional || warning.IsAcknowledged)
{
<span class="badge bg-secondary">Acknowledged</span>
}
else
{
<span class="badge bg-warning text-dark">Active</span>
}
</td>
<td>
@if (warning.Source == "Persisted" && warning.ContinuityWarningID.HasValue)
{
@if (warning.IsDismissed || warning.IsIntentional)
{
<form asp-action="Restore" method="post" class="d-inline">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button>
</form>
}
else
{
<form asp-action="Dismiss" method="post" class="d-inline">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Dismiss</button>
</form>
<form asp-action="MarkIntentional" method="post" class="d-inline">
<input type="hidden" name="id" value="@warning.ContinuityWarningID" />
<input type="hidden" name="projectId" value="@Model.Project.ProjectID" />
<input type="hidden" name="bookId" value="@Model.BookID" />
<input type="hidden" name="sceneId" value="@Model.SceneID" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Intentional</button>
</form>
}
}
else
{
@if (warning.IsAcknowledged)
{
<form asp-action="RestoreGeneratedWarning" method="post" class="d-inline">
<input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnFilter.BookID" value="@Model.BookID" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.ChapterID" />
<input type="hidden" name="ReturnFilter.SceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnFilter.WarningTypeID" value="@Model.WarningTypeID" />
<input type="hidden" name="ReturnFilter.WarningSeverityID" value="@Model.WarningSeverityID" />
<input type="hidden" name="ReturnFilter.EntityType" value="@Model.EntityType" />
<input type="hidden" name="ReturnFilter.Category" value="@Model.Category" />
<input type="hidden" name="ReturnFilter.IncludeDismissed" value="@Model.IncludeDismissed" />
<input type="hidden" name="ReturnFilter.IncludeIntentional" value="@Model.IncludeIntentional" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.ShowAcknowledgedWarnings" />
<button class="btn btn-outline-secondary btn-sm" type="submit">Restore</button>
</form>
}
else
{
<form asp-action="AcknowledgeGeneratedWarning" method="post" class="d-flex gap-1 align-items-center">
<input type="hidden" name="WarningType" value="@warning.WarningType" />
<input type="hidden" name="SceneID" value="@warning.SceneID" />
<input type="hidden" name="CharacterID" value="@warning.CharacterID" />
<input type="hidden" name="AssetID" value="@warning.AssetID" />
<input type="hidden" name="ReturnFilter.ProjectID" value="@Model.Project.ProjectID" />
<input type="hidden" name="ReturnFilter.BookID" value="@Model.BookID" />
<input type="hidden" name="ReturnFilter.ChapterID" value="@Model.ChapterID" />
<input type="hidden" name="ReturnFilter.SceneID" value="@Model.SceneID" />
<input type="hidden" name="ReturnFilter.WarningTypeID" value="@Model.WarningTypeID" />
<input type="hidden" name="ReturnFilter.WarningSeverityID" value="@Model.WarningSeverityID" />
<input type="hidden" name="ReturnFilter.EntityType" value="@Model.EntityType" />
<input type="hidden" name="ReturnFilter.Category" value="@Model.Category" />
<input type="hidden" name="ReturnFilter.IncludeDismissed" value="@Model.IncludeDismissed" />
<input type="hidden" name="ReturnFilter.IncludeIntentional" value="@Model.IncludeIntentional" />
<input type="hidden" name="ReturnFilter.ShowAcknowledgedWarnings" value="@Model.ShowAcknowledgedWarnings" />
<input class="form-control form-control-sm" type="text" name="Notes" maxlength="500" placeholder="Optional note" />
<button class="btn btn-outline-primary btn-sm" type="submit">Acknowledge</button>
</form>
}
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>