PlotDirector/PlotLine/wwwroot/js/word-companion-presence.js

249 lines
12 KiB
JavaScript

(() => {
const roots = [...document.querySelectorAll("[data-word-companion-presence]")]
.filter((node) => node.dataset.wordCompanionPresence !== "false");
if (roots.length === 0 || !window.signalR) {
return;
}
const labelFor = (status) => (status?.isConnected || status?.IsConnected ? "Connected" : status?.status || status?.Status || "Offline");
const field = (status, camel, pascal) => status?.[camel] ?? status?.[pascal] ?? null;
let companionConnected = false;
let companionDocumentOpen = false;
let currentScanStatus = document.querySelector("[data-onboarding-scan-panel]")?.dataset.scanStatus || "NotStarted";
let scanStartedInThisPage = currentScanStatus === "Running";
const formatTime = (value) => {
if (!value) {
return "No heartbeat yet";
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "No heartbeat yet" : `Last heartbeat ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}`;
};
const applyStatus = (status) => {
const connected = !!field(status, "isConnected", "IsConnected");
const documentOpen = !!field(status, "documentOpen", "DocumentOpen");
const version = field(status, "companionVersion", "CompanionVersion") || "Waiting";
const documentName = field(status, "currentDocumentName", "CurrentDocumentName") || "No document reported yet";
const label = connected ? "Connected" : labelFor(status);
companionConnected = connected;
companionDocumentOpen = documentOpen;
const title = [
`Word Companion: ${label}`,
version && version !== "Waiting" ? `Version ${version}` : "",
documentName && documentName !== "No document reported yet" ? documentName : "",
formatTime(field(status, "lastHeartbeatUtc", "LastHeartbeatUtc"))
].filter(Boolean).join("\n");
for (const root of roots) {
root.classList.toggle("is-connected", connected);
root.classList.toggle("is-offline", !connected);
root.dataset.wordCompanionStatus = label;
root.title = title;
}
document.querySelectorAll("[data-word-companion-status-label]").forEach((node) => {
node.textContent = label;
});
document.querySelectorAll("[data-word-companion-waiting-status]").forEach((node) => {
node.textContent = connected ? "Word Companion Connected" : "Word Companion disconnected. Waiting for reconnection...";
});
document.querySelectorAll("[data-word-companion-connected-copy]").forEach((node) => {
if (!connected) {
node.textContent = "Open Word and start the Companion. This page will update automatically.";
} else if (!documentOpen) {
node.textContent = "Open your manuscript in Word, then the scan button will appear.";
} else if (currentScanStatus === "Complete") {
node.textContent = "Your scan is ready. Review what PlotDirector found before anything is imported.";
} else if (currentScanStatus === "Running") {
node.textContent = "The Companion is reading your manuscript and sending structure back to PlotDirector.";
} else {
node.textContent = "Your Companion is connected. Scan your manuscript when you are ready.";
}
});
document.querySelectorAll("[data-word-companion-version]").forEach((node) => {
node.textContent = version;
});
document.querySelectorAll("[data-word-companion-document]").forEach((node) => {
node.textContent = documentName;
});
document.querySelectorAll("[data-word-companion-dashboard-title]").forEach((node) => {
node.textContent = connected ? "Word Companion Connected" : "Connect your Word Companion";
});
document.querySelectorAll("[data-word-companion-dashboard-description]").forEach((node) => {
node.textContent = connected
? "Your Word Companion is ready for this story setup."
: "Open Word, start the Companion, and sign in with your PlotDirector account.";
});
updateScanButton();
};
const numberText = (value) => {
const number = Number.parseInt(value || "0", 10);
return Number.isInteger(number) ? number.toLocaleString() : "0";
};
const updateScanButton = () => {
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
button.disabled = !companionConnected || !companionDocumentOpen || currentScanStatus === "Running";
button.textContent = currentScanStatus === "Complete" ? "Scan again" : "Scan manuscript";
});
};
const applyScanState = (state) => {
if (!state) {
return;
}
const status = field(state, "status", "Status") || "NotStarted";
if (status === "Running") {
scanStartedInThisPage = true;
}
currentScanStatus = status;
const message = field(state, "message", "Message") || (status === "Complete" ? "Scan complete. Review what PlotDirector found." : "Ready to scan");
const percent = field(state, "percentComplete", "PercentComplete");
const previewId = field(state, "previewID", "PreviewID") || field(state, "previewId", "PreviewId");
const panel = document.querySelector("[data-onboarding-scan-panel]");
if (panel) {
panel.dataset.scanStatus = status;
panel.classList.toggle("is-running", status === "Running");
panel.classList.toggle("is-complete", status === "Complete");
panel.classList.toggle("is-failed", status === "Failed");
}
document.querySelectorAll("[data-onboarding-scan-message]").forEach((node) => {
node.textContent = message;
});
document.querySelectorAll("[data-onboarding-scan-percent]").forEach((node) => {
node.textContent = percent === null || percent === undefined ? "" : `${percent}%`;
});
document.querySelectorAll("[data-onboarding-scan-progress]").forEach((node) => {
node.style.width = `${Math.max(0, Math.min(100, Number.parseInt(percent || "0", 10) || 0))}%`;
});
document.querySelectorAll("[data-onboarding-scan-chapters]").forEach((node) => {
node.textContent = numberText(field(state, "chapterCount", "ChapterCount"));
});
document.querySelectorAll("[data-onboarding-scan-scenes]").forEach((node) => {
node.textContent = numberText(field(state, "sceneCount", "SceneCount"));
});
document.querySelectorAll("[data-onboarding-scan-characters]").forEach((node) => {
node.textContent = numberText(field(state, "characterCandidateCount", "CharacterCandidateCount"));
});
document.querySelectorAll("[data-onboarding-scan-words]").forEach((node) => {
node.textContent = numberText(field(state, "totalWordCount", "TotalWordCount"));
});
document.querySelectorAll("[data-onboarding-scan-review]").forEach((node) => {
const ready = status === "Complete" && previewId;
node.classList.toggle("disabled", !ready);
node.setAttribute("aria-disabled", String(!ready));
node.href = ready ? `/onboarding/scan-review?previewId=${encodeURIComponent(previewId)}` : "#";
});
updateScanButton();
if (status === "Complete"
&& previewId
&& document.querySelector("[data-onboarding-auto-review]")
&& scanStartedInThisPage
&& !document.body.dataset.onboardingReviewRedirected) {
document.body.dataset.onboardingReviewRedirected = "true";
window.location.href = `/onboarding/scan-review?previewId=${encodeURIComponent(previewId)}`;
}
};
const connection = new signalR.HubConnectionBuilder()
.withUrl("/hubs/word-companion-follow")
.withAutomaticReconnect()
.build();
connection.on("WordCompanionPresenceChanged", applyStatus);
connection.on("OnboardingScanStateChanged", applyScanState);
connection.on("OnboardingScanProgress", applyScanState);
connection.on("OnboardingScanCompleted", applyScanState);
connection.on("OnboardingScanFailed", applyScanState);
connection.on("OnboardingBuildProgress", (state) => {
const message = field(state, "message", "Message") || "Preparing chapters...";
const percent = field(state, "percentComplete", "PercentComplete");
const safePercent = Math.max(0, Math.min(100, Number.parseInt(percent || "0", 10) || 0));
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.classList.add("is-running");
});
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = message;
});
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = percent === null || percent === undefined ? "" : `${percent}%`;
});
document.querySelectorAll("[data-onboarding-build-progress]").forEach((node) => {
node.style.width = `${safePercent}%`;
});
});
connection.on("OnboardingBuildCompleted", (result) => {
const previewId = field(result, "previewID", "PreviewID") || field(result, "previewId", "PreviewId");
if (previewId) {
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(previewId)}`;
}
});
connection.onreconnecting(() => applyStatus({ status: "Connecting", isConnected: false }));
connection.onreconnected(() => connection.invoke("WatchCompanionPresence").then(applyStatus).catch(() => {}));
connection.onclose(() => applyStatus({ status: "Offline", isConnected: false }));
connection.start()
.then(() => connection.invoke("WatchCompanionPresence"))
.then(applyStatus)
.catch(() => applyStatus({ status: "Offline", isConnected: false }));
document.querySelectorAll("[data-onboarding-scan-start]").forEach((button) => {
button.addEventListener("click", async () => {
button.disabled = true;
applyScanState({ status: "Running", message: "Asking the Word Companion to scan...", percentComplete: 0 });
try {
const state = await connection.invoke("StartOnboardingManuscriptScan");
applyScanState(state);
} catch (error) {
applyScanState({
status: "Failed",
message: error?.message || "The scan could not start. Reconnect the Word Companion and try again."
});
}
});
});
document.querySelectorAll("[data-onboarding-build-start]").forEach((button) => {
button.addEventListener("click", async () => {
const previewId = button.dataset.previewId;
if (!previewId) {
return;
}
button.disabled = true;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.classList.add("is-running");
});
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = "Preparing the approved chapters...";
});
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = "0%";
});
document.querySelectorAll("[data-onboarding-build-progress]").forEach((node) => {
node.style.width = "0%";
});
try {
const result = await connection.invoke("StartOnboardingProjectBuild", previewId);
const returnedPreviewId = field(result, "previewID", "PreviewID") || previewId;
window.location.href = `/onboarding/build-complete?previewId=${encodeURIComponent(returnedPreviewId)}`;
} catch (error) {
button.disabled = false;
document.querySelectorAll("[data-onboarding-build-panel]").forEach((node) => {
node.classList.remove("is-running");
});
document.querySelectorAll("[data-onboarding-build-message]").forEach((node) => {
node.textContent = error?.message || "The approved chapters could not be prepared. Check the review and try again.";
});
document.querySelectorAll("[data-onboarding-build-percent]").forEach((node) => {
node.textContent = "";
});
}
});
});
})();