diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index bc6d3ff..12c79b3 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -2171,6 +2171,10 @@ 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(onboarding.Contains("missingSceneSources", StringComparison.Ordinal), "Retry should detect partial builds with missing scene source text."); + Assert(onboarding.Contains("Completing saved scene source text before updating Word markers", StringComparison.Ordinal), "Retry should repair missing scene sources before marker sync."); + Assert(onboarding.Contains("await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild)", StringComparison.Ordinal), "Retry should persist recovered scene source text for an existing build."); + Assert(onboarding.Contains("await manuscriptTextStatistics.RecalculateBookAsync(preview.BookID)", StringComparison.Ordinal), "Retry should repair missing scene text statistics when source text already exists."); 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."); diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index ea4eff0..e4dc699 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -260,9 +260,36 @@ public sealed class OnboardingService( ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); if (existingBuild is not null && existingBuild.SceneMappings.Count > 0) { + var existingSceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); + var sourceSceneIds = existingSceneSources.Select(source => source.SceneID).ToHashSet(); + var missingSceneSources = existingBuild.SceneMappings + .Where(mapping => !sourceSceneIds.Contains(mapping.SceneID)) + .ToList(); + var repairedSceneSources = missingSceneSources.Count > 0; + if (repairedSceneSources) + { + await PublishBuildProgress(userId, preview.PreviewID, "Completing saved scene source text before updating Word markers...", 65, progress, existingBuild); + var recoveredScenes = await AnalyseApprovedChaptersAsync(userId, preview, review, book, progress); + await SaveStructuralChapterSourcesAsync(preview, existingBuild); + await SaveSceneManuscriptSourcesAsync(preview, recoveredScenes, existingBuild); + await coreImportCharacterMapping.MapBookAsync(preview.ProjectID, preview.BookID); + } + else + { + await manuscriptTextStatistics.RecalculateBookAsync(preview.BookID); + } + var resumed = await BuildExistingStructureResultAsync(userId, preview, review, existingBuild); await scanPreviews.SaveBuildResultAsync(userId, resumed); - await PublishBuildProgress(userId, preview.PreviewID, "Resuming Word marker sync. No scene detection was rerun.", 90, progress, resumed); + await PublishBuildProgress( + userId, + preview.PreviewID, + repairedSceneSources + ? "Scene source text recovered. Updating Word markers." + : "Resuming Word marker sync. No scene detection was rerun.", + 90, + progress, + resumed); return resumed; } @@ -272,51 +299,7 @@ public sealed class OnboardingService( var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); await PublishBuildProgress(userId, preview.PreviewID, "Detecting scene boundaries...", 20, progress); - var structuralScenes = new List(); - for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++) - { - var chapterDecision = chapterDecisions[chapterIndex]; - var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey) - ?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview."); - if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText)) - { - throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' has no scanned text. Scan the manuscript again before importing."); - } - - if (chapterPreview.ParagraphMap.Count == 0) - { - throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing."); - } - - var analysisPercent = 20 + (chapterIndex * 20 / Math.Max(1, chapterDecisions.Count)); - await PublishBuildProgress( - userId, - preview.PreviewID, - $"Analysing Chapter {chapterIndex + 1:N0} of {chapterDecisions.Count:N0} - {chapterDecision.Title}", - analysisPercent, - progress); - var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest - { - ProjectID = preview.ProjectID, - BookID = preview.BookID, - PreviewID = preview.PreviewID, - TemporaryChapterKey = chapterDecision.TemporaryChapterKey, - ChapterNumber = chapterDecision.ChapterNumber, - SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}", - StoryEra = BookContextOptions.StoryEraLabel(book.StoryEra), - PrimaryLocale = BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode), - ChapterText = chapterPreview.ChapterText, - ParagraphMap = chapterPreview.ParagraphMap, - SourceParagraphs = chapterPreview.SourceParagraphs - }); - - if (!analysis.Success) - { - throw new InvalidOperationException($"Scene boundary detection failed for '{chapterDecision.Title}': {analysis.ErrorMessage}"); - } - - structuralScenes.AddRange(analysis.Scenes); - } + var structuralScenes = await AnalyseApprovedChaptersAsync(userId, preview, review, book, progress); await PublishBuildProgress(userId, preview.PreviewID, "Validating detected scene structure...", 45, progress); var request = new OnboardingManuscriptBuildRequest @@ -382,6 +365,63 @@ public sealed class OnboardingService( return result; } + private async Task> AnalyseApprovedChaptersAsync( + int userId, + ManuscriptScanPreview preview, + ManuscriptScanReviewDecision review, + Book book, + Func? progress) + { + var chapterDecisions = review.Chapters.Where(chapter => chapter.Include).OrderBy(chapter => chapter.ChapterNumber).ToList(); + var structuralScenes = new List(); + for (var chapterIndex = 0; chapterIndex < chapterDecisions.Count; chapterIndex++) + { + var chapterDecision = chapterDecisions[chapterIndex]; + var chapterPreview = preview.Chapters.FirstOrDefault(item => item.TemporaryChapterKey == chapterDecision.TemporaryChapterKey) + ?? throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' could not be found in the scan preview."); + if (string.IsNullOrWhiteSpace(chapterPreview.ChapterText)) + { + throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' has no scanned text. Scan the manuscript again before importing."); + } + + if (chapterPreview.ParagraphMap.Count == 0) + { + throw new InvalidOperationException($"The approved chapter '{chapterDecision.Title}' does not have paragraph mapping. Scan the manuscript again before importing."); + } + + var analysisPercent = 20 + (chapterIndex * 20 / Math.Max(1, chapterDecisions.Count)); + await PublishBuildProgress( + userId, + preview.PreviewID, + $"Analysing Chapter {chapterIndex + 1:N0} of {chapterDecisions.Count:N0} - {chapterDecision.Title}", + analysisPercent, + progress); + var analysis = await structureAnalysis.AnalyseChapterAsync(new ManuscriptStructureAnalysisRequest + { + ProjectID = preview.ProjectID, + BookID = preview.BookID, + PreviewID = preview.PreviewID, + TemporaryChapterKey = chapterDecision.TemporaryChapterKey, + ChapterNumber = chapterDecision.ChapterNumber, + SourceLabel = $"{preview.DocumentTitle ?? "Word manuscript"}: {chapterDecision.Title}", + StoryEra = BookContextOptions.StoryEraLabel(book.StoryEra), + PrimaryLocale = BookContextOptions.PrimaryLocaleLabel(book.PrimaryLocaleCode), + ChapterText = chapterPreview.ChapterText, + ParagraphMap = chapterPreview.ParagraphMap, + SourceParagraphs = chapterPreview.SourceParagraphs + }); + + if (!analysis.Success) + { + throw new InvalidOperationException($"Scene boundary detection failed for '{chapterDecision.Title}': {analysis.ErrorMessage}"); + } + + structuralScenes.AddRange(analysis.Scenes); + } + + return structuralScenes; + } + private async Task BuildExistingStructureResultAsync( int userId, ManuscriptScanPreview preview,