From 3740658f3e34f855ea5b4f92a1d55a947561b8c3 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 26 Aug 2026 19:05:27 +0000 Subject: [PATCH] Fix core import scene follow and character evidence --- PlotLine.Tests/Program.cs | 61 +++++++- PlotLine/Models/ManuscriptScanModels.cs | 11 ++ PlotLine/Services/OnboardingService.cs | 47 +++++-- PlotLine/Services/WordCompanionService.cs | 82 ++++++++--- PlotLine/ViewModels/OnboardingViewModels.cs | 13 ++ .../Views/Onboarding/BuildComplete.cshtml | 27 +++- PlotLine/Views/Onboarding/ScanReview.cshtml | 131 +++++++++++------- PlotLine/wwwroot/css/onboarding.css | 70 ++++++++++ PlotLine/wwwroot/js/word-companion-host.js | 71 ++++++++-- .../wwwroot/js/word-companion-presence.js | 10 +- 10 files changed, 422 insertions(+), 101 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 0ea75f9..62c94b1 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -4,6 +4,7 @@ using System.Text; using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Mvc; using PlotLine.Controllers; +using PlotLine.Data; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using PlotLine.Models; @@ -76,6 +77,7 @@ var tests = new (string Name, Action Test)[] ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls), ("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters), + ("Core import character evidence is candidate specific", CoreImportCharacterEvidenceIsCandidateSpecific), ("Core import deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), ("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview), @@ -134,6 +136,7 @@ var tests = new (string Name, Action Test)[] ("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions), ("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers), ("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow), + ("Word Companion runtime follows multiple imported scene anchors", WordCompanionRuntimeFollowsMultipleImportedSceneAnchors), ("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable), ("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect), ("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable), @@ -558,6 +561,22 @@ static void WordCompanionCanonicalMarkersDriveRuntimeFollow() Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based."); } +static void WordCompanionRuntimeFollowsMultipleImportedSceneAnchors() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); + + Assert(script.Contains("paragraph.contentControls.load(\"items/tag,title\")", StringComparison.Ordinal), "Runtime structure reads must load paragraph content controls, not only text/style."); + Assert(script.Contains("chapterAnchorId: paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical chapter anchors."); + Assert(script.Contains("sceneAnchorId: paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical scene anchors."); + Assert(script.Contains("const hasCanonicalSceneAnchors = paragraphSnapshots.some", StringComparison.Ordinal), "Runtime structure should detect imported scene-anchor mode."); + Assert(script.Contains("if (paragraph.sceneAnchorId)", StringComparison.Ordinal), "Runtime structure should start scenes from PD-SCENE anchors on ordinary paragraphs."); + Assert(script.Contains("return chapter.scenes\r\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal) + || script.Contains("return chapter.scenes\n .filter((item) => item.startParagraphIndex <= paragraphIndex)", StringComparison.Ordinal), "Cursor resolution should select the latest preceding scene boundary."); + Assert(script.Contains("findExactAnchorControl(paragraph, tag)", StringComparison.Ordinal), "Marker insertion should reuse only the exact canonical SceneID tag."); + Assert(!script.Contains("const existing = findAnchorControl(paragraph, prefix);", StringComparison.Ordinal), "Marker insertion must not collapse distinct PD-SCENE tags by prefix."); +} + static void WordCompanionExistingProjectBookLinkingRemainsAvailable() { var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); @@ -1827,7 +1846,44 @@ static void CoreImportScanStageDefersScenesAndCharacters() Assert(presence.Contains("Opening chapter review...", StringComparison.Ordinal), "Successful scan should show the immediate auto-navigation state."); Assert(onboarding.Contains("DiscoverCoreImportCharacterCandidates(canonicalSceneSources)", StringComparison.Ordinal), "Character candidates should be generated after canonical scene sources exist."); Assert(wordService.Contains("DiscoverCoreImportCharacterCandidates", StringComparison.Ordinal), "Post-scene character discovery should be explicit."); - Assert(wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should carry canonical scene evidence."); + Assert(wordService.Contains("var evidenceScenes = CandidateSceneEvidence", StringComparison.Ordinal), "Post-scene character candidates should calculate candidate-specific canonical scene evidence."); + Assert(wordService.Contains("EvidenceScenes = evidenceScenes", StringComparison.Ordinal), "Post-scene character candidates should carry candidate-specific canonical scene evidence."); + Assert(!wordService.Contains("Source SceneIDs", StringComparison.Ordinal), "Post-scene character candidates should not dump every canonical SceneID into every candidate reason."); +} + +static void CoreImportCharacterEvidenceIsCandidateSpecific() +{ + var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); + var scanReview = File.ReadAllText(Path.Combine(root, "Views/Onboarding/ScanReview.cshtml")); + var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs")); + var viewModels = File.ReadAllText(Path.Combine(root, "ViewModels/OnboardingViewModels.cs")); + var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); + + var sources = new List + { + new() { SceneID = 101, ChapterID = 1, SourceText = "Rosie saw Beth. Beth smiled at Rosie.", StructuralSummary = "Beth and Rosie talk outside." }, + new() { SceneID = 102, ChapterID = 1, SourceText = "Rosie waited. Rosie called Colin.", StructuralSummary = "Rosie waits for Colin." }, + new() { SceneID = 201, ChapterID = 2, SourceText = "Colin answered Rosie. Colin mentioned Beth.", StructuralSummary = "Colin answers the call." } + }; + + var candidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sources); + var rosie = candidates.Single(candidate => candidate.Name == "Rosie"); + var beth = candidates.Single(candidate => candidate.Name == "Beth"); + var colin = candidates.Single(candidate => candidate.Name == "Colin"); + + Assert(rosie.MentionCount == rosie.EvidenceScenes.Sum(scene => scene.MentionCount), "Mention count should equal summed candidate-specific scene evidence."); + Assert(rosie.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 101, 102, 201 }), "Rosie evidence should include only scenes where Rosie is mentioned."); + Assert(beth.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 101, 201 }), "Beth evidence should include only scenes where Beth is mentioned."); + Assert(colin.EvidenceScenes.Select(scene => scene.SceneID).Order().SequenceEqual(new[] { 102, 201 }), "Colin evidence should include only scenes where Colin is mentioned."); + Assert(!beth.EvidenceScenes.Select(scene => scene.SceneID).SequenceEqual(colin.EvidenceScenes.Select(scene => scene.SceneID)), "Different candidates should be able to have different evidence scene sets."); + Assert(models.Contains("ManuscriptScanCharacterEvidenceScene", StringComparison.Ordinal), "Scan candidate model should carry structured evidence scenes."); + Assert(viewModels.Contains("EvidenceSceneCount => EvidenceScenes.Count", StringComparison.Ordinal), "Character review view model should expose distinct evidence scene count."); + Assert(onboarding.Contains("EvidenceScenes = candidate.EvidenceScenes", StringComparison.Ordinal), "Character review model should preserve candidate evidence scenes."); + Assert(scanReview.Contains("mention@(candidate.MentionCount == 1 ? string.Empty : \"s\") across", StringComparison.Ordinal), "Character review should explain mentions across scenes."); + Assert(scanReview.Contains("View evidence", StringComparison.Ordinal), "Character review should collapse evidence behind a disclosure."); + Assert(scanReview.Contains("data-scene-id=\"@evidence.SceneID\"", StringComparison.Ordinal), "Raw SceneIDs should be diagnostics/data attributes, not visible labels."); + Assert(!scanReview.Contains("score @candidate.QualityScore", StringComparison.Ordinal), "Character review should not expose an unexplained score label."); + Assert(scanReview.Contains("!candidate.Reason.Contains(\"Source SceneIDs:\"", StringComparison.Ordinal), "Character review should suppress legacy raw source SceneID strings."); } static void CoreImportDeterministicallyMapsKnownCharacters() @@ -1962,6 +2018,7 @@ static void WordSyncRetryResumesExistingStructure() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.cs")); + var complete = File.ReadAllText(Path.Combine(root, "Views/Onboarding/BuildComplete.cshtml")); var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql")); var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal); @@ -1969,6 +2026,8 @@ static void WordSyncRetryResumesExistingStructure() Assert(resumeCheck >= 0 && sceneDetection > resumeCheck, "Existing structural builds should be checked before rerunning scene boundary AI."); Assert(onboarding.Contains("BuildExistingStructureResultAsync", StringComparison.Ordinal), "Retry should rebuild marker instructions from persisted structure."); Assert(onboarding.Contains("BuildSceneMarkerInstructionsFromSources", StringComparison.Ordinal), "Retry should use stored SceneID paragraph positions for marker sync."); + Assert(complete.Contains("Resync Word markers", StringComparison.Ordinal), "Scene detection complete page should expose a marker repair action for existing imported documents."); + Assert(complete.Contains("Scene detection is not rerun.", StringComparison.Ordinal), "Marker repair UI should explain it reuses existing structure."); Assert(repository.Contains("Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Existing build lookup should use a stored procedure."); Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Forward migration should provide the existing build lookup procedure."); } diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index 435c28a..d470013 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -123,6 +123,17 @@ public sealed class ManuscriptScanCharacterCandidatePreview public string? Reason { get; init; } public bool IsExistingCharacterMatch { get; init; } public string? SuggestedImportance { get; init; } + public IReadOnlyList EvidenceScenes { get; init; } = []; +} + +public sealed class ManuscriptScanCharacterEvidenceScene +{ + public int SceneID { get; init; } + public int ChapterID { get; init; } + public int ChapterNumber { get; init; } + public int SceneNumber { get; init; } + public int MentionCount { get; init; } + public string? StructuralSummary { get; init; } } public sealed class ManuscriptScanState diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 2f72cf1..eabb210 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -128,7 +128,7 @@ public sealed class OnboardingService( return null; } - var (userId, preview, _, project, book, existingCharacters) = context.Value; + 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); @@ -218,7 +218,9 @@ public sealed class OnboardingService( } var saved = await scanPreviews.SaveReviewAsync(userId, review); - return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters)); + var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) + ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); + return saved is null ? null : BuildScanReviewViewModel((userId, preview, saved, project, book, existingCharacters, build)); } public async Task BuildApprovedStructureAsync(Guid previewId, Func? progress = null) @@ -229,7 +231,7 @@ public sealed class OnboardingService( return null; } - var (userId, preview, review, _, _, _) = context.Value; + var (userId, preview, review, _, _, _, _) = context.Value; if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) { throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters."); @@ -409,8 +411,10 @@ public sealed class OnboardingService( return null; } - var (userId, preview, review, _, _, existingCharacters) = context.Value; - var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID); + var (userId, preview, review, _, _, existingCharacters, contextBuild) = context.Value; + var build = contextBuild + ?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) + ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (build is null || build.SceneMappings.Count == 0) { throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); @@ -733,7 +737,7 @@ public sealed class OnboardingService( } } - private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId) + private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters, OnboardingManuscriptBuildResult? Build)?> GetScanReviewContextAsync(Guid? previewId) { var userId = RequireUserId(); var state = await onboarding.GetAsync(userId); @@ -767,13 +771,15 @@ public sealed class OnboardingService( } var existingCharacters = await characters.ListCharactersAsync(project.ProjectID); - return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList()); + var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) + ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); + return (userId, preview, review, project, book, existingCharacters.Where(character => !character.IsArchived).ToList(), build); } private static ManuscriptScanReviewViewModel BuildScanReviewViewModel( - (int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters) context) + (int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList ExistingCharacters, OnboardingManuscriptBuildResult? Build) context) { - var (userId, preview, review, project, book, existingCharacters) = context; + var (userId, preview, review, project, book, existingCharacters, build) = context; _ = userId; var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal); var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, StringComparer.Ordinal); @@ -854,13 +860,30 @@ public sealed class OnboardingService( && characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision) ? targetDecision.Name : null, - SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName) + SexHint = CharacterSexHintService.Normalise(decision?.SexHint, reviewName), + EvidenceScenes = candidate.EvidenceScenes + .OrderBy(evidence => evidence.ChapterNumber) + .ThenBy(evidence => evidence.SceneNumber) + .ThenBy(evidence => evidence.SceneID) + .Select(evidence => new ManuscriptScanCharacterEvidenceSceneViewModel + { + SceneID = evidence.SceneID, + ChapterID = evidence.ChapterID, + ChapterNumber = evidence.ChapterNumber, + SceneNumber = evidence.SceneNumber, + MentionCount = evidence.MentionCount, + StructuralSummary = evidence.StructuralSummary + }) + .ToList() }; }) .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(); + var canonicalSceneCount = build?.ScenesCreated > 0 + ? build.ScenesCreated + : build?.SceneMappings.Count > 0 ? build.SceneMappings.Count : preview.SceneCount; var selectedChapterWordCount = chapters .Where(chapter => chapter.Include) .Sum(chapter => chapter.WordCount); @@ -874,10 +897,10 @@ public sealed class OnboardingService( SelectedBookTitle = BookOptionTitle(book), TotalWordCount = preview.TotalWordCount, ChapterCount = preview.ChapterCount, - SceneCount = preview.SceneCount, + SceneCount = canonicalSceneCount, CharacterCandidateCount = preview.CharacterCandidateCount, SelectedChapterCount = selectedChapterKeys.Count, - SelectedSceneCount = selectedScenes.Count, + SelectedSceneCount = canonicalSceneCount > 0 ? canonicalSceneCount : selectedScenes.Count, SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)), SelectedWordCount = selectedChapterWordCount, Chapters = chapters, diff --git a/PlotLine/Services/WordCompanionService.cs b/PlotLine/Services/WordCompanionService.cs index 3a5fb21..ebb49a6 100644 --- a/PlotLine/Services/WordCompanionService.cs +++ b/PlotLine/Services/WordCompanionService.cs @@ -726,10 +726,12 @@ public sealed class WordCompanionService( public static IReadOnlyList DiscoverCoreImportCharacterCandidates( IReadOnlyList sourceScenes) { - var manuscriptText = string.Join("\n\n", sourceScenes + var orderedScenes = sourceScenes .OrderBy(scene => scene.ChapterID) .ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue) .ThenBy(scene => scene.SceneID) + .ToList(); + var manuscriptText = string.Join("\n\n", orderedScenes .Select(scene => scene.SourceText) .Where(text => !string.IsNullOrWhiteSpace(text))); if (string.IsNullOrWhiteSpace(manuscriptText)) @@ -737,30 +739,68 @@ public sealed class WordCompanionService( return []; } - var sceneIds = sourceScenes - .Where(scene => !string.IsNullOrWhiteSpace(scene.SourceText)) - .Select(scene => scene.SceneID) - .Distinct() - .Order() - .ToList(); - var sceneEvidence = sceneIds.Count == 0 - ? "Discovered from canonical scene source text." - : $"Discovered from canonical scene source text. Source SceneIDs: {string.Join(", ", sceneIds)}."; + var chapterNumbers = orderedScenes + .GroupBy(scene => scene.ChapterID) + .Select((group, index) => new { group.Key, ChapterNumber = index + 1 }) + .ToDictionary(item => item.Key, item => item.ChapterNumber); + var sceneNumbers = orderedScenes + .GroupBy(scene => scene.ChapterID) + .SelectMany(group => group.Select((scene, index) => new { scene.SceneID, SceneNumber = index + 1 })) + .ToDictionary(item => item.SceneID, item => item.SceneNumber); return DiscoverCharacterCandidates(manuscriptText) .Where(IsAutoSelectedScanCharacterCandidate) - .Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview + .Select((candidate, index) => { - TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}", - Name = candidate.Text, - MentionCount = candidate.MentionCount, - QualityScore = candidate.QualityScore, - Category = candidate.Category, - Reason = string.IsNullOrWhiteSpace(candidate.Reason) - ? sceneEvidence - : $"{candidate.Reason} {sceneEvidence}", - IsExistingCharacterMatch = candidate.IsExistingCharacterMatch, - SuggestedImportance = candidate.Confidence + var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers); + return new ManuscriptScanCharacterCandidatePreview + { + TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}", + Name = candidate.Text, + MentionCount = evidenceScenes.Sum(evidence => evidence.MentionCount), + QualityScore = candidate.QualityScore, + Category = candidate.Category, + Reason = string.IsNullOrWhiteSpace(candidate.Reason) + ? "Discovered from canonical scene source text." + : candidate.Reason, + IsExistingCharacterMatch = candidate.IsExistingCharacterMatch, + SuggestedImportance = candidate.Confidence, + EvidenceScenes = evidenceScenes + }; + }) + .ToList(); + } + + private static IReadOnlyList CandidateSceneEvidence( + string candidateText, + IReadOnlyList sourceScenes, + IReadOnlyDictionary chapterNumbers, + IReadOnlyDictionary sceneNumbers) + { + if (string.IsNullOrWhiteSpace(candidateText)) + { + return []; + } + + var pattern = $@"(? new + { + Source = scene, + MentionCount = string.IsNullOrWhiteSpace(scene.SourceText) + ? 0 + : regex.Matches(scene.SourceText).Count + }) + .Where(item => item.MentionCount > 0) + .Select(item => new ManuscriptScanCharacterEvidenceScene + { + SceneID = item.Source.SceneID, + ChapterID = item.Source.ChapterID, + ChapterNumber = chapterNumbers.GetValueOrDefault(item.Source.ChapterID), + SceneNumber = sceneNumbers.GetValueOrDefault(item.Source.SceneID), + MentionCount = item.MentionCount, + StructuralSummary = item.Source.StructuralSummary }) .ToList(); } diff --git a/PlotLine/ViewModels/OnboardingViewModels.cs b/PlotLine/ViewModels/OnboardingViewModels.cs index 21a2157..82c74c9 100644 --- a/PlotLine/ViewModels/OnboardingViewModels.cs +++ b/PlotLine/ViewModels/OnboardingViewModels.cs @@ -752,9 +752,22 @@ public sealed class ManuscriptScanReviewCharacterViewModel public string? AliasTargetTemporaryCharacterKey { get; init; } public string? AliasTargetName { get; init; } public string SexHint { get; init; } = "Unknown"; + public IReadOnlyList EvidenceScenes { get; init; } = []; + public int EvidenceSceneCount => EvidenceScenes.Count; public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey); } +public sealed class ManuscriptScanCharacterEvidenceSceneViewModel +{ + public int SceneID { get; init; } + public int ChapterID { get; init; } + public int ChapterNumber { get; init; } + public int SceneNumber { get; init; } + public int MentionCount { get; init; } + public string? StructuralSummary { get; init; } + public string Label => $"Chapter {ChapterNumber:N0} - Scene {SceneNumber:N0}"; +} + public sealed class ManuscriptScanExistingCharacterOptionViewModel { public int CharacterID { get; init; } diff --git a/PlotLine/Views/Onboarding/BuildComplete.cshtml b/PlotLine/Views/Onboarding/BuildComplete.cshtml index 76424ad..22318cc 100644 --- a/PlotLine/Views/Onboarding/BuildComplete.cshtml +++ b/PlotLine/Views/Onboarding/BuildComplete.cshtml @@ -16,7 +16,7 @@

@if (coreComplete) { - @:PlotDirector created the approved chapters and scenes, saved the Word markers, and applied your character review. Core import is complete. + @:PlotDirector created the approved chapters and scenes, confirmed the Word markers were saved, and applied your character review. Core import is complete. } else if (Model.AlreadyBuilt) { @@ -24,7 +24,7 @@ } else { - @:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, updated the Word markers, and saved the manuscript. Word may now be closed if you wish; keep this browser workflow open to review characters. + @:PlotDirector created the approved chapters and detected scenes, persisted their manuscript text, and confirmed the Word markers were saved. Word may now be closed if you wish; keep this browser workflow open to review characters. }

@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) @@ -100,6 +100,22 @@

Next: review characters

Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.

+ +
+
+

Need to repair the Word document?

+

Resync Word markers uses the existing imported chapters and scenes. Scene detection is not rerun.

+
+ + + +
}
@@ -111,6 +127,13 @@ else { Review characters + } Open Project Overview Open Writer Workspace diff --git a/PlotLine/Views/Onboarding/ScanReview.cshtml b/PlotLine/Views/Onboarding/ScanReview.cshtml index 365f2d5..3662df6 100644 --- a/PlotLine/Views/Onboarding/ScanReview.cshtml +++ b/PlotLine/Views/Onboarding/ScanReview.cshtml @@ -214,59 +214,92 @@ @foreach (var candidate in group.Candidates) { var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)); -
  • +
  • - - - + + + + +
  • +
    + @if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName)) + { + Alias of detected character: @candidate.AliasTargetName + } + @if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName)) + { + Matches existing character: @candidate.ExistingCharacterName + } + @if (!string.IsNullOrWhiteSpace(candidate.Reason) && !candidate.Reason.Contains("Source SceneIDs:", StringComparison.OrdinalIgnoreCase)) + { + @candidate.Reason + } +
    + @if (candidate.EvidenceScenes.Any()) + { +
    + View evidence +
      + @foreach (var evidence in candidate.EvidenceScenes.Take(8)) + { +
    1. + @evidence.Label + @evidence.MentionCount mention@(evidence.MentionCount == 1 ? string.Empty : "s") + @if (!string.IsNullOrWhiteSpace(evidence.StructuralSummary)) + { +

      @evidence.StructuralSummary

      + } +
    2. + } +
    + @if (candidate.EvidenceScenes.Count > 8) { - + @(candidate.EvidenceScenes.Count - 8) more scene@(candidate.EvidenceScenes.Count - 8 == 1 ? string.Empty : "s") not shown. } - - - - - @candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore - @if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName)) - { - Alias of detected character: @candidate.AliasTargetName - } - @if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName)) - { - Matches existing character: @candidate.ExistingCharacterName - } - @if (!string.IsNullOrWhiteSpace(candidate.Reason)) - { - @candidate.Reason +
    } } diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 8b5c08d..c7c8e7d 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -459,6 +459,10 @@ background: rgba(182, 138, 70, .12); } +.onboarding-marker-resync-panel { + margin-top: 1rem; +} + .onboarding-manual-save-actions { grid-column: 1 / -1; display: flex; @@ -1349,6 +1353,72 @@ summary.story-review-chapter-heading { gap: .5rem; } +.onboarding-character-card-header { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: .85rem; +} + +.onboarding-character-card-header div { + display: grid; + gap: .15rem; +} + +.onboarding-character-card-header strong { + font-size: 1.05rem; +} + +.onboarding-character-card-header span, +.onboarding-character-card-header small, +.onboarding-character-card-notes small, +.onboarding-character-evidence summary, +.onboarding-character-evidence li span { + color: var(--bs-secondary-color); + font-weight: 700; +} + +.onboarding-character-controls { + display: grid; + grid-template-columns: minmax(120px, .75fr) repeat(4, minmax(150px, 1fr)); + gap: .6rem; + align-items: end; +} + +.onboarding-character-card-notes { + display: flex; + flex-wrap: wrap; + gap: .35rem .75rem; +} + +.onboarding-character-evidence { + border-top: 1px solid rgba(31, 42, 68, .08); + padding-top: .45rem; +} + +.onboarding-character-evidence summary { + cursor: pointer; +} + +.onboarding-character-evidence ol { + display: grid; + gap: .45rem; + margin: .55rem 0 0; + padding-left: 1.25rem; +} + +.onboarding-character-evidence li { + border: 0; + border-radius: 0; + padding: 0; + background: transparent; +} + +.onboarding-character-evidence li p { + margin: .15rem 0 0; + color: var(--bs-secondary-color); +} + .onboarding-review-toggle { display: inline-flex; align-items: center; diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index 7f615dc..4f0d04a 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -2000,6 +2000,7 @@ const buildDocumentStructure = (paragraphs, selectionParagraphs) => { const chapters = []; + const hasCanonicalSceneAnchors = paragraphs.some((paragraph) => paragraphAnchorId(paragraph, "PD-SCENE")); let heading1Count = 0; let heading2Count = 0; let chapter = null; @@ -2007,7 +2008,9 @@ paragraphs.forEach((paragraph, index) => { const title = String(paragraph.text || "").trim(); - if (!title) { + const chapterAnchorId = paragraphAnchorId(paragraph, "PD-CHAPTER"); + const sceneAnchorId = paragraphAnchorId(paragraph, "PD-SCENE"); + if (!title && !chapterAnchorId && !sceneAnchorId) { return; } @@ -2017,15 +2020,26 @@ index: chapters.length + 1, paragraphIndex: index, title, - anchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"), + anchorId: chapterAnchorId, scenes: [] }; chapters.push(chapter); scene = null; + if (sceneAnchorId) { + heading2Count += 1; + scene = { + index: chapter.scenes.length + 1, + paragraphIndex: index, + title: `Scene ${chapter.scenes.length + 1}`, + anchorId: sceneAnchorId, + wordCount: 0 + }; + chapter.scenes.push(scene); + } return; } - if (isBuiltInHeading(paragraph, 2)) { + if (isBuiltInHeading(paragraph, 2) || sceneAnchorId) { heading2Count += 1; if (!chapter) { return; @@ -2034,15 +2048,17 @@ scene = { index: chapter.scenes.length + 1, paragraphIndex: index, - title, - anchorId: paragraphAnchorId(paragraph, "PD-SCENE"), + title: isBuiltInHeading(paragraph, 2) ? title : `Scene ${chapter.scenes.length + 1}`, + anchorId: sceneAnchorId, wordCount: 0 }; chapter.scenes.push(scene); - return; + if (isBuiltInHeading(paragraph, 2)) { + return; + } } - if (scene) { + if (scene && title && (!hasCanonicalSceneAnchors || scene.anchorId)) { scene.wordCount += countWords(title); } }); @@ -2144,8 +2160,8 @@ styleBuiltIn: paragraph?.styleBuiltIn || "", style: paragraph?.style || "", wordCount: countWords(text), - chapterAnchorId: null, - sceneAnchorId: null + chapterAnchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"), + sceneAnchorId: paragraphAnchorId(paragraph, "PD-SCENE") }; }; @@ -2164,6 +2180,7 @@ const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => { const paragraphSnapshots = paragraphs.map(snapshotParagraph); + const hasCanonicalSceneAnchors = paragraphSnapshots.some((paragraph) => paragraph.sceneAnchorId); const chapters = []; let heading1Count = 0; let sceneBoundaryCount = 0; @@ -2210,7 +2227,12 @@ }; chapters.push(chapter); scene = null; - startScene(paragraph, usesExplicitScenes ? "Scene 1" : "Scene 1", paragraph.sceneAnchorId); + if (paragraph.sceneAnchorId) { + sceneBoundaryCount += 1; + startScene(paragraph, "Scene 1", paragraph.sceneAnchorId); + } else if (!hasCanonicalSceneAnchors) { + startScene(paragraph, "Scene 1", null); + } documentWordCount += paragraph.wordCount; continue; } @@ -2219,6 +2241,13 @@ continue; } + if (paragraph.sceneAnchorId) { + sceneBoundaryCount += 1; + startScene(paragraph, `Scene ${chapter.scenes.length + 1}`, paragraph.sceneAnchorId); + } else if (!scene) { + startScene(paragraph, "Scene 1", null); + } + if (usesExplicitScenes && sceneSeparatorTexts.has(paragraph.text)) { sceneBoundaryCount += 1; startScene(paragraph); @@ -3109,13 +3138,11 @@ try { for (const paragraph of bodyParagraphs.items) { - if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) { - paragraph.contentControls.load("items/tag,title"); - } + paragraph.contentControls.load("items/tag,title"); } await context.sync(); } catch (error) { - console.warn("Unable to load Word heading content controls. Continuing without PlotDirector anchors.", error); + console.warn("Unable to load Word content controls. Continuing without PlotDirector anchors.", error); } const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items); @@ -3131,6 +3158,15 @@ selectionParagraphs.load("text,styleBuiltIn,style"); await context.sync(); + try { + for (const paragraph of bodyParagraphs.items) { + paragraph.contentControls.load("items/tag,title"); + } + await context.sync(); + } catch (error) { + console.warn("Unable to load Word runtime content controls. Continuing without PlotDirector anchors.", error); + } + const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes); const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items); documentStructureCache = cache; @@ -4491,8 +4527,13 @@ return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null; }; + const findExactAnchorControl = (paragraph, tag) => { + const controls = safeContentControlItems(paragraph); + return controls.find((control) => String(control.tag || "") === tag) || null; + }; + const ensureAnchorControl = (paragraph, title, tag, prefix) => { - const existing = findAnchorControl(paragraph, prefix); + const existing = findExactAnchorControl(paragraph, tag); if (existing && existing.tag === tag && existing.title === title) { return false; } diff --git a/PlotLine/wwwroot/js/word-companion-presence.js b/PlotLine/wwwroot/js/word-companion-presence.js index f97103d..714cb13 100644 --- a/PlotLine/wwwroot/js/word-companion-presence.js +++ b/PlotLine/wwwroot/js/word-companion-presence.js @@ -165,6 +165,9 @@ const setBuildActionState = (text, disabled, mode = "") => { document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => { + if (!button.dataset.defaultActionText) { + button.dataset.defaultActionText = button.textContent.trim() || "Detect Scenes"; + } button.textContent = text; button.disabled = disabled; if (mode) { @@ -226,7 +229,12 @@ button.disabled = true; button.textContent = "I've saved the Word document"; }); - setBuildActionState("Detect Scenes", false); + document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => { + const defaultText = button.dataset.defaultActionText || button.textContent.trim() || "Detect Scenes"; + button.textContent = defaultText; + button.disabled = false; + delete button.dataset.buildMode; + }); }; const confirmManualSaveFromWeb = async () => {