Phase 20E Build the review and edit stage for the scan preview.

This commit is contained in:
Nick Beckley 2026-07-03 23:12:15 +01:00
parent 5ee9ffbdd1
commit f78e1ac8fc
8 changed files with 699 additions and 94 deletions

View File

@ -24,6 +24,39 @@ public sealed class OnboardingController(IOnboardingService onboarding) : Contro
return model is null ? NotFound() : View(model);
}
[HttpPost("scan-review")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> SaveScanReview(ManuscriptScanReviewForm form, string intent = "save")
{
var readyToImport = string.Equals(intent, "continue", StringComparison.OrdinalIgnoreCase);
try
{
var model = await onboarding.SaveScanReviewAsync(form, readyToImport);
if (model is null)
{
return NotFound();
}
TempData["OnboardingReviewMessage"] = readyToImport
? "Review saved. Next: import into PlotDirector."
: "Review choices saved.";
return readyToImport
? RedirectToAction(nameof(Index))
: RedirectToAction(nameof(ScanReview), new { previewId = form.PreviewID });
}
catch (InvalidOperationException ex)
{
ModelState.AddModelError(string.Empty, ex.Message);
var model = await onboarding.GetScanReviewAsync(form.PreviewID);
if (model is null)
{
return NotFound();
}
return View("ScanReview", model);
}
}
[HttpPost("welcome")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Welcome()

View File

@ -13,6 +13,14 @@ public static class ManuscriptScanStatuses
public const string Failed = "Failed";
}
public static class ManuscriptScanReviewStatuses
{
public const string ScanComplete = "ScanComplete";
public const string ReviewInProgress = "ReviewInProgress";
public const string ReviewComplete = "ReviewComplete";
public const string ReadyToImport = "ReadyToImport";
}
public sealed class ManuscriptScanCommand
{
public int UserID { get; init; }
@ -98,6 +106,7 @@ public sealed class ManuscriptScanCharacterCandidatePreview
public sealed class ManuscriptScanState
{
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
public string Message { get; init; } = string.Empty;
public int? PercentComplete { get; init; }
public int ChapterCount { get; init; }
@ -107,3 +116,40 @@ public sealed class ManuscriptScanState
public Guid? PreviewID { get; init; }
public DateTime? UpdatedUtc { get; init; }
}
public sealed class ManuscriptScanReviewDecision
{
public Guid PreviewID { get; init; }
public string Status { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
public DateTime UpdatedUtc { get; init; } = DateTime.UtcNow;
public IReadOnlyList<ManuscriptScanChapterReviewDecision> Chapters { get; init; } = [];
public IReadOnlyList<ManuscriptScanSceneReviewDecision> Scenes { get; init; } = [];
public IReadOnlyList<ManuscriptScanCharacterReviewDecision> Characters { get; init; } = [];
}
public sealed class ManuscriptScanChapterReviewDecision
{
public string TemporaryChapterKey { get; init; } = string.Empty;
public bool Include { get; init; }
public string Title { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
}
public sealed class ManuscriptScanSceneReviewDecision
{
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public bool Include { get; init; }
public string? Title { get; init; }
public int SceneNumberWithinChapter { get; init; }
public bool AllowZeroWords { get; init; }
}
public sealed class ManuscriptScanCharacterReviewDecision
{
public string TemporaryCharacterKey { get; init; } = string.Empty;
public bool Include { get; init; }
public string Name { get; init; } = string.Empty;
public string Category { get; init; } = "PossibleCharacter";
public int? ExistingCharacterID { get; init; }
}

View File

@ -13,12 +13,15 @@ public interface IManuscriptScanPreviewStore
Task<ManuscriptScanState> GetStateAsync(int userId, int onboardingId);
Task<ManuscriptScanPreview?> GetLatestPreviewAsync(int userId, int onboardingId);
Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId);
Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review);
}
public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
{
private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanPreview> previews = new();
private readonly ConcurrentDictionary<Guid, ManuscriptScanReviewDecision> reviews = new();
public Task<ManuscriptScanState> StartAsync(int userId, int onboardingId, int projectId, int bookId)
{
@ -71,6 +74,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
});
session.Status = ManuscriptScanStatuses.Complete;
session.ReviewStatus = ManuscriptScanReviewStatuses.ScanComplete;
session.Message = "Scan complete";
session.PercentComplete = 100;
session.ChapterCount = stored.ChapterCount;
@ -132,10 +136,48 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
public Task<ManuscriptScanPreview?> GetPreviewAsync(int userId, Guid previewId)
=> Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null);
public Task<ManuscriptScanReviewDecision?> GetReviewAsync(int userId, Guid previewId)
{
if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
}
var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview));
return Task.FromResult<ManuscriptScanReviewDecision?>(review);
}
public Task<ManuscriptScanReviewDecision?> SaveReviewAsync(int userId, ManuscriptScanReviewDecision review)
{
if (!previews.TryGetValue(review.PreviewID, out var preview) || preview.UserID != userId)
{
return Task.FromResult<ManuscriptScanReviewDecision?>(null);
}
var stored = new ManuscriptScanReviewDecision
{
PreviewID = review.PreviewID,
Status = review.Status,
UpdatedUtc = DateTime.UtcNow,
Chapters = review.Chapters,
Scenes = review.Scenes,
Characters = review.Characters
};
reviews[review.PreviewID] = stored;
if (sessions.TryGetValue((userId, preview.OnboardingID), out var session))
{
session.ReviewStatus = stored.Status;
session.UpdatedUtc = stored.UpdatedUtc;
}
return Task.FromResult<ManuscriptScanReviewDecision?>(stored);
}
private static ManuscriptScanState ToState(ScanSession session)
=> new()
{
Status = session.Status,
ReviewStatus = session.ReviewStatus,
Message = session.Message,
PercentComplete = session.PercentComplete,
ChapterCount = session.ChapterCount,
@ -152,6 +194,44 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
return string.IsNullOrWhiteSpace(cleaned) ? fallback : cleaned;
}
private static ManuscriptScanReviewDecision BuildDefaultReview(ManuscriptScanPreview preview)
=> new()
{
PreviewID = preview.PreviewID,
Status = ManuscriptScanReviewStatuses.ScanComplete,
Chapters = preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter => new ManuscriptScanChapterReviewDecision
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
Include = !string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0,
Title = chapter.Title,
ChapterNumber = chapter.ChapterNumber
})
.ToList(),
Scenes = preview.Scenes
.OrderBy(scene => scene.TemporaryChapterKey)
.ThenBy(scene => scene.SceneNumberWithinChapter)
.Select(scene => new ManuscriptScanSceneReviewDecision
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
Include = true,
Title = string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title,
SceneNumberWithinChapter = scene.SceneNumberWithinChapter
})
.ToList(),
Characters = preview.CharacterCandidates
.Select(candidate => new ManuscriptScanCharacterReviewDecision
{
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
Include = string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
Name = candidate.Name,
Category = candidate.Category
})
.ToList()
};
private sealed class ScanSession
{
public int UserID { get; init; }
@ -159,6 +239,7 @@ public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore
public int ProjectID { get; init; }
public int BookID { get; init; }
public string Status { get; set; } = ManuscriptScanStatuses.NotStarted;
public string ReviewStatus { get; set; } = ManuscriptScanReviewStatuses.ScanComplete;
public string Message { get; set; } = string.Empty;
public int? PercentComplete { get; set; }
public int ChapterCount { get; set; }

View File

@ -9,6 +9,7 @@ public interface IOnboardingService
Task<UserOnboardingState> GetOrCreateAsync();
Task<OnboardingWizardViewModel> GetWizardAsync();
Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null);
Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport);
Task<bool> ShouldShowOnboardingAsync();
Task<OnboardingNudgeViewModel> GetDashboardNudgeAsync();
Task<bool> ShouldShowWordCompanionHeaderAsync();
@ -29,6 +30,7 @@ public sealed class OnboardingService(
IProjectActivityService activity,
IWordCompanionPresenceService companionPresence,
IManuscriptScanPreviewStore scanPreviews,
ICharacterRepository characters,
ICurrentUserService currentUser) : IOnboardingService
{
private static readonly HashSet<string> WritingJourneys = new(StringComparer.Ordinal)
@ -102,6 +104,87 @@ public sealed class OnboardingService(
}
public async Task<ManuscriptScanReviewViewModel?> GetScanReviewAsync(Guid? previewId = null)
{
var context = await GetScanReviewContextAsync(previewId);
return context is null ? null : BuildScanReviewViewModel(context.Value);
}
public async Task<ManuscriptScanReviewViewModel?> SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport)
{
var context = await GetScanReviewContextAsync(form.PreviewID);
if (context is null)
{
return null;
}
var (userId, preview, _, project, book, existingCharacters) = context.Value;
var chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, StringComparer.Ordinal);
var existingCharacterIds = existingCharacters.Select(character => character.CharacterID).ToHashSet();
var chapterDecisions = form.Chapters
.Where(item => chapterLookup.ContainsKey(item.TemporaryChapterKey))
.Select(item => new ManuscriptScanChapterReviewDecision
{
TemporaryChapterKey = item.TemporaryChapterKey,
Include = item.Include,
Title = Clean(item.Title),
ChapterNumber = item.ChapterNumber
})
.OrderBy(item => item.ChapterNumber)
.ToList();
var includedChapterKeys = chapterDecisions.Where(item => item.Include).Select(item => item.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
var sceneDecisions = form.Scenes
.Where(item => sceneLookup.ContainsKey(item.TemporarySceneKey))
.Select(item => new ManuscriptScanSceneReviewDecision
{
TemporarySceneKey = item.TemporarySceneKey,
TemporaryChapterKey = item.TemporaryChapterKey,
Include = item.Include && includedChapterKeys.Contains(item.TemporaryChapterKey),
Title = CleanOptional(item.Title),
SceneNumberWithinChapter = item.SceneNumberWithinChapter,
AllowZeroWords = item.AllowZeroWords
})
.OrderBy(item => item.TemporaryChapterKey)
.ThenBy(item => item.SceneNumberWithinChapter)
.ToList();
var characterDecisions = form.Characters
.Where(item => candidateLookup.ContainsKey(item.TemporaryCharacterKey))
.Select(item =>
{
var cleanName = Clean(item.Name);
var matchedExistingId = item.ExistingCharacterID.HasValue && existingCharacterIds.Contains(item.ExistingCharacterID.Value)
? item.ExistingCharacterID
: MatchExistingCharacter(cleanName, existingCharacters)?.CharacterID;
return new ManuscriptScanCharacterReviewDecision
{
TemporaryCharacterKey = item.TemporaryCharacterKey,
Include = item.Include,
Name = cleanName,
Category = item.Category,
ExistingCharacterID = matchedExistingId
};
})
.ToList();
var review = new ManuscriptScanReviewDecision
{
PreviewID = preview.PreviewID,
Status = readyToImport ? ManuscriptScanReviewStatuses.ReadyToImport : ManuscriptScanReviewStatuses.ReviewInProgress,
Chapters = chapterDecisions,
Scenes = sceneDecisions,
Characters = characterDecisions
};
ValidateScanReview(review, preview);
var saved = await scanPreviews.SaveReviewAsync(userId, review);
return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters));
}
private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId)
{
var userId = RequireUserId();
var state = await onboarding.GetAsync(userId);
@ -123,14 +206,111 @@ public sealed class OnboardingService(
var project = await projects.GetForUserAsync(preview.ProjectID, userId);
var book = await books.GetAsync(preview.BookID);
if (project is null || book is null || book.ProjectID != project.ProjectID)
if (project is null || !project.IsOwner || project.IsArchived || book is null || book.ProjectID != project.ProjectID || book.IsArchived)
{
return null;
}
var review = await scanPreviews.GetReviewAsync(userId, preview.PreviewID);
if (review is null)
{
return null;
}
var existingCharacters = await characters.ListCharactersAsync(project.ProjectID);
return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList());
}
private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters) context)
{
var (userId, preview, review, project, book, existingCharacters) = context;
_ = userId;
var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal);
var characterReview = review.Characters.ToDictionary(item => item.TemporaryCharacterKey, StringComparer.Ordinal);
var existingOptions = existingCharacters
.OrderBy(character => character.CharacterName)
.Select(character => new ManuscriptScanExistingCharacterOptionViewModel
{
CharacterID = character.CharacterID,
CharacterName = character.CharacterName
})
.ToList();
var chapters = preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter =>
{
chapterReview.TryGetValue(chapter.TemporaryChapterKey, out var decision);
var scenes = preview.Scenes
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
.OrderBy(scene => scene.SceneNumberWithinChapter)
.Select(scene =>
{
sceneReview.TryGetValue(scene.TemporarySceneKey, out var sceneDecision);
return new ManuscriptScanReviewSceneViewModel
{
TemporarySceneKey = scene.TemporarySceneKey,
TemporaryChapterKey = scene.TemporaryChapterKey,
SceneNumberWithinChapter = scene.SceneNumberWithinChapter,
Title = scene.Title,
ReviewTitle = sceneDecision?.Title ?? (string.IsNullOrWhiteSpace(scene.Title) ? $"Scene {scene.SceneNumberWithinChapter}" : scene.Title),
Include = sceneDecision?.Include ?? true,
WordCount = scene.WordCount,
OpeningTextPreview = scene.OpeningTextPreview
};
})
.ToList();
return new ManuscriptScanReviewChapterViewModel
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
ChapterNumber = chapter.ChapterNumber,
Title = chapter.Title,
ReviewTitle = decision?.Title ?? chapter.Title,
Include = decision?.Include ?? (!string.IsNullOrWhiteSpace(chapter.Title) && chapter.WordCount > 0),
WordCount = chapter.WordCount,
Scenes = scenes
};
})
.ToList();
var characters = preview.CharacterCandidates
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
.ThenByDescending(candidate => candidate.QualityScore)
.ThenByDescending(candidate => candidate.MentionCount)
.ThenBy(candidate => candidate.Name)
.Select(candidate =>
{
characterReview.TryGetValue(candidate.TemporaryCharacterKey, out var decision);
var reviewName = decision?.Name ?? candidate.Name;
var existing = decision?.ExistingCharacterID.HasValue == true
? existingCharacters.FirstOrDefault(character => character.CharacterID == decision.ExistingCharacterID.Value)
: MatchExistingCharacter(reviewName, existingCharacters);
return new ManuscriptScanReviewCharacterViewModel
{
TemporaryCharacterKey = candidate.TemporaryCharacterKey,
Name = candidate.Name,
ReviewName = reviewName,
Include = decision?.Include ?? string.Equals(candidate.Category, "ProbableCharacter", StringComparison.OrdinalIgnoreCase),
MentionCount = candidate.MentionCount,
QualityScore = candidate.QualityScore,
Category = candidate.Category,
Reason = candidate.Reason,
ExistingCharacterID = existing?.CharacterID,
ExistingCharacterName = existing?.CharacterName
};
})
.ToList();
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
var selectedScenes = chapters.SelectMany(chapter => chapter.Scenes).Where(scene => scene.Include && selectedChapterKeys.Contains(scene.TemporaryChapterKey)).ToList();
return new ManuscriptScanReviewViewModel
{
PreviewID = preview.PreviewID,
ReviewStatus = review.Status,
DocumentTitle = string.IsNullOrWhiteSpace(preview.DocumentTitle) ? "Word manuscript" : preview.DocumentTitle!,
SelectedProjectName = project.ProjectName,
SelectedBookTitle = BookOptionTitle(book),
@ -138,29 +318,83 @@ public sealed class OnboardingService(
ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount,
Chapters = preview.Chapters
.OrderBy(chapter => chapter.ChapterNumber)
.Select(chapter => new ManuscriptScanReviewChapterViewModel
{
TemporaryChapterKey = chapter.TemporaryChapterKey,
ChapterNumber = chapter.ChapterNumber,
Title = chapter.Title,
WordCount = chapter.WordCount,
Scenes = preview.Scenes
.Where(scene => string.Equals(scene.TemporaryChapterKey, chapter.TemporaryChapterKey, StringComparison.Ordinal))
.OrderBy(scene => scene.SceneNumberWithinChapter)
.ToList()
})
.ToList(),
CharacterCandidates = preview.CharacterCandidates
.OrderByDescending(candidate => CandidateCategorySortValue(candidate.Category))
.ThenByDescending(candidate => candidate.QualityScore)
.ThenByDescending(candidate => candidate.MentionCount)
.ThenBy(candidate => candidate.Name)
.ToList()
SelectedChapterCount = selectedChapterKeys.Count,
SelectedSceneCount = selectedScenes.Count,
SelectedCharacterCount = characters.Count(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
SelectedWordCount = selectedScenes.Sum(scene => scene.WordCount),
Chapters = chapters,
CharacterCandidates = characters,
ExistingCharacterOptions = existingOptions
};
}
private static void ValidateScanReview(ManuscriptScanReviewDecision review, ManuscriptScanPreview preview)
{
var previewChapters = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
var previewScenes = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
var includedChapterKeys = review.Chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal);
if (review.Chapters.Where(chapter => chapter.Include).Any(chapter => string.IsNullOrWhiteSpace(chapter.Title)))
{
throw new InvalidOperationException("Every included chapter needs a title.");
}
if (review.Chapters.Select(chapter => chapter.ChapterNumber).Distinct().Count() != review.Chapters.Count)
{
throw new InvalidOperationException("Chapter order is not valid. Restore the scan defaults and try again.");
}
foreach (var scene in review.Scenes.Where(scene => scene.Include))
{
if (!includedChapterKeys.Contains(scene.TemporaryChapterKey))
{
throw new InvalidOperationException("Included scenes must belong to included chapters.");
}
if (!previewScenes.TryGetValue(scene.TemporarySceneKey, out var previewScene))
{
throw new InvalidOperationException("One of the selected scenes no longer exists in the scan preview.");
}
if (previewScene.WordCount <= 0 && !scene.AllowZeroWords)
{
throw new InvalidOperationException("Included scenes with no words must be explicitly allowed.");
}
}
foreach (var chapter in review.Chapters)
{
if (!previewChapters.ContainsKey(chapter.TemporaryChapterKey))
{
throw new InvalidOperationException("One of the selected chapters no longer exists in the scan preview.");
}
}
var duplicateName = review.Characters
.Where(character => character.Include && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase))
.Select(character => Clean(character.Name))
.Where(name => !string.IsNullOrWhiteSpace(name))
.GroupBy(name => name, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault(group => group.Count() > 1);
if (duplicateName is not null)
{
throw new InvalidOperationException($"The character name \"{duplicateName.Key}\" appears more than once. Exclude or rename one before continuing.");
}
if (review.Characters.Any(character => character.Include && string.IsNullOrWhiteSpace(character.Name)))
{
throw new InvalidOperationException("Every included character needs a display name.");
}
}
private static Character? MatchExistingCharacter(string? name, IReadOnlyList<Character> existingCharacters)
{
var clean = Clean(name);
return string.IsNullOrWhiteSpace(clean)
? null
: existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase));
}
public async Task<bool> ShouldShowOnboardingAsync()
{
if (!currentUser.UserId.HasValue)
@ -500,6 +734,7 @@ public sealed class OnboardingService(
=> new()
{
Status = state.Status,
ReviewStatus = state.ReviewStatus,
Message = state.Message,
PercentComplete = state.PercentComplete,
ChapterCount = state.ChapterCount,

View File

@ -117,6 +117,7 @@ public sealed class CompanionPresenceViewModel
public sealed class ManuscriptScanStateViewModel
{
public string Status { get; init; } = ManuscriptScanStatuses.NotStarted;
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
public string Message { get; init; } = string.Empty;
public int? PercentComplete { get; init; }
public int ChapterCount { get; init; }
@ -132,6 +133,7 @@ public sealed class ManuscriptScanStateViewModel
public sealed class ManuscriptScanReviewViewModel
{
public Guid PreviewID { get; init; }
public string ReviewStatus { get; init; } = ManuscriptScanReviewStatuses.ScanComplete;
public string DocumentTitle { get; init; } = "Word manuscript";
public string SelectedProjectName { get; init; } = string.Empty;
public string SelectedBookTitle { get; init; } = string.Empty;
@ -139,8 +141,13 @@ public sealed class ManuscriptScanReviewViewModel
public int ChapterCount { get; init; }
public int SceneCount { get; init; }
public int CharacterCandidateCount { get; init; }
public int SelectedChapterCount { get; init; }
public int SelectedSceneCount { get; init; }
public int SelectedCharacterCount { get; init; }
public int SelectedWordCount { get; init; }
public IReadOnlyList<ManuscriptScanReviewChapterViewModel> Chapters { get; init; } = [];
public IReadOnlyList<ManuscriptScanCharacterCandidatePreview> CharacterCandidates { get; init; } = [];
public IReadOnlyList<ManuscriptScanReviewCharacterViewModel> CharacterCandidates { get; init; } = [];
public IReadOnlyList<ManuscriptScanExistingCharacterOptionViewModel> ExistingCharacterOptions { get; init; } = [];
}
public sealed class ManuscriptScanReviewChapterViewModel
@ -148,6 +155,76 @@ public sealed class ManuscriptScanReviewChapterViewModel
public string TemporaryChapterKey { get; init; } = string.Empty;
public int ChapterNumber { get; init; }
public string Title { get; init; } = string.Empty;
public string ReviewTitle { get; init; } = string.Empty;
public bool Include { get; init; }
public int WordCount { get; init; }
public IReadOnlyList<ManuscriptScanScenePreview> Scenes { get; init; } = [];
public IReadOnlyList<ManuscriptScanReviewSceneViewModel> Scenes { get; init; } = [];
}
public sealed class ManuscriptScanReviewSceneViewModel
{
public string TemporarySceneKey { get; init; } = string.Empty;
public string TemporaryChapterKey { get; init; } = string.Empty;
public int SceneNumberWithinChapter { get; init; }
public string? Title { get; init; }
public string? ReviewTitle { get; init; }
public bool Include { get; init; }
public int WordCount { get; init; }
public string? OpeningTextPreview { get; init; }
}
public sealed class ManuscriptScanReviewCharacterViewModel
{
public string TemporaryCharacterKey { get; init; } = string.Empty;
public string Name { get; init; } = string.Empty;
public string ReviewName { get; init; } = string.Empty;
public bool Include { get; init; }
public int MentionCount { get; init; }
public int QualityScore { get; init; }
public string Category { get; init; } = "PossibleCharacter";
public string? Reason { get; init; }
public int? ExistingCharacterID { get; init; }
public string? ExistingCharacterName { get; init; }
public bool IsExistingCharacterMatch => ExistingCharacterID.HasValue;
}
public sealed class ManuscriptScanExistingCharacterOptionViewModel
{
public int CharacterID { get; init; }
public string CharacterName { get; init; } = string.Empty;
}
public sealed class ManuscriptScanReviewForm
{
public Guid PreviewID { get; set; }
public List<ManuscriptScanChapterReviewFormItem> Chapters { get; set; } = [];
public List<ManuscriptScanSceneReviewFormItem> Scenes { get; set; } = [];
public List<ManuscriptScanCharacterReviewFormItem> Characters { get; set; } = [];
}
public sealed class ManuscriptScanChapterReviewFormItem
{
public string TemporaryChapterKey { get; set; } = string.Empty;
public bool Include { get; set; }
public string Title { get; set; } = string.Empty;
public int ChapterNumber { get; set; }
}
public sealed class ManuscriptScanSceneReviewFormItem
{
public string TemporarySceneKey { get; set; } = string.Empty;
public string TemporaryChapterKey { get; set; } = string.Empty;
public bool Include { get; set; }
public string? Title { get; set; }
public int SceneNumberWithinChapter { get; set; }
public bool AllowZeroWords { get; set; }
}
public sealed class ManuscriptScanCharacterReviewFormItem
{
public string TemporaryCharacterKey { get; set; } = string.Empty;
public bool Include { get; set; }
public string Name { get; set; } = string.Empty;
public string Category { get; set; } = "PossibleCharacter";
public int? ExistingCharacterID { get; set; }
}

View File

@ -310,9 +310,13 @@
data-onboarding-scan-review
href="@(Model.ScanState.PreviewID.HasValue ? Url.Action("ScanReview", "Onboarding", new { previewId = Model.ScanState.PreviewID }) : "#")"
aria-disabled="@(!Model.ScanState.IsComplete)">
Review scan
@(string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal) ? "Edit review" : "Review scan")
</a>
</div>
@if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{
<p class="onboarding-scan-next">Next: import into PlotDirector.</p>
}
</section>
</div>
</section>

View File

@ -16,32 +16,50 @@
}
<section class="onboarding-shell" aria-labelledby="scan-review-title">
<div class="onboarding-panel onboarding-review-panel">
<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" />
<div class="onboarding-copy">
<p class="eyebrow">@Model.SelectedBookTitle</p>
<h1 id="scan-review-title">Review manuscript scan</h1>
<p>@Model.DocumentTitle</p>
<p>Nothing has been added to your project yet. Review the detected structure, then continue when you're happy.</p>
<p>You can tidy this up now, or import the structure and refine it later.</p>
</div>
@if (TempData["OnboardingReviewMessage"] is string reviewMessage)
{
<div class="alert alert-success">@reviewMessage</div>
}
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="onboarding-scan-counts onboarding-review-counts">
<div>
<span>Chapters</span>
<strong>@Model.ChapterCount</strong>
<strong>@Model.SelectedChapterCount / @Model.ChapterCount</strong>
</div>
<div>
<span>Scenes</span>
<strong>@Model.SceneCount</strong>
<strong>@Model.SelectedSceneCount / @Model.SceneCount</strong>
</div>
<div>
<span>Characters</span>
<strong>@Model.CharacterCandidateCount</strong>
<strong>@Model.SelectedCharacterCount / @Model.CharacterCandidateCount</strong>
</div>
<div>
<span>Words</span>
<strong>@Model.TotalWordCount.ToString("N0")</strong>
<strong>@Model.SelectedWordCount.ToString("N0")</strong>
</div>
</div>
<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="button" data-bulk-target="scene" data-bulk-state="true">Include all scenes</button>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bulk-target="scene" data-bulk-state="false">Exclude all scenes</button>
<button class="btn btn-sm btn-outline-secondary" type="reset">Restore scan defaults</button>
</div>
<section class="onboarding-review-grid" aria-label="Scan preview">
<div class="onboarding-review-section">
<h2>Chapters and scenes</h2>
@ -52,27 +70,63 @@
else
{
<div class="onboarding-review-chapters">
@{
var chapterIndex = 0;
var sceneIndex = 0;
}
@foreach (var chapter in Model.Chapters)
{
<article class="onboarding-review-chapter">
<header>
<details class="onboarding-review-chapter" open>
<summary>
<span>Chapter @chapter.ChapterNumber</span>
<strong>@chapter.Title</strong>
<strong>@chapter.ReviewTitle</strong>
<small>@chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s")</small>
</header>
</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="hidden" name="Chapters[@chapterIndex].Include" value="false" />
<input type="checkbox" name="Chapters[@chapterIndex].Include" value="true" checked="@chapter.Include" data-review-kind="chapter" />
Include chapter
</label>
<label class="onboarding-review-field">
Chapter title
<input class="form-control" name="Chapters[@chapterIndex].Title" value="@chapter.ReviewTitle" />
</label>
@if (chapter.Scenes.Any())
{
<ol>
@foreach (var scene in chapter.Scenes)
{
<li>
<strong>@(scene.Title ?? $"Scene {scene.SceneNumberWithinChapter}")</strong>
<input type="hidden" name="Scenes[@sceneIndex].TemporarySceneKey" value="@scene.TemporarySceneKey" />
<input type="hidden" name="Scenes[@sceneIndex].TemporaryChapterKey" value="@scene.TemporaryChapterKey" />
<input type="hidden" name="Scenes[@sceneIndex].SceneNumberWithinChapter" value="@scene.SceneNumberWithinChapter" />
<label class="onboarding-review-toggle">
<input type="hidden" name="Scenes[@sceneIndex].Include" value="false" />
<input type="checkbox" name="Scenes[@sceneIndex].Include" value="true" checked="@scene.Include" data-review-kind="scene" />
Include scene
</label>
<label class="onboarding-review-field">
Scene title
<input class="form-control" name="Scenes[@sceneIndex].Title" value="@scene.ReviewTitle" placeholder="Scene @scene.SceneNumberWithinChapter" />
</label>
<span>@scene.WordCount.ToString("N0") words</span>
@if (scene.WordCount == 0)
{
<label class="onboarding-review-toggle">
<input type="hidden" name="Scenes[@sceneIndex].AllowZeroWords" value="false" />
<input type="checkbox" name="Scenes[@sceneIndex].AllowZeroWords" value="true" />
Allow empty scene
</label>
}
@if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview))
{
<p>@scene.OpeningTextPreview</p>
}
</li>
sceneIndex++;
}
</ol>
}
@ -80,7 +134,8 @@
{
<p>No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.</p>
}
</article>
</details>
chapterIndex++;
}
</div>
}
@ -94,72 +149,103 @@
}
else
{
var characterIndex = 0;
<div class="onboarding-character-groups">
<section>
<h3>Probable characters</h3>
@if (!probableCharacters.Any())
{
<p>No high-confidence character names yet.</p>
}
else
{
<ul class="onboarding-character-candidates">
@foreach (var candidate in probableCharacters)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
</li>
}
</ul>
}
</section>
@if (possibleCharacters.Any())
@foreach (var group in new[]
{
new { Title = "Probable characters", Candidates = probableCharacters },
new { Title = "Possible characters", Candidates = possibleCharacters },
new { Title = "Relationship titles", Candidates = relationshipTitles }
})
{
if (!group.Candidates.Any())
{
continue;
}
<section>
<h3>Possible characters</h3>
<h3>@group.Title</h3>
<ul class="onboarding-character-candidates">
@foreach (var candidate in possibleCharacters)
@foreach (var candidate in group.Candidates)
{
<li>
<strong>@candidate.Name</strong>
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
Include
</label>
<label class="onboarding-review-field">
Name
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
</label>
<label class="onboarding-review-field">
Existing character
<select class="form-select" name="Characters[@characterIndex].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>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
{
<small>Matches existing character: @candidate.ExistingCharacterName</small>
}
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
{
<small>@candidate.Reason</small>
}
</li>
characterIndex++;
}
</ul>
</section>
}
@if (relationshipTitles.Any())
{
<section>
<h3>Relationship titles</h3>
<ul class="onboarding-character-candidates">
@foreach (var candidate in relationshipTitles)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s")</span>
</li>
}
</ul>
</section>
}
@if (excludedCharacters.Any())
{
<details class="onboarding-excluded-candidates">
<summary>Show excluded candidates</summary>
<ul class="onboarding-character-candidates">
@foreach (var candidate in excludedCharacters)
{
<li>
<strong>@candidate.Name</strong>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / @candidate.Reason</span>
</li>
}
</ul>
<section>
<h3>Excluded candidates</h3>
<ul class="onboarding-character-candidates">
@foreach (var candidate in excludedCharacters)
{
<li>
<input type="hidden" name="Characters[@characterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@characterIndex].Category" value="@candidate.Category" />
<label class="onboarding-review-toggle">
<input type="hidden" name="Characters[@characterIndex].Include" value="false" />
<input type="checkbox" name="Characters[@characterIndex].Include" value="true" checked="@candidate.Include" />
Include
</label>
<label class="onboarding-review-field">
Name
<input class="form-control" name="Characters[@characterIndex].Name" value="@candidate.ReviewName" />
</label>
<label class="onboarding-review-field">
Existing character
<select class="form-select" name="Characters[@characterIndex].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>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
{
<small>@candidate.Reason</small>
}
</li>
characterIndex++;
}
</ul>
</section>
</details>
}
</div>
@ -169,6 +255,23 @@
<div class="onboarding-actions">
<a class="btn btn-outline-secondary" asp-controller="Onboarding" asp-action="Index">Back to setup</a>
<button class="btn btn-outline-primary" type="submit" name="intent" value="save">Save review</button>
<button class="btn btn-primary" type="submit" name="intent" value="continue">Save and continue</button>
</div>
</div>
</form>
</section>
<script>
document.querySelector("[data-review-tools]")?.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>

View File

@ -309,6 +309,12 @@
gap: .65rem;
}
.onboarding-scan-next {
margin: .25rem 0 0;
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-review-panel {
width: min(1120px, 100%);
}
@ -317,6 +323,13 @@
margin-bottom: 1.25rem;
}
.onboarding-review-tools {
display: flex;
flex-wrap: wrap;
gap: .5rem;
margin-bottom: 1rem;
}
.onboarding-review-grid {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, .8fr);
@ -346,14 +359,15 @@
background: rgba(255, 255, 255, .58);
}
.onboarding-review-chapter header {
.onboarding-review-chapter summary {
display: grid;
gap: .2rem;
margin-bottom: .75rem;
cursor: pointer;
}
.onboarding-review-chapter header span,
.onboarding-review-chapter header small,
.onboarding-review-chapter summary span,
.onboarding-review-chapter summary small,
.onboarding-review-chapter li span {
color: var(--bs-secondary-color);
font-weight: 700;
@ -379,7 +393,7 @@
.onboarding-review-chapter li {
display: grid;
gap: .2rem;
gap: .45rem;
}
.onboarding-review-chapter li p {
@ -388,10 +402,22 @@
}
.onboarding-character-candidates li {
display: flex;
display: grid;
gap: .5rem;
}
.onboarding-review-toggle {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: .75rem;
gap: .45rem;
font-weight: 700;
}
.onboarding-review-field {
display: grid;
gap: .25rem;
color: var(--bs-secondary-color);
font-weight: 700;
}
.onboarding-character-groups section {