(() => { 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 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 isAuthenticated = shell?.dataset.authenticated === "true"; let projects = []; let books = []; let officeReady = false; let wordHostAvailable = false; 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 setCurrentStructure = (chapterTitle, sceneTitle, wordCount) => { if (currentChapter) { currentChapter.textContent = chapterTitle || "Not detected"; } if (currentScene) { currentScene.textContent = sceneTitle || "Not detected"; } if (currentWordCount) { currentWordCount.textContent = Number.isInteger(wordCount) ? String(wordCount) : "-"; } }; 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 syncing in a future step."; } }; 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 buildDocumentStructure = (paragraphs, selectionParagraphs) => { const chapters = []; let chapter = null; let scene = null; paragraphs.forEach((paragraph, index) => { const title = String(paragraph.text || "").trim(); if (!title) { return; } if (isBuiltInHeading(paragraph, 1)) { chapter = { index: chapters.length + 1, paragraphIndex: index, title, scenes: [] }; chapters.push(chapter); scene = null; return; } if (isBuiltInHeading(paragraph, 2)) { if (!chapter) { return; } scene = { index: chapter.scenes.length + 1, paragraphIndex: index, title, 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 }; }; 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 ${chapter.index}: ${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 = `Scene ${sceneItem.index}: ${sceneItem.title}`; sceneList.append(sceneRow); } chapterBlock.append(sceneList); } documentOutline.append(chapterBlock); } }; const refreshDocumentStructure = async () => { setDocumentMessage(""); if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") { setCurrentStructure(null, null, null); setDocumentMessage("Word document access is unavailable."); return; } if (refreshDocumentButton) { refreshDocumentButton.disabled = true; refreshDocumentButton.textContent = "Scanning..."; } setDocumentMessage("Scanning document structure..."); try { const structure = await window.Word.run(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(); return buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items); }); setCurrentStructure( structure.currentChapter?.title, structure.currentScene?.title, structure.currentScene?.wordCount ); renderOutline(structure); setDocumentMessage(""); } catch { setCurrentStructure(null, null, null); setDocumentMessage("Unable to scan the Word document."); } finally { if (refreshDocumentButton) { refreshDocumentButton.disabled = false; refreshDocumentButton.textContent = "Refresh Document Structure"; } } }; const fetchJson = async (url) => { const response = await window.fetch(url, { credentials: "same-origin", headers: { Accept: "application/json" } }); if (!response.ok) { throw new Error(`Request failed: ${response.status}`); } return await response.json(); }; const loadBooks = async (projectId, preferredBookId) => { if (!projectId) { books = []; resetSelect(bookSelect, "Select a project first"); setBookMessage(""); writeStoredId(storageKeys.bookId, null); updateSummary(); return; } resetSelect(bookSelect, "Loading books..."); setBookMessage(""); 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(); } catch { books = []; resetSelect(bookSelect, "Unable to load books."); setBookMessage("Unable to load books."); writeStoredId(storageKeys.bookId, null); updateSummary(); } }; 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); 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(); }); refreshDocumentButton?.addEventListener("click", refreshDocumentStructure); 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."); return; } window.Office.onReady() .then((info) => { officeReady = true; wordHostAvailable = !!window.Word && !!window.Office.HostType && info?.host === window.Office.HostType.Word; setOfficeStatus("Word Companion ready"); if (!wordHostAvailable) { setDocumentMessage("Word document access is unavailable."); } }) .catch(() => { setConnectionStatus("Unable to connect to PlotDirector."); setOfficeStatus("Word Companion unavailable"); setDocumentMessage("Word document access is unavailable."); }); })();