From f76e5bc3880968655eeed7601de10b016fe27696 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Wed, 26 Aug 2026 13:00:47 +0000 Subject: [PATCH] Add explicit Word save confirmation for import --- PlotLine.Tests/Program.cs | 13 +- PlotLine/Hubs/WordCompanionFollowHub.cs | 23 +++- PlotLine/Models/ManuscriptScanModels.cs | 2 + PlotLine/Services/OnboardingService.cs | 6 +- PlotLine/Views/Onboarding/Index.cshtml | 6 + PlotLine/Views/WordCompanionHost/Index.cshtml | 10 ++ PlotLine/wwwroot/css/onboarding.css | 13 ++ PlotLine/wwwroot/css/word-companion.css | 9 ++ PlotLine/wwwroot/js/word-companion-host.js | 103 ++++++++++----- .../wwwroot/js/word-companion-presence.js | 124 +++++++++++++++++- 10 files changed, 269 insertions(+), 40 deletions(-) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index e3665a8..0ea75f9 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -1926,10 +1926,14 @@ static void WordSyncRequiresSaveAcknowledgement() var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine"); var companion = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js")); var hub = File.ReadAllText(Path.Combine(root, "Hubs/WordCompanionFollowHub.cs")); + var models = File.ReadAllText(Path.Combine(root, "Models/ManuscriptScanModels.cs")); var presence = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-presence.js")); + var onboardingView = File.ReadAllText(Path.Combine(root, "Views/Onboarding/Index.cshtml")); + var wordHostView = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml")); 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("window.confirm(`${manualMessage}", StringComparison.Ordinal), "Companion should not use a blocking browser confirmation for manual Word saves."); + Assert(companion.Contains("setOnboardingManualSavePrompt", StringComparison.Ordinal), "Companion should show an explicit manual-save prompt after adding markers."); 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."); @@ -1940,7 +1944,14 @@ static void WordSyncRequiresSaveAcknowledgement() 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(hub.Contains("OnboardingBuildManualSaveRequired", StringComparison.Ordinal), "Server should notify the browser when Word needs a manual save."); + Assert(models.Contains("public int? BuildID", StringComparison.Ordinal), "Build progress should identify the build awaiting manual save."); + Assert(models.Contains("public string? MarkerStatus", StringComparison.Ordinal), "Build progress should carry marker sync state to the browser."); Assert(presence.Contains("OnboardingBuildMarkerSyncCompleted", StringComparison.Ordinal), "Browser should wait for marker sync completion before redirecting."); + Assert(presence.Contains("OnboardingBuildManualSaveRequired", StringComparison.Ordinal), "Browser should render manual-save-required progress events."); + Assert(presence.Contains("CompleteOnboardingBuildMarkerSync\", pendingManualSaveBuildId", StringComparison.Ordinal), "Browser should confirm manual Word saves back to the server."); + Assert(onboardingView.Contains("data-onboarding-build-save-confirm", StringComparison.Ordinal), "Onboarding page should expose a manual Word save confirmation action."); + Assert(wordHostView.Contains("data-onboarding-manual-save-confirm", StringComparison.Ordinal), "Word Companion should expose a manual Word save confirmation action."); 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."); diff --git a/PlotLine/Hubs/WordCompanionFollowHub.cs b/PlotLine/Hubs/WordCompanionFollowHub.cs index 440328b..03a3007 100644 --- a/PlotLine/Hubs/WordCompanionFollowHub.cs +++ b/PlotLine/Hubs/WordCompanionFollowHub.cs @@ -294,9 +294,12 @@ public sealed class WordCompanionFollowHub( { 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 + PercentComplete = 100, + PreviewID = result?.PreviewID, + BuildID = buildId, + MarkerStatus = markerStatus }); - await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId, markerStatus }); + await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId, markerStatus, previewID = result?.PreviewID }); } public async Task RequireOnboardingBuildManualSave(int buildId, string message, MarkerSyncClientResult? result = null) @@ -310,14 +313,19 @@ public sealed class WordCompanionFollowHub( SerialiseMarkerDiagnostics(result)); await onboardingBuilds.MarkMarkerSyncUserSaveRequiredAsync(buildId, message); - await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress + var progress = 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 - }); + PercentComplete = 90, + PreviewID = result?.PreviewID, + BuildID = buildId, + MarkerStatus = "UserSaveRequired" + }; + await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", progress); + await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildManualSaveRequired", progress); } public async Task FailOnboardingBuildMarkerSync(int buildId, string message, MarkerSyncClientResult? result = null) @@ -337,7 +345,9 @@ public sealed class WordCompanionFollowHub( Message = string.IsNullOrWhiteSpace(message) ? "Word marker sync failed. Keep Word open and try again." : message, - PercentComplete = 90 + PercentComplete = 90, + BuildID = buildId, + MarkerStatus = "Failed" }); } @@ -427,6 +437,7 @@ public sealed class WordCompanionFollowHub( public sealed class MarkerSyncClientResult { + public Guid? PreviewID { get; init; } public string? MarkerStatus { get; init; } public string? Message { get; init; } public bool? MarkersChanged { get; init; } diff --git a/PlotLine/Models/ManuscriptScanModels.cs b/PlotLine/Models/ManuscriptScanModels.cs index 7bbc026..435c28a 100644 --- a/PlotLine/Models/ManuscriptScanModels.cs +++ b/PlotLine/Models/ManuscriptScanModels.cs @@ -262,4 +262,6 @@ public sealed class OnboardingBuildProgress public int CharactersCreated { get; init; } public int SceneAppearancesCreated { get; init; } public Guid? PreviewID { get; init; } + public int? BuildID { get; init; } + public string? MarkerStatus { get; init; } } diff --git a/PlotLine/Services/OnboardingService.cs b/PlotLine/Services/OnboardingService.cs index d3dfd6e..2f72cf1 100644 --- a/PlotLine/Services/OnboardingService.cs +++ b/PlotLine/Services/OnboardingService.cs @@ -395,7 +395,11 @@ public sealed class OnboardingService( } public async Task GetBuildResultAsync(Guid previewId) - => await scanPreviews.GetBuildResultAsync(RequireUserId(), previewId); + { + var userId = RequireUserId(); + return await scanPreviews.GetBuildResultAsync(userId, previewId) + ?? await builds.GetByPreviewAsync(userId, previewId); + } public async Task FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form) { diff --git a/PlotLine/Views/Onboarding/Index.cshtml b/PlotLine/Views/Onboarding/Index.cshtml index 7f76a30..26d6173 100644 --- a/PlotLine/Views/Onboarding/Index.cshtml +++ b/PlotLine/Views/Onboarding/Index.cshtml @@ -317,6 +317,11 @@ + } @@ -339,6 +344,7 @@ diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 9d21fcf..03e9c5f 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -102,6 +102,16 @@

Linked to PlotDirector

+ +
Writing Workspace diff --git a/PlotLine/wwwroot/css/onboarding.css b/PlotLine/wwwroot/css/onboarding.css index 69f1c90..8b5c08d 100644 --- a/PlotLine/wwwroot/css/onboarding.css +++ b/PlotLine/wwwroot/css/onboarding.css @@ -454,6 +454,19 @@ border-color: rgba(47, 111, 99, .34); } +.onboarding-build-panel.is-manual-save { + border-color: rgba(182, 138, 70, .5); + background: rgba(182, 138, 70, .12); +} + +.onboarding-manual-save-actions { + grid-column: 1 / -1; + display: flex; + flex-wrap: wrap; + gap: .5rem; + margin-top: .2rem; +} + .onboarding-review-panel { width: min(1120px, 100%); } diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index b170afd..5be0bf4 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -308,6 +308,15 @@ body { box-shadow: 0 1px 2px rgba(74, 50, 31, 0.05); } +.word-companion-manual-save { + border-color: rgba(182, 138, 70, .55); + background: #fff7e6; +} + +.word-companion-manual-save .word-companion-section-header span { + color: var(--companion-accent-dark); +} + .word-companion-section span, .word-companion-section label, .word-companion-summary span { diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index 2f3cd3c..7f615dc 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -19,6 +19,9 @@ const linkedManuscriptCard = document.querySelector("[data-linked-manuscript-card]"); const linkedProjectTitle = document.querySelector("[data-linked-project-title]"); const linkedBookTitle = document.querySelector("[data-linked-book-title]"); + const onboardingManualSavePanel = document.querySelector("[data-onboarding-manual-save]"); + const onboardingManualSaveMessage = document.querySelector("[data-onboarding-manual-save-message]"); + const onboardingManualSaveConfirmButton = document.querySelector("[data-onboarding-manual-save-confirm]"); const firstRunWizard = document.querySelector("[data-first-run-wizard]"); const firstRunProjectSelect = document.querySelector("[data-first-run-project-select]"); const firstRunBookSelect = document.querySelector("[data-first-run-book-select]"); @@ -215,6 +218,7 @@ let firstRunImportStructure = null; let firstRunCharacterCandidatesModel = []; let lastOnboardingScanPreview = null; + let pendingOnboardingManualSave = null; let firstRunStructureImported = false; let firstRunCharacterStepActive = false; let firstRunCharacterStepActivatedAt = 0; @@ -1840,10 +1844,68 @@ message, ...diagnostics }); + return true; } catch (error) { console.warn("Unable to report onboarding marker sync completion.", error); } } + return false; + }; + + const setOnboardingManualSavePrompt = (buildId, message, diagnostics = {}) => { + pendingOnboardingManualSave = { buildId, diagnostics }; + setHidden(onboardingManualSavePanel, false); + setText(onboardingManualSaveMessage, message); + if (onboardingManualSaveConfirmButton) { + onboardingManualSaveConfirmButton.disabled = false; + onboardingManualSaveConfirmButton.textContent = "I've saved it"; + } + setDocumentMessage(message); + }; + + const clearOnboardingManualSavePrompt = () => { + pendingOnboardingManualSave = null; + setHidden(onboardingManualSavePanel, true); + if (onboardingManualSaveConfirmButton) { + onboardingManualSaveConfirmButton.disabled = true; + onboardingManualSaveConfirmButton.textContent = "I've saved it"; + } + }; + + const confirmOnboardingManualSave = async () => { + if (!pendingOnboardingManualSave?.buildId) { + return; + } + + if (onboardingManualSaveConfirmButton) { + onboardingManualSaveConfirmButton.disabled = true; + onboardingManualSaveConfirmButton.textContent = "Confirming..."; + } + + const completed = await invokeMarkerSyncCompleted( + pendingOnboardingManualSave.buildId, + "UserConfirmedSave", + "Scene markers added; user confirmed the Word document was saved.", + { + ...pendingOnboardingManualSave.diagnostics, + markersChanged: true, + saveState: "UserConfirmedSave", + saveApi: pendingOnboardingManualSave.diagnostics?.saveApi || "manual-companion", + ...officeRuntimeDiagnostics() + }); + + if (!completed) { + setDocumentMessage("PlotDirector could not confirm the save yet. Keep Word open and try again."); + if (onboardingManualSaveConfirmButton) { + onboardingManualSaveConfirmButton.disabled = false; + onboardingManualSaveConfirmButton.textContent = "I've saved it"; + } + return; + } + + clearOnboardingManualSavePrompt(); + setDocumentMessage("Scene markers added and save confirmed. Word may now be closed."); + setDiagnostics({ lastError: "-" }); }; const ensureDocumentGuid = () => { @@ -2496,6 +2558,7 @@ await inspectPlotDirectorMarkers().catch(() => null); if (!markersChanged) { + clearOnboardingManualSavePrompt(); const diagnostics = { markersChanged: false, saveState: "NotRequired", @@ -2513,41 +2576,20 @@ return; } - const saveResult = await saveWordDocumentIfAvailable(); const saveDiagnostics = { + previewID: result?.previewID || result?.PreviewID || null, markersChanged: true, - ...saveResult + saveState: "UserSaveRequired", + saveApi: "manual-companion", + ...officeRuntimeDiagnostics() }; - 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; - } - + const manualMessage = "Scene markers have been added. Save this document in Word, then click I've saved it in PlotDirector or the Companion."; + console.info("Onboarding marker sync requires manual Word save confirmation.", saveDiagnostics); + setOnboardingManualSavePrompt(buildId, manualMessage, saveDiagnostics); + setDiagnostics({ lastError: "Waiting for manual Word save confirmation." }); await invokeMarkerSyncManualSaveRequired( buildId, - "Scene markers were added, but Word save still needs confirmation. Save the document in Word, then continue the import again.", + manualMessage, saveDiagnostics); } catch (error) { console.error("Unable to update onboarding Word markers.", error); @@ -6236,6 +6278,7 @@ firstRunContinueButton?.addEventListener("click", () => finishFirstRunOnboarding()); firstRunCancelButton?.addEventListener("click", () => setFirstRunMode(false)); firstRunImportCancelButton?.addEventListener("click", () => resetFirstRunPreview("Import cancelled.")); + onboardingManualSaveConfirmButton?.addEventListener("click", confirmOnboardingManualSave); refreshDocumentButton?.addEventListener("click", refreshDocumentStructure); analyseManuscriptStructureButton?.addEventListener("click", analyseManuscriptStructure); diff --git a/PlotLine/wwwroot/js/word-companion-presence.js b/PlotLine/wwwroot/js/word-companion-presence.js index 67c20ca..f97103d 100644 --- a/PlotLine/wwwroot/js/word-companion-presence.js +++ b/PlotLine/wwwroot/js/word-companion-presence.js @@ -160,6 +160,106 @@ .withAutomaticReconnect() .build(); let pendingBuildPreviewId = ""; + let pendingManualSaveBuildId = 0; + let pendingManualSavePreviewId = ""; + + const setBuildActionState = (text, disabled, mode = "") => { + document.querySelectorAll("[data-onboarding-build-action]").forEach((button) => { + button.textContent = text; + button.disabled = disabled; + if (mode) { + button.dataset.buildMode = mode; + } else { + delete button.dataset.buildMode; + } + }); + }; + + const setManualSaveRequired = (state) => { + const buildId = Number.parseInt( + field(state, "buildID", "BuildID") + ?? field(state, "buildId", "BuildId") + ?? "0", + 10); + const previewId = field(state, "previewID", "PreviewID") + || field(state, "previewId", "PreviewId") + || pendingBuildPreviewId; + const message = field(state, "message", "Message") + || "Scene markers have been added. Save the document in Word, then confirm here."; + + pendingManualSaveBuildId = Number.isInteger(buildId) ? buildId : 0; + pendingManualSavePreviewId = previewId || pendingBuildPreviewId; + pendingBuildPreviewId = pendingManualSavePreviewId || pendingBuildPreviewId; + + document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { + node.classList.add("is-running", "is-manual-save"); + }); + document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { + node.textContent = message; + }); + document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => { + node.textContent = "90%"; + }); + document.querySelectorAll("[data-onboarding-build-progress]").forEach((node) => { + node.style.width = "90%"; + }); + document.querySelectorAll("[data-onboarding-manual-save-actions]").forEach((node) => { + node.hidden = false; + }); + document.querySelectorAll("[data-onboarding-build-save-confirm]").forEach((button) => { + button.disabled = !pendingManualSaveBuildId; + button.textContent = "I've saved the Word document"; + }); + setBuildActionState("I've saved the Word document", !pendingManualSaveBuildId, "confirm-save"); + }; + + const clearManualSaveRequired = () => { + pendingManualSaveBuildId = 0; + pendingManualSavePreviewId = ""; + document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { + node.classList.remove("is-manual-save"); + }); + document.querySelectorAll("[data-onboarding-manual-save-actions]").forEach((node) => { + node.hidden = true; + }); + document.querySelectorAll("[data-onboarding-build-save-confirm]").forEach((button) => { + button.disabled = true; + button.textContent = "I've saved the Word document"; + }); + setBuildActionState("Detect Scenes", false); + }; + + const confirmManualSaveFromWeb = async () => { + if (!pendingManualSaveBuildId) { + return; + } + + setBuildActionState("Confirming...", true, "confirm-save"); + document.querySelectorAll("[data-onboarding-build-save-confirm]").forEach((button) => { + button.disabled = true; + button.textContent = "Confirming..."; + }); + + try { + await connection.invoke("CompleteOnboardingBuildMarkerSync", pendingManualSaveBuildId, { + previewID: pendingManualSavePreviewId || pendingBuildPreviewId || null, + markerStatus: "UserConfirmedSave", + message: "Scene markers added; user confirmed the Word document was saved.", + markersChanged: true, + saveState: "UserConfirmedSave", + saveApi: "manual-web" + }); + } catch (error) { + document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { + node.textContent = error?.message || "PlotDirector could not confirm the save yet. Keep Word open and try again."; + }); + document.querySelectorAll("[data-onboarding-build-save-confirm]").forEach((button) => { + button.disabled = false; + button.textContent = "I've saved the Word document"; + }); + setBuildActionState("I've saved the Word document", false, "confirm-save"); + } + }; connection.on("WordCompanionPresenceChanged", applyStatus); connection.on("OnboardingScanStateChanged", applyScanState); @@ -170,8 +270,14 @@ const message = field(state, "message", "Message") || "Preparing chapters..."; const percent = field(state, "percentComplete", "PercentComplete"); const safePercent = Math.max(0, Math.min(100, Number.parseInt(percent || "0", 10) || 0)); + const markerStatus = field(state, "markerStatus", "MarkerStatus"); + if (markerStatus === "UserSaveRequired") { + setManualSaveRequired(state); + return; + } document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { node.classList.add("is-running"); + node.classList.remove("is-manual-save"); }); document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { node.textContent = message; @@ -189,7 +295,11 @@ window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(previewId)}`; } }); - connection.on("OnboardingBuildMarkerSyncCompleted", () => { + connection.on("OnboardingBuildManualSaveRequired", setManualSaveRequired); + connection.on("OnboardingBuildMarkerSyncCompleted", (result) => { + const previewId = field(result, "previewID", "PreviewID") || field(result, "previewId", "PreviewId"); + pendingBuildPreviewId = previewId || pendingBuildPreviewId || pendingManualSavePreviewId; + clearManualSaveRequired(); if (pendingBuildPreviewId) { window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(pendingBuildPreviewId)}`; } @@ -221,11 +331,17 @@ document.querySelectorAll("[data-onboarding-build-start]").forEach((button) => { button.addEventListener("click", async () => { + if (button.dataset.buildMode === "confirm-save" || pendingManualSaveBuildId) { + await confirmManualSaveFromWeb(); + return; + } + const previewId = button.dataset.previewId; if (!previewId) { return; } pendingBuildPreviewId = previewId; + clearManualSaveRequired(); button.disabled = true; document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { node.classList.add("is-running"); @@ -243,7 +359,7 @@ const result = await connection.invoke("StartOnboardingProjectBuild", previewId); pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId; document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => { - node.textContent = "Updating Word document with scene markers. Keep Word open while the document is saved."; + node.textContent = "Updating Word document with scene markers. Keep Word open, then save the document when prompted."; }); } catch (error) { button.disabled = false; @@ -259,4 +375,8 @@ } }); }); + + document.querySelectorAll("[data-onboarding-build-save-confirm]").forEach((button) => { + button.addEventListener("click", confirmManualSaveFromWeb); + }); })();