Phase 10N: Word Companion Auto Context Refresh

This commit is contained in:
Nick Beckley 2026-06-14 15:22:56 +01:00
parent 2136c6d686
commit 0ffe678d87
6 changed files with 598 additions and 70 deletions

View File

@ -47,9 +47,9 @@ public sealed class AccountController(IAuthService authService) : Controller
[HttpGet] [HttpGet]
[AllowAnonymous] [AllowAnonymous]
public IActionResult Login(string? returnUrl = null) public IActionResult Login(string? returnUrl = null, bool companion = false)
{ {
return View(new LoginViewModel { ReturnUrl = returnUrl }); return View(new LoginViewModel { ReturnUrl = returnUrl, IsCompanionLogin = IsCompanionLogin(returnUrl, companion) });
} }
[HttpPost] [HttpPost]
@ -59,6 +59,7 @@ public sealed class AccountController(IAuthService authService) : Controller
{ {
if (!ModelState.IsValid) if (!ModelState.IsValid)
{ {
model.IsCompanionLogin = IsCompanionLogin(model.ReturnUrl, model.IsCompanionLogin);
return View(model); return View(model);
} }
@ -71,6 +72,7 @@ public sealed class AccountController(IAuthService authService) : Controller
if (!result.Success) if (!result.Success)
{ {
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed."); ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed.");
model.IsCompanionLogin = IsCompanionLogin(model.ReturnUrl, model.IsCompanionLogin);
return View(model); return View(model);
} }
@ -82,6 +84,17 @@ public sealed class AccountController(IAuthService authService) : Controller
return RedirectToAction("Index", "Projects"); return RedirectToAction("Index", "Projects");
} }
private static bool IsCompanionLogin(string? returnUrl, bool explicitCompanion)
{
if (explicitCompanion)
{
return true;
}
return !string.IsNullOrWhiteSpace(returnUrl)
&& returnUrl.StartsWith("/word-companion", StringComparison.OrdinalIgnoreCase);
}
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
public async Task<IActionResult> Logout() public async Task<IActionResult> Logout()

View File

@ -67,6 +67,8 @@ public sealed class LoginViewModel
public bool RememberMe { get; set; } public bool RememberMe { get; set; }
public string? ReturnUrl { get; set; } public string? ReturnUrl { get; set; }
public bool IsCompanionLogin { get; set; }
} }
public sealed class ForgotPasswordViewModel public sealed class ForgotPasswordViewModel

View File

@ -1,8 +1,69 @@
@model LoginViewModel @model LoginViewModel
@{ @{
ViewData["Title"] = "Login"; ViewData["Title"] = "Login";
if (Model.IsCompanionLogin)
{
Layout = null;
}
} }
@if (Model.IsCompanionLogin)
{
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Word Companion Login - PlotDirector</title>
<link rel="stylesheet" href="~/css/word-companion.css" asp-append-version="true" />
</head>
<body class="word-companion-login-body">
<main class="word-companion-login-shell">
<header class="word-companion-login-header">
<h1>PlotDirector</h1>
<p>Word Companion</p>
</header>
@if (TempData["AuthMessage"] is string authMessage)
{
<div class="word-companion-login-alert">@authMessage</div>
}
<section class="word-companion-login-card">
<form asp-action="Login" method="post" class="word-companion-login-form">
<input asp-for="ReturnUrl" type="hidden" />
<input asp-for="IsCompanionLogin" type="hidden" />
<div asp-validation-summary="ModelOnly" class="word-companion-login-validation"></div>
<div>
<label asp-for="Email">Email address</label>
<input asp-for="Email" autocomplete="email" />
<span asp-validation-for="Email"></span>
</div>
<div>
<label asp-for="Password">Password</label>
<input asp-for="Password" autocomplete="current-password" />
<span asp-validation-for="Password"></span>
</div>
<label class="word-companion-login-check">
<input asp-for="RememberMe" />
<span>Remember me</span>
</label>
<button type="submit">Login</button>
<div class="word-companion-login-links">
<a asp-action="Register">Create account</a>
<a asp-action="ForgotPassword">Forgot password?</a>
<a asp-action="ResendVerificationEmail">Need a new verification email?</a>
</div>
</form>
</section>
</main>
</body>
</html>
}
else
{
<div class="page-heading compact"> <div class="page-heading compact">
<div> <div>
<p class="eyebrow">Account</p> <p class="eyebrow">Account</p>
@ -19,6 +80,7 @@
<section class="edit-panel"> <section class="edit-panel">
<form asp-action="Login" method="post" class="asset-create-form"> <form asp-action="Login" method="post" class="asset-create-form">
<input asp-for="ReturnUrl" type="hidden" /> <input asp-for="ReturnUrl" type="hidden" />
<input asp-for="IsCompanionLogin" type="hidden" />
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div> <div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
<div class="row g-3"> <div class="row g-3">
@ -50,7 +112,11 @@
</p> </p>
</form> </form>
</section> </section>
}
@if (!Model.IsCompanionLogin)
{
@section Scripts { @section Scripts {
<partial name="_ValidationScriptsPartial" /> <partial name="_ValidationScriptsPartial" />
} }
}

View File

@ -87,6 +87,7 @@
<div class="word-companion-badges"> <div class="word-companion-badges">
<span class="word-companion-badge" data-plotdirector-scene-status>Not detected</span> <span class="word-companion-badge" data-plotdirector-scene-status>Not detected</span>
<span class="word-companion-badge" data-anchor-status>Not attached</span> <span class="word-companion-badge" data-anchor-status>Not attached</span>
<span class="word-companion-badge">Scene Tracking: <strong data-scene-tracking-status>Manual</strong></span>
</div> </div>
</div> </div>
@ -172,6 +173,7 @@
<button type="button" data-save-scene-links disabled>Save Scene Links</button> <button type="button" data-save-scene-links disabled>Save Scene Links</button>
</div> </div>
<p class="word-companion-message" data-scene-links-status>Link a PlotDirector scene first.</p> <p class="word-companion-message" data-scene-links-status>Link a PlotDirector scene first.</p>
<p class="word-companion-link-context" data-links-editing-scene>Link a PlotDirector scene first.</p>
<div> <div>
<span>Characters</span> <span>Characters</span>
<div class="word-companion-check-list" data-character-chips></div> <div class="word-companion-check-list" data-character-chips></div>

View File

@ -27,6 +27,140 @@ body {
font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif; font-family: "Segoe UI", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
} }
.word-companion-login-body {
min-width: 280px;
}
.word-companion-login-shell {
display: grid;
align-content: start;
gap: 12px;
width: 100%;
max-width: 420px;
padding: 14px 12px;
}
.word-companion-login-header {
display: grid;
gap: 2px;
border-bottom: 1px solid var(--companion-line);
padding-bottom: 9px;
}
.word-companion-login-header h1 {
margin: 0;
color: var(--companion-accent-dark);
font-size: 1.22rem;
line-height: 1.1;
}
.word-companion-login-header p {
margin: 0;
color: var(--companion-muted);
font-size: 0.82rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-login-card,
.word-companion-login-alert {
border: 1px solid var(--companion-line);
border-radius: 10px;
background: var(--companion-panel);
box-shadow: 0 2px 8px rgba(74, 50, 31, 0.08);
}
.word-companion-login-card {
padding: 14px;
}
.word-companion-login-alert {
padding: 10px;
color: var(--companion-accent-dark);
font-size: 0.86rem;
}
.word-companion-login-form {
display: grid;
gap: 10px;
}
.word-companion-login-form div {
display: grid;
gap: 4px;
}
.word-companion-login-form label {
color: var(--companion-muted);
font-size: 0.74rem;
font-weight: 800;
text-transform: uppercase;
}
.word-companion-login-form input[type="email"],
.word-companion-login-form input[type="password"],
.word-companion-login-form input:not([type]) {
width: 100%;
min-height: 34px;
border: 1px solid var(--companion-line);
border-radius: 6px;
padding: 7px 8px;
background: #fffdf8;
color: var(--companion-ink);
font: inherit;
}
.word-companion-login-form span {
color: #8f3528;
font-size: 0.76rem;
}
.word-companion-login-check {
display: flex !important;
align-items: center;
gap: 7px;
color: var(--companion-ink) !important;
font-size: 0.84rem !important;
font-weight: 600 !important;
text-transform: none !important;
}
.word-companion-login-check input {
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--companion-accent);
}
.word-companion-login-form button {
min-height: 36px;
border: 1px solid var(--companion-accent);
border-radius: 8px;
background: linear-gradient(180deg, #7b5432 0%, var(--companion-accent-dark) 100%);
color: #fff9ef;
font: inherit;
font-size: 0.9rem;
font-weight: 800;
}
.word-companion-login-links {
display: grid;
gap: 6px;
border-top: 1px solid var(--companion-line);
padding-top: 9px;
}
.word-companion-login-links a {
color: var(--companion-accent-dark);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
.word-companion-login-validation:empty {
display: none;
}
.word-companion-shell { .word-companion-shell {
display: grid; display: grid;
align-content: start; align-content: start;
@ -341,6 +475,7 @@ body {
.word-companion-badge { .word-companion-badge {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 4px;
width: fit-content; width: fit-content;
min-height: 24px; min-height: 24px;
border: 1px solid rgba(111, 75, 45, 0.24); border: 1px solid rgba(111, 75, 45, 0.24);
@ -353,6 +488,22 @@ body {
text-transform: none !important; text-transform: none !important;
} }
.word-companion-badge strong {
color: var(--companion-accent-dark);
font-size: inherit;
}
.word-companion-link-context {
margin: -2px 0 2px;
border: 1px solid rgba(111, 75, 45, 0.16);
border-radius: 6px;
padding: 7px 8px;
background: var(--companion-soft);
color: var(--companion-accent-dark);
font-size: 0.82rem;
font-weight: 700;
}
.word-companion-scene-card div, .word-companion-scene-card div,
.word-companion-sync-meta div, .word-companion-sync-meta div,
.word-companion-structure-grid div, .word-companion-structure-grid div,

View File

@ -1,8 +1,10 @@
(() => { (() => {
const storageKeys = { const storageKeys = {
projectId: "plotdirector.word.projectId", projectId: "plotdirector.word.projectId",
bookId: "plotdirector.word.bookId" bookId: "plotdirector.word.bookId",
activeTab: "plotdirector.word.activeTab"
}; };
const validTabNames = new Set(["scene", "links", "structure", "diagnostics"]);
const shell = document.querySelector(".word-companion-shell"); const shell = document.querySelector(".word-companion-shell");
const tabButtons = [...document.querySelectorAll("[data-tab-button]")]; const tabButtons = [...document.querySelectorAll("[data-tab-button]")];
@ -21,6 +23,7 @@
const currentChapter = document.querySelector("[data-current-chapter]"); const currentChapter = document.querySelector("[data-current-chapter]");
const currentScene = document.querySelector("[data-current-scene]"); const currentScene = document.querySelector("[data-current-scene]");
const currentWordCount = document.querySelector("[data-current-word-count]"); const currentWordCount = document.querySelector("[data-current-word-count]");
const sceneTrackingStatus = document.querySelector("[data-scene-tracking-status]");
const documentMessage = document.querySelector("[data-document-message]"); const documentMessage = document.querySelector("[data-document-message]");
const syncNote = document.querySelector("[data-sync-note]"); const syncNote = document.querySelector("[data-sync-note]");
const documentOutline = document.querySelector("[data-document-outline]"); const documentOutline = document.querySelector("[data-document-outline]");
@ -57,6 +60,7 @@
const primaryLocationSelect = document.querySelector("[data-primary-location-select]"); const primaryLocationSelect = document.querySelector("[data-primary-location-select]");
const saveSceneLinksButton = document.querySelector("[data-save-scene-links]"); const saveSceneLinksButton = document.querySelector("[data-save-scene-links]");
const sceneLinksStatus = document.querySelector("[data-scene-links-status]"); const sceneLinksStatus = document.querySelector("[data-scene-links-status]");
const linksEditingScene = document.querySelector("[data-links-editing-scene]");
const chapterCreateLink = document.querySelector("[data-chapter-create-link]"); const chapterCreateLink = document.querySelector("[data-chapter-create-link]");
const chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]"); const chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]");
const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]"); const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]");
@ -146,6 +150,13 @@
let structurePreviewModel = null; let structurePreviewModel = null;
let isApplyingStructureSync = false; let isApplyingStructureSync = false;
let pendingApplyStructureSyncConfirmation = null; let pendingApplyStructureSyncConfirmation = null;
let sceneTrackingMode = "Manual";
let sceneContextStale = false;
let selectionRefreshTimer = null;
let selectionHandlerRegistered = false;
let isAutoRefreshingScene = false;
let lastLinkedSceneTitle = "";
let lastLinkedChapterTitle = "";
let currentSceneLinks = { let currentSceneLinks = {
characters: [], characters: [],
assets: [], assets: [],
@ -159,18 +170,51 @@
} }
}; };
const selectTab = (tabName) => { const readStoredText = (key) => {
try {
return window.localStorage.getItem(key) || "";
} catch {
return "";
}
};
const writeStoredText = (key, value) => {
try {
if (value) {
window.localStorage.setItem(key, String(value));
} else {
window.localStorage.removeItem(key);
}
} catch {
// Some Office hosts can restrict storage. The pane still falls back to the Scene tab.
}
};
const selectTab = (tabName, persist = true) => {
const targetTab = validTabNames.has(tabName) && tabButtons.some((button) => button.dataset.tabButton === tabName)
? tabName
: "scene";
for (const button of tabButtons) { for (const button of tabButtons) {
const active = button.dataset.tabButton === tabName; const active = button.dataset.tabButton === targetTab;
button.classList.toggle("is-active", active); button.classList.toggle("is-active", active);
button.setAttribute("aria-selected", active ? "true" : "false"); button.setAttribute("aria-selected", active ? "true" : "false");
} }
for (const panel of tabPanels) { for (const panel of tabPanels) {
const active = panel.dataset.tabPanel === tabName; const active = panel.dataset.tabPanel === targetTab;
panel.classList.toggle("is-active", active); panel.classList.toggle("is-active", active);
panel.hidden = !active; panel.hidden = !active;
} }
if (persist) {
writeStoredText(storageKeys.activeTab, targetTab);
}
};
const restoreActiveTab = () => {
const storedTab = readStoredText(storageKeys.activeTab);
selectTab(validTabNames.has(storedTab) ? storedTab : "scene", false);
}; };
const setConnectionStatus = (message) => { const setConnectionStatus = (message) => {
@ -311,18 +355,63 @@
updateApplyStructureSyncButton(); updateApplyStructureSyncButton();
}; };
const updateLinksEditingScene = () => {
if (!linksEditingScene) {
return;
}
linksEditingScene.textContent = resolvedSceneId
? `Editing links for: ${lastLinkedSceneTitle || detectedSceneTitle || "Current scene"}`
: "Link a PlotDirector scene first.";
};
const updateSceneActionButtons = () => {
const blocked = sceneContextStale || !resolvedSceneId;
if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = blocked;
}
if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = blocked;
}
if (primaryLocationSelect) {
primaryLocationSelect.disabled = blocked;
}
for (const container of [characterChips, assetChips]) {
for (const checkbox of container ? [...container.querySelectorAll("input[type='checkbox']")] : []) {
checkbox.disabled = blocked;
}
}
};
const setSceneTrackingState = (status) => {
if (status === "Live" || status === "Manual") {
sceneTrackingMode = status;
}
setText(sceneTrackingStatus, status || sceneTrackingMode || "Manual");
};
const setSceneContextStale = (isStale, message = "") => {
sceneContextStale = !!isStale;
setText(sceneTrackingStatus, sceneContextStale ? "Stale" : sceneTrackingMode);
updateSceneActionButtons();
if (message) {
setDocumentMessage(message);
setSceneLinksStatus(message);
}
};
const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => { const setResolvedScene = (sceneId, chapterId = null, resolutionMethod = "") => {
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null; resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null; resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null;
currentResolutionMethod = resolutionMethod || ""; currentResolutionMethod = resolutionMethod || "";
if (syncSceneProgressButton) { updateSceneActionButtons();
syncSceneProgressButton.disabled = !resolvedSceneId; setSyncStatus(sceneContextStale
} ? "Refresh Current Scene before syncing."
if (saveSceneLinksButton) { : resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
saveSceneLinksButton.disabled = !resolvedSceneId; setSceneLinksStatus(sceneContextStale
} ? "Refresh Current Scene before saving."
setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene."); : resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first.");
setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first."); updateLinksEditingScene();
updateAnchorDisplay(); updateAnchorDisplay();
}; };
@ -431,6 +520,8 @@
hideChapterCreateLink(); hideChapterCreateLink();
hideSceneCreateLink(); hideSceneCreateLink();
currentResolutionMethod = ""; currentResolutionMethod = "";
lastLinkedSceneTitle = "";
lastLinkedChapterTitle = "";
currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match"; currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match";
storedAnchorInvalid = false; storedAnchorInvalid = false;
currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null }; currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null };
@ -447,6 +538,7 @@
setText(plotDirectorBlockedReason, "-"); setText(plotDirectorBlockedReason, "-");
setHidden(plotDirectorBlockedReasonRow, true); setHidden(plotDirectorBlockedReasonRow, true);
updateAnchorDisplay(); updateAnchorDisplay();
updateLinksEditingScene();
}; };
const linkItemId = (item, type) => { const linkItemId = (item, type) => {
@ -528,6 +620,8 @@
}; };
const renderCompanionData = (data) => { const renderCompanionData = (data) => {
lastLinkedSceneTitle = data?.sceneTitle || detectedSceneTitle || "";
lastLinkedChapterTitle = data?.chapterTitle || detectedChapterTitle || "";
setText(plotDirectorSceneTitle, displayValue(data?.sceneTitle)); setText(plotDirectorSceneTitle, displayValue(data?.sceneTitle));
setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided")); setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided"));
setText(plotDirectorEstimatedWords, displayValue(data?.estimatedWords)); setText(plotDirectorEstimatedWords, displayValue(data?.estimatedWords));
@ -553,10 +647,13 @@
locations: Array.isArray(data?.locations) ? data.locations : [], locations: Array.isArray(data?.locations) ? data.locations : [],
primaryLocationId: data?.primaryLocationId ?? null primaryLocationId: data?.primaryLocationId ?? null
}; };
renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId); renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId && !sceneContextStale);
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId); renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId && !sceneContextStale);
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId); renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId && !sceneContextStale);
setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene."); updateLinksEditingScene();
setSceneLinksStatus(sceneContextStale
? "Refresh Current Scene before saving."
: resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene.");
setHidden(plotDirectorSceneDetails, false); setHidden(plotDirectorSceneDetails, false);
setHidden(writingBriefBlock, false); setHidden(writingBriefBlock, false);
setHidden(plotDirectorLinkGroups, false); setHidden(plotDirectorLinkGroups, false);
@ -667,6 +764,7 @@
} }
currentResolutionAnchorType = anchorType || "Title Match"; currentResolutionAnchorType = anchorType || "Title Match";
storedAnchorInvalid = false; storedAnchorInvalid = false;
setSceneContextStale(false);
setPlotDirectorSceneStatus("Linked to PlotDirector"); setPlotDirectorSceneStatus("Linked to PlotDirector");
setDocumentMessage(""); setDocumentMessage("");
hideChapterCreateLink(); hideChapterCreateLink();
@ -1424,7 +1522,7 @@
const message = errorText(error); const message = errorText(error);
console.error("Unable to scan the Word document.", error); console.error("Unable to scan the Word document.", error);
setCurrentStructure(null, null, null); setCurrentStructure(null, null, null);
setDocumentMessage("Unable to scan the Word document."); setDocumentMessage(`Unable to scan the Word document: ${message}`);
setDiagnostics({ lastScan: "Failure", lastError: message }); setDiagnostics({ lastScan: "Failure", lastError: message });
return false; return false;
} finally { } finally {
@ -1780,8 +1878,9 @@
}); });
} catch (error) { } catch (error) {
console.error("Word Companion diagnostics failed.", error); console.error("Word Companion diagnostics failed.", error);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) }); const message = errorText(error);
setDocumentMessage("Unable to scan the Word document."); setDiagnostics({ lastScan: "Failure", lastError: message });
setDocumentMessage(`Unable to scan the Word document: ${message}`);
} finally { } finally {
if (runDiagnosticsButton) { if (runDiagnosticsButton) {
runDiagnosticsButton.disabled = false; runDiagnosticsButton.disabled = false;
@ -1852,12 +1951,58 @@
}; };
}; };
const verifyCurrentWordSceneMatchesLinkedScene = async () => {
if (!resolvedSceneId) {
return false;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
try {
const structure = await window.Word.run(async (context) => {
return await readDocumentStructure(context);
});
const currentChapterTitle = structure.currentChapter?.title || "";
const currentSceneTitle = structure.currentScene?.title || "";
const currentSceneAnchorId = structure.currentScene?.anchorId || null;
const currentChapterAnchorId = structure.currentChapter?.anchorId || null;
const anchoredSceneMatches = Number.isInteger(currentSceneAnchorId) && currentSceneAnchorId === resolvedSceneId;
const anchoredChapterMatches = Number.isInteger(currentChapterAnchorId) && currentChapterAnchorId === resolvedChapterId;
const titleSceneMatches = normalizeTitleForResolve(currentSceneTitle, "scene").toLocaleLowerCase()
=== normalizeTitleForResolve(lastLinkedSceneTitle || detectedSceneTitle, "scene").toLocaleLowerCase();
const titleChapterMatches = normalizeTitleForResolve(currentChapterTitle, "chapter").toLocaleLowerCase()
=== normalizeTitleForResolve(lastLinkedChapterTitle || detectedChapterTitle, "chapter").toLocaleLowerCase();
const matches = anchoredSceneMatches || (titleSceneMatches && (anchoredChapterMatches || titleChapterMatches));
if (!matches) {
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
setSceneContextStale(false);
return true;
} catch (error) {
console.error("Unable to verify current Word scene before saving.", error);
setDiagnostics({ lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return false;
}
};
const saveSceneLinks = async () => { const saveSceneLinks = async () => {
if (!resolvedSceneId) { if (!resolvedSceneId) {
setSceneLinksStatus("Link a PlotDirector scene first."); setSceneLinksStatus("Link a PlotDirector scene first.");
return; return;
} }
if (sceneContextStale || !(await verifyCurrentWordSceneMatchesLinkedScene())) {
setSceneLinksStatus("The Word cursor is now in a different scene. Refresh Current Scene before saving.");
return;
}
if (saveSceneLinksButton) { if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = true; saveSceneLinksButton.disabled = true;
saveSceneLinksButton.textContent = "Saving..."; saveSceneLinksButton.textContent = "Saving...";
@ -1878,7 +2023,7 @@
setDiagnostics({ lastError: errorText(error) }); setDiagnostics({ lastError: errorText(error) });
} finally { } finally {
if (saveSceneLinksButton) { if (saveSceneLinksButton) {
saveSceneLinksButton.disabled = !resolvedSceneId; saveSceneLinksButton.disabled = !resolvedSceneId || sceneContextStale;
saveSceneLinksButton.textContent = "Save Scene Links"; saveSceneLinksButton.textContent = "Save Scene Links";
} }
} }
@ -2461,6 +2606,11 @@
return; return;
} }
if (sceneContextStale) {
setSyncStatus("Refresh Current Scene before syncing.");
return;
}
if (!Number.isInteger(detectedSceneWordCount)) { if (!Number.isInteger(detectedSceneWordCount)) {
setSyncStatus("Unable to sync progress."); setSyncStatus("Unable to sync progress.");
return; return;
@ -2485,7 +2635,7 @@
setSyncStatus("Unable to sync progress."); setSyncStatus("Unable to sync progress.");
} finally { } finally {
if (syncSceneProgressButton) { if (syncSceneProgressButton) {
syncSceneProgressButton.disabled = !resolvedSceneId; syncSceneProgressButton.disabled = !resolvedSceneId || sceneContextStale;
syncSceneProgressButton.textContent = "Sync Progress"; syncSceneProgressButton.textContent = "Sync Progress";
} }
} }
@ -2755,6 +2905,144 @@
} }
}; };
const hasCurrentWordContextChanged = (structure) => {
const chapterTitle = structure.currentChapter?.title || "";
const sceneTitle = structure.currentScene?.title || "";
const chapterAnchorId = structure.currentChapter?.anchorId || null;
const sceneAnchorId = structure.currentScene?.anchorId || null;
return normalizeTitleForResolve(chapterTitle, "chapter") !== normalizeTitleForResolve(detectedChapterTitle, "chapter")
|| normalizeTitleForResolve(sceneTitle, "scene") !== normalizeTitleForResolve(detectedSceneTitle, "scene")
|| chapterAnchorId !== detectedChapterAnchorId
|| sceneAnchorId !== detectedSceneAnchorId;
};
const refreshCurrentSceneFromSelection = async () => {
if (isAutoRefreshingScene || !isAuthenticated || !selectedBook()) {
return;
}
if (!officeReady || !wordHostAvailable || !window.Word || typeof window.Word.run !== "function") {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
isAutoRefreshingScene = true;
setSceneTrackingState("Refreshing");
setDocumentMessage("Updating current scene...");
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)
});
if (!changed) {
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Current scene updated.");
return;
}
if (!detectedSceneTitle) {
await refreshPlotDirectorChapter();
} else {
await refreshPlotDirectorScene();
}
setSceneContextStale(false);
setSceneTrackingState("Live");
setDocumentMessage("Current scene updated.");
} catch (error) {
console.error("Unable to refresh current scene from Word selection.", error);
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
setSceneContextStale(true, "The Word cursor is now in a different scene. Refresh Current Scene before saving.");
} finally {
isAutoRefreshingScene = false;
}
};
const scheduleSelectionRefresh = () => {
if (selectionRefreshTimer) {
window.clearTimeout(selectionRefreshTimer);
}
selectionRefreshTimer = window.setTimeout(refreshCurrentSceneFromSelection, 850);
};
const handleDocumentSelectionChanged = () => {
scheduleSelectionRefresh();
};
const removeSelectionTracking = () => {
if (!selectionHandlerRegistered
|| !window.Office?.context?.document
|| typeof window.Office.context.document.removeHandlerAsync !== "function"
|| !window.Office?.EventType?.DocumentSelectionChanged) {
return;
}
try {
window.Office.context.document.removeHandlerAsync(
window.Office.EventType.DocumentSelectionChanged,
{ handler: handleDocumentSelectionChanged }
);
selectionHandlerRegistered = false;
} catch (error) {
console.warn("Unable to remove Word selection tracking handler.", error);
}
};
const setupSelectionTracking = () => {
if (!isAuthenticated) {
return;
}
if (!window.Office?.context?.document
|| typeof window.Office.context.document.addHandlerAsync !== "function"
|| !window.Office?.EventType?.DocumentSelectionChanged) {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
return;
}
try {
window.Office.context.document.addHandlerAsync(
window.Office.EventType.DocumentSelectionChanged,
handleDocumentSelectionChanged,
(result) => {
if (result?.status === window.Office.AsyncResultStatus.Succeeded) {
selectionHandlerRegistered = true;
setSceneTrackingState("Live");
} else {
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
}
}
);
window.addEventListener("beforeunload", removeSelectionTracking);
} catch (error) {
console.warn("Unable to register Word selection tracking handler.", error);
setSceneTrackingState("Manual");
setDocumentMessage("Automatic scene tracking is unavailable in this Word version. Use Refresh Current Scene.");
}
};
const refreshCurrentScene = async () => { const refreshCurrentScene = async () => {
if (!selectedBook()) { if (!selectedBook()) {
setDocumentMessage("Select a PlotDirector book first."); setDocumentMessage("Select a PlotDirector book first.");
@ -2768,6 +3056,7 @@
if (!scanned) { if (!scanned) {
return; return;
} }
setSceneContextStale(false);
if (!detectedSceneTitle) { if (!detectedSceneTitle) {
await refreshPlotDirectorChapter(); await refreshPlotDirectorChapter();
@ -2880,6 +3169,7 @@
for (const button of tabButtons) { for (const button of tabButtons) {
button.addEventListener("click", () => selectTab(button.dataset.tabButton)); button.addEventListener("click", () => selectTab(button.dataset.tabButton));
} }
restoreActiveTab();
projectSelect?.addEventListener("change", async () => { projectSelect?.addEventListener("change", async () => {
const projectId = Number.parseInt(projectSelect.value || "", 10); const projectId = Number.parseInt(projectSelect.value || "", 10);
@ -2949,6 +3239,7 @@
if (!window.Office || typeof window.Office.onReady !== "function") { if (!window.Office || typeof window.Office.onReady !== "function") {
setOfficeStatus("Word Companion ready"); setOfficeStatus("Word Companion ready");
setDocumentMessage("Word document access is unavailable."); setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
setDiagnostics({ setDiagnostics({
host: "Unavailable", host: "Unavailable",
platform: "Unavailable", platform: "Unavailable",
@ -2963,6 +3254,9 @@
setOfficeStatus("Word Companion ready"); setOfficeStatus("Word Companion ready");
if (!wordHostAvailable) { if (!wordHostAvailable) {
setDocumentMessage("Word document access is unavailable."); setDocumentMessage("Word document access is unavailable.");
setSceneTrackingState("Manual");
} else {
setupSelectionTracking();
} }
}) })
.catch((error) => { .catch((error) => {