Restore Word Companion existing book linking

This commit is contained in:
Nick Beckley 2026-08-25 14:12:57 +00:00
parent 9d8d443067
commit 398579c5c9
3 changed files with 213 additions and 0 deletions

View File

@ -114,6 +114,13 @@ var tests = new (string Name, Action Test)[]
("Writing workspace pages register roles without changing follow behavior", WritingWorkspacePagesRegisterRolesWithoutChangingFollowBehavior),
("Word Companion exposes writing workspace launch actions", WordCompanionExposesWritingWorkspaceLaunchActions),
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
("Word Companion canonical markers drive runtime follow", WordCompanionCanonicalMarkersDriveRuntimeFollow),
("Word Companion existing project book linking remains available", WordCompanionExistingProjectBookLinkingRemainsAvailable),
("Word Companion document identity survives reconnect", WordCompanionDocumentIdentitySurvivesReconnect),
("Word Companion multiscreen launch and follow remain reachable", WordCompanionMultiscreenLaunchAndFollowRemainReachable),
("Word Companion modes remain distinct", WordCompanionModesRemainDistinct),
("Writing pages do not require Story Intelligence", WritingPagesDoNotRequireStoryIntelligence),
("Word Companion host script has valid tab mapping", WordCompanionHostScriptHasValidTabMapping),
("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates),
("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers),
("Admin Story Intelligence reset returns to diagnostics with messages", AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages),
@ -506,6 +513,145 @@ static void WordCompanionWritingScreensReceiveResolvedContextIdentifiers()
Assert(intentView.Contains("data-document-guid=\"@Model.DocumentGuid\"", StringComparison.Ordinal), "Writing Intent should retain the document identifier.");
}
static void WordCompanionCanonicalMarkersDriveRuntimeFollow()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/WordCompanionRepository.cs"));
Assert(script.Contains("chapter: \"PD-CHAPTER-\"", StringComparison.Ordinal), "Canonical chapter marker prefix is missing.");
Assert(script.Contains("scene: \"PD-SCENE-\"", StringComparison.Ordinal), "Canonical scene marker prefix is missing.");
Assert(script.Contains("const classifyPlotDirectorContentControl", StringComparison.Ordinal), "Word marker classifier is missing.");
Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Document scan should read canonical chapter IDs from Word anchors.");
Assert(script.Contains("paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Document scan should read canonical scene IDs from Word anchors.");
Assert(script.Contains("detectedSceneAnchorId = Number.isInteger(sceneAnchorId)", StringComparison.Ordinal), "Current scene detection should preserve canonical scene IDs.");
Assert(script.Contains("postJson(\"/api/word-companion/runtime/current-scene\"", StringComparison.Ordinal), "Word Companion should notify PlotDirector of the runtime current scene.");
Assert(script.Contains("sceneId: numericSceneId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical SceneID.");
Assert(script.Contains("chapterId: numericChapterId", StringComparison.Ordinal), "Runtime follow should submit the resolved canonical ChapterID.");
Assert(controller.Contains("presence.UpdateCurrentStoryPositionAsync", StringComparison.Ordinal), "Current-scene API should update writing workspace presence.");
Assert(controller.Contains("BroadcastToCurrentUserAsync(\"storyPositionChanged\"", StringComparison.Ordinal), "Current-scene API should broadcast canonical story-position changes.");
Assert(repository.Contains("\"dbo.WordCompanion_Runtime_CurrentScene_Validate\"", StringComparison.Ordinal), "Runtime current scene validation must remain stored-procedure based.");
}
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
Assert(view.Contains("data-project-select", StringComparison.Ordinal), "Runtime Project selector should remain visible for unbound documents.");
Assert(view.Contains("data-book-select", StringComparison.Ordinal), "Runtime Book selector should remain visible for unbound documents.");
Assert(view.Contains("data-first-run-project-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Project.");
Assert(view.Contains("data-first-run-book-select", StringComparison.Ordinal), "Bind flow should let users choose an existing Book.");
Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Bind flow should expose a direct existing-book link action.");
Assert(script.Contains("firstRunLinkButton?.addEventListener(\"click\", linkFirstRunManuscript)", StringComparison.Ordinal), "Existing-book link action should be wired.");
Assert(script.Contains("postJson(\"/api/word-companion/manuscript/link\"", StringComparison.Ordinal), "Existing-book link action should call the link endpoint.");
Assert(script.Contains("/api/word-companion/projects", StringComparison.Ordinal), "Project selector should load existing Projects.");
Assert(script.Contains("/api/word-companion/projects/${projectId}/books", StringComparison.Ordinal), "Book selector should load existing Books.");
Assert(controller.Contains("[HttpPost(\"manuscript/link\")", StringComparison.Ordinal), "Link endpoint is missing.");
Assert(service.Contains("LinkManuscriptAsync", StringComparison.Ordinal), "Link service method is missing.");
Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Existing-book linking should save a document binding directly.");
Assert(!service[service.IndexOf("public async Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync", StringComparison.Ordinal)..service.IndexOf("public async Task<WordCompanionManuscriptUnlinkResponse?> UnlinkManuscriptAsync", StringComparison.Ordinal)].Contains("ImportManuscriptAsync", StringComparison.Ordinal), "Existing-book linking must not route through manuscript import.");
Assert(repository.Contains("\"dbo.ManuscriptDocument_Save\"", StringComparison.Ordinal), "Document binding save must remain stored-procedure based.");
}
static void WordCompanionDocumentIdentitySurvivesReconnect()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
Assert(script.Contains("plotdirector.word.documentGuid", StringComparison.Ordinal), "Word document GUID metadata key is missing.");
Assert(script.Contains("plotdirector.word.boundBookId", StringComparison.Ordinal), "Word bound BookID metadata key is missing.");
Assert(script.Contains("plotdirector.word.boundProjectId", StringComparison.Ordinal), "Word bound ProjectID metadata key is missing.");
Assert(script.Contains("const readDocumentBinding", StringComparison.Ordinal), "Word host should read durable document binding metadata.");
Assert(script.Contains("const writeDocumentBinding", StringComparison.Ordinal), "Word host should write durable document binding metadata.");
Assert(script.Contains("settings.saveAsync", StringComparison.Ordinal), "Word document binding metadata must be saved into the document.");
Assert(script.Contains("const ensureDocumentGuid", StringComparison.Ordinal), "Word host should mint a document GUID for new bindings.");
Assert(script.Contains("/api/word-companion/runtime/book/${binding.bookId}?documentGuid=${encodeURIComponent(binding.documentGuid)}", StringComparison.Ordinal), "Reconnect should validate saved document binding against the server.");
Assert(service.Contains("GetRuntimeBookAsync(int bookId, Guid documentGuid)", StringComparison.Ordinal), "Runtime book service is missing.");
Assert(service.Contains("manuscriptDocuments.GetByGuidAsync(documentGuid, userId)", StringComparison.Ordinal), "Reconnect should resolve documents by saved GUID.");
Assert(service.Contains("manuscriptDocuments.UpdateLastOpenedAsync", StringComparison.Ordinal), "Reconnect should touch LastOpened for the durable binding.");
Assert(repository.Contains("\"dbo.ManuscriptDocument_GetByGuid\"", StringComparison.Ordinal), "Document GUID lookup must remain stored-procedure based.");
Assert(repository.Contains("\"dbo.ManuscriptDocument_UpdateLastOpened\"", StringComparison.Ordinal), "Last-opened update must remain stored-procedure based.");
}
static void WordCompanionMultiscreenLaunchAndFollowRemainReachable()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
var hostScript = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
var contextView = File.ReadAllText(Path.Combine(root, "Views/Writing/Context.cshtml"));
var intentView = File.ReadAllText(Path.Combine(root, "Views/Writing/Intent.cshtml"));
Assert(view.Contains("data-open-writing-workspace", StringComparison.Ordinal), "Word Companion should expose the workspace launch action.");
Assert(view.Contains("data-open-writing-context", StringComparison.Ordinal), "Word Companion should expose the Context launch action.");
Assert(view.Contains("data-open-writing-intent", StringComparison.Ordinal), "Word Companion should expose the Intent launch action.");
Assert(hostScript.Contains("openWritingWindow(\"context\")", StringComparison.Ordinal), "Context launch should call the writing window helper.");
Assert(hostScript.Contains("openWritingWindow(\"intent\")", StringComparison.Ordinal), "Intent launch should call the writing window helper.");
Assert(hostScript.Contains("new URL(target === \"workspace\" ? \"/Writer\" : `/Writing/${target}`", StringComparison.Ordinal), "Writing windows should target the dedicated Context, Intent, and Workspace routes.");
Assert(hostScript.Contains("url.searchParams.set(\"sceneId\", sceneId)", StringComparison.Ordinal), "Writing windows should receive the current canonical SceneID.");
Assert(follow.Contains("connection.invoke(\"WatchWritingWorkspace\"", StringComparison.Ordinal), "Writing panes should attach to the Word Companion follow hub.");
Assert(follow.Contains("connection.on(\"storyPositionChanged\"", StringComparison.Ordinal), "Writing panes should receive runtime scene changes.");
Assert(follow.Contains("registerWindow", StringComparison.Ordinal), "Writing panes should register their multiscreen role.");
Assert(contextView.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Context page should load shared follow script.");
Assert(intentView.Contains("~/js/writing-follow.js", StringComparison.Ordinal), "Intent page should load shared follow script.");
}
static void WordCompanionModesRemainDistinct()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(view.Contains("data-first-run-wizard hidden", StringComparison.Ordinal), "Bind/import wizard should be hidden until a document is unbound.");
Assert(view.Contains("data-linked-manuscript-card hidden", StringComparison.Ordinal), "Linked manuscript card should be hidden until a durable binding is present.");
Assert(view.Contains("data-runtime-book-selectors", StringComparison.Ordinal), "Normal Project/Book selectors should remain available outside the bind wizard.");
Assert(view.Contains("data-first-run-link", StringComparison.Ordinal), "Existing-book link action should be distinct from import.");
Assert(view.Contains("data-first-run-scan", StringComparison.Ordinal), "Scan action should remain available for first-run import.");
Assert(view.Contains("data-first-run-import", StringComparison.Ordinal), "Import action should remain a separate confirmation after scan.");
Assert(view.Contains("data-unlink-word-document", StringComparison.Ordinal), "Unlink action should remain available for linked documents.");
Assert(script.Contains("setHidden(runtimeBookSelectors, linked)", StringComparison.Ordinal), "Runtime selectors should only hide after binding.");
Assert(script.Contains("setFirstRunMode(true)", StringComparison.Ordinal), "Unbound documents should enter the bind/import mode.");
Assert(script.Contains("setFirstRunMode(false)", StringComparison.Ordinal), "Linked documents should leave the bind/import mode.");
}
static void WritingPagesDoNotRequireStoryIntelligence()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/WritingController.cs"));
var contextPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingContextContent.cshtml"));
var intentPartial = File.ReadAllText(Path.Combine(root, "Views/Writing/_WritingIntentContent.cshtml"));
var follow = File.ReadAllText(Path.Combine(root, "wwwroot/js/writing-follow.js"));
Assert(controller.Contains("IStoryContextService storyContext", StringComparison.Ordinal), "Writing pages should use canonical story context.");
Assert(controller.Contains("IWriterWorkspaceService writerWorkspace", StringComparison.Ordinal), "Writing Intent should use existing writer workspace planning data.");
Assert(!controller.Contains("IStoryIntelligence", StringComparison.Ordinal), "Writing routes should not depend on Story Intelligence services.");
Assert(!controller.Contains("Onboarding", StringComparison.Ordinal), "Writing routes should not depend on import onboarding.");
Assert(contextPartial.Contains("@Model.EmptyTitle", StringComparison.Ordinal), "Writing Context should render a normal empty state when context is unavailable.");
Assert(intentPartial.Contains("@Model.EmptyMessage", StringComparison.Ordinal), "Writing Intent should render a normal empty state when planning data is unavailable.");
Assert(follow.Contains("No current Word scene has been received", StringComparison.Ordinal), "Writing panes should tolerate no current Word scene without Story Intelligence.");
}
static void WordCompanionHostScriptHasValidTabMapping()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
var view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
Assert(script.Contains("const normalizeTabName = (tabName) =>", StringComparison.Ordinal), "Tab-name compatibility helper is missing.");
Assert(script.Contains("return \"review\";", StringComparison.Ordinal), "Legacy links tab should map to Review.");
Assert(script.Contains("return \"maintenance\";", StringComparison.Ordinal), "Legacy structure tab should map to Maintenance.");
Assert(!script.Contains("return \"review\";\n }\n }\n if", StringComparison.Ordinal), "Tab-name mapping contains an extra closing brace.");
Assert(view.Split("word-companion-review-group").Length - 1 == 3, "Review panel should contain exactly the three review groups.");
}
static void WritingScreensShowDiagnosticEmptyStates()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");

View File

@ -70,6 +70,7 @@
</div>
</div>
<div class="word-companion-actions">
<button type="button" class="word-companion-primary-action" data-first-run-link disabled>Link Existing Book</button>
<button type="button" class="word-companion-primary-action" data-first-run-scan disabled>Scan Manuscript</button>
<button type="button" data-first-run-cancel>Cancel</button>
</div>

View File

@ -24,6 +24,7 @@
const firstRunBookSelect = document.querySelector("[data-first-run-book-select]");
const firstRunNewBookTitle = document.querySelector("[data-first-run-new-book-title]");
const firstRunCreateBookButton = document.querySelector("[data-first-run-create-book]");
const firstRunLinkButton = document.querySelector("[data-first-run-link]");
const firstRunScanButton = document.querySelector("[data-first-run-scan]");
const firstRunCancelButton = document.querySelector("[data-first-run-cancel]");
const firstRunStatus = document.querySelector("[data-first-run-status]");
@ -1499,6 +1500,9 @@
if (firstRunScanButton) {
firstRunScanButton.disabled = !project || !book || isFirstRunImporting;
}
if (firstRunLinkButton) {
firstRunLinkButton.disabled = !project || !book || isFirstRunImporting;
}
if (firstRunCreateBookButton) {
firstRunCreateBookButton.disabled = !project || !String(firstRunNewBookTitle?.value || "").trim() || isFirstRunImporting;
}
@ -3766,6 +3770,67 @@
cleanUnlinkWordDocumentDialog.showModal();
});
const linkFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
if (!project || !book) {
updateFirstRunButtons();
return;
}
isFirstRunImporting = true;
updateFirstRunButtons();
setFirstRunStatus("Linking Word document...");
try {
const documentGuid = ensureDocumentGuid();
const response = await postJson("/api/word-companion/manuscript/link", {
bookId: book.bookId,
documentGuid
});
const document = response.manuscriptDocument;
await writeDocumentBinding({
documentGuid: document?.documentGuid || documentGuid,
bookId: response.bookId || book.bookId,
projectId: document?.projectId || project.projectId,
bindingVersion: document?.bindingVersion || 1
});
firstRunDocumentBinding = {
documentGuid: document?.documentGuid || documentGuid,
bookId: response.bookId || book.bookId,
projectId: document?.projectId || project.projectId,
bindingVersion: document?.bindingVersion || 1
};
writeStoredId(storageKeys.projectId, firstRunDocumentBinding.projectId);
writeStoredId(storageKeys.bookId, firstRunDocumentBinding.bookId);
if (projectSelect) {
projectSelect.value = String(firstRunDocumentBinding.projectId);
}
await loadBooks(firstRunDocumentBinding.projectId, firstRunDocumentBinding.bookId);
setRuntimeBookContext({
projectId: firstRunDocumentBinding.projectId,
projectTitle: project.title,
bookId: firstRunDocumentBinding.bookId,
bookTitle: book.title,
bookSubtitle: book.subtitle,
usesExplicitScenes: !!book.usesExplicitScenes,
manuscriptDocument: document
});
setFirstRunMode(false);
setConnectionStatus("Connected");
setBookMessage(response.message || "Manuscript linked.");
setDocumentMessage(response.message || "Manuscript linked.");
await loadRuntimeDocumentAwareness();
} catch (error) {
console.error("Unable to link Word document.", error);
setFirstRunStatus(`Unable to link manuscript: ${errorText(error)}`);
} finally {
isFirstRunImporting = false;
updateFirstRunButtons();
}
};
const importFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
@ -6110,6 +6175,7 @@
});
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
firstRunLinkButton?.addEventListener("click", linkFirstRunManuscript);
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
firstRunCreateCharactersButton?.addEventListener("click", createFirstRunCharacters);
firstRunSkipCharactersButton?.addEventListener("click", () => finishFirstRunOnboarding("Character creation skipped."));