diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 0cbd8ba..f196915 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -186,6 +186,17 @@ +
+
+ Structure Sync Preview + +
+

Select a project and book to analyse manuscript structure.

+
+

No preview generated.

+
+
+
Document Outline
diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index 3a64cc4..9bc0208 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -343,6 +343,54 @@ body { border-color: rgba(47, 111, 99, 0.38); } +.word-companion-structure-preview { + border-color: rgba(47, 111, 99, 0.28); +} + +.word-companion-preview-groups, +.word-companion-preview-group, +.word-companion-preview-item { + display: grid; + gap: 6px; +} + +.word-companion-preview-group { + border-top: 1px solid var(--companion-line); + padding-top: 8px; +} + +.word-companion-preview-group:first-child { + border-top: 0; + padding-top: 0; +} + +.word-companion-preview-item { + border: 1px solid rgba(47, 111, 99, 0.18); + border-radius: 6px; + padding: 7px; + background: #ffffff; +} + +.word-companion-preview-item span, +.word-companion-preview-matches li { + color: var(--companion-ink); + font-size: 0.86rem; + font-weight: 500; + text-transform: none; +} + +.word-companion-preview-matches { + display: grid; + gap: 3px; +} + +.word-companion-preview-matches ul { + display: grid; + gap: 3px; + margin: 0; + padding-left: 18px; +} + .word-companion-dialog { width: min(360px, calc(100vw - 24px)); border: 1px solid var(--companion-line); diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index c8a934c..da801d5 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -22,6 +22,9 @@ const documentMessage = document.querySelector("[data-document-message]"); const syncNote = document.querySelector("[data-sync-note]"); const documentOutline = document.querySelector("[data-document-outline]"); + const analyseManuscriptStructureButton = document.querySelector("[data-analyse-manuscript-structure]"); + const structurePreviewStatus = document.querySelector("[data-structure-preview-status]"); + const structurePreviewResults = document.querySelector("[data-structure-preview-results]"); const refreshPlotDirectorSceneButton = document.querySelector("[data-refresh-plotdirector-scene]"); const plotDirectorSceneStatus = document.querySelector("[data-plotdirector-scene-status]"); const anchorStatus = document.querySelector("[data-anchor-status]"); @@ -133,6 +136,7 @@ let linkDialogChapters = []; let selectedLinkChapterId = null; let pendingCreateChapterConfirmation = null; + let structurePreviewModel = null; let currentSceneLinks = { characters: [], assets: [], @@ -249,6 +253,16 @@ setText(syncStatus, message); }; + const setStructurePreviewStatus = (message) => { + setText(structurePreviewStatus, message); + }; + + const setAnalyseButtonEnabled = () => { + if (analyseManuscriptStructureButton) { + analyseManuscriptStructureButton.disabled = !selectedProject() || !selectedBook(); + } + }; + const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => { resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null; resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null; @@ -780,6 +794,7 @@ if (syncNote) { syncNote.textContent = book ? "" : "Select a PlotDirector book before refreshing."; } + setAnalyseButtonEnabled(); }; const normalizedStyleName = (paragraph) => { @@ -937,6 +952,308 @@ } }; + const previewGroups = [ + { key: "alreadyLinked", title: "Already Linked" }, + { key: "couldLink", title: "Could Link" }, + { key: "wouldCreateChapters", title: "Would Create Chapters" }, + { key: "wouldCreateScenes", title: "Would Create Scenes" }, + { key: "manualReview", title: "Manual Review Required" } + ]; + + const previewActionLabels = { + alreadyLinked: "Already Linked", + couldLink: "Could Link", + wouldCreateChapter: "Would Create Chapter", + wouldCreateScene: "Would Create Scene", + manualReview: "Manual Review Required" + }; + + const previewActionSymbols = { + alreadyLinked: "✓", + couldLink: "~", + wouldCreateChapter: "+", + wouldCreateScene: "+", + manualReview: "?" + }; + + const titleKey = (title, type) => normalizeTitleForResolve(title, type).toLocaleLowerCase(); + + const createPreviewItem = ({ category, action, type, wordTitle, match = "", anchorStatus = "None", matches = [], pdChapter = null }) => ({ + category, + action, + type, + wordTitle, + match, + anchorStatus, + matches, + pdChapter + }); + + const addPreviewItem = (model, item) => { + if (Array.isArray(model[item.category])) { + model[item.category].push(item); + } + }; + + const sceneRowsForChapter = (chapter) => Array.isArray(chapter?.scenes) ? chapter.scenes : []; + + const findSceneById = (chapters, sceneId) => { + for (const chapter of chapters) { + const scene = sceneRowsForChapter(chapter).find((item) => item.sceneId === sceneId); + if (scene) { + return { chapter, scene }; + } + } + + return null; + }; + + const compareChapterForPreview = (wordChapter, plotDirectorChapters, model) => { + const anchorStatus = wordChapter.anchorId ? `PD-CHAPTER-${wordChapter.anchorId}` : "None"; + if (wordChapter.anchorId) { + const anchoredChapter = plotDirectorChapters.find((chapter) => chapter.chapterId === wordChapter.anchorId); + if (anchoredChapter) { + const item = createPreviewItem({ + category: "alreadyLinked", + action: "alreadyLinked", + type: "Chapter", + wordTitle: wordChapter.title, + match: `Chapter ${anchoredChapter.chapterId}: ${anchoredChapter.title}`, + anchorStatus, + pdChapter: anchoredChapter + }); + addPreviewItem(model, item); + return item; + } + + const item = createPreviewItem({ + category: "manualReview", + action: "manualReview", + type: "Chapter", + wordTitle: wordChapter.title, + anchorStatus: `${anchorStatus} not found`, + matches: [], + pdChapter: null + }); + addPreviewItem(model, item); + return item; + } + + const matches = plotDirectorChapters.filter((chapter) => titleKey(chapter.title, "chapter") === titleKey(wordChapter.title, "chapter")); + if (matches.length === 1) { + const item = createPreviewItem({ + category: "couldLink", + action: "couldLink", + type: "Chapter", + wordTitle: wordChapter.title, + match: `Chapter ${matches[0].chapterId}: ${matches[0].title}`, + anchorStatus, + pdChapter: matches[0] + }); + addPreviewItem(model, item); + return item; + } + + if (matches.length > 1) { + const item = createPreviewItem({ + category: "manualReview", + action: "manualReview", + type: "Chapter", + wordTitle: wordChapter.title, + anchorStatus, + matches: matches.map((chapter) => `Chapter ${chapter.chapterId}: ${chapter.title}`), + pdChapter: null + }); + addPreviewItem(model, item); + return item; + } + + const item = createPreviewItem({ + category: "wouldCreateChapters", + action: "wouldCreateChapter", + type: "Chapter", + wordTitle: wordChapter.title, + anchorStatus, + pdChapter: null + }); + addPreviewItem(model, item); + return item; + }; + + const compareSceneForPreview = (wordScene, chapterPreview, plotDirectorChapters, model) => { + const anchorStatus = wordScene.anchorId ? `PD-SCENE-${wordScene.anchorId}` : "None"; + if (wordScene.anchorId) { + const anchored = findSceneById(plotDirectorChapters, wordScene.anchorId); + if (anchored) { + addPreviewItem(model, createPreviewItem({ + category: "alreadyLinked", + action: "alreadyLinked", + type: "Scene", + wordTitle: wordScene.title, + match: `Scene ${anchored.scene.sceneId}: ${anchored.scene.title}`, + anchorStatus + })); + return; + } + + addPreviewItem(model, createPreviewItem({ + category: "manualReview", + action: "manualReview", + type: "Scene", + wordTitle: wordScene.title, + anchorStatus: `${anchorStatus} not found` + })); + return; + } + + if (!chapterPreview?.pdChapter && chapterPreview?.category === "manualReview") { + addPreviewItem(model, createPreviewItem({ + category: "manualReview", + action: "manualReview", + type: "Scene", + wordTitle: wordScene.title, + anchorStatus, + matches: ["Parent chapter requires manual review."] + })); + return; + } + + if (!chapterPreview?.pdChapter) { + addPreviewItem(model, createPreviewItem({ + category: "wouldCreateScenes", + action: "wouldCreateScene", + type: "Scene", + wordTitle: wordScene.title, + anchorStatus + })); + return; + } + + const matches = sceneRowsForChapter(chapterPreview.pdChapter) + .filter((scene) => titleKey(scene.title, "scene") === titleKey(wordScene.title, "scene")); + if (matches.length === 1) { + addPreviewItem(model, createPreviewItem({ + category: "couldLink", + action: "couldLink", + type: "Scene", + wordTitle: wordScene.title, + match: `Scene ${matches[0].sceneId}: ${matches[0].title}`, + anchorStatus + })); + return; + } + + if (matches.length > 1) { + addPreviewItem(model, createPreviewItem({ + category: "manualReview", + action: "manualReview", + type: "Scene", + wordTitle: wordScene.title, + anchorStatus, + matches: matches.map((scene) => `Scene ${scene.sceneId}: ${scene.title}`) + })); + return; + } + + addPreviewItem(model, createPreviewItem({ + category: "wouldCreateScenes", + action: "wouldCreateScene", + type: "Scene", + wordTitle: wordScene.title, + anchorStatus + })); + }; + + const buildStructurePreviewModel = (wordStructure, plotDirectorStructure) => { + const model = { + alreadyLinked: [], + couldLink: [], + wouldCreateChapters: [], + wouldCreateScenes: [], + manualReview: [] + }; + const plotDirectorChapters = Array.isArray(plotDirectorStructure?.chapters) ? plotDirectorStructure.chapters : []; + + for (const wordChapter of Array.isArray(wordStructure?.chapters) ? wordStructure.chapters : []) { + const chapterPreview = compareChapterForPreview(wordChapter, plotDirectorChapters, model); + for (const wordScene of sceneRowsForChapter(wordChapter)) { + compareSceneForPreview(wordScene, chapterPreview, plotDirectorChapters, model); + } + } + + return model; + }; + + const renderPreviewItem = (item) => { + const row = document.createElement("article"); + row.className = "word-companion-preview-item"; + + const title = document.createElement("strong"); + title.textContent = `${previewActionSymbols[item.action] || ""} ${item.type}: ${item.wordTitle}`; + row.append(title); + + const anchor = document.createElement("span"); + anchor.textContent = `Anchor: ${item.anchorStatus || "None"}`; + row.append(anchor); + + if (item.match) { + const match = document.createElement("span"); + match.textContent = `Match: ${item.match}`; + row.append(match); + } + + if (Array.isArray(item.matches) && item.matches.length > 0) { + const matches = document.createElement("div"); + matches.className = "word-companion-preview-matches"; + const label = document.createElement("span"); + label.textContent = "Matches:"; + matches.append(label); + const list = document.createElement("ul"); + for (const matchText of item.matches) { + const li = document.createElement("li"); + li.textContent = matchText; + list.append(li); + } + matches.append(list); + row.append(matches); + } + + const action = document.createElement("span"); + action.textContent = `Action: ${previewActionLabels[item.action] || item.action}`; + row.append(action); + return row; + }; + + const renderStructurePreview = (model) => { + if (!structurePreviewResults) { + return; + } + + structurePreviewResults.innerHTML = ""; + for (const group of previewGroups) { + const section = document.createElement("section"); + section.className = "word-companion-preview-group"; + + const heading = document.createElement("strong"); + heading.textContent = group.title; + section.append(heading); + + const items = Array.isArray(model?.[group.key]) ? model[group.key] : []; + if (items.length === 0) { + const empty = document.createElement("p"); + empty.className = "word-companion-empty-list"; + empty.textContent = "None"; + section.append(empty); + } else { + for (const item of items) { + section.append(renderPreviewItem(item)); + } + } + + structurePreviewResults.append(section); + } + }; + const readDocumentStructure = async (context) => { const bodyParagraphs = context.document.body.paragraphs; const selectionParagraphs = context.document.getSelection().paragraphs; @@ -1012,6 +1329,64 @@ } }; + const analyseManuscriptStructure = async () => { + const book = selectedBook(); + if (!selectedProject() || !book) { + setStructurePreviewStatus("Select a project and book to analyse manuscript structure."); + return false; + } + + if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { + setStructurePreviewStatus("Word document access is unavailable."); + setDiagnostics({ lastScan: "Failure", lastError: "Word document access is unavailable." }); + return false; + } + + if (analyseManuscriptStructureButton) { + analyseManuscriptStructureButton.disabled = true; + analyseManuscriptStructureButton.textContent = "Analysing..."; + } + setStructurePreviewStatus("Analysing manuscript structure..."); + + try { + const wordStructure = await window.Word.run(async (context) => { + return await readDocumentStructure(context); + }); + const plotDirectorStructure = await fetchJson(`/api/word-companion/books/${book.bookId}/structure`); + + setCurrentStructure( + wordStructure.currentChapter?.title, + wordStructure.currentScene?.title, + wordStructure.currentScene?.wordCount, + wordStructure.currentChapter?.anchorId, + wordStructure.currentScene?.anchorId, + wordStructure.currentChapter?.index + ); + renderOutline(wordStructure); + structurePreviewModel = buildStructurePreviewModel(wordStructure, plotDirectorStructure); + renderStructurePreview(structurePreviewModel); + setStructurePreviewStatus("Preview generated. No changes have been applied."); + setDiagnostics({ + lastScan: "Success", + lastError: "-", + paragraphs: String(wordStructure.paragraphCount), + heading1: String(wordStructure.heading1Count), + heading2: String(wordStructure.heading2Count) + }); + return true; + } catch (error) { + console.error("Unable to analyse manuscript structure.", error); + setStructurePreviewStatus("Unable to analyse manuscript structure."); + setDiagnostics({ lastScan: "Failure", lastError: errorText(error) }); + return false; + } finally { + if (analyseManuscriptStructureButton) { + analyseManuscriptStructureButton.textContent = "Analyse Manuscript Structure"; + setAnalyseButtonEnabled(); + } + } + }; + const runDiagnostics = async () => { if (runDiagnosticsButton) { runDiagnosticsButton.disabled = true; @@ -2153,6 +2528,7 @@ }); refreshDocumentButton?.addEventListener("click", refreshDocumentStructure); + analyseManuscriptStructureButton?.addEventListener("click", analyseManuscriptStructure); refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene); refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene); syncSceneProgressButton?.addEventListener("click", syncSceneProgress);