diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index f196915..b0ae7a4 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -189,7 +189,10 @@
Structure Sync Preview - +
+ + +

Select a project and book to analyse manuscript structure.

@@ -373,6 +376,21 @@
+ + +
+
+ Apply Structure Sync? +
+

This will:

+
+

Manual review items will be skipped.

+
+ + +
+
+
diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index 9bc0208..e6c9f7e 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -129,6 +129,13 @@ body { gap: 8px; } +.word-companion-header-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + .word-companion-section-header button { min-height: 32px; border: 1px solid var(--companion-accent); @@ -391,6 +398,18 @@ body { padding-left: 18px; } +.word-companion-apply-summary ul { + display: grid; + gap: 4px; + margin: 0; + padding-left: 18px; +} + +.word-companion-apply-summary li { + color: var(--companion-ink); + font-size: 0.9rem; +} + .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 da801d5..46c95f6 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -23,6 +23,7 @@ const syncNote = document.querySelector("[data-sync-note]"); const documentOutline = document.querySelector("[data-document-outline]"); const analyseManuscriptStructureButton = document.querySelector("[data-analyse-manuscript-structure]"); + const applyStructureSyncButton = document.querySelector("[data-apply-structure-sync]"); const structurePreviewStatus = document.querySelector("[data-structure-preview-status]"); const structurePreviewResults = document.querySelector("[data-structure-preview-results]"); const refreshPlotDirectorSceneButton = document.querySelector("[data-refresh-plotdirector-scene]"); @@ -87,6 +88,10 @@ const createChapterDialogBook = document.querySelector("[data-create-chapter-dialog-book]"); const createChapterDialogConfirm = document.querySelector("[data-create-chapter-dialog-confirm]"); const createChapterDialogCancel = document.querySelector("[data-create-chapter-dialog-cancel]"); + const applyStructureSyncDialog = document.querySelector("[data-apply-structure-sync-dialog]"); + const applyStructureSyncSummary = document.querySelector("[data-apply-structure-sync-summary]"); + const applyStructureSyncConfirm = document.querySelector("[data-apply-structure-sync-confirm]"); + const applyStructureSyncCancel = document.querySelector("[data-apply-structure-sync-cancel]"); const resolveProjectId = document.querySelector("[data-resolve-project-id]"); const resolveProjectTitle = document.querySelector("[data-resolve-project-title]"); const resolveBookId = document.querySelector("[data-resolve-book-id]"); @@ -137,6 +142,8 @@ let selectedLinkChapterId = null; let pendingCreateChapterConfirmation = null; let structurePreviewModel = null; + let isApplyingStructureSync = false; + let pendingApplyStructureSyncConfirmation = null; let currentSceneLinks = { characters: [], assets: [], @@ -257,10 +264,35 @@ setText(structurePreviewStatus, message); }; + const resetStructurePreview = (message = "Select a project and book to analyse manuscript structure.") => { + structurePreviewModel = null; + if (structurePreviewResults) { + structurePreviewResults.innerHTML = ""; + const empty = document.createElement("p"); + empty.textContent = "No preview generated."; + structurePreviewResults.append(empty); + } + setStructurePreviewStatus(message); + updateApplyStructureSyncButton(); + }; + + const previewItems = (model = structurePreviewModel) => previewGroups.flatMap((group) => + Array.isArray(model?.[group.key]) ? model[group.key] : []); + + const actionablePreviewItems = (model = structurePreviewModel) => previewItems(model) + .filter((item) => item.category === "couldLink" || item.category === "wouldCreateChapters" || item.category === "wouldCreateScenes"); + + const updateApplyStructureSyncButton = () => { + if (applyStructureSyncButton) { + applyStructureSyncButton.disabled = isApplyingStructureSync || actionablePreviewItems().length === 0; + } + }; + const setAnalyseButtonEnabled = () => { if (analyseManuscriptStructureButton) { - analyseManuscriptStructureButton.disabled = !selectedProject() || !selectedBook(); + analyseManuscriptStructureButton.disabled = isApplyingStructureSync || !selectedProject() || !selectedBook(); } + updateApplyStructureSyncButton(); }; const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => { @@ -978,7 +1010,21 @@ const titleKey = (title, type) => normalizeTitleForResolve(title, type).toLocaleLowerCase(); - const createPreviewItem = ({ category, action, type, wordTitle, match = "", anchorStatus = "None", matches = [], pdChapter = null }) => ({ + const createPreviewItem = ({ + category, + action, + type, + wordTitle, + match = "", + anchorStatus = "None", + matches = [], + pdChapter = null, + pdScene = null, + wordChapter = null, + wordScene = null, + parentItem = null + }) => ({ + id: `${type}-${wordChapter?.index || 0}-${wordScene?.index || 0}-${wordChapter?.paragraphIndex ?? wordScene?.paragraphIndex ?? 0}`, category, action, type, @@ -986,7 +1032,13 @@ match, anchorStatus, matches, - pdChapter + pdChapter, + pdScene, + wordChapter, + wordScene, + parentItem, + result: "Pending", + error: "" }); const addPreviewItem = (model, item) => { @@ -1020,7 +1072,8 @@ wordTitle: wordChapter.title, match: `Chapter ${anchoredChapter.chapterId}: ${anchoredChapter.title}`, anchorStatus, - pdChapter: anchoredChapter + pdChapter: anchoredChapter, + wordChapter }); addPreviewItem(model, item); return item; @@ -1033,7 +1086,8 @@ wordTitle: wordChapter.title, anchorStatus: `${anchorStatus} not found`, matches: [], - pdChapter: null + pdChapter: null, + wordChapter }); addPreviewItem(model, item); return item; @@ -1048,7 +1102,8 @@ wordTitle: wordChapter.title, match: `Chapter ${matches[0].chapterId}: ${matches[0].title}`, anchorStatus, - pdChapter: matches[0] + pdChapter: matches[0], + wordChapter }); addPreviewItem(model, item); return item; @@ -1062,7 +1117,8 @@ wordTitle: wordChapter.title, anchorStatus, matches: matches.map((chapter) => `Chapter ${chapter.chapterId}: ${chapter.title}`), - pdChapter: null + pdChapter: null, + wordChapter }); addPreviewItem(model, item); return item; @@ -1074,7 +1130,8 @@ type: "Chapter", wordTitle: wordChapter.title, anchorStatus, - pdChapter: null + pdChapter: null, + wordChapter }); addPreviewItem(model, item); return item; @@ -1091,7 +1148,12 @@ type: "Scene", wordTitle: wordScene.title, match: `Scene ${anchored.scene.sceneId}: ${anchored.scene.title}`, - anchorStatus + anchorStatus, + pdChapter: anchored.chapter, + pdScene: anchored.scene, + wordChapter: chapterPreview?.wordChapter, + wordScene, + parentItem: chapterPreview })); return; } @@ -1101,7 +1163,10 @@ action: "manualReview", type: "Scene", wordTitle: wordScene.title, - anchorStatus: `${anchorStatus} not found` + anchorStatus: `${anchorStatus} not found`, + wordChapter: chapterPreview?.wordChapter, + wordScene, + parentItem: chapterPreview })); return; } @@ -1113,7 +1178,10 @@ type: "Scene", wordTitle: wordScene.title, anchorStatus, - matches: ["Parent chapter requires manual review."] + matches: ["Parent chapter requires manual review."], + wordChapter: chapterPreview?.wordChapter, + wordScene, + parentItem: chapterPreview })); return; } @@ -1124,7 +1192,10 @@ action: "wouldCreateScene", type: "Scene", wordTitle: wordScene.title, - anchorStatus + anchorStatus, + wordChapter: chapterPreview?.wordChapter, + wordScene, + parentItem: chapterPreview })); return; } @@ -1138,7 +1209,12 @@ type: "Scene", wordTitle: wordScene.title, match: `Scene ${matches[0].sceneId}: ${matches[0].title}`, - anchorStatus + anchorStatus, + pdChapter: chapterPreview.pdChapter, + pdScene: matches[0], + wordChapter: chapterPreview.wordChapter, + wordScene, + parentItem: chapterPreview })); return; } @@ -1150,7 +1226,10 @@ type: "Scene", wordTitle: wordScene.title, anchorStatus, - matches: matches.map((scene) => `Scene ${scene.sceneId}: ${scene.title}`) + matches: matches.map((scene) => `Scene ${scene.sceneId}: ${scene.title}`), + wordChapter: chapterPreview.wordChapter, + wordScene, + parentItem: chapterPreview })); return; } @@ -1160,7 +1239,11 @@ action: "wouldCreateScene", type: "Scene", wordTitle: wordScene.title, - anchorStatus + anchorStatus, + pdChapter: chapterPreview.pdChapter, + wordChapter: chapterPreview.wordChapter, + wordScene, + parentItem: chapterPreview })); }; @@ -1221,6 +1304,12 @@ const action = document.createElement("span"); action.textContent = `Action: ${previewActionLabels[item.action] || item.action}`; row.append(action); + + if (item.result && item.result !== "Pending") { + const result = document.createElement("span"); + result.textContent = item.error ? `Result: ${item.result} - ${item.error}` : `Result: ${item.result}`; + row.append(result); + } return row; }; @@ -1252,6 +1341,7 @@ structurePreviewResults.append(section); } + updateApplyStructureSyncButton(); }; const readDocumentStructure = async (context) => { @@ -1387,6 +1477,259 @@ } }; + const applyCounts = (model = structurePreviewModel) => { + const actionable = actionablePreviewItems(model); + const linkChapters = actionable.filter((item) => item.category === "couldLink" && item.type === "Chapter").length; + const linkScenes = actionable.filter((item) => item.category === "couldLink" && item.type === "Scene").length; + const createChapters = actionable.filter((item) => item.category === "wouldCreateChapters").length; + const createScenes = actionable.filter((item) => item.category === "wouldCreateScenes").length; + const manualReview = Array.isArray(model?.manualReview) ? model.manualReview.length : 0; + + return { linkChapters, linkScenes, createChapters, createScenes, manualReview }; + }; + + const renderApplySummary = (counts) => { + if (!applyStructureSyncSummary) { + return; + } + + applyStructureSyncSummary.innerHTML = ""; + const lines = [ + `Link ${counts.linkChapters + counts.linkScenes} chapters/scenes`, + `Create ${counts.createChapters} chapters`, + `Create ${counts.createScenes} scenes` + ]; + const list = document.createElement("ul"); + for (const line of lines) { + const item = document.createElement("li"); + item.textContent = line; + list.append(item); + } + applyStructureSyncSummary.append(list); + }; + + const closeApplyStructureSyncDialog = (confirmed) => { + if (pendingApplyStructureSyncConfirmation) { + pendingApplyStructureSyncConfirmation(confirmed); + pendingApplyStructureSyncConfirmation = null; + } + + if (applyStructureSyncDialog?.close) { + applyStructureSyncDialog.close(); + } else if (applyStructureSyncDialog) { + applyStructureSyncDialog.hidden = true; + } + }; + + const confirmApplyStructureSync = () => { + const counts = applyCounts(); + renderApplySummary(counts); + + if (!applyStructureSyncDialog) { + return Promise.resolve(window.confirm( + `Apply Structure Sync?\n\nThis will:\n\n- Link ${counts.linkChapters + counts.linkScenes} chapters/scenes\n- Create ${counts.createChapters} chapters\n- Create ${counts.createScenes} scenes\n\nManual review items will be skipped.` + )); + } + + return new Promise((resolve) => { + pendingApplyStructureSyncConfirmation = resolve; + if (applyStructureSyncDialog.showModal) { + applyStructureSyncDialog.showModal(); + } else { + applyStructureSyncDialog.hidden = false; + } + }); + }; + + const markPreviewItem = (item, result, error = "") => { + item.result = result; + item.error = error; + }; + + const previewChapterSortOrder = (item) => Number.isInteger(item?.wordChapter?.index) && item.wordChapter.index > 0 + ? item.wordChapter.index * 10 + : null; + + const previewSceneSortOrder = (item) => Number.isInteger(item?.wordScene?.index) && item.wordScene.index > 0 + ? item.wordScene.index * 10 + : null; + + const createChapterFromPreviewItem = async (bookId, item) => { + const response = await postJson(`/api/word-companion/books/${bookId}/chapters`, { + title: normalizeWhitespace(item.wordTitle), + sortOrder: previewChapterSortOrder(item) + }); + + if (!response?.chapterId) { + throw new Error("Chapter creation did not return a chapter ID."); + } + + item.pdChapter = { + chapterId: response.chapterId, + title: response.title || item.wordTitle, + sortOrder: response.sortOrder || previewChapterSortOrder(item) || 0, + scenes: [] + }; + item.match = `Chapter ${item.pdChapter.chapterId}: ${item.pdChapter.title}`; + }; + + const createSceneFromPreviewItem = async (bookId, item) => { + const chapter = item.pdChapter || item.parentItem?.pdChapter; + if (!chapter?.chapterId) { + throw new Error("Scene parent chapter is not available."); + } + + const response = await postJson(`/api/word-companion/books/${bookId}/chapters/${chapter.chapterId}/scenes`, { + sceneTitle: normalizeWhitespace(item.wordTitle), + sortOrder: previewSceneSortOrder(item) + }); + + if (!response?.sceneId) { + throw new Error("Scene creation did not return a scene ID."); + } + + item.pdChapter = chapter; + item.pdScene = { + sceneId: response.sceneId, + title: item.wordTitle, + sortOrder: previewSceneSortOrder(item) || 0 + }; + item.match = `Scene ${item.pdScene.sceneId}: ${item.pdScene.title}`; + }; + + const attachAnchorForPreviewItem = async (item) => { + if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { + throw new Error("Word document access is unavailable."); + } + + await window.Word.run(async (context) => { + const structure = await readDocumentStructure(context); + const source = item.type === "Chapter" ? item.wordChapter : item.wordScene; + const paragraph = Number.isInteger(source?.paragraphIndex) + ? structure.bodyParagraphs[source.paragraphIndex] + : null; + if (!paragraph) { + throw new Error("Unable to locate the Word heading."); + } + + if (item.type === "Chapter") { + const chapterId = item.pdChapter?.chapterId; + if (!chapterId) { + throw new Error("Chapter ID is not available."); + } + ensureAnchorControl(paragraph, "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER"); + } else { + const sceneId = item.pdScene?.sceneId; + if (!sceneId) { + throw new Error("Scene ID is not available."); + } + ensureAnchorControl(paragraph, "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE"); + } + + await context.sync(); + }); + }; + + const resultSummaryText = (summary) => + `Structure Sync Complete. Created: ${summary.createdChapters} Chapters, ${summary.createdScenes} Scenes. Linked: ${summary.linkedChapters} Chapters, ${summary.linkedScenes} Scenes. Skipped: ${summary.skipped} Manual Review items. Failed: ${summary.failed} Items.`; + + const applyStructureSync = async () => { + const book = selectedBook(); + if (!book || actionablePreviewItems().length === 0 || isApplyingStructureSync) { + updateApplyStructureSyncButton(); + return; + } + + const confirmed = await confirmApplyStructureSync(); + if (!confirmed) { + return; + } + + isApplyingStructureSync = true; + setAnalyseButtonEnabled(); + setStructurePreviewStatus("Applying structure sync..."); + + const summary = { + createdChapters: 0, + createdScenes: 0, + linkedChapters: 0, + linkedScenes: 0, + skipped: Array.isArray(structurePreviewModel?.manualReview) ? structurePreviewModel.manualReview.length : 0, + failed: 0 + }; + + const linkChapters = actionablePreviewItems().filter((item) => item.category === "couldLink" && item.type === "Chapter"); + const createChapters = actionablePreviewItems().filter((item) => item.category === "wouldCreateChapters"); + const linkScenes = actionablePreviewItems().filter((item) => item.category === "couldLink" && item.type === "Scene"); + const createScenes = actionablePreviewItems().filter((item) => item.category === "wouldCreateScenes"); + const anchorQueue = []; + let finalStatus = ""; + + const queueAnchor = (item, counterName) => { + anchorQueue.push({ item, counterName }); + }; + + const attachQueuedItem = async (item, counterName) => { + try { + await attachAnchorForPreviewItem(item); + markPreviewItem(item, "Success"); + summary[counterName] += 1; + } catch (error) { + markPreviewItem(item, "Failed", errorText(error)); + summary.failed += 1; + } + }; + + try { + for (const item of linkChapters) { + queueAnchor(item, "linkedChapters"); + } + + for (const item of createChapters) { + try { + await createChapterFromPreviewItem(book.bookId, item); + queueAnchor(item, "createdChapters"); + } catch (error) { + markPreviewItem(item, "Failed", errorText(error)); + summary.failed += 1; + } + } + + for (const item of linkScenes) { + queueAnchor(item, "linkedScenes"); + } + + for (const item of createScenes) { + try { + await createSceneFromPreviewItem(book.bookId, item); + queueAnchor(item, "createdScenes"); + } catch (error) { + markPreviewItem(item, "Failed", errorText(error)); + summary.failed += 1; + } + } + + for (const queued of anchorQueue) { + await attachQueuedItem(queued.item, queued.counterName); + } + + for (const item of structurePreviewModel.manualReview || []) { + markPreviewItem(item, "Skipped"); + } + + renderStructurePreview(structurePreviewModel); + finalStatus = resultSummaryText(summary); + setStructurePreviewStatus(finalStatus); + } finally { + isApplyingStructureSync = false; + setAnalyseButtonEnabled(); + if (finalStatus) { + await analyseManuscriptStructure(); + setStructurePreviewStatus(`${finalStatus} Preview refreshed.`); + } + } + }; + const runDiagnostics = async () => { if (runDiagnosticsButton) { runDiagnosticsButton.disabled = true; @@ -2429,12 +2772,14 @@ writeStoredId(storageKeys.bookId, null); updateSummary(); resetPlotDirectorScene("Not detected"); + resetStructurePreview(); return; } resetSelect(bookSelect, "Loading books..."); setBookMessage(""); resetPlotDirectorScene("Not detected"); + resetStructurePreview("Select a book to analyse manuscript structure."); try { const data = await fetchJson(`/api/word-companion/projects/${projectId}/books`); @@ -2458,6 +2803,9 @@ } updateSummary(); resetPlotDirectorScene(selectedBook() ? "Not detected" : "Not detected"); + resetStructurePreview(selectedBook() + ? "Ready to analyse manuscript structure." + : "Select a book to analyse manuscript structure."); } catch { books = []; resetSelect(bookSelect, "Unable to load books."); @@ -2465,6 +2813,7 @@ writeStoredId(storageKeys.bookId, null); updateSummary(); resetPlotDirectorScene("Not detected"); + resetStructurePreview("Unable to load books."); } }; @@ -2517,6 +2866,7 @@ writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null); writeStoredId(storageKeys.bookId, null); resetPlotDirectorScene("Not detected"); + resetStructurePreview(); await loadBooks(projectId, null); }); @@ -2525,10 +2875,20 @@ writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null); updateSummary(); resetPlotDirectorScene("Not detected"); + resetStructurePreview(selectedBook() + ? "Ready to analyse manuscript structure." + : "Select a book to analyse manuscript structure."); }); refreshDocumentButton?.addEventListener("click", refreshDocumentStructure); analyseManuscriptStructureButton?.addEventListener("click", analyseManuscriptStructure); + applyStructureSyncButton?.addEventListener("click", applyStructureSync); + applyStructureSyncConfirm?.addEventListener("click", () => closeApplyStructureSyncDialog(true)); + applyStructureSyncCancel?.addEventListener("click", () => closeApplyStructureSyncDialog(false)); + applyStructureSyncDialog?.addEventListener("cancel", (event) => { + event.preventDefault(); + closeApplyStructureSyncDialog(false); + }); refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene); refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene); syncSceneProgressButton?.addEventListener("click", syncSceneProgress);