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.") => {