Phase 10L: Word Companion Structure Sync Preview

This commit is contained in:
Nick Beckley 2026-06-14 12:27:57 +01:00
parent ffcbe99028
commit cb1be6b6ba
3 changed files with 435 additions and 0 deletions

View File

@ -186,6 +186,17 @@
</div>
</section>
<section class="word-companion-section word-companion-structure-preview" aria-label="Structure Sync Preview">
<div class="word-companion-section-header">
<span>Structure Sync Preview</span>
<button type="button" data-analyse-manuscript-structure disabled>Analyse Manuscript Structure</button>
</div>
<p class="word-companion-message" data-structure-preview-status>Select a project and book to analyse manuscript structure.</p>
<div class="word-companion-preview-groups" data-structure-preview-results>
<p>No preview generated.</p>
</div>
</section>
<details class="word-companion-section word-companion-collapsible">
<summary>Document Outline</summary>
<div class="word-companion-outline" data-document-outline>

View File

@ -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);

View File

@ -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);