Phase 10L: Word Companion Structure Sync Preview
This commit is contained in:
parent
ffcbe99028
commit
cb1be6b6ba
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user