3977 lines
163 KiB
JavaScript
3977 lines
163 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 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 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 firstRunAnalysis = null;
|
|
let firstRunImportStructure = null;
|
|
let firstRunCharacterCandidatesModel = [];
|
|
let firstRunStructureImported = false;
|
|
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 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) => {
|
|
const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`);
|
|
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();
|
|
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 = 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;
|
|
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);
|
|
|
|
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 || "").toLowerCase() !== "very low";
|
|
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.toLowerCase()}`
|
|
: `${Number(candidate.mentionCount || 0).toLocaleString()} mentions`;
|
|
|
|
label.append(checkbox, name, mentions);
|
|
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 paragraphAnchorId = (paragraph, prefix) => {
|
|
const controls = Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : [];
|
|
for (const control of controls) {
|
|
const id = anchorIdFromTag(control.tag, prefix);
|
|
if (id) {
|
|
return id;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
const buildDocumentStructure = (paragraphs, selectionParagraphs) => {
|
|
const chapters = [];
|
|
let heading1Count = 0;
|
|
let heading2Count = 0;
|
|
let chapter = null;
|
|
let scene = null;
|
|
|
|
paragraphs.forEach((paragraph, index) => {
|
|
const title = String(paragraph.text || "").trim();
|
|
if (!title) {
|
|
return;
|
|
}
|
|
|
|
if (isBuiltInHeading(paragraph, 1)) {
|
|
heading1Count += 1;
|
|
chapter = {
|
|
index: chapters.length + 1,
|
|
paragraphIndex: index,
|
|
title,
|
|
anchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"),
|
|
scenes: []
|
|
};
|
|
chapters.push(chapter);
|
|
scene = null;
|
|
return;
|
|
}
|
|
|
|
if (isBuiltInHeading(paragraph, 2)) {
|
|
heading2Count += 1;
|
|
if (!chapter) {
|
|
return;
|
|
}
|
|
|
|
scene = {
|
|
index: chapter.scenes.length + 1,
|
|
paragraphIndex: index,
|
|
title,
|
|
anchorId: paragraphAnchorId(paragraph, "PD-SCENE"),
|
|
wordCount: 0
|
|
};
|
|
chapter.scenes.push(scene);
|
|
return;
|
|
}
|
|
|
|
if (scene) {
|
|
scene.wordCount += countWords(title);
|
|
}
|
|
});
|
|
|
|
const selectedParagraph = selectionParagraphs.find((paragraph) => String(paragraph.text || "").trim());
|
|
const selectedIndex = selectedParagraph
|
|
? paragraphs.findIndex((paragraph) => paragraphMatches(paragraph, selectedParagraph))
|
|
: -1;
|
|
const detectedChapter = selectedIndex >= 0
|
|
? chapters.filter((item) => item.paragraphIndex <= selectedIndex).at(-1) || null
|
|
: null;
|
|
const detectedScene = detectedChapter && selectedIndex >= 0
|
|
? detectedChapter.scenes.filter((item) => item.paragraphIndex <= selectedIndex).at(-1) || null
|
|
: null;
|
|
|
|
return {
|
|
chapters,
|
|
currentChapter: detectedChapter,
|
|
currentScene: detectedScene,
|
|
paragraphCount: paragraphs.length,
|
|
heading1Count,
|
|
heading2Count
|
|
};
|
|
};
|
|
|
|
const 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 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();
|
|
|
|
for (const paragraph of bodyParagraphs.items) {
|
|
if (isBuiltInHeading(paragraph, 1) || isBuiltInHeading(paragraph, 2)) {
|
|
paragraph.contentControls.load("items/tag,title");
|
|
}
|
|
}
|
|
await context.sync();
|
|
|
|
const structure = buildDocumentStructure(bodyParagraphs.items, selectionParagraphs.items);
|
|
structure.bodyParagraphs = bodyParagraphs.items;
|
|
return structure;
|
|
};
|
|
|
|
const refreshDocumentStructure = async () => {
|
|
setDocumentMessage("");
|
|
|
|
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
|
|
setCurrentStructure(null, null, null);
|
|
setDocumentMessage("Word document access is unavailable.");
|
|
setDiagnostics({ lastScan: "Failure", lastError: "Word document access is unavailable." });
|
|
return false;
|
|
}
|
|
|
|
if (refreshDocumentButton) {
|
|
refreshDocumentButton.disabled = true;
|
|
refreshDocumentButton.textContent = "Scanning...";
|
|
}
|
|
setDocumentMessage("Scanning document structure...");
|
|
|
|
try {
|
|
const structure = await window.Word.run(async (context) => {
|
|
return await readDocumentStructure(context);
|
|
});
|
|
|
|
setCurrentStructure(
|
|
structure.currentChapter?.title,
|
|
structure.currentScene?.title,
|
|
structure.currentScene?.wordCount,
|
|
structure.currentChapter?.anchorId,
|
|
structure.currentScene?.anchorId,
|
|
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.");
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
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) {
|
|
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;
|
|
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.");
|
|
} else {
|
|
setFirstRunMode(false);
|
|
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 = () => {
|
|
setFirstRunMode(false);
|
|
setFirstRunStatus("");
|
|
setHidden(firstRunCharacterActions, true);
|
|
};
|
|
|
|
const createFirstRunCharacters = async () => {
|
|
const project = selectedFirstRunProject() || selectedProject();
|
|
const candidateNames = selectedFirstRunCharacterNames();
|
|
if (!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 initialiseFirstRunBinding = async () => {
|
|
if (!isAuthenticated || !wordHostAvailable) {
|
|
setFirstRunMode(false);
|
|
return;
|
|
}
|
|
|
|
const binding = readDocumentBinding();
|
|
if (binding) {
|
|
try {
|
|
const response = await fetchJson(`/api/word-companion/manuscript/book/${binding.bookId}`);
|
|
const document = response?.manuscriptDocument;
|
|
if (document && String(document.documentGuid).toLowerCase() === binding.documentGuid.toLowerCase()) {
|
|
firstRunDocumentBinding = binding;
|
|
writeStoredId(storageKeys.projectId, binding.projectId);
|
|
writeStoredId(storageKeys.bookId, binding.bookId);
|
|
if (projectSelect) {
|
|
projectSelect.value = String(binding.projectId);
|
|
}
|
|
await loadBooks(binding.projectId, binding.bookId);
|
|
setFirstRunMode(false);
|
|
setConnectionStatus("Connected");
|
|
return;
|
|
}
|
|
} catch {
|
|
// If the saved binding no longer resolves, treat the document as unbound.
|
|
}
|
|
}
|
|
|
|
firstRunDocumentBinding = null;
|
|
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 = Array.isArray(paragraph?.contentControls?.items) ? paragraph.contentControls.items : [];
|
|
return controls.find((control) => anchorIdFromTag(control.tag, prefix)) || null;
|
|
};
|
|
|
|
const ensureAnchorControl = (paragraph, title, tag, prefix) => {
|
|
const existing = findAnchorControl(paragraph, prefix);
|
|
const control = existing || paragraph.getRange().insertContentControl();
|
|
control.title = title;
|
|
control.tag = tag;
|
|
control.appearance = "Hidden";
|
|
return control;
|
|
};
|
|
|
|
const attachResolvedPlotDirectorIds = async (successMessage = "PlotDirector IDs attached successfully.") => {
|
|
if (!resolvedSceneId || !resolvedChapterId) {
|
|
setDocumentMessage("No resolved PlotDirector scene.");
|
|
return false;
|
|
}
|
|
|
|
const replacingExistingAnchor = (detectedSceneAnchorId && detectedSceneAnchorId !== resolvedSceneId)
|
|
|| (detectedChapterAnchorId && detectedChapterAnchorId !== resolvedChapterId);
|
|
if (replacingExistingAnchor && !window.confirm("This scene is already linked. Replace the existing PlotDirector link?")) {
|
|
return false;
|
|
}
|
|
|
|
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
|
|
setDocumentMessage("Word document access is unavailable.");
|
|
return false;
|
|
}
|
|
|
|
if (attachPlotDirectorIdsButton) {
|
|
attachPlotDirectorIdsButton.disabled = true;
|
|
attachPlotDirectorIdsButton.textContent = "Attaching...";
|
|
}
|
|
|
|
try {
|
|
await window.Word.run(async (context) => {
|
|
const structure = await readDocumentStructure(context);
|
|
if (!structure.currentChapter || !structure.currentScene) {
|
|
throw new Error("No scene detected in Word. Use Heading 2 for scenes.");
|
|
}
|
|
|
|
const chapterParagraph = structure.bodyParagraphs[structure.currentChapter.paragraphIndex];
|
|
const sceneParagraph = structure.bodyParagraphs[structure.currentScene.paragraphIndex];
|
|
if (!chapterParagraph || !sceneParagraph) {
|
|
throw new Error("Unable to locate the current Word headings.");
|
|
}
|
|
|
|
ensureAnchorControl(chapterParagraph, "PlotDirector Chapter", `PD-CHAPTER-${resolvedChapterId}`, "PD-CHAPTER");
|
|
ensureAnchorControl(sceneParagraph, "PlotDirector Scene", `PD-SCENE-${resolvedSceneId}`, "PD-SCENE");
|
|
await context.sync();
|
|
});
|
|
|
|
detectedChapterAnchorId = resolvedChapterId;
|
|
detectedSceneAnchorId = resolvedSceneId;
|
|
storedAnchorInvalid = false;
|
|
currentResolutionAnchorType = "SceneID Anchor";
|
|
currentResolutionMethod = "Anchor";
|
|
setDocumentMessage(successMessage);
|
|
setDiagnostics({ lastError: "-" });
|
|
updateAnchorDisplay();
|
|
return true;
|
|
} catch (error) {
|
|
console.error("Unable to attach PlotDirector IDs.", error);
|
|
setDocumentMessage("Unable to attach PlotDirector IDs.");
|
|
setDiagnostics({ lastError: errorText(error) });
|
|
updateAnchorDisplay();
|
|
return false;
|
|
} finally {
|
|
if (attachPlotDirectorIdsButton) {
|
|
attachPlotDirectorIdsButton.textContent = detectedSceneAnchorId === resolvedSceneId
|
|
? "Attach PlotDirector IDs"
|
|
: "Reattach PlotDirector IDs";
|
|
updateAnchorDisplay();
|
|
}
|
|
}
|
|
};
|
|
|
|
const 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.");
|
|
}
|
|
|
|
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.");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
isAutoRefreshingScene = true;
|
|
setSceneTrackingState("Refreshing");
|
|
setDocumentMessage("Updating current scene...");
|
|
|
|
try {
|
|
const structure = await window.Word.run(async (context) => {
|
|
return await readDocumentStructure(context);
|
|
});
|
|
const changed = hasCurrentWordContextChanged(structure);
|
|
setCurrentStructure(
|
|
structure.currentChapter?.title,
|
|
structure.currentScene?.title,
|
|
structure.currentScene?.wordCount,
|
|
structure.currentChapter?.anchorId,
|
|
structure.currentScene?.anchorId,
|
|
structure.currentChapter?.index
|
|
);
|
|
renderOutline(structure);
|
|
setDiagnostics({
|
|
lastScan: "Success",
|
|
lastError: "-",
|
|
paragraphs: String(structure.paragraphCount),
|
|
heading1: String(structure.heading1Count),
|
|
heading2: String(structure.heading2Count)
|
|
});
|
|
|
|
if (!changed) {
|
|
setSceneContextStale(false);
|
|
setSceneTrackingState("Live");
|
|
setDocumentMessage("Current scene updated.");
|
|
return;
|
|
}
|
|
|
|
if (!detectedSceneTitle) {
|
|
await refreshPlotDirectorChapter();
|
|
} else {
|
|
await refreshPlotDirectorScene();
|
|
}
|
|
setSceneContextStale(false);
|
|
setSceneTrackingState("Live");
|
|
setDocumentMessage("Current scene updated.");
|
|
} catch (error) {
|
|
console.error("Unable to refresh current scene from Word selection.", error);
|
|
setDiagnostics({ lastScan: "Failure", 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) => {
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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);
|
|
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"
|
|
});
|
|
});
|
|
})();
|