Phase 10L.1: Apply Structure Sync.

This commit is contained in:
Nick Beckley 2026-06-14 14:12:15 +01:00
parent cb1be6b6ba
commit 69da86fbd6
3 changed files with 413 additions and 16 deletions

View File

@ -189,7 +189,10 @@
<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 class="word-companion-header-actions">
<button type="button" data-analyse-manuscript-structure disabled>Analyse Manuscript Structure</button>
<button type="button" data-apply-structure-sync disabled>Apply Structure Sync</button>
</div>
</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>
@ -373,6 +376,21 @@
</div>
</form>
</dialog>
<dialog class="word-companion-dialog" data-apply-structure-sync-dialog>
<form method="dialog">
<div class="word-companion-section-header">
<span>Apply Structure Sync?</span>
</div>
<p class="word-companion-dialog-copy">This will:</p>
<div class="word-companion-apply-summary" data-apply-structure-sync-summary></div>
<p class="word-companion-dialog-copy">Manual review items will be skipped.</p>
<div class="word-companion-dialog-actions">
<button type="button" data-apply-structure-sync-confirm>Apply</button>
<button type="button" data-apply-structure-sync-cancel>Cancel</button>
</div>
</form>
</dialog>
</main>
<script src="~/js/word-companion-host.js" asp-append-version="true"></script>
</body>

View File

@ -129,6 +129,13 @@ body {
gap: 8px;
}
.word-companion-header-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.word-companion-section-header button {
min-height: 32px;
border: 1px solid var(--companion-accent);
@ -391,6 +398,18 @@ body {
padding-left: 18px;
}
.word-companion-apply-summary ul {
display: grid;
gap: 4px;
margin: 0;
padding-left: 18px;
}
.word-companion-apply-summary li {
color: var(--companion-ink);
font-size: 0.9rem;
}
.word-companion-dialog {
width: min(360px, calc(100vw - 24px));
border: 1px solid var(--companion-line);

View File

@ -23,6 +23,7 @@
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]");
@ -87,6 +88,10 @@
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]");
@ -137,6 +142,8 @@
let selectedLinkChapterId = null;
let pendingCreateChapterConfirmation = null;
let structurePreviewModel = null;
let isApplyingStructureSync = false;
let pendingApplyStructureSyncConfirmation = null;
let currentSceneLinks = {
characters: [],
assets: [],
@ -257,10 +264,35 @@
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 = !selectedProject() || !selectedBook();
analyseManuscriptStructureButton.disabled = isApplyingStructureSync || !selectedProject() || !selectedBook();
}
updateApplyStructureSyncButton();
};
const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => {
@ -978,7 +1010,21 @@
const titleKey = (title, type) => normalizeTitleForResolve(title, type).toLocaleLowerCase();
const createPreviewItem = ({ category, action, type, wordTitle, match = "", anchorStatus = "None", matches = [], pdChapter = null }) => ({
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,
@ -986,7 +1032,13 @@
match,
anchorStatus,
matches,
pdChapter
pdChapter,
pdScene,
wordChapter,
wordScene,
parentItem,
result: "Pending",
error: ""
});
const addPreviewItem = (model, item) => {
@ -1020,7 +1072,8 @@
wordTitle: wordChapter.title,
match: `Chapter ${anchoredChapter.chapterId}: ${anchoredChapter.title}`,
anchorStatus,
pdChapter: anchoredChapter
pdChapter: anchoredChapter,
wordChapter
});
addPreviewItem(model, item);
return item;
@ -1033,7 +1086,8 @@
wordTitle: wordChapter.title,
anchorStatus: `${anchorStatus} not found`,
matches: [],
pdChapter: null
pdChapter: null,
wordChapter
});
addPreviewItem(model, item);
return item;
@ -1048,7 +1102,8 @@
wordTitle: wordChapter.title,
match: `Chapter ${matches[0].chapterId}: ${matches[0].title}`,
anchorStatus,
pdChapter: matches[0]
pdChapter: matches[0],
wordChapter
});
addPreviewItem(model, item);
return item;
@ -1062,7 +1117,8 @@
wordTitle: wordChapter.title,
anchorStatus,
matches: matches.map((chapter) => `Chapter ${chapter.chapterId}: ${chapter.title}`),
pdChapter: null
pdChapter: null,
wordChapter
});
addPreviewItem(model, item);
return item;
@ -1074,7 +1130,8 @@
type: "Chapter",
wordTitle: wordChapter.title,
anchorStatus,
pdChapter: null
pdChapter: null,
wordChapter
});
addPreviewItem(model, item);
return item;
@ -1091,7 +1148,12 @@
type: "Scene",
wordTitle: wordScene.title,
match: `Scene ${anchored.scene.sceneId}: ${anchored.scene.title}`,
anchorStatus
anchorStatus,
pdChapter: anchored.chapter,
pdScene: anchored.scene,
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
@ -1101,7 +1163,10 @@
action: "manualReview",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus: `${anchorStatus} not found`
anchorStatus: `${anchorStatus} not found`,
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
@ -1113,7 +1178,10 @@
type: "Scene",
wordTitle: wordScene.title,
anchorStatus,
matches: ["Parent chapter requires manual review."]
matches: ["Parent chapter requires manual review."],
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
@ -1124,7 +1192,10 @@
action: "wouldCreateScene",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus
anchorStatus,
wordChapter: chapterPreview?.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
@ -1138,7 +1209,12 @@
type: "Scene",
wordTitle: wordScene.title,
match: `Scene ${matches[0].sceneId}: ${matches[0].title}`,
anchorStatus
anchorStatus,
pdChapter: chapterPreview.pdChapter,
pdScene: matches[0],
wordChapter: chapterPreview.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
@ -1150,7 +1226,10 @@
type: "Scene",
wordTitle: wordScene.title,
anchorStatus,
matches: matches.map((scene) => `Scene ${scene.sceneId}: ${scene.title}`)
matches: matches.map((scene) => `Scene ${scene.sceneId}: ${scene.title}`),
wordChapter: chapterPreview.wordChapter,
wordScene,
parentItem: chapterPreview
}));
return;
}
@ -1160,7 +1239,11 @@
action: "wouldCreateScene",
type: "Scene",
wordTitle: wordScene.title,
anchorStatus
anchorStatus,
pdChapter: chapterPreview.pdChapter,
wordChapter: chapterPreview.wordChapter,
wordScene,
parentItem: chapterPreview
}));
};
@ -1221,6 +1304,12 @@
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;
};
@ -1252,6 +1341,7 @@
structurePreviewResults.append(section);
}
updateApplyStructureSyncButton();
};
const readDocumentStructure = async (context) => {
@ -1387,6 +1477,259 @@
}
};
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;
@ -2429,12 +2772,14 @@
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`);
@ -2458,6 +2803,9 @@
}
updateSummary();
resetPlotDirectorScene(selectedBook() ? "Not detected" : "Not detected");
resetStructurePreview(selectedBook()
? "Ready to analyse manuscript structure."
: "Select a book to analyse manuscript structure.");
} catch {
books = [];
resetSelect(bookSelect, "Unable to load books.");
@ -2465,6 +2813,7 @@
writeStoredId(storageKeys.bookId, null);
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview("Unable to load books.");
}
};
@ -2517,6 +2866,7 @@
writeStoredId(storageKeys.projectId, Number.isInteger(projectId) && projectId > 0 ? projectId : null);
writeStoredId(storageKeys.bookId, null);
resetPlotDirectorScene("Not detected");
resetStructurePreview();
await loadBooks(projectId, null);
});
@ -2525,10 +2875,20 @@
writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null);
updateSummary();
resetPlotDirectorScene("Not detected");
resetStructurePreview(selectedBook()
? "Ready to analyse manuscript structure."
: "Select a book to analyse manuscript structure.");
});
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);