337 lines
21 KiB
Plaintext
337 lines
21 KiB
Plaintext
@model ManuscriptScanReviewViewModel
|
|
@{
|
|
ViewData["Title"] = "Review manuscript scan";
|
|
var alreadyMatchedCharacters = Model.CharacterCandidates
|
|
.Where(candidate => candidate.IsExistingCharacterMatch)
|
|
.ToList();
|
|
var probableCharacters = Model.CharacterCandidates
|
|
.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var possibleCharacters = Model.CharacterCandidates
|
|
.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var relationshipTitles = Model.CharacterCandidates
|
|
.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase))
|
|
.ToList();
|
|
var primaryCharacters = probableCharacters
|
|
.Concat(possibleCharacters)
|
|
.Concat(relationshipTitles)
|
|
.Concat(alreadyMatchedCharacters)
|
|
.OrderBy(candidate => candidate.ReviewName)
|
|
.ThenBy(candidate => candidate.Name)
|
|
.ToList();
|
|
var detectedAliasTargets = primaryCharacters
|
|
.OrderBy(candidate => candidate.ReviewName)
|
|
.ToList();
|
|
var visibleCharacterCount = probableCharacters.Count + possibleCharacters.Count + relationshipTitles.Count + alreadyMatchedCharacters.Count;
|
|
var isCharacterStep = string.Equals(ViewData["ScanReviewStep"] as string, "characters", StringComparison.OrdinalIgnoreCase);
|
|
const int characterPageSize = 10;
|
|
var primaryCharacterPageCount = Math.Max(1, (int)Math.Ceiling(primaryCharacters.Count / (double)characterPageSize));
|
|
var characterReviewPageCount = primaryCharacterPageCount;
|
|
var requestedCharacterPage = ViewData["CharacterReviewPage"] is int requestedPage ? requestedPage : 1;
|
|
var characterPage = Math.Clamp(requestedCharacterPage, 1, characterReviewPageCount);
|
|
var currentCharacters = primaryCharacters.Skip((characterPage - 1) * characterPageSize).Take(characterPageSize).ToList();
|
|
var currentStep = isCharacterStep ? 9 : 7;
|
|
const int totalSteps = 10;
|
|
var characterPageTitle = $"Review Characters {characterPage} of {primaryCharacterPageCount}";
|
|
var duplicateCanonicalNames = primaryCharacters
|
|
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
|
|
.Select(candidate => candidate.ReviewName.Trim())
|
|
.Where(name => !string.IsNullOrWhiteSpace(name))
|
|
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
|
|
.Where(group => group.Count() > 1)
|
|
.Select(group => group.Key)
|
|
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
|
var includedPrimaryKeys = primaryCharacters
|
|
.Where(candidate => candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
|
|
.Select(candidate => candidate.TemporaryCharacterKey)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
var invalidCharacterKeys = primaryCharacters
|
|
.Where(candidate =>
|
|
(candidate.Include && string.IsNullOrWhiteSpace(candidate.ReviewName))
|
|
|| (candidate.Include && string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey) && duplicateCanonicalNames.Contains(candidate.ReviewName.Trim()))
|
|
|| (candidate.Include
|
|
&& !string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey)
|
|
&& !includedPrimaryKeys.Contains(candidate.AliasTargetTemporaryCharacterKey)))
|
|
.Select(candidate => candidate.TemporaryCharacterKey)
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
}
|
|
|
|
<section class="onboarding-shell" aria-labelledby="scan-review-title">
|
|
<form class="onboarding-panel onboarding-review-panel" asp-controller="Onboarding" asp-action="SaveScanReview" method="post">
|
|
@Html.AntiForgeryToken()
|
|
<input type="hidden" asp-for="PreviewID" name="PreviewID" />
|
|
<input type="hidden" name="characterPage" value="@characterPage" />
|
|
<partial name="_OnboardingJourneyHeader" model="@(new OnboardingJourneyHeaderViewModel { CurrentStep = currentStep, TotalSteps = totalSteps })" />
|
|
|
|
<div class="onboarding-copy">
|
|
<p class="eyebrow">Step @currentStep of @totalSteps</p>
|
|
<h1 id="scan-review-title">@(isCharacterStep ? "Review Characters" : "Review Chapters")</h1>
|
|
<p>@(isCharacterStep ? "Review this page of detected names and aliases, then continue through the character section." : "Confirm which chapters should be imported before PlotDirector analyses their scene structure.")</p>
|
|
</div>
|
|
|
|
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
|
|
{
|
|
<div class="alert alert-success">@reviewMessage</div>
|
|
}
|
|
@if (!ViewData.ModelState.IsValid)
|
|
{
|
|
<div class="alert alert-danger" role="alert">
|
|
<strong>Review this before continuing.</strong>
|
|
<div asp-validation-summary="ModelOnly"></div>
|
|
</div>
|
|
}
|
|
|
|
<div class="onboarding-scan-counts onboarding-review-counts">
|
|
<div>
|
|
<span>Chapters</span>
|
|
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
|
|
</div>
|
|
<div>
|
|
<span>Characters</span>
|
|
<strong>@Model.SelectedCharacterCount / @visibleCharacterCount</strong>
|
|
</div>
|
|
<div>
|
|
<span>Words</span>
|
|
<strong>@Model.SelectedWordCount.ToString("N0")</strong>
|
|
</div>
|
|
</div>
|
|
|
|
@if (!isCharacterStep)
|
|
{
|
|
<div class="onboarding-review-tools" data-review-tools>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="true">Include all chapters</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="chapter" data-bulk-state="false">Exclude all chapters</button>
|
|
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
|
|
</div>
|
|
}
|
|
|
|
<section class="onboarding-review-workflow" aria-label="Scan preview">
|
|
<div class="onboarding-review-section" data-scan-review-panel="structure" hidden="@isCharacterStep">
|
|
<h2>Chapters and scenes</h2>
|
|
@if (!Model.Chapters.Any())
|
|
{
|
|
<p>No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.</p>
|
|
}
|
|
else
|
|
{
|
|
<div class="onboarding-review-chapters">
|
|
@{
|
|
var chapterIndex = 0;
|
|
}
|
|
@foreach (var chapter in Model.Chapters)
|
|
{
|
|
<details class="onboarding-review-chapter">
|
|
<summary>
|
|
<span>Chapter @chapter.ChapterNumber</span>
|
|
<strong>@chapter.ReviewTitle</strong>
|
|
<small>@chapter.WordCount.ToString("N0") words</small>
|
|
</summary>
|
|
<input type="hidden" name="Chapters[@chapterIndex].TemporaryChapterKey" value="@chapter.TemporaryChapterKey" />
|
|
<input type="hidden" name="Chapters[@chapterIndex].ChapterNumber" value="@chapter.ChapterNumber" />
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" />
|
|
<input type="hidden" name="Chapters[@chapterIndex].Include" value="false" />
|
|
Include chapter
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Chapter title
|
|
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
|
|
</label>
|
|
</details>
|
|
chapterIndex++;
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
|
|
<aside class="onboarding-review-section" data-scan-review-panel="characters" hidden="@(!isCharacterStep)">
|
|
<div class="onboarding-subprogress" aria-label="Character review progress">
|
|
<div>
|
|
<span>Character page @characterPage of @primaryCharacterPageCount</span>
|
|
<strong>@Math.Min(characterPage * characterPageSize, primaryCharacters.Count).ToString("N0") / @primaryCharacters.Count.ToString("N0") reviewed</strong>
|
|
</div>
|
|
<div class="onboarding-progress-track">
|
|
<span style="width:@((characterPage * 100) / primaryCharacterPageCount)%"></span>
|
|
</div>
|
|
</div>
|
|
<div class="onboarding-review-section-heading">
|
|
<div>
|
|
<h2>@characterPageTitle</h2>
|
|
<p>
|
|
Character candidates page @characterPage of @primaryCharacterPageCount.
|
|
</p>
|
|
</div>
|
|
<strong>@currentCharacters.Count character@(currentCharacters.Count == 1 ? string.Empty : "s") on this step</strong>
|
|
</div>
|
|
|
|
<div class="onboarding-character-step-summary">
|
|
<div>
|
|
<span>Detected names</span>
|
|
<strong>@visibleCharacterCount.ToString("N0")</strong>
|
|
</div>
|
|
<div>
|
|
<span>Wizard pages</span>
|
|
<strong>@characterReviewPageCount.ToString("N0")</strong>
|
|
</div>
|
|
</div>
|
|
|
|
@{
|
|
var allCharacters = primaryCharacters.ToList();
|
|
}
|
|
@if (!currentCharacters.Any())
|
|
{
|
|
<p class="onboarding-empty-state">No character candidates were found for this step.</p>
|
|
}
|
|
@if (invalidCharacterKeys.Any(key => currentCharacters.Any(candidate => string.Equals(candidate.TemporaryCharacterKey, key, StringComparison.Ordinal))))
|
|
{
|
|
<p class="onboarding-empty-state onboarding-empty-state--error">The item that needs attention is highlighted below.</p>
|
|
}
|
|
<div class="onboarding-character-groups">
|
|
@foreach (var group in new[]
|
|
{
|
|
new { Title = "Already matched known characters", Candidates = currentCharacters.Where(candidate => candidate.IsExistingCharacterMatch).ToList() },
|
|
new { Title = "Likely new characters", Candidates = currentCharacters.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
|
|
new { Title = "Possible new names or aliases", Candidates = currentCharacters.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "PossibleCharacter", StringComparison.OrdinalIgnoreCase)).ToList() },
|
|
new { Title = "Generic or ambiguous references", Candidates = currentCharacters.Where(candidate => !candidate.IsExistingCharacterMatch && string.Equals(candidate.Category, "RelationshipTitle", StringComparison.OrdinalIgnoreCase)).ToList() }
|
|
})
|
|
{
|
|
if (!group.Candidates.Any())
|
|
{
|
|
continue;
|
|
}
|
|
|
|
<section>
|
|
<h3>@group.Title</h3>
|
|
<ul class="onboarding-character-candidates">
|
|
@foreach (var candidate in group.Candidates)
|
|
{
|
|
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
|
<li class="@(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
|
|
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
|
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
|
|
<label class="onboarding-review-toggle">
|
|
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
|
|
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
|
|
Include
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Name
|
|
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Existing character
|
|
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
|
|
<option value="">Create later if imported</option>
|
|
@foreach (var option in Model.ExistingCharacterOptions)
|
|
{
|
|
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Alias of detected character
|
|
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
|
|
<option value="">Not an alias</option>
|
|
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
|
{
|
|
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
|
|
}
|
|
</select>
|
|
</label>
|
|
<label class="onboarding-review-field">
|
|
Sex
|
|
<select class="form-select" name="Characters[@currentCharacterIndex].SexHint">
|
|
<option value="Unknown" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal))">Unknown</option>
|
|
<option value="Female" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Female, StringComparison.Ordinal))">Female</option>
|
|
<option value="Male" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Male, StringComparison.Ordinal))">Male</option>
|
|
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
|
|
</select>
|
|
</label>
|
|
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
|
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
|
{
|
|
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
|
|
{
|
|
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
|
{
|
|
<small>@candidate.Reason</small>
|
|
}
|
|
</li>
|
|
}
|
|
</ul>
|
|
</section>
|
|
}
|
|
</div>
|
|
@foreach (var candidate in allCharacters.Where(candidate => !currentCharacters.Any(current => string.Equals(current.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal))))
|
|
{
|
|
var hiddenCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
|
<div hidden>
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].Category" value="@candidate.Category" />
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].Include" value="@candidate.Include.ToString().ToLowerInvariant()" />
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].Name" value="@candidate.ReviewName" />
|
|
@if (candidate.ExistingCharacterID.HasValue)
|
|
{
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].ExistingCharacterID" value="@candidate.ExistingCharacterID" />
|
|
}
|
|
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetTemporaryCharacterKey))
|
|
{
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].AliasTargetTemporaryCharacterKey" value="@candidate.AliasTargetTemporaryCharacterKey" />
|
|
}
|
|
<input type="hidden" name="Characters[@hiddenCharacterIndex].SexHint" value="@candidate.SexHint" />
|
|
</div>
|
|
}
|
|
</aside>
|
|
</section>
|
|
|
|
<div class="onboarding-actions">
|
|
@if (isCharacterStep)
|
|
{
|
|
@if (characterPage <= 1)
|
|
{
|
|
<a class="btn btn-outline-secondary" asp-action="ScanReview" asp-route-previewId="@Model.PreviewID">Back to chapters</a>
|
|
}
|
|
else
|
|
{
|
|
<button class="btn btn-outline-secondary" type="submit" name="intent" value="character-back">Back</button>
|
|
}
|
|
|
|
@if (characterPage < characterReviewPageCount)
|
|
{
|
|
<button class="btn btn-primary" type="submit" name="intent" value="character-next">Save and continue</button>
|
|
}
|
|
else
|
|
{
|
|
<button class="btn btn-primary" type="submit" name="intent" value="continue">Complete core import</button>
|
|
}
|
|
}
|
|
else
|
|
{
|
|
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
|
|
<button class="btn btn-primary" type="submit" name="intent" value="structure">Continue to Scene Detection</button>
|
|
}
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
<script>
|
|
const reviewTools = document.querySelector("[data-review-tools]");
|
|
reviewTools?.addEventListener("click", (event) => {
|
|
const button = event.target.closest("[data-bulk-target]");
|
|
if (!button) {
|
|
return;
|
|
}
|
|
|
|
const target = button.dataset.bulkTarget;
|
|
const checked = button.dataset.bulkState === "true";
|
|
document.querySelectorAll(`[data-review-kind="${target}"]`).forEach((checkbox) => {
|
|
checkbox.checked = checked;
|
|
});
|
|
});
|
|
</script>
|