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,
IManuscriptScanPreviewStore scanStore,
IOnboardingService onboardingService,
IOnboardingBuildRepository onboardingBuilds,
ILogger<WordCompanionFollowHub> logger) : Hub
{
public override async Task OnConnectedAsync()
@ -223,13 +224,55 @@ public sealed class WordCompanionFollowHub(
var companionConnectionId = await presence.GetCompanionConnectionIdAsync(userId);
if (!string.IsNullOrWhiteSpace(companionConnectionId))
{
await onboardingBuilds.MarkMarkerSyncSentAsync(result.BuildID);
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;
}
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)
{
int? disconnectedUserId = null;

View File

@ -1,12 +1,12 @@
@model OnboardingManuscriptBuildResult
@{
ViewData["Title"] = "Chapters prepared";
ViewData["Title"] = "Manuscript imported";
}
<section class="onboarding-shell" aria-labelledby="build-complete-title">
<div class="onboarding-panel onboarding-review-panel">
<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">
<span></span>
@ -15,12 +15,12 @@
}
</ol>
<div class="onboarding-copy">
<p class="eyebrow">Prepare Chapters</p>
<p class="eyebrow">Core Import Complete</p>
<div class="onboarding-success-heading">
<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>
<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))
{
<p class="text-warning">@Model.MarkerWarning</p>
@ -33,7 +33,7 @@
<strong>@Model.ChaptersCreated</strong>
</div>
<div>
<span>Scenes created now</span>
<span>Scenes</span>
<strong>@Model.ScenesCreated</strong>
</div>
<div>
@ -47,12 +47,13 @@
</div>
<section class="onboarding-complete-next">
<h2>Next: analyse manuscript</h2>
<p>Story Intelligence will detect scenes from the approved chapter text. You will review the results before PlotDirector creates scenes.</p>
<h2>Optional: analyse manuscript</h2>
<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>
<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-secondary" asp-controller="Writer" asp-action="Index" asp-route-projectId="@Model.ProjectID">Open Writer Workspace</a>
</div>

View File

@ -300,8 +300,8 @@
{
<div class="onboarding-build-panel" data-onboarding-build-panel>
<div>
<p class="onboarding-scan-next">Review saved. PlotDirector can now analyse the approved chapters.</p>
<p class="onboarding-build-message" data-onboarding-build-message>Next: analyse the manuscript and review suggested scenes.</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: detect scene boundaries, create PlotDirector scenes, then update and save Word markers.</p>
</div>
<strong data-onboarding-build-percent></strong>
<div class="onboarding-scan-progress onboarding-build-progress" aria-hidden="true">
@ -319,7 +319,12 @@
</form>
@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)
{

View File

@ -17,7 +17,7 @@
<div class="onboarding-copy story-live-hero">
<p class="eyebrow">Analyse manuscript</p>
<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>
@if (TempData["OnboardingStoryIntelligenceError"] is string error)
@ -30,7 +30,7 @@
}
<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 class="story-live-progress-card" aria-label="Import progress">

View File

@ -2199,13 +2199,19 @@
return scene;
};
const addWords = (text, words) => {
const addWords = (paragraph, text, words) => {
if (!chapter || !scene || words <= 0) {
return;
}
chapter.wordCount += words;
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;
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
@ -2233,6 +2239,12 @@
totalWordCount += words;
chapter.wordCount += words;
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;
}
@ -2249,7 +2261,7 @@
if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text);
totalWordCount += words;
addWords(text, words);
addWords(paragraph, text, words);
return;
}
@ -2259,7 +2271,7 @@
}
totalWordCount += words;
addWords(text, words);
addWords(paragraph, text, words);
});
if (totalWordCount === 0) {
@ -2391,39 +2403,83 @@
};
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.");
}
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]));
const sceneMap = new Map((result?.sceneMappings || result?.SceneMappings || [])
.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.");
}
await window.Word.run(async (context) => {
const paragraphs = context.document.body.paragraphs;
paragraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync();
try {
await window.Word.run(async (context) => {
const paragraphs = context.document.body.paragraphs;
paragraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync();
for (const chapter of lastOnboardingScanPreview.chapters || []) {
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");
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 || []) {
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");
}
}
for (const scene of lastOnboardingScanPreview.scenes || []) {
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");
}
}
}
await context.sync();
});
const saved = await saveWordDocumentIfAvailable();
if (!saved) {
throw new Error("Word markers were inserted, but the document could not be saved.");
}
for (const scene of lastOnboardingScanPreview.scenes || []) {
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");
}
await inspectPlotDirectorMarkers().catch(() => null);
if (Number.isInteger(buildId) && buildId > 0) {
await companionPresenceConnection.invoke("CompleteOnboardingBuildMarkerSync", buildId);
}
await context.sync();
});
await inspectPlotDirectorMarkers().catch(() => null);
} catch (error) {
if (Number.isInteger(buildId) && buildId > 0) {
await companionPresenceConnection.invoke("FailOnboardingBuildMarkerSync", buildId, errorText(error));
}
throw error;
}
};
const friendlyScanError = (error) => {

View File

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