Add bookmark fallback for Word scene anchors
This commit is contained in:
parent
340fec3cab
commit
846016b7be
@ -6,7 +6,7 @@ The add-in is deliberately small:
|
|||||||
|
|
||||||
- The manifest adds one ribbon button: **Open PlotDirector Companion**.
|
- The manifest adds one ribbon button: **Open PlotDirector Companion**.
|
||||||
- The task pane opens the PlotDirector-hosted `/word-companion` route.
|
- 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.
|
- The companion does not request document write permissions.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@ -22,9 +22,9 @@
|
|||||||
<DefaultSettings>
|
<DefaultSettings>
|
||||||
<SourceLocation DefaultValue="{{taskpaneUrl}}"/>
|
<SourceLocation DefaultValue="{{taskpaneUrl}}"/>
|
||||||
</DefaultSettings>
|
</DefaultSettings>
|
||||||
<!-- ReadDocument is required for the companion to read Word paragraphs, selection context,
|
<!-- ReadWriteDocument is required because the companion writes hidden PlotDirector anchors
|
||||||
and document body content. It does not grant document write access. -->
|
back into the Word document after a manuscript import. -->
|
||||||
<Permissions>ReadDocument</Permissions>
|
<Permissions>ReadWriteDocument</Permissions>
|
||||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||||
<Hosts>
|
<Hosts>
|
||||||
<Host xsi:type="Document">
|
<Host xsi:type="Document">
|
||||||
|
|||||||
@ -22,9 +22,9 @@
|
|||||||
<DefaultSettings>
|
<DefaultSettings>
|
||||||
<SourceLocation DefaultValue="https://plotdirector.com/word-companion"/>
|
<SourceLocation DefaultValue="https://plotdirector.com/word-companion"/>
|
||||||
</DefaultSettings>
|
</DefaultSettings>
|
||||||
<!-- ReadDocument is required for the companion to read Word paragraphs, selection context,
|
<!-- ReadWriteDocument is required because the companion writes hidden PlotDirector anchors
|
||||||
and document body content. It does not grant document write access. -->
|
back into the Word document after a manuscript import. -->
|
||||||
<Permissions>ReadDocument</Permissions>
|
<Permissions>ReadWriteDocument</Permissions>
|
||||||
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
<VersionOverrides xmlns="http://schemas.microsoft.com/office/taskpaneappversionoverrides" xsi:type="VersionOverridesV1_0">
|
||||||
<Hosts>
|
<Hosts>
|
||||||
<Host xsi:type="Document">
|
<Host xsi:type="Document">
|
||||||
|
|||||||
@ -1973,7 +1973,7 @@ static void WordCompanionRuntimeFollowsMultipleImportedSceneAnchors()
|
|||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
||||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
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("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("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.");
|
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 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("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("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("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()
|
static void WordCompanionExistingProjectBookLinkingRemainsAvailable()
|
||||||
{
|
{
|
||||||
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
|
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 view = File.ReadAllText(Path.Combine(root, "Views/WordCompanionHost/Index.cshtml"));
|
||||||
var script = File.ReadAllText(Path.Combine(root, "wwwroot/js/word-companion-host.js"));
|
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 controller = File.ReadAllText(Path.Combine(root, "Controllers/WordCompanionController.cs"));
|
||||||
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
|
var service = File.ReadAllText(Path.Combine(root, "Services/WordCompanionService.cs"));
|
||||||
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.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("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", 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(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(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("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.");
|
Assert(service.Contains("manuscriptDocuments.SaveAsync(request.BookId, documentGuid, RequireUserId())", StringComparison.Ordinal), "Existing-book linking should save a document binding directly.");
|
||||||
|
|||||||
@ -1978,6 +1978,22 @@
|
|||||||
return Number.isInteger(id) && id > 0 ? id : null;
|
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) => {
|
const safeContentControlItems = (paragraph) => {
|
||||||
try {
|
try {
|
||||||
return Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : [];
|
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 paragraphAnchorId = (paragraph, prefix) => {
|
||||||
const controls = safeContentControlItems(paragraph);
|
const controls = safeContentControlItems(paragraph);
|
||||||
for (const control of controls) {
|
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;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -2562,12 +2644,13 @@
|
|||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
await reportOnboardingScanProgress(command, "Detecting chapters", 20);
|
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)) {
|
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;
|
return bodyParagraphs.items;
|
||||||
});
|
});
|
||||||
console.info("[Word Companion Scan] Word extraction completed.", {
|
console.info("[Word Companion Scan] Word extraction completed.", {
|
||||||
@ -2645,14 +2728,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
await loadParagraphAnchors(context, paragraphs, markerParagraphIndexes, "marker sync");
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chapterInstructions.length > 0 || sceneInstructions.length > 0) {
|
if (chapterInstructions.length > 0 || sceneInstructions.length > 0) {
|
||||||
for (const instruction of chapterInstructions) {
|
for (const instruction of chapterInstructions) {
|
||||||
@ -3246,14 +3322,7 @@
|
|||||||
selectionParagraphs.load("text,styleBuiltIn,style");
|
selectionParagraphs.load("text,styleBuiltIn,style");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
try {
|
await loadParagraphAnchors(context, bodyParagraphs, null, "document structure");
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
|
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
|
||||||
structure.bodyParagraphs = bodyParagraphs.items;
|
structure.bodyParagraphs = bodyParagraphs.items;
|
||||||
@ -3268,14 +3337,7 @@
|
|||||||
selectionParagraphs.load("text,styleBuiltIn,style");
|
selectionParagraphs.load("text,styleBuiltIn,style");
|
||||||
await context.sync();
|
await context.sync();
|
||||||
|
|
||||||
try {
|
await loadParagraphAnchors(context, bodyParagraphs, null, "runtime structure");
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
|
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
|
||||||
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
|
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
|
||||||
@ -4657,6 +4719,11 @@
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingBookmark = findExactAnchorBookmark(paragraph, tag);
|
||||||
|
if (existingBookmark) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const rangeLocations = ["Content", "Start", "Whole"];
|
const rangeLocations = ["Content", "Start", "Whole"];
|
||||||
let lastError = null;
|
let lastError = null;
|
||||||
for (const rangeLocation of rangeLocations) {
|
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.") => {
|
const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user