Phase 10I: Word Companion Manuscript Anchors (Hidden PlotDirector IDs)

This commit is contained in:
Nick Beckley 2026-06-13 21:35:34 +01:00
parent 6dd46c2632
commit cda6279d9f
3 changed files with 357 additions and 35 deletions

View File

@ -66,6 +66,22 @@
<span>Link Status</span>
<strong data-plotdirector-scene-status>Not detected</strong>
</div>
<div>
<span>Anchor Status</span>
<strong data-anchor-status>Not attached</strong>
</div>
<div>
<span>Book ID</span>
<strong data-anchor-book-id>-</strong>
</div>
<div>
<span>Chapter ID</span>
<strong data-anchor-chapter-id>-</strong>
</div>
<div>
<span>Scene ID</span>
<strong data-anchor-scene-id>-</strong>
</div>
<div>
<span>Word Count in Word</span>
<strong data-current-word-count>-</strong>
@ -101,6 +117,7 @@
<strong data-sync-plotdirector-count>-</strong>
</div>
</div>
<button type="button" data-attach-plotdirector-ids disabled>Attach PlotDirector IDs</button>
<p class="word-companion-message" data-sync-note></p>
</section>
@ -189,6 +206,22 @@
<span>Scene ID</span>
<strong data-resolve-scene-id>-</strong>
</div>
<div>
<span>Anchor Type</span>
<strong data-diagnostic-anchor-type>Title Match</strong>
</div>
<div>
<span>Stored Scene ID</span>
<strong data-diagnostic-stored-scene-id>-</strong>
</div>
<div>
<span>Stored Chapter ID</span>
<strong data-diagnostic-stored-chapter-id>-</strong>
</div>
<div>
<span>Current Resolution Method</span>
<strong data-diagnostic-resolution-method>-</strong>
</div>
<div>
<span>Office Host</span>
<strong data-diagnostic-host>Unknown</strong>

View File

@ -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;
}

View File

@ -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;
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();
}
try {
const companionData = await fetchJson(`/api/word-companion/scenes/${resolvedSceneId}/companion`);
try {
companionData.revisionStatus = await findSceneRevisionStatus(book.bookId, resolvedSceneId);
} catch {
companionData.revisionStatus = "";
}
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) {