From f78e1ac8fc0b9385cc34befaccb31c1a5943a94d Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 3 Jul 2026 23:12:15 +0100 Subject: [PATCH] Phase 20E Build the review and edit stage for the scan preview. --- PlotLine/Controllers/OnboardingController.cs | 33 +++ PlotLine/Models/ManuscriptScanModels.cs | 46 +++ .../Services/ManuscriptScanPreviewStore.cs | 81 +++++ PlotLine/Services/OnboardingService.cs | 277 ++++++++++++++++-- PlotLine/ViewModels/OnboardingViewModels.cs | 81 ++++- PlotLine/Views/Onboarding/Index.cshtml | 6 +- PlotLine/Views/Onboarding/ScanReview.cshtml | 229 +++++++++++---- PlotLine/wwwroot/css/onboarding.css | 40 ++- 8 files changed, 699 insertions(+), 94 deletions(-) diff --git a/PlotLine/Controllers/OnboardingController.cs b/PlotLine/Controllers/OnboardingController.cs index 8c15fe8..0d654c1 100644 --- a/PlotLine/Controllers/OnboardingController.cs +++ b/PlotLine/Controllers/OnboardingController.cs @@ -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 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 Welcome() diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index 4f3764b..d529f41 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -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 Chapters { get; init; } = []; + public IReadOnlyList Scenes { get; init; } = []; + public IReadOnlyList 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; } +} diff --git a/PlotLine/Services/ManuscriptScanPreviewStore.cs b/PlotLine/Services/ManuscriptScanPreviewStore.cs index d8ac900..42cd94a 100644 --- a/PlotLine/Services/ManuscriptScanPreviewStore.cs +++ b/PlotLine/Services/ManuscriptScanPreviewStore.cs @@ -13,12 +13,15 @@ public interface IManuscriptScanPreviewStore Task GetStateAsync(int userId, int onboardingId); Task GetLatestPreviewAsync(int userId, int onboardingId); Task GetPreviewAsync(int userId, Guid previewId); + Task GetReviewAsync(int userId, Guid previewId); + Task SaveReviewAsync(int userId, ManuscriptScanReviewDecision review); } public sealed class ManuscriptScanPreviewStore : IManuscriptScanPreviewStore { private readonly ConcurrentDictionary<(int UserId, int OnboardingId), ScanSession> sessions = new(); private readonly ConcurrentDictionary previews = new(); + private readonly ConcurrentDictionary reviews = new(); public Task 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 GetPreviewAsync(int userId, Guid previewId) => Task.FromResult(previews.TryGetValue(previewId, out var preview) && preview.UserID == userId ? preview : null); + public Task GetReviewAsync(int userId, Guid previewId) + { + if (!previews.TryGetValue(previewId, out var preview) || preview.UserID != userId) + { + return Task.FromResult(null); + } + + var review = reviews.GetOrAdd(previewId, _ => BuildDefaultReview(preview)); + return Task.FromResult(review); + } + + public Task SaveReviewAsync(int userId, ManuscriptScanReviewDecision review) + { + if (!previews.TryGetValue(review.PreviewID, out var preview) || preview.UserID != userId) + { + return Task.FromResult(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(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; } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 4729773..8679553 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -9,6 +9,7 @@ public interface IOnboardingService Task GetOrCreateAsync(); Task GetWizardAsync(); Task GetScanReviewAsync(Guid? previewId = null); + Task SaveScanReviewAsync(ManuscriptScanReviewForm form, bool readyToImport); Task ShouldShowOnboardingAsync(); Task GetDashboardNudgeAsync(); Task ShouldShowWordCompanionHeaderAsync(); @@ -29,6 +30,7 @@ public sealed class OnboardingService( IProjectActivityService activity, IWordCompanionPresenceService companionPresence, IManuscriptScanPreviewStore scanPreviews, + ICharacterRepository characters, ICurrentUserService currentUser) : IOnboardingService { private static readonly HashSet WritingJourneys = new(StringComparer.Ordinal) @@ -102,6 +104,87 @@ public sealed class OnboardingService( } public async Task GetScanReviewAsync(Guid? previewId = null) + { + var context = await GetScanReviewContextAsync(previewId); + return context is null ? null : BuildScanReviewViewModel(context.Value); + } + + public async Task 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 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 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 existingCharacters) + { + var clean = Clean(name); + return string.IsNullOrWhiteSpace(clean) + ? null + : existingCharacters.FirstOrDefault(character => string.Equals(character.CharacterName, clean, StringComparison.OrdinalIgnoreCase)); + } + public async Task 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, diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index faa7d99..f6a17bb 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -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 Chapters { get; init; } = []; - public IReadOnlyList CharacterCandidates { get; init; } = []; + public IReadOnlyList CharacterCandidates { get; init; } = []; + public IReadOnlyList 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 Scenes { get; init; } = []; + public IReadOnlyList 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 Chapters { get; set; } = []; + public List Scenes { get; set; } = []; + public List 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; } } diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index b2ea0e8..e1f0697 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -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") + @if (string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) + { +

Next: import into PlotDirector.

+ } diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index de1fddf..018ec3f 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -16,32 +16,50 @@ }
-
+
+ @Html.AntiForgeryToken() + +

@Model.SelectedBookTitle

Review manuscript scan

-

@Model.DocumentTitle

+

Nothing has been added to your project yet. Review the detected structure, then continue when you're happy.

+

You can tidy this up now, or import the structure and refine it later.

+ @if (TempData["OnboardingReviewMessage"] is string reviewMessage) + { +
@reviewMessage
+ } +
+
Chapters - @Model.ChapterCount + @Model.SelectedChapterCount / @Model.ChapterCount
Scenes - @Model.SceneCount + @Model.SelectedSceneCount / @Model.SceneCount
Characters - @Model.CharacterCandidateCount + @Model.SelectedCharacterCount / @Model.CharacterCandidateCount
Words - @Model.TotalWordCount.ToString("N0") + @Model.SelectedWordCount.ToString("N0")
+
+ + + + + +
+

Chapters and scenes

@@ -52,27 +70,63 @@ else {
+ @{ + var chapterIndex = 0; + var sceneIndex = 0; + } @foreach (var chapter in Model.Chapters) { -
-
+
+ Chapter @chapter.ChapterNumber - @chapter.Title + @chapter.ReviewTitle @chapter.WordCount.ToString("N0") words / @chapter.Scenes.Count scene@(chapter.Scenes.Count == 1 ? string.Empty : "s") -
+ + + + + + @if (chapter.Scenes.Any()) {
    @foreach (var scene in chapter.Scenes) {
  1. - @(scene.Title ?? $"Scene {scene.SceneNumberWithinChapter}") + + + + + @scene.WordCount.ToString("N0") words + @if (scene.WordCount == 0) + { + + } @if (!string.IsNullOrWhiteSpace(scene.OpeningTextPreview)) {

    @scene.OpeningTextPreview

    }
  2. + sceneIndex++; }
} @@ -80,7 +134,8 @@ {

No scene breaks were detected. PlotDirector will treat this chapter as one scene for now.

} -
+ + chapterIndex++; }
} @@ -94,72 +149,103 @@ } else { + var characterIndex = 0;
-
-

Probable characters

- @if (!probableCharacters.Any()) - { -

No high-confidence character names yet.

- } - else - { -
    - @foreach (var candidate in probableCharacters) - { -
  • - @candidate.Name - @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore -
  • - } -
- } -
- - @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; + } +
-

Possible characters

+

@group.Title

    - @foreach (var candidate in possibleCharacters) + @foreach (var candidate in group.Candidates) {
  • - @candidate.Name + + + + + @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore + @if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName)) + { + Matches existing character: @candidate.ExistingCharacterName + } + @if (!string.IsNullOrWhiteSpace(candidate.Reason)) + { + @candidate.Reason + }
  • + characterIndex++; }
} - - @if (relationshipTitles.Any()) - { -
-

Relationship titles

-
    - @foreach (var candidate in relationshipTitles) - { -
  • - @candidate.Name - @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") -
  • - } -
-
- } - @if (excludedCharacters.Any()) {
Show excluded candidates -
    - @foreach (var candidate in excludedCharacters) - { -
  • - @candidate.Name - @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / @candidate.Reason -
  • - } -
+
+

Excluded candidates

+
    + @foreach (var candidate in excludedCharacters) + { +
  • + + + + + + @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore + @if (!string.IsNullOrWhiteSpace(candidate.Reason)) + { + @candidate.Reason + } +
  • + characterIndex++; + } +
+
}
@@ -169,6 +255,23 @@
Back to setup + +
-
+
+ + diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 93843cb..ff911b6 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -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 {