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

5778 lines
237 KiB
JavaScript

(() => {
const storageKeys = {
projectId: "plotdirector.word.projectId",
bookId: "plotdirector.word.bookId",
activeTab: "plotdirector.word.activeTab"
};
const documentMetadataKeys = {
documentGuid: "plotdirector.word.documentGuid",
bookId: "plotdirector.word.boundBookId",
projectId: "plotdirector.word.boundProjectId",
bindingVersion: "plotdirector.word.bindingVersion"
};
const validTabNames = new Set(["scene", "review", "maintenance", "diagnostics"]);
const shell = document.querySelector(".word-companion-shell");
const tabsNav = document.querySelector(".word-companion-tabs");
const tabButtons = [...document.querySelectorAll("[data-tab-button]")];
const tabPanels = [...document.querySelectorAll("[data-tab-panel]")];
const linkedManuscriptCard = document.querySelector("[data-linked-manuscript-card]");
const linkedProjectTitle = document.querySelector("[data-linked-project-title]");
const linkedBookTitle = document.querySelector("[data-linked-book-title]");
const firstRunWizard = document.querySelector("[data-first-run-wizard]");
const firstRunProjectSelect = document.querySelector("[data-first-run-project-select]");
const firstRunBookSelect = document.querySelector("[data-first-run-book-select]");
const firstRunNewBookTitle = document.querySelector("[data-first-run-new-book-title]");
const firstRunCreateBookButton = document.querySelector("[data-first-run-create-book]");
const firstRunScanButton = document.querySelector("[data-first-run-scan]");
const firstRunCancelButton = document.querySelector("[data-first-run-cancel]");
const firstRunStatus = document.querySelector("[data-first-run-status]");
const firstRunSummary = document.querySelector("[data-first-run-summary]");
const firstRunCharacterSection = document.querySelector("[data-first-run-character-section]");
const firstRunCharacterStatus = document.querySelector("[data-first-run-character-status]");
const firstRunCharacterCandidates = document.querySelector("[data-first-run-character-candidates]");
const firstRunCharacterActions = document.querySelector("[data-first-run-character-actions]");
const firstRunCreateCharactersButton = document.querySelector("[data-first-run-create-characters]");
const firstRunSkipCharactersButton = document.querySelector("[data-first-run-skip-characters]");
const firstRunContinueButton = document.querySelector("[data-first-run-continue]");
const firstRunImportActions = document.querySelector("[data-first-run-import-actions]");
const firstRunImportButton = document.querySelector("[data-first-run-import]");
const firstRunImportCancelButton = document.querySelector("[data-first-run-import-cancel]");
const firstRunReplaceDialog = document.querySelector("[data-first-run-replace-dialog]");
const firstRunReplaceConfirm = document.querySelector("[data-first-run-replace-confirm]");
const firstRunReplaceCancel = document.querySelector("[data-first-run-replace-cancel]");
const unlinkWordDocumentDialog = document.querySelector("[data-unlink-word-document-dialog]");
const unlinkWordDocumentConfirm = document.querySelector("[data-unlink-word-document-confirm]");
const unlinkWordDocumentCancel = document.querySelector("[data-unlink-word-document-cancel]");
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 runtimeBookSelectors = document.querySelector("[data-runtime-book-selectors]");
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 sceneTrackingStatus = document.querySelector("[data-scene-tracking-status]");
const documentMessage = document.querySelector("[data-document-message]");
const syncNote = document.querySelector("[data-sync-note]");
const documentOutline = document.querySelector("[data-document-outline]");
const analyseManuscriptStructureButton = document.querySelector("[data-analyse-manuscript-structure]");
const applyStructureSyncButton = document.querySelector("[data-apply-structure-sync]");
const structurePreviewStatus = document.querySelector("[data-structure-preview-status]");
const structurePreviewResults = document.querySelector("[data-structure-preview-results]");
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 unlinkWordDocumentButton = document.querySelector("[data-unlink-word-document]");
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 runtimeLastSync = document.querySelector("[data-runtime-last-sync]");
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 characterReviewSummary = document.querySelector("[data-character-review-summary]");
const assetReviewSummary = document.querySelector("[data-asset-review-summary]");
const locationReviewSummary = document.querySelector("[data-location-review-summary]");
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 linksEditingScene = document.querySelector("[data-links-editing-scene]");
const chapterCreateLink = document.querySelector("[data-chapter-create-link]");
const chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]");
const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]");
const linkExistingChapterButton = document.querySelector("[data-link-existing-chapter]");
const chapterCreateLinkStatus = document.querySelector("[data-chapter-create-link-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 linkExistingChapterDialog = document.querySelector("[data-link-existing-chapter-dialog]");
const linkChapterSearch = document.querySelector("[data-link-chapter-search]");
const linkChapterOptions = document.querySelector("[data-link-chapter-options]");
const linkSelectedChapterButton = document.querySelector("[data-link-selected-chapter]");
const linkChapterDialogCancel = document.querySelector("[data-link-chapter-dialog-cancel]");
const linkChapterDialogStatus = document.querySelector("[data-link-chapter-dialog-status]");
const createChapterDialog = document.querySelector("[data-create-chapter-dialog]");
const createChapterDialogChapter = document.querySelector("[data-create-chapter-dialog-chapter]");
const createChapterDialogBook = document.querySelector("[data-create-chapter-dialog-book]");
const createChapterDialogConfirm = document.querySelector("[data-create-chapter-dialog-confirm]");
const createChapterDialogCancel = document.querySelector("[data-create-chapter-dialog-cancel]");
const applyStructureSyncDialog = document.querySelector("[data-apply-structure-sync-dialog]");
const applyStructureSyncSummary = document.querySelector("[data-apply-structure-sync-summary]");
const applyStructureSyncConfirm = document.querySelector("[data-apply-structure-sync-confirm]");
const applyStructureSyncCancel = document.querySelector("[data-apply-structure-sync-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";
const userId = Number.parseInt(shell?.dataset.userId || "", 10);
const companionVersion = String(shell?.dataset.companionVersion || "20C").trim();
let projects = [];
let books = [];
let officeReady = false;
let wordHostAvailable = false;
let officeHost = "Unknown";
let officePlatform = "Unknown";
let detectedChapterTitle = "";
let detectedSceneTitle = "";
let detectedSceneWordCount = null;
let detectedChapterIndex = 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 linkDialogChapters = [];
let selectedLinkChapterId = null;
let pendingCreateChapterConfirmation = null;
let structurePreviewModel = null;
let firstRunDocumentBinding = null;
let runtimeBookContext = null;
let documentStructureCache = null;
let plotDirectorStructureCache = null;
let plotDirectorStructureBookId = null;
let firstRunAnalysis = null;
let firstRunImportStructure = null;
let firstRunCharacterCandidatesModel = [];
let lastOnboardingScanPreview = null;
let firstRunStructureImported = false;
let firstRunCharacterStepActive = false;
let firstRunCharacterStepActivatedAt = 0;
let isFirstRunImporting = false;
let isCreatingFirstRunCharacters = false;
let isApplyingStructureSync = false;
let pendingApplyStructureSyncConfirmation = null;
let sceneTrackingMode = "Manual";
let sceneContextStale = false;
let selectionRefreshTimer = null;
let runtimeSelectionDirty = false;
let runtimeLastActivityAt = 0;
let runtimeUpdatePending = false;
let runtimeGeneration = 0;
let lastCurrentSceneSnapshot = null;
const runtimeIdleDelayMs = 1200;
let selectionHandlerRegistered = false;
let isAutoRefreshingScene = false;
let sceneWordCountSyncTimer = null;
let sceneWordCountSyncInFlight = false;
let sceneWordCountSyncPending = false;
let lastSyncedSceneId = null;
let lastSyncedWordCount = null;
let characterSuggestionTimer = null;
let characterSuggestionInFlight = false;
let characterSuggestionPending = false;
let characterAliasCacheProjectId = null;
let characterAliasCacheDocumentGuid = "";
let characterAliasCache = [];
let lastSuggestedSceneId = null;
let lastSuggestedCharacterSignature = "";
let assetAliasCacheProjectId = null;
let assetAliasCacheDocumentGuid = "";
let assetAliasCache = [];
let lastSuggestedAssetSceneId = null;
let lastSuggestedAssetSignature = "";
let locationAliasCacheProjectId = null;
let locationAliasCacheDocumentGuid = "";
let locationAliasCache = [];
let lastSuggestedLocationSceneId = null;
let lastSuggestedLocationSignature = "";
let lastFollowSceneNotificationSignature = "";
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
let runtimeLastDebugAt = 0;
let companionPresenceConnection = null;
let companionPresenceHeartbeatTimer = null;
let currentSceneLinks = {
characters: [],
assets: [],
locations: [],
primaryLocationId: null
};
const runtimeDebug = (message) => {
const now = Date.now();
if (now - runtimeLastDebugAt < 1000) {
return;
}
runtimeLastDebugAt = now;
console.debug(`[Word Companion Runtime] ${message}`);
};
const runtimeTiming = (message) => {
console.debug(`[Word Companion Timing] ${message}`);
};
const setOfficeStatus = (message) => {
if (officeStatus) {
officeStatus.textContent = message;
}
};
const readStoredText = (key) => {
try {
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const writeStoredText = (key, value) => {
try {
if (value) {
window.localStorage.setItem(key, String(value));
} else {
window.localStorage.removeItem(key);
}
} catch {
// Some Office hosts can restrict storage. The pane still falls back to the Scene tab.
}
};
const normalizeTabName = (tabName) => {
if (tabName === "links") {
return "review";
}
if (tabName === "structure") {
return "maintenance";
}
return tabName;
};
const selectTab = (tabName, persist = true) => {
const requestedTab = normalizeTabName(tabName);
const targetTab = validTabNames.has(requestedTab) && tabButtons.some((button) => button.dataset.tabButton === requestedTab)
? requestedTab
: "scene";
for (const button of tabButtons) {
const active = button.dataset.tabButton === targetTab;
button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false");
}
for (const panel of tabPanels) {
const active = panel.dataset.tabPanel === targetTab;
panel.classList.toggle("is-active", active);
panel.hidden = !active;
}
if (persist) {
writeStoredText(storageKeys.activeTab, targetTab);
}
};
const restoreActiveTab = () => {
const storedTab = normalizeTabName(readStoredText(storageKeys.activeTab));
selectTab(validTabNames.has(storedTab) ? storedTab : "scene", false);
};
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) {
setHidden(attachPlotDirectorIdsButton, attached && !storedAnchorInvalid);
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, chapterIndex = null) => {
const nextChapterTitle = chapterTitle || "";
const nextSceneTitle = sceneTitle || "";
const nextWordCount = Number.isInteger(wordCount) ? wordCount : null;
const nextChapterIndex = Number.isInteger(chapterIndex) && chapterIndex > 0 ? chapterIndex : null;
const nextChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null;
const nextSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null;
const unchanged = detectedChapterTitle === nextChapterTitle
&& detectedSceneTitle === nextSceneTitle
&& detectedSceneWordCount === nextWordCount
&& detectedChapterIndex === nextChapterIndex
&& detectedChapterAnchorId === nextChapterAnchorId
&& detectedSceneAnchorId === nextSceneAnchorId;
detectedChapterTitle = chapterTitle || "";
detectedSceneTitle = sceneTitle || "";
detectedSceneWordCount = Number.isInteger(wordCount) ? wordCount : null;
detectedChapterIndex = Number.isInteger(chapterIndex) && chapterIndex > 0 ? chapterIndex : null;
detectedChapterAnchorId = Number.isInteger(chapterAnchorId) && chapterAnchorId > 0 ? chapterAnchorId : null;
detectedSceneAnchorId = Number.isInteger(sceneAnchorId) && sceneAnchorId > 0 ? sceneAnchorId : null;
if (unchanged) {
return;
}
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 clearPendingSceneWordCountSync = () => {
if (sceneWordCountSyncTimer) {
window.clearTimeout(sceneWordCountSyncTimer);
sceneWordCountSyncTimer = null;
}
};
const setHidden = (element, hidden) => {
if (element) {
element.hidden = hidden;
}
};
const updateUnlinkWordDocumentButton = () => {
setHidden(unlinkWordDocumentButton, !firstRunDocumentBinding);
};
const setSyncStatus = (message) => {
setText(syncStatus, message);
};
const setStructurePreviewStatus = (message) => {
setText(structurePreviewStatus, message);
};
const resetStructurePreview = (message = "Select a project and book to analyse manuscript structure.") => {
structurePreviewModel = null;
if (structurePreviewResults) {
structurePreviewResults.innerHTML = "";
const empty = document.createElement("p");
empty.textContent = "No preview generated.";
structurePreviewResults.append(empty);
}
setStructurePreviewStatus(message);
updateApplyStructureSyncButton();
};
const previewItems = (model = structurePreviewModel) => previewGroups.flatMap((group) =>
Array.isArray(model?.[group.key]) ? model[group.key] : []);
const actionablePreviewItems = (model = structurePreviewModel) => previewItems(model)
.filter((item) => item.category === "couldLink" || item.category === "wouldCreateChapters" || item.category === "wouldCreateScenes");
const updateApplyStructureSyncButton = () => {
if (applyStructureSyncButton) {
applyStructureSyncButton.disabled = isApplyingStructureSync || actionablePreviewItems().length === 0;
}
};
const setAnalyseButtonEnabled = () => {
if (analyseManuscriptStructureButton) {
analyseManuscriptStructureButton.disabled = isApplyingStructureSync || !selectedProject() || !selectedBook();
}
updateApplyStructureSyncButton();
};
const updateLinksEditingScene = () => {
if (!linksEditingScene) {
return;
}
linksEditingScene.textContent = resolvedSceneId
? `Editing links for: ${lastLinkedSceneTitle || detectedSceneTitle || "Current scene"}`
: "Link a PlotDirector scene first.";
};
const updateSceneActionButtons = () => {
const blocked = sceneContextStale || !resolvedSceneId;
const syncBlocked = blocked || !resolvedChapterId || sceneWordCountSyncInFlight;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = syncBlocked;
}
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = blocked;
}
if (primaryLocationSelect) {
primaryLocationSelect.disabled = blocked;
}
for (const container of [characterChips, assetChips]) {
for (const checkbox of container ? [...container.querySelectorAll("input[type='checkbox']")] : []) {
checkbox.disabled = blocked;
}
}
};
const setSceneTrackingState = (status) => {
if (status === "Live" || status === "Manual") {
sceneTrackingMode = status;
}
setText(sceneTrackingStatus, status || sceneTrackingMode || "Manual");
};
const setSceneContextStale = (isStale, message = "") => {
sceneContextStale = !!isStale;
if (sceneContextStale) {
clearPendingSceneWordCountSync();
clearPendingCharacterSuggestionDetection();
}
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
updateSceneActionButtons();
if (message) {
setDocumentMessage(message);
setSceneLinksStatus(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 (resolvedSceneId !== lastSyncedSceneId) {
lastSyncedWordCount = null;
}
if (!resolvedSceneId) {
clearPendingSceneWordCountSync();
clearPendingCharacterSuggestionDetection();
}
updateSceneActionButtons();
setSyncStatus(sceneContextStale
? "Refresh Current Scene before syncing."
: resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
setSceneLinksStatus(sceneContextStale
? "Refresh Current Scene before saving."
: resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first.");
updateLinksEditingScene();
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 formatDateTime = (value) => {
if (!value) {
return "-";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
};
const formatLastSynced = (value) => {
if (!value) {
return "-";
}
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return "-";
}
const today = new Date();
const dayLabel = date.toDateString() === today.toDateString()
? "Today"
: date.toLocaleDateString();
return `Last synced: ${dayLabel} ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
};
const setRuntimeBookContext = (context) => {
runtimeBookContext = context || null;
const lastSyncUtc = runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc;
setText(runtimeLastSync, formatLastSynced(lastSyncUtc));
setText(syncLastSynced, formatLastSynced(lastSyncUtc));
updateLinkedManuscriptUi();
};
const clearRuntimeCaches = () => {
documentStructureCache = null;
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
runtimeGeneration += 1;
lastCurrentSceneSnapshot = null;
resetRuntimeCharacterAliasCache();
resetRuntimeAssetAliasCache();
resetRuntimeLocationAliasCache();
setRuntimeBookContext(null);
};
const clearPlotDirectorStructureCache = () => {
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
};
const setSceneLinksStatus = (message) => {
setText(sceneLinksStatus, message);
};
const setCreateLinkStatus = (message) => {
setText(createLinkStatus, message);
};
const setChapterCreateLinkStatus = (message) => {
setText(chapterCreateLinkStatus, message);
};
const canUseChapterCreateLinkActions = () => !!selectedBook()
&& !!detectedChapterTitle
&& !resolvedChapterId;
const canUseCreateLinkActions = () => !!selectedBook()
&& !!detectedSceneTitle
&& Number.isInteger(resolvedChapterId)
&& resolvedChapterId > 0;
const hideChapterCreateLink = () => {
setHidden(chapterCreateLink, true);
setChapterCreateLinkStatus("");
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = true;
createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector";
}
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = true;
linkExistingChapterButton.textContent = "Link to Existing Chapter";
}
};
const showChapterCreateLink = (message = "") => {
setText(chapterCreateLinkChapter, displayValue(detectedChapterTitle));
setHidden(chapterCreateLink, false);
setChapterCreateLinkStatus(message);
const enabled = canUseChapterCreateLinkActions();
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = !enabled;
createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector";
}
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = !enabled;
linkExistingChapterButton.textContent = "Link to Existing Chapter";
}
};
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, false);
hideChapterCreateLink();
hideSceneCreateLink();
currentResolutionMethod = "";
lastLinkedSceneTitle = "";
lastLinkedChapterTitle = "";
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);
updateReviewSummaries();
setResolvedScene(null);
setText(syncPlotDirectorCount, "-");
setText(plotDirectorSceneTitle, "-");
setText(plotDirectorRevisionStatus, "-");
setText(plotDirectorEstimatedWords, "-");
setText(plotDirectorActualWords, "-");
setText(plotDirectorBlockedStatus, "-");
setText(plotDirectorBlockedReason, "-");
setHidden(plotDirectorBlockedReasonRow, true);
updateAnchorDisplay();
updateLinksEditingScene();
};
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) => {
lastLinkedSceneTitle = data?.sceneTitle || detectedSceneTitle || "";
lastLinkedChapterTitle = data?.chapterTitle || detectedChapterTitle || "";
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 && !sceneContextStale);
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId && !sceneContextStale);
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId && !sceneContextStale);
updateReviewSummaries();
updateLinksEditingScene();
setSceneLinksStatus(sceneContextStale
? "Refresh Current Scene before saving."
: 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) => {
if (plotDirectorStructureBookId === bookId && Array.isArray(plotDirectorStructureCache?.chapters)) {
return plotDirectorStructureCache.chapters;
}
const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`);
plotDirectorStructureBookId = bookId;
plotDirectorStructureCache = structure || null;
return Array.isArray(structure?.chapters) ? structure.chapters : [];
};
const fetchBookChapters = async (bookId) => {
const response = await fetchJson(`/api/word-companion/books/${bookId}/chapters`);
return Array.isArray(response?.chapters) ? response.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;
setSceneContextStale(false);
setPlotDirectorSceneStatus("Linked to PlotDirector");
setDocumentMessage("");
hideChapterCreateLink();
hideSceneCreateLink();
setResolvedScene(sceneId, chapterId, resolutionMethod);
renderCompanionData(companionData);
notifyPlotDirectorCurrentScene(sceneId, chapterId);
scheduleSceneWordCountSync();
scheduleCharacterSuggestionDetection();
};
const setText = (element, value) => {
if (element) {
const nextValue = value === null || value === undefined ? "" : String(value);
if (element.textContent !== nextValue) {
element.textContent = nextValue;
}
}
};
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 bookDisplayTitle = (book) => {
const displayTitle = String(book?.displayTitle || "").trim();
if (displayTitle) {
return displayTitle;
}
const title = String(book?.title || "").trim();
const subtitle = String(book?.subtitle || "").trim();
return subtitle ? `${title}: ${subtitle}` : title;
};
const runtimeProjectTitle = () =>
runtimeBookContext?.projectTitle
|| selectedProject()?.title
|| "Project";
const runtimeBookTitle = () =>
runtimeBookContext?.bookTitle
|| bookDisplayTitle(selectedBook())
|| "Book";
const updateLinkedManuscriptUi = () => {
const linked = !!firstRunDocumentBinding;
setHidden(linkedManuscriptCard, !linked);
setHidden(runtimeBookSelectors, linked);
if (linked) {
setText(linkedProjectTitle, runtimeProjectTitle());
setText(linkedBookTitle, runtimeBookTitle());
}
};
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 selectedFirstRunProject = () => {
const projectId = Number.parseInt(firstRunProjectSelect?.value || "", 10);
return projects.find((project) => project.projectId === projectId) || null;
};
const selectedFirstRunBook = () => {
const bookId = Number.parseInt(firstRunBookSelect?.value || "", 10);
return books.find((book) => book.bookId === bookId) || null;
};
const selectedPresenceProjectId = () => {
const projectId = firstRunDocumentBinding?.projectId
|| selectedFirstRunProject()?.projectId
|| selectedProject()?.projectId
|| readStoredId(storageKeys.projectId);
return Number.isInteger(projectId) && projectId > 0 ? projectId : null;
};
const selectedPresenceBookId = () => {
const bookId = firstRunDocumentBinding?.bookId
|| selectedFirstRunBook()?.bookId
|| selectedBook()?.bookId
|| readStoredId(storageKeys.bookId);
return Number.isInteger(bookId) && bookId > 0 ? bookId : null;
};
const currentDocumentName = () => {
const url = String(window.Office?.context?.document?.url || "").trim();
if (!url) {
return "";
}
const lastSegment = url.split(/[\\/]/).filter(Boolean).pop() || "";
try {
return decodeURIComponent(lastSegment);
} catch {
return lastSegment;
}
};
const companionPresencePayload = () => ({
userID: Number.isInteger(userId) && userId > 0 ? userId : null,
companionVersion,
machineName: "",
documentOpen: !!wordHostAvailable,
currentDocumentName: currentDocumentName(),
linkedProjectID: selectedPresenceProjectId(),
linkedBookID: selectedPresenceBookId()
});
const sendCompanionHeartbeat = async () => {
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
return;
}
await companionPresenceConnection.invoke("CompanionHeartbeat", companionPresencePayload());
};
const refreshCompanionPresence = () => {
sendCompanionHeartbeat().catch((error) => {
console.debug("Word Companion heartbeat unavailable.", error);
});
};
const startCompanionPresence = async () => {
if (!isAuthenticated || !window.signalR || companionPresenceConnection) {
return;
}
companionPresenceConnection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
companionPresenceConnection.onreconnected(refreshCompanionPresence);
companionPresenceConnection.on("ScanCurrentDocument", (command) => {
void runOnboardingDocumentScan(command);
});
companionPresenceConnection.on("UpdateOnboardingBuildMarkers", (result) => {
void updateOnboardingBuildMarkers(result);
});
companionPresenceConnection.onclose(() => {
if (companionPresenceHeartbeatTimer) {
window.clearInterval(companionPresenceHeartbeatTimer);
companionPresenceHeartbeatTimer = null;
}
});
await companionPresenceConnection.start();
await companionPresenceConnection.invoke("RegisterCompanion", companionPresencePayload());
companionPresenceHeartbeatTimer = window.setInterval(refreshCompanionPresence, 25000);
};
window.addEventListener("beforeunload", () => {
if (companionPresenceHeartbeatTimer) {
window.clearInterval(companionPresenceHeartbeatTimer);
companionPresenceHeartbeatTimer = null;
}
if (companionPresenceConnection) {
companionPresenceConnection.stop().catch(() => {});
}
});
const setFirstRunStatus = (message) => setText(firstRunStatus, message);
const updateFirstRunButtons = () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
const characterStepReady = firstRunCharacterStepActive && Date.now() - firstRunCharacterStepActivatedAt >= 750;
if (firstRunScanButton) {
firstRunScanButton.disabled = !project || !book || isFirstRunImporting;
}
if (firstRunCreateBookButton) {
firstRunCreateBookButton.disabled = !project || !String(firstRunNewBookTitle?.value || "").trim() || isFirstRunImporting;
}
if (firstRunImportButton) {
firstRunImportButton.disabled = !firstRunAnalysis?.canImport || !firstRunImportStructure || isFirstRunImporting;
}
if (firstRunCreateCharactersButton) {
firstRunCreateCharactersButton.disabled = !characterStepReady
|| isCreatingFirstRunCharacters
|| selectedFirstRunCharacterNames().length === 0;
}
};
const setFirstRunMode = (visible) => {
setHidden(firstRunWizard, !visible);
setHidden(tabsNav, visible);
updateUnlinkWordDocumentButton();
updateLinkedManuscriptUi();
for (const panel of tabPanels) {
setHidden(panel, visible ? true : panel.dataset.tabPanel !== "scene" && !panel.classList.contains("is-active"));
}
if (!visible) {
restoreActiveTab();
}
};
const syncFirstRunProjectOptions = () => {
if (!firstRunProjectSelect) {
return;
}
if (projects.length === 0) {
resetSelect(firstRunProjectSelect, "No projects available.");
return;
}
addOptions(firstRunProjectSelect, "Choose a project", projects, "projectId", "title");
const currentProjectId = Number.parseInt(projectSelect?.value || "", 10);
if (Number.isInteger(currentProjectId) && currentProjectId > 0) {
firstRunProjectSelect.value = String(currentProjectId);
}
};
const syncFirstRunBookOptions = (preferredBookId = null) => {
if (!firstRunBookSelect) {
return;
}
if (books.length === 0) {
resetSelect(firstRunBookSelect, "No books available.");
return;
}
addOptions(firstRunBookSelect, "Choose a book", books.map((book) => ({
...book,
displayTitle: bookDisplayTitle(book)
})), "bookId", "displayTitle");
const currentBookId = preferredBookId || Number.parseInt(bookSelect?.value || "", 10);
const validBook = books.find((book) => book.bookId === currentBookId);
if (validBook) {
firstRunBookSelect.value = String(validBook.bookId);
}
updateFirstRunButtons();
};
const resetFirstRunPreview = (message = "Select a Project and Book to begin.") => {
firstRunAnalysis = null;
firstRunImportStructure = null;
firstRunCharacterCandidatesModel = [];
firstRunStructureImported = false;
firstRunCharacterStepActive = false;
firstRunCharacterStepActivatedAt = 0;
setHidden(firstRunSummary, true);
setHidden(firstRunImportActions, true);
setHidden(firstRunCharacterSection, true);
setHidden(firstRunCharacterActions, true);
if (firstRunSummary) {
firstRunSummary.innerHTML = "";
}
if (firstRunCharacterCandidates) {
firstRunCharacterCandidates.innerHTML = "";
}
setText(firstRunCharacterStatus, "");
setFirstRunStatus(message);
updateFirstRunButtons();
};
const selectedFirstRunCharacterNames = () => {
if (!firstRunCharacterCandidates) {
return [];
}
return [...firstRunCharacterCandidates.querySelectorAll("input[type='checkbox']:checked")]
.map((checkbox) => String(checkbox.value || "").trim())
.filter(Boolean);
};
const renderFirstRunCharacterCandidates = (candidates) => {
firstRunCharacterCandidatesModel = Array.isArray(candidates) ? candidates : [];
if (!firstRunCharacterSection || !firstRunCharacterCandidates) {
return;
}
firstRunCharacterCandidates.innerHTML = "";
setHidden(firstRunCharacterSection, false);
setHidden(firstRunCharacterActions, !firstRunStructureImported || firstRunCharacterCandidatesModel.length === 0);
setHidden(firstRunCreateCharactersButton, false);
setHidden(firstRunSkipCharactersButton, false);
setHidden(firstRunContinueButton, true);
if (firstRunCharacterCandidatesModel.length === 0) {
setText(firstRunCharacterStatus, "No likely major characters found.");
updateFirstRunButtons();
return;
}
setText(firstRunCharacterStatus, `${firstRunCharacterCandidatesModel.length} likely major characters found.`);
for (const candidate of firstRunCharacterCandidatesModel) {
const label = document.createElement("label");
label.className = "word-companion-character-candidate";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.value = candidate.text || "";
checkbox.checked = String(candidate.confidence || "").trim().toLowerCase() === "high";
checkbox.addEventListener("change", updateFirstRunButtons);
const name = document.createElement("strong");
name.textContent = candidate.text || "";
const mentions = document.createElement("em");
const reason = String(candidate.reason || "").trim();
mentions.textContent = reason
? `${Number(candidate.mentionCount || 0).toLocaleString()} mentions · ${reason}`
: `${Number(candidate.mentionCount || 0).toLocaleString()} mentions`;
const text = document.createElement("span");
text.className = "word-companion-character-candidate-text";
text.append(name, mentions);
label.append(checkbox, text);
firstRunCharacterCandidates.append(label);
}
updateFirstRunButtons();
};
const renderFirstRunSummary = (analysis) => {
if (!firstRunSummary) {
return;
}
firstRunSummary.innerHTML = "";
const title = document.createElement("strong");
title.textContent = "Manuscript Analysis";
firstRunSummary.append(title);
const list = document.createElement("dl");
const rows = [
["Project", analysis.projectTitle],
["Book", bookDisplayTitle({ title: analysis.bookTitle, subtitle: analysis.bookSubtitle })],
["Chapters", Number(analysis.chapterCount || 0).toLocaleString()],
["Scenes", Number(analysis.sceneCount || 0).toLocaleString()],
["Words", Number(analysis.wordCount || 0).toLocaleString()]
];
for (const [label, value] of rows) {
const term = document.createElement("dt");
term.textContent = label;
const description = document.createElement("dd");
description.textContent = value || "-";
list.append(term, description);
}
firstRunSummary.append(list);
setHidden(firstRunSummary, false);
setHidden(firstRunImportActions, false);
setFirstRunStatus(analysis.message || "Preview generated.");
updateFirstRunButtons();
};
const readDocumentBinding = () => {
const settings = window.Office?.context?.document?.settings;
if (!settings) {
return null;
}
const documentGuid = String(settings.get(documentMetadataKeys.documentGuid) || "").trim();
const bookId = Number.parseInt(settings.get(documentMetadataKeys.bookId) || "", 10);
const projectId = Number.parseInt(settings.get(documentMetadataKeys.projectId) || "", 10);
const bindingVersion = Number.parseInt(settings.get(documentMetadataKeys.bindingVersion) || "", 10);
if (!documentGuid || !Number.isInteger(bookId) || bookId <= 0 || !Number.isInteger(projectId) || projectId <= 0) {
return null;
}
return {
documentGuid,
bookId,
projectId,
bindingVersion: Number.isInteger(bindingVersion) && bindingVersion > 0 ? bindingVersion : 1
};
};
const currentDocumentBinding = () => {
const binding = firstRunDocumentBinding || readDocumentBinding();
if (binding) {
return binding;
}
const documentGuid = runtimeBookContext?.manuscriptDocument?.documentGuid || "";
const bookId = Number.parseInt(runtimeBookContext?.bookId || "", 10);
const projectId = Number.parseInt(runtimeBookContext?.projectId || "", 10);
if (documentGuid && Number.isInteger(bookId) && bookId > 0 && Number.isInteger(projectId) && projectId > 0) {
return {
documentGuid,
bookId,
projectId,
bindingVersion: Number.parseInt(runtimeBookContext?.manuscriptDocument?.bindingVersion || "", 10) || 1
};
}
return null;
};
const writeDocumentBinding = (binding) => new Promise((resolve, reject) => {
const settings = window.Office?.context?.document?.settings;
if (!settings) {
reject(new Error("Word document settings are unavailable."));
return;
}
settings.set(documentMetadataKeys.documentGuid, binding.documentGuid);
settings.set(documentMetadataKeys.bookId, String(binding.bookId));
settings.set(documentMetadataKeys.projectId, String(binding.projectId));
settings.set(documentMetadataKeys.bindingVersion, String(binding.bindingVersion || 1));
settings.saveAsync((result) => {
if (result.status === window.Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(result.error || new Error("Unable to save Word document metadata."));
}
});
});
const removeDocumentBinding = () => new Promise((resolve, reject) => {
const settings = window.Office?.context?.document?.settings;
if (!settings) {
reject(new Error("Word document settings are unavailable."));
return;
}
for (const key of Object.values(documentMetadataKeys)) {
if (typeof settings.remove === "function") {
settings.remove(key);
} else {
settings.set(key, "");
}
}
settings.saveAsync((result) => {
if (result.status === window.Office.AsyncResultStatus.Succeeded) {
resolve();
} else {
reject(result.error || new Error("Unable to remove Word document metadata."));
}
});
});
const ensureDocumentGuid = () => {
if (firstRunDocumentBinding?.documentGuid) {
return firstRunDocumentBinding.documentGuid;
}
if (window.crypto?.randomUUID) {
return window.crypto.randomUUID();
}
return "10000000-1000-4000-8000-100000000000".replace(/[018]/g, (char) =>
(Number(char) ^ Math.random() * 16 >> Number(char) / 4).toString(16));
};
const currentDocumentGuid = () => firstRunDocumentBinding?.documentGuid || readDocumentBinding()?.documentGuid || "";
const updateSummary = () => {
const project = selectedProject();
const book = selectedBook();
if (summaryProject) {
summaryProject.textContent = project?.title || "(Not connected)";
}
if (summaryBook) {
summaryBook.textContent = book ? bookDisplayTitle(book) : "(Not connected)";
}
if (syncNote) {
syncNote.textContent = book ? "" : "Select a PlotDirector book before refreshing.";
}
setAnalyseButtonEnabled();
updateLinkedManuscriptUi();
};
const updateReviewSummaries = () => {
const characterCount = Array.isArray(currentSceneLinks.characters) ? currentSceneLinks.characters.length : 0;
const assetCount = Array.isArray(currentSceneLinks.assets) ? currentSceneLinks.assets.length : 0;
const locationCount = Array.isArray(currentSceneLinks.locations) ? currentSceneLinks.locations.length : 0;
setText(characterReviewSummary, `Characters (${characterCount})`);
setText(assetReviewSummary, `Assets (${assetCount})`);
setText(locationReviewSummary, `Locations (${locationCount})`);
};
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 safeContentControlItems = (paragraph) => {
try {
return Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : [];
} catch (error) {
console.warn("Word paragraph content controls were not available.", error);
return [];
}
};
const paragraphAnchorId = (paragraph, prefix) => {
const controls = safeContentControlItems(paragraph);
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 buildFirstRunImportStructure = (paragraphs, usesExplicitScenes) => {
const chapters = [];
let chapter = null;
let scene = null;
let totalWords = 0;
const manuscriptText = [];
const separatorTexts = new Set(["***", "###"]);
const startScene = () => {
if (!chapter) {
return null;
}
const nextScene = {
title: `Scene ${chapter.scenes.length + 1}`,
sortOrder: (chapter.scenes.length + 1) * 10,
wordCount: 0
};
chapter.scenes.push(nextScene);
return nextScene;
};
paragraphs.forEach((paragraph) => {
const text = String(paragraph.text || "").trim();
if (!text) {
return;
}
manuscriptText.push(text);
if (isBuiltInHeading(paragraph, 1)) {
chapter = {
title: text,
sortOrder: (chapters.length + 1) * 10,
wordCount: 0,
scenes: []
};
chapters.push(chapter);
scene = startScene();
totalWords += countWords(text);
return;
}
if (!chapter || !scene) {
return;
}
if (usesExplicitScenes && separatorTexts.has(text)) {
scene = startScene();
return;
}
const words = countWords(text);
chapter.wordCount += words;
scene.wordCount += words;
totalWords += words;
});
return {
chapters,
chapterCount: chapters.length,
sceneCount: chapters.reduce((total, item) => total + item.scenes.length, 0),
wordCount: totalWords,
documentText: manuscriptText.join("\n")
};
};
const sceneSeparatorTexts = new Set(["***", "###"]);
const snapshotParagraph = (paragraph, index) => {
const text = String(paragraph?.text || "").trim();
return {
index,
text,
styleBuiltIn: paragraph?.styleBuiltIn || "",
style: paragraph?.style || "",
wordCount: countWords(text),
chapterAnchorId: null,
sceneAnchorId: null
};
};
const finishRuntimeScene = (scene, endIndex) => {
if (scene) {
scene.endParagraphIndex = endIndex;
}
};
const finishRuntimeChapter = (chapter, endIndex) => {
if (chapter) {
chapter.endParagraphIndex = endIndex;
finishRuntimeScene(chapter.scenes.at(-1), endIndex);
}
};
const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => {
const paragraphSnapshots = paragraphs.map(snapshotParagraph);
const chapters = [];
let heading1Count = 0;
let sceneBoundaryCount = 0;
let chapter = null;
let scene = null;
let documentWordCount = 0;
const startScene = (startParagraph, title = null, anchorId = null) => {
if (!chapter) {
return null;
}
finishRuntimeScene(scene, startParagraph.index - 1);
scene = {
index: chapter.scenes.length + 1,
paragraphIndex: startParagraph.index,
startParagraphIndex: startParagraph.index,
endParagraphIndex: startParagraph.index,
title: title || `Scene ${chapter.scenes.length + 1}`,
anchorId,
wordCount: 0
};
chapter.scenes.push(scene);
return scene;
};
for (const paragraph of paragraphSnapshots) {
if (!paragraph.text) {
continue;
}
if (isBuiltInHeading(paragraph, 1)) {
finishRuntimeChapter(chapter, paragraph.index - 1);
heading1Count += 1;
chapter = {
index: chapters.length + 1,
paragraphIndex: paragraph.index,
startParagraphIndex: paragraph.index,
endParagraphIndex: paragraph.index,
title: paragraph.text,
anchorId: paragraph.chapterAnchorId,
wordCount: 0,
scenes: []
};
chapters.push(chapter);
scene = null;
startScene(paragraph, usesExplicitScenes ? "Scene 1" : "Scene 1", paragraph.sceneAnchorId);
documentWordCount += paragraph.wordCount;
continue;
}
if (!chapter) {
continue;
}
if (usesExplicitScenes && sceneSeparatorTexts.has(paragraph.text)) {
sceneBoundaryCount += 1;
startScene(paragraph);
continue;
}
chapter.wordCount += paragraph.wordCount;
if (scene) {
scene.wordCount += paragraph.wordCount;
}
documentWordCount += paragraph.wordCount;
}
finishRuntimeChapter(chapter, paragraphSnapshots.length - 1);
return {
paragraphs: paragraphSnapshots,
chapters,
usesExplicitScenes: !!usesExplicitScenes,
paragraphCount: paragraphSnapshots.length,
heading1Count,
heading2Count: sceneBoundaryCount,
documentWordCount,
currentParagraphIndex: null,
currentChapter: null,
currentScene: null
};
};
const onboardingCandidateValue = (candidate, camel, pascal) => candidate?.[camel] ?? candidate?.[pascal] ?? null;
const mapOnboardingCharacterCandidates = (candidates) => (Array.isArray(candidates) ? candidates : [])
.map((candidate, index) => ({
temporaryCharacterKey: `character-${index + 1}`,
name: onboardingCandidateValue(candidate, "text", "Text") || onboardingCandidateValue(candidate, "name", "Name") || "",
mentionCount: Number.parseInt(onboardingCandidateValue(candidate, "mentionCount", "MentionCount") || "0", 10) || 0,
qualityScore: Number.parseInt(onboardingCandidateValue(candidate, "qualityScore", "QualityScore") || "0", 10) || 0,
category: onboardingCandidateValue(candidate, "category", "Category") || "PossibleCharacter",
reason: onboardingCandidateValue(candidate, "reason", "Reason") || "",
isExistingCharacterMatch: !!onboardingCandidateValue(candidate, "isExistingCharacterMatch", "IsExistingCharacterMatch"),
suggestedImportance: null
}))
.filter((candidate) => candidate.name);
const buildOnboardingScanPreview = (paragraphs, command) => {
const chapters = [];
const scenes = [];
const documentText = [];
let chapter = null;
let scene = null;
let totalWordCount = 0;
let detectedHeadingChapters = 0;
let manuscriptStarted = false;
const isFrontMatterHeading = (value) => {
const clean = normalizeTitleForResolve(value || "", "").toLocaleLowerCase();
return [
"title page",
"copyright",
"dedication",
"contents",
"table of contents",
"acknowledgements",
"acknowledgments",
"about the author"
].includes(clean);
};
const finishScene = () => {
scene = null;
};
const startChapter = (paragraph, title, isOpening = false) => {
finishScene();
chapter = {
temporaryChapterKey: `chapter-${chapters.length + 1}`,
chapterNumber: chapters.length + 1,
title,
wordCount: 0,
chapterTextParagraphs: [],
chapterText: "",
startPosition: paragraph?.index ?? null,
existingChapterID: isOpening ? null : paragraphAnchorId(paragraph, "PD-CHAPTER")
};
chapters.push(chapter);
startScene(paragraph, null);
return chapter;
};
const startScene = (paragraph, title = null) => {
if (!chapter) {
return null;
}
finishScene();
scene = {
temporarySceneKey: `scene-${scenes.length + 1}`,
temporaryChapterKey: chapter.temporaryChapterKey,
sceneNumberWithinChapter: scenes.filter((item) => item.temporaryChapterKey === chapter.temporaryChapterKey).length + 1,
title,
wordCount: 0,
openingTextPreview: "",
startPosition: paragraph?.index ?? null,
existingSceneID: paragraph ? paragraphAnchorId(paragraph, "PD-SCENE") : null
};
scenes.push(scene);
return scene;
};
const addWords = (text, words) => {
if (!chapter || !scene || words <= 0) {
return;
}
chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text);
scene.wordCount += words;
if (!scene.openingTextPreview && text && !sceneSeparatorTexts.has(text)) {
scene.openingTextPreview = text.length > 180 ? `${text.slice(0, 177)}...` : text;
}
};
paragraphs.forEach((paragraph, index) => {
paragraph.index = index;
const text = String(paragraph.text || "").trim();
if (!text) {
return;
}
const words = countWords(text);
if (isBuiltInHeading(paragraph, 1)) {
if (!manuscriptStarted && isFrontMatterHeading(text)) {
return;
}
manuscriptStarted = true;
documentText.push(text);
detectedHeadingChapters += 1;
startChapter(paragraph, text);
totalWordCount += words;
chapter.wordCount += words;
chapter.chapterTextParagraphs.push(text);
return;
}
if (!chapter) {
if (!manuscriptStarted) {
return;
}
startChapter(paragraph, "Opening pages", true);
}
documentText.push(text);
if (isBuiltInHeading(paragraph, 2)) {
startScene(paragraph, text);
totalWordCount += words;
addWords(text, words);
return;
}
if (sceneSeparatorTexts.has(text)) {
startScene(paragraph, null);
return;
}
totalWordCount += words;
addWords(text, words);
});
if (totalWordCount === 0) {
throw new Error("The document appears to be empty. Add manuscript text, then try again.");
}
if (detectedHeadingChapters === 0) {
throw new Error("No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.");
}
chapters.forEach((item) => {
item.chapterText = Array.isArray(item.chapterTextParagraphs)
? item.chapterTextParagraphs.join("\n\n")
: "";
delete item.chapterTextParagraphs;
});
const documentTextForDiscovery = documentText.join("\n");
return {
previewID: "00000000-0000-0000-0000-000000000000",
userID: scanCommandValue(command, "userID", "UserID", "userId"),
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID", "onboardingId"),
projectID: scanCommandValue(command, "projectID", "ProjectID", "projectId"),
bookID: scanCommandValue(command, "bookID", "BookID", "bookId"),
source: "WordCompanion",
documentTitle: currentDocumentName() || "Word manuscript",
companionDocumentIdentifier: currentDocumentGuid(),
totalWordCount,
chapterCount: chapters.length,
sceneCount: scenes.length,
characterCandidateCount: 0,
createdUtc: new Date().toISOString(),
chapters,
scenes,
characterCandidates: [],
documentTextForDiscovery
};
};
const scanCommandValue = (command, ...names) => {
for (const name of names) {
if (command?.[name] !== undefined && command?.[name] !== null) {
return command[name];
}
}
return null;
};
const reportOnboardingScanProgress = async (command, message, percentComplete, counts = {}) => {
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
return;
}
await companionPresenceConnection.invoke("ReportOnboardingScanProgress", {
userID: scanCommandValue(command, "userID", "UserID", "userId"),
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID", "onboardingId"),
message,
percentComplete,
chapterCount: counts.chapterCount || 0,
sceneCount: counts.sceneCount || 0,
characterCandidateCount: counts.characterCandidateCount || 0,
totalWordCount: counts.totalWordCount || 0
});
};
const failOnboardingScan = async (command, message) => {
if (!companionPresenceConnection || companionPresenceConnection.state !== signalR.HubConnectionState.Connected) {
return;
}
await companionPresenceConnection.invoke("FailOnboardingScan", {
userID: scanCommandValue(command, "userID", "UserID", "userId"),
onboardingID: scanCommandValue(command, "onboardingID", "OnboardingID", "onboardingId"),
message
});
};
const runOnboardingDocumentScan = async (command) => {
if (!wordHostAvailable || !window.Word) {
await failOnboardingScan(command, "Open your manuscript in Microsoft Word, then try the scan again.");
return;
}
try {
await reportOnboardingScanProgress(command, "Preparing document scan", 5);
const paragraphs = await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync();
await reportOnboardingScanProgress(command, "Detecting chapters", 20);
for (const paragraph of bodyParagraphs.items) {
if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) {
paragraph.contentControls.load("items/tag,title");
}
}
await context.sync();
return bodyParagraphs.items;
});
const preview = buildOnboardingScanPreview(paragraphs, command);
await reportOnboardingScanProgress(command, "Detecting scene breaks", 50, preview);
await reportOnboardingScanProgress(command, `Scene ${preview.sceneCount} of ${preview.sceneCount} scanned`, 70, preview);
await reportOnboardingScanProgress(command, "Finding character candidates", 85, preview);
const documentTextForDiscovery = preview.documentTextForDiscovery || "";
delete preview.documentTextForDiscovery;
try {
const discovery = await postJson("/api/word-companion/manuscript/discover-characters", {
projectId: preview.projectID,
documentText: documentTextForDiscovery,
includeExcluded: true
});
preview.characterCandidates = mapOnboardingCharacterCandidates(discovery?.candidates);
preview.characterCandidateCount = preview.characterCandidates
.filter((candidate) => String(candidate.category || "").toLowerCase() !== "excluded")
.length;
} catch (discoveryError) {
console.warn("Unable to refine onboarding character candidates.", discoveryError);
preview.characterCandidates = [];
preview.characterCandidateCount = 0;
}
await reportOnboardingScanProgress(command, "Preparing preview", 95, preview);
lastOnboardingScanPreview = preview;
await companionPresenceConnection.invoke("CompleteOnboardingScan", preview);
} catch (error) {
console.error("Unable to complete onboarding scan.", error);
await failOnboardingScan(command, friendlyScanError(error));
}
};
const updateOnboardingBuildMarkers = async (result) => {
if (!lastOnboardingScanPreview || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
const chapterMap = new Map((result?.chapterMappings || result?.ChapterMappings || [])
.map((item) => [item.temporaryChapterKey || item.TemporaryChapterKey, item.chapterID || item.ChapterID]));
const sceneMap = new Map((result?.sceneMappings || result?.SceneMappings || [])
.map((item) => [item.temporarySceneKey || item.TemporarySceneKey, item.sceneID || item.SceneID]));
await window.Word.run(async (context) => {
const paragraphs = context.document.body.paragraphs;
paragraphs.load("items/text,items/style,items/styleBuiltIn");
await context.sync();
for (const chapter of lastOnboardingScanPreview.chapters || []) {
const chapterId = chapterMap.get(chapter.temporaryChapterKey);
const index = Number.parseInt(chapter.startPosition, 10);
if (chapterId && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
}
}
for (const scene of lastOnboardingScanPreview.scenes || []) {
const sceneId = sceneMap.get(scene.temporarySceneKey);
const index = Number.parseInt(scene.startPosition, 10);
if (sceneId && Number.isInteger(index) && paragraphs.items[index]) {
ensureAnchorControl(paragraphs.items[index], "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
}
}
await context.sync();
});
};
const friendlyScanError = (error) => {
const message = errorText(error);
if (/No chapters were detected/i.test(message)) {
return "No chapters were detected. Make sure your chapter headings use Heading 1, or add clear chapter headings.";
}
if (/empty/i.test(message)) {
return "The document appears to be empty. Add manuscript text, then try again.";
}
if (/network|disconnect|connection/i.test(message)) {
return "The Word Companion disconnected before the scan finished. Reopen Word and try again.";
}
return `The scan could not finish: ${message}`;
};
const paragraphMatchesSnapshot = (snapshot, paragraph) => {
return String(snapshot?.text || "").trim() === String(paragraph?.text || "").trim()
&& normalizedStyleName(snapshot) === normalizedStyleName(paragraph);
};
const findSelectedParagraphIndex = (cache, selectionParagraphs) => {
const selectedParagraph = (selectionParagraphs || []).find((paragraph) => String(paragraph.text || "").trim());
if (!cache || !selectedParagraph) {
return cache?.currentParagraphIndex ?? -1;
}
const matches = cache.paragraphs
.filter((paragraph) => paragraphMatchesSnapshot(paragraph, selectedParagraph))
.map((paragraph) => paragraph.index);
if (matches.length === 0) {
return cache.currentParagraphIndex ?? -1;
}
if (!Number.isInteger(cache.currentParagraphIndex)) {
return matches[0];
}
return matches.reduce((best, candidate) =>
Math.abs(candidate - cache.currentParagraphIndex) < Math.abs(best - cache.currentParagraphIndex) ? candidate : best, matches[0]);
};
const chapterForParagraphIndex = (cache, paragraphIndex) => {
if (!cache || !Number.isInteger(paragraphIndex) || paragraphIndex < 0) {
return null;
}
return cache.chapters
.filter((chapter) => chapter.startParagraphIndex <= paragraphIndex)
.at(-1) || null;
};
const sceneForParagraphIndex = (chapter, paragraphIndex) => {
if (!chapter || !Number.isInteger(paragraphIndex) || paragraphIndex < 0) {
return null;
}
return chapter.scenes
.filter((item) => item.startParagraphIndex <= paragraphIndex)
.at(-1) || null;
};
const applyRuntimeSelection = (paragraphIndex) => {
if (!documentStructureCache) {
return false;
}
const currentChapterItem = chapterForParagraphIndex(documentStructureCache, paragraphIndex);
const currentSceneItem = sceneForParagraphIndex(currentChapterItem, paragraphIndex);
const changed = currentChapterItem?.startParagraphIndex !== documentStructureCache.currentChapter?.startParagraphIndex
|| currentSceneItem?.startParagraphIndex !== documentStructureCache.currentScene?.startParagraphIndex
|| currentChapterItem?.anchorId !== documentStructureCache.currentChapter?.anchorId
|| currentSceneItem?.anchorId !== documentStructureCache.currentScene?.anchorId;
documentStructureCache.currentParagraphIndex = paragraphIndex;
documentStructureCache.currentChapter = currentChapterItem;
documentStructureCache.currentScene = currentSceneItem;
if (!changed) {
return false;
}
setCurrentStructure(
currentChapterItem?.title,
currentSceneItem?.title,
currentSceneItem?.wordCount,
currentChapterItem?.anchorId,
currentSceneItem?.anchorId,
currentChapterItem?.index
);
return changed;
};
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 previewGroups = [
{ key: "alreadyLinked", title: "Already Linked" },
{ key: "couldLink", title: "Could Link" },
{ key: "wouldCreateChapters", title: "Would Create Chapters" },
{ key: "wouldCreateScenes", title: "Would Create Scenes" },
{ key: "manualReview", title: "Manual Review Required" }
];
const previewActionLabels = {
alreadyLinked: "Already Linked",
couldLink: "Could Link",
wouldCreateChapter: "Would Create Chapter",
wouldCreateScene: "Would Create Scene",
manualReview: "Manual Review Required"
};
const previewActionSymbols = {
alreadyLinked: "✓",
couldLink: "~",
wouldCreateChapter: "+",
wouldCreateScene: "+",
manualReview: "?"
};
const titleKey = (title, type) => normalizeTitleForResolve(title, type).toLocaleLowerCase();
const createPreviewItem = ({
category,
action,
type,
wordTitle,
match = "",
anchorStatus = "None",
matches = [],
pdChapter = null,
pdScene = null,
wordChapter = null,
wordScene = null,
parentItem = null
}) => ({
id: `${type}-${wordChapter?.index || 0}-${wordScene?.index || 0}-${wordChapter?.paragraphIndex ?? wordScene?.paragraphIndex ?? 0}`,
category,
action,
type,
wordTitle,
match,
anchorStatus,
matches,
pdChapter,
pdScene,
wordChapter,
wordScene,
parentItem,
result: "Pending",
error: ""
});
const addPreviewItem = (model, item) => {
if (Array.isArray(model[item.category])) {
model[item.category].push(item);
}
};
const sceneRowsForChapter = (chapter) => Array.isArray(chapter?.scenes) ? chapter.scenes : [];
const findSceneById = (chapters, sceneId) => {
for (const chapter of chapters) {
const scene = sceneRowsForChapter(chapter).find((item) => item.sceneId === sceneId);
if (scene) {
return { chapter, scene };
}
}
return null;
};
const compareChapterForPreview = (wordChapter, plotDirectorChapters, model) => {
const anchorStatus = wordChapter.anchorId ? `PD-CHAPTER-${wordChapter.anchorId}` : "None";
if (wordChapter.anchorId) {
const anchoredChapter = plotDirectorChapters.find((chapter) => chapter.chapterId === wordChapter.anchorId);
if (anchoredChapter) {
const item = createPreviewItem({
category: "alreadyLinked",
action: "alreadyLinked",
type: "Chapter",
wordTitle: wordChapter.title,
match: `Chapter ${anchoredChapter.chapterId}: ${anchoredChapter.title}`,
anchorStatus,
pdChapter: anchoredChapter,
wordChapter
});
addPreviewItem(model, item);
return item;
}
const item = createPreviewItem({
category: "manualReview",
action: "manualReview",
type: "Chapter",
wordTitle: wordChapter.title,
anchorStatus: `${anchorStatus} not found`,
matches: [],
pdChapter: null,
wordChapter
});
addPreviewItem(model, item);
return item;
}
const matches = plotDirectorChapters.filter((chapter) => titleKey(chapter.title, "chapter") === titleKey(wordChapter.title, "chapter"));
if (matches.length === 1) {
const item = createPreviewItem({
category: "couldLink",
action: "couldLink",
type: "Chapter",
wordTitle: wordChapter.title,
match: `Chapter ${matches[0].chapterId}: ${matches[0].title}`,
anchorStatus,
pdChapter: matches[0],
wordChapter
});
addPreviewItem(model, item);
return item;
}
if (matches.length > 1) {
const item = createPreviewItem({
category: "manualReview",
action: "manualReview",
type: "Chapter",
wordTitle: wordChapter.title,
anchorStatus,
matches: matches.map((chapter) => `Chapter ${chapter.chapterId}: ${chapter.title}`),
pdChapter: null,
wordChapter
});
addPreviewItem(model, item);
return item;
}
const item = createPreviewItem({
category: "wouldCreateChapters",
action: "wouldCreateChapter",
type: "Chapter",
wordTitle: wordChapter.title,
anchorStatus,
pdChapter: null,
wordChapter
});
addPreviewItem(model, item);
return item;
};
const compareSceneForPreview = (wordScene, chapterPreview, plotDirectorChapters, model) => {
const anchorStatus = wordScene.anchorId ? `PD-SCENE-${wordScene.anchorId}` : "None";
if (wordScene.anchorId) {
const anchored = findSceneById(plotDirectorChapters, wordScene.anchorId);
if (anchored) {
addPreviewItem(model, createPreviewItem({
category: "alreadyLinked",
action: "alreadyLinked",
type: "Scene",
wordTitle: wordScene.title,
match: `Scene ${anchored.scene.sceneId}: ${anchored.scene.title}`,
anchorStatus,
pdChapter: anchored.chapter,
pdScene: anchored.scene,
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
addPreviewItem(model, createPreviewItem({
category: "manualReview",
action: "manualReview",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus: `${anchorStatus} not found`,
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
if (!chapterPreview?.pdChapter && chapterPreview?.category === "manualReview") {
addPreviewItem(model, createPreviewItem({
category: "manualReview",
action: "manualReview",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus,
matches: ["Parent chapter requires manual review."],
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
if (!chapterPreview?.pdChapter) {
addPreviewItem(model, createPreviewItem({
category: "wouldCreateScenes",
action: "wouldCreateScene",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus,
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
const matches = sceneRowsForChapter(chapterPreview.pdChapter)
.filter((scene) => titleKey(scene.title, "scene") === titleKey(wordScene.title, "scene"));
if (matches.length === 1) {
addPreviewItem(model, createPreviewItem({
category: "couldLink",
action: "couldLink",
type: "Scene",
wordTitle: wordScene.title,
match: `Scene ${matches[0].sceneId}: ${matches[0].title}`,
anchorStatus,
pdChapter: chapterPreview.pdChapter,
pdScene: matches[0],
wordChapter: chapterPreview.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
if (matches.length > 1) {
addPreviewItem(model, createPreviewItem({
category: "manualReview",
action: "manualReview",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus,
matches: matches.map((scene) => `Scene ${scene.sceneId}: ${scene.title}`),
wordChapter: chapterPreview.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
addPreviewItem(model, createPreviewItem({
category: "wouldCreateScenes",
action: "wouldCreateScene",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus,
pdChapter: chapterPreview.pdChapter,
wordChapter: chapterPreview.wordChapter,
wordScene,
parentItem: chapterPreview
}));
};
const buildStructurePreviewModel = (wordStructure, plotDirectorStructure) => {
const model = {
alreadyLinked: [],
couldLink: [],
wouldCreateChapters: [],
wouldCreateScenes: [],
manualReview: []
};
const plotDirectorChapters = Array.isArray(plotDirectorStructure?.chapters) ? plotDirectorStructure.chapters : [];
for (const wordChapter of Array.isArray(wordStructure?.chapters) ? wordStructure.chapters : []) {
const chapterPreview = compareChapterForPreview(wordChapter, plotDirectorChapters, model);
for (const wordScene of sceneRowsForChapter(wordChapter)) {
compareSceneForPreview(wordScene, chapterPreview, plotDirectorChapters, model);
}
}
return model;
};
const renderPreviewItem = (item) => {
const row = document.createElement("article");
row.className = "word-companion-preview-item";
const title = document.createElement("strong");
title.textContent = `${previewActionSymbols[item.action] || ""} ${item.type}: ${item.wordTitle}`;
row.append(title);
const anchor = document.createElement("span");
anchor.textContent = `Anchor: ${item.anchorStatus || "None"}`;
row.append(anchor);
if (item.match) {
const match = document.createElement("span");
match.textContent = `Match: ${item.match}`;
row.append(match);
}
if (Array.isArray(item.matches) && item.matches.length > 0) {
const matches = document.createElement("div");
matches.className = "word-companion-preview-matches";
const label = document.createElement("span");
label.textContent = "Matches:";
matches.append(label);
const list = document.createElement("ul");
for (const matchText of item.matches) {
const li = document.createElement("li");
li.textContent = matchText;
list.append(li);
}
matches.append(list);
row.append(matches);
}
const action = document.createElement("span");
action.textContent = `Action: ${previewActionLabels[item.action] || item.action}`;
row.append(action);
if (item.result && item.result !== "Pending") {
const result = document.createElement("span");
result.textContent = item.error ? `Result: ${item.result} - ${item.error}` : `Result: ${item.result}`;
row.append(result);
}
return row;
};
const renderStructurePreview = (model) => {
if (!structurePreviewResults) {
return;
}
structurePreviewResults.innerHTML = "";
for (const group of previewGroups) {
const section = document.createElement("section");
section.className = "word-companion-preview-group";
const heading = document.createElement("strong");
heading.textContent = group.title;
section.append(heading);
const items = Array.isArray(model?.[group.key]) ? model[group.key] : [];
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "word-companion-empty-list";
empty.textContent = "None";
section.append(empty);
} else {
for (const item of items) {
section.append(renderPreviewItem(item));
}
}
structurePreviewResults.append(section);
}
updateApplyStructureSyncButton();
};
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();
try {
for (const paragraph of bodyParagraphs.items) {
if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) {
paragraph.contentControls.load("items/tag,title");
}
}
await context.sync();
} catch (error) {
console.warn("Unable to load Word heading content controls. Continuing without PlotDirector anchors.", error);
}
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
structure.bodyParagraphs = bodyParagraphs.items;
return structure;
};
const readRuntimeDocumentStructure = async (context, usesExplicitScenes) => {
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();
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
documentStructureCache = cache;
applyRuntimeSelection(selectedIndex);
runtimeDebug("Cache rebuilt.");
return cache;
};
const readSelectionParagraphs = async (context) => {
const selectionParagraphs = context.document.getSelection().paragraphs;
selectionParagraphs.load("text,styleBuiltIn,style");
await context.sync();
return selectionParagraphs.items;
};
const runtimePerformanceNow = () =>
window.performance && typeof window.performance.now === "function" ? window.performance.now() : Date.now();
const isSnapshotFresh = (snapshot) => !!snapshot
&& !snapshot.stale
&& snapshot.generation === runtimeGeneration
&& snapshot.documentGuid === currentDocumentGuid()
&& snapshot.sceneId === resolvedSceneId;
const captureCurrentSceneSnapshot = async (options = {}) => {
const includeSelection = !!options.includeSelection;
const updateDisplay = options.updateDisplay !== false;
const generation = runtimeGeneration;
const startedAt = runtimePerformanceNow();
const project = selectedProject();
const book = selectedBook();
const scene = documentStructureCache?.currentScene;
if (!scene || !Number.isInteger(scene.startParagraphIndex) || !Number.isInteger(scene.endParagraphIndex)) {
return null;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
return null;
}
const officeStartedAt = runtimePerformanceNow();
const wordSnapshot = await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("text");
let selectionParagraphs = null;
if (includeSelection) {
selectionParagraphs = context.document.getSelection().paragraphs;
selectionParagraphs.load("text,styleBuiltIn,style");
}
await context.sync();
return {
bodyTexts: bodyParagraphs.items.map((paragraph) => String(paragraph.text || "")),
selectionParagraphs: includeSelection && selectionParagraphs
? selectionParagraphs.items.map((paragraph) => ({
text: String(paragraph.text || ""),
styleBuiltIn: paragraph.styleBuiltIn,
style: paragraph.style
}))
: []
};
});
const officeMs = Math.round(runtimePerformanceNow() - officeStartedAt);
if (generation !== runtimeGeneration) {
runtimeTiming(`Office snapshot: ${officeMs}ms stale`);
return { stale: true, generation };
}
let selectionChanged = false;
if (includeSelection) {
const selectedIndex = findSelectedParagraphIndex(documentStructureCache, wordSnapshot.selectionParagraphs);
selectionChanged = applyRuntimeSelection(selectedIndex);
}
const currentSceneItem = documentStructureCache?.currentScene;
if (!currentSceneItem || !Number.isInteger(currentSceneItem.startParagraphIndex) || !Number.isInteger(currentSceneItem.endParagraphIndex)) {
return null;
}
const texts = [];
let wordCount = 0;
const endIndex = Math.min(currentSceneItem.endParagraphIndex, wordSnapshot.bodyTexts.length - 1);
const chapterHeadingIndex = documentStructureCache.currentChapter?.startParagraphIndex;
for (let index = currentSceneItem.startParagraphIndex; index <= endIndex; index += 1) {
const text = String(wordSnapshot.bodyTexts[index] || "").trim();
if (!text || sceneSeparatorTexts.has(text) || index === chapterHeadingIndex) {
continue;
}
texts.push(text);
wordCount += countWords(text);
}
currentSceneItem.wordCount = wordCount;
if (updateDisplay) {
setCurrentStructure(
documentStructureCache.currentChapter?.title,
currentSceneItem.title,
wordCount,
documentStructureCache.currentChapter?.anchorId,
currentSceneItem.anchorId,
documentStructureCache.currentChapter?.index
);
}
const snapshot = {
documentGuid: currentDocumentGuid(),
projectId: project?.projectId || null,
bookId: book?.bookId || null,
chapterId: resolvedChapterId,
sceneId: resolvedSceneId,
chapterTitle: documentStructureCache.currentChapter?.title || "",
sceneTitle: currentSceneItem.title || "",
sceneText: texts.join("\n"),
wordCount,
selectionSignature: `${documentStructureCache.currentParagraphIndex ?? -1}:${currentSceneItem.startParagraphIndex}:${currentSceneItem.endParagraphIndex}`,
selectionChanged,
generation
};
lastCurrentSceneSnapshot = snapshot;
runtimeTiming(`Office snapshot: ${officeMs}ms; scene words: ${wordCount}; total snapshot: ${Math.round(runtimePerformanceNow() - startedAt)}ms`);
return snapshot;
};
const getFreshCurrentSceneSnapshot = async () => {
if (isSnapshotFresh(lastCurrentSceneSnapshot)) {
return lastCurrentSceneSnapshot;
}
return await captureCurrentSceneSnapshot();
};
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 runtimeBook = selectedBook();
if (runtimeBook) {
return await readRuntimeDocumentStructure(context, !!runtimeBook.usesExplicitScenes);
}
documentStructureCache = null;
return await readDocumentStructure(context);
});
if (!documentStructureCache) {
setCurrentStructure(
structure.currentChapter?.title,
structure.currentScene?.title,
structure.currentScene?.wordCount,
structure.currentChapter?.anchorId,
structure.currentScene?.anchorId,
structure.currentChapter?.index
);
}
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: ${message}`);
setDiagnostics({ lastScan: "Failure", lastError: message });
return false;
} finally {
if (refreshDocumentButton) {
refreshDocumentButton.disabled = false;
refreshDocumentButton.textContent = "Refresh Document Structure";
}
}
};
const analyseManuscriptStructure = async () => {
const book = selectedBook();
if (!selectedProject() || !book) {
setStructurePreviewStatus("Select a project and book to analyse manuscript structure.");
return false;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setStructurePreviewStatus("Word document access is unavailable.");
setDiagnostics({ lastScan: "Failure", lastError: "Word document access is unavailable." });
return false;
}
if (analyseManuscriptStructureButton) {
analyseManuscriptStructureButton.disabled = true;
analyseManuscriptStructureButton.textContent = "Analysing...";
}
setStructurePreviewStatus("Analysing manuscript structure...");
try {
const wordStructure = await window.Word.run(async (context) => {
return await readDocumentStructure(context);
});
const plotDirectorStructure = await fetchJson(`/api/word-companion/books/${book.bookId}/structure`);
setCurrentStructure(
wordStructure.currentChapter?.title,
wordStructure.currentScene?.title,
wordStructure.currentScene?.wordCount,
wordStructure.currentChapter?.anchorId,
wordStructure.currentScene?.anchorId,
wordStructure.currentChapter?.index
);
renderOutline(wordStructure);
structurePreviewModel = buildStructurePreviewModel(wordStructure, plotDirectorStructure);
renderStructurePreview(structurePreviewModel);
setStructurePreviewStatus("Preview generated. No changes have been applied.");
setDiagnostics({
lastScan: "Success",
lastError: "-",
paragraphs: String(wordStructure.paragraphCount),
heading1: String(wordStructure.heading1Count),
heading2: String(wordStructure.heading2Count)
});
return true;
} catch (error) {
console.error("Unable to analyse manuscript structure.", error);
setStructurePreviewStatus("Unable to analyse manuscript structure.");
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
return false;
} finally {
if (analyseManuscriptStructureButton) {
analyseManuscriptStructureButton.textContent = "Analyse Manuscript Structure";
setAnalyseButtonEnabled();
}
}
};
const applyCounts = (model = structurePreviewModel) => {
const actionable = actionablePreviewItems(model);
const linkChapters = actionable.filter((item) => item.category === "couldLink" && item.type === "Chapter").length;
const linkScenes = actionable.filter((item) => item.category === "couldLink" && item.type === "Scene").length;
const createChapters = actionable.filter((item) => item.category === "wouldCreateChapters").length;
const createScenes = actionable.filter((item) => item.category === "wouldCreateScenes").length;
const manualReview = Array.isArray(model?.manualReview) ? model.manualReview.length : 0;
return { linkChapters, linkScenes, createChapters, createScenes, manualReview };
};
const renderApplySummary = (counts) => {
if (!applyStructureSyncSummary) {
return;
}
applyStructureSyncSummary.innerHTML = "";
const lines = [
`Link ${counts.linkChapters + counts.linkScenes} chapters/scenes`,
`Create ${counts.createChapters} chapters`,
`Create ${counts.createScenes} scenes`
];
const list = document.createElement("ul");
for (const line of lines) {
const item = document.createElement("li");
item.textContent = line;
list.append(item);
}
applyStructureSyncSummary.append(list);
};
const closeApplyStructureSyncDialog = (confirmed) => {
if (pendingApplyStructureSyncConfirmation) {
pendingApplyStructureSyncConfirmation(confirmed);
pendingApplyStructureSyncConfirmation = null;
}
if (applyStructureSyncDialog?.close) {
applyStructureSyncDialog.close();
} else if (applyStructureSyncDialog) {
applyStructureSyncDialog.hidden = true;
}
};
const confirmApplyStructureSync = () => {
const counts = applyCounts();
renderApplySummary(counts);
if (!applyStructureSyncDialog) {
return Promise.resolve(window.confirm(
`Apply Structure Sync?\n\nThis will:\n\n- Link ${counts.linkChapters + counts.linkScenes} chapters/scenes\n- Create ${counts.createChapters} chapters\n- Create ${counts.createScenes} scenes\n\nManual review items will be skipped.`
));
}
return new Promise((resolve) => {
pendingApplyStructureSyncConfirmation = resolve;
if (applyStructureSyncDialog.showModal) {
applyStructureSyncDialog.showModal();
} else {
applyStructureSyncDialog.hidden = false;
}
});
};
const markPreviewItem = (item, result, error = "") => {
item.result = result;
item.error = error;
};
const previewChapterSortOrder = (item) => Number.isInteger(item?.wordChapter?.index) && item.wordChapter.index > 0
? item.wordChapter.index * 10
: null;
const previewSceneSortOrder = (item) => Number.isInteger(item?.wordScene?.index) && item.wordScene.index > 0
? item.wordScene.index * 10
: null;
const createChapterFromPreviewItem = async (bookId, item) => {
const response = await postJson(`/api/word-companion/books/${bookId}/chapters`, {
title: normalizeWhitespace(item.wordTitle),
sortOrder: previewChapterSortOrder(item)
});
if (!response?.chapterId) {
throw new Error("Chapter creation did not return a chapter ID.");
}
clearPlotDirectorStructureCache();
item.pdChapter = {
chapterId: response.chapterId,
title: response.title || item.wordTitle,
sortOrder: response.sortOrder || previewChapterSortOrder(item) || 0,
scenes: []
};
item.match = `Chapter ${item.pdChapter.chapterId}: ${item.pdChapter.title}`;
};
const createSceneFromPreviewItem = async (bookId, item) => {
const chapter = item.pdChapter || item.parentItem?.pdChapter;
if (!chapter?.chapterId) {
throw new Error("Scene parent chapter is not available.");
}
const response = await postJson(`/api/word-companion/books/${bookId}/chapters/${chapter.chapterId}/scenes`, {
sceneTitle: normalizeWhitespace(item.wordTitle),
sortOrder: previewSceneSortOrder(item)
});
if (!response?.sceneId) {
throw new Error("Scene creation did not return a scene ID.");
}
clearPlotDirectorStructureCache();
item.pdChapter = chapter;
item.pdScene = {
sceneId: response.sceneId,
title: item.wordTitle,
sortOrder: previewSceneSortOrder(item) || 0
};
item.match = `Scene ${item.pdScene.sceneId}: ${item.pdScene.title}`;
};
const attachAnchorForPreviewItem = async (item) => {
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
throw new Error("Word document access is unavailable.");
}
await window.Word.run(async (context) => {
const structure = await readDocumentStructure(context);
const source = item.type === "Chapter" ? item.wordChapter : item.wordScene;
const paragraph = Number.isInteger(source?.paragraphIndex)
? structure.bodyParagraphs[source.paragraphIndex]
: null;
if (!paragraph) {
throw new Error("Unable to locate the Word heading.");
}
if (item.type === "Chapter") {
const chapterId = item.pdChapter?.chapterId;
if (!chapterId) {
throw new Error("Chapter ID is not available.");
}
ensureAnchorControl(paragraph, "PlotDirector Chapter", `PD-CHAPTER-${chapterId}`, "PD-CHAPTER");
} else {
const sceneId = item.pdScene?.sceneId;
if (!sceneId) {
throw new Error("Scene ID is not available.");
}
ensureAnchorControl(paragraph, "PlotDirector Scene", `PD-SCENE-${sceneId}`, "PD-SCENE");
}
await context.sync();
});
};
const resultSummaryText = (summary) =>
`Structure Sync Complete. Created: ${summary.createdChapters} Chapters, ${summary.createdScenes} Scenes. Linked: ${summary.linkedChapters} Chapters, ${summary.linkedScenes} Scenes. Skipped: ${summary.skipped} Manual Review items. Failed: ${summary.failed} Items.`;
const applyStructureSync = async () => {
const book = selectedBook();
if (!book || actionablePreviewItems().length === 0 || isApplyingStructureSync) {
updateApplyStructureSyncButton();
return;
}
const confirmed = await confirmApplyStructureSync();
if (!confirmed) {
return;
}
isApplyingStructureSync = true;
setAnalyseButtonEnabled();
setStructurePreviewStatus("Applying structure sync...");
const summary = {
createdChapters: 0,
createdScenes: 0,
linkedChapters: 0,
linkedScenes: 0,
skipped: Array.isArray(structurePreviewModel?.manualReview) ? structurePreviewModel.manualReview.length : 0,
failed: 0
};
const linkChapters = actionablePreviewItems().filter((item) => item.category === "couldLink" && item.type === "Chapter");
const createChapters = actionablePreviewItems().filter((item) => item.category === "wouldCreateChapters");
const linkScenes = actionablePreviewItems().filter((item) => item.category === "couldLink" && item.type === "Scene");
const createScenes = actionablePreviewItems().filter((item) => item.category === "wouldCreateScenes");
const anchorQueue = [];
let finalStatus = "";
const queueAnchor = (item, counterName) => {
anchorQueue.push({ item, counterName });
};
const attachQueuedItem = async (item, counterName) => {
try {
await attachAnchorForPreviewItem(item);
markPreviewItem(item, "Success");
summary[counterName] += 1;
} catch (error) {
markPreviewItem(item, "Failed", errorText(error));
summary.failed += 1;
}
};
try {
for (const item of linkChapters) {
queueAnchor(item, "linkedChapters");
}
for (const item of createChapters) {
try {
await createChapterFromPreviewItem(book.bookId, item);
queueAnchor(item, "createdChapters");
} catch (error) {
markPreviewItem(item, "Failed", errorText(error));
summary.failed += 1;
}
}
for (const item of linkScenes) {
queueAnchor(item, "linkedScenes");
}
for (const item of createScenes) {
try {
await createSceneFromPreviewItem(book.bookId, item);
queueAnchor(item, "createdScenes");
} catch (error) {
markPreviewItem(item, "Failed", errorText(error));
summary.failed += 1;
}
}
for (const queued of anchorQueue) {
await attachQueuedItem(queued.item, queued.counterName);
}
for (const item of structurePreviewModel.manualReview || []) {
markPreviewItem(item, "Skipped");
}
renderStructurePreview(structurePreviewModel);
finalStatus = resultSummaryText(summary);
setStructurePreviewStatus(finalStatus);
} finally {
isApplyingStructureSync = false;
setAnalyseButtonEnabled();
if (finalStatus) {
clearPlotDirectorStructureCache();
await analyseManuscriptStructure();
setStructurePreviewStatus(`${finalStatus} Preview refreshed.`);
}
}
};
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);
const message = errorText(error);
setDiagnostics({ lastScan: "Failure", lastError: message });
setDocumentMessage(`Unable to scan the Word document: ${message}`);
} 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 analyseFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
if (!project || !book) {
resetFirstRunPreview("Select a Project and Book to begin.");
return;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
resetFirstRunPreview("Word document access is unavailable.");
return;
}
if (firstRunScanButton) {
firstRunScanButton.disabled = true;
firstRunScanButton.textContent = "Scanning...";
}
setFirstRunStatus("Scanning manuscript structure...");
try {
const importStructure = await window.Word.run(async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
bodyParagraphs.load("text,styleBuiltIn,style");
await context.sync();
return buildFirstRunImportStructure(bodyParagraphs.items, !!book.usesExplicitScenes);
});
firstRunImportStructure = importStructure;
firstRunStructureImported = false;
const analysis = await postJson("/api/word-companion/manuscript/analyse", {
projectId: project.projectId,
bookId: book.bookId,
documentGuid: ensureDocumentGuid(),
chapterCount: importStructure.chapterCount,
sceneCount: importStructure.sceneCount,
wordCount: importStructure.wordCount
});
firstRunAnalysis = analysis;
renderFirstRunSummary(analysis);
try {
const discovery = await postJson("/api/word-companion/manuscript/discover-characters", {
projectId: project.projectId,
documentText: importStructure.documentText || ""
});
firstRunCharacterCandidatesModel = Array.isArray(discovery?.candidates) ? discovery.candidates : [];
setHidden(firstRunCharacterSection, true);
setHidden(firstRunCharacterActions, true);
} catch (discoveryError) {
console.error("Unable to discover character candidates.", discoveryError);
firstRunCharacterCandidatesModel = [];
setHidden(firstRunCharacterSection, true);
setHidden(firstRunCharacterActions, true);
}
setDiagnostics({
lastScan: "Success",
lastError: "-",
paragraphs: "-",
heading1: String(importStructure.chapterCount),
heading2: "-"
});
} catch (error) {
console.error("Unable to scan manuscript for first-run import.", error);
resetFirstRunPreview(`Unable to scan manuscript: ${errorText(error)}`);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
} finally {
if (firstRunScanButton) {
firstRunScanButton.textContent = "Scan Manuscript";
}
updateFirstRunButtons();
}
};
const requestReplacePlannedConfirmation = () => new Promise((resolve) => {
if (!firstRunReplaceDialog || typeof firstRunReplaceDialog.showModal !== "function") {
resolve(window.confirm("This Book already contains planned chapters and scenes.\n\nImporting this manuscript will replace the planned structure.\n\nContinue?"));
return;
}
const cleanup = (confirmed) => {
firstRunReplaceConfirm?.removeEventListener("click", confirmHandler);
firstRunReplaceCancel?.removeEventListener("click", cancelHandler);
firstRunReplaceDialog?.removeEventListener("cancel", cancelEventHandler);
firstRunReplaceDialog?.close();
resolve(confirmed);
};
const confirmHandler = () => cleanup(true);
const cancelHandler = () => cleanup(false);
const cancelEventHandler = (event) => {
event.preventDefault();
cleanup(false);
};
firstRunReplaceConfirm?.addEventListener("click", confirmHandler);
firstRunReplaceCancel?.addEventListener("click", cancelHandler);
firstRunReplaceDialog?.addEventListener("cancel", cancelEventHandler);
firstRunReplaceDialog.showModal();
});
const requestUnlinkWordDocumentConfirmation = () => new Promise((resolve) => {
if (!unlinkWordDocumentDialog || typeof unlinkWordDocumentDialog.showModal !== "function") {
resolve(window.confirm("This will remove PlotDirector binding metadata from this Word document.\n\nIf PlotDirector is reachable, it will also unlink this Book in PlotDirector.\n\nYour manuscript text will not be changed.\n\nContinue?"));
return;
}
const cleanup = (confirmed) => {
unlinkWordDocumentConfirm?.removeEventListener("click", confirmHandler);
unlinkWordDocumentCancel?.removeEventListener("click", cancelHandler);
unlinkWordDocumentDialog?.removeEventListener("cancel", cancelEventHandler);
unlinkWordDocumentDialog?.close();
resolve(confirmed);
};
const confirmHandler = () => cleanup(true);
const cancelHandler = () => cleanup(false);
const cancelEventHandler = (event) => {
event.preventDefault();
cleanup(false);
};
unlinkWordDocumentConfirm?.addEventListener("click", confirmHandler);
unlinkWordDocumentCancel?.addEventListener("click", cancelHandler);
unlinkWordDocumentDialog?.addEventListener("cancel", cancelEventHandler);
unlinkWordDocumentDialog.showModal();
});
const importFirstRunManuscript = async () => {
const project = selectedFirstRunProject();
const book = selectedFirstRunBook();
if (!project || !book || !firstRunImportStructure || !firstRunAnalysis?.canImport) {
updateFirstRunButtons();
return;
}
let replacePlanned = false;
if (firstRunAnalysis.requiresReplaceConfirmation) {
replacePlanned = await requestReplacePlannedConfirmation();
if (!replacePlanned) {
setFirstRunStatus("Import cancelled.");
return;
}
}
isFirstRunImporting = true;
updateFirstRunButtons();
setFirstRunStatus("Importing manuscript structure...");
try {
const documentGuid = ensureDocumentGuid();
const response = await postJson("/api/word-companion/manuscript/import", {
projectId: project.projectId,
bookId: book.bookId,
documentGuid,
bindingVersion: 1,
replacePlanned,
chapters: firstRunImportStructure.chapters
});
if (!response.imported) {
firstRunAnalysis = {
...firstRunAnalysis,
canImport: !response.requiresReplaceConfirmation,
requiresReplaceConfirmation: !!response.requiresReplaceConfirmation,
message: response.message || firstRunAnalysis.message
};
setFirstRunStatus(response.message || "Import was not completed.");
updateFirstRunButtons();
return;
}
const document = response.manuscriptDocument;
await writeDocumentBinding({
documentGuid: document?.documentGuid || documentGuid,
bookId: response.bookId,
projectId: response.projectId,
bindingVersion: document?.bindingVersion || 1
});
firstRunDocumentBinding = {
documentGuid: document?.documentGuid || documentGuid,
bookId: response.bookId,
projectId: response.projectId,
bindingVersion: document?.bindingVersion || 1
};
writeStoredId(storageKeys.projectId, response.projectId);
writeStoredId(storageKeys.bookId, response.bookId);
if (projectSelect) {
projectSelect.value = String(response.projectId);
}
const discoveredCharacters = firstRunCharacterCandidatesModel;
await loadBooks(response.projectId, response.bookId);
setConnectionStatus("Connected");
firstRunStructureImported = true;
firstRunCharacterStepActive = discoveredCharacters.length > 0;
firstRunCharacterStepActivatedAt = firstRunCharacterStepActive ? Date.now() : 0;
firstRunCharacterCandidatesModel = discoveredCharacters;
if (discoveredCharacters.length > 0) {
setFirstRunMode(true);
renderFirstRunCharacterCandidates(discoveredCharacters);
setHidden(firstRunImportActions, true);
setHidden(firstRunCharacterActions, false);
setFirstRunStatus(response.message || "Manuscript structure imported.");
setDocumentMessage(response.message || "Manuscript structure imported.");
window.setTimeout(updateFirstRunButtons, 750);
} else {
await finishFirstRunOnboarding(response.message || "Manuscript structure imported.");
}
} catch (error) {
console.error("Unable to import manuscript structure.", error);
setFirstRunStatus(`Unable to import manuscript: ${errorText(error)}`);
} finally {
isFirstRunImporting = false;
updateFirstRunButtons();
}
};
const finishFirstRunOnboarding = async (message = "") => {
firstRunCharacterStepActive = false;
firstRunCharacterStepActivatedAt = 0;
firstRunCharacterCandidatesModel = [];
if (firstRunCharacterCandidates) {
firstRunCharacterCandidates.innerHTML = "";
}
setHidden(firstRunCharacterSection, true);
setFirstRunMode(false);
setFirstRunStatus("");
setHidden(firstRunCharacterActions, true);
if (message) {
setDocumentMessage(message);
}
const runtimeLoaded = await loadRuntimeDocumentAwareness();
if (message && runtimeLoaded && detectedChapterTitle) {
setDocumentMessage(message);
}
};
const createFirstRunCharacters = async () => {
const project = selectedFirstRunProject() || selectedProject();
const candidateNames = selectedFirstRunCharacterNames();
if (!firstRunCharacterStepActive || Date.now() - firstRunCharacterStepActivatedAt < 750 || !project || candidateNames.length === 0) {
updateFirstRunButtons();
return;
}
isCreatingFirstRunCharacters = true;
updateFirstRunButtons();
setText(firstRunCharacterStatus, "Creating selected characters...");
try {
const response = await postJson("/api/word-companion/manuscript/create-discovered-characters", {
projectId: project.projectId,
candidateNames
});
setDocumentMessage(response.message || "Characters created.");
setText(firstRunCharacterStatus, `${response.createdCount || 0} created, ${response.skippedCount || 0} already existed.`);
await finishFirstRunOnboarding(response.message || "Characters created.");
} catch (error) {
console.error("Unable to create discovered characters.", error);
setText(firstRunCharacterStatus, `Unable to create characters: ${errorText(error)}`);
} finally {
isCreatingFirstRunCharacters = false;
updateFirstRunButtons();
}
};
const notifyPlotDirectorCurrentScene = async (sceneId = resolvedSceneId, chapterId = resolvedChapterId) => {
const project = selectedProject();
const book = selectedBook();
const documentGuid = currentDocumentGuid();
const numericSceneId = Number.parseInt(sceneId || "", 10);
const numericChapterId = Number.parseInt(chapterId || "", 10);
if (!project || !book || !documentGuid || !Number.isInteger(numericSceneId) || numericSceneId <= 0 || !Number.isInteger(numericChapterId) || numericChapterId <= 0) {
return;
}
const signature = `${project.projectId}:${book.bookId}:${numericSceneId}`;
if (lastFollowSceneNotificationSignature === signature) {
return;
}
lastFollowSceneNotificationSignature = signature;
try {
await postJson("/api/word-companion/runtime/current-scene", {
documentGuid,
projectId: project.projectId,
bookId: book.bookId,
chapterId: numericChapterId,
sceneId: numericSceneId
});
runtimeDebug("Current scene follow event sent.");
} catch (error) {
lastFollowSceneNotificationSignature = "";
console.debug("Unable to notify PlotDirector of current scene.", error);
}
};
const loadRuntimeDocumentAwareness = async () => {
const book = selectedBook();
if (!book || !officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
return false;
}
setDocumentMessage("Reading manuscript position...");
try {
clearPlotDirectorStructureCache();
await Promise.all([
loadRuntimeCharacterAliases(true),
loadRuntimeAssetAliases(true),
loadRuntimeLocationAliases(true)
]);
await getBookStructureChapters(book.bookId);
const structure = await window.Word.run(async (context) => await readRuntimeDocumentStructure(context, !!book.usesExplicitScenes));
renderOutline(structure);
setDiagnostics({
lastScan: "Success",
lastError: "-",
paragraphs: String(structure.paragraphCount),
heading1: String(structure.heading1Count),
heading2: String(structure.heading2Count)
});
if (!detectedChapterTitle) {
resetPlotDirectorScene("Not detected");
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Manuscript linked. Click inside a chapter to show the current scene.");
return true;
}
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
} else {
await refreshPlotDirectorScene();
}
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("");
return true;
} catch (error) {
console.error("Unable to initialise bound manuscript runtime.", error);
documentStructureCache = null;
setSceneTrackingState("Manual");
setDocumentMessage(`Unable to read manuscript position: ${errorText(error)}`);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
return false;
}
};
const initialiseFirstRunBinding = async () => {
if (!isAuthenticated || !wordHostAvailable) {
setFirstRunMode(false);
return;
}
const binding = readDocumentBinding();
if (binding) {
try {
const runtime = await fetchJson(`/api/word-companion/runtime/book/${binding.bookId}?documentGuid=${encodeURIComponent(binding.documentGuid)}`);
const document = runtime?.manuscriptDocument;
if (runtime?.bookId === binding.bookId
&& runtime?.projectId === binding.projectId
&& document
&& String(document.documentGuid).toLowerCase() === binding.documentGuid.toLowerCase()) {
firstRunDocumentBinding = binding;
setRuntimeBookContext(runtime);
writeStoredId(storageKeys.projectId, runtime.projectId);
writeStoredId(storageKeys.bookId, runtime.bookId);
if (projectSelect) {
projectSelect.value = String(runtime.projectId);
}
await loadBooks(runtime.projectId, runtime.bookId);
setFirstRunMode(false);
setConnectionStatus("Connected");
setBookMessage("Bound manuscript detected.");
await loadRuntimeDocumentAwareness();
return;
}
} catch {
// If the saved binding no longer resolves, treat the document as unbound.
}
}
firstRunDocumentBinding = null;
clearRuntimeCaches();
syncFirstRunProjectOptions();
if (selectedFirstRunProject()) {
await loadBooks(selectedFirstRunProject().projectId, null);
} else {
resetSelect(firstRunBookSelect, "Select a project first");
}
resetFirstRunPreview("Select a Project and Book to begin.");
setFirstRunMode(true);
};
const returnToUnboundOnboarding = async (message) => {
firstRunDocumentBinding = null;
cancelPendingRuntimeUpdate();
clearPendingSceneWordCountSync();
clearRuntimeCaches();
resetPlotDirectorScene("Not detected");
setSceneTrackingState("Manual");
setConnectionStatus("Connected");
writeStoredId(storageKeys.projectId, null);
writeStoredId(storageKeys.bookId, null);
if (projectSelect) {
projectSelect.value = "";
}
resetSelect(bookSelect, "Select a project first");
syncFirstRunProjectOptions();
if (firstRunProjectSelect) {
firstRunProjectSelect.value = "";
}
resetSelect(firstRunBookSelect, "Select a project first");
resetFirstRunPreview(message || "Select a Project and Book to begin.");
setDocumentMessage(message || "Manuscript unlinked.");
setSyncStatus("No resolved PlotDirector scene.");
setFirstRunMode(true);
updateUnlinkWordDocumentButton();
};
const unlinkThisWordDocument = async () => {
console.debug("Word Companion unlink clicked");
setDocumentMessage("Preparing to unlink manuscript...");
const binding = currentDocumentBinding();
if (!binding) {
setDocumentMessage("This Word document is not linked.");
return;
}
if (!binding.documentGuid || !Number.isInteger(binding.bookId) || binding.bookId <= 0) {
setDocumentMessage("Unable to unlink: bound document metadata is incomplete.");
return;
}
const confirmed = await requestUnlinkWordDocumentConfirmation();
if (!confirmed) {
setDocumentMessage("Unlink cancelled.");
return;
}
setDocumentMessage("Unlinking manuscript...");
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = true;
unlinkWordDocumentButton.textContent = "Unlinking...";
}
try {
await postJson("/api/word-companion/manuscript/unlink", {
documentGuid: binding.documentGuid,
bookId: binding.bookId
});
await removeDocumentBinding();
await returnToUnboundOnboarding("Manuscript unlinked.");
} catch (error) {
console.error("Unable to unlink manuscript binding.", error);
setDocumentMessage("Unable to unlink from PlotDirector.");
const removeLocalOnly = window.confirm("PlotDirector could not be updated, but you can still remove the binding from this Word document.\n\nYou may need to unlink the Book manually in PlotDirector later.\n\nRemove binding from this Word document only?");
if (removeLocalOnly) {
try {
await removeDocumentBinding();
await returnToUnboundOnboarding("Word metadata removed. Unlink the Book manually in PlotDirector if it still shows as linked.");
} catch (metadataError) {
console.error("Unable to remove Word document metadata.", metadataError);
setDocumentMessage(`Unable to remove Word metadata: ${errorText(metadataError)}`);
}
} else {
setDocumentMessage(`Unable to unlink manuscript: ${errorText(error)}`);
}
} finally {
if (unlinkWordDocumentButton) {
unlinkWordDocumentButton.disabled = false;
unlinkWordDocumentButton.textContent = "Unlink this Word document";
}
updateUnlinkWordDocumentButton();
}
};
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 verifyCurrentWordSceneMatchesLinkedScene = async () => {
if (!resolvedSceneId) {
return false;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
try {
const structure = await window.Word.run(async (context) => {
return await readDocumentStructure(context);
});
const currentChapterTitle = structure.currentChapter?.title || "";
const currentSceneTitle = structure.currentScene?.title || "";
const currentSceneAnchorId = structure.currentScene?.anchorId || null;
const currentChapterAnchorId = structure.currentChapter?.anchorId || null;
const anchoredSceneMatches = Number.isInteger(currentSceneAnchorId) && currentSceneAnchorId === resolvedSceneId;
const anchoredChapterMatches = Number.isInteger(currentChapterAnchorId) && currentChapterAnchorId === resolvedChapterId;
const titleSceneMatches = normalizeTitleForResolve(currentSceneTitle, "scene").toLocaleLowerCase()
=== normalizeTitleForResolve(lastLinkedSceneTitle || detectedSceneTitle, "scene").toLocaleLowerCase();
const titleChapterMatches = normalizeTitleForResolve(currentChapterTitle, "chapter").toLocaleLowerCase()
=== normalizeTitleForResolve(lastLinkedChapterTitle || detectedChapterTitle, "chapter").toLocaleLowerCase();
const matches = anchoredSceneMatches || (titleSceneMatches && (anchoredChapterMatches || titleChapterMatches));
if (!matches) {
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
setSceneContextStale(false);
return true;
} catch (error) {
console.error("Unable to verify current Word scene before saving.", error);
setDiagnostics({ lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
};
const saveSceneLinks = async () => {
if (!resolvedSceneId) {
setSceneLinksStatus("Link a PlotDirector scene first.");
return;
}
if (sceneContextStale || !(await verifyCurrentWordSceneMatchesLinkedScene())) {
setSceneLinksStatus("The Word cursor is now in a different scene. Refresh Current Scene before saving.");
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 || sceneContextStale;
saveSceneLinksButton.textContent = "Save Scene Links";
}
}
};
const findAnchorControl = (paragraph, prefix) => {
const controls = safeContentControlItems(paragraph);
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 attachResolvedChapterId = async (successMessage) => {
if (!resolvedChapterId) {
setChapterCreateLinkStatus("No resolved PlotDirector chapter.");
return false;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setChapterCreateLinkStatus("Word document access is unavailable.");
return false;
}
try {
await window.Word.run(async (context) => {
const structure = await readDocumentStructure(context);
if (!structure.currentChapter) {
throw new Error("No chapter detected in Word. Use Heading 1 for chapters.");
}
const chapterParagraph = structure.bodyParagraphs[structure.currentChapter.paragraphIndex];
if (!chapterParagraph) {
throw new Error("Unable to locate the current Word chapter heading.");
}
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
await context.sync();
});
detectedChapterAnchorId = resolvedChapterId;
storedAnchorInvalid = false;
currentResolutionAnchorType = "ChapterID Anchor";
currentResolutionMethod = "Chapter anchor";
setChapterCreateLinkStatus(successMessage);
setDocumentMessage(successMessage);
setDiagnostics({ lastError: "-" });
updateAnchorDisplay();
return true;
} catch (error) {
console.error("Unable to attach PlotDirector chapter ID.", error);
setChapterCreateLinkStatus("Unable to attach PlotDirector chapter ID.");
setDiagnostics({ lastError: errorText(error) });
updateAnchorDisplay();
return false;
}
};
const attachPlotDirectorIds = async () => {
await attachResolvedPlotDirectorIds();
};
const refreshAfterChapterLink = async (message) => {
hideChapterCreateLink();
await refreshPlotDirectorScene();
setDocumentMessage(message);
setChapterCreateLinkStatus(message);
};
const closeCreateChapterDialog = (confirmed) => {
if (pendingCreateChapterConfirmation) {
pendingCreateChapterConfirmation(confirmed);
pendingCreateChapterConfirmation = null;
}
if (createChapterDialog?.close) {
createChapterDialog.close();
} else if (createChapterDialog) {
createChapterDialog.hidden = true;
}
};
const confirmCreateChapter = (chapterTitle, bookTitle) => {
setText(createChapterDialogChapter, `"${chapterTitle}"`);
setText(createChapterDialogBook, `"${bookTitle}"`);
if (!createChapterDialog) {
return Promise.resolve(window.confirm(`Create PlotDirector chapter:\n\n"${chapterTitle}"\n\nin book:\n\n"${bookTitle}"\n\n?`));
}
return new Promise((resolve) => {
pendingCreateChapterConfirmation = resolve;
if (createChapterDialog.showModal) {
createChapterDialog.showModal();
} else {
createChapterDialog.hidden = false;
}
});
};
const createPlotDirectorChapter = async () => {
const book = selectedBook();
if (!book || !detectedChapterTitle || resolvedChapterId) {
showChapterCreateLink("Chapter not found in PlotDirector.");
return;
}
const chapterTitle = normalizeWhitespace(detectedChapterTitle);
const confirmed = await confirmCreateChapter(chapterTitle, bookDisplayTitle(book) || "selected book");
if (!confirmed) {
return;
}
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = true;
createPlotDirectorChapterButton.textContent = "Creating...";
}
try {
const sortOrder = Number.isInteger(detectedChapterIndex) && detectedChapterIndex > 0
? detectedChapterIndex * 10
: null;
const response = await postJson(`/api/word-companion/books/${book.bookId}/chapters`, {
title: chapterTitle,
sortOrder
});
if (!response?.chapterId) {
throw new Error("Chapter creation did not return a chapter ID.");
}
clearPlotDirectorStructureCache();
setResolvedScene(null, response.chapterId, "Manual chapter link");
const anchored = await attachResolvedChapterId("Chapter created and linked successfully.");
if (!anchored) {
return;
}
await refreshAfterChapterLink("Chapter created and linked successfully.");
} catch (error) {
setChapterCreateLinkStatus("Unable to create chapter.");
setDiagnostics({ lastError: errorText(error) });
} finally {
if (createPlotDirectorChapterButton) {
createPlotDirectorChapterButton.disabled = !canUseChapterCreateLinkActions();
createPlotDirectorChapterButton.textContent = "Create Chapter in PlotDirector";
}
}
};
const renderLinkChapterOptions = () => {
if (!linkChapterOptions) {
return;
}
const query = normalizeWhitespace(linkChapterSearch?.value || "").toLocaleLowerCase();
const rows = linkDialogChapters.filter((chapter) => !query || String(chapter.title || "").toLocaleLowerCase().includes(query));
linkChapterOptions.innerHTML = "";
selectedLinkChapterId = null;
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = true;
}
if (rows.length === 0) {
const empty = document.createElement("p");
empty.className = "word-companion-empty-list";
empty.textContent = "No chapters found.";
linkChapterOptions.append(empty);
return;
}
for (const chapter of rows) {
const chapterId = Number.parseInt(chapter.chapterId || "", 10);
if (!Number.isInteger(chapterId) || chapterId <= 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-chapter";
radio.value = String(chapterId);
radio.addEventListener("change", () => {
selectedLinkChapterId = chapterId;
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = false;
}
});
const text = document.createElement("span");
text.textContent = chapter.title || "Untitled chapter";
label.append(radio, text);
linkChapterOptions.append(label);
}
};
const openLinkExistingChapterDialog = async () => {
const book = selectedBook();
if (!book || !detectedChapterTitle || resolvedChapterId) {
showChapterCreateLink("Chapter not found in PlotDirector.");
return;
}
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = true;
linkExistingChapterButton.textContent = "Loading...";
}
try {
linkDialogChapters = await fetchBookChapters(book.bookId);
selectedLinkChapterId = null;
if (linkChapterSearch) {
linkChapterSearch.value = "";
}
setText(linkChapterDialogStatus, "");
renderLinkChapterOptions();
if (linkExistingChapterDialog?.showModal) {
linkExistingChapterDialog.showModal();
} else if (linkExistingChapterDialog) {
linkExistingChapterDialog.hidden = false;
}
} catch (error) {
setChapterCreateLinkStatus("Unable to load chapters.");
setDiagnostics({ lastError: errorText(error) });
} finally {
if (linkExistingChapterButton) {
linkExistingChapterButton.disabled = !canUseChapterCreateLinkActions();
linkExistingChapterButton.textContent = "Link to Existing Chapter";
}
}
};
const closeLinkExistingChapterDialog = () => {
if (linkExistingChapterDialog?.close) {
linkExistingChapterDialog.close();
} else if (linkExistingChapterDialog) {
linkExistingChapterDialog.hidden = true;
}
};
const linkSelectedExistingChapter = async () => {
const chapter = linkDialogChapters.find((item) => item.chapterId === selectedLinkChapterId);
if (!chapter) {
setText(linkChapterDialogStatus, "Select a chapter.");
return;
}
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = true;
linkSelectedChapterButton.textContent = "Linking...";
}
try {
setResolvedScene(null, chapter.chapterId, "Manual chapter link");
const anchored = await attachResolvedChapterId("Chapter linked successfully.");
if (!anchored) {
return;
}
closeLinkExistingChapterDialog();
await refreshAfterChapterLink("Chapter linked successfully.");
} catch (error) {
setText(linkChapterDialogStatus, "Unable to link chapter.");
setChapterCreateLinkStatus("Unable to link chapter.");
setDiagnostics({ lastError: errorText(error) });
} finally {
if (linkSelectedChapterButton) {
linkSelectedChapterButton.disabled = !selectedLinkChapterId;
linkSelectedChapterButton.textContent = "Link Chapter";
}
}
};
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.");
}
clearPlotDirectorStructureCache();
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 finishSceneWordCountSync = () => {
sceneWordCountSyncInFlight = false;
updateSceneActionButtons();
if (sceneWordCountSyncPending) {
sceneWordCountSyncPending = false;
scheduleSceneWordCountSync(1000);
}
};
const syncSceneProgress = async (options = {}) => {
const source = options.source || "manual";
const totalStartedAt = runtimePerformanceNow();
if (!resolvedSceneId) {
setSyncStatus("No resolved PlotDirector scene.");
return;
}
if (!resolvedChapterId) {
setSyncStatus("No resolved PlotDirector chapter.");
return;
}
if (sceneContextStale) {
setSyncStatus("Refresh Current Scene before syncing.");
return;
}
const book = selectedBook();
const documentGuid = currentDocumentGuid();
if (!book || !documentGuid) {
setSyncStatus("Bound manuscript metadata is unavailable.");
return;
}
if (sceneWordCountSyncInFlight) {
sceneWordCountSyncPending = true;
runtimeDebug("Scene sync deferred; sync already running.");
return;
}
clearPendingSceneWordCountSync();
sceneWordCountSyncInFlight = true;
sceneWordCountSyncPending = false;
let snapshot = options.snapshot || null;
try {
snapshot = isSnapshotFresh(snapshot) ? snapshot : await getFreshCurrentSceneSnapshot();
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
runtimeDebug("Scene sync failed.");
finishSceneWordCountSync();
return;
}
if (!isSnapshotFresh(snapshot)) {
setSyncStatus("Waiting for typing to pause");
runtimeDebug("Scene sync skipped (stale snapshot).");
finishSceneWordCountSync();
return;
}
const wordCount = snapshot.wordCount;
if (!Number.isInteger(wordCount) || wordCount < 0) {
setSyncStatus("Sync failed");
runtimeDebug("Scene sync failed.");
finishSceneWordCountSync();
return;
}
if (lastSyncedSceneId === snapshot.sceneId && lastSyncedWordCount === wordCount) {
setSyncStatus("Synced");
runtimeDebug("Sync skipped (count unchanged).");
finishSceneWordCountSync();
return;
}
runtimeDebug("Scene word count changed.");
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = true;
syncSceneProgressButton.textContent = "Syncing...";
}
setSyncStatus("Syncing...");
runtimeDebug(`Scene sync started (${source}).`);
try {
const apiStartedAt = runtimePerformanceNow();
const response = await postJson("/api/word-companion/runtime/scene-word-count", {
documentGuid: snapshot.documentGuid || documentGuid,
bookId: snapshot.bookId || book.bookId,
chapterId: snapshot.chapterId,
sceneId: snapshot.sceneId,
wordCount
});
runtimeTiming(`Word count sync API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Scene sync result ignored (stale snapshot).");
return;
}
const actualWords = response?.actualWords ?? wordCount;
const syncedAt = new Date();
lastSyncedSceneId = snapshot.sceneId;
lastSyncedWordCount = actualWords;
setText(syncPlotDirectorCount, displayValue(actualWords));
setText(plotDirectorActualWords, displayValue(actualWords));
setText(syncLastSynced, formatLastSynced(syncedAt));
setText(runtimeLastSync, formatLastSynced(syncedAt));
setSyncStatus("Synced");
runtimeDebug("Scene sync succeeded.");
runtimeTiming(`Total word count sync: ${Math.round(runtimePerformanceNow() - totalStartedAt)}ms`);
} catch (error) {
setSyncStatus("Sync failed");
setDiagnostics({ lastError: errorText(error) });
runtimeDebug("Scene sync failed.");
} finally {
if (syncSceneProgressButton) {
syncSceneProgressButton.textContent = "Sync Current Scene";
}
finishSceneWordCountSync();
}
};
const scheduleSceneWordCountSync = (delayMs = 4000) => {
clearPendingSceneWordCountSync();
if (!resolvedSceneId || !resolvedChapterId || sceneContextStale || sceneWordCountSyncInFlight) {
return;
}
sceneWordCountSyncTimer = window.setTimeout(() => {
sceneWordCountSyncTimer = null;
void syncSceneProgress({ source: "automatic" });
}, delayMs);
};
const clearPendingCharacterSuggestionDetection = () => {
if (characterSuggestionTimer) {
window.clearTimeout(characterSuggestionTimer);
characterSuggestionTimer = null;
}
};
const resetRuntimeCharacterAliasCache = () => {
characterAliasCacheProjectId = null;
characterAliasCacheDocumentGuid = "";
characterAliasCache = [];
lastSuggestedSceneId = null;
lastSuggestedCharacterSignature = "";
clearPendingCharacterSuggestionDetection();
};
const resetRuntimeAssetAliasCache = () => {
assetAliasCacheProjectId = null;
assetAliasCacheDocumentGuid = "";
assetAliasCache = [];
lastSuggestedAssetSceneId = null;
lastSuggestedAssetSignature = "";
};
const resetRuntimeLocationAliasCache = () => {
locationAliasCacheProjectId = null;
locationAliasCacheDocumentGuid = "";
locationAliasCache = [];
lastSuggestedLocationSceneId = null;
lastSuggestedLocationSignature = "";
};
const loadRuntimeCharacterAliases = async (force = false) => {
const project = selectedProject();
const documentGuid = currentDocumentGuid();
if (!project || !documentGuid) {
characterAliasCache = [];
return [];
}
if (!force
&& characterAliasCacheProjectId === project.projectId
&& characterAliasCacheDocumentGuid === documentGuid
&& characterAliasCache.length > 0) {
return characterAliasCache;
}
const response = await fetchJson(`/api/word-companion/runtime/characters?projectId=${encodeURIComponent(project.projectId)}&documentGuid=${encodeURIComponent(documentGuid)}`);
characterAliasCacheProjectId = project.projectId;
characterAliasCacheDocumentGuid = documentGuid;
characterAliasCache = Array.isArray(response?.characters) ? response.characters : [];
runtimeDebug("Character alias cache refreshed.");
return characterAliasCache;
};
const loadRuntimeAssetAliases = async (force = false) => {
const project = selectedProject();
const documentGuid = currentDocumentGuid();
if (!project || !documentGuid) {
assetAliasCache = [];
return [];
}
if (!force
&& assetAliasCacheProjectId === project.projectId
&& assetAliasCacheDocumentGuid === documentGuid
&& assetAliasCache.length > 0) {
return assetAliasCache;
}
const response = await fetchJson(`/api/word-companion/runtime/assets?projectId=${encodeURIComponent(project.projectId)}&documentGuid=${encodeURIComponent(documentGuid)}`);
assetAliasCacheProjectId = project.projectId;
assetAliasCacheDocumentGuid = documentGuid;
assetAliasCache = Array.isArray(response?.assets) ? response.assets : [];
runtimeDebug(`Asset cache loaded: ${assetAliasCache.length} aliases.`);
return assetAliasCache;
};
const loadRuntimeLocationAliases = async (force = false) => {
const project = selectedProject();
const documentGuid = currentDocumentGuid();
if (!project || !documentGuid) {
locationAliasCache = [];
return [];
}
if (!force
&& locationAliasCacheProjectId === project.projectId
&& locationAliasCacheDocumentGuid === documentGuid
&& locationAliasCache.length > 0) {
return locationAliasCache;
}
const response = await fetchJson(`/api/word-companion/runtime/locations?projectId=${encodeURIComponent(project.projectId)}&documentGuid=${encodeURIComponent(documentGuid)}`);
locationAliasCacheProjectId = project.projectId;
locationAliasCacheDocumentGuid = documentGuid;
locationAliasCache = Array.isArray(response?.locations) ? response.locations : [];
runtimeDebug(`Location cache loaded: ${locationAliasCache.length} aliases.`);
return locationAliasCache;
};
const escapeRegex = (value) => String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasWholeWordMatch = (text, matchText) => {
const normalized = String(matchText || "").trim();
if (!normalized) {
return false;
}
const pattern = escapeRegex(normalized).replace(/\s+/g, "\\s+");
return new RegExp(`(^|[^\\p{L}\\p{N}_])${pattern}(?=$|[^\\p{L}\\p{N}_])`, "iu").test(text);
};
const detectCharactersInSceneText = (sceneText, aliases) => {
if (!sceneText || !Array.isArray(aliases) || aliases.length === 0) {
return [];
}
const detected = new Map();
for (const item of aliases) {
const characterId = Number.parseInt(item.characterId || "", 10);
if (!Number.isInteger(characterId) || characterId <= 0 || detected.has(characterId)) {
continue;
}
if (hasWholeWordMatch(sceneText, item.matchText)) {
detected.set(characterId, item.name || item.matchText || "Character");
}
}
return [...detected.entries()].map(([characterId, name]) => ({ characterId, name }));
};
const detectAssetsInSceneText = (sceneText, aliases) => {
if (!sceneText || !Array.isArray(aliases) || aliases.length === 0) {
return [];
}
const detected = new Map();
for (const item of aliases) {
const assetId = Number.parseInt(item.assetId || "", 10);
if (!Number.isInteger(assetId) || assetId <= 0 || detected.has(assetId)) {
continue;
}
if (hasWholeWordMatch(sceneText, item.matchText)) {
detected.set(assetId, item.name || item.matchText || "Asset");
}
}
return [...detected.entries()].map(([assetId, name]) => ({ assetId, name }));
};
const countLocationMatchWords = (value) => String(value || "")
.trim()
.split(/\s+/)
.filter(Boolean)
.length;
const isConservativeLocationCandidate = (item) => {
if (item?.excludeFromCompanionDetection) {
return false;
}
const priority = Number.parseInt(item?.detectionPriority || "0", 10);
return countLocationMatchWords(item?.matchText) >= 2 || (Number.isInteger(priority) && priority >= 75);
};
const detectLocationsInSceneText = (sceneText, aliases) => {
if (!sceneText || !Array.isArray(aliases) || aliases.length === 0) {
return [];
}
const detected = new Map();
for (const item of aliases) {
const locationId = Number.parseInt(item.locationId || "", 10);
if (!Number.isInteger(locationId) || locationId <= 0 || detected.has(locationId) || !isConservativeLocationCandidate(item)) {
continue;
}
if (hasWholeWordMatch(sceneText, item.matchText)) {
detected.set(locationId, item.name || item.matchText || "Location");
}
}
return [...detected.entries()].map(([locationId, name]) => ({ locationId, name }));
};
const finishCharacterSuggestionDetection = () => {
characterSuggestionInFlight = false;
if (characterSuggestionPending) {
characterSuggestionPending = false;
scheduleCharacterSuggestionDetection(1500);
}
};
const detectAndSubmitCharacterSuggestions = async () => {
characterSuggestionTimer = null;
if (!resolvedSceneId || sceneContextStale || !currentDocumentGuid()) {
return;
}
if (characterSuggestionInFlight) {
characterSuggestionPending = true;
runtimeDebug("Character suggestion detection deferred; detection already running.");
return;
}
characterSuggestionInFlight = true;
characterSuggestionPending = false;
const totalStartedAt = runtimePerformanceNow();
try {
const snapshot = await getFreshCurrentSceneSnapshot();
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Runtime suggestions skipped (stale snapshot).");
return;
}
const [characterAliases, assetAliases, locationAliases] = await Promise.all([
loadRuntimeCharacterAliases(),
loadRuntimeAssetAliases(),
loadRuntimeLocationAliases()
]);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Runtime suggestions skipped after cache load (stale snapshot).");
return;
}
const sceneText = snapshot.sceneText || "";
const characterStartedAt = runtimePerformanceNow();
const detected = detectCharactersInSceneText(sceneText, characterAliases);
runtimeTiming(`Character detection: ${Math.round(runtimePerformanceNow() - characterStartedAt)}ms`);
const characterIds = detected.map((item) => item.characterId);
const signature = characterIds.slice().sort((left, right) => left - right).join(",");
const assetStartedAt = runtimePerformanceNow();
const detectedAssets = detectAssetsInSceneText(sceneText, assetAliases);
runtimeTiming(`Asset detection: ${Math.round(runtimePerformanceNow() - assetStartedAt)}ms`);
const assetIds = detectedAssets.map((item) => item.assetId);
const assetSignature = assetIds.slice().sort((left, right) => left - right).join(",");
const locationStartedAt = runtimePerformanceNow();
const detectedLocations = detectLocationsInSceneText(sceneText, locationAliases);
runtimeTiming(`Location detection: ${Math.round(runtimePerformanceNow() - locationStartedAt)}ms`);
const locationIds = detectedLocations.map((item) => item.locationId);
const locationSignature = locationIds.slice().sort((left, right) => left - right).join(",");
if (!signature || (lastSuggestedSceneId === resolvedSceneId && lastSuggestedCharacterSignature === signature)) {
runtimeDebug("Character suggestions skipped.");
} else {
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Character suggestions skipped before submit (stale snapshot).");
return;
}
const apiStartedAt = runtimePerformanceNow();
const response = await postJson("/api/word-companion/runtime/character-suggestions", {
documentGuid: snapshot.documentGuid,
sceneId: snapshot.sceneId,
characterIds
});
runtimeTiming(`Character suggestion API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Character suggestion result ignored (stale snapshot).");
return;
}
lastSuggestedSceneId = snapshot.sceneId;
lastSuggestedCharacterSignature = signature;
if ((response?.createdCount || 0) > 0) {
const names = detected.slice(0, 3).map((item) => item.name).join(", ");
const suffix = detected.length > 3 ? ` +${detected.length - 3}` : "";
setDocumentMessage(names ? `Characters detected: ${names}${suffix}` : response.message);
}
runtimeDebug(response?.message || "Character suggestions submitted.");
}
if (!assetSignature || (lastSuggestedAssetSceneId === resolvedSceneId && lastSuggestedAssetSignature === assetSignature)) {
runtimeDebug("Asset suggestions skipped.");
} else {
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Asset suggestions skipped before submit (stale snapshot).");
return;
}
runtimeDebug(`Asset matches found: ${detectedAssets.map((item) => item.name).join(", ")}`);
const apiStartedAt = runtimePerformanceNow();
const assetResponse = await postJson("/api/word-companion/runtime/asset-suggestions", {
documentGuid: snapshot.documentGuid,
sceneId: snapshot.sceneId,
assetIds
});
runtimeTiming(`Asset suggestion API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Asset suggestion result ignored (stale snapshot).");
return;
}
lastSuggestedAssetSceneId = snapshot.sceneId;
lastSuggestedAssetSignature = assetSignature;
if ((assetResponse?.createdCount || 0) > 0) {
const names = detectedAssets.slice(0, 3).map((item) => item.name).join(", ");
const suffix = detectedAssets.length > 3 ? ` +${detectedAssets.length - 3}` : "";
setDocumentMessage(names ? `Assets detected: ${names}${suffix}` : assetResponse.message);
}
runtimeDebug(assetResponse?.message || "Asset suggestions submitted.");
}
if (!locationSignature || (lastSuggestedLocationSceneId === resolvedSceneId && lastSuggestedLocationSignature === locationSignature)) {
runtimeDebug("Location suggestions skipped.");
} else {
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Location suggestions skipped before submit (stale snapshot).");
return;
}
runtimeDebug(`Location matches found: ${detectedLocations.map((item) => item.name).join(", ")}`);
const apiStartedAt = runtimePerformanceNow();
const locationResponse = await postJson("/api/word-companion/runtime/location-suggestions", {
documentGuid: snapshot.documentGuid,
sceneId: snapshot.sceneId,
locationIds
});
runtimeTiming(`Location suggestion API: ${Math.round(runtimePerformanceNow() - apiStartedAt)}ms`);
if (!isSnapshotFresh(snapshot)) {
runtimeDebug("Location suggestion result ignored (stale snapshot).");
return;
}
lastSuggestedLocationSceneId = snapshot.sceneId;
lastSuggestedLocationSignature = locationSignature;
if ((locationResponse?.createdCount || 0) > 0) {
const names = detectedLocations.slice(0, 3).map((item) => item.name).join(", ");
const suffix = detectedLocations.length > 3 ? ` +${detectedLocations.length - 3}` : "";
setDocumentMessage(names ? `Locations detected: ${names}${suffix}` : locationResponse.message);
}
runtimeDebug(locationResponse?.message || "Location suggestions submitted.");
}
runtimeTiming(`Total suggestion pass: ${Math.round(runtimePerformanceNow() - totalStartedAt)}ms`);
} catch (error) {
console.error("Unable to detect runtime suggestions.", error);
setDiagnostics({ lastError: errorText(error) });
} finally {
finishCharacterSuggestionDetection();
}
};
const scheduleCharacterSuggestionDetection = (delayMs = 2500) => {
clearPendingCharacterSuggestionDetection();
if (!resolvedSceneId || sceneContextStale || characterSuggestionInFlight) {
if (characterSuggestionInFlight) {
characterSuggestionPending = true;
}
return;
}
characterSuggestionTimer = window.setTimeout(() => {
void detectAndSubmitCharacterSuggestions();
}, delayMs);
};
const refreshPlotDirectorChapter = async () => {
const project = selectedProject();
const book = selectedBook();
const requestChapterTitle = normalizeTitleForResolve(detectedChapterTitle, "chapter");
setResolveDiagnostics({
projectId: project?.projectId,
projectTitle: project?.title,
bookId: book?.bookId,
bookTitle: book ? bookDisplayTitle(book) : undefined,
detectedChapter: detectedChapterTitle,
detectedScene: detectedSceneTitle,
requestChapter: requestChapterTitle,
requestScene: "",
result: "Not run",
chapterId: null,
sceneId: null
});
if (!book) {
resetPlotDirectorScene("Not detected");
setDocumentMessage("Select a PlotDirector book first.");
return false;
}
if (!detectedChapterTitle) {
resetPlotDirectorScene("Not detected");
setDocumentMessage("No chapter detected in Word. Use Heading 1 for chapters.");
return false;
}
resetPlotDirectorScene("Not detected");
try {
const chapters = await fetchBookChapters(book.bookId);
const anchoredChapter = detectedChapterAnchorId
? chapters.find((chapter) => chapter.chapterId === detectedChapterAnchorId)
: null;
const requestChapterKey = requestChapterTitle.toLocaleLowerCase();
const titleChapter = requestChapterKey
? chapters.find((chapter) => normalizeTitleForResolve(chapter.title, "chapter").toLocaleLowerCase() === requestChapterKey)
: null;
const chapter = anchoredChapter || titleChapter;
if (chapter) {
hideChapterCreateLink();
setResolvedScene(null, chapter.chapterId, anchoredChapter ? "Chapter anchor" : "Chapter title");
currentResolutionAnchorType = anchoredChapter ? "ChapterID Anchor" : "Title Match";
setPlotDirectorSceneStatus("Chapter linked to PlotDirector");
setDocumentMessage("No scene detected in Word. Use Heading 2 for scenes.");
setResolveDiagnostics({
result: "Chapter matched",
chapterId: chapter.chapterId,
sceneId: null
});
return true;
}
setResolvedScene(null);
setPlotDirectorSceneStatus("Chapter not found in PlotDirector.");
setDocumentMessage("Chapter not found in PlotDirector.");
setResolveDiagnostics({
result: "Chapter not matched",
chapterId: null,
sceneId: null
});
showChapterCreateLink("Chapter not found in PlotDirector.");
return false;
} catch (error) {
resetPlotDirectorScene("Not found in PlotDirector");
setDiagnostics({ lastError: errorText(error) });
setDocumentMessage("Unable to load PlotDirector chapter data.");
return false;
}
};
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 ? bookDisplayTitle(book) : undefined,
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) {
currentResolutionAnchorType = "ChapterID Anchor";
setResolveDiagnostics({
result: "Chapter anchor not found",
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) {
if (chapterMatched) {
hideChapterCreateLink();
showSceneCreateLink("Scene not found in PlotDirector.");
} else {
hideSceneCreateLink();
showChapterCreateLink("Chapter not found in PlotDirector.");
}
}
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 hasCurrentWordContextChanged = (structure) => {
const chapterTitle = structure.currentChapter?.title || "";
const sceneTitle = structure.currentScene?.title || "";
const chapterAnchorId = structure.currentChapter?.anchorId || null;
const sceneAnchorId = structure.currentScene?.anchorId || null;
return normalizeTitleForResolve(chapterTitle, "chapter") !== normalizeTitleForResolve(detectedChapterTitle, "chapter")
|| normalizeTitleForResolve(sceneTitle, "scene") !== normalizeTitleForResolve(detectedSceneTitle, "scene")
|| chapterAnchorId !== detectedChapterAnchorId
|| sceneAnchorId !== detectedSceneAnchorId;
};
const refreshCurrentSceneFromSelection = async () => {
selectionRefreshTimer = null;
if (isAutoRefreshingScene) {
runtimeUpdatePending = true;
runtimeDebug("Runtime update deferred; update already running.");
return;
}
if (!isAuthenticated || !selectedBook()) {
runtimeSelectionDirty = false;
runtimeDebug("Runtime update skipped.");
return;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
runtimeSelectionDirty = false;
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
if (!documentStructureCache) {
runtimeSelectionDirty = false;
setSceneTrackingState("Manual");
setDocumentMessage("Document structure is not cached yet. Use Refresh Current Scene.");
return;
}
const idleForMs = Date.now() - runtimeLastActivityAt;
if (runtimeSelectionDirty && idleForMs < runtimeIdleDelayMs) {
runtimeDebug("Runtime update skipped (typing active).");
scheduleSelectionRefresh(runtimeIdleDelayMs - idleForMs);
return;
}
isAutoRefreshingScene = true;
runtimeSelectionDirty = false;
runtimeUpdatePending = false;
setSceneTrackingState("Updating...");
runtimeDebug("Runtime update executed.");
try {
const snapshot = await captureCurrentSceneSnapshot({ includeSelection: true });
if (!isSnapshotFresh(snapshot)) {
setSceneTrackingState("Waiting for typing to pause");
runtimeDebug("Runtime update skipped (stale snapshot).");
return;
}
const changed = !!snapshot.selectionChanged;
if (!changed) {
setSceneContextStale(false);
setSceneTrackingState("Current scene updated");
scheduleSceneWordCountSync();
scheduleCharacterSuggestionDetection();
return;
}
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
} else {
await refreshPlotDirectorScene();
}
setSceneContextStale(false);
setSceneTrackingState("Current scene updated");
setDocumentMessage("Current scene updated.");
scheduleSceneWordCountSync();
scheduleCharacterSuggestionDetection();
} catch (error) {
console.error("Unable to refresh current scene from Word selection.", error);
setDiagnostics({ lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
} finally {
isAutoRefreshingScene = false;
if (runtimeUpdatePending || runtimeSelectionDirty) {
runtimeUpdatePending = false;
scheduleSelectionRefresh(runtimeIdleDelayMs);
}
}
};
const scheduleSelectionRefresh = (delayMs = runtimeIdleDelayMs) => {
if (selectionRefreshTimer) {
window.clearTimeout(selectionRefreshTimer);
}
selectionRefreshTimer = window.setTimeout(refreshCurrentSceneFromSelection, Math.max(0, delayMs));
setSceneTrackingState("Waiting for typing to pause");
runtimeDebug("Runtime update scheduled.");
};
const markRuntimeDirty = (reason = "selection", delayMs = runtimeIdleDelayMs) => {
runtimeSelectionDirty = true;
runtimeLastActivityAt = Date.now();
runtimeGeneration += 1;
lastCurrentSceneSnapshot = null;
clearPendingSceneWordCountSync();
clearPendingCharacterSuggestionDetection();
if (sceneWordCountSyncInFlight) {
sceneWordCountSyncPending = true;
}
if (characterSuggestionInFlight) {
characterSuggestionPending = true;
}
runtimeDebug(`${reason} changed.`);
if (isAutoRefreshingScene) {
runtimeUpdatePending = true;
setSceneTrackingState("Waiting for typing to pause");
return;
}
scheduleSelectionRefresh(delayMs);
};
const cancelPendingRuntimeUpdate = () => {
if (selectionRefreshTimer) {
window.clearTimeout(selectionRefreshTimer);
selectionRefreshTimer = null;
}
runtimeSelectionDirty = false;
runtimeUpdatePending = false;
};
const handleDocumentSelectionChanged = () => {
markRuntimeDirty("Selection");
};
const removeSelectionTracking = () => {
if (!selectionHandlerRegistered
|| !window.Office?.context?.document
|| typeof window.Office.context.document.removeHandlerAsync !== "function"
|| !window.Office?.EventType?.DocumentSelectionChanged) {
return;
}
try {
window.Office.context.document.removeHandlerAsync(
window.Office.EventType.DocumentSelectionChanged,
{ handler: handleDocumentSelectionChanged }
);
selectionHandlerRegistered = false;
} catch (error) {
console.warn("Unable to remove Word selection tracking handler.", error);
}
};
const setupSelectionTracking = () => {
if (!isAuthenticated) {
return;
}
if (!window.Office?.context?.document
|| typeof window.Office.context.document.addHandlerAsync !== "function"
|| !window.Office?.EventType?.DocumentSelectionChanged) {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
try {
window.Office.context.document.addHandlerAsync(
window.Office.EventType.DocumentSelectionChanged,
handleDocumentSelectionChanged,
(result) => {
if (result?.status === window.Office.AsyncResultStatus.Succeeded) {
selectionHandlerRegistered = true;
setSceneTrackingState("Live");
} else {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
}
}
);
window.addEventListener("beforeunload", removeSelectionTracking);
} catch (error) {
console.warn("Unable to register Word selection tracking handler.", error);
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
}
};
const refreshCurrentScene = async () => {
if (!selectedBook()) {
setDocumentMessage("Select a PlotDirector book first.");
resetPlotDirectorScene("Not detected");
return;
}
cancelPendingRuntimeUpdate();
setRefreshCurrentSceneBusy(true);
setSceneTrackingState("Updating...");
try {
await Promise.all([
loadRuntimeCharacterAliases(true),
loadRuntimeAssetAliases(true),
loadRuntimeLocationAliases(true)
]);
const scanned = await refreshDocumentStructure();
if (!scanned) {
return;
}
setSceneContextStale(false);
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
setSceneTrackingState("Current scene updated");
return;
}
await refreshPlotDirectorScene();
setSceneTrackingState("Current scene updated");
} finally {
setRefreshCurrentSceneBusy(false);
}
};
const loadBooks = async (projectId, preferredBookId) => {
documentStructureCache = null;
clearPlotDirectorStructureCache();
if (!projectId) {
books = [];
resetSelect(bookSelect, "Select a project first");
resetSelect(firstRunBookSelect, "Select a project first");
setBookMessage("");
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview();
return;
}
resetSelect(bookSelect, "Loading books...");
setBookMessage("");
resetPlotDirectorScene("Not detected");
resetStructurePreview("Select a book to analyse manuscript structure.");
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.");
resetSelect(firstRunBookSelect, "No books available.");
setBookMessage("No books available.");
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetFirstRunPreview("No books available.");
return;
}
addOptions(bookSelect, "Choose a book", books.map((book) => ({
...book,
displayTitle: bookDisplayTitle(book)
})), "bookId", "displayTitle");
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);
}
syncFirstRunBookOptions(validBook?.bookId || preferredBookId);
updateSummary();
resetPlotDirectorScene(selectedBook() ? "Not detected" : "Not detected");
resetStructurePreview(selectedBook()
? "Ready to analyse manuscript structure."
: "Select a book to analyse manuscript structure.");
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
} catch {
books = [];
resetSelect(bookSelect, "Unable to load books.");
resetSelect(firstRunBookSelect, "Unable to load books.");
setBookMessage("Unable to load books.");
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview("Unable to load books.");
resetFirstRunPreview("Unable to load books.");
}
};
const loadProjects = async () => {
resetSelect(projectSelect, "Loading projects...");
resetSelect(bookSelect, "Select a project first");
resetSelect(firstRunProjectSelect, "Loading projects...");
resetSelect(firstRunBookSelect, "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.");
resetSelect(firstRunProjectSelect, "No projects available.");
setProjectMessage("No projects available.");
writeStoredId(storageKeys.projectId, null);
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetFirstRunPreview("No projects available.");
return;
}
addOptions(projectSelect, "Choose a project", projects, "projectId", "title");
syncFirstRunProjectOptions();
const storedProjectId = readStoredId(storageKeys.projectId);
const validProject = projects.find((project) => project.projectId === storedProjectId);
if (validProject && projectSelect) {
projectSelect.value = String(validProject.projectId);
if (firstRunProjectSelect) {
firstRunProjectSelect.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();
resetFirstRunPreview("Select a Project and Book to begin.");
}
} catch {
projects = [];
books = [];
resetSelect(projectSelect, "Unable to connect to PlotDirector.");
resetSelect(bookSelect, "Select a project first");
resetSelect(firstRunProjectSelect, "Unable to connect to PlotDirector.");
resetSelect(firstRunBookSelect, "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();
resetFirstRunPreview("Unable to connect to PlotDirector.");
}
};
for (const button of tabButtons) {
button.addEventListener("click", () => selectTab(button.dataset.tabButton));
}
restoreActiveTab();
projectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(projectSelect.value || "", 10);
clearRuntimeCaches();
if (firstRunProjectSelect && Number.isInteger(projectId) && projectId > 0) {
firstRunProjectSelect.value = String(projectId);
}
writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null);
writeStoredId(storageKeys.bookId, null);
resetPlotDirectorScene("Not detected");
resetStructurePreview();
resetFirstRunPreview("Select a Book to begin.");
await loadBooks(projectId, null);
refreshCompanionPresence();
});
bookSelect?.addEventListener("change", () => {
const bookId = Number.parseInt(bookSelect.value || "", 10);
clearRuntimeCaches();
writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null);
if (firstRunBookSelect && Number.isInteger(bookId) && bookId > 0) {
firstRunBookSelect.value = String(bookId);
}
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview(selectedBook()
? "Ready to analyse manuscript structure."
: "Select a book to analyse manuscript structure.");
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
refreshCompanionPresence();
});
firstRunProjectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(firstRunProjectSelect.value || "", 10);
clearRuntimeCaches();
if (projectSelect && Number.isInteger(projectId) && projectId > 0) {
projectSelect.value = String(projectId);
}
writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null);
writeStoredId(storageKeys.bookId, null);
resetFirstRunPreview("Select a Book to begin.");
await loadBooks(projectId, null);
refreshCompanionPresence();
});
firstRunBookSelect?.addEventListener("change", () => {
const bookId = Number.parseInt(firstRunBookSelect.value || "", 10);
clearRuntimeCaches();
if (bookSelect && Number.isInteger(bookId) && bookId > 0) {
bookSelect.value = String(bookId);
}
writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null);
updateSummary();
resetFirstRunPreview(selectedFirstRunBook()
? "Ready to scan manuscript structure."
: "Select a Book to begin.");
refreshCompanionPresence();
});
firstRunNewBookTitle?.addEventListener("input", updateFirstRunButtons);
firstRunCreateBookButton?.addEventListener("click", async () => {
const project = selectedFirstRunProject();
const title = String(firstRunNewBookTitle?.value || "").trim();
if (!project || !title) {
updateFirstRunButtons();
return;
}
firstRunCreateBookButton.disabled = true;
setFirstRunStatus("Creating Book...");
try {
const response = await postJson(`/api/word-companion/projects/${project.projectId}/books`, { title });
if (firstRunNewBookTitle) {
firstRunNewBookTitle.value = "";
}
if (projectSelect) {
projectSelect.value = String(project.projectId);
}
await loadBooks(project.projectId, response.bookId);
if (firstRunBookSelect) {
firstRunBookSelect.value = String(response.bookId);
}
setFirstRunStatus("Ready to scan manuscript structure.");
} catch (error) {
console.error("Unable to create Book from Word Companion.", error);
setFirstRunStatus(`Unable to create Book: ${errorText(error)}`);
} finally {
updateFirstRunButtons();
}
});
firstRunScanButton?.addEventListener("click", analyseFirstRunManuscript);
firstRunImportButton?.addEventListener("click", importFirstRunManuscript);
firstRunCreateCharactersButton?.addEventListener("click", createFirstRunCharacters);
firstRunSkipCharactersButton?.addEventListener("click", () => finishFirstRunOnboarding("Character creation skipped."));
firstRunContinueButton?.addEventListener("click", () => finishFirstRunOnboarding());
firstRunCancelButton?.addEventListener("click", () => setFirstRunMode(false));
firstRunImportCancelButton?.addEventListener("click", () => resetFirstRunPreview("Import cancelled."));
refreshDocumentButton?.addEventListener("click", refreshDocumentStructure);
analyseManuscriptStructureButton?.addEventListener("click", analyseManuscriptStructure);
applyStructureSyncButton?.addEventListener("click", applyStructureSync);
applyStructureSyncConfirm?.addEventListener("click", () => closeApplyStructureSyncDialog(true));
applyStructureSyncCancel?.addEventListener("click", () => closeApplyStructureSyncDialog(false));
applyStructureSyncDialog?.addEventListener("cancel", (event) => {
event.preventDefault();
closeApplyStructureSyncDialog(false);
});
refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene);
refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene);
syncSceneProgressButton?.addEventListener("click", () => syncSceneProgress({ source: "manual" }));
saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
unlinkWordDocumentButton?.addEventListener("click", unlinkThisWordDocument);
createPlotDirectorChapterButton?.addEventListener("click", createPlotDirectorChapter);
linkExistingChapterButton?.addEventListener("click", openLinkExistingChapterDialog);
linkChapterSearch?.addEventListener("input", renderLinkChapterOptions);
linkSelectedChapterButton?.addEventListener("click", linkSelectedExistingChapter);
linkChapterDialogCancel?.addEventListener("click", closeLinkExistingChapterDialog);
createChapterDialogConfirm?.addEventListener("click", () => closeCreateChapterDialog(true));
createChapterDialogCancel?.addEventListener("click", () => closeCreateChapterDialog(false));
createChapterDialog?.addEventListener("cancel", (event) => {
event.preventDefault();
closeCreateChapterDialog(false);
});
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);
const projectsLoadPromise = isAuthenticated ? loadProjects() : Promise.resolve();
if (!isAuthenticated) {
resetSelect(projectSelect, "Please sign in to PlotDirector.");
resetSelect(bookSelect, "Please sign in to PlotDirector.");
resetSelect(firstRunProjectSelect, "Please sign in to PlotDirector.");
resetSelect(firstRunBookSelect, "Please sign in to PlotDirector.");
updateSummary();
}
if (!window.Office || typeof window.Office.onReady !== "function") {
setOfficeStatus("Word Companion ready");
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
setDiagnostics({
host: "Unavailable",
platform: "Unavailable",
ready: "No",
wordApi: "No"
});
startCompanionPresence().catch((error) => {
console.debug("Word Companion presence unavailable.", error);
});
return;
}
updateOfficeReadiness()
.then(async () => {
setOfficeStatus("Word Companion ready");
await projectsLoadPromise;
await initialiseFirstRunBinding();
await startCompanionPresence();
if (!wordHostAvailable) {
setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
} else {
setupSelectionTracking();
}
})
.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"
});
});
})();