Add explicit Word save confirmation for import

This commit is contained in:
Nick Beckley 2026-08-26 13:00:47 +00:00
parent e0c3171329
commit f76e5bc388
10 changed files with 269 additions and 40 deletions

View File

@ -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.");

View File

@ -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; }

View File

@ -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; }
}

View File

@ -395,7 +395,11 @@ public sealed class OnboardingService(
}
public async Task<OnboardingManuscriptBuildResult?> 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<OnboardingManuscriptBuildResult?> FinaliseCharacterReviewAsync(ManuscriptScanReviewForm form)
{

View File

@ -317,6 +317,11 @@
<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>
</div>
}
</section>
@ -339,6 +344,7 @@
<button class="btn btn-primary"
type="button"
data-onboarding-build-start
data-onboarding-build-action
data-preview-id="@Model.ScanState.PreviewID">
Detect Scenes
</button>

View File

@ -102,6 +102,16 @@
<p>Linked to PlotDirector</p>
</section>
<section class="word-companion-section word-companion-manual-save" data-onboarding-manual-save hidden aria-label="Save Word document">
<div class="word-companion-section-header">
<span>Save Required</span>
</div>
<p class="word-companion-message" data-onboarding-manual-save-message>Scene markers have been added. Save this Word document to continue.</p>
<div class="word-companion-actions">
<button type="button" class="word-companion-primary-action" data-onboarding-manual-save-confirm disabled>I've saved it</button>
</div>
</section>
<section class="word-companion-section word-companion-writing-workspace" aria-label="Writing workspace">
<div class="word-companion-section-header">
<span>Writing Workspace</span>

View File

@ -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%);
}

View File

@ -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 {

View File

@ -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);

View File

@ -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);
});
})();