Add bookmark fallback for Word scene anchors

This commit is contained in:
Nick Beckley 2026-09-05 20:47:15 +00:00
parent 340fec3cab
commit 846016b7be
5 changed files with 130 additions and 38 deletions

View File

@ -6,7 +6,7 @@ The add-in is deliberately small:
- The manifest adds one ribbon button: **Open PlotDirector Companion**.
- The task pane opens the PlotDirector-hosted `/word-companion` route.
- The manifest requests `ReadDocument` so the companion can read Word paragraphs, selection context, and document body content for the manual document-structure scan.
- The manifest requests `ReadWriteDocument` so the companion can read Word paragraphs and write hidden PlotDirector anchors back into the manuscript after import.
- The companion does not request document write permissions.
## Development

View File

@ -22,9 +22,9 @@
<DefaultSettings>
<SourceLocation DefaultValue="{{taskpaneUrl}}"/>
</DefaultSettings>
<!-- ReadDocument is required for the companion to read Word paragraphs, selection context,
and document body content. It does not grant document write access. -->
<Permissions>ReadDocument</Permissions>
<!-- ReadWriteDocument is required because the companion writes hidden PlotDirector anchors
back into the Word document after a manuscript import. -->
<Permissions>ReadWriteDocument</Permissions>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
<Hosts>
<Host xsi:type="Document">

View File

@ -22,9 +22,9 @@
<DefaultSettings>
<SourceLocation DefaultValue="https://plotdirector.com/word-companion"/>
</DefaultSettings>
<!-- ReadDocument is required for the companion to read Word paragraphs, selection context,
and document body content. It does not grant document write access. -->
<Permissions>ReadDocument</Permissions>
<!-- ReadWriteDocument is required because the companion writes hidden PlotDirector anchors
back into the Word document after a manuscript import. -->
<Permissions>ReadWriteDocument</Permissions>
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
<Hosts>
<Host xsi:type="Document">

View File

@ -1973,7 +1973,7 @@ static void WordCompanionRuntimeFollowsMultipleImportedSceneAnchors()
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
Assert(script.Contains("paragraph.contentControls.load(\"items/tag,title\")", StringComparison.Ordinal), "Runtime structure reads must load paragraph content controls, not only text/style.");
Assert(script.Contains("await loadParagraphAnchors(context, bodyParagraphs, null, \"runtime structure\")", StringComparison.Ordinal), "Runtime structure reads must load paragraph anchors, not only text/style.");
Assert(script.Contains("chapterAnchorId: paragraphAnchorId(paragraph, \"PD-CHAPTER\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical chapter anchors.");
Assert(script.Contains("sceneAnchorId: paragraphAnchorId(paragraph, \"PD-SCENE\")", StringComparison.Ordinal), "Runtime paragraph snapshots should carry canonical scene anchors.");
Assert(script.Contains("const hasCanonicalSceneAnchors = paragraphSnapshots.some", StringComparison.Ordinal), "Runtime structure should detect imported scene-anchor mode.");
@ -1984,15 +1984,19 @@ static void WordCompanionRuntimeFollowsMultipleImportedSceneAnchors()
Assert(script.Contains("const reusable = existing || findAnchorControl(paragraph, prefix)", StringComparison.Ordinal), "Marker insertion should reuse an existing same-prefix marker on the same paragraph before inserting another control.");
Assert(script.Contains("const rangeLocations = [\"Content\", \"Start\", \"Whole\"]", StringComparison.Ordinal), "Marker insertion should try heading content before progressively smaller Word ranges.");
Assert(script.Contains("paragraph.getRange(rangeLocation).insertContentControl()", StringComparison.Ordinal), "Marker insertion should retry Word-supported paragraph range locations.");
Assert(script.Contains("insertBookmark(bookmarkName)", StringComparison.Ordinal), "Marker insertion should fall back to hidden bookmarks when Word rejects content-control insertion.");
Assert(script.Contains("anchorIdFromBookmark(bookmark, prefix)", StringComparison.Ordinal), "Runtime anchor detection should read PlotDirector bookmark markers.");
Assert(script.Contains("await ensureAnchorControl(context, paragraphs.items[index], title, tag, prefix)", StringComparison.Ordinal), "Bulk marker sync should await each marker insertion so Word range failures are isolated.");
Assert(script.Contains("paragraphs.items[index].contentControls.load(\"items/tag,title\")", StringComparison.Ordinal), "Bulk marker sync should inspect existing paragraph markers before deciding to insert content controls.");
Assert(script.Contains("await loadParagraphAnchors(context, paragraphs, markerParagraphIndexes, \"marker sync\")", StringComparison.Ordinal), "Bulk marker sync should inspect existing paragraph markers before deciding to insert content controls.");
}
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var companionRoot = Path.Combine(AppContext.BaseDirectory, "../../../../PlotDirector.WordCompanion");
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 manifest = File.ReadAllText(Path.Combine(companionRoot, "manifest.template.xml"));
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"));
@ -2006,6 +2010,7 @@ static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
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(manifest.Contains("<Permissions>ReadWriteDocument</Permissions>", StringComparison.Ordinal), "Word Companion manifest must request write permission because marker sync writes anchors into the document.");
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.");

View File

@ -1978,6 +1978,22 @@
return Number.isInteger(id) && id > 0 ? id : null;
};
const markerBookmarkName = (tag) => {
const name = `_${String(tag || "").trim().toUpperCase().replace(/[^A-Z0-9]+/g, "_")}`;
return name.length <= 40 ? name : name.slice(0, 40);
};
const anchorIdFromBookmark = (bookmark, prefix) => {
const expectedPrefix = `_${String(prefix || "").trim().toUpperCase().replace(/[^A-Z0-9]+/g, "_")}_`;
const value = String(bookmark || "").trim().toUpperCase();
if (!value.startsWith(expectedPrefix)) {
return null;
}
const id = Number.parseInt(value.slice(expectedPrefix.length), 10);
return Number.isInteger(id) && id > 0 ? id : null;
};
const safeContentControlItems = (paragraph) => {
try {
return Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : [];
@ -1987,6 +2003,64 @@
}
};
const safeBookmarkItems = (paragraph) => {
try {
return Array.isArray(paragraph?._plotDirectorBookmarks) ? paragraph._plotDirectorBookmarks : [];
} catch (error) {
console.warn("Word paragraph bookmarks were not available.", error);
return [];
}
};
const findAnchorBookmark = (paragraph, prefix) => {
const bookmarks = safeBookmarkItems(paragraph);
return bookmarks.find((bookmark) => anchorIdFromBookmark(bookmark, prefix)) || "";
};
const findExactAnchorBookmark = (paragraph, tag) => {
const expected = markerBookmarkName(tag);
const bookmarks = safeBookmarkItems(paragraph);
return bookmarks.find((bookmark) => String(bookmark || "").toUpperCase() === expected) || "";
};
const loadParagraphAnchors = async (context, paragraphs, indexes = null, logContext = "document") => {
const items = Array.isArray(paragraphs?.items) ? paragraphs.items : paragraphs;
if (!Array.isArray(items) || items.length === 0) {
return;
}
const selectedIndexes = indexes
? [...indexes].filter((index) => Number.isInteger(index) && items[index])
: items.map((_, index) => index);
try {
for (const index of selectedIndexes) {
items[index].contentControls.load("items/tag,title");
}
await context.sync();
} catch (error) {
console.warn(`Unable to load Word content controls for PlotDirector ${logContext} anchors.`, error);
}
const bookmarkReads = [];
try {
for (const index of selectedIndexes) {
const range = items[index].getRange("Whole");
if (range && typeof range.getBookmarks === "function") {
bookmarkReads.push({ paragraph: items[index], result: range.getBookmarks(true, true) });
}
}
if (bookmarkReads.length > 0) {
await context.sync();
for (const read of bookmarkReads) {
read.paragraph._plotDirectorBookmarks = Array.isArray(read.result.value) ? read.result.value : [];
}
}
} catch (error) {
console.warn(`Unable to load Word bookmarks for PlotDirector ${logContext} anchors.`, error);
}
};
const paragraphAnchorId = (paragraph, prefix) => {
const controls = safeContentControlItems(paragraph);
for (const control of controls) {
@ -1996,6 +2070,14 @@
}
}
const bookmarks = safeBookmarkItems(paragraph);
for (const bookmark of bookmarks) {
const id = anchorIdFromBookmark(bookmark, prefix);
if (id) {
return id;
}
}
return null;
};
@ -2562,12 +2644,13 @@
await context.sync();
await reportOnboardingScanProgress(command, "Detecting chapters", 20);
for (const paragraph of bodyParagraphs.items) {
const headingIndexes = new Set();
bodyParagraphs.items.forEach((paragraph, index) => {
if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) {
paragraph.contentControls.load("items/tag,title");
headingIndexes.add(index);
}
}
await context.sync();
});
await loadParagraphAnchors(context, bodyParagraphs, headingIndexes, "onboarding scan");
return bodyParagraphs.items;
});
console.info("[Word Companion Scan] Word extraction completed.", {
@ -2645,14 +2728,7 @@
}
}
try {
for (const index of markerParagraphIndexes) {
paragraphs.items[index].contentControls.load("items/tag,title");
}
await context.sync();
} catch (error) {
console.warn("Unable to inspect existing PlotDirector markers before marker sync.", error);
}
await loadParagraphAnchors(context, paragraphs, markerParagraphIndexes, "marker sync");
if (chapterInstructions.length > 0 || sceneInstructions.length > 0) {
for (const instruction of chapterInstructions) {
@ -3246,14 +3322,7 @@
selectionParagraphs.load("text,styleBuiltIn,style");
await context.sync();
try {
for (const paragraph of bodyParagraphs.items) {
paragraph.contentControls.load("items/tag,title");
}
await context.sync();
} catch (error) {
console.warn("Unable to load Word content controls. Continuing without PlotDirector anchors.", error);
}
await loadParagraphAnchors(context, bodyParagraphs, null, "document structure");
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
structure.bodyParagraphs = bodyParagraphs.items;
@ -3268,14 +3337,7 @@
selectionParagraphs.load("text,styleBuiltIn,style");
await context.sync();
try {
for (const paragraph of bodyParagraphs.items) {
paragraph.contentControls.load("items/tag,title");
}
await context.sync();
} catch (error) {
console.warn("Unable to load Word runtime content controls. Continuing without PlotDirector anchors.", error);
}
await loadParagraphAnchors(context, bodyParagraphs, null, "runtime structure");
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
@ -4657,6 +4719,11 @@
return true;
}
const existingBookmark = findExactAnchorBookmark(paragraph, tag);
if (existingBookmark) {
return false;
}
const rangeLocations = ["Content", "Start", "Whole"];
let lastError = null;
for (const rangeLocation of rangeLocations) {
@ -4673,7 +4740,27 @@
}
}
throw lastError || new Error(`Unable to create PlotDirector marker ${tag}.`);
try {
const bookmarkName = markerBookmarkName(tag);
const oldBookmark = findAnchorBookmark(paragraph, prefix);
if (oldBookmark && oldBookmark !== bookmarkName && typeof context.document.deleteBookmark === "function") {
context.document.deleteBookmark(oldBookmark);
}
paragraph.getRange("Start").insertBookmark(bookmarkName);
paragraph._plotDirectorBookmarks = [
...safeBookmarkItems(paragraph).filter((bookmark) => !anchorIdFromBookmark(bookmark, prefix)),
bookmarkName
];
await context.sync();
console.info(`Created PlotDirector bookmark marker ${bookmarkName} after content-control insertion failed for ${tag}.`);
return true;
} catch (bookmarkError) {
console.error(`Unable to create PlotDirector marker ${tag} as a content control or bookmark.`, {
contentControlError: errorText(lastError),
bookmarkError: errorText(bookmarkError)
});
throw lastError || bookmarkError || new Error(`Unable to create PlotDirector marker ${tag}.`);
}
};
const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {