From 398579c5c9eeb764753eec3f5177318f9460fe17 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 25 Aug 2026 14:12:57 +0000 Subject: [PATCH] Restore Word Companion existing book linking --- PlotLine.Tests/Program.cs | 146 ++++++++++++++++++ PlotLine/Views/WordCompanionHost/Index.cshtml | 1 + PlotLine/wwwroot/js/word-companion-host.js | 66 ++++++++ 3 files changed, 213 insertions(+) diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs index 08d0185..ab82125 100644 --- a/PlotLine.Tests/Program.cs +++ b/PlotLine.Tests/Program.cs @@ -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 LinkManuscriptAsync", StringComparison.Ordinal)..service.IndexOf("public async Task 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"); diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 4c90b02..9d21fcf 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -70,6 +70,7 @@
+
diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index f3ba666..d696119 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -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."));