Fix core import scene follow and character evidence

This commit is contained in:
Nick Beckley 2026-08-26 19:05:27 +00:00
parent f76e5bc388
commit 3740658f3e
10 changed files with 422 additions and 101 deletions

View File

@ -4,6 +4,7 @@ using System.Text;
using Microsoft.AspNetCore.Http.Metadata; using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PlotLine.Controllers; using PlotLine.Controllers;
using PlotLine.Data;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using PlotLine.Models; using PlotLine.Models;
@ -76,6 +77,7 @@ var tests = new (string Name, Action Test)[]
("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText), ("Structural import persists canonical scene source text", StructuralImportPersistsCanonicalSceneSourceText),
("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls), ("Structural import adds summaries without extra scene AI calls", StructuralImportAddsSummariesWithoutExtraSceneAiCalls),
("Core import scan stage defers scenes and characters", CoreImportScanStageDefersScenesAndCharacters), ("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 deterministically maps known characters", CoreImportDeterministicallyMapsKnownCharacters),
("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters), ("Core import wizard orders scenes before characters", CoreImportWizardOrdersScenesBeforeCharacters),
("Core import remaps characters after review", CoreImportRemapsCharactersAfterReview), ("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 exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers), ("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow), ("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 existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable),
("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect), ("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect),
("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable), ("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."); 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() static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
{ {
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); 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(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(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("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<SceneManuscriptSourceRecord>
{
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() static void CoreImportDeterministicallyMapsKnownCharacters()
@ -1962,6 +2018,7 @@ static void WordSyncRetryResumesExistingStructure()
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs")); var onboarding = File.ReadAllText(Path.Combine(root, "Services/OnboardingService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/OnboardingBuildRepository.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 migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql"));
var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal); 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(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("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(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(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."); Assert(migration.Contains("CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview", StringComparison.Ordinal), "Forward migration should provide the existing build lookup procedure.");
} }

View File

@ -123,6 +123,17 @@ public sealed class ManuscriptScanCharacterCandidatePreview
public string? Reason { get; init; } public string? Reason { get; init; }
public bool IsExistingCharacterMatch { get; init; } public bool IsExistingCharacterMatch { get; init; }
public string? SuggestedImportance { get; init; } public string? SuggestedImportance { get; init; }
public IReadOnlyList<ManuscriptScanCharacterEvidenceScene> 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 public sealed class ManuscriptScanState

View File

@ -128,7 +128,7 @@ public sealed class OnboardingService(
return null; 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 chapterLookup = preview.Chapters.ToDictionary(chapter => chapter.TemporaryChapterKey, StringComparer.Ordinal);
var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal); var sceneLookup = preview.Scenes.ToDictionary(scene => scene.TemporarySceneKey, StringComparer.Ordinal);
var candidateLookup = preview.CharacterCandidates.ToDictionary(candidate => candidate.TemporaryCharacterKey, 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); 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<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null) public async Task<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? progress = null)
@ -229,7 +231,7 @@ public sealed class OnboardingService(
return null; return null;
} }
var (userId, preview, review, _, _, _) = context.Value; var (userId, preview, review, _, _, _, _) = context.Value;
if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) if (!string.Equals(review.Status, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{ {
throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters."); throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters.");
@ -409,8 +411,10 @@ public sealed class OnboardingService(
return null; return null;
} }
var (userId, preview, review, _, _, existingCharacters) = context.Value; var (userId, preview, review, _, _, existingCharacters, contextBuild) = context.Value;
var build = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID); var build = contextBuild
?? await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID)
?? await builds.GetByPreviewAsync(userId, preview.PreviewID);
if (build is null || build.SceneMappings.Count == 0) if (build is null || build.SceneMappings.Count == 0)
{ {
throw new InvalidOperationException("Import the approved chapters and scenes before reviewing characters."); 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<Character> ExistingCharacters)?> GetScanReviewContextAsync(Guid? previewId) private async Task<(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters, OnboardingManuscriptBuildResult? Build)?> GetScanReviewContextAsync(Guid? previewId)
{ {
var userId = RequireUserId(); var userId = RequireUserId();
var state = await onboarding.GetAsync(userId); var state = await onboarding.GetAsync(userId);
@ -767,13 +771,15 @@ public sealed class OnboardingService(
} }
var existingCharacters = await characters.ListCharactersAsync(project.ProjectID); 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( private static ManuscriptScanReviewViewModel BuildScanReviewViewModel(
(int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters) context) (int UserId, ManuscriptScanPreview Preview, ManuscriptScanReviewDecision Review, Project Project, Book Book, IReadOnlyList<Character> ExistingCharacters, OnboardingManuscriptBuildResult? Build) context)
{ {
var (userId, preview, review, project, book, existingCharacters) = context; var (userId, preview, review, project, book, existingCharacters, build) = context;
_ = userId; _ = userId;
var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal); var chapterReview = review.Chapters.ToDictionary(item => item.TemporaryChapterKey, StringComparer.Ordinal);
var sceneReview = review.Scenes.ToDictionary(item => item.TemporarySceneKey, 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) && characterReview.TryGetValue(decision.AliasTargetTemporaryCharacterKey, out var targetDecision)
? targetDecision.Name ? targetDecision.Name
: null, : 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(); .ToList();
var selectedChapterKeys = chapters.Where(chapter => chapter.Include).Select(chapter => chapter.TemporaryChapterKey).ToHashSet(StringComparer.Ordinal); 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 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 var selectedChapterWordCount = chapters
.Where(chapter => chapter.Include) .Where(chapter => chapter.Include)
.Sum(chapter => chapter.WordCount); .Sum(chapter => chapter.WordCount);
@ -874,10 +897,10 @@ public sealed class OnboardingService(
SelectedBookTitle = BookOptionTitle(book), SelectedBookTitle = BookOptionTitle(book),
TotalWordCount = preview.TotalWordCount, TotalWordCount = preview.TotalWordCount,
ChapterCount = preview.ChapterCount, ChapterCount = preview.ChapterCount,
SceneCount = preview.SceneCount, SceneCount = canonicalSceneCount,
CharacterCandidateCount = preview.CharacterCandidateCount, CharacterCandidateCount = preview.CharacterCandidateCount,
SelectedChapterCount = selectedChapterKeys.Count, 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)), SelectedCharacterCount = characters.Count(character => character.Include && !character.IsAlias && !string.Equals(character.Category, "Excluded", StringComparison.OrdinalIgnoreCase)),
SelectedWordCount = selectedChapterWordCount, SelectedWordCount = selectedChapterWordCount,
Chapters = chapters, Chapters = chapters,

View File

@ -726,10 +726,12 @@ public sealed class WordCompanionService(
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates( public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes) IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes)
{ {
var manuscriptText = string.Join("\n\n", sourceScenes var orderedScenes = sourceScenes
.OrderBy(scene => scene.ChapterID) .OrderBy(scene => scene.ChapterID)
.ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue) .ThenBy(scene => scene.SourceStartParagraph ?? int.MaxValue)
.ThenBy(scene => scene.SceneID) .ThenBy(scene => scene.SceneID)
.ToList();
var manuscriptText = string.Join("\n\n", orderedScenes
.Select(scene => scene.SourceText) .Select(scene => scene.SourceText)
.Where(text => !string.IsNullOrWhiteSpace(text))); .Where(text => !string.IsNullOrWhiteSpace(text)));
if (string.IsNullOrWhiteSpace(manuscriptText)) if (string.IsNullOrWhiteSpace(manuscriptText))
@ -737,30 +739,68 @@ public sealed class WordCompanionService(
return []; return [];
} }
var sceneIds = sourceScenes var chapterNumbers = orderedScenes
.Where(scene => !string.IsNullOrWhiteSpace(scene.SourceText)) .GroupBy(scene => scene.ChapterID)
.Select(scene => scene.SceneID) .Select((group, index) => new { group.Key, ChapterNumber = index + 1 })
.Distinct() .ToDictionary(item => item.Key, item => item.ChapterNumber);
.Order() var sceneNumbers = orderedScenes
.ToList(); .GroupBy(scene => scene.ChapterID)
var sceneEvidence = sceneIds.Count == 0 .SelectMany(group => group.Select((scene, index) => new { scene.SceneID, SceneNumber = index + 1 }))
? "Discovered from canonical scene source text." .ToDictionary(item => item.SceneID, item => item.SceneNumber);
: $"Discovered from canonical scene source text. Source SceneIDs: {string.Join(", ", sceneIds)}.";
return DiscoverCharacterCandidates(manuscriptText) return DiscoverCharacterCandidates(manuscriptText)
.Where(IsAutoSelectedScanCharacterCandidate) .Where(IsAutoSelectedScanCharacterCandidate)
.Select((candidate, index) => new ManuscriptScanCharacterCandidatePreview .Select((candidate, index) =>
{
var evidenceScenes = CandidateSceneEvidence(candidate.Text, orderedScenes, chapterNumbers, sceneNumbers);
return new ManuscriptScanCharacterCandidatePreview
{ {
TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}", TemporaryCharacterKey = $"post-scene-{StableCandidateKey(candidate.Text, index)}",
Name = candidate.Text, Name = candidate.Text,
MentionCount = candidate.MentionCount, MentionCount = evidenceScenes.Sum(evidence => evidence.MentionCount),
QualityScore = candidate.QualityScore, QualityScore = candidate.QualityScore,
Category = candidate.Category, Category = candidate.Category,
Reason = string.IsNullOrWhiteSpace(candidate.Reason) Reason = string.IsNullOrWhiteSpace(candidate.Reason)
? sceneEvidence ? "Discovered from canonical scene source text."
: $"{candidate.Reason} {sceneEvidence}", : candidate.Reason,
IsExistingCharacterMatch = candidate.IsExistingCharacterMatch, IsExistingCharacterMatch = candidate.IsExistingCharacterMatch,
SuggestedImportance = candidate.Confidence SuggestedImportance = candidate.Confidence,
EvidenceScenes = evidenceScenes
};
})
.ToList();
}
private static IReadOnlyList<ManuscriptScanCharacterEvidenceScene> CandidateSceneEvidence(
string candidateText,
IReadOnlyList<SceneManuscriptSourceRecord> sourceScenes,
IReadOnlyDictionary<int, int> chapterNumbers,
IReadOnlyDictionary<int, int> sceneNumbers)
{
if (string.IsNullOrWhiteSpace(candidateText))
{
return [];
}
var pattern = $@"(?<![\p{{L}}\p{{N}}]){Regex.Escape(candidateText.Trim())}(?=(?:['’]s)?(?![\p{{L}}\p{{N}}])|$)";
var regex = new Regex(pattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
return sourceScenes
.Select(scene => 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(); .ToList();
} }

View File

@ -752,9 +752,22 @@ public sealed class ManuscriptScanReviewCharacterViewModel
public string? AliasTargetTemporaryCharacterKey { get; init; } public string? AliasTargetTemporaryCharacterKey { get; init; }
public string? AliasTargetName { get; init; } public string? AliasTargetName { get; init; }
public string SexHint { get; init; } = "Unknown"; public string SexHint { get; init; } = "Unknown";
public IReadOnlyList<ManuscriptScanCharacterEvidenceSceneViewModel> EvidenceScenes { get; init; } = [];
public int EvidenceSceneCount => EvidenceScenes.Count;
public bool IsAlias => !string.IsNullOrWhiteSpace(AliasTargetTemporaryCharacterKey); 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 sealed class ManuscriptScanExistingCharacterOptionViewModel
{ {
public int CharacterID { get; init; } public int CharacterID { get; init; }

View File

@ -16,7 +16,7 @@
<p> <p>
@if (coreComplete) @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) else if (Model.AlreadyBuilt)
{ {
@ -24,7 +24,7 @@
} }
else 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.
} }
</p> </p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) @if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
@ -100,6 +100,22 @@
<h2>Next: review characters</h2> <h2>Next: review characters</h2>
<p>Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.</p> <p>Character detection remains part of core import. Review detected names, aliases and existing matches to finish importing the manuscript.</p>
</section> </section>
<section class="onboarding-build-panel onboarding-marker-resync-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Need to repair the Word document?</p>
<p class="onboarding-build-message" data-onboarding-build-message>Resync Word markers uses the existing imported chapters and scenes. Scene detection is not rerun.</p>
</div>
<strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
<span data-onboarding-build-progress></span>
</div>
<div class="onboarding-manual-save-actions" data-onboarding-manual-save-actions hidden>
<button class="btn btn-primary btn-sm" type="button" data-onboarding-build-save-confirm disabled>
I've saved the Word document
</button>
</div>
</section>
} }
<div class="onboarding-actions"> <div class="onboarding-actions">
@ -111,6 +127,13 @@
else else
{ {
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a> <a class="btn btn-primary" asp-controller="Onboarding" asp-action="ScanCharacters" asp-route-previewId="@Model.PreviewID">Review characters</a>
<button class="btn btn-outline-primary"
type="button"
data-onboarding-build-start
data-onboarding-build-action
data-preview-id="@Model.PreviewID">
Resync Word markers
</button>
} }
<a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a> <a class="btn btn-outline-primary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Open Project Overview</a>
<a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a> <a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>

View File

@ -214,22 +214,30 @@
@foreach (var candidate in group.Candidates) @foreach (var candidate in group.Candidates)
{ {
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)); var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
<li class="@(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)"> <li class="onboarding-character-card @(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
<input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" /> <input type="hidden" name="Characters[@currentCharacterIndex].TemporaryCharacterKey" value="@candidate.TemporaryCharacterKey" />
<input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" /> <input type="hidden" name="Characters[@currentCharacterIndex].Category" value="@candidate.Category" />
<div class="onboarding-character-card-header">
<div>
<strong>@candidate.ReviewName</strong>
<span>@candidate.MentionCount.ToString("N0") mention@(candidate.MentionCount == 1 ? string.Empty : "s") across @candidate.EvidenceSceneCount.ToString("N0") scene@(candidate.EvidenceSceneCount == 1 ? string.Empty : "s")</span>
</div>
<small>@(candidate.IsExistingCharacterMatch ? "Likely existing character" : candidate.Category)</small>
</div>
<div class="onboarding-character-controls">
<label class="onboarding-review-toggle"> <label class="onboarding-review-toggle">
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" /> <input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" /> <input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
Include Create or link
</label> </label>
<label class="onboarding-review-field"> <label class="onboarding-review-field">
Name Character name
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" /> <input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
</label> </label>
<label class="onboarding-review-field"> <label class="onboarding-review-field">
Existing character Link existing
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID"> <select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
<option value="">Create later if imported</option> <option value="">Create new character</option>
@foreach (var option in Model.ExistingCharacterOptions) @foreach (var option in Model.ExistingCharacterOptions)
{ {
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option> <option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
@ -237,7 +245,7 @@
</select> </select>
</label> </label>
<label class="onboarding-review-field"> <label class="onboarding-review-field">
Alias of detected character Alias of
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey"> <select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
<option value="">Not an alias</option> <option value="">Not an alias</option>
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal))) @foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
@ -255,7 +263,8 @@
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option> <option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
</select> </select>
</label> </label>
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span> </div>
<div class="onboarding-character-card-notes">
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName)) @if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
{ {
<small>Alias of detected character: @candidate.AliasTargetName</small> <small>Alias of detected character: @candidate.AliasTargetName</small>
@ -264,10 +273,34 @@
{ {
<small>Matches existing character: @candidate.ExistingCharacterName</small> <small>Matches existing character: @candidate.ExistingCharacterName</small>
} }
@if (!string.IsNullOrWhiteSpace(candidate.Reason)) @if (!string.IsNullOrWhiteSpace(candidate.Reason) && !candidate.Reason.Contains("Source SceneIDs:", StringComparison.OrdinalIgnoreCase))
{ {
<small>@candidate.Reason</small> <small>@candidate.Reason</small>
} }
</div>
@if (candidate.EvidenceScenes.Any())
{
<details class="onboarding-character-evidence">
<summary>View evidence</summary>
<ol>
@foreach (var evidence in candidate.EvidenceScenes.Take(8))
{
<li data-scene-id="@evidence.SceneID">
<strong>@evidence.Label</strong>
<span>@evidence.MentionCount mention@(evidence.MentionCount == 1 ? string.Empty : "s")</span>
@if (!string.IsNullOrWhiteSpace(evidence.StructuralSummary))
{
<p>@evidence.StructuralSummary</p>
}
</li>
}
</ol>
@if (candidate.EvidenceScenes.Count > 8)
{
<small>@(candidate.EvidenceScenes.Count - 8) more scene@(candidate.EvidenceScenes.Count - 8 == 1 ? string.Empty : "s") not shown.</small>
}
</details>
}
</li> </li>
} }
</ul> </ul>

View File

@ -459,6 +459,10 @@
background: rgba(182, 138, 70, .12); background: rgba(182, 138, 70, .12);
} }
.onboarding-marker-resync-panel {
margin-top: 1rem;
}
.onboarding-manual-save-actions { .onboarding-manual-save-actions {
grid-column: 1 / -1; grid-column: 1 / -1;
display: flex; display: flex;
@ -1349,6 +1353,72 @@ summary.story-review-chapter-heading {
gap: .5rem; 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 { .onboarding-review-toggle {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;

View File

@ -2000,6 +2000,7 @@
const buildDocumentStructure = (paragraphs, selectionParagraphs) => { const buildDocumentStructure = (paragraphs, selectionParagraphs) => {
const chapters = []; const chapters = [];
const hasCanonicalSceneAnchors = paragraphs.some((paragraph) => paragraphAnchorId(paragraph, "PD-SCENE"));
let heading1Count = 0; let heading1Count = 0;
let heading2Count = 0; let heading2Count = 0;
let chapter = null; let chapter = null;
@ -2007,7 +2008,9 @@
paragraphs.forEach((paragraph, index) => { paragraphs.forEach((paragraph, index) => {
const title = String(paragraph.text || "").trim(); 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; return;
} }
@ -2017,15 +2020,26 @@
index: chapters.length + 1, index: chapters.length + 1,
paragraphIndex: index, paragraphIndex: index,
title, title,
anchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"), anchorId: chapterAnchorId,
scenes: [] scenes: []
}; };
chapters.push(chapter); chapters.push(chapter);
scene = null; 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; return;
} }
if (isBuiltInHeading(paragraph, 2)) { if (isBuiltInHeading(paragraph, 2) || sceneAnchorId) {
heading2Count += 1; heading2Count += 1;
if (!chapter) { if (!chapter) {
return; return;
@ -2034,15 +2048,17 @@
scene = { scene = {
index: chapter.scenes.length + 1, index: chapter.scenes.length + 1,
paragraphIndex: index, paragraphIndex: index,
title, title: isBuiltInHeading(paragraph, 2) ? title : `Scene ${chapter.scenes.length + 1}`,
anchorId: paragraphAnchorId(paragraph, "PD-SCENE"), anchorId: sceneAnchorId,
wordCount: 0 wordCount: 0
}; };
chapter.scenes.push(scene); chapter.scenes.push(scene);
if (isBuiltInHeading(paragraph, 2)) {
return; return;
} }
}
if (scene) { if (scene && title && (!hasCanonicalSceneAnchors || scene.anchorId)) {
scene.wordCount += countWords(title); scene.wordCount += countWords(title);
} }
}); });
@ -2144,8 +2160,8 @@
styleBuiltIn: paragraph?.styleBuiltIn || "", styleBuiltIn: paragraph?.styleBuiltIn || "",
style: paragraph?.style || "", style: paragraph?.style || "",
wordCount: countWords(text), wordCount: countWords(text),
chapterAnchorId: null, chapterAnchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"),
sceneAnchorId: null sceneAnchorId: paragraphAnchorId(paragraph, "PD-SCENE")
}; };
}; };
@ -2164,6 +2180,7 @@
const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => { const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => {
const paragraphSnapshots = paragraphs.map(snapshotParagraph); const paragraphSnapshots = paragraphs.map(snapshotParagraph);
const hasCanonicalSceneAnchors = paragraphSnapshots.some((paragraph) => paragraph.sceneAnchorId);
const chapters = []; const chapters = [];
let heading1Count = 0; let heading1Count = 0;
let sceneBoundaryCount = 0; let sceneBoundaryCount = 0;
@ -2210,7 +2227,12 @@
}; };
chapters.push(chapter); chapters.push(chapter);
scene = null; 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; documentWordCount += paragraph.wordCount;
continue; continue;
} }
@ -2219,6 +2241,13 @@
continue; 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)) { if (usesExplicitScenes && sceneSeparatorTexts.has(paragraph.text)) {
sceneBoundaryCount += 1; sceneBoundaryCount += 1;
startScene(paragraph); startScene(paragraph);
@ -3109,13 +3138,11 @@
try { try {
for (const paragraph of bodyParagraphs.items) { 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(); await context.sync();
} catch (error) { } 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); const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
@ -3131,6 +3158,15 @@
selectionParagraphs.load("text,styleBuiltIn,style"); selectionParagraphs.load("text,styleBuiltIn,style");
await context.sync(); 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 cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items); const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
documentStructureCache = cache; documentStructureCache = cache;
@ -4491,8 +4527,13 @@
return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null; 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 ensureAnchorControl = (paragraph, title, tag, prefix) => {
const existing = findAnchorControl(paragraph, prefix); const existing = findExactAnchorControl(paragraph, tag);
if (existing && existing.tag === tag && existing.title === title) { if (existing && existing.tag === tag && existing.title === title) {
return false; return false;
} }

View File

@ -165,6 +165,9 @@
const setBuildActionState = (text, disabled, mode = "") => { const setBuildActionState = (text, disabled, mode = "") => {
document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => { document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => {
if (!button.dataset.defaultActionText) {
button.dataset.defaultActionText = button.textContent.trim() || "Detect Scenes";
}
button.textContent = text; button.textContent = text;
button.disabled = disabled; button.disabled = disabled;
if (mode) { if (mode) {
@ -226,7 +229,12 @@
button.disabled = true; button.disabled = true;
button.textContent = "I've saved the Word document"; 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 () => { const confirmManualSaveFromWeb = async () => {