(() => { const storageKeys = { projectId: "plotdirector.word.projectId", bookId: "plotdirector.word.bookId" }; const shell = document.querySelector(".word-companion-shell"); const officeStatus = document.querySelector("[data-office-status]"); const connectionStatus = document.querySelector("[data-connection-status]"); const summaryConnection = document.querySelector("[data-summary-connection]"); const summaryProject = document.querySelector("[data-summary-project]"); const summaryBook = document.querySelector("[data-summary-book]"); const projectSelect = document.querySelector("[data-project-select]"); const bookSelect = document.querySelector("[data-book-select]"); const projectMessage = document.querySelector("[data-project-message]"); const bookMessage = document.querySelector("[data-book-message]"); const refreshCurrentSceneButton = document.querySelector("[data-refresh-current-scene]"); const refreshDocumentButton = document.querySelector("[data-refresh-document]"); const currentChapter = document.querySelector("[data-current-chapter]"); const currentScene = document.querySelector("[data-current-scene]"); const currentWordCount = document.querySelector("[data-current-word-count]"); const documentMessage = document.querySelector("[data-document-message]"); const syncNote = document.querySelector("[data-sync-note]"); const documentOutline = document.querySelector("[data-document-outline]"); const refreshPlotDirectorSceneButton = document.querySelector("[data-refresh-plotdirector-scene]"); const plotDirectorSceneStatus = document.querySelector("[data-plotdirector-scene-status]"); const anchorStatus = document.querySelector("[data-anchor-status]"); const anchorBookId = document.querySelector("[data-anchor-book-id]"); const anchorChapterId = document.querySelector("[data-anchor-chapter-id]"); const anchorSceneId = document.querySelector("[data-anchor-scene-id]"); const attachPlotDirectorIdsButton = document.querySelector("[data-attach-plotdirector-ids]"); const plotDirectorSceneDetails = document.querySelector("[data-plotdirector-scene-details]"); const plotDirectorSceneTitle = document.querySelector("[data-plotdirector-scene-title]"); const plotDirectorRevisionStatus = document.querySelector("[data-plotdirector-revision-status]"); const plotDirectorEstimatedWords = document.querySelector("[data-plotdirector-estimated-words]"); const plotDirectorActualWords = document.querySelector("[data-plotdirector-actual-words]"); const plotDirectorBlockedStatus = document.querySelector("[data-plotdirector-blocked-status]"); const plotDirectorBlockedReasonRow = document.querySelector("[data-plotdirector-blocked-reason-row]"); const plotDirectorBlockedReason = document.querySelector("[data-plotdirector-blocked-reason]"); const syncSceneProgressButton = document.querySelector("[data-sync-scene-progress]"); const syncWordCount = document.querySelector("[data-sync-word-count]"); const syncPlotDirectorCount = document.querySelector("[data-sync-plotdirector-count]"); const syncLastSynced = document.querySelector("[data-sync-last-synced]"); const syncStatus = document.querySelector("[data-sync-status]"); const writingBriefBlock = document.querySelector("[data-writing-brief-block]"); const writingBrief = document.querySelector("[data-writing-brief]"); const plotDirectorLinkGroups = document.querySelector("[data-plotdirector-link-groups]"); const characterChips = document.querySelector("[data-character-chips]"); const assetChips = document.querySelector("[data-asset-chips]"); const locationChips = document.querySelector("[data-location-chips]"); const primaryLocationSelect = document.querySelector("[data-primary-location-select]"); const saveSceneLinksButton = document.querySelector("[data-save-scene-links]"); const sceneLinksStatus = document.querySelector("[data-scene-links-status]"); const chapterCreateLink = document.querySelector("[data-chapter-create-link]"); const chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]"); const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]"); const linkExistingChapterButton = document.querySelector("[data-link-existing-chapter]"); const chapterCreateLinkStatus = document.querySelector("[data-chapter-create-link-status]"); const sceneCreateLink = document.querySelector("[data-scene-create-link]"); const createLinkChapter = document.querySelector("[data-create-link-chapter]"); const createLinkScene = document.querySelector("[data-create-link-scene]"); const createPlotDirectorSceneButton = document.querySelector("[data-create-plotdirector-scene]"); const linkExistingSceneButton = document.querySelector("[data-link-existing-scene]"); const createLinkStatus = document.querySelector("[data-create-link-status]"); const linkExistingDialog = document.querySelector("[data-link-existing-dialog]"); const linkSceneSearch = document.querySelector("[data-link-scene-search]"); const linkSceneOptions = document.querySelector("[data-link-scene-options]"); const linkSelectedSceneButton = document.querySelector("[data-link-selected-scene]"); const linkDialogCancel = document.querySelector("[data-link-dialog-cancel]"); const linkDialogStatus = document.querySelector("[data-link-dialog-status]"); const createSceneDialog = document.querySelector("[data-create-scene-dialog]"); const createDialogScene = document.querySelector("[data-create-dialog-scene]"); const createDialogChapter = document.querySelector("[data-create-dialog-chapter]"); const createDialogConfirm = document.querySelector("[data-create-dialog-confirm]"); const createDialogCancel = document.querySelector("[data-create-dialog-cancel]"); const linkExistingChapterDialog = document.querySelector("[data-link-existing-chapter-dialog]"); const linkChapterSearch = document.querySelector("[data-link-chapter-search]"); const linkChapterOptions = document.querySelector("[data-link-chapter-options]"); const linkSelectedChapterButton = document.querySelector("[data-link-selected-chapter]"); const linkChapterDialogCancel = document.querySelector("[data-link-chapter-dialog-cancel]"); const linkChapterDialogStatus = document.querySelector("[data-link-chapter-dialog-status]"); const createChapterDialog = document.querySelector("[data-create-chapter-dialog]"); const createChapterDialogChapter = document.querySelector("[data-create-chapter-dialog-chapter]"); 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 resolveProjectId = document.querySelector("[data-resolve-project-id]"); const resolveProjectTitle = document.querySelector("[data-resolve-project-title]"); const resolveBookId = document.querySelector("[data-resolve-book-id]"); const resolveBookTitle = document.querySelector("[data-resolve-book-title]"); const resolveDetectedChapter = document.querySelector("[data-resolve-detected-chapter]"); const resolveDetectedScene = document.querySelector("[data-resolve-detected-scene]"); const resolveRequestChapter = document.querySelector("[data-resolve-request-chapter]"); const resolveRequestScene = document.querySelector("[data-resolve-request-scene]"); const resolveResult = document.querySelector("[data-resolve-result]"); const resolveChapterId = document.querySelector("[data-resolve-chapter-id]"); const resolveSceneId = document.querySelector("[data-resolve-scene-id]"); const runDiagnosticsButton = document.querySelector("[data-run-diagnostics]"); const diagnosticHost = document.querySelector("[data-diagnostic-host]"); const diagnosticPlatform = document.querySelector("[data-diagnostic-platform]"); const diagnosticReady = document.querySelector("[data-diagnostic-ready]"); const diagnosticWordApi = document.querySelector("[data-diagnostic-word-api]"); const diagnosticLastScan = document.querySelector("[data-diagnostic-last-scan]"); const diagnosticLastError = document.querySelector("[data-diagnostic-last-error]"); const diagnosticAnchorType = document.querySelector("[data-diagnostic-anchor-type]"); const diagnosticStoredSceneId = document.querySelector("[data-diagnostic-stored-scene-id]"); const diagnosticStoredChapterId = document.querySelector("[data-diagnostic-stored-chapter-id]"); const diagnosticResolutionMethod = document.querySelector("[data-diagnostic-resolution-method]"); const diagnosticParagraphs = document.querySelector("[data-diagnostic-paragraphs]"); const diagnosticHeading1 = document.querySelector("[data-diagnostic-heading1]"); const diagnosticHeading2 = document.querySelector("[data-diagnostic-heading2]"); const isAuthenticated = shell?.dataset.authenticated === "true"; let projects = []; let books = []; let officeReady = false; let wordHostAvailable = false; let officeHost = "Unknown"; let officePlatform = "Unknown"; let detectedChapterTitle = ""; let detectedSceneTitle = ""; let detectedSceneWordCount = null; let detectedChapterIndex = null; let detectedChapterAnchorId = null; let detectedSceneAnchorId = null; let resolvedSceneId = null; let resolvedChapterId = null; let currentResolutionMethod = ""; let currentResolutionAnchorType = "Title Match"; let storedAnchorInvalid = false; let linkDialogScenes = []; let selectedLinkSceneId = null; let pendingCreateSceneConfirmation = null; let linkDialogChapters = []; let selectedLinkChapterId = null; let pendingCreateChapterConfirmation = null; let currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null }; const setOfficeStatus = (message) => { if (officeStatus) { officeStatus.textContent = message; } }; const setConnectionStatus = (message) => { if (connectionStatus) { connectionStatus.textContent = message; } if (summaryConnection) { summaryConnection.textContent = message; } }; const setProjectMessage = (message) => { if (projectMessage) { projectMessage.textContent = message; } }; const setBookMessage = (message) => { if (bookMessage) { bookMessage.textContent = message; } }; const setDocumentMessage = (message) => { if (documentMessage) { documentMessage.textContent = message; } }; const setPlotDirectorSceneStatus = (message) => { if (plotDirectorSceneStatus) { plotDirectorSceneStatus.textContent = message; } }; const setAnchorDiagnostics = (values) => { const hasValue = (name) => Object.prototype.hasOwnProperty.call(values, name); if (hasValue("anchorType")) setText(diagnosticAnchorType, displayValue(values.anchorType)); if (hasValue("storedSceneId")) setText(diagnosticStoredSceneId, displayValue(values.storedSceneId)); if (hasValue("storedChapterId")) setText(diagnosticStoredChapterId, displayValue(values.storedChapterId)); if (hasValue("resolutionMethod")) setText(diagnosticResolutionMethod, displayValue(values.resolutionMethod)); }; const updateAnchorDisplay = () => { const book = selectedBook(); const hasMatchingSceneAnchor = Number.isInteger(resolvedSceneId) && resolvedSceneId > 0 && detectedSceneAnchorId === resolvedSceneId; const hasMatchingChapterAnchor = !Number.isInteger(resolvedChapterId) || resolvedChapterId <= 0 || detectedChapterAnchorId === resolvedChapterId; const attached = hasMatchingSceneAnchor && hasMatchingChapterAnchor; setText(anchorStatus, attached ? "Attached to PlotDirector" : "Not attached"); setText(anchorBookId, book?.bookId ? String(book.bookId) : "-"); setText(anchorChapterId, Number.isInteger(resolvedChapterId) && resolvedChapterId > 0 ? String(resolvedChapterId) : "-"); setText(anchorSceneId, Number.isInteger(resolvedSceneId) && resolvedSceneId > 0 ? String(resolvedSceneId) : "-"); if (attachPlotDirectorIdsButton) { attachPlotDirectorIdsButton.disabled = !resolvedSceneId || (attached && !storedAnchorInvalid); attachPlotDirectorIdsButton.textContent = detectedSceneAnchorId && !attached ? "Reattach PlotDirector IDs" : "Attach PlotDirector IDs"; } setAnchorDiagnostics({ anchorType: currentResolutionAnchorType || "Title Match", storedSceneId: detectedSceneAnchorId, storedChapterId: detectedChapterAnchorId, resolutionMethod: currentResolutionMethod }); }; const setCurrentStructure = (chapterTitle, sceneTitle, wordCount, chapterAnchorId = null, sceneAnchorId = null, chapterIndex = null) => { detectedChapterTitle = chapterTitle || ""; detectedSceneTitle = sceneTitle || ""; detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null; detectedChapterIndex = Number.isInteger(chapterIndex) && chapterIndex > 0 ? chapterIndex : null; detectedChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null; detectedSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null; if (currentChapter) { currentChapter.textContent = chapterTitle || "Not detected"; } if (currentScene) { currentScene.textContent = sceneTitle || "Not detected"; } if (currentWordCount) { currentWordCount.textContent = Number.isInteger(wordCount) ? String(wordCount) : "-"; } setText(syncWordCount, Number.isInteger(wordCount) ? String(wordCount) : "-"); updateAnchorDisplay(); }; const setHidden = (element, hidden) => { if (element) { element.hidden = hidden; } }; const setSyncStatus = (message) => { setText(syncStatus, message); }; const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => { resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null; resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null; currentResolutionMethod = resolutionMethod || ""; if (syncSceneProgressButton) { syncSceneProgressButton.disabled = !resolvedSceneId; } if (saveSceneLinksButton) { saveSceneLinksButton.disabled = !resolvedSceneId; } setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene."); setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); updateAnchorDisplay(); }; const setRefreshCurrentSceneBusy = (isBusy) => { if (!refreshCurrentSceneButton) { return; } refreshCurrentSceneButton.disabled = isBusy; refreshCurrentSceneButton.textContent = isBusy ? "Refreshing..." : "Refresh Current Scene"; }; const displayValue = (value) => { if (value === null || value === undefined || value === "") { return "-"; } return String(value); }; const setSceneLinksStatus = (message) => { setText(sceneLinksStatus, message); }; const setCreateLinkStatus = (message) => { setText(createLinkStatus, message); }; const setChapterCreateLinkStatus = (message) => { setText(chapterCreateLinkStatus, message); }; const canUseChapterCreateLinkActions = () => !!selectedBook() && !!detectedChapterTitle && !resolvedChapterId; const canUseCreateLinkActions = () => !!selectedBook() && !!detectedSceneTitle && Number.isInteger(resolvedChapterId) && resolvedChapterId > 0; const hideChapterCreateLink = () => { setHidden(chapterCreateLink, true); setChapterCreateLinkStatus(""); if (createPlotDirectorChapterButton) { createPlotDirectorChapterButton.disabled = true; createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector"; } if (linkExistingChapterButton) { linkExistingChapterButton.disabled = true; linkExistingChapterButton.textContent = "Link to Existing Chapter"; } }; const showChapterCreateLink = (message = "") => { setText(chapterCreateLinkChapter, displayValue(detectedChapterTitle)); setHidden(chapterCreateLink, false); setChapterCreateLinkStatus(message); const enabled = canUseChapterCreateLinkActions(); if (createPlotDirectorChapterButton) { createPlotDirectorChapterButton.disabled = !enabled; createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector"; } if (linkExistingChapterButton) { linkExistingChapterButton.disabled = !enabled; linkExistingChapterButton.textContent = "Link to Existing Chapter"; } }; const hideSceneCreateLink = () => { setHidden(sceneCreateLink, true); setCreateLinkStatus(""); if (createPlotDirectorSceneButton) { createPlotDirectorSceneButton.disabled = true; createPlotDirectorSceneButton.textContent = "Create Scene in PlotDirector"; } if (linkExistingSceneButton) { linkExistingSceneButton.disabled = true; linkExistingSceneButton.textContent = "Link to Existing Scene"; } }; const showSceneCreateLink = (message = "") => { setText(createLinkChapter, displayValue(detectedChapterTitle)); setText(createLinkScene, displayValue(detectedSceneTitle)); setHidden(sceneCreateLink, false); setCreateLinkStatus(message); const enabled = canUseCreateLinkActions(); if (createPlotDirectorSceneButton) { createPlotDirectorSceneButton.disabled = !enabled; createPlotDirectorSceneButton.textContent = "Create Scene in PlotDirector"; } if (linkExistingSceneButton) { linkExistingSceneButton.disabled = !enabled; linkExistingSceneButton.textContent = "Link to Existing Scene"; } }; const resetPlotDirectorScene = (message) => { setPlotDirectorSceneStatus(message); setHidden(plotDirectorSceneDetails, true); setHidden(writingBriefBlock, true); setHidden(plotDirectorLinkGroups, true); hideChapterCreateLink(); hideSceneCreateLink(); currentResolutionMethod = ""; currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match"; storedAnchorInvalid = false; currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null }; renderSceneLinkList(characterChips, [], "character"); renderSceneLinkList(assetChips, [], "asset"); renderPrimaryLocationOptions([], null, false); setResolvedScene(null); setText(syncPlotDirectorCount, "-"); setText(plotDirectorSceneTitle, "-"); setText(plotDirectorRevisionStatus, "-"); setText(plotDirectorEstimatedWords, "-"); setText(plotDirectorActualWords, "-"); setText(plotDirectorBlockedStatus, "-"); setText(plotDirectorBlockedReason, "-"); setHidden(plotDirectorBlockedReasonRow, true); updateAnchorDisplay(); }; const linkItemId = (item, type) => { if (type === "asset") { return item?.assetId || item?.AssetId || item?.storyAssetId || item?.StoryAssetId || 0; } if (type === "location") { return item?.locationId || item?.LocationId || 0; } return item?.characterId || item?.CharacterId || 0; }; const renderSceneLinkList = (container, items, type, enabled = false) => { if (!container) { return; } container.innerHTML = ""; const rows = Array.isArray(items) ? items : []; if (rows.length === 0) { const empty = document.createElement("p"); empty.className = "word-companion-empty-list"; empty.textContent = "None"; container.append(empty); return; } for (const item of rows) { const id = Number.parseInt(linkItemId(item, type), 10); if (!Number.isInteger(id) || id <= 0) { continue; } const label = document.createElement("label"); label.className = "word-companion-check-item"; const checkbox = document.createElement("input"); checkbox.type = "checkbox"; checkbox.value = String(id); checkbox.checked = !!item.linked; checkbox.disabled = !enabled; const text = document.createElement("span"); text.textContent = item.name || "Unnamed"; label.append(checkbox, text); container.append(label); } }; const renderPrimaryLocationOptions = (items, selectedLocationId, enabled = false) => { if (!primaryLocationSelect) { return; } primaryLocationSelect.innerHTML = ""; const none = document.createElement("option"); none.value = ""; none.textContent = "None"; primaryLocationSelect.append(none); const selectedId = Number.parseInt(selectedLocationId || "", 10); for (const item of Array.isArray(items) ? items : []) { const id = Number.parseInt(linkItemId(item, "location"), 10); if (!Number.isInteger(id) || id <= 0) { continue; } const option = document.createElement("option"); option.value = String(id); option.textContent = item.name || "Unnamed"; option.selected = id === selectedId; primaryLocationSelect.append(option); } primaryLocationSelect.disabled = !enabled; }; const renderCompanionData = (data) => { setText(plotDirectorSceneTitle, displayValue(data?.sceneTitle)); setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided")); setText(plotDirectorEstimatedWords, displayValue(data?.estimatedWords)); setText(plotDirectorActualWords, displayValue(data?.actualWords)); setText(syncPlotDirectorCount, displayValue(data?.actualWords)); setText(plotDirectorBlockedStatus, data?.blocked ? "Blocked" : "Not blocked"); if (data?.blockedReason) { setText(plotDirectorBlockedReason, data.blockedReason); setHidden(plotDirectorBlockedReasonRow, false); } else { setText(plotDirectorBlockedReason, "-"); setHidden(plotDirectorBlockedReasonRow, true); } if (writingBrief) { writingBrief.textContent = data?.writingBrief || "No writing brief has been added for this scene."; } currentSceneLinks = { characters: Array.isArray(data?.characters) ? data.characters : [], assets: Array.isArray(data?.assets) ? data.assets : [], locations: Array.isArray(data?.locations) ? data.locations : [], primaryLocationId: data?.primaryLocationId ?? null }; renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId); renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId); renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId); setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); setHidden(plotDirectorSceneDetails, false); setHidden(writingBriefBlock, false); setHidden(plotDirectorLinkGroups, false); }; const normalizeWhitespace = (value) => String(value || "").trim().replace(/\s+/g, " "); const normalizeTitleForResolve = (title, type) => { const original = normalizeWhitespace(title); if (!original) { return ""; } const label = type === "scene" ? "scene" : "chapter"; const ordinal = "(?:\\d+|[ivxlcdm]+|one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen|eighteen|nineteen|twenty)"; const prefix = new RegExp(`^${label}\\s+${ordinal}\\s*(?::|-|\\u2013|\\u2014)\\s*`, "i"); const normalized = normalizeWhitespace(original.replace(prefix, "")); return normalized || original; }; const setResolveDiagnostics = (values) => { const hasValue = (name) => Object.prototype.hasOwnProperty.call(values, name); if (hasValue("projectId")) setText(resolveProjectId, displayValue(values.projectId)); if (hasValue("projectTitle")) setText(resolveProjectTitle, displayValue(values.projectTitle)); if (hasValue("bookId")) setText(resolveBookId, displayValue(values.bookId)); if (hasValue("bookTitle")) setText(resolveBookTitle, displayValue(values.bookTitle)); if (hasValue("detectedChapter")) setText(resolveDetectedChapter, displayValue(values.detectedChapter)); if (hasValue("detectedScene")) setText(resolveDetectedScene, displayValue(values.detectedScene)); if (hasValue("requestChapter")) setText(resolveRequestChapter, displayValue(values.requestChapter)); if (hasValue("requestScene")) setText(resolveRequestScene, displayValue(values.requestScene)); if (hasValue("result")) setText(resolveResult, displayValue(values.result)); if (hasValue("chapterId")) setText(resolveChapterId, displayValue(values.chapterId)); if (hasValue("sceneId")) setText(resolveSceneId, displayValue(values.sceneId)); }; const findSceneRevisionStatus = async (bookId, sceneId) => { const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`); const chapters = Array.isArray(structure?.chapters) ? structure.chapters : []; for (const chapter of chapters) { const scenes = Array.isArray(chapter.scenes) ? chapter.scenes : []; const scene = scenes.find((item) => item.sceneId === sceneId); if (scene) { return scene.revisionStatus || ""; } } return ""; }; const findSceneInBookStructure = async (bookId, predicate) => { const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`); const chapters = Array.isArray(structure?.chapters) ? structure.chapters : []; for (const chapter of chapters) { const scenes = Array.isArray(chapter.scenes) ? chapter.scenes : []; for (const scene of scenes) { if (predicate(chapter, scene)) { return { chapter, scene }; } } } return null; }; const getBookStructureChapters = async (bookId) => { const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`); return Array.isArray(structure?.chapters) ? structure.chapters : []; }; const fetchBookChapters = async (bookId) => { const response = await fetchJson(`/api/word-companion/books/${bookId}/chapters`); return Array.isArray(response?.chapters) ? response.chapters : []; }; const findCurrentChapterInChapters = (chapters) => { if (Number.isInteger(resolvedChapterId) && resolvedChapterId > 0) { const anchoredChapter = chapters.find((chapter) => chapter.chapterId === resolvedChapterId); if (anchoredChapter) { return anchoredChapter; } } const requestChapterTitle = normalizeTitleForResolve(detectedChapterTitle, "chapter").toLocaleLowerCase(); if (!requestChapterTitle) { return null; } return chapters.find((chapter) => normalizeTitleForResolve(chapter.title, "chapter").toLocaleLowerCase() === requestChapterTitle) || null; }; const findCurrentChapter = async () => { const book = selectedBook(); if (!book) { return null; } return findCurrentChapterInChapters(await getBookStructureChapters(book.bookId)); }; const loadResolvedCompanionData = async (bookId, sceneId, chapterId, resolutionMethod, anchorType) => { const companionData = await fetchJson(`/api/word-companion/scenes/${sceneId}/companion`); try { companionData.revisionStatus = await findSceneRevisionStatus(bookId, sceneId); } catch { companionData.revisionStatus = ""; } currentResolutionAnchorType = anchorType || "Title Match"; storedAnchorInvalid = false; setPlotDirectorSceneStatus("Linked to PlotDirector"); setDocumentMessage(""); hideChapterCreateLink(); hideSceneCreateLink(); setResolvedScene(sceneId, chapterId, resolutionMethod); renderCompanionData(companionData); }; const setText = (element, value) => { if (element) { element.textContent = value; } }; const setDiagnostics = (values) => { const hasValue = (name) => Object.prototype.hasOwnProperty.call(values, name); if (hasValue("host")) { setText(diagnosticHost, values.host); } if (hasValue("platform")) { setText(diagnosticPlatform, values.platform); } if (hasValue("ready")) { setText(diagnosticReady, values.ready); } if (hasValue("wordApi")) { setText(diagnosticWordApi, values.wordApi); } if (hasValue("lastScan")) { setText(diagnosticLastScan, values.lastScan); } if (hasValue("lastError")) { setText(diagnosticLastError, values.lastError); } if (hasValue("paragraphs")) { setText(diagnosticParagraphs, values.paragraphs); } if (hasValue("heading1")) { setText(diagnosticHeading1, values.heading1); } if (hasValue("heading2")) { setText(diagnosticHeading2, values.heading2); } }; const errorText = (error) => { const parts = []; if (error?.name) { parts.push(error.name); } if (error?.code && error.code !== error.name) { parts.push(error.code); } if (error?.message) { parts.push(error.message); } if (error?.debugInfo?.errorLocation) { parts.push(`Location: ${error.debugInfo.errorLocation}`); } return parts.length > 0 ? parts.join(": ") : String(error); }; const updateOfficeReadiness = async () => { if (!window.Office || typeof window.Office.onReady !== "function") { officeReady = false; wordHostAvailable = false; officeHost = "Unavailable"; officePlatform = "Unavailable"; setDiagnostics({ host: officeHost, platform: officePlatform, ready: "No", wordApi: "No" }); return null; } const info = await window.Office.onReady(); officeReady = true; officeHost = info?.host || "Unknown"; officePlatform = info?.platform || "Unknown"; wordHostAvailable = !!window.Word && !!window.Office.HostType && info?.host === window.Office.HostType.Word; setDiagnostics({ host: officeHost, platform: officePlatform, ready: "Yes", wordApi: wordHostAvailable ? "Yes" : "No" }); return info; }; const readStoredId = (key) => { try { const value = window.localStorage.getItem(key); const id = Number.parseInt(value || "", 10); return Number.isInteger(id) && id > 0 ? id : null; } catch { return null; } }; const writeStoredId = (key, value) => { try { if (value) { window.localStorage.setItem(key, String(value)); } else { window.localStorage.removeItem(key); } } catch { // Some Office hosts can restrict storage. Selection still works for the current pane session. } }; const resetSelect = (select, message) => { if (!select) { return; } select.innerHTML = ""; const option = document.createElement("option"); option.value = ""; option.textContent = message; select.append(option); select.disabled = true; }; const addOptions = (select, placeholder, rows, idKey, titleKey) => { if (!select) { return; } select.innerHTML = ""; const placeholderOption = document.createElement("option"); placeholderOption.value = ""; placeholderOption.textContent = placeholder; select.append(placeholderOption); for (const row of rows) { const option = document.createElement("option"); option.value = String(row[idKey]); option.textContent = row[titleKey]; select.append(option); } select.disabled = rows.length === 0; }; const selectedProject = () => { const projectId = Number.parseInt(projectSelect?.value || "", 10); return projects.find((project) => project.projectId === projectId) || null; }; const selectedBook = () => { const bookId = Number.parseInt(bookSelect?.value || "", 10); return books.find((book) => book.bookId === bookId) || null; }; const updateSummary = () => { const project = selectedProject(); const book = selectedBook(); if (summaryProject) { summaryProject.textContent = project?.title || "(Not connected)"; } if (summaryBook) { summaryBook.textContent = book?.title || "(Not connected)"; } if (syncNote) { syncNote.textContent = book ? "" : "Select a PlotDirector book before refreshing."; } }; const normalizedStyleName = (paragraph) => { const style = paragraph?.styleBuiltIn || paragraph?.style || ""; return String(style).replace(/\s+/g, "").toLowerCase(); }; const isBuiltInHeading = (paragraph, level) => { const style = normalizedStyleName(paragraph); return style === `heading${level}` || style.endsWith(`.heading${level}`) || style.includes(`heading${level}`); }; const countWords = (text) => { const words = String(text || "").trim().split(/\s+/).filter(Boolean); return words.length; }; const paragraphMatches = (left, right) => { return String(left?.text || "").trim() === String(right?.text || "").trim() && normalizedStyleName(left) === normalizedStyleName(right); }; const anchorIdFromTag = (tag, prefix) => { const match = String(tag || "").match(new RegExp(`^${prefix}-(\\d+)$`, "i")); if (!match) { return null; } const id = Number.parseInt(match[1], 10); return Number.isInteger(id) && id > 0 ? id : null; }; const paragraphAnchorId = (paragraph, prefix) => { const controls = Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : []; for (const control of controls) { const id = anchorIdFromTag(control.tag, prefix); if (id) { return id; } } return null; }; const buildDocumentStructure = (paragraphs, selectionParagraphs) => { const chapters = []; let heading1Count = 0; let heading2Count = 0; let chapter = null; let scene = null; paragraphs.forEach((paragraph, index) => { const title = String(paragraph.text || "").trim(); if (!title) { return; } if (isBuiltInHeading(paragraph, 1)) { heading1Count += 1; chapter = { index: chapters.length + 1, paragraphIndex: index, title, anchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"), scenes: [] }; chapters.push(chapter); scene = null; return; } if (isBuiltInHeading(paragraph, 2)) { heading2Count += 1; if (!chapter) { return; } scene = { index: chapter.scenes.length + 1, paragraphIndex: index, title, anchorId: paragraphAnchorId(paragraph, "PD-SCENE"), wordCount: 0 }; chapter.scenes.push(scene); return; } if (scene) { scene.wordCount += countWords(title); } }); const selectedParagraph = selectionParagraphs.find((paragraph) => String(paragraph.text || "").trim()); const selectedIndex = selectedParagraph ? paragraphs.findIndex((paragraph) => paragraphMatches(paragraph, selectedParagraph)) : -1; const detectedChapter = selectedIndex >= 0 ? chapters.filter((item) => item.paragraphIndex <= selectedIndex).at(-1) || null : null; const detectedScene = detectedChapter && selectedIndex >= 0 ? detectedChapter.scenes.filter((item) => item.paragraphIndex <= selectedIndex).at(-1) || null : null; return { chapters, currentChapter: detectedChapter, currentScene: detectedScene, paragraphCount: paragraphs.length, heading1Count, heading2Count }; }; const renderOutline = (structure) => { if (!documentOutline) { return; } documentOutline.innerHTML = ""; if (structure.chapters.length === 0) { const message = document.createElement("p"); message.textContent = "No chapters detected. Use Heading 1 for chapter titles."; documentOutline.append(message); return; } const hasScenes = structure.chapters.some((chapter) => chapter.scenes.length > 0); if (!hasScenes) { const message = document.createElement("p"); message.textContent = "No scenes detected. Use Heading 2 for scene titles."; documentOutline.append(message); } for (const chapter of structure.chapters) { const chapterBlock = document.createElement("div"); chapterBlock.className = "word-companion-outline-chapter"; const chapterTitle = document.createElement("strong"); chapterTitle.textContent = chapter.title; chapterBlock.append(chapterTitle); if (chapter.scenes.length > 0) { const sceneList = document.createElement("ul"); for (const sceneItem of chapter.scenes) { const sceneRow = document.createElement("li"); sceneRow.textContent = sceneItem.title; sceneList.append(sceneRow); } chapterBlock.append(sceneList); } documentOutline.append(chapterBlock); } }; const readDocumentStructure = async (context) => { const bodyParagraphs = context.document.body.paragraphs; const selectionParagraphs = context.document.getSelection().paragraphs; bodyParagraphs.load("text,styleBuiltIn,style"); selectionParagraphs.load("text,styleBuiltIn,style"); await context.sync(); for (const paragraph of bodyParagraphs.items) { if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) { paragraph.contentControls.load("items/tag,title"); } } await context.sync(); const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items); structure.bodyParagraphs = bodyParagraphs.items; return structure; }; const refreshDocumentStructure = async () => { setDocumentMessage(""); if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { setCurrentStructure(null, null, null); setDocumentMessage("Word document access is unavailable."); setDiagnostics({ lastScan: "Failure", lastError: "Word document access is unavailable." }); return false; } if (refreshDocumentButton) { refreshDocumentButton.disabled = true; refreshDocumentButton.textContent = "Scanning..."; } setDocumentMessage("Scanning document structure..."); try { const structure = await window.Word.run(async (context) => { return await readDocumentStructure(context); }); setCurrentStructure( structure.currentChapter?.title, structure.currentScene?.title, structure.currentScene?.wordCount, structure.currentChapter?.anchorId, structure.currentScene?.anchorId, structure.currentChapter?.index ); resetPlotDirectorScene("Not detected"); renderOutline(structure); setDocumentMessage(""); setDiagnostics({ lastScan: "Success", lastError: "-", paragraphs: String(structure.paragraphCount), heading1: String(structure.heading1Count), heading2: String(structure.heading2Count) }); return true; } catch (error) { const message = errorText(error); console.error("Unable to scan the Word document.", error); setCurrentStructure(null, null, null); setDocumentMessage("Unable to scan the Word document."); setDiagnostics({ lastScan: "Failure", lastError: message }); return false; } finally { if (refreshDocumentButton) { refreshDocumentButton.disabled = false; refreshDocumentButton.textContent = "Refresh Document Structure"; } } }; const runDiagnostics = async () => { if (runDiagnosticsButton) { runDiagnosticsButton.disabled = true; runDiagnosticsButton.textContent = "Running..."; } try { await updateOfficeReadiness(); if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { setDiagnostics({ lastScan: "Failure", lastError: "Word document access is unavailable.", paragraphs: "-", heading1: "-", heading2: "-" }); return; } const paragraphCount = await window.Word.run(async (context) => { const paragraphs = context.document.body.paragraphs; paragraphs.load("items"); await context.sync(); return paragraphs.items.length; }); setDiagnostics({ lastScan: "Success", lastError: "-", paragraphs: String(paragraphCount) }); } catch (error) { console.error("Word Companion diagnostics failed.", error); setDiagnostics({ lastScan: "Failure", lastError: errorText(error) }); setDocumentMessage("Unable to scan the Word document."); } finally { if (runDiagnosticsButton) { runDiagnosticsButton.disabled = false; runDiagnosticsButton.textContent = "Run Diagnostics"; } } }; const fetchJson = async (url, options = {}) => { const response = await window.fetch(url, { credentials: "same-origin", ...options, headers: { Accept: "application/json", ...(options.headers || {}) } }); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } return await response.json(); }; const postJson = (url, body) => fetchJson(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const patchJson = (url, body) => fetchJson(url, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const putJson = (url, body) => fetchJson(url, { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const selectedLinkIds = (container) => { if (!container) { return []; } return [...container.querySelectorAll("input[type='checkbox']:checked")] .map((checkbox) => Number.parseInt(checkbox.value || "", 10)) .filter((id) => Number.isInteger(id) && id > 0); }; const selectedPrimaryLocationId = () => { const id = Number.parseInt(primaryLocationSelect?.value || "", 10); return Number.isInteger(id) && id > 0 ? id : null; }; const updateCurrentSceneLinkState = () => { const selectedCharacters = new Set(selectedLinkIds(characterChips)); const selectedAssets = new Set(selectedLinkIds(assetChips)); currentSceneLinks = { characters: currentSceneLinks.characters.map((item) => ({ ...item, linked: selectedCharacters.has(Number.parseInt(linkItemId(item, "character"), 10)) })), assets: currentSceneLinks.assets.map((item) => ({ ...item, linked: selectedAssets.has(Number.parseInt(linkItemId(item, "asset"), 10)) })), locations: currentSceneLinks.locations, primaryLocationId: selectedPrimaryLocationId() }; }; const saveSceneLinks = async () => { if (!resolvedSceneId) { setSceneLinksStatus("No resolved PlotDirector scene."); return; } if (saveSceneLinksButton) { saveSceneLinksButton.disabled = true; saveSceneLinksButton.textContent = "Saving..."; } try { await putJson(`/api/word-companion/scenes/${resolvedSceneId}/links`, { characterIds: selectedLinkIds(characterChips), assetIds: selectedLinkIds(assetChips), locationIds: [], primaryLocationId: selectedPrimaryLocationId() }); updateCurrentSceneLinkState(); setSceneLinksStatus("Scene links saved successfully."); setDiagnostics({ lastError: "-" }); } catch (error) { setSceneLinksStatus("Unable to save scene links."); setDiagnostics({ lastError: errorText(error) }); } finally { if (saveSceneLinksButton) { saveSceneLinksButton.disabled = !resolvedSceneId; saveSceneLinksButton.textContent = "Save Scene Links"; } } }; const findAnchorControl = (paragraph, prefix) => { const controls = Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : []; return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null; }; const ensureAnchorControl = (paragraph, title, tag, prefix) => { const existing = findAnchorControl(paragraph, prefix); const control = existing || paragraph.getRange().insertContentControl(); control.title = title; control.tag = tag; control.appearance = "Hidden"; return control; }; const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => { if (!resolvedSceneId || !resolvedChapterId) { setDocumentMessage("No resolved PlotDirector scene."); return false; } const replacingExistingAnchor = (detectedSceneAnchorId && detectedSceneAnchorId !== resolvedSceneId) || (detectedChapterAnchorId && detectedChapterAnchorId !== resolvedChapterId); if (replacingExistingAnchor && !window.confirm("This scene is already linked. Replace the existing PlotDirector link?")) { return false; } if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { setDocumentMessage("Word document access is unavailable."); return false; } if (attachPlotDirectorIdsButton) { attachPlotDirectorIdsButton.disabled = true; attachPlotDirectorIdsButton.textContent = "Attaching..."; } try { await window.Word.run(async (context) => { const structure = await readDocumentStructure(context); if (!structure.currentChapter || !structure.currentScene) { throw new Error("No scene detected in Word. Use Heading 2 for scenes."); } const chapterParagraph = structure.bodyParagraphs[structure.currentChapter.paragraphIndex]; const sceneParagraph = structure.bodyParagraphs[structure.currentScene.paragraphIndex]; if (!chapterParagraph || !sceneParagraph) { throw new Error("Unable to locate the current Word headings."); } ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER"); ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE"); await context.sync(); }); detectedChapterAnchorId = resolvedChapterId; detectedSceneAnchorId = resolvedSceneId; storedAnchorInvalid = false; currentResolutionAnchorType = "SceneID Anchor"; currentResolutionMethod = "Anchor"; setDocumentMessage(successMessage); setDiagnostics({ lastError: "-" }); updateAnchorDisplay(); return true; } catch (error) { console.error("Unable to attach PlotDirector IDs.", error); setDocumentMessage("Unable to attach PlotDirector IDs."); setDiagnostics({ lastError: errorText(error) }); updateAnchorDisplay(); return false; } finally { if (attachPlotDirectorIdsButton) { attachPlotDirectorIdsButton.textContent = detectedSceneAnchorId === resolvedSceneId ? "Attach PlotDirector IDs" : "Reattach PlotDirector IDs"; updateAnchorDisplay(); } } }; const attachResolvedChapterId = async (successMessage) => { if (!resolvedChapterId) { setChapterCreateLinkStatus("No resolved PlotDirector chapter."); return false; } if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { setChapterCreateLinkStatus("Word document access is unavailable."); return false; } try { await window.Word.run(async (context) => { const structure = await readDocumentStructure(context); if (!structure.currentChapter) { throw new Error("No chapter detected in Word. Use Heading 1 for chapters."); } const chapterParagraph = structure.bodyParagraphs[structure.currentChapter.paragraphIndex]; if (!chapterParagraph) { throw new Error("Unable to locate the current Word chapter heading."); } ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER"); await context.sync(); }); detectedChapterAnchorId = resolvedChapterId; storedAnchorInvalid = false; currentResolutionAnchorType = "ChapterID Anchor"; currentResolutionMethod = "Chapter anchor"; setChapterCreateLinkStatus(successMessage); setDocumentMessage(successMessage); setDiagnostics({ lastError: "-" }); updateAnchorDisplay(); return true; } catch (error) { console.error("Unable to attach PlotDirector chapter ID.", error); setChapterCreateLinkStatus("Unable to attach PlotDirector chapter ID."); setDiagnostics({ lastError: errorText(error) }); updateAnchorDisplay(); return false; } }; const attachPlotDirectorIds = async () => { await attachResolvedPlotDirectorIds(); }; const refreshAfterChapterLink = async (message) => { hideChapterCreateLink(); await refreshPlotDirectorScene(); setDocumentMessage(message); setChapterCreateLinkStatus(message); }; const closeCreateChapterDialog = (confirmed) => { if (pendingCreateChapterConfirmation) { pendingCreateChapterConfirmation(confirmed); pendingCreateChapterConfirmation = null; } if (createChapterDialog?.close) { createChapterDialog.close(); } else if (createChapterDialog) { createChapterDialog.hidden = true; } }; const confirmCreateChapter = (chapterTitle, bookTitle) => { setText(createChapterDialogChapter, `"${chapterTitle}"`); setText(createChapterDialogBook, `"${bookTitle}"`); if (!createChapterDialog) { return Promise.resolve(window.confirm(`Create PlotDirector chapter:\n\n"${chapterTitle}"\n\nin book:\n\n"${bookTitle}"\n\n?`)); } return new Promise((resolve) => { pendingCreateChapterConfirmation = resolve; if (createChapterDialog.showModal) { createChapterDialog.showModal(); } else { createChapterDialog.hidden = false; } }); }; const createPlotDirectorChapter = async () => { const book = selectedBook(); if (!book || !detectedChapterTitle || resolvedChapterId) { showChapterCreateLink("Chapter not found in PlotDirector."); return; } const chapterTitle = normalizeWhitespace(detectedChapterTitle); const confirmed = await confirmCreateChapter(chapterTitle, book.title || "selected book"); if (!confirmed) { return; } if (createPlotDirectorChapterButton) { createPlotDirectorChapterButton.disabled = true; createPlotDirectorChapterButton.textContent = "Creating..."; } try { const sortOrder = Number.isInteger(detectedChapterIndex) && detectedChapterIndex > 0 ? detectedChapterIndex * 10 : null; const response = await postJson(`/api/word-companion/books/${book.bookId}/chapters`, { title: chapterTitle, sortOrder }); if (!response?.chapterId) { throw new Error("Chapter creation did not return a chapter ID."); } setResolvedScene(null, response.chapterId, "Manual chapter link"); const anchored = await attachResolvedChapterId("Chapter created and linked successfully."); if (!anchored) { return; } await refreshAfterChapterLink("Chapter created and linked successfully."); } catch (error) { setChapterCreateLinkStatus("Unable to create chapter."); setDiagnostics({ lastError: errorText(error) }); } finally { if (createPlotDirectorChapterButton) { createPlotDirectorChapterButton.disabled = !canUseChapterCreateLinkActions(); createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector"; } } }; const renderLinkChapterOptions = () => { if (!linkChapterOptions) { return; } const query = normalizeWhitespace(linkChapterSearch?.value || "").toLocaleLowerCase(); const rows = linkDialogChapters.filter((chapter) => !query || String(chapter.title || "").toLocaleLowerCase().includes(query)); linkChapterOptions.innerHTML = ""; selectedLinkChapterId = null; if (linkSelectedChapterButton) { linkSelectedChapterButton.disabled = true; } if (rows.length === 0) { const empty = document.createElement("p"); empty.className = "word-companion-empty-list"; empty.textContent = "No chapters found."; linkChapterOptions.append(empty); return; } for (const chapter of rows) { const chapterId = Number.parseInt(chapter.chapterId || "", 10); if (!Number.isInteger(chapterId) || chapterId <= 0) { continue; } const label = document.createElement("label"); label.className = "word-companion-scene-option"; const radio = document.createElement("input"); radio.type = "radio"; radio.name = "word-companion-link-chapter"; radio.value = String(chapterId); radio.addEventListener("change", () => { selectedLinkChapterId = chapterId; if (linkSelectedChapterButton) { linkSelectedChapterButton.disabled = false; } }); const text = document.createElement("span"); text.textContent = chapter.title || "Untitled chapter"; label.append(radio, text); linkChapterOptions.append(label); } }; const openLinkExistingChapterDialog = async () => { const book = selectedBook(); if (!book || !detectedChapterTitle || resolvedChapterId) { showChapterCreateLink("Chapter not found in PlotDirector."); return; } if (linkExistingChapterButton) { linkExistingChapterButton.disabled = true; linkExistingChapterButton.textContent = "Loading..."; } try { linkDialogChapters = await fetchBookChapters(book.bookId); selectedLinkChapterId = null; if (linkChapterSearch) { linkChapterSearch.value = ""; } setText(linkChapterDialogStatus, ""); renderLinkChapterOptions(); if (linkExistingChapterDialog?.showModal) { linkExistingChapterDialog.showModal(); } else if (linkExistingChapterDialog) { linkExistingChapterDialog.hidden = false; } } catch (error) { setChapterCreateLinkStatus("Unable to load chapters."); setDiagnostics({ lastError: errorText(error) }); } finally { if (linkExistingChapterButton) { linkExistingChapterButton.disabled = !canUseChapterCreateLinkActions(); linkExistingChapterButton.textContent = "Link to Existing Chapter"; } } }; const closeLinkExistingChapterDialog = () => { if (linkExistingChapterDialog?.close) { linkExistingChapterDialog.close(); } else if (linkExistingChapterDialog) { linkExistingChapterDialog.hidden = true; } }; const linkSelectedExistingChapter = async () => { const chapter = linkDialogChapters.find((item) => item.chapterId === selectedLinkChapterId); if (!chapter) { setText(linkChapterDialogStatus, "Select a chapter."); return; } if (linkSelectedChapterButton) { linkSelectedChapterButton.disabled = true; linkSelectedChapterButton.textContent = "Linking..."; } try { setResolvedScene(null, chapter.chapterId, "Manual chapter link"); const anchored = await attachResolvedChapterId("Chapter linked successfully."); if (!anchored) { return; } closeLinkExistingChapterDialog(); await refreshAfterChapterLink("Chapter linked successfully."); } catch (error) { setText(linkChapterDialogStatus, "Unable to link chapter."); setChapterCreateLinkStatus("Unable to link chapter."); setDiagnostics({ lastError: errorText(error) }); } finally { if (linkSelectedChapterButton) { linkSelectedChapterButton.disabled = !selectedLinkChapterId; linkSelectedChapterButton.textContent = "Link Chapter"; } } }; const resolveCreatedOrLinkedScene = async (sceneId, chapterId, successMessage) => { const book = selectedBook(); if (!book) { setCreateLinkStatus("Select a PlotDirector book first."); return false; } await loadResolvedCompanionData(book.bookId, sceneId, chapterId, "Manual link", "Title Match"); const anchored = await attachResolvedPlotDirectorIds(successMessage); if (!anchored) { return false; } setCreateLinkStatus(successMessage); hideSceneCreateLink(); return true; }; const closeCreateSceneDialog = (confirmed) => { if (pendingCreateSceneConfirmation) { pendingCreateSceneConfirmation(confirmed); pendingCreateSceneConfirmation = null; } if (createSceneDialog?.close) { createSceneDialog.close(); } else if (createSceneDialog) { createSceneDialog.hidden = true; } }; const confirmCreateScene = (sceneTitle, chapterTitle) => { setText(createDialogScene, `"${sceneTitle}"`); setText(createDialogChapter, `"${chapterTitle}"`); if (!createSceneDialog) { return Promise.resolve(window.confirm(`Create PlotDirector scene:\n\n"${sceneTitle}"\n\nwithin chapter:\n\n"${chapterTitle}"\n\n?`)); } return new Promise((resolve) => { pendingCreateSceneConfirmation = resolve; if (createSceneDialog.showModal) { createSceneDialog.showModal(); } else { createSceneDialog.hidden = false; } }); }; const createPlotDirectorScene = async () => { const book = selectedBook(); if (!book || !detectedSceneTitle) { showSceneCreateLink("Scene not found in PlotDirector."); return; } const sceneTitle = normalizeTitleForResolve(detectedSceneTitle, "scene"); const chapterTitle = normalizeTitleForResolve(detectedChapterTitle, "chapter"); const confirmed = await confirmCreateScene(sceneTitle, chapterTitle); if (!confirmed) { return; } if (createPlotDirectorSceneButton) { createPlotDirectorSceneButton.disabled = true; createPlotDirectorSceneButton.textContent = "Creating..."; } try { const chapter = await findCurrentChapter(); if (!chapter) { showSceneCreateLink("This chapter does not exist in PlotDirector. Create or link the chapter first."); return; } const response = await postJson(`/api/word-companion/books/${book.bookId}/chapters/${chapter.chapterId}/scenes`, { sceneTitle }); if (!response?.sceneId || !response?.chapterId) { throw new Error("Scene creation did not return a scene ID."); } await resolveCreatedOrLinkedScene(response.sceneId, response.chapterId, "Scene created and linked successfully."); } catch (error) { setCreateLinkStatus("Unable to create scene."); setDiagnostics({ lastError: errorText(error) }); } finally { if (createPlotDirectorSceneButton) { createPlotDirectorSceneButton.disabled = !canUseCreateLinkActions(); createPlotDirectorSceneButton.textContent = "Create Scene in PlotDirector"; } } }; const renderLinkSceneOptions = () => { if (!linkSceneOptions) { return; } const query = normalizeWhitespace(linkSceneSearch?.value || "").toLocaleLowerCase(); const rows = linkDialogScenes.filter((scene) => !query || String(scene.title || "").toLocaleLowerCase().includes(query)); linkSceneOptions.innerHTML = ""; selectedLinkSceneId = null; if (linkSelectedSceneButton) { linkSelectedSceneButton.disabled = true; } if (rows.length === 0) { const empty = document.createElement("p"); empty.className = "word-companion-empty-list"; empty.textContent = "No scenes found."; linkSceneOptions.append(empty); return; } for (const scene of rows) { const sceneId = Number.parseInt(scene.sceneId || "", 10); if (!Number.isInteger(sceneId) || sceneId <= 0) { continue; } const label = document.createElement("label"); label.className = "word-companion-scene-option"; const radio = document.createElement("input"); radio.type = "radio"; radio.name = "word-companion-link-scene"; radio.value = String(sceneId); radio.addEventListener("change", () => { selectedLinkSceneId = sceneId; if (linkSelectedSceneButton) { linkSelectedSceneButton.disabled = false; } }); const text = document.createElement("span"); text.textContent = scene.title || "Untitled scene"; label.append(radio, text); linkSceneOptions.append(label); } }; const openLinkExistingSceneDialog = async () => { const book = selectedBook(); if (!book || !detectedSceneTitle) { showSceneCreateLink("Scene not found in PlotDirector."); return; } if (linkExistingSceneButton) { linkExistingSceneButton.disabled = true; linkExistingSceneButton.textContent = "Loading..."; } try { const chapters = await getBookStructureChapters(book.bookId); const chapter = findCurrentChapterInChapters(chapters); if (!chapter) { showSceneCreateLink("This chapter does not exist in PlotDirector. Create or link the chapter first."); return; } linkDialogScenes = Array.isArray(chapter.scenes) ? chapter.scenes : []; selectedLinkSceneId = null; if (linkSceneSearch) { linkSceneSearch.value = ""; } setText(linkDialogStatus, ""); renderLinkSceneOptions(); if (linkExistingDialog?.showModal) { linkExistingDialog.showModal(); } else if (linkExistingDialog) { linkExistingDialog.hidden = false; } } catch (error) { setCreateLinkStatus("Unable to load scenes."); setDiagnostics({ lastError: errorText(error) }); } finally { if (linkExistingSceneButton) { linkExistingSceneButton.disabled = !canUseCreateLinkActions(); linkExistingSceneButton.textContent = "Link to Existing Scene"; } } }; const closeLinkExistingSceneDialog = () => { if (linkExistingDialog?.close) { linkExistingDialog.close(); } else if (linkExistingDialog) { linkExistingDialog.hidden = true; } }; const linkSelectedExistingScene = async () => { const book = selectedBook(); const scene = linkDialogScenes.find((item) => item.sceneId === selectedLinkSceneId); if (!book || !scene) { setText(linkDialogStatus, "Select a scene."); return; } if (linkSelectedSceneButton) { linkSelectedSceneButton.disabled = true; linkSelectedSceneButton.textContent = "Linking..."; } try { const chapter = await findCurrentChapter(); if (!chapter) { throw new Error("This chapter does not exist in PlotDirector. Create or link the chapter first."); } closeLinkExistingSceneDialog(); await resolveCreatedOrLinkedScene(scene.sceneId, chapter.chapterId, "Scene linked successfully."); } catch (error) { setText(linkDialogStatus, "Unable to link scene."); setCreateLinkStatus("Unable to link scene."); setDiagnostics({ lastError: errorText(error) }); } finally { if (linkSelectedSceneButton) { linkSelectedSceneButton.disabled = !selectedLinkSceneId; linkSelectedSceneButton.textContent = "Link Scene"; } } }; const syncSceneProgress = async () => { if (!resolvedSceneId) { setSyncStatus("No resolved PlotDirector scene."); return; } if (!Number.isInteger(detectedSceneWordCount)) { setSyncStatus("Unable to sync progress."); return; } if (syncSceneProgressButton) { syncSceneProgressButton.disabled = true; syncSceneProgressButton.textContent = "Syncing..."; } const lastWorkedOn = new Date().toISOString(); try { const response = await patchJson(`/api/word-companion/scenes/${resolvedSceneId}/word-count`, { actualWordCount: detectedSceneWordCount, lastWorkedOn }); setText(syncPlotDirectorCount, displayValue(response?.actualWords ?? detectedSceneWordCount)); setText(plotDirectorActualWords, displayValue(response?.actualWords ?? detectedSceneWordCount)); setText(syncLastSynced, new Date().toLocaleString()); setSyncStatus("Progress synced successfully."); } catch { setSyncStatus("Unable to sync progress."); } finally { if (syncSceneProgressButton) { syncSceneProgressButton.disabled = !resolvedSceneId; syncSceneProgressButton.textContent = "Sync Progress"; } } }; const refreshPlotDirectorChapter = async () => { const project = selectedProject(); const book = selectedBook(); const requestChapterTitle = normalizeTitleForResolve(detectedChapterTitle, "chapter"); setResolveDiagnostics({ projectId: project?.projectId, projectTitle: project?.title, bookId: book?.bookId, bookTitle: book?.title, detectedChapter: detectedChapterTitle, detectedScene: detectedSceneTitle, requestChapter: requestChapterTitle, requestScene: "", result: "Not run", chapterId: null, sceneId: null }); if (!book) { resetPlotDirectorScene("Not detected"); setDocumentMessage("Select a PlotDirector book first."); return false; } if (!detectedChapterTitle) { resetPlotDirectorScene("Not detected"); setDocumentMessage("No chapter detected in Word. Use Heading 1 for chapters."); return false; } resetPlotDirectorScene("Not detected"); try { const chapters = await fetchBookChapters(book.bookId); const anchoredChapter = detectedChapterAnchorId ? chapters.find((chapter) => chapter.chapterId === detectedChapterAnchorId) : null; const requestChapterKey = requestChapterTitle.toLocaleLowerCase(); const titleChapter = requestChapterKey ? chapters.find((chapter) => normalizeTitleForResolve(chapter.title, "chapter").toLocaleLowerCase() === requestChapterKey) : null; const chapter = anchoredChapter || titleChapter; if (chapter) { hideChapterCreateLink(); setResolvedScene(null, chapter.chapterId, anchoredChapter ? "Chapter anchor" : "Chapter title"); currentResolutionAnchorType = anchoredChapter ? "ChapterID Anchor" : "Title Match"; setPlotDirectorSceneStatus("Chapter linked to PlotDirector"); setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes."); setResolveDiagnostics({ result: "Chapter matched", chapterId: chapter.chapterId, sceneId: null }); return true; } setResolvedScene(null); setPlotDirectorSceneStatus("Chapter not found in PlotDirector."); setDocumentMessage("Chapter not found in PlotDirector."); setResolveDiagnostics({ result: "Chapter not matched", chapterId: null, sceneId: null }); showChapterCreateLink("Chapter not found in PlotDirector."); return false; } catch (error) { resetPlotDirectorScene("Not found in PlotDirector"); setDiagnostics({ lastError: errorText(error) }); setDocumentMessage("Unable to load PlotDirector chapter data."); return false; } }; const refreshPlotDirectorScene = async () => { const project = selectedProject(); const book = selectedBook(); const requestChapterTitle = normalizeTitleForResolve(detectedChapterTitle, "chapter"); const requestSceneTitle = normalizeTitleForResolve(detectedSceneTitle, "scene"); setResolveDiagnostics({ projectId: project?.projectId, projectTitle: project?.title, bookId: book?.bookId, bookTitle: book?.title, detectedChapter: detectedChapterTitle, detectedScene: detectedSceneTitle, requestChapter: requestChapterTitle, requestScene: requestSceneTitle, result: "Not run", chapterId: null, sceneId: null }); setAnchorDiagnostics({ anchorType: detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match", storedSceneId: detectedSceneAnchorId, storedChapterId: detectedChapterAnchorId, resolutionMethod: "-" }); if (!book) { resetPlotDirectorScene("Not detected"); setDocumentMessage("Select a PlotDirector book first."); setResolveDiagnostics({ result: "Error" }); return false; } if (!detectedChapterTitle || !detectedSceneTitle) { resetPlotDirectorScene("Not detected"); setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes."); setResolveDiagnostics({ result: "Error" }); return false; } if (refreshPlotDirectorSceneButton) { refreshPlotDirectorSceneButton.disabled = true; refreshPlotDirectorSceneButton.textContent = "Refreshing..."; } const restorePlotDirectorSceneButton = () => { if (refreshPlotDirectorSceneButton) { refreshPlotDirectorSceneButton.disabled = false; refreshPlotDirectorSceneButton.textContent = "Refresh PlotDirector Scene"; } }; resetPlotDirectorScene("Not detected"); setDocumentMessage("Resolving PlotDirector scene..."); try { if (detectedSceneAnchorId) { const anchored = await findSceneInBookStructure( book.bookId, (_chapter, scene) => scene.sceneId === detectedSceneAnchorId ); if (anchored) { setResolveDiagnostics({ result: "Matched", chapterId: anchored.chapter.chapterId, sceneId: anchored.scene.sceneId }); await loadResolvedCompanionData(book.bookId, anchored.scene.sceneId, anchored.chapter.chapterId, "Anchor", "SceneID Anchor"); return true; } storedAnchorInvalid = true; currentResolutionAnchorType = "SceneID Anchor"; setPlotDirectorSceneStatus("Not found in PlotDirector"); setDocumentMessage("Stored PlotDirector ID is invalid."); setResolveDiagnostics({ result: "Error", chapterId: detectedChapterAnchorId, sceneId: detectedSceneAnchorId }); setAnchorDiagnostics({ anchorType: "SceneID Anchor", storedSceneId: detectedSceneAnchorId, storedChapterId: detectedChapterAnchorId, resolutionMethod: "Anchor" }); } if (detectedChapterAnchorId) { const chapters = await getBookStructureChapters(book.bookId); const anchoredChapter = chapters.find((chapter) => chapter.chapterId === detectedChapterAnchorId); const anchoredChapterScene = anchoredChapter ? (Array.isArray(anchoredChapter.scenes) ? anchoredChapter.scenes : []) .find((scene) => normalizeTitleForResolve(scene.title, "scene").toLocaleLowerCase() === requestSceneTitle.toLocaleLowerCase()) : null; if (anchoredChapterScene) { setResolveDiagnostics({ result: "Matched", chapterId: anchoredChapter.chapterId, sceneId: anchoredChapterScene.sceneId }); await loadResolvedCompanionData(book.bookId, anchoredChapterScene.sceneId, anchoredChapter.chapterId, "Anchor", "ChapterID Anchor"); return true; } if (anchoredChapter && !detectedSceneAnchorId) { currentResolutionAnchorType = "ChapterID Anchor"; setResolvedScene(null, detectedChapterAnchorId, "Chapter anchor"); setResolveDiagnostics({ result: "Chapter matched", chapterId: detectedChapterAnchorId, sceneId: null }); } else if (!detectedSceneAnchorId) { currentResolutionAnchorType = "ChapterID Anchor"; setResolveDiagnostics({ result: "Chapter anchor not found", chapterId: detectedChapterAnchorId, sceneId: null }); } } const resolveResponse = await postJson(`/api/word-companion/books/${book.bookId}/resolve-scene`, { chapterTitle: requestChapterTitle, sceneTitle: requestSceneTitle }); if (!resolveResponse?.matched || !resolveResponse.sceneId) { const hadInvalidAnchor = storedAnchorInvalid; const matchedChapterId = Number.parseInt(resolveResponse?.chapterId || "", 10); const anchorChapterId = Number.isInteger(resolvedChapterId) && resolvedChapterId > 0 ? resolvedChapterId : null; const unmatchedChapterId = Number.isInteger(matchedChapterId) && matchedChapterId > 0 ? matchedChapterId : anchorChapterId; const chapterMatched = !!resolveResponse?.chapterMatched || !!unmatchedChapterId; resetPlotDirectorScene("Not found in PlotDirector"); storedAnchorInvalid = hadInvalidAnchor; if (chapterMatched) { setResolvedScene(null, unmatchedChapterId, unmatchedChapterId === anchorChapterId ? "Chapter anchor" : "Chapter title"); setPlotDirectorSceneStatus("Scene not found in PlotDirector."); } setDocumentMessage(hadInvalidAnchor ? "Stored PlotDirector ID is invalid." : chapterMatched ? "Scene not found in PlotDirector." : "This chapter does not exist in PlotDirector. Create or link the chapter first."); setResolveDiagnostics({ result: "Not matched", chapterId: chapterMatched ? unmatchedChapterId : resolveResponse?.chapterId, sceneId: resolveResponse?.sceneId }); if (!hadInvalidAnchor) { if (chapterMatched) { hideChapterCreateLink(); showSceneCreateLink("Scene not found in PlotDirector."); } else { hideSceneCreateLink(); showChapterCreateLink("Chapter not found in PlotDirector."); } } restorePlotDirectorSceneButton(); return false; } const hadInvalidAnchor = storedAnchorInvalid; setResolveDiagnostics({ result: hadInvalidAnchor ? "Matched by title fallback" : "Matched", chapterId: resolveResponse.chapterId, sceneId: resolveResponse.sceneId }); await loadResolvedCompanionData(book.bookId, resolveResponse.sceneId, resolveResponse.chapterId, hadInvalidAnchor ? "Title fallback" : "Title", "Title Match"); if (hadInvalidAnchor) { storedAnchorInvalid = true; setDocumentMessage("Stored PlotDirector ID is invalid."); updateAnchorDisplay(); } return true; } catch (error) { const hadInvalidAnchor = storedAnchorInvalid; setDiagnostics({ lastError: errorText(error) }); resetPlotDirectorScene("Not found in PlotDirector"); storedAnchorInvalid = hadInvalidAnchor; setDocumentMessage(hadInvalidAnchor ? "Stored PlotDirector ID is invalid." : "Unable to load PlotDirector scene data."); setResolveDiagnostics({ result: "Error" }); return false; } finally { restorePlotDirectorSceneButton(); } }; const refreshCurrentScene = async () => { if (!selectedBook()) { setDocumentMessage("Select a PlotDirector book first."); resetPlotDirectorScene("Not detected"); return; } setRefreshCurrentSceneBusy(true); try { const scanned = await refreshDocumentStructure(); if (!scanned) { return; } if (!detectedSceneTitle) { await refreshPlotDirectorChapter(); return; } await refreshPlotDirectorScene(); } finally { setRefreshCurrentSceneBusy(false); } }; const loadBooks = async (projectId, preferredBookId) => { if (!projectId) { books = []; resetSelect(bookSelect, "Select a project first"); setBookMessage(""); writeStoredId(storageKeys.bookId, null); updateSummary(); resetPlotDirectorScene("Not detected"); return; } resetSelect(bookSelect, "Loading books..."); setBookMessage(""); resetPlotDirectorScene("Not detected"); try { const data = await fetchJson(`/api/word-companion/projects/${projectId}/books`); books = Array.isArray(data.books) ? data.books : []; if (books.length === 0) { resetSelect(bookSelect, "No books available."); setBookMessage("No books available."); writeStoredId(storageKeys.bookId, null); updateSummary(); return; } addOptions(bookSelect, "Choose a book", books, "bookId", "title"); const validBook = books.find((book) => book.bookId === preferredBookId); if (validBook && bookSelect) { bookSelect.value = String(validBook.bookId); writeStoredId(storageKeys.bookId, validBook.bookId); } else { writeStoredId(storageKeys.bookId, null); } updateSummary(); resetPlotDirectorScene(selectedBook() ? "Not detected" : "Not detected"); } catch { books = []; resetSelect(bookSelect, "Unable to load books."); setBookMessage("Unable to load books."); writeStoredId(storageKeys.bookId, null); updateSummary(); resetPlotDirectorScene("Not detected"); } }; const loadProjects = async () => { resetSelect(projectSelect, "Loading projects..."); resetSelect(bookSelect, "Select a project first"); setProjectMessage(""); setBookMessage(""); try { const data = await fetchJson("/api/word-companion/projects"); projects = Array.isArray(data.projects) ? data.projects : []; if (projects.length === 0) { resetSelect(projectSelect, "No projects available."); setProjectMessage("No projects available."); writeStoredId(storageKeys.projectId, null); writeStoredId(storageKeys.bookId, null); updateSummary(); return; } addOptions(projectSelect, "Choose a project", projects, "projectId", "title"); const storedProjectId = readStoredId(storageKeys.projectId); const validProject = projects.find((project) => project.projectId === storedProjectId); if (validProject && projectSelect) { projectSelect.value = String(validProject.projectId); writeStoredId(storageKeys.projectId, validProject.projectId); await loadBooks(validProject.projectId, readStoredId(storageKeys.bookId)); } else { writeStoredId(storageKeys.projectId, null); writeStoredId(storageKeys.bookId, null); updateSummary(); } } catch { projects = []; books = []; resetSelect(projectSelect, "Unable to connect to PlotDirector."); resetSelect(bookSelect, "Select a project first"); setConnectionStatus("Unable to connect to PlotDirector."); setProjectMessage("Unable to connect to PlotDirector."); writeStoredId(storageKeys.projectId, null); writeStoredId(storageKeys.bookId, null); updateSummary(); } }; projectSelect?.addEventListener("change", async () => { const projectId = Number.parseInt(projectSelect.value || "", 10); writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null); writeStoredId(storageKeys.bookId, null); resetPlotDirectorScene("Not detected"); await loadBooks(projectId, null); }); bookSelect?.addEventListener("change", () => { const bookId = Number.parseInt(bookSelect.value || "", 10); writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null); updateSummary(); resetPlotDirectorScene("Not detected"); }); refreshDocumentButton?.addEventListener("click", refreshDocumentStructure); refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene); refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene); syncSceneProgressButton?.addEventListener("click", syncSceneProgress); saveSceneLinksButton?.addEventListener("click", saveSceneLinks); attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds); createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter); linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog); linkChapterSearch?.addEventListener("input", renderLinkChapterOptions); linkSelectedChapterButton?.addEventListener("click", linkSelectedExistingChapter); linkChapterDialogCancel?.addEventListener("click", closeLinkExistingChapterDialog); createChapterDialogConfirm?.addEventListener("click", () => closeCreateChapterDialog(true)); createChapterDialogCancel?.addEventListener("click", () => closeCreateChapterDialog(false)); createChapterDialog?.addEventListener("cancel", (event) => { event.preventDefault(); closeCreateChapterDialog(false); }); createPlotDirectorSceneButton?.addEventListener("click", createPlotDirectorScene); linkExistingSceneButton?.addEventListener("click", openLinkExistingSceneDialog); linkSceneSearch?.addEventListener("input", renderLinkSceneOptions); linkSelectedSceneButton?.addEventListener("click", linkSelectedExistingScene); linkDialogCancel?.addEventListener("click", closeLinkExistingSceneDialog); createDialogConfirm?.addEventListener("click", () => closeCreateSceneDialog(true)); createDialogCancel?.addEventListener("click", () => closeCreateSceneDialog(false)); createSceneDialog?.addEventListener("cancel", (event) => { event.preventDefault(); closeCreateSceneDialog(false); }); runDiagnosticsButton?.addEventListener("click", runDiagnostics); if (isAuthenticated) { loadProjects(); } else { resetSelect(projectSelect, "Please sign in to PlotDirector."); resetSelect(bookSelect, "Please sign in to PlotDirector."); updateSummary(); } if (!window.Office || typeof window.Office.onReady !== "function") { setOfficeStatus("Word Companion ready"); setDocumentMessage("Word document access is unavailable."); setDiagnostics({ host: "Unavailable", platform: "Unavailable", ready: "No", wordApi: "No" }); return; } updateOfficeReadiness() .then(() => { setOfficeStatus("Word Companion ready"); if (!wordHostAvailable) { setDocumentMessage("Word document access is unavailable."); } }) .catch((error) => { console.error("Office readiness check failed.", error); setConnectionStatus("Unable to connect to PlotDirector."); setOfficeStatus("Word Companion unavailable"); setDocumentMessage("Word document access is unavailable."); setDiagnostics({ ready: "No", wordApi: "No", lastScan: "Failure" }); }); })();