Phase 16E - Bound Document Runtime and Current Scene Detection

This commit is contained in:
Nick Beckley 2026-06-27 21:10:20 +01:00
parent 772a144d70
commit f26cd48173
6 changed files with 413 additions and 41 deletions

View File

@ -102,6 +102,13 @@ public sealed class WordCompanionController(IWordCompanionService wordCompanion)
return response is null ? BadRequest() : Ok(response);
}
[HttpGet("runtime/book/{bookId:int}")]
public async Task<IActionResult> GetRuntimeBook(int bookId, [FromQuery] Guid documentGuid)
{
var response = await wordCompanion.GetRuntimeBookAsync(bookId, documentGuid);
return response is null ? NotFound() : Ok(response);
}
[HttpGet("manuscript/book/{bookId:int}")]
public async Task<IActionResult> GetManuscriptForBook(int bookId)
{

View File

@ -238,6 +238,18 @@ public sealed class WordCompanionManuscriptBookResponse
public WordCompanionManuscriptDocumentDto? ManuscriptDocument { get; init; }
}
public sealed class WordCompanionRuntimeBookResponse
{
public int ProjectId { get; init; }
public string ProjectTitle { get; init; } = string.Empty;
public int BookId { get; init; }
public string BookTitle { get; init; } = string.Empty;
public string? BookSubtitle { get; init; }
public bool UsesExplicitScenes { get; init; }
public DateTime? LastSyncUtc { get; init; }
public WordCompanionManuscriptDocumentDto ManuscriptDocument { get; init; } = new();
}
public sealed class WordCompanionManuscriptLinkRequest
{
public int BookId { get; set; }

View File

@ -19,6 +19,7 @@ public interface IWordCompanionService
Task<WordCompanionSyncManuscriptResponse?> SyncManuscriptAsync(int bookId, WordCompanionSyncManuscriptRequest request);
Task<WordCompanionUpdateSceneLinksResponse?> UpdateSceneLinksAsync(int sceneId, WordCompanionUpdateSceneLinksRequest request);
Task<WordCompanionUpdateWordCountResponse?> UpdateWordCountAsync(int sceneId, WordCompanionUpdateWordCountRequest request);
Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid);
Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId);
Task<WordCompanionManuscriptLinkResponse?> LinkManuscriptAsync(WordCompanionManuscriptLinkRequest request);
Task<WordCompanionManuscriptAnalyseResponse?> AnalyseManuscriptAsync(WordCompanionManuscriptAnalyseRequest request);
@ -221,6 +222,50 @@ public sealed class WordCompanionService(
: repository.UpdateWordCountAsync(sceneId, RequireUserId(), actualWords.Value, request.LastWorkedOn);
}
public async Task<WordCompanionRuntimeBookResponse?> GetRuntimeBookAsync(int bookId, Guid documentGuid)
{
if (bookId <= 0 || documentGuid == Guid.Empty)
{
return null;
}
var userId = RequireUserId();
var document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId);
if (document is null || document.BookID != bookId || !document.IsActive)
{
return null;
}
await manuscriptDocuments.UpdateLastOpenedAsync(document.ManuscriptDocumentID, userId);
document = await manuscriptDocuments.GetByGuidAsync(documentGuid, userId) ?? document;
var projects = await repository.ListProjectsAsync(userId);
var project = projects.FirstOrDefault(item => item.ProjectId == document.ProjectID);
if (project is null)
{
return null;
}
var books = await repository.ListBooksAsync(document.ProjectID, userId);
var book = books.FirstOrDefault(item => item.BookId == document.BookID);
if (book is null)
{
return null;
}
return new WordCompanionRuntimeBookResponse
{
ProjectId = project.ProjectId,
ProjectTitle = project.Title,
BookId = book.BookId,
BookTitle = book.Title,
BookSubtitle = book.Subtitle,
UsesExplicitScenes = book.UsesExplicitScenes,
LastSyncUtc = document.LastSyncUtc,
ManuscriptDocument = ToDto(document)
};
}
public async Task<WordCompanionManuscriptBookResponse?> GetManuscriptForBookAsync(int bookId)
{
if (bookId <= 0)

View File

@ -154,6 +154,10 @@
<span>Revision</span>
<strong data-plotdirector-revision-status>-</strong>
</div>
<div>
<span>Last Sync</span>
<strong data-runtime-last-sync>-</strong>
</div>
<div>
<span>Blocked</span>
<strong data-plotdirector-blocked-status>-</strong>

View File

@ -74,6 +74,7 @@
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 runtimeLastSync = document.querySelector("[data-runtime-last-sync]");
const syncSceneProgressButton = document.querySelector("[data-sync-scene-progress]");
const syncWordCount = document.querySelector("[data-sync-word-count]");
const syncPlotDirectorCount = document.querySelector("[data-sync-plotdirector-count]");
@ -177,6 +178,10 @@
let pendingCreateChapterConfirmation = null;
let structurePreviewModel = null;
let firstRunDocumentBinding = null;
let runtimeBookContext = null;
let documentStructureCache = null;
let plotDirectorStructureCache = null;
let plotDirectorStructureBookId = null;
let firstRunAnalysis = null;
let firstRunImportStructure = null;
let firstRunCharacterCandidatesModel = [];
@ -467,6 +472,33 @@
return String(value);
};
const formatDateTime = (value) => {
if (!value) {
return "-";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
};
const setRuntimeBookContext = (context) => {
runtimeBookContext = context || null;
setText(runtimeLastSync, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc));
setText(syncLastSynced, formatDateTime(runtimeBookContext?.lastSyncUtc || runtimeBookContext?.manuscriptDocument?.lastSyncUtc));
};
const clearRuntimeCaches = () => {
documentStructureCache = null;
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
setRuntimeBookContext(null);
};
const clearPlotDirectorStructureCache = () => {
plotDirectorStructureCache = null;
plotDirectorStructureBookId = null;
};
const setSceneLinksStatus = (message) => {
setText(sceneLinksStatus, message);
};
@ -755,7 +787,13 @@
};
const getBookStructureChapters = async (bookId) => {
if (plotDirectorStructureBookId === bookId && Array.isArray(plotDirectorStructureCache?.chapters)) {
return plotDirectorStructureCache.chapters;
}
const structure = await fetchJson(`/api/word-companion/books/${bookId}/structure`);
plotDirectorStructureBookId = bookId;
plotDirectorStructureCache = structure || null;
return Array.isArray(structure?.chapters) ? structure.chapters : [];
};
@ -1405,6 +1443,191 @@
};
};
const sceneSeparatorTexts = new Set(["***", "###"]);
const snapshotParagraph = (paragraph, index) => {
const text = String(paragraph?.text || "").trim();
return {
index,
text,
styleBuiltIn: paragraph?.styleBuiltIn || "",
style: paragraph?.style || "",
wordCount: countWords(text),
chapterAnchorId: paragraphAnchorId(paragraph, "PD-CHAPTER"),
sceneAnchorId: paragraphAnchorId(paragraph, "PD-SCENE")
};
};
const finishRuntimeScene = (scene, endIndex) => {
if (scene) {
scene.endParagraphIndex = endIndex;
}
};
const finishRuntimeChapter = (chapter, endIndex) => {
if (chapter) {
chapter.endParagraphIndex = endIndex;
finishRuntimeScene(chapter.scenes.at(-1), endIndex);
}
};
const buildRuntimeDocumentStructure = (paragraphs, usesExplicitScenes) => {
const paragraphSnapshots = paragraphs.map(snapshotParagraph);
const chapters = [];
let heading1Count = 0;
let sceneBoundaryCount = 0;
let chapter = null;
let scene = null;
let documentWordCount = 0;
const startScene = (startParagraph, title = null, anchorId = null) => {
if (!chapter) {
return null;
}
finishRuntimeScene(scene, startParagraph.index - 1);
scene = {
index: chapter.scenes.length + 1,
paragraphIndex: startParagraph.index,
startParagraphIndex: startParagraph.index,
endParagraphIndex: startParagraph.index,
title: title || `Scene ${chapter.scenes.length + 1}`,
anchorId,
wordCount: 0
};
chapter.scenes.push(scene);
return scene;
};
for (const paragraph of paragraphSnapshots) {
if (!paragraph.text) {
continue;
}
if (isBuiltInHeading(paragraph, 1)) {
finishRuntimeChapter(chapter, paragraph.index - 1);
heading1Count += 1;
chapter = {
index: chapters.length + 1,
paragraphIndex: paragraph.index,
startParagraphIndex: paragraph.index,
endParagraphIndex: paragraph.index,
title: paragraph.text,
anchorId: paragraph.chapterAnchorId,
wordCount: 0,
scenes: []
};
chapters.push(chapter);
scene = null;
startScene(paragraph, usesExplicitScenes ? "Scene 1" : "Scene 1", paragraph.sceneAnchorId);
documentWordCount += paragraph.wordCount;
continue;
}
if (!chapter) {
continue;
}
if (usesExplicitScenes && sceneSeparatorTexts.has(paragraph.text)) {
sceneBoundaryCount += 1;
startScene(paragraph);
continue;
}
chapter.wordCount += paragraph.wordCount;
if (scene) {
scene.wordCount += paragraph.wordCount;
}
documentWordCount += paragraph.wordCount;
}
finishRuntimeChapter(chapter, paragraphSnapshots.length - 1);
return {
paragraphs: paragraphSnapshots,
chapters,
usesExplicitScenes: !!usesExplicitScenes,
paragraphCount: paragraphSnapshots.length,
heading1Count,
heading2Count: sceneBoundaryCount,
documentWordCount,
currentParagraphIndex: null,
currentChapter: null,
currentScene: null
};
};
const paragraphMatchesSnapshot = (snapshot, paragraph) => {
return String(snapshot?.text || "").trim() === String(paragraph?.text || "").trim()
&& normalizedStyleName(snapshot) === normalizedStyleName(paragraph);
};
const findSelectedParagraphIndex = (cache, selectionParagraphs) => {
const selectedParagraph = (selectionParagraphs || []).find((paragraph) => String(paragraph.text || "").trim());
if (!cache || !selectedParagraph) {
return cache?.currentParagraphIndex ?? -1;
}
const matches = cache.paragraphs
.filter((paragraph) => paragraphMatchesSnapshot(paragraph, selectedParagraph))
.map((paragraph) => paragraph.index);
if (matches.length === 0) {
return cache.currentParagraphIndex ?? -1;
}
if (!Number.isInteger(cache.currentParagraphIndex)) {
return matches[0];
}
return matches.reduce((best, candidate) =>
Math.abs(candidate - cache.currentParagraphIndex) < Math.abs(best - cache.currentParagraphIndex) ? candidate : best, matches[0]);
};
const chapterForParagraphIndex = (cache, paragraphIndex) => {
if (!cache || !Number.isInteger(paragraphIndex) || paragraphIndex < 0) {
return null;
}
return cache.chapters
.filter((chapter) => chapter.startParagraphIndex <= paragraphIndex)
.at(-1) || null;
};
const sceneForParagraphIndex = (chapter, paragraphIndex) => {
if (!chapter || !Number.isInteger(paragraphIndex) || paragraphIndex < 0) {
return null;
}
return chapter.scenes
.filter((item) => item.startParagraphIndex <= paragraphIndex)
.at(-1) || null;
};
const applyRuntimeSelection = (paragraphIndex) => {
if (!documentStructureCache) {
return false;
}
const currentChapterItem = chapterForParagraphIndex(documentStructureCache, paragraphIndex);
const currentSceneItem = sceneForParagraphIndex(currentChapterItem, paragraphIndex);
const changed = currentChapterItem?.paragraphIndex !== documentStructureCache.currentChapter?.paragraphIndex
|| currentSceneItem?.paragraphIndex !== documentStructureCache.currentScene?.paragraphIndex;
documentStructureCache.currentParagraphIndex = paragraphIndex;
documentStructureCache.currentChapter = currentChapterItem;
documentStructureCache.currentScene = currentSceneItem;
setCurrentStructure(
currentChapterItem?.title,
currentSceneItem?.title,
currentSceneItem?.wordCount,
currentChapterItem?.anchorId,
currentSceneItem?.anchorId,
currentChapterItem?.index
);
return changed;
};
const renderOutline = (structure) => {
if (!documentOutline) {
return;
@ -1828,6 +2051,35 @@
return structure;
};
const readRuntimeDocumentStructure = async (context, usesExplicitScenes) => {
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)) {
paragraph.contentControls.load("items/tag,title");
}
}
await context.sync();
const cache = buildRuntimeDocumentStructure(bodyParagraphs.items, usesExplicitScenes);
const selectedIndex = findSelectedParagraphIndex(cache, selectionParagraphs.items);
documentStructureCache = cache;
applyRuntimeSelection(selectedIndex);
return cache;
};
const readSelectionParagraphs = async (context) => {
const selectionParagraphs = context.document.getSelection().paragraphs;
selectionParagraphs.load("text,styleBuiltIn,style");
await context.sync();
return selectionParagraphs.items;
};
const refreshDocumentStructure = async () => {
setDocumentMessage("");
@ -1846,9 +2098,16 @@
try {
const structure = await window.Word.run(async (context) => {
const runtimeBook = selectedBook();
if (runtimeBook) {
return await readRuntimeDocumentStructure(context, !!runtimeBook.usesExplicitScenes);
}
documentStructureCache = null;
return await readDocumentStructure(context);
});
if (!documentStructureCache) {
setCurrentStructure(
structure.currentChapter?.title,
structure.currentScene?.title,
@ -1857,6 +2116,7 @@
structure.currentScene?.anchorId,
structure.currentChapter?.index
);
}
resetPlotDirectorScene("Not detected");
renderOutline(structure);
setDocumentMessage("");
@ -2028,6 +2288,7 @@
throw new Error("Chapter creation did not return a chapter ID.");
}
clearPlotDirectorStructureCache();
item.pdChapter = {
chapterId: response.chapterId,
title: response.title || item.wordTitle,
@ -2052,6 +2313,7 @@
throw new Error("Scene creation did not return a scene ID.");
}
clearPlotDirectorStructureCache();
item.pdChapter = chapter;
item.pdScene = {
sceneId: response.sceneId,
@ -2188,6 +2450,7 @@
isApplyingStructureSync = false;
setAnalyseButtonEnabled();
if (finalStatus) {
clearPlotDirectorStructureCache();
await analyseManuscriptStructure();
setStructurePreviewStatus(`${finalStatus} Preview refreshed.`);
}
@ -2480,6 +2743,45 @@
}
};
const loadRuntimeDocumentAwareness = async () => {
const book = selectedBook();
if (!book || !officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
return false;
}
setDocumentMessage("Reading manuscript position...");
try {
clearPlotDirectorStructureCache();
await getBookStructureChapters(book.bookId);
const structure = await window.Word.run(async (context) => await readRuntimeDocumentStructure(context, !!book.usesExplicitScenes));
renderOutline(structure);
setDiagnostics({
lastScan: "Success",
lastError: "-",
paragraphs: String(structure.paragraphCount),
heading1: String(structure.heading1Count),
heading2: String(structure.heading2Count)
});
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
} else {
await refreshPlotDirectorScene();
}
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("");
return true;
} catch (error) {
console.error("Unable to initialise bound manuscript runtime.", error);
documentStructureCache = null;
setSceneTrackingState("Manual");
setDocumentMessage(`Unable to read manuscript position: ${errorText(error)}`);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
return false;
}
};
const initialiseFirstRunBinding = async () => {
if (!isAuthenticated || !wordHostAvailable) {
setFirstRunMode(false);
@ -2489,18 +2791,24 @@
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()) {
const runtime = await fetchJson(`/api/word-companion/runtime/book/${binding.bookId}?documentGuid=${encodeURIComponent(binding.documentGuid)}`);
const document = runtime?.manuscriptDocument;
if (runtime?.bookId === binding.bookId
&& runtime?.projectId === binding.projectId
&& document
&& String(document.documentGuid).toLowerCase() === binding.documentGuid.toLowerCase()) {
firstRunDocumentBinding = binding;
writeStoredId(storageKeys.projectId, binding.projectId);
writeStoredId(storageKeys.bookId, binding.bookId);
setRuntimeBookContext(runtime);
writeStoredId(storageKeys.projectId, runtime.projectId);
writeStoredId(storageKeys.bookId, runtime.bookId);
if (projectSelect) {
projectSelect.value = String(binding.projectId);
projectSelect.value = String(runtime.projectId);
}
await loadBooks(binding.projectId, binding.bookId);
await loadBooks(runtime.projectId, runtime.bookId);
setFirstRunMode(false);
setConnectionStatus("Connected");
setBookMessage("Bound manuscript detected.");
await loadRuntimeDocumentAwareness();
return;
}
} catch {
@ -2509,6 +2817,7 @@
}
firstRunDocumentBinding = null;
clearRuntimeCaches();
syncFirstRunProjectOptions();
if (selectedFirstRunProject()) {
await loadBooks(selectedFirstRunProject().projectId, null);
@ -2833,6 +3142,7 @@
throw new Error("Chapter creation did not return a chapter ID.");
}
clearPlotDirectorStructureCache();
setResolvedScene(null, response.chapterId, "Manual chapter link");
const anchored = await attachResolvedChapterId("Chapter created and linked successfully.");
if (!anchored) {
@ -3061,6 +3371,7 @@
throw new Error("Scene creation did not return a scene ID.");
}
clearPlotDirectorStructureCache();
await resolveCreatedOrLinkedScene(response.sceneId, response.chapterId, "Scene created and linked successfully.");
} catch (error) {
setCreateLinkStatus("Unable to create scene.");
@ -3535,36 +3846,23 @@
return;
}
if (!documentStructureCache) {
setSceneTrackingState("Manual");
setDocumentMessage("Document structure is not cached yet. Use Refresh Current Scene.");
return;
}
isAutoRefreshingScene = true;
setSceneTrackingState("Refreshing");
setDocumentMessage("Updating current scene...");
setSceneTrackingState("Live");
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)
});
const selectionParagraphs = await window.Word.run(async (context) => await readSelectionParagraphs(context));
const selectedIndex = findSelectedParagraphIndex(documentStructureCache, selectionParagraphs);
const changed = applyRuntimeSelection(selectedIndex);
if (!changed) {
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Current scene updated.");
return;
}
@ -3575,10 +3873,10 @@
}
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Current scene updated.");
setDocumentMessage("");
} catch (error) {
console.error("Unable to refresh current scene from Word selection.", error);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
setDiagnostics({ lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
} finally {
isAutoRefreshingScene = false;
@ -3677,6 +3975,8 @@
};
const loadBooks = async (projectId, preferredBookId) => {
documentStructureCache = null;
clearPlotDirectorStructureCache();
if (!projectId) {
books = [];
resetSelect(bookSelect, "Select a project first");
@ -3804,6 +4104,7 @@
projectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(projectSelect.value || "", 10);
clearRuntimeCaches();
if (firstRunProjectSelect && Number.isInteger(projectId) && projectId > 0) {
firstRunProjectSelect.value = String(projectId);
}
@ -3817,6 +4118,7 @@
bookSelect?.addEventListener("change", () => {
const bookId = Number.parseInt(bookSelect.value || "", 10);
clearRuntimeCaches();
writeStoredId(storageKeys.bookId, Number.isInteger(bookId) && bookId > 0 ? bookId : null);
if (firstRunBookSelect && Number.isInteger(bookId) && bookId > 0) {
firstRunBookSelect.value = String(bookId);
@ -3833,6 +4135,7 @@
firstRunProjectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(firstRunProjectSelect.value || "", 10);
clearRuntimeCaches();
if (projectSelect && Number.isInteger(projectId) && projectId > 0) {
projectSelect.value = String(projectId);
}
@ -3844,6 +4147,7 @@
firstRunBookSelect?.addEventListener("change", () => {
const bookId = Number.parseInt(firstRunBookSelect.value || "", 10);
clearRuntimeCaches();
if (bookSelect && Number.isInteger(bookId) && bookId > 0) {
bookSelect.value = String(bookId);
}

File diff suppressed because one or more lines are too long