diff --git a/PlotDirector.WordCompanion/README.md b/PlotDirector.WordCompanion/README.md
index 3e823e4..46053d5 100644
--- a/PlotDirector.WordCompanion/README.md
+++ b/PlotDirector.WordCompanion/README.md
@@ -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
diff --git a/PlotDirector.WordCompanion/manifest.template.xml b/PlotDirector.WordCompanion/manifest.template.xml
index 6ec396f..b0cbc7c 100644
--- a/PlotDirector.WordCompanion/manifest.template.xml
+++ b/PlotDirector.WordCompanion/manifest.template.xml
@@ -22,9 +22,9 @@
-
- ReadDocument
+
+ ReadWriteDocument
diff --git a/PlotDirector.WordCompanion/manifest.xml b/PlotDirector.WordCompanion/manifest.xml
index 13f22cb..dc48fd1 100644
--- a/PlotDirector.WordCompanion/manifest.xml
+++ b/PlotDirector.WordCompanion/manifest.xml
@@ -22,9 +22,9 @@
-
- ReadDocument
+
+ ReadWriteDocument
diff --git a/PlotLine.Tests/Program.cs b/PlotLine.Tests/Program.cs
index 53e620c..8f8df28 100644
--- a/PlotLine.Tests/Program.cs
+++ b/PlotLine.Tests/Program.cs
@@ -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("ReadWriteDocument", 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.");
diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js
index a66cc14..1e64b35 100644
--- a/PlotLine/wwwroot/js/word-companion-host.js
+++ b/PlotLine/wwwroot/js/word-companion-host.js
@@ -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.") => {