From cda6279d9f2943ef6c6404076d1f01f6aa01eb1b Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 13 Jun 2026 21:35:34 +0100 Subject: [PATCH] Phase 10I: Word Companion Manuscript Anchors (Hidden PlotDirector IDs) --- PlotLine/Views/WordCompanionHost/Index.cshtml | 33 ++ PlotLine/wwwroot/css/word-companion.css | 17 + PlotLine/wwwroot/js/word-companion-host.js | 342 ++++++++++++++++-- 3 files changed, 357 insertions(+), 35 deletions(-) diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 36dbb36..43f5e32 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -66,6 +66,22 @@ Link Status Not detected +
+ Anchor Status + Not attached +
+
+ Book ID + - +
+
+ Chapter ID + - +
+
+ Scene ID + - +
Word Count in Word - @@ -101,6 +117,7 @@ -
+

@@ -189,6 +206,22 @@ Scene ID - +
+ Anchor Type + Title Match +
+
+ Stored Scene ID + - +
+
+ Stored Chapter ID + - +
+
+ Current Resolution Method + - +
Office Host Unknown diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index de91089..cfbf3ab 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -177,6 +177,23 @@ body { color: var(--companion-muted); } +.word-companion-current-scene > button { + min-height: 34px; + border: 1px solid var(--companion-line); + border-radius: 6px; + padding: 6px 8px; + background: #ffffff; + color: var(--companion-accent-dark); + font: inherit; + font-size: 0.84rem; + font-weight: 800; +} + +.word-companion-current-scene > button:disabled { + background: var(--companion-soft); + color: var(--companion-muted); +} + .word-companion-message:empty { display: none; } diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index 7a3e725..f1d3962 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -24,6 +24,11 @@ const documentOutline = document.querySelector("[data-document-outline]"); const refreshPlotDirectorSceneButton = document.querySelector("[data-refresh-plotdirector-scene]"); const plotDirectorSceneStatus = document.querySelector("[data-plotdirector-scene-status]"); + const anchorStatus = document.querySelector("[data-anchor-status]"); + const anchorBookId = document.querySelector("[data-anchor-book-id]"); + const anchorChapterId = document.querySelector("[data-anchor-chapter-id]"); + const anchorSceneId = document.querySelector("[data-anchor-scene-id]"); + const attachPlotDirectorIdsButton = document.querySelector("[data-attach-plotdirector-ids]"); const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]"); const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]"); const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]"); @@ -64,6 +69,10 @@ const diagnosticWordApi = document.querySelector("[data-diagnostic-word-api]"); const diagnosticLastScan = document.querySelector("[data-diagnostic-last-scan]"); const diagnosticLastError = document.querySelector("[data-diagnostic-last-error]"); + const diagnosticAnchorType = document.querySelector("[data-diagnostic-anchor-type]"); + const diagnosticStoredSceneId = document.querySelector("[data-diagnostic-stored-scene-id]"); + const diagnosticStoredChapterId = document.querySelector("[data-diagnostic-stored-chapter-id]"); + const diagnosticResolutionMethod = document.querySelector("[data-diagnostic-resolution-method]"); const diagnosticParagraphs = document.querySelector("[data-diagnostic-paragraphs]"); const diagnosticHeading1 = document.querySelector("[data-diagnostic-heading1]"); const diagnosticHeading2 = document.querySelector("[data-diagnostic-heading2]"); @@ -77,7 +86,13 @@ let detectedChapterTitle = ""; let detectedSceneTitle = ""; let detectedSceneWordCount = null; + let detectedChapterAnchorId = null; + let detectedSceneAnchorId = null; let resolvedSceneId = null; + let resolvedChapterId = null; + let currentResolutionMethod = ""; + let currentResolutionAnchorType = "Title Match"; + let storedAnchorInvalid = false; let currentSceneLinks = { characters: [], assets: [], @@ -124,10 +139,51 @@ } }; - const setCurrentStructure = (chapterTitle, sceneTitle, wordCount) => { + const setAnchorDiagnostics = (values) => { + const hasValue = (name) => Object.prototype.hasOwnProperty.call(values, name); + + if (hasValue("anchorType")) setText(diagnosticAnchorType, displayValue(values.anchorType)); + if (hasValue("storedSceneId")) setText(diagnosticStoredSceneId, displayValue(values.storedSceneId)); + if (hasValue("storedChapterId")) setText(diagnosticStoredChapterId, displayValue(values.storedChapterId)); + if (hasValue("resolutionMethod")) setText(diagnosticResolutionMethod, displayValue(values.resolutionMethod)); + }; + + const updateAnchorDisplay = () => { + const book = selectedBook(); + const hasMatchingSceneAnchor = Number.isInteger(resolvedSceneId) + && resolvedSceneId > 0 + && detectedSceneAnchorId === resolvedSceneId; + const hasMatchingChapterAnchor = !Number.isInteger(resolvedChapterId) + || resolvedChapterId <= 0 + || detectedChapterAnchorId === resolvedChapterId; + const attached = hasMatchingSceneAnchor && hasMatchingChapterAnchor; + + setText(anchorStatus, attached ? "Attached to PlotDirector" : "Not attached"); + setText(anchorBookId, book?.bookId ? String(book.bookId) : "-"); + setText(anchorChapterId, Number.isInteger(resolvedChapterId) && resolvedChapterId > 0 ? String(resolvedChapterId) : "-"); + setText(anchorSceneId, Number.isInteger(resolvedSceneId) && resolvedSceneId > 0 ? String(resolvedSceneId) : "-"); + + if (attachPlotDirectorIdsButton) { + attachPlotDirectorIdsButton.disabled = !resolvedSceneId || (attached && !storedAnchorInvalid); + attachPlotDirectorIdsButton.textContent = detectedSceneAnchorId && !attached + ? "Reattach PlotDirector IDs" + : "Attach PlotDirector IDs"; + } + + setAnchorDiagnostics({ + anchorType: currentResolutionAnchorType || "Title Match", + storedSceneId: detectedSceneAnchorId, + storedChapterId: detectedChapterAnchorId, + resolutionMethod: currentResolutionMethod + }); + }; + + const setCurrentStructure = (chapterTitle, sceneTitle, wordCount, chapterAnchorId = null, sceneAnchorId = null) => { detectedChapterTitle = chapterTitle || ""; detectedSceneTitle = sceneTitle || ""; detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null; + detectedChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null; + detectedSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null; if (currentChapter) { currentChapter.textContent = chapterTitle || "Not detected"; @@ -139,6 +195,7 @@ currentWordCount.textContent = Number.isInteger(wordCount) ? String(wordCount) : "-"; } setText(syncWordCount, Number.isInteger(wordCount) ? String(wordCount) : "-"); + updateAnchorDisplay(); }; const setHidden = (element, hidden) => { @@ -151,8 +208,10 @@ setText(syncStatus, message); }; - const setResolvedScene = (sceneId) => { + const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => { resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null; + resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null; + currentResolutionMethod = resolutionMethod || ""; if (syncSceneProgressButton) { syncSceneProgressButton.disabled = !resolvedSceneId; } @@ -161,6 +220,7 @@ } setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene."); setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); + updateAnchorDisplay(); }; const setRefreshCurrentSceneBusy = (isBusy) => { @@ -189,6 +249,9 @@ setHidden(plotDirectorSceneDetails, true); setHidden(writingBriefBlock, true); setHidden(plotDirectorLinkGroups, true); + currentResolutionMethod = ""; + currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match"; + storedAnchorInvalid = false; currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null }; renderSceneLinkList(characterChips, [], "character"); renderSceneLinkList(assetChips, [], "asset"); @@ -202,6 +265,7 @@ setText(plotDirectorBlockedStatus, "-"); setText(plotDirectorBlockedReason, "-"); setHidden(plotDirectorBlockedReasonRow, true); + updateAnchorDisplay(); }; const linkItemId = (item, type) => { @@ -362,6 +426,36 @@ return ""; }; + const findSceneInBookStructure = async (bookId, predicate) => { + const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`); + const chapters = Array.isArray(structure?.chapters) ? structure.chapters : []; + for (const chapter of chapters) { + const scenes = Array.isArray(chapter.scenes) ? chapter.scenes : []; + for (const scene of scenes) { + if (predicate(chapter, scene)) { + return { chapter, scene }; + } + } + } + + return null; + }; + + const loadResolvedCompanionData = async (bookId, sceneId, chapterId, resolutionMethod, anchorType) => { + const companionData = await fetchJson(`/api/word-companion/scenes/${sceneId}/companion`); + try { + companionData.revisionStatus = await findSceneRevisionStatus(bookId, sceneId); + } catch { + companionData.revisionStatus = ""; + } + currentResolutionAnchorType = anchorType || "Title Match"; + storedAnchorInvalid = false; + setPlotDirectorSceneStatus("Linked to PlotDirector"); + setDocumentMessage(""); + setResolvedScene(sceneId, chapterId, resolutionMethod); + renderCompanionData(companionData); + }; + const setText = (element, value) => { if (element) { element.textContent = value; @@ -551,6 +645,28 @@ && normalizedStyleName(left) === normalizedStyleName(right); }; + const anchorIdFromTag = (tag, prefix) => { + const match = String(tag || "").match(new RegExp(`^${prefix}-(\\d+)$`, "i")); + if (!match) { + return null; + } + + const id = Number.parseInt(match[1], 10); + return Number.isInteger(id) && id > 0 ? id : null; + }; + + const paragraphAnchorId = (paragraph, prefix) => { + const controls = Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : []; + for (const control of controls) { + const id = anchorIdFromTag(control.tag, prefix); + if (id) { + return id; + } + } + + return null; + }; + const buildDocumentStructure = (paragraphs, selectionParagraphs) => { const chapters = []; let heading1Count = 0; @@ -570,6 +686,7 @@ index: chapters.length + 1, paragraphIndex: index, title, + anchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"), scenes: [] }; chapters.push(chapter); @@ -587,6 +704,7 @@ index: chapter.scenes.length + 1, paragraphIndex: index, title, + anchorId: paragraphAnchorId(paragraph, "PD-SCENE"), wordCount: 0 }; chapter.scenes.push(scene); @@ -662,6 +780,26 @@ } }; + const readDocumentStructure = async (context) => { + const bodyParagraphs = context.document.body.paragraphs; + const selectionParagraphs = context.document.getSelection().paragraphs; + + bodyParagraphs.load("text,styleBuiltIn,style"); + selectionParagraphs.load("text,styleBuiltIn,style"); + await context.sync(); + + for (const paragraph of bodyParagraphs.items) { + if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) { + paragraph.contentControls.load("items/tag,title"); + } + } + await context.sync(); + + const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items); + structure.bodyParagraphs = bodyParagraphs.items; + return structure; + }; + const refreshDocumentStructure = async () => { setDocumentMessage(""); @@ -680,20 +818,15 @@ try { const structure = await window.Word.run(async (context) => { - const bodyParagraphs = context.document.body.paragraphs; - const selectionParagraphs = context.document.getSelection().paragraphs; - - bodyParagraphs.load("text,styleBuiltIn,style"); - selectionParagraphs.load("text,styleBuiltIn,style"); - await context.sync(); - - return buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items); + return await readDocumentStructure(context); }); setCurrentStructure( structure.currentChapter?.title, structure.currentScene?.title, - structure.currentScene?.wordCount + structure.currentScene?.wordCount, + structure.currentChapter?.anchorId, + structure.currentScene?.anchorId ); resetPlotDirectorScene("Not detected"); renderOutline(structure); @@ -859,6 +992,83 @@ } }; + const findAnchorControl = (paragraph, prefix) => { + const controls = Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : []; + return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null; + }; + + const ensureAnchorControl = (paragraph, title, tag, prefix) => { + const existing = findAnchorControl(paragraph, prefix); + const control = existing || paragraph.getRange().insertContentControl(); + control.title = title; + control.tag = tag; + control.appearance = "Hidden"; + return control; + }; + + const attachPlotDirectorIds = async () => { + if (!resolvedSceneId || !resolvedChapterId) { + setDocumentMessage("No resolved PlotDirector scene."); + return; + } + + const replacingExistingAnchor = (detectedSceneAnchorId && detectedSceneAnchorId !== resolvedSceneId) + || (detectedChapterAnchorId && detectedChapterAnchorId !== resolvedChapterId); + if (replacingExistingAnchor && !window.confirm("This scene is already linked. Replace the existing PlotDirector link?")) { + return; + } + + if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { + setDocumentMessage("Word document access is unavailable."); + return; + } + + if (attachPlotDirectorIdsButton) { + attachPlotDirectorIdsButton.disabled = true; + attachPlotDirectorIdsButton.textContent = "Attaching..."; + } + + try { + await window.Word.run(async (context) => { + const structure = await readDocumentStructure(context); + if (!structure.currentChapter || !structure.currentScene) { + throw new Error("No scene detected in Word. Use Heading 2 for scenes."); + } + + const chapterParagraph = structure.bodyParagraphs[structure.currentChapter.paragraphIndex]; + const sceneParagraph = structure.bodyParagraphs[structure.currentScene.paragraphIndex]; + if (!chapterParagraph || !sceneParagraph) { + throw new Error("Unable to locate the current Word headings."); + } + + ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER"); + ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE"); + await context.sync(); + }); + + detectedChapterAnchorId = resolvedChapterId; + detectedSceneAnchorId = resolvedSceneId; + storedAnchorInvalid = false; + currentResolutionAnchorType = "SceneID Anchor"; + currentResolutionMethod = "Anchor"; + setDocumentMessage("PlotDirector IDs attached successfully."); + setDiagnostics({ lastError: "-" }); + updateAnchorDisplay(); + } catch (error) { + console.error("Unable to attach PlotDirector IDs.", error); + setDocumentMessage("Unable to attach PlotDirector IDs."); + setDiagnostics({ lastError: errorText(error) }); + updateAnchorDisplay(); + } finally { + if (attachPlotDirectorIdsButton) { + attachPlotDirectorIdsButton.textContent = detectedSceneAnchorId === resolvedSceneId + ? "Attach PlotDirector IDs" + : "Reattach PlotDirector IDs"; + updateAnchorDisplay(); + } + } + }; + const syncSceneProgress = async () => { if (!resolvedSceneId) { setSyncStatus("No resolved PlotDirector scene."); @@ -914,6 +1124,12 @@ chapterId: null, sceneId: null }); + setAnchorDiagnostics({ + anchorType: detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match", + storedSceneId: detectedSceneAnchorId, + storedChapterId: detectedChapterAnchorId, + resolutionMethod: "-" + }); if (!book) { resetPlotDirectorScene("Not detected"); @@ -942,16 +1158,80 @@ resetPlotDirectorScene("Not detected"); setDocumentMessage("Resolving PlotDirector scene..."); - let resolvedSceneId = null; try { + if (detectedSceneAnchorId) { + const anchored = await findSceneInBookStructure( + book.bookId, + (_chapter, scene) => scene.sceneId === detectedSceneAnchorId + ); + + if (anchored) { + setResolveDiagnostics({ + result: "Matched", + chapterId: anchored.chapter.chapterId, + sceneId: anchored.scene.sceneId + }); + await loadResolvedCompanionData(book.bookId, anchored.scene.sceneId, anchored.chapter.chapterId, "Anchor", "SceneID Anchor"); + return true; + } + + storedAnchorInvalid = true; + currentResolutionAnchorType = "SceneID Anchor"; + setPlotDirectorSceneStatus("Not found in PlotDirector"); + setDocumentMessage("Stored PlotDirector ID is invalid."); + setResolveDiagnostics({ + result: "Error", + chapterId: detectedChapterAnchorId, + sceneId: detectedSceneAnchorId + }); + setAnchorDiagnostics({ + anchorType: "SceneID Anchor", + storedSceneId: detectedSceneAnchorId, + storedChapterId: detectedChapterAnchorId, + resolutionMethod: "Anchor" + }); + } + + if (detectedChapterAnchorId) { + const anchoredChapterScene = await findSceneInBookStructure( + book.bookId, + (chapter, scene) => chapter.chapterId === detectedChapterAnchorId + && normalizeTitleForResolve(scene.title, "scene").toLocaleLowerCase() === requestSceneTitle.toLocaleLowerCase() + ); + + if (anchoredChapterScene) { + setResolveDiagnostics({ + result: "Matched", + chapterId: anchoredChapterScene.chapter.chapterId, + sceneId: anchoredChapterScene.scene.sceneId + }); + await loadResolvedCompanionData(book.bookId, anchoredChapterScene.scene.sceneId, anchoredChapterScene.chapter.chapterId, "Anchor", "ChapterID Anchor"); + return true; + } + + if (!detectedSceneAnchorId) { + storedAnchorInvalid = true; + currentResolutionAnchorType = "ChapterID Anchor"; + setPlotDirectorSceneStatus("Not found in PlotDirector"); + setDocumentMessage("Stored PlotDirector ID is invalid."); + setResolveDiagnostics({ + result: "Error", + chapterId: detectedChapterAnchorId, + sceneId: null + }); + } + } + const resolveResponse = await postJson(`/api/word-companion/books/${book.bookId}/resolve-scene`, { chapterTitle: requestChapterTitle, sceneTitle: requestSceneTitle }); if (!resolveResponse?.matched || !resolveResponse.sceneId) { + const hadInvalidAnchor = storedAnchorInvalid; resetPlotDirectorScene("Not found in PlotDirector"); - setDocumentMessage("Scene not found in PlotDirector."); + storedAnchorInvalid = hadInvalidAnchor; + setDocumentMessage(hadInvalidAnchor ? "Stored PlotDirector ID is invalid." : "Scene not found in PlotDirector."); setResolveDiagnostics({ result: "Not matched", chapterId: resolveResponse?.chapterId, @@ -961,35 +1241,26 @@ return false; } - resolvedSceneId = resolveResponse.sceneId; + const hadInvalidAnchor = storedAnchorInvalid; + setResolveDiagnostics({ - result: "Matched", + result: hadInvalidAnchor ? "Matched by title fallback" : "Matched", chapterId: resolveResponse.chapterId, sceneId: resolveResponse.sceneId }); - } catch { - resetPlotDirectorScene("Not found in PlotDirector"); - setDocumentMessage("Scene not found in PlotDirector."); - setResolveDiagnostics({ result: "Error" }); - restorePlotDirectorSceneButton(); - return false; - } - - try { - const companionData = await fetchJson(`/api/word-companion/scenes/${resolvedSceneId}/companion`); - try { - companionData.revisionStatus = await findSceneRevisionStatus(book.bookId, resolvedSceneId); - } catch { - companionData.revisionStatus = ""; + await loadResolvedCompanionData(book.bookId, resolveResponse.sceneId, resolveResponse.chapterId, hadInvalidAnchor ? "Title fallback" : "Title", "Title Match"); + if (hadInvalidAnchor) { + storedAnchorInvalid = true; + setDocumentMessage("Stored PlotDirector ID is invalid."); + updateAnchorDisplay(); } - setPlotDirectorSceneStatus("Linked to PlotDirector"); - setDocumentMessage(""); - setResolvedScene(resolvedSceneId); - renderCompanionData(companionData); return true; - } catch { + } catch (error) { + const hadInvalidAnchor = storedAnchorInvalid; + setDiagnostics({ lastError: errorText(error) }); resetPlotDirectorScene("Not found in PlotDirector"); - setDocumentMessage("Unable to load PlotDirector scene data."); + storedAnchorInvalid = hadInvalidAnchor; + setDocumentMessage(hadInvalidAnchor ? "Stored PlotDirector ID is invalid." : "Unable to load PlotDirector scene data."); setResolveDiagnostics({ result: "Error" }); return false; } finally { @@ -1134,6 +1405,7 @@ refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene); syncSceneProgressButton?.addEventListener("click", syncSceneProgress); saveSceneLinksButton?.addEventListener("click", saveSceneLinks); + attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds); runDiagnosticsButton?.addEventListener("click", runDiagnostics); if (isAuthenticated) {