Document Outline
diff --git a/PlotLine/wwwroot/css/word-companion.css b/PlotLine/wwwroot/css/word-companion.css
index 3a64cc4..9bc0208 100644
--- a/PlotLine/wwwroot/css/word-companion.css
+++ b/PlotLine/wwwroot/css/word-companion.css
@@ -343,6 +343,54 @@ body {
border-color: rgba(47, 111, 99, 0.38);
}
+.word-companion-structure-preview {
+ border-color: rgba(47, 111, 99, 0.28);
+}
+
+.word-companion-preview-groups,
+.word-companion-preview-group,
+.word-companion-preview-item {
+ display: grid;
+ gap: 6px;
+}
+
+.word-companion-preview-group {
+ border-top: 1px solid var(--companion-line);
+ padding-top: 8px;
+}
+
+.word-companion-preview-group:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.word-companion-preview-item {
+ border: 1px solid rgba(47, 111, 99, 0.18);
+ border-radius: 6px;
+ padding: 7px;
+ background: #ffffff;
+}
+
+.word-companion-preview-item span,
+.word-companion-preview-matches li {
+ color: var(--companion-ink);
+ font-size: 0.86rem;
+ font-weight: 500;
+ text-transform: none;
+}
+
+.word-companion-preview-matches {
+ display: grid;
+ gap: 3px;
+}
+
+.word-companion-preview-matches ul {
+ display: grid;
+ gap: 3px;
+ margin: 0;
+ padding-left: 18px;
+}
+
.word-companion-dialog {
width: min(360px, calc(100vw - 24px));
border: 1px solid var(--companion-line);
diff --git a/PlotLine/wwwroot/js/word-companion-host.js b/PlotLine/wwwroot/js/word-companion-host.js
index c8a934c..da801d5 100644
--- a/PlotLine/wwwroot/js/word-companion-host.js
+++ b/PlotLine/wwwroot/js/word-companion-host.js
@@ -22,6 +22,9 @@
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 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]");
@@ -133,6 +136,7 @@
let linkDialogChapters = [];
let selectedLinkChapterId = null;
let pendingCreateChapterConfirmation = null;
+ let structurePreviewModel = null;
let currentSceneLinks = {
characters: [],
assets: [],
@@ -249,6 +253,16 @@
setText(syncStatus, message);
};
+ const setStructurePreviewStatus = (message) => {
+ setText(structurePreviewStatus, message);
+ };
+
+ const setAnalyseButtonEnabled = () => {
+ if (analyseManuscriptStructureButton) {
+ analyseManuscriptStructureButton.disabled = !selectedProject() || !selectedBook();
+ }
+ };
+
const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => {
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null;
@@ -780,6 +794,7 @@
if (syncNote) {
syncNote.textContent = book ? "" : "Select a PlotDirector book before refreshing.";
}
+ setAnalyseButtonEnabled();
};
const normalizedStyleName = (paragraph) => {
@@ -937,6 +952,308 @@
}
};
+ 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 }) => ({
+ category,
+ action,
+ type,
+ wordTitle,
+ match,
+ anchorStatus,
+ matches,
+ pdChapter
+ });
+
+ 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
+ });
+ addPreviewItem(model, item);
+ return item;
+ }
+
+ const item = createPreviewItem({
+ category: "manualReview",
+ action: "manualReview",
+ type: "Chapter",
+ wordTitle: wordChapter.title,
+ anchorStatus: `${anchorStatus} not found`,
+ matches: [],
+ pdChapter: null
+ });
+ 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]
+ });
+ 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
+ });
+ addPreviewItem(model, item);
+ return item;
+ }
+
+ const item = createPreviewItem({
+ category: "wouldCreateChapters",
+ action: "wouldCreateChapter",
+ type: "Chapter",
+ wordTitle: wordChapter.title,
+ anchorStatus,
+ pdChapter: null
+ });
+ 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
+ }));
+ return;
+ }
+
+ addPreviewItem(model, createPreviewItem({
+ category: "manualReview",
+ action: "manualReview",
+ type: "Scene",
+ wordTitle: wordScene.title,
+ anchorStatus: `${anchorStatus} not found`
+ }));
+ 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."]
+ }));
+ return;
+ }
+
+ if (!chapterPreview?.pdChapter) {
+ addPreviewItem(model, createPreviewItem({
+ category: "wouldCreateScenes",
+ action: "wouldCreateScene",
+ type: "Scene",
+ wordTitle: wordScene.title,
+ anchorStatus
+ }));
+ 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
+ }));
+ 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}`)
+ }));
+ return;
+ }
+
+ addPreviewItem(model, createPreviewItem({
+ category: "wouldCreateScenes",
+ action: "wouldCreateScene",
+ type: "Scene",
+ wordTitle: wordScene.title,
+ anchorStatus
+ }));
+ };
+
+ 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);
+ 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);
+ }
+ };
+
const readDocumentStructure = async (context) => {
const bodyParagraphs = context.document.body.paragraphs;
const selectionParagraphs = context.document.getSelection().paragraphs;
@@ -1012,6 +1329,64 @@
}
};
+ 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 runDiagnostics = async () => {
if (runDiagnosticsButton) {
runDiagnosticsButton.disabled = true;
@@ -2153,6 +2528,7 @@
});
refreshDocumentButton?.addEventListener("click", refreshDocumentStructure);
+ analyseManuscriptStructureButton?.addEventListener("click", analyseManuscriptStructure);
refreshPlotDirectorSceneButton?.addEventListener("click", refreshPlotDirectorScene);
refreshCurrentSceneButton?.addEventListener("click", refreshCurrentScene);
syncSceneProgressButton?.addEventListener("click", syncSceneProgress);