various fixes

This commit is contained in:
2026-08-28 09:42:19 -04:00
parent 5d679f5184
commit 03bead092c
19 changed files with 983 additions and 1443 deletions
+275 -68
View File
@@ -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.
})();