From 2b77e85bbc074e1e2eee9ae244d60610dc20fc75 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 13 Jun 2026 17:01:05 +0100 Subject: [PATCH] Phase 10D manual document structure. --- PlotLine/Views/WordCompanionHost/Index.cshtml | 60 ++- PlotLine/wwwroot/css/word-companion.css | 117 ++++- PlotLine/wwwroot/js/word-companion-host.js | 443 +++++++++++++++++- 3 files changed, 608 insertions(+), 12 deletions(-) diff --git a/PlotLine/Views/WordCompanionHost/Index.cshtml b/PlotLine/Views/WordCompanionHost/Index.cshtml index 291d22c..8eaf9a7 100644 --- a/PlotLine/Views/WordCompanionHost/Index.cshtml +++ b/PlotLine/Views/WordCompanionHost/Index.cshtml @@ -28,14 +28,20 @@ } -
- Project - (Not connected) +
+ + +

-
- Book - (Not connected) +
+ + +

@@ -43,6 +49,47 @@ (Not connected)
+
+
+ Document Structure + +
+
+
+ Current Chapter + Not detected +
+
+ Current Scene + Not detected +
+
+ Current Scene Word Count + - +
+
+

+

+
+

No structure scanned.

+
+
+ +
+
+ Connection Status + @statusText +
+
+ Current Project + (Not connected) +
+
+ Current Book + (Not connected) +
+
+
Word Companion loading
@@ -50,4 +97,3 @@ - diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css index 50396e0..db3b6fc 100644 --- a/PlotLine/wwwroot/css/word-companion.css +++ b/PlotLine/wwwroot/css/word-companion.css @@ -54,6 +54,7 @@ body { .word-companion-status, .word-companion-user, +.word-companion-message, .word-companion-footer { margin: 0; color: var(--companion-muted); @@ -81,29 +82,139 @@ body { .word-companion-section { display: grid; - gap: 4px; + gap: 8px; border: 1px solid var(--companion-line); border-radius: 8px; padding: 12px; background: var(--companion-panel); } -.word-companion-section span { +.word-companion-section span, +.word-companion-section label, +.word-companion-summary span { color: var(--companion-muted); font-size: 0.76rem; font-weight: 800; text-transform: uppercase; } +.word-companion-section label { + display: block; +} + +.word-companion-section select { + width: 100%; + min-height: 36px; + border: 1px solid var(--companion-line); + border-radius: 6px; + padding: 6px 8px; + background: #ffffff; + color: var(--companion-ink); + font: inherit; +} + +.word-companion-section select:disabled { + background: var(--companion-soft); + color: var(--companion-muted); +} + +.word-companion-section-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.word-companion-section-header button { + min-height: 32px; + border: 1px solid var(--companion-accent); + border-radius: 6px; + padding: 5px 9px; + background: var(--companion-accent); + color: #ffffff; + font: inherit; + font-size: 0.78rem; + font-weight: 700; +} + +.word-companion-section-header button:disabled { + border-color: var(--companion-line); + background: var(--companion-soft); + color: var(--companion-muted); +} + +.word-companion-message:empty { + display: none; +} + .word-companion-section strong { min-width: 0; font-size: 0.98rem; overflow-wrap: anywhere; } +.word-companion-structure-grid { + display: grid; + gap: 8px; +} + +.word-companion-structure-grid div { + display: grid; + gap: 2px; +} + +.word-companion-outline { + display: grid; + gap: 8px; + border-top: 1px solid var(--companion-line); + padding-top: 8px; +} + +.word-companion-outline p { + margin: 0; + color: var(--companion-muted); + font-size: 0.9rem; +} + +.word-companion-outline-chapter { + display: grid; + gap: 4px; +} + +.word-companion-outline-chapter ul { + display: grid; + gap: 3px; + margin: 0; + padding-left: 18px; +} + +.word-companion-outline-chapter li { + color: var(--companion-ink); + font-size: 0.9rem; +} + +.word-companion-summary { + display: grid; + gap: 8px; + border: 1px solid rgba(47, 111, 99, 0.18); + border-radius: 8px; + padding: 12px; + background: var(--companion-soft); +} + +.word-companion-summary div { + display: grid; + gap: 2px; +} + +.word-companion-summary strong { + min-width: 0; + font-size: 0.92rem; + overflow-wrap: anywhere; +} + .word-companion-footer { border-top: 1px solid var(--companion-line); margin-top: auto; padding-top: 12px; } - diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js index 8c1dd91..15a0504 100644 --- a/PlotLine/wwwroot/js/word-companion-host.js +++ b/PlotLine/wwwroot/js/word-companion-host.js @@ -1,6 +1,31 @@ (() => { + 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) { @@ -12,20 +37,434 @@ 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(() => { + .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."); }); })(); -