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) {