Phase 10D manual document structure.

This commit is contained in:
Nick Beckley 2026-06-13 17:01:05 +01:00
parent bb774a5066
commit 2b77e85bbc
3 changed files with 608 additions and 12 deletions

View File

@ -28,14 +28,20 @@
} }
</header> </header>
<section class="word-companion-section" aria-label="Project"> <section class="word-companion-section" aria-label="Project selection">
<span>Project</span> <label for="word-companion-project">Project</label>
<strong>(Not connected)</strong> <select id="word-companion-project" data-project-select disabled>
<option value="">Loading projects...</option>
</select>
<p class="word-companion-message" data-project-message></p>
</section> </section>
<section class="word-companion-section" aria-label="Book"> <section class="word-companion-section" aria-label="Book selection">
<span>Book</span> <label for="word-companion-book">Book</label>
<strong>(Not connected)</strong> <select id="word-companion-book" data-book-select disabled>
<option value="">Select a project first</option>
</select>
<p class="word-companion-message" data-book-message></p>
</section> </section>
<section class="word-companion-section" aria-label="Current Scene"> <section class="word-companion-section" aria-label="Current Scene">
@ -43,6 +49,47 @@
<strong>(Not connected)</strong> <strong>(Not connected)</strong>
</section> </section>
<section class="word-companion-section word-companion-document" aria-label="Document Structure">
<div class="word-companion-section-header">
<span>Document Structure</span>
<button type="button" data-refresh-document>Refresh Document Structure</button>
</div>
<div class="word-companion-structure-grid">
<div>
<span>Current Chapter</span>
<strong data-current-chapter>Not detected</strong>
</div>
<div>
<span>Current Scene</span>
<strong data-current-scene>Not detected</strong>
</div>
<div>
<span>Current Scene Word Count</span>
<strong data-current-word-count>-</strong>
</div>
</div>
<p class="word-companion-message" data-document-message></p>
<p class="word-companion-message" data-sync-note></p>
<div class="word-companion-outline" data-document-outline>
<p>No structure scanned.</p>
</div>
</section>
<section class="word-companion-summary" aria-label="Connection summary">
<div>
<span>Connection Status</span>
<strong data-summary-connection>@statusText</strong>
</div>
<div>
<span>Current Project</span>
<strong data-summary-project>(Not connected)</strong>
</div>
<div>
<span>Current Book</span>
<strong data-summary-book>(Not connected)</strong>
</div>
</section>
<footer class="word-companion-footer"> <footer class="word-companion-footer">
<span data-office-status>Word Companion loading</span> <span data-office-status>Word Companion loading</span>
</footer> </footer>
@ -50,4 +97,3 @@
<script src="~/js/word-companion-host.js" asp-append-version="true"></script> <script src="~/js/word-companion-host.js" asp-append-version="true"></script>
</body> </body>
</html> </html>

View File

@ -54,6 +54,7 @@ body {
.word-companion-status, .word-companion-status,
.word-companion-user, .word-companion-user,
.word-companion-message,
.word-companion-footer { .word-companion-footer {
margin: 0; margin: 0;
color: var(--companion-muted); color: var(--companion-muted);
@ -81,29 +82,139 @@ body {
.word-companion-section { .word-companion-section {
display: grid; display: grid;
gap: 4px; gap: 8px;
border: 1px solid var(--companion-line); border: 1px solid var(--companion-line);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
background: var(--companion-panel); background: var(--companion-panel);
} }
.word-companion-section span { .word-companion-section span,
.word-companion-section label,
.word-companion-summary span {
color: var(--companion-muted); color: var(--companion-muted);
font-size: 0.76rem; font-size: 0.76rem;
font-weight: 800; font-weight: 800;
text-transform: uppercase; 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 { .word-companion-section strong {
min-width: 0; min-width: 0;
font-size: 0.98rem; font-size: 0.98rem;
overflow-wrap: anywhere; 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 { .word-companion-footer {
border-top: 1px solid var(--companion-line); border-top: 1px solid var(--companion-line);
margin-top: auto; margin-top: auto;
padding-top: 12px; padding-top: 12px;
} }

View File

@ -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 officeStatus = document.querySelector("[data-office-status]");
const connectionStatus = document.querySelector("[data-connection-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) => { const setOfficeStatus = (message) => {
if (officeStatus) { if (officeStatus) {
@ -12,20 +37,434 @@
if (connectionStatus) { if (connectionStatus) {
connectionStatus.textContent = message; 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") { if (!window.Office || typeof window.Office.onReady !== "function") {
setOfficeStatus("Word Companion ready"); setOfficeStatus("Word Companion ready");
setDocumentMessage("Word document access is unavailable.");
return; return;
} }
window.Office.onReady() window.Office.onReady()
.then(() => { .then((info) => {
officeReady = true;
wordHostAvailable = !!window.Word
&& !!window.Office.HostType
&& info?.host === window.Office.HostType.Word;
setOfficeStatus("Word Companion ready"); setOfficeStatus("Word Companion ready");
if (!wordHostAvailable) {
setDocumentMessage("Word document access is unavailable.");
}
}) })
.catch(() => { .catch(() => {
setConnectionStatus("Unable to connect to PlotDirector."); setConnectionStatus("Unable to connect to PlotDirector.");
setOfficeStatus("Word Companion unavailable"); setOfficeStatus("Word Companion unavailable");
setDocumentMessage("Word document access is unavailable.");
}); });
})(); })();