Require Word marker save acknowledgement

This commit is contained in:
Nick Beckley 2026-08-25 12:35:00 +00:00
parent fbe8e60d59
commit adf93a075c
6 changed files with 153 additions and 40 deletions

View File

@ -14,6 +14,7 @@ public sealed class WordCompanionFollowHub(
IOnboardingRepository onboarding, IOnboardingRepository onboarding,
IManuscriptScanPreviewStore scanStore, IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService, IOnboardingService onboardingService,
IOnboardingBuildRepository onboardingBuilds,
ILogger<WordCompanionFollowHub> logger) : Hub ILogger<WordCompanionFollowHub> logger) : Hub
{ {
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
@ -223,13 +224,55 @@ public sealed class WordCompanionFollowHub(
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId); var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
if (!string.IsNullOrWhiteSpace(companionConnectionId)) if (!string.IsNullOrWhiteSpace(companionConnectionId))
{ {
await onboardingBuilds.MarkMarkerSyncSentAsync(result.BuildID);
await Clients.Client(companionConnectionId).SendAsync("UpdateOnboardingBuildMarkers", result); await Clients.Client(companionConnectionId).SendAsync("UpdateOnboardingBuildMarkers", result);
} }
else
{
await onboardingBuilds.MarkMarkerSyncFailedAsync(result.BuildID, "The Word Companion disconnected before marker sync could be sent.");
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
Message = "The Word Companion disconnected before PlotDirector could update and save Word markers.",
PercentComplete = 90,
ChaptersCreated = result.ChaptersCreated,
ScenesCreated = result.ScenesCreated,
CharactersCreated = result.CharactersCreated + result.CharactersReused,
SceneAppearancesCreated = result.SceneAppearancesCreated,
PreviewID = previewId
});
}
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildCompleted", result);
return result; return result;
} }
public async Task CompleteOnboardingBuildMarkerSync(int buildId)
{
var userId = RequireUserId();
await onboardingBuilds.MarkMarkerSyncCompletedAsync(buildId);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Complete",
Message = "Manuscript structure has been created and saved. Word and this browser page may now be closed.",
PercentComplete = 100
});
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildMarkerSyncCompleted", new { buildId });
}
public async Task FailOnboardingBuildMarkerSync(int buildId, string message)
{
var userId = RequireUserId();
await onboardingBuilds.MarkMarkerSyncFailedAsync(buildId, message);
await Clients.Group(PresenceGroup(userId)).SendAsync("OnboardingBuildProgress", new OnboardingBuildProgress
{
Status = "Failed",
Message = string.IsNullOrWhiteSpace(message)
? "Word marker sync failed. Keep Word open and try again."
: message,
PercentComplete = 90
});
}
public override async Task OnDisconnectedAsync(Exception? exception) public override async Task OnDisconnectedAsync(Exception? exception)
{ {
int? disconnectedUserId = null; int? disconnectedUserId = null;

View File

@ -1,12 +1,12 @@
@model OnboardingManuscriptBuildResult @model OnboardingManuscriptBuildResult
@{ @{
ViewData["Title"] = "Chapters prepared"; ViewData["Title"] = "Manuscript imported";
} }
<section class="onboarding-shell" aria-labelledby="build-complete-title"> <section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel"> <div class="onboarding-panel onboarding-review-panel">
<ol class="onboarding-stepper onboarding-stepper--complete" aria-label="Story setup journey"> <ol class="onboarding-stepper onboarding-stepper--complete" aria-label="Story setup journey">
@foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Prepare Chapters", "Complete" }) @foreach (var label in new[] { "Welcome", "Writing Preferences", "Project", "Book", "Connect Word", "Scan", "Review", "Create Structure", "Complete" })
{ {
<li class="is-complete"> <li class="is-complete">
<span></span> <span></span>
@ -15,12 +15,12 @@
} }
</ol> </ol>
<div class="onboarding-copy"> <div class="onboarding-copy">
<p class="eyebrow">Prepare Chapters</p> <p class="eyebrow">Core Import Complete</p>
<div class="onboarding-success-heading"> <div class="onboarding-success-heading">
<span aria-hidden="true">&check;</span> <span aria-hidden="true">&check;</span>
<h1 id="build-complete-title">Your chapters are ready.</h1> <h1 id="build-complete-title">Your manuscript has been imported.</h1>
</div> </div>
<p>@(Model.AlreadyBuilt ? "These chapters have already been prepared in PlotDirector, so nothing was duplicated." : "PlotDirector prepared the approved chapters. Story Intelligence will create scenes after you review the analysis.")</p> <p>@(Model.AlreadyBuilt ? "This manuscript structure has already been created in PlotDirector, so nothing was duplicated." : "PlotDirector created the approved chapters and scenes, updated the Word markers, and saved the manuscript. Word and this browser page may now be closed.")</p>
@if (!string.IsNullOrWhiteSpace(Model.MarkerWarning)) @if (!string.IsNullOrWhiteSpace(Model.MarkerWarning))
{ {
<p class="text-warning">@Model.MarkerWarning</p> <p class="text-warning">@Model.MarkerWarning</p>
@ -33,7 +33,7 @@
<strong>@Model.ChaptersCreated</strong> <strong>@Model.ChaptersCreated</strong>
</div> </div>
<div> <div>
<span>Scenes created now</span> <span>Scenes</span>
<strong>@Model.ScenesCreated</strong> <strong>@Model.ScenesCreated</strong>
</div> </div>
<div> <div>
@ -47,12 +47,13 @@
</div> </div>
<section class="onboarding-complete-next"> <section class="onboarding-complete-next">
<h2>Next: analyse manuscript</h2> <h2>Optional: analyse manuscript</h2>
<p>Story Intelligence will detect scenes from the approved chapter text. You will review the results before PlotDirector creates scenes.</p> <p>Story Intelligence can now enrich the existing scenes with summaries, context, relationships, knowledge, timeline details and metrics. You can analyse now or return later.</p>
</section> </section>
<div class="onboarding-actions"> <div class="onboarding-actions">
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse manuscript</a> <a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse my book</a>
<a class="btn btn-outline-secondary" asp-controller="Projects" asp-action="Details" asp-route-id="@Model.ProjectID">Not now</a>
<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-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> <a class="btn btn-outline-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
</div> </div>

View File

@ -300,8 +300,8 @@
{ {
<div class="onboarding-build-panel" data-onboarding-build-panel> <div class="onboarding-build-panel" data-onboarding-build-panel>
<div> <div>
<p class="onboarding-scan-next">Review saved. PlotDirector can now analyse the approved chapters.</p> <p class="onboarding-scan-next">Review saved. PlotDirector can now import the approved manuscript structure.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Next: analyse the manuscript and review suggested scenes.</p> <p class="onboarding-build-message" data-onboarding-build-message>Next: detect scene boundaries, create PlotDirector scenes, then update and save Word markers.</p>
</div> </div>
<strong data-onboarding-build-percent></strong> <strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true"> <div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
@ -319,7 +319,12 @@
</form> </form>
@if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal)) @if (Model.IsMicrosoftWordPath && string.Equals(Model.ScanState.ReviewStatus, ManuscriptScanReviewStatuses.ReadyToImport, StringComparison.Ordinal))
{ {
<a class="btn btn-primary" asp-controller="Onboarding" asp-action="StoryIntelligence">Analyse manuscript</a> <button class="btn btn-primary"
type="button"
data-onboarding-build-start
data-preview-id="@Model.ScanState.PreviewID">
Import manuscript
</button>
} }
else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete) else if (Model.IsMicrosoftWordPath && Model.ScanState.IsComplete)
{ {

View File

@ -17,7 +17,7 @@
<div class="onboarding-copy story-live-hero"> <div class="onboarding-copy story-live-hero">
<p class="eyebrow">Analyse manuscript</p> <p class="eyebrow">Analyse manuscript</p>
<h1 id="story-progress-title">Reading your manuscript</h1> <h1 id="story-progress-title">Reading your manuscript</h1>
<p>PlotDirector is analysing your chapters and preparing scenes for review. Keep Word and the Companion open until PlotDirector has finished creating the scene structure you need.</p> <p>PlotDirector is enriching the imported scenes with summaries, context, relationships, knowledge, timeline details and metrics.</p>
</div> </div>
@if (TempData["OnboardingStoryIntelligenceError"] is string error) @if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -30,7 +30,7 @@
} }
<section class="alert alert-info"> <section class="alert alert-info">
PlotDirector will keep analysing your manuscript on the server. Do not close Word yet if you need this manuscript to receive PlotDirector scene markers for the writing workspace. PlotDirector will keep analysing your imported scenes on the server. The manuscript structure and Word markers are already saved, so Word does not need to remain open for this analysis.
</section> </section>
<section class="story-live-progress-card" aria-label="Import progress"> <section class="story-live-progress-card" aria-label="Import progress">

View File

@ -2199,13 +2199,19 @@
return scene; return scene;
}; };
const addWords = (text, words) => { const addWords = (paragraph, text, words) => {
if (!chapter || !scene || words <= 0) { if (!chapter || !scene || words <= 0) {
return; return;
} }
chapter.wordCount += words; chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text); chapter.chapterTextParagraphs.push(text);
chapter.paragraphMap = chapter.paragraphMap || [];
chapter.paragraphMap.push({
structuralParagraphOrdinal: chapter.chapterTextParagraphs.length,
wordParagraphIndex: Number.isInteger(paragraph?.index) ? paragraph.index : -1,
text
});
scene.wordCount += words; scene.wordCount += words;
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) { if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text; scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
@ -2233,6 +2239,12 @@
totalWordCount += words; totalWordCount += words;
chapter.wordCount += words; chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text); chapter.chapterTextParagraphs.push(text);
chapter.paragraphMap = chapter.paragraphMap || [];
chapter.paragraphMap.push({
structuralParagraphOrdinal: chapter.chapterTextParagraphs.length,
wordParagraphIndex: Number.isInteger(paragraph?.index) ? paragraph.index : -1,
text
});
return; return;
} }
@ -2249,7 +2261,7 @@
if (isBuiltInHeading(paragraph, 2)) { if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text); startScene(paragraph, text);
totalWordCount += words; totalWordCount += words;
addWords(text, words); addWords(paragraph, text, words);
return; return;
} }
@ -2259,7 +2271,7 @@
} }
totalWordCount += words; totalWordCount += words;
addWords(text, words); addWords(paragraph, text, words);
}); });
if (totalWordCount === 0) { if (totalWordCount === 0) {
@ -2391,20 +2403,48 @@
}; };
const updateOnboardingBuildMarkers = async (result) => { const updateOnboardingBuildMarkers = async (result) => {
if (!lastOnboardingScanPreview || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { if (!wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable."); 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 || []) const chapterMap = new Map((result?.chapterMappings || result?.ChapterMappings || [])
.map((item) => [item.temporaryChapterKey || item.TemporaryChapterKey, item.chapterID || item.ChapterID])); .map((item) => [item.temporaryChapterKey || item.TemporaryChapterKey, item.chapterID || item.ChapterID]));
const sceneMap = new Map((result?.sceneMappings || result?.SceneMappings || []) const sceneMap = new Map((result?.sceneMappings || result?.SceneMappings || [])
.map((item) => [item.temporarySceneKey || item.TemporarySceneKey, item.sceneID || item.SceneID])); .map((item) => [item.temporarySceneKey || item.TemporarySceneKey, item.sceneID || item.SceneID]));
const chapterInstructions = result?.chapterMarkerInstructions || result?.ChapterMarkerInstructions || [];
const sceneInstructions = result?.sceneMarkerInstructions || result?.SceneMarkerInstructions || [];
if (!lastOnboardingScanPreview && chapterInstructions.length === 0 && sceneInstructions.length === 0) {
throw new Error("Word marker instructions are unavailable.");
}
try {
await window.Word.run(async (context) => { await window.Word.run(async (context) => {
const paragraphs = context.document.body.paragraphs; const paragraphs = context.document.body.paragraphs;
paragraphs.load("items/text,items/style,items/styleBuiltIn"); paragraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync(); await context.sync();
if (chapterInstructions.length > 0 || sceneInstructions.length > 0) {
for (const instruction of chapterInstructions) {
const index = Number.parseInt(instruction.wordParagraphIndex ?? instruction.WordParagraphIndex, 10);
const tag = instruction.markerTag || instruction.MarkerTag;
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);
}
}
for (const instruction of sceneInstructions) {
const index = Number.parseInt(instruction.wordParagraphIndex ?? instruction.WordParagraphIndex, 10);
const tag = instruction.markerTag || instruction.MarkerTag;
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);
}
}
} else {
for (const chapter of lastOnboardingScanPreview.chapters || []) { for (const chapter of lastOnboardingScanPreview.chapters || []) {
const chapterId = chapterMap.get(chapter.temporaryChapterKey); const chapterId = chapterMap.get(chapter.temporaryChapterKey);
const index = Number.parseInt(chapter.startPosition, 10); const index = Number.parseInt(chapter.startPosition, 10);
@ -2420,10 +2460,26 @@
ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE"); ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
} }
} }
}
await context.sync(); 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); await inspectPlotDirectorMarkers().catch(() => null);
if (Number.isInteger(buildId) && buildId > 0) {
await companionPresenceConnection.invoke("CompleteOnboardingBuildMarkerSync", buildId);
}
} catch (error) {
if (Number.isInteger(buildId) && buildId > 0) {
await companionPresenceConnection.invoke("FailOnboardingBuildMarkerSync", buildId, errorText(error));
}
throw error;
}
}; };
const friendlyScanError = (error) => { const friendlyScanError = (error) => {

View File

@ -154,6 +154,7 @@
.withUrl("/hubs/word-companion-follow") .withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect() .withAutomaticReconnect()
.build(); .build();
let pendingBuildPreviewId = "";
connection.on("WordCompanionPresenceChanged", applyStatus); connection.on("WordCompanionPresenceChanged", applyStatus);
connection.on("OnboardingScanStateChanged", applyScanState); connection.on("OnboardingScanStateChanged", applyScanState);
@ -183,6 +184,11 @@
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(previewId)}`; window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(previewId)}`;
} }
}); });
connection.on("OnboardingBuildMarkerSyncCompleted", () => {
if (pendingBuildPreviewId) {
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(pendingBuildPreviewId)}`;
}
});
connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false })); connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false }));
connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {})); connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {}));
connection.onclose(() => applyStatus({ status: "Offline", isConnected: false })); connection.onclose(() => applyStatus({ status: "Offline", isConnected: false }));
@ -229,8 +235,10 @@
}); });
try { try {
const result = await connection.invoke("StartOnboardingProjectBuild", previewId); const result = await connection.invoke("StartOnboardingProjectBuild", previewId);
const returnedPreviewId = field(result, "previewID", "PreviewID") || previewId; pendingBuildPreviewId = field(result, "previewID", "PreviewID") || previewId;
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(returnedPreviewId)}`; document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Updating and saving Word markers. Keep Word and this browser page open.";
});
} catch (error) { } catch (error) {
button.disabled = false; button.disabled = false;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => { document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {