PlotDirector/PlotLine/wwwroot/js/word-companion-host.js
2026-06-13 20:38:31 +01:00

1029 lines
40 KiB
JavaScript

(() => {
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 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 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 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 resolvedSceneId = 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 setCurrentStructure = (chapterTitle, sceneTitle, wordCount) => {
detectedChapterTitle = chapterTitle || "";
detectedSceneTitle = sceneTitle || "";
detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : 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) : "-");
};
const setHidden = (element, hidden) => {
if (element) {
element.hidden = hidden;
}
};
const setSyncStatus = (message) => {
setText(syncStatus, message);
};
const setResolvedScene = (sceneId) => {
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId;
}
setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
};
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 resetPlotDirectorScene = (message) => {
setPlotDirectorSceneStatus(message);
setHidden(plotDirectorSceneDetails, true);
setHidden(writingBriefBlock, true);
setHidden(plotDirectorLinkGroups, true);
setResolvedScene(null);
setText(syncPlotDirectorCount, "-");
setText(plotDirectorSceneTitle, "-");
setText(plotDirectorRevisionStatus, "-");
setText(plotDirectorEstimatedWords, "-");
setText(plotDirectorActualWords, "-");
setText(plotDirectorBlockedStatus, "-");
setText(plotDirectorBlockedReason, "-");
setHidden(plotDirectorBlockedReasonRow, true);
};
const renderLinkedItems = (container, items) => {
if (!container) {
return;
}
container.innerHTML = "";
const linkedItems = Array.isArray(items) ? items.filter((item) => item.linked) : [];
if (linkedItems.length === 0) {
const empty = document.createElement("span");
empty.className = "word-companion-empty-chip";
empty.textContent = "None";
container.append(empty);
return;
}
for (const item of linkedItems) {
const chip = document.createElement("span");
chip.className = "word-companion-chip";
chip.textContent = item.name || "Unnamed";
container.append(chip);
}
};
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.";
}
renderLinkedItems(characterChips, data?.characters);
renderLinkedItems(assetChips, data?.assets);
renderLinkedItems(locationChips, data?.locations);
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 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 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,
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,
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 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) => {
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
);
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 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 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
});
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...");
let resolvedSceneId = null;
try {
const resolveResponse = await postJson(`/api/word-companion/books/${book.bookId}/resolve-scene`, {
chapterTitle: requestChapterTitle,
sceneTitle: requestSceneTitle
});
if (!resolveResponse?.matched || !resolveResponse.sceneId) {
resetPlotDirectorScene("Not found in PlotDirector");
setDocumentMessage("Scene not found in PlotDirector.");
setResolveDiagnostics({
result: "Not matched",
chapterId: resolveResponse?.chapterId,
sceneId: resolveResponse?.sceneId
});
restorePlotDirectorSceneButton();
return false;
}
resolvedSceneId = resolveResponse.sceneId;
setResolveDiagnostics({
result: "Matched",
chapterId: resolveResponse.chapterId,
sceneId: resolveResponse.sceneId
});
} catch {
resetPlotDirectorScene("Not found in PlotDirector");
setDocumentMessage("Scene not found in PlotDirector.");
setResolveDiagnostics({ result: "Error" });
restorePlotDirectorSceneButton();
return false;
}
try {
const companionData = await fetchJson(`/api/word-companion/scenes/${resolvedSceneId}/companion`);
try {
companionData.revisionStatus = await findSceneRevisionStatus(book.bookId, resolvedSceneId);
} catch {
companionData.revisionStatus = "";
}
setPlotDirectorSceneStatus("Linked to PlotDirector");
setDocumentMessage("");
renderCompanionData(companionData);
setResolvedScene(resolvedSceneId);
return true;
} catch {
resetPlotDirectorScene("Not found in PlotDirector");
setDocumentMessage("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) {
resetPlotDirectorScene("Not detected");
setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes.");
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);
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"
});
});
})();