PlotDirector/PlotLine/wwwroot/js/word-companion-host.js

1814 lines
74 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 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 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 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 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 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) => {
detectedChapterTitle = chapterTitle || "";
detectedSceneTitle = sceneTitle || "";
detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : 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 canUseCreateLinkActions = () => !!selectedBook()
&& !!detectedSceneTitle
&& Number.isInteger(resolvedChapterId)
&& resolvedChapterId > 0;
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);
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 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("");
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
);
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 attachPlotDirectorIds = async () => {
await attachResolvedPlotDirectorIds();
};
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 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) {
storedAnchorInvalid = true;
currentResolutionAnchorType = "ChapterID Anchor";
setPlotDirectorSceneStatus("Not found in PlotDirector");
setDocumentMessage("Stored PlotDirector ID is invalid.");
setResolveDiagnostics({
result: "Error",
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) {
showSceneCreateLink(chapterMatched
? "Scene not found in PlotDirector."
: "This chapter does not exist in PlotDirector. Create or link the chapter first.");
}
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) {
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);
saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
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"
});
});
})();