Fix core import scene follow and character evidence
This commit is contained in:
parent
f76e5bc388
commit
3740658f3e
@ -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<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()
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
@ -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<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
|
||||
|
||||
@ -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<OnboardingManuscriptBuildResult?> BuildApprovedStructureAsync(Guid previewId, Func<OnboardingBuildProgress, Task>? 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<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 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<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;
|
||||
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,
|
||||
|
||||
@ -726,10 +726,12 @@ public sealed class WordCompanionService(
|
||||
public static IReadOnlyList<ManuscriptScanCharacterCandidatePreview> DiscoverCoreImportCharacterCandidates(
|
||||
IReadOnlyList<SceneManuscriptSourceRecord> 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<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();
|
||||
}
|
||||
|
||||
@ -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<ManuscriptScanCharacterEvidenceSceneViewModel> 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; }
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
<p>
|
||||
@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.
|
||||
}
|
||||
</p>
|
||||
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
|
||||
@ -100,6 +100,22 @@
|
||||
<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>
|
||||
</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">
|
||||
@ -111,6 +127,13 @@
|
||||
else
|
||||
{
|
||||
<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-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
|
||||
|
||||
@ -214,59 +214,92 @@
|
||||
@foreach (var candidate in group.Candidates)
|
||||
{
|
||||
var currentCharacterIndex = allCharacters.FindIndex(item => string.Equals(item.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal));
|
||||
<li class="@(invalidCharacterKeys.Contains(candidate.TemporaryCharacterKey) ? "is-invalid" : string.Empty)">
|
||||
<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].Category" value="@candidate.Category" />
|
||||
<label class="onboarding-review-toggle">
|
||||
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
|
||||
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
|
||||
Include
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Name
|
||||
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Existing character
|
||||
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
|
||||
<option value="">Create later if imported</option>
|
||||
@foreach (var option in Model.ExistingCharacterOptions)
|
||||
<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">
|
||||
<input type="checkbox" name="Characters[@currentCharacterIndex].Include" value="true" checked="@candidate.Include" />
|
||||
<input type="hidden" name="Characters[@currentCharacterIndex].Include" value="false" />
|
||||
Create or link
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Character name
|
||||
<input class="form-control" name="Characters[@currentCharacterIndex].Name" value="@candidate.ReviewName" />
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Link existing
|
||||
<select class="form-select" name="Characters[@currentCharacterIndex].ExistingCharacterID">
|
||||
<option value="">Create new character</option>
|
||||
@foreach (var option in Model.ExistingCharacterOptions)
|
||||
{
|
||||
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Alias of
|
||||
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
|
||||
<option value="">Not an alias</option>
|
||||
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
||||
{
|
||||
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Sex
|
||||
<select class="form-select" name="Characters[@currentCharacterIndex].SexHint">
|
||||
<option value="Unknown" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal))">Unknown</option>
|
||||
<option value="Female" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Female, StringComparison.Ordinal))">Female</option>
|
||||
<option value="Male" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Male, StringComparison.Ordinal))">Male</option>
|
||||
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="onboarding-character-card-notes">
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
||||
{
|
||||
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
|
||||
{
|
||||
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.Reason) && !candidate.Reason.Contains("Source SceneIDs:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<option value="@option.CharacterID" selected="@(candidate.ExistingCharacterID == option.CharacterID)">@option.CharacterName</option>
|
||||
<small>@(candidate.EvidenceScenes.Count - 8) more scene@(candidate.EvidenceScenes.Count - 8 == 1 ? string.Empty : "s") not shown.</small>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Alias of detected character
|
||||
<select class="form-select" name="Characters[@currentCharacterIndex].AliasTargetTemporaryCharacterKey">
|
||||
<option value="">Not an alias</option>
|
||||
@foreach (var target in detectedAliasTargets.Where(target => !string.Equals(target.TemporaryCharacterKey, candidate.TemporaryCharacterKey, StringComparison.Ordinal)))
|
||||
{
|
||||
<option value="@target.TemporaryCharacterKey" selected="@(string.Equals(candidate.AliasTargetTemporaryCharacterKey, target.TemporaryCharacterKey, StringComparison.Ordinal))">@target.ReviewName</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<label class="onboarding-review-field">
|
||||
Sex
|
||||
<select class="form-select" name="Characters[@currentCharacterIndex].SexHint">
|
||||
<option value="Unknown" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Unknown, StringComparison.Ordinal))">Unknown</option>
|
||||
<option value="Female" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Female, StringComparison.Ordinal))">Female</option>
|
||||
<option value="Male" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.Male, StringComparison.Ordinal))">Male</option>
|
||||
<option value="NonBinaryOrOther" selected="@(string.Equals(candidate.SexHint, CharacterSexHintService.NonBinaryOrOther, StringComparison.Ordinal))">Non-binary / other</option>
|
||||
</select>
|
||||
</label>
|
||||
<span>@candidate.MentionCount mention@(candidate.MentionCount == 1 ? string.Empty : "s") / score @candidate.QualityScore</span>
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.AliasTargetName))
|
||||
{
|
||||
<small>Alias of detected character: @candidate.AliasTargetName</small>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.ExistingCharacterName))
|
||||
{
|
||||
<small>Matches existing character: @candidate.ExistingCharacterName</small>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(candidate.Reason))
|
||||
{
|
||||
<small>@candidate.Reason</small>
|
||||
</details>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user