Phase 10N: Word Companion Auto Context Refresh
This commit is contained in:
parent
2136c6d686
commit
0ffe678d87
@ -47,9 +47,9 @@ public sealed class AccountController(IAuthService authService) : Controller
|
||||
|
||||
[HttpGet]
|
||||
[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]
|
||||
@ -59,6 +59,7 @@ public sealed class AccountController(IAuthService authService) : Controller
|
||||
{
|
||||
if (!ModelState.IsValid)
|
||||
{
|
||||
model.IsCompanionLogin = IsCompanionLogin(model.ReturnUrl, model.IsCompanionLogin);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@ -71,6 +72,7 @@ public sealed class AccountController(IAuthService authService) : Controller
|
||||
if (!result.Success)
|
||||
{
|
||||
ModelState.AddModelError(string.Empty, result.ErrorMessage ?? "Login could not be completed.");
|
||||
model.IsCompanionLogin = IsCompanionLogin(model.ReturnUrl, model.IsCompanionLogin);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
@ -82,6 +84,17 @@ public sealed class AccountController(IAuthService authService) : Controller
|
||||
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]
|
||||
[ValidateAntiForgeryToken]
|
||||
public async Task<IActionResult> Logout()
|
||||
|
||||
@ -67,6 +67,8 @@ public sealed class LoginViewModel
|
||||
public bool RememberMe { get; set; }
|
||||
|
||||
public string? ReturnUrl { get; set; }
|
||||
|
||||
public bool IsCompanionLogin { get; set; }
|
||||
}
|
||||
|
||||
public sealed class ForgotPasswordViewModel
|
||||
|
||||
@ -1,24 +1,86 @@
|
||||
@model LoginViewModel
|
||||
@{
|
||||
ViewData["Title"] = "Login";
|
||||
if (Model.IsCompanionLogin)
|
||||
{
|
||||
Layout = null;
|
||||
}
|
||||
}
|
||||
|
||||
<div class="page-heading compact">
|
||||
@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>
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Login</h1>
|
||||
<p class="lead-text">Sign in to PlotDirector.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (TempData["AuthMessage"] is string authMessage)
|
||||
{
|
||||
@if (TempData["AuthMessage"] is string authMessage)
|
||||
{
|
||||
<div class="alert alert-info">@authMessage</div>
|
||||
}
|
||||
}
|
||||
|
||||
<section class="edit-panel">
|
||||
<section class="edit-panel">
|
||||
<form asp-action="Login" method="post" class="asset-create-form">
|
||||
<input asp-for="ReturnUrl" type="hidden" />
|
||||
<input asp-for="IsCompanionLogin" type="hidden" />
|
||||
<div asp-validation-summary="ModelOnly" class="alert alert-warning"></div>
|
||||
|
||||
<div class="row g-3">
|
||||
@ -49,8 +111,12 @@
|
||||
<a asp-action="ResendVerificationEmail">Need a new verification email?</a>
|
||||
</p>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (!Model.IsCompanionLogin)
|
||||
{
|
||||
@section Scripts {
|
||||
<partial name="_ValidationScriptsPartial" />
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,6 +87,7 @@
|
||||
<div class="word-companion-badges">
|
||||
<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">Scene Tracking: <strong data-scene-tracking-status>Manual</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -172,6 +173,7 @@
|
||||
<button type="button" data-save-scene-links disabled>Save Scene Links</button>
|
||||
</div>
|
||||
<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>
|
||||
<span>Characters</span>
|
||||
<div class="word-companion-check-list" data-character-chips></div>
|
||||
|
||||
@ -27,6 +27,140 @@ body {
|
||||
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 {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@ -341,6 +475,7 @@ body {
|
||||
.word-companion-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
width: fit-content;
|
||||
min-height: 24px;
|
||||
border: 1px solid rgba(111, 75, 45, 0.24);
|
||||
@ -353,6 +488,22 @@ body {
|
||||
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-sync-meta div,
|
||||
.word-companion-structure-grid div,
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
(() => {
|
||||
const storageKeys = {
|
||||
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 tabButtons = [...document.querySelectorAll("[data-tab-button]")];
|
||||
@ -21,6 +23,7 @@
|
||||
const currentChapter = document.querySelector("[data-current-chapter]");
|
||||
const currentScene = document.querySelector("[data-current-scene]");
|
||||
const currentWordCount = document.querySelector("[data-current-word-count]");
|
||||
const sceneTrackingStatus = document.querySelector("[data-scene-tracking-status]");
|
||||
const documentMessage = document.querySelector("[data-document-message]");
|
||||
const syncNote = document.querySelector("[data-sync-note]");
|
||||
const documentOutline = document.querySelector("[data-document-outline]");
|
||||
@ -57,6 +60,7 @@
|
||||
const primaryLocationSelect = document.querySelector("[data-primary-location-select]");
|
||||
const saveSceneLinksButton = document.querySelector("[data-save-scene-links]");
|
||||
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 chapterCreateLinkChapter = document.querySelector("[data-chapter-create-link-chapter]");
|
||||
const createPlotDirectorChapterButton = document.querySelector("[data-create-plotdirector-chapter]");
|
||||
@ -146,6 +150,13 @@
|
||||
let structurePreviewModel = null;
|
||||
let isApplyingStructureSync = false;
|
||||
let pendingApplyStructureSyncConfirmation = null;
|
||||
let sceneTrackingMode = "Manual";
|
||||
let sceneContextStale = false;
|
||||
let selectionRefreshTimer = null;
|
||||
let selectionHandlerRegistered = false;
|
||||
let isAutoRefreshingScene = false;
|
||||
let lastLinkedSceneTitle = "";
|
||||
let lastLinkedChapterTitle = "";
|
||||
let currentSceneLinks = {
|
||||
characters: [],
|
||||
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) {
|
||||
const active = button.dataset.tabButton === tabName;
|
||||
const active = button.dataset.tabButton === targetTab;
|
||||
button.classList.toggle("is-active", active);
|
||||
button.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
|
||||
for (const panel of tabPanels) {
|
||||
const active = panel.dataset.tabPanel === tabName;
|
||||
const active = panel.dataset.tabPanel === targetTab;
|
||||
panel.classList.toggle("is-active", 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) => {
|
||||
@ -311,18 +355,63 @@
|
||||
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 = "") => {
|
||||
resolvedSceneId = Number.isInteger(sceneId) && sceneId > 0 ? sceneId : null;
|
||||
resolvedChapterId = Number.isInteger(chapterId) && chapterId > 0 ? chapterId : null;
|
||||
currentResolutionMethod = resolutionMethod || "";
|
||||
if (syncSceneProgressButton) {
|
||||
syncSceneProgressButton.disabled = !resolvedSceneId;
|
||||
}
|
||||
if (saveSceneLinksButton) {
|
||||
saveSceneLinksButton.disabled = !resolvedSceneId;
|
||||
}
|
||||
setSyncStatus(resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
|
||||
setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first.");
|
||||
updateSceneActionButtons();
|
||||
setSyncStatus(sceneContextStale
|
||||
? "Refresh Current Scene before syncing."
|
||||
: resolvedSceneId ? "Ready to sync." : "No resolved PlotDirector scene.");
|
||||
setSceneLinksStatus(sceneContextStale
|
||||
? "Refresh Current Scene before saving."
|
||||
: resolvedSceneId ? "Ready to save." : "Link a PlotDirector scene first.");
|
||||
updateLinksEditingScene();
|
||||
updateAnchorDisplay();
|
||||
};
|
||||
|
||||
@ -431,6 +520,8 @@
|
||||
hideChapterCreateLink();
|
||||
hideSceneCreateLink();
|
||||
currentResolutionMethod = "";
|
||||
lastLinkedSceneTitle = "";
|
||||
lastLinkedChapterTitle = "";
|
||||
currentResolutionAnchorType = detectedSceneAnchorId ? "SceneID Anchor" : detectedChapterAnchorId ? "ChapterID Anchor" : "Title Match";
|
||||
storedAnchorInvalid = false;
|
||||
currentSceneLinks = { characters: [], assets: [], locations: [], primaryLocationId: null };
|
||||
@ -447,6 +538,7 @@
|
||||
setText(plotDirectorBlockedReason, "-");
|
||||
setHidden(plotDirectorBlockedReasonRow, true);
|
||||
updateAnchorDisplay();
|
||||
updateLinksEditingScene();
|
||||
};
|
||||
|
||||
const linkItemId = (item, type) => {
|
||||
@ -528,6 +620,8 @@
|
||||
};
|
||||
|
||||
const renderCompanionData = (data) => {
|
||||
lastLinkedSceneTitle = data?.sceneTitle || detectedSceneTitle || "";
|
||||
lastLinkedChapterTitle = data?.chapterTitle || detectedChapterTitle || "";
|
||||
setText(plotDirectorSceneTitle, displayValue(data?.sceneTitle));
|
||||
setText(plotDirectorRevisionStatus, displayValue(data?.revisionStatus || data?.revisionStatusName || "Not provided"));
|
||||
setText(plotDirectorEstimatedWords, displayValue(data?.estimatedWords));
|
||||
@ -553,10 +647,13 @@
|
||||
locations: Array.isArray(data?.locations) ? data.locations : [],
|
||||
primaryLocationId: data?.primaryLocationId ?? null
|
||||
};
|
||||
renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId);
|
||||
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId);
|
||||
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId);
|
||||
setSceneLinksStatus(resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene.");
|
||||
renderSceneLinkList(characterChips, currentSceneLinks.characters, "character", !!resolvedSceneId && !sceneContextStale);
|
||||
renderSceneLinkList(assetChips, currentSceneLinks.assets, "asset", !!resolvedSceneId && !sceneContextStale);
|
||||
renderPrimaryLocationOptions(currentSceneLinks.locations, currentSceneLinks.primaryLocationId, !!resolvedSceneId && !sceneContextStale);
|
||||
updateLinksEditingScene();
|
||||
setSceneLinksStatus(sceneContextStale
|
||||
? "Refresh Current Scene before saving."
|
||||
: resolvedSceneId ? "Ready to save." : "No resolved PlotDirector scene.");
|
||||
setHidden(plotDirectorSceneDetails, false);
|
||||
setHidden(writingBriefBlock, false);
|
||||
setHidden(plotDirectorLinkGroups, false);
|
||||
@ -667,6 +764,7 @@
|
||||
}
|
||||
currentResolutionAnchorType = anchorType || "Title Match";
|
||||
storedAnchorInvalid = false;
|
||||
setSceneContextStale(false);
|
||||
setPlotDirectorSceneStatus("Linked to PlotDirector");
|
||||
setDocumentMessage("");
|
||||
hideChapterCreateLink();
|
||||
@ -1424,7 +1522,7 @@
|
||||
const message = errorText(error);
|
||||
console.error("Unable to scan the Word document.", error);
|
||||
setCurrentStructure(null, null, null);
|
||||
setDocumentMessage("Unable to scan the Word document.");
|
||||
setDocumentMessage(`Unable to scan the Word document: ${message}`);
|
||||
setDiagnostics({ lastScan: "Failure", lastError: message });
|
||||
return false;
|
||||
} finally {
|
||||
@ -1780,8 +1878,9 @@
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Word Companion diagnostics failed.", error);
|
||||
setDiagnostics({ lastScan: "Failure", lastError: errorText(error) });
|
||||
setDocumentMessage("Unable to scan the Word document.");
|
||||
const message = errorText(error);
|
||||
setDiagnostics({ lastScan: "Failure", lastError: message });
|
||||
setDocumentMessage(`Unable to scan the Word document: ${message}`);
|
||||
} finally {
|
||||
if (runDiagnosticsButton) {
|
||||
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 () => {
|
||||
if (!resolvedSceneId) {
|
||||
setSceneLinksStatus("Link a PlotDirector scene first.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (sceneContextStale || !(await verifyCurrentWordSceneMatchesLinkedScene())) {
|
||||
setSceneLinksStatus("The Word cursor is now in a different scene. Refresh Current Scene before saving.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveSceneLinksButton) {
|
||||
saveSceneLinksButton.disabled = true;
|
||||
saveSceneLinksButton.textContent = "Saving...";
|
||||
@ -1878,7 +2023,7 @@
|
||||
setDiagnostics({ lastError: errorText(error) });
|
||||
} finally {
|
||||
if (saveSceneLinksButton) {
|
||||
saveSceneLinksButton.disabled = !resolvedSceneId;
|
||||
saveSceneLinksButton.disabled = !resolvedSceneId || sceneContextStale;
|
||||
saveSceneLinksButton.textContent = "Save Scene Links";
|
||||
}
|
||||
}
|
||||
@ -2461,6 +2606,11 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (sceneContextStale) {
|
||||
setSyncStatus("Refresh Current Scene before syncing.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Number.isInteger(detectedSceneWordCount)) {
|
||||
setSyncStatus("Unable to sync progress.");
|
||||
return;
|
||||
@ -2485,7 +2635,7 @@
|
||||
setSyncStatus("Unable to sync progress.");
|
||||
} finally {
|
||||
if (syncSceneProgressButton) {
|
||||
syncSceneProgressButton.disabled = !resolvedSceneId;
|
||||
syncSceneProgressButton.disabled = !resolvedSceneId || sceneContextStale;
|
||||
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 () => {
|
||||
if (!selectedBook()) {
|
||||
setDocumentMessage("Select a PlotDirector book first.");
|
||||
@ -2768,6 +3056,7 @@
|
||||
if (!scanned) {
|
||||
return;
|
||||
}
|
||||
setSceneContextStale(false);
|
||||
|
||||
if (!detectedSceneTitle) {
|
||||
await refreshPlotDirectorChapter();
|
||||
@ -2880,6 +3169,7 @@
|
||||
for (const button of tabButtons) {
|
||||
button.addEventListener("click", () => selectTab(button.dataset.tabButton));
|
||||
}
|
||||
restoreActiveTab();
|
||||
|
||||
projectSelect?.addEventListener("change", async () => {
|
||||
const projectId = Number.parseInt(projectSelect.value || "", 10);
|
||||
@ -2949,6 +3239,7 @@
|
||||
if (!window.Office || typeof window.Office.onReady !== "function") {
|
||||
setOfficeStatus("Word Companion ready");
|
||||
setDocumentMessage("Word document access is unavailable.");
|
||||
setSceneTrackingState("Manual");
|
||||
setDiagnostics({
|
||||
host: "Unavailable",
|
||||
platform: "Unavailable",
|
||||
@ -2963,6 +3254,9 @@
|
||||
setOfficeStatus("Word Companion ready");
|
||||
if (!wordHostAvailable) {
|
||||
setDocumentMessage("Word document access is unavailable.");
|
||||
setSceneTrackingState("Manual");
|
||||
} else {
|
||||
setupSelectionTracking();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user