diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index e55cf85..3d4e6a5 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -82,6 +82,7 @@ var tests = new (string Name, Action Test)[] ("Scan review separates known matches from unresolved names", ScanReviewSeparatesKnownMatchesFromUnresolvedNames), ("Story Intelligence uses persisted scene source after restart", StoryIntelligenceUsesPersistedSceneSourceAfterRestart), ("Word sync requires save acknowledgement", WordSyncRequiresSaveAcknowledgement), + ("Word sync retry resumes existing structure", WordSyncRetryResumesExistingStructure), ("Story Intelligence enriches existing structural scenes", StoryIntelligenceEnrichesExistingStructuralScenes), ("Phase 21T repairs durable character understanding", Phase21TRepairsDurableCharacterUnderstanding), ("Phase 21U extracts mandatory character appearance fixtures", Phase21UExtractsMandatoryCharacterAppearanceFixtures), @@ -1921,14 +1922,39 @@ static void WordSyncRequiresSaveAcknowledgement() var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js")); - var migration = File.ReadAllText(Path.Combine(root, "Sql/149_Phase24_StructuralImportMarkerSync.sql")); + var migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql")); Assert(companion.Contains("saveWordDocumentIfAvailable", StringComparison.Ordinal), "Companion should save Word before acknowledging marker sync."); + Assert(companion.Contains("markersChanged", StringComparison.Ordinal), "Companion should distinguish marker insertion from an idempotent no-change retry."); + Assert(companion.Contains("SaveNotRequired", StringComparison.Ordinal), "Companion should acknowledge marker sync when no Word save is required."); + Assert(companion.Contains("UserConfirmedSave", StringComparison.Ordinal), "Companion should support an explicit manual save acknowledgement."); + Assert(companion.Contains("RequireOnboardingBuildManualSave", StringComparison.Ordinal), "Companion should report manual-save-required state without throwing a generic SignalR error."); Assert(companion.Contains("CompleteOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should acknowledge successful marker sync."); Assert(companion.Contains("FailOnboardingBuildMarkerSync", StringComparison.Ordinal), "Companion should report failed marker sync."); + Assert(companion.Contains("officeRuntimeDiagnostics", StringComparison.Ordinal), "Companion should capture Word host diagnostics for save failures."); + Assert(hub.Contains("MarkerSyncClientResult", StringComparison.Ordinal), "Server should accept marker sync diagnostics."); Assert(hub.Contains("MarkMarkerSyncCompletedAsync", StringComparison.Ordinal), "Server should persist completed marker sync."); + Assert(hub.Contains("MarkMarkerSyncUserSaveRequiredAsync", StringComparison.Ordinal), "Server should persist manual-save-required marker sync."); Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting."); Assert(migration.Contains("MarkerSyncCompletedUtc", StringComparison.Ordinal), "Database should persist marker sync completion time."); + Assert(migration.Contains("UserSaveRequired", StringComparison.Ordinal), "Database should persist manual-save-required marker sync state."); + Assert(migration.Contains("SaveNotRequired", StringComparison.Ordinal), "Database should persist no-change marker sync state."); +} + +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 migration = File.ReadAllText(Path.Combine(root, "Sql/158_Phase25C_OnboardingMarkerSaveStates.sql")); + + var resumeCheck = onboarding.IndexOf("await builds.GetByPreviewAsync(userId, preview.PreviewID)", StringComparison.Ordinal); + var sceneDetection = onboarding.IndexOf("\"Detecting scene boundaries...\"", StringComparison.Ordinal); + 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(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."); } static void StoryIntelligenceEnrichesExistingStructuralScenes() diff --git a/PlotLine/Data/OnboardingBuildRepository.cs b/PlotLine/Data/OnboardingBuildRepository.cs index 3811bd1..9a5eb74 100644 --- a/PlotLine/Data/OnboardingBuildRepository.cs +++ b/PlotLine/Data/OnboardingBuildRepository.cs @@ -8,9 +8,11 @@ namespace PlotLine.Data; public interface IOnboardingBuildRepository { Task BuildAsync(int userId, OnboardingManuscriptBuildRequest request); + Task GetByPreviewAsync(int userId, Guid previewId); Task MarkMarkerSyncSentAsync(int buildId); - Task MarkMarkerSyncCompletedAsync(int buildId); - Task MarkMarkerSyncFailedAsync(int buildId, string message); + Task MarkMarkerSyncCompletedAsync(int buildId, string markerStatus, string message); + Task MarkMarkerSyncUserSaveRequiredAsync(int buildId, string message); + Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null); Task SaveStructuralChapterSourceAsync(StructuralChapterSourceSaveRequest request); Task SaveSceneManuscriptSourceAsync(SceneManuscriptSourceSaveRequest request); Task SaveSceneStructuralSummaryAsync(int sceneId, string structuralSummary); @@ -38,6 +40,22 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa }, commandType: CommandType.StoredProcedure); + return await ReadBuildResultAsync(result); + } + + public async Task GetByPreviewAsync(int userId, Guid previewId) + { + using var connection = connectionFactory.CreateConnection(); + using var result = await connection.QueryMultipleAsync( + "dbo.Onboarding_ManuscriptBuild_GetByPreview", + new { UserID = userId, PreviewID = previewId }, + commandType: CommandType.StoredProcedure); + + return await ReadBuildResultAsync(result); + } + + private static async Task ReadBuildResultAsync(SqlMapper.GridReader result) + { var header = await result.ReadSingleOrDefaultAsync(); if (header is null) { @@ -78,21 +96,30 @@ public sealed class OnboardingBuildRepository(ISqlConnectionFactory connectionFa commandType: CommandType.StoredProcedure); } - public async Task MarkMarkerSyncCompletedAsync(int buildId) + public async Task MarkMarkerSyncCompletedAsync(int buildId, string markerStatus, string message) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted", - new { BuildID = buildId }, + new { BuildID = buildId, MarkerStatus = markerStatus, Message = message }, commandType: CommandType.StoredProcedure); } - public async Task MarkMarkerSyncFailedAsync(int buildId, string message) + public async Task MarkMarkerSyncUserSaveRequiredAsync(int buildId, string message) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.Onboarding_ManuscriptBuild_MarkerSyncUserSaveRequired", + new { BuildID = buildId, Message = message }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkMarkerSyncFailedAsync(int buildId, string message, string? diagnostics = null) { using var connection = connectionFactory.CreateConnection(); await connection.ExecuteAsync( "dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed", - new { BuildID = buildId, Message = message }, + new { BuildID = buildId, Message = message, Diagnostics = diagnostics }, commandType: CommandType.StoredProcedure); } diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index 370b7f6..440328b 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -4,6 +4,7 @@ using PlotLine.Data; using PlotLine.Models; using PlotLine.Services; using System.Security.Claims; +using System.Text.Json; namespace PlotLine.Hubs; @@ -262,23 +263,74 @@ public sealed class WordCompanionFollowHub( return result; } - public async Task CompleteOnboardingBuildMarkerSync(int buildId) + public async Task CompleteOnboardingBuildMarkerSync(int buildId, MarkerSyncClientResult? result = null) { var userId = RequireUserId(); - await onboardingBuilds.MarkMarkerSyncCompletedAsync(buildId); + var markerStatus = NormaliseCompletedMarkerStatus(result?.MarkerStatus); + var message = string.IsNullOrWhiteSpace(result?.Message) + ? markerStatus switch + { + "SaveNotRequired" => "Scene markers already existed. No Word save was required.", + "UserConfirmedSave" => "Scene markers added; user confirmed the Word document was saved.", + _ => "Scene markers added and document saved." + } + : result.Message; + + logger.LogInformation( + "Onboarding marker sync completed. UserID={UserID} BuildID={BuildID} MarkerStatus={MarkerStatus} MarkersChanged={MarkersChanged} SaveState={SaveState} SaveApi={SaveApi} Host={Host} Platform={Platform} Version={Version} DocumentGuid={DocumentGuid}", + userId, + buildId, + markerStatus, + result?.MarkersChanged, + result?.SaveState, + result?.SaveApi, + result?.Host, + result?.Platform, + result?.Version, + result?.DocumentGuid); + + await onboardingBuilds.MarkMarkerSyncCompletedAsync(buildId, markerStatus, message); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress { Status = "Complete", Message = "Manuscript structure has been created and saved. Word may now be closed; keep this browser workflow open to review characters.", PercentComplete = 100 }); - await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId }); + await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId, markerStatus }); } - public async Task FailOnboardingBuildMarkerSync(int buildId, string message) + public async Task RequireOnboardingBuildManualSave(int buildId, string message, MarkerSyncClientResult? result = null) { var userId = RequireUserId(); - await onboardingBuilds.MarkMarkerSyncFailedAsync(buildId, message); + logger.LogWarning( + "Onboarding marker sync requires manual Word save. UserID={UserID} BuildID={BuildID} Message={Message} Diagnostics={Diagnostics}", + userId, + buildId, + message, + SerialiseMarkerDiagnostics(result)); + + await onboardingBuilds.MarkMarkerSyncUserSaveRequiredAsync(buildId, message); + await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress + { + Status = "Running", + Message = string.IsNullOrWhiteSpace(message) + ? "Scene markers have been added. Save the document in Word, then continue the import again." + : message, + PercentComplete = 90 + }); + } + + public async Task FailOnboardingBuildMarkerSync(int buildId, string message, MarkerSyncClientResult? result = null) + { + var userId = RequireUserId(); + logger.LogWarning( + "Onboarding marker sync failed. UserID={UserID} BuildID={BuildID} Message={Message} Diagnostics={Diagnostics}", + userId, + buildId, + message, + SerialiseMarkerDiagnostics(result)); + + await onboardingBuilds.MarkMarkerSyncFailedAsync(buildId, message, SerialiseMarkerDiagnostics(result)); await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress { Status = "Failed", @@ -343,6 +395,12 @@ public sealed class WordCompanionFollowHub( => string.Equals(state.WritingSoftware, WritingSoftwareValues.MicrosoftWord, StringComparison.Ordinal) || state.UsesWordCompanion == true; + private static string NormaliseCompletedMarkerStatus(string? markerStatus) + => markerStatus is "SaveNotRequired" or "UserConfirmedSave" ? markerStatus : "Completed"; + + private static string? SerialiseMarkerDiagnostics(MarkerSyncClientResult? result) + => result is null ? null : JsonSerializer.Serialize(result); + private static ManuscriptScanPreview NormalisePreviewContext(ManuscriptScanPreview preview, int userId, UserOnboardingState state) { return new() @@ -366,3 +424,20 @@ public sealed class WordCompanionFollowHub( }; } } + +public sealed class MarkerSyncClientResult +{ + public string? MarkerStatus { get; init; } + public string? Message { get; init; } + public bool? MarkersChanged { get; init; } + public string? SaveState { get; init; } + public string? SaveApi { get; init; } + public string? Host { get; init; } + public string? Platform { get; init; } + public string? Version { get; init; } + public string? DocumentGuid { get; init; } + public string? DocumentUrl { get; init; } + public string? ErrorName { get; init; } + public string? ErrorCode { get; init; } + public string? ErrorMessage { get; init; } +} diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index 42cfbd5..d3dfd6e 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -235,6 +235,16 @@ public sealed class OnboardingService( throw new InvalidOperationException("Review the scan and choose Save and continue before preparing chapters."); } + var existingBuild = await scanPreviews.GetBuildResultAsync(userId, preview.PreviewID) + ?? await builds.GetByPreviewAsync(userId, preview.PreviewID); + if (existingBuild is not null && existingBuild.SceneMappings.Count > 0) + { + 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); + return resumed; + } + await PublishBuildProgress(userId, preview.PreviewID, "Preparing approved chapters for scene detection...", 10, progress); ValidateScanStructure(review, preview); @@ -348,6 +358,42 @@ public sealed class OnboardingService( return result; } + private async Task BuildExistingStructureResultAsync( + int userId, + ManuscriptScanPreview preview, + ManuscriptScanReviewDecision review, + OnboardingManuscriptBuildResult existingBuild) + { + var sceneSources = await builds.ListSceneManuscriptSourcesByBookAsync(preview.BookID); + var characterCandidates = WordCompanionService.DiscoverCoreImportCharacterCandidates(sceneSources); + await scanPreviews.SaveCharacterCandidatesAsync(userId, preview.PreviewID, characterCandidates); + + return new OnboardingManuscriptBuildResult + { + BuildID = existingBuild.BuildID, + PreviewID = existingBuild.PreviewID, + ProjectID = existingBuild.ProjectID, + BookID = existingBuild.BookID, + ManuscriptDocumentID = existingBuild.ManuscriptDocumentID, + Status = existingBuild.Status, + MarkerStatus = existingBuild.MarkerStatus, + ChaptersCreated = existingBuild.ChaptersCreated, + ScenesCreated = existingBuild.ScenesCreated, + CharactersCreated = existingBuild.CharactersCreated, + CharactersReused = existingBuild.CharactersReused, + SceneAppearancesCreated = existingBuild.SceneAppearancesCreated, + AlreadyBuilt = true, + Message = "Manuscript structure already exists. Updating and saving Word markers...", + ChapterMappings = existingBuild.ChapterMappings, + SceneMappings = existingBuild.SceneMappings, + CharacterMappings = existingBuild.CharacterMappings, + ChapterMarkerInstructions = BuildChapterMarkerInstructions(preview, existingBuild.ChapterMappings), + SceneMarkerInstructions = BuildSceneMarkerInstructionsFromSources(sceneSources, existingBuild.SceneMappings), + SceneStructure = BuildSceneStructureSummaryFromSources(review.Chapters.Where(chapter => chapter.Include).ToList(), sceneSources), + MarkerWarning = existingBuild.MarkerWarning + }; + } + public async Task GetBuildResultAsync(Guid previewId) => await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId); @@ -514,6 +560,31 @@ public sealed class OnboardingService( .ToList(); } + private static IReadOnlyList BuildSceneMarkerInstructionsFromSources( + IReadOnlyList sources, + IReadOnlyList mappings) + { + var sourcesBySceneId = sources + .Where(source => source.WordStartParagraphIndex.HasValue) + .ToDictionary(source => source.SceneID); + return mappings + .Where(mapping => sourcesBySceneId.ContainsKey(mapping.SceneID)) + .Select(mapping => + { + var source = sourcesBySceneId[mapping.SceneID]; + return new OnboardingBuildMarkerInstruction + { + TemporaryKey = mapping.TemporarySceneKey, + EntityID = mapping.SceneID, + WordParagraphIndex = source.WordStartParagraphIndex!.Value, + MarkerTag = $"PD-SCENE-{mapping.SceneID}", + MarkerTitle = "PlotDirector Scene", + MarkerPrefix = "PD-SCENE" + }; + }) + .ToList(); + } + private static IReadOnlyList BuildSceneStructureSummary( IReadOnlyList chapters, IReadOnlyList scenes) @@ -545,6 +616,44 @@ public sealed class OnboardingService( }) .ToList(); + private static IReadOnlyList BuildSceneStructureSummaryFromSources( + IReadOnlyList chapters, + IReadOnlyList sources) + { + var byChapter = sources + .GroupBy(source => source.ChapterID) + .OrderBy(group => group.Min(source => source.SourceStartParagraph ?? int.MaxValue)) + .ThenBy(group => group.Key) + .ToList(); + return chapters + .OrderBy(chapter => chapter.ChapterNumber) + .Select((chapter, index) => + { + var chapterSources = byChapter.ElementAtOrDefault(index)? + .OrderBy(source => source.SourceStartParagraph ?? int.MaxValue) + .ThenBy(source => source.SceneID) + .ToList() ?? []; + return new OnboardingSceneStructureSummary + { + TemporaryChapterKey = chapter.TemporaryChapterKey, + ChapterNumber = chapter.ChapterNumber, + ChapterTitle = chapter.Title, + SceneCount = chapterSources.Count, + WordCount = chapterSources.Sum(scene => scene.SourceWordCount), + Scenes = chapterSources.Select((scene, sceneIndex) => new OnboardingSceneStructureDetail + { + SceneNumberWithinChapter = sceneIndex + 1, + Title = $"Scene {sceneIndex + 1:N0}", + WordCount = scene.SourceWordCount, + StructuralSummary = scene.StructuralSummary, + StartParagraph = scene.SourceStartParagraph ?? 0, + EndParagraph = scene.SourceEndParagraph ?? 0 + }).ToList() + }; + }) + .ToList(); + } + private async Task SaveStructuralChapterSourcesAsync(ManuscriptScanPreview preview, OnboardingManuscriptBuildResult result) { var chapterIds = result.ChapterMappings.ToDictionary(item => item.TemporaryChapterKey, item => item.ChapterID, StringComparer.Ordinal); diff --git a/PlotLine/Sql/158_Phase25C_OnboardingMarkerSaveStates.sql b/PlotLine/Sql/158_Phase25C_OnboardingMarkerSaveStates.sql new file mode 100644 index 0000000..67feda1 --- /dev/null +++ b/PlotLine/Sql/158_Phase25C_OnboardingMarkerSaveStates.sql @@ -0,0 +1,109 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_GetByPreview + @UserID int, + @PreviewID uniqueidentifier +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @BuildID int; + SELECT @BuildID = OnboardingManuscriptBuildID + FROM dbo.OnboardingManuscriptBuilds + WHERE PreviewID = @PreviewID + AND UserID = @UserID; + + IF @BuildID IS NULL + RETURN; + + SELECT OnboardingManuscriptBuildID AS BuildID, PreviewID, ProjectID, BookID, ManuscriptDocumentID, Status, MarkerStatus, + ChaptersCreated, ScenesCreated, CharactersCreated, CharactersReused, SceneAppearancesCreated, + CAST(1 AS bit) AS AlreadyBuilt, + CASE MarkerStatus + WHEN N'Completed' THEN N'This manuscript structure has already been built and saved.' + WHEN N'SaveNotRequired' THEN N'This manuscript structure has already been built; the expected Word markers were already present.' + WHEN N'UserConfirmedSave' THEN N'This manuscript structure has already been built and the Word save was confirmed.' + WHEN N'UserSaveRequired' THEN N'This manuscript structure has already been built. Save the Word document, then continue the import again.' + ELSE N'This manuscript structure has already been built into PlotDirector.' + END AS Message + FROM dbo.OnboardingManuscriptBuilds + WHERE OnboardingManuscriptBuildID = @BuildID; + + SELECT TemporaryChapterKey, ChapterID + FROM dbo.OnboardingManuscriptBuildChapterMap + WHERE OnboardingManuscriptBuildID = @BuildID + ORDER BY TemporaryChapterKey; + + SELECT TemporarySceneKey, SceneID + FROM dbo.OnboardingManuscriptBuildSceneMap + WHERE OnboardingManuscriptBuildID = @BuildID + ORDER BY TemporarySceneKey; + + SELECT TemporaryCharacterKey, CharacterID, Created + FROM dbo.OnboardingManuscriptBuildCharacterMap + WHERE OnboardingManuscriptBuildID = @BuildID + ORDER BY TemporaryCharacterKey; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncCompleted + @BuildID int, + @MarkerStatus nvarchar(40) = N'Completed', + @Message nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + + IF @MarkerStatus NOT IN (N'Completed', N'SaveNotRequired', N'UserConfirmedSave') + SET @MarkerStatus = N'Completed'; + + UPDATE dbo.OnboardingManuscriptBuilds + SET MarkerStatus = @MarkerStatus, + MarkerSyncMessage = COALESCE( + LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500), + CASE @MarkerStatus + WHEN N'SaveNotRequired' THEN N'Scene markers already existed. No Word save was required.' + WHEN N'UserConfirmedSave' THEN N'Scene markers added; user confirmed the Word document was saved.' + ELSE N'Scene markers added and document saved.' + END), + MarkerSyncCompletedUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncUserSaveRequired + @BuildID int, + @Message nvarchar(500) = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.OnboardingManuscriptBuilds + SET MarkerStatus = N'UserSaveRequired', + MarkerSyncMessage = COALESCE( + LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500), + N'Scene markers have been added. Save the document in Word, then continue the import again.'), + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.Onboarding_ManuscriptBuild_MarkerSyncFailed + @BuildID int, + @Message nvarchar(500) = NULL, + @Diagnostics nvarchar(max) = NULL +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.OnboardingManuscriptBuilds + SET MarkerStatus = N'Failed', + MarkerSyncMessage = LEFT(NULLIF(LTRIM(RTRIM(@Message)), N''), 500), + UpdatedUtc = SYSUTCDATETIME() + WHERE OnboardingManuscriptBuildID = @BuildID; +END; +GO diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index ccb684e..2f3cd3c 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -1771,18 +1771,81 @@ }); }); + const officeRuntimeDiagnostics = () => { + const diagnostics = window.Office?.context?.diagnostics || {}; + const document = window.Office?.context?.document; + return { + host: diagnostics.host || window.Office?.context?.host || officeHost || "", + platform: diagnostics.platform || window.Office?.context?.platform || officePlatform || "", + version: diagnostics.version || "", + documentUrl: document?.url || "", + documentGuid: currentDocumentGuid() + }; + }; + + const officeAsyncErrorDetails = (error) => ({ + errorName: error?.name || "", + errorCode: error?.code || "", + errorMessage: error?.message || errorText(error) + }); + const saveWordDocumentIfAvailable = () => new Promise((resolve) => { const document = window.Office?.context?.document; if (!document || typeof document.saveAsync !== "function") { - resolve(false); + resolve({ + saveState: "Unsupported", + saveApi: "Office.context.document.saveAsync", + ...officeRuntimeDiagnostics(), + errorMessage: "Office.context.document.saveAsync is not available in this Word host." + }); return; } document.saveAsync((result) => { - resolve(result.status === window.Office.AsyncResultStatus.Succeeded); + const succeeded = result.status === window.Office.AsyncResultStatus.Succeeded; + resolve({ + saveState: succeeded ? "Saved" : "Failed", + saveApi: "Office.context.document.saveAsync", + ...officeRuntimeDiagnostics(), + ...(succeeded ? {} : officeAsyncErrorDetails(result.error)) + }); }); }); + const invokeMarkerSyncFailure = async (buildId, message, diagnostics = {}) => { + if (Number.isInteger(buildId) && buildId > 0 && companionPresenceConnection) { + try { + await companionPresenceConnection.invoke("FailOnboardingBuildMarkerSync", buildId, message, diagnostics); + } catch (error) { + console.warn("Unable to report onboarding marker sync failure.", error); + } + } + }; + + const invokeMarkerSyncManualSaveRequired = async (buildId, message, diagnostics = {}) => { + if (Number.isInteger(buildId) && buildId > 0 && companionPresenceConnection) { + try { + await companionPresenceConnection.invoke("RequireOnboardingBuildManualSave", buildId, message, diagnostics); + } catch (error) { + console.warn("Unable to report onboarding manual save requirement.", error); + } + } + }; + + const invokeMarkerSyncCompleted = async (buildId, markerStatus, message, diagnostics = {}) => { + if (Number.isInteger(buildId) && buildId > 0 && companionPresenceConnection) { + try { + await companionPresenceConnection.invoke("CompleteOnboardingBuildMarkerSync", buildId, { + markerStatus, + message, + ...diagnostics + }); + } catch (error) { + console.warn("Unable to report onboarding marker sync completion.", error); + } + } + }; + const ensureDocumentGuid = () => { if (firstRunDocumentBinding?.documentGuid) { return firstRunDocumentBinding.documentGuid; @@ -2368,10 +2431,6 @@ }; const updateOnboardingBuildMarkers = async (result) => { - if (!wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { - throw new Error("Word document access is unavailable."); - } - const buildId = Number.parseInt(result?.buildID || result?.BuildID || "0", 10); const chapterMap = new Map((result?.chapterMappings || result?.ChapterMappings || []) .map((item) => [item.temporaryChapterKey || item.TemporaryChapterKey, item.chapterID || item.ChapterID])); @@ -2384,6 +2443,11 @@ } try { + if (!wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { + throw new Error("Word document access is unavailable."); + } + + let markersChanged = false; await window.Word.run(async (context) => { const paragraphs = context.document.body.paragraphs; paragraphs.load("items/text,items/style,items/styleBuiltIn"); @@ -2396,7 +2460,7 @@ const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Chapter"; const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-CHAPTER"; if (tag && Number.isInteger(index) && paragraphs.items[index]) { - ensureAnchorControl(paragraphs.items[index], title, tag, prefix); + markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged; } } @@ -2406,7 +2470,7 @@ const title = instruction.markerTitle || instruction.MarkerTitle || "PlotDirector Scene"; const prefix = instruction.markerPrefix || instruction.MarkerPrefix || "PD-SCENE"; if (tag && Number.isInteger(index) && paragraphs.items[index]) { - ensureAnchorControl(paragraphs.items[index], title, tag, prefix); + markersChanged = ensureAnchorControl(paragraphs.items[index], title, tag, prefix) || markersChanged; } } } else { @@ -2414,7 +2478,7 @@ const chapterId = chapterMap.get(chapter.temporaryChapterKey); const index = Number.parseInt(chapter.startPosition, 10); if (chapterId && Number.isInteger(index) && paragraphs.items[index]) { - ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER"); + markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER") || markersChanged; } } @@ -2422,7 +2486,7 @@ const sceneId = sceneMap.get(scene.temporarySceneKey); const index = Number.parseInt(scene.startPosition, 10); if (sceneId && Number.isInteger(index) && paragraphs.items[index]) { - ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE"); + markersChanged = ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE") || markersChanged; } } } @@ -2430,20 +2494,71 @@ await context.sync(); }); - const saved = await saveWordDocumentIfAvailable(); - if (!saved) { - throw new Error("Word markers were inserted, but the document could not be saved."); + await inspectPlotDirectorMarkers().catch(() => null); + if (!markersChanged) { + const diagnostics = { + markersChanged: false, + saveState: "NotRequired", + saveApi: "none", + ...officeRuntimeDiagnostics() + }; + console.info("Onboarding marker sync completed without Word document changes.", diagnostics); + await invokeMarkerSyncCompleted( + buildId, + "SaveNotRequired", + "Scene markers already existed. No Word save was required.", + diagnostics); + setDocumentMessage("Scene markers already existed. Word may now be closed."); + setDiagnostics({ lastError: "-" }); + return; } - await inspectPlotDirectorMarkers().catch(() => null); - if (Number.isInteger(buildId) && buildId > 0) { - await companionPresenceConnection.invoke("CompleteOnboardingBuildMarkerSync", buildId); + const saveResult = await saveWordDocumentIfAvailable(); + const saveDiagnostics = { + markersChanged: true, + ...saveResult + }; + console.info("Onboarding marker sync Word save result.", saveDiagnostics); + if (saveResult.saveState === "Saved") { + await invokeMarkerSyncCompleted( + buildId, + "Completed", + "Scene markers added and document saved.", + saveDiagnostics); + setDocumentMessage("Scene markers added and document saved. Word may now be closed."); + setDiagnostics({ lastError: "-" }); + return; } + + const manualMessage = "Scene markers have been added. Save the document in Word now, then click OK to continue."; + setDocumentMessage(manualMessage); + setDiagnostics({ lastError: saveResult.errorMessage || "Word document save requires manual confirmation." }); + const userConfirmedSave = window.confirm(`${manualMessage}\n\nClick Cancel if the document was not saved.`); + if (userConfirmedSave) { + await invokeMarkerSyncCompleted( + buildId, + "UserConfirmedSave", + "Scene markers added; user confirmed the Word document was saved.", + saveDiagnostics); + setDocumentMessage("Scene markers added and save confirmed. Word may now be closed."); + setDiagnostics({ lastError: "-" }); + return; + } + + await invokeMarkerSyncManualSaveRequired( + buildId, + "Scene markers were added, but Word save still needs confirmation. Save the document in Word, then continue the import again.", + saveDiagnostics); } catch (error) { - if (Number.isInteger(buildId) && buildId > 0) { - await companionPresenceConnection.invoke("FailOnboardingBuildMarkerSync", buildId, errorText(error)); - } - throw error; + console.error("Unable to update onboarding Word markers.", error); + setDocumentMessage("Unable to update PlotDirector scene markers."); + setDiagnostics({ lastError: errorText(error) }); + await invokeMarkerSyncFailure(buildId, errorText(error), { + markersChanged: null, + saveState: "Failed", + ...officeRuntimeDiagnostics(), + errorMessage: errorText(error) + }); } }; @@ -4336,11 +4451,15 @@ const ensureAnchorControl = (paragraph, title, tag, prefix) => { const existing = findAnchorControl(paragraph, prefix); + if (existing && existing.tag === tag && existing.title === title) { + return false; + } + const control = existing || paragraph.getRange().insertContentControl(); control.title = title; control.tag = tag; control.appearance = "Hidden"; - return control; + return true; }; const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {