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

4323 lines
176 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", "links", "structure", "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 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 officeStatus = document.querySelector("[data-office-status]");
const connectionStatus = document.querySelector("[data-connection-status]");
const summaryConnection = document.querySelector("[data-summary-connection]");
const summaryProject = document.querySelector("[data-summary-project]");
const summaryBook = document.querySelector("[data-summary-book]");
const projectSelect = document.querySelector("[data-project-select]");
const bookSelect = document.querySelector("[data-book-select]");
const projectMessage = document.querySelector("[data-project-message]");
const bookMessage = document.querySelector("[data-book-message]");
const refreshCurrentSceneButton = document.querySelector("[data-refresh-current-scene]");
const refreshDocumentButton = document.querySelector("[data-refresh-document]");
const currentChapter = document.querySelector("[data-current-chapter]");
const currentScene = document.querySelector("[data-current-scene]");
const currentWordCount = document.querySelector("[data-current-word-count]");
const 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 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 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";
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 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 selectionHandlerRegistered = false;
let isAutoRefreshingScene = false;
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
let currentSceneLinks = {
characters: [],
assets: [],
locations: [],
primaryLocationId: null
};
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 selectTab = (tabName, persist = true) => {
const targetTab = validTabNames.has(tabName) && tabButtons.some((button) => button.dataset.tabButton === tabName)
? tabName
: "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 = 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) {
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) => {
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 (currentChapter) {
currentChapter.textContent = chapterTitle || "Not detected";
}
if (currentScene) {
currentScene.textContent = sceneTitle || "Not detected";
}
if (currentWordCount) {
currentWordCount.textContent = Number.isInteger(wordCount) ? String(wordCount) : "-";
}
setText(syncWordCount, Number.isInteger(wordCount) ? String(wordCount) : "-");
updateAnchorDisplay();
};
const setHidden = (element, hidden) => {
if (element) {
element.hidden = hidden;
}
};
const setSyncStatus = (message) => {
setText(syncStatus, message);
};
const 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;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = blocked;
}
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;
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 || "";
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 setRuntimeBookContext = (context) => {
runtimeBookContext = context || null;
setText(runtimeLastSync, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc));
setText(syncLastSynced, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc));
};
const clearRuntimeCaches = () => {
documentStructureCache = null;
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
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);
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);
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);
};
const setText = (element, value) => {
if (element) {
element.textContent = value;
}
};
const setDiagnostics = (values) => {
const hasValue = (name) => Object.prototype.hasOwnProperty.call(values, name);
if (hasValue("host")) {
setText(diagnosticHost, values.host);
}
if (hasValue("platform")) {
setText(diagnosticPlatform, values.platform);
}
if (hasValue("ready")) {
setText(diagnosticReady, values.ready);
}
if (hasValue("wordApi")) {
setText(diagnosticWordApi, values.wordApi);
}
if (hasValue("lastScan")) {
setText(diagnosticLastScan, values.lastScan);
}
if (hasValue("lastError")) {
setText(diagnosticLastError, values.lastError);
}
if (hasValue("paragraphs")) {
setText(diagnosticParagraphs, values.paragraphs);
}
if (hasValue("heading1")) {
setText(diagnosticHeading1, values.heading1);
}
if (hasValue("heading2")) {
setText(diagnosticHeading2, values.heading2);
}
};
const errorText = (error) => {
const parts = [];
if (error?.name) {
parts.push(error.name);
}
if (error?.code && error.code !== error.name) {
parts.push(error.code);
}
if (error?.message) {
parts.push(error.message);
}
if (error?.debugInfo?.errorLocation) {
parts.push(`Location: ${error.debugInfo.errorLocation}`);
}
return parts.length > 0 ? parts.join(": ") : String(error);
};
const updateOfficeReadiness = async () => {
if (!window.Office || typeof window.Office.onReady !== "function") {
officeReady = false;
wordHostAvailable = false;
officeHost = "Unavailable";
officePlatform = "Unavailable";
setDiagnostics({
host: officeHost,
platform: officePlatform,
ready: "No",
wordApi: "No"
});
return null;
}
const info = await window.Office.onReady();
officeReady = true;
officeHost = info?.host || "Unknown";
officePlatform = info?.platform || "Unknown";
wordHostAvailable = !!window.Word
&& !!window.Office.HostType
&& info?.host === window.Office.HostType.Word;
setDiagnostics({
host: officeHost,
platform: officePlatform,
ready: "Yes",
wordApi: wordHostAvailable ? "Yes" : "No"
});
return info;
};
const readStoredId = (key) => {
try {
const value = window.localStorage.getItem(key);
const id = Number.parseInt(value || "", 10);
return Number.isInteger(id) && id > 0 ? id : null;
} catch {
return null;
}
};
const writeStoredId = (key, value) => {
try {
if (value) {
window.localStorage.setItem(key, String(value));
} else {
window.localStorage.removeItem(key);
}
} catch {
// Some Office hosts can restrict storage. Selection still works for the current pane session.
}
};
const resetSelect = (select, message) => {
if (!select) {
return;
}
select.innerHTML = "";
const option = document.createElement("option");
option.value = "";
option.textContent = message;
select.append(option);
select.disabled = true;
};
const addOptions = (select, placeholder, rows, idKey, titleKey) => {
if (!select) {
return;
}
select.innerHTML = "";
const placeholderOption = document.createElement("option");
placeholderOption.value = "";
placeholderOption.textContent = placeholder;
select.append(placeholderOption);
for (const row of rows) {
const option = document.createElement("option");
option.value = String(row[idKey]);
option.textContent = row[titleKey];
select.append(option);
}
select.disabled = rows.length === 0;
};
const bookDisplayTitle = (book) => {
const title = String(book?.title || "").trim();
const subtitle = String(book?.subtitle || "").trim();
return subtitle ? `${title}: ${subtitle}` : title;
};
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 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);
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 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 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 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();
};
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 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?.paragraphIndex !== documentStructureCache.currentChapter?.paragraphIndex
|| currentSceneItem?.paragraphIndex !== documentStructureCache.currentScene?.paragraphIndex;
documentStructureCache.currentParagraphIndex = paragraphIndex;
documentStructureCache.currentChapter = currentChapterItem;
documentStructureCache.currentScene = currentSceneItem;
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);
return cache;
};
const readSelectionParagraphs = async (context) => {
const selectionParagraphs = context.document.getSelection().paragraphs;
selectionParagraphs.load("text,styleBuiltIn,style");
await context.sync();
return selectionParagraphs.items;
};
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 || ""
});
renderFirstRunCharacterCandidates(discovery?.candidates || []);
} catch (discoveryError) {
console.error("Unable to discover character candidates.", discoveryError);
renderFirstRunCharacterCandidates([]);
setText(firstRunCharacterStatus, "Character discovery is unavailable.");
}
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 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 {
setFirstRunMode(true);
setHidden(firstRunSummary, true);
setHidden(firstRunImportActions, true);
setHidden(firstRunCharacterSection, false);
setHidden(firstRunCharacterActions, false);
setHidden(firstRunCreateCharactersButton, true);
setHidden(firstRunSkipCharactersButton, true);
setHidden(firstRunContinueButton, false);
setText(firstRunCharacterStatus, "No likely characters found.");
setFirstRunStatus(response.message || "Manuscript structure imported.");
setDocumentMessage(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 = () => {
firstRunCharacterStepActive = false;
firstRunCharacterStepActivatedAt = 0;
firstRunCharacterCandidatesModel = [];
if (firstRunCharacterCandidates) {
firstRunCharacterCandidates.innerHTML = "";
}
setHidden(firstRunCharacterSection, true);
setFirstRunMode(false);
setFirstRunStatus("");
setHidden(firstRunCharacterActions, true);
loadRuntimeDocumentAwareness();
};
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.`);
finishFirstRunOnboarding();
} catch (error) {
console.error("Unable to create discovered characters.", error);
setText(firstRunCharacterStatus, `Unable to create characters: ${errorText(error)}`);
} finally {
isCreatingFirstRunCharacters = false;
updateFirstRunButtons();
}
};
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 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 (!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 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 syncSceneProgress = async () => {
if (!resolvedSceneId) {
setSyncStatus("No resolved PlotDirector scene.");
return;
}
if (sceneContextStale) {
setSyncStatus("Refresh Current Scene before syncing.");
return;
}
if (!Number.isInteger(detectedSceneWordCount)) {
setSyncStatus("Unable to sync progress.");
return;
}
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = true;
syncSceneProgressButton.textContent = "Syncing...";
}
const lastWorkedOn = new Date().toISOString();
try {
const response = await patchJson(`/api/word-companion/scenes/${resolvedSceneId}/word-count`, {
actualWordCount: detectedSceneWordCount,
lastWorkedOn
});
setText(syncPlotDirectorCount, displayValue(response?.actualWords ?? detectedSceneWordCount));
setText(plotDirectorActualWords, displayValue(response?.actualWords ?? detectedSceneWordCount));
setText(syncLastSynced, new Date().toLocaleString());
setSyncStatus("Progress synced successfully.");
} catch {
setSyncStatus("Unable to sync progress.");
} finally {
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId || sceneContextStale;
syncSceneProgressButton.textContent = "Sync Progress";
}
}
};
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 () => {
if (isAutoRefreshingScene || !isAuthenticated || !selectedBook()) {
return;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
if (!documentStructureCache) {
setSceneTrackingState("Manual");
setDocumentMessage("Document structure is not cached yet. Use Refresh Current Scene.");
return;
}
isAutoRefreshingScene = true;
setSceneTrackingState("Live");
try {
const selectionParagraphs = await window.Word.run(async (context) => await readSelectionParagraphs(context));
const selectedIndex = findSelectedParagraphIndex(documentStructureCache, selectionParagraphs);
const changed = applyRuntimeSelection(selectedIndex);
if (!changed) {
setSceneContextStale(false);
setSceneTrackingState("Live");
return;
}
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
} else {
await refreshPlotDirectorScene();
}
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("");
} 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;
}
};
const scheduleSelectionRefresh = () => {
if (selectionRefreshTimer) {
window.clearTimeout(selectionRefreshTimer);
}
selectionRefreshTimer = window.setTimeout(refreshCurrentSceneFromSelection, 850);
};
const handleDocumentSelectionChanged = () => {
scheduleSelectionRefresh();
};
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;
}
setRefreshCurrentSceneBusy(true);
try {
const scanned = await refreshDocumentStructure();
if (!scanned) {
return;
}
setSceneContextStale(false);
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
return;
}
await refreshPlotDirectorScene();
} 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);
});
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.");
});
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);
});
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.");
});
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);
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);
saveSceneLinksButton?.addEventListener("click", saveSceneLinks);
attachPlotDirectorIdsButton?.addEventListener("click", attachPlotDirectorIds);
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"
});
return;
}
updateOfficeReadiness()
.then(async () => {
setOfficeStatus("Word Companion ready");
await projectsLoadPromise;
await initialiseFirstRunBinding();
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"
});
});
})();