various fixes
This commit is contained in:
+275
-68
@@ -10,12 +10,9 @@
|
||||
* page, shared with the header toggling).
|
||||
*
|
||||
* Phase 34 task 02: the header's functional controls are module-owned
|
||||
* (assets/header.js): the Sync sources state machine (#sync-btn's
|
||||
* §7.4 lifecycle — the page only renders #sync-result +
|
||||
* #sync-error-banner off the module's "bor:sync-status" event) and the
|
||||
* New chat binding (on a non-chat page "new chat" means going to the
|
||||
* chat, fresh — the module clears the phase-14 conversation key and
|
||||
* navigates to "/").
|
||||
* (assets/header.js), including the New chat binding (on a non-chat
|
||||
* page "new chat" means going to the chat, fresh — the module clears
|
||||
* the phase-14 conversation key and navigates to "/").
|
||||
*
|
||||
* Phase 26: the table's path links open the document in the
|
||||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||||
@@ -27,7 +24,278 @@
|
||||
* tag; esbuild inlines it into the page bundle).
|
||||
*/
|
||||
|
||||
import { fetchIsAdmin, fmtSyncResult, initSharedHeader } from "./header.js";
|
||||
import { fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
|
||||
/* ---------- Sync sources button (Sources page only) ----------
|
||||
*
|
||||
* The §7.4 never-stale lifecycle: idle → click → POST /api/sync
|
||||
* → running (2 s poll of GET /api/sync/status) → success | failed.
|
||||
* Admin-only: the button is hidden for anonymous users (initSharedHeader
|
||||
* hides it). A failed run opens an error modal (same as the former
|
||||
* header.js module — recreated here since the navbar button is gone).
|
||||
*
|
||||
* Elements: #sync-btn (the button), #sync-label (the text),
|
||||
* #sync-icon (the spinner icon), #sync-result (aria-live result
|
||||
* line), #sync-error-banner / #sync-error-text (error banner).
|
||||
*/
|
||||
const syncBtn = document.querySelector("#sync-btn");
|
||||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||||
const syncResult = document.querySelector("#sync-result");
|
||||
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
||||
const syncErrorText = document.querySelector("#sync-error-text");
|
||||
|
||||
const SYNC_POLL_MS = 2000;
|
||||
let syncPollTimer = null;
|
||||
let lastSyncState = null;
|
||||
|
||||
function emitSyncStatus(status) {
|
||||
lastSyncState = status.state;
|
||||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||||
}
|
||||
|
||||
function stopSyncPolling() {
|
||||
if (syncPollTimer !== null) {
|
||||
clearTimeout(syncPollTimer);
|
||||
syncPollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function fmtSyncTime(iso) {
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fmtSyncResult(detail) {
|
||||
const d = detail || {};
|
||||
const added = d.added || 0;
|
||||
const updated = d.updated || 0;
|
||||
const parts = [`${added} added`];
|
||||
if (updated > 0) parts.push(`${updated} updated`);
|
||||
if ((d.unchanged || 0) > 0 || (added === 0 && updated === 0)) {
|
||||
parts.push(`${d.unchanged || 0} unchanged`);
|
||||
}
|
||||
if ((d.pruned || 0) > 0) parts.push(`${d.pruned} pruned`);
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
function sanitizeSyncError(message) {
|
||||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||||
}
|
||||
|
||||
function enterSyncRunningState() {
|
||||
if (!syncBtn) return;
|
||||
syncBtn.disabled = true;
|
||||
syncBtn.setAttribute("aria-busy", "true");
|
||||
syncBtn.removeAttribute("title");
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = "Syncing…";
|
||||
}
|
||||
|
||||
function settleSyncButton(label) {
|
||||
if (!syncBtn) return;
|
||||
syncBtn.disabled = false;
|
||||
syncBtn.removeAttribute("aria-busy");
|
||||
syncBtn.removeAttribute("title");
|
||||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||||
syncBtn.classList.remove("is-error");
|
||||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||||
if (syncLabel) syncLabel.textContent = label;
|
||||
}
|
||||
|
||||
function showSyncError(detail) {
|
||||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||
}
|
||||
|
||||
function hideSyncError() {
|
||||
if (syncErrorText) syncErrorText.textContent = "";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||
}
|
||||
|
||||
/* ---------- sync failure modal (recreated here since the navbar button is gone) ---------- */
|
||||
let syncModal = null;
|
||||
let syncModalReturnFocus = null;
|
||||
|
||||
function createSyncModal() {
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "sync-modal-backdrop";
|
||||
backdrop.innerHTML =
|
||||
'<div class="sync-modal" role="alertdialog" aria-modal="true" ' +
|
||||
'aria-labelledby="sync-modal-title" aria-describedby="sync-modal-error">' +
|
||||
'<h2 id="sync-modal-title">Sync failed</h2>' +
|
||||
'<p id="sync-modal-error"></p>' +
|
||||
'<button type="button" class="sync-modal-close" aria-label="Close error dialog">\u00d7</button>' +
|
||||
"</div>";
|
||||
document.body.appendChild(backdrop);
|
||||
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) closeSyncModal();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||||
});
|
||||
return backdrop;
|
||||
}
|
||||
|
||||
function showSyncModal(error) {
|
||||
if (!syncBtn || !document.body) return;
|
||||
if (!syncModal) syncModal = createSyncModal();
|
||||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||||
if (syncModal.classList.contains("is-open")) return;
|
||||
const active = document.activeElement;
|
||||
syncModalReturnFocus = active && active !== document.body ? active : syncBtn;
|
||||
syncModal.classList.add("is-open");
|
||||
syncModal.querySelector(".sync-modal-close").focus();
|
||||
}
|
||||
|
||||
function closeSyncModal() {
|
||||
if (!syncModal || !syncModal.classList.contains("is-open")) return;
|
||||
syncModal.classList.remove("is-open");
|
||||
const target = syncModalReturnFocus;
|
||||
syncModalReturnFocus = null;
|
||||
if (target && document.contains(target)) target.focus();
|
||||
}
|
||||
|
||||
function applySyncSuccess(status) {
|
||||
const time = fmtSyncTime(status.finished_at);
|
||||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||||
if (syncResult) syncResult.textContent = fmtSyncResult(status);
|
||||
hideSyncError();
|
||||
emitSyncStatus(status);
|
||||
// Refresh the catalog live — the KB just changed.
|
||||
loadDocs();
|
||||
}
|
||||
|
||||
function applySyncFailure(status) {
|
||||
const error = sanitizeSyncError(status.error);
|
||||
settleSyncButton("Sync sources");
|
||||
if (syncBtn) {
|
||||
syncBtn.title = error;
|
||||
syncBtn.setAttribute("aria-label", error);
|
||||
syncBtn.classList.add("is-error");
|
||||
}
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
showSyncError(error);
|
||||
emitSyncStatus(status);
|
||||
showSyncModal(error);
|
||||
}
|
||||
|
||||
function applySyncIdle(status) {
|
||||
settleSyncButton("Sync sources");
|
||||
emitSyncStatus(status || { state: "idle" });
|
||||
}
|
||||
|
||||
function startSyncPolling() {
|
||||
if (syncPollTimer !== null) return;
|
||||
const tick = async () => {
|
||||
let status = null;
|
||||
let notAdmin = false;
|
||||
try {
|
||||
const r = await fetch("/api/sync/status");
|
||||
if (r.status === 403) notAdmin = true;
|
||||
else if (r.ok) status = await r.json();
|
||||
} catch { /* network blip — retry next tick */ }
|
||||
if (notAdmin) {
|
||||
stopSyncPolling();
|
||||
if (syncBtn) syncBtn.hidden = true;
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
if (!status) {
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
return;
|
||||
}
|
||||
if (status.state === "success") {
|
||||
stopSyncPolling();
|
||||
applySyncSuccess(status);
|
||||
return;
|
||||
}
|
||||
if (status.state === "failed") {
|
||||
stopSyncPolling();
|
||||
applySyncFailure(status);
|
||||
return;
|
||||
}
|
||||
if (status.state === "idle") {
|
||||
stopSyncPolling();
|
||||
applySyncIdle(status);
|
||||
return;
|
||||
}
|
||||
// Still running: keep button state honest and re-schedule.
|
||||
enterSyncRunningState();
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
};
|
||||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||||
}
|
||||
|
||||
async function startSync() {
|
||||
let r;
|
||||
try {
|
||||
r = await fetch("/api/sync", { method: "POST" });
|
||||
} catch {
|
||||
applySyncFailure({
|
||||
state: "failed",
|
||||
error: "Could not reach the server to start the sync — try again.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (r.status === 403) {
|
||||
stopSyncPolling();
|
||||
if (syncBtn) syncBtn.hidden = true;
|
||||
applySyncIdle();
|
||||
return;
|
||||
}
|
||||
if (r.status === 202 || r.status === 409) {
|
||||
enterSyncRunningState();
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||||
startSyncPolling();
|
||||
return;
|
||||
}
|
||||
let detail = "";
|
||||
try { detail = (await r.json()).detail || ""; } catch { /* non-JSON */ }
|
||||
applySyncFailure({
|
||||
state: "failed",
|
||||
error: detail || `The server refused to start the sync (${r.status}).`,
|
||||
});
|
||||
}
|
||||
|
||||
/* Load-time re-attach (ADMIN ONLY): a running run re-enters running state,
|
||||
* a terminal run renders its last result. */
|
||||
async function initSyncButton() {
|
||||
if (!syncBtn) return;
|
||||
if (!(await fetchIsAdmin())) return;
|
||||
let status;
|
||||
try {
|
||||
const r = await fetch("/api/sync/status");
|
||||
if (r.status === 403) { syncBtn.hidden = true; return; }
|
||||
if (!r.ok) return;
|
||||
status = await r.json();
|
||||
} catch { return; }
|
||||
if (status.state === "running") {
|
||||
enterSyncRunningState();
|
||||
emitSyncStatus(status);
|
||||
startSyncPolling();
|
||||
} else if (status.state === "success") {
|
||||
applySyncSuccess(status);
|
||||
} else if (status.state === "failed") {
|
||||
applySyncFailure(status);
|
||||
} else {
|
||||
applySyncIdle(status);
|
||||
}
|
||||
}
|
||||
|
||||
if (syncBtn) {
|
||||
syncBtn.addEventListener("click", startSync);
|
||||
initSyncButton();
|
||||
}
|
||||
|
||||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||||
|
||||
const tbody = document.querySelector("#docs-tbody");
|
||||
@@ -141,65 +409,6 @@ function showEmpty() {
|
||||
if (tableWrap) tableWrap.hidden = true;
|
||||
}
|
||||
|
||||
/* ---------- Phase 32 (state machine module-owned from phase 34
|
||||
* task 02): the #sync-result line + #sync-error-banner ----------
|
||||
*
|
||||
* The #sync-btn state machine itself (click → POST /api/sync, the 2 s
|
||||
* GET /api/sync/status poll, the running / success / failed button
|
||||
* states, the admin-only load re-attach) lives in the shared header
|
||||
* module (assets/header.js) — so the SAME button markup on ANY page
|
||||
* behaves identically. This page keeps only the page-specific
|
||||
* rendering: the aria-live last-result line and the role="alert" error
|
||||
* banner, driven by the module's "bor:sync-status" event (detail = the
|
||||
* GET /api/sync/status object):
|
||||
*
|
||||
* running → clear the result line, hide the banner (a new run starts
|
||||
* clean — the module emits the frame immediately on
|
||||
* click/boot, no 2 s poll lag);
|
||||
* success → the last-result counts in #sync-result (fmtSyncResult —
|
||||
* "added" always shown, zero terms omitted) + the catalog
|
||||
* re-fetches live (the KB just changed — never a stale
|
||||
* table) + the banner hidden;
|
||||
* failed → #sync-error-banner with the error text, result cleared;
|
||||
* idle → hide the banner, clear the result (a run vanishing with
|
||||
* a server restart, or the post-403 cleanup).
|
||||
*/
|
||||
const syncResult = document.querySelector("#sync-result");
|
||||
const syncErrorBanner = document.querySelector("#sync-error-banner");
|
||||
const syncErrorText = document.querySelector("#sync-error-text");
|
||||
|
||||
function showSyncError(detail) {
|
||||
if (syncErrorText) syncErrorText.textContent = detail || "The sync failed.";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = false;
|
||||
}
|
||||
|
||||
function hideSyncError() {
|
||||
if (syncErrorText) syncErrorText.textContent = "";
|
||||
if (syncErrorBanner) syncErrorBanner.hidden = true;
|
||||
}
|
||||
|
||||
window.addEventListener("bor:sync-status", (e) => {
|
||||
const status = e.detail || {};
|
||||
if (status.state === "running") {
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
} else if (status.state === "success") {
|
||||
if (syncResult) syncResult.textContent = fmtSyncResult(status.detail);
|
||||
hideSyncError();
|
||||
// The KB just changed — refresh the catalog live so the table,
|
||||
// stats, and empty state never sit stale under the "Synced" label
|
||||
// (the sync is the page's own action; a reload should not be
|
||||
// needed to see it).
|
||||
loadDocs();
|
||||
} else if (status.state === "failed") {
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
showSyncError(status.error);
|
||||
} else {
|
||||
// idle
|
||||
if (syncResult) syncResult.textContent = "";
|
||||
hideSyncError();
|
||||
}
|
||||
});
|
||||
|
||||
(async () => {
|
||||
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
||||
@@ -213,6 +422,4 @@ window.addEventListener("bor:sync-status", (e) => {
|
||||
}
|
||||
if (gateEl) gateEl.hidden = true;
|
||||
loadDocs();
|
||||
// Phase 34 task 02: the sync re-attach is module-owned (header.js
|
||||
// boots it on the same cached whoami) — nothing to start here.
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user