393 lines
14 KiB
JavaScript
393 lines
14 KiB
JavaScript
/* Brain of Reese — Sources page (knowledge base index view).
|
||
*
|
||
* Wires the real `GET /api/docs` endpoint (import phase): stat cards +
|
||
* full-width document table, or the designed empty state when nothing is
|
||
* indexed yet. Cells are built with DOM APIs (textContent) — never
|
||
* innerHTML with document-derived data (XSS-safe by construction).
|
||
*
|
||
* Phase 19: the page joins the shared header (assets/header.js) — the
|
||
* whoami gate below runs on the module's cached promise (one request per
|
||
* page, shared with the header toggling), and the header gains the New
|
||
* chat button: on a non-chat page "new chat" means going to the chat,
|
||
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
||
*
|
||
* Phase 26: the table's path links open the document in the
|
||
* almost-fullscreen modal overlay (assets/document-modal.js) on the
|
||
* SAME page — no new tab, no navigation. The link keeps its
|
||
* /document.html href as the no-JS / context-menu escape hatch;
|
||
* left-clicks are intercepted (preventDefault) and routed to
|
||
* openDocumentModal. The module is loaded through the relative import
|
||
* below — the header.js single-evaluation design (no direct <script>
|
||
* tag; esbuild inlines it into the page bundle).
|
||
*/
|
||
|
||
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||
|
||
const tbody = document.querySelector("#docs-tbody");
|
||
const emptyEl = document.querySelector("#sources-empty");
|
||
const tableWrap = document.querySelector(".table-wrap");
|
||
const statCards = document.querySelector("#stat-cards");
|
||
const gateEl = document.querySelector("#sources-gate");
|
||
const statDocs = document.querySelector("#stat-docs");
|
||
const statChunks = document.querySelector("#stat-chunks");
|
||
const statLast = document.querySelector("#stat-last");
|
||
|
||
/* Phase 16: whoami BEFORE the docs fetch. Anonymous visitors get the
|
||
* sign-in gate (stat cards + table hidden) and NO /api/docs call — the
|
||
* catalog is admin-only. The document viewer itself stays public (the
|
||
* soft rule), so the gate copy points at what keeps working.
|
||
* Phase 19: the whoami request is the shared header module's cached
|
||
* promise — the same single request initSharedHeader() awaited. */
|
||
function isAdmin() {
|
||
return fetchIsAdmin();
|
||
}
|
||
|
||
/* Phase 19: New chat on a non-chat page means "go to the chat, fresh":
|
||
* clear the phase-14 conversation key, then land on the chat page — its
|
||
* empty state, since the conversation is gone from storage. */
|
||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||
if (newChatBtn) {
|
||
newChatBtn.addEventListener("click", () => {
|
||
clearChatStorage();
|
||
window.location.href = "/";
|
||
});
|
||
}
|
||
|
||
function fmtDate(iso) {
|
||
try {
|
||
return new Date(iso).toLocaleString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
/* Viewer link (phase 10) — same encoded URL the chat chips use; both
|
||
* query values are percent-encoded (paths contain slashes, sometimes
|
||
* spaces). Phase 26: this is the href the .doc-link CARRIES (no-JS /
|
||
* context-menu escape hatch) — the left-click opens the same-page modal
|
||
* instead. */
|
||
export function documentUrl(source, path) {
|
||
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
|
||
}
|
||
|
||
async function loadDocs() {
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/docs");
|
||
} catch {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
const { documents } = await r.json();
|
||
if (!documents.length) {
|
||
showEmpty();
|
||
return;
|
||
}
|
||
|
||
tbody.replaceChildren();
|
||
let totalChunks = 0;
|
||
let last = "";
|
||
for (const d of documents) {
|
||
totalChunks += d.chunks;
|
||
if (d.indexed_at > last) last = d.indexed_at;
|
||
tbody.appendChild(makeRow(d));
|
||
}
|
||
statDocs.textContent = String(documents.length);
|
||
statChunks.textContent = String(totalChunks);
|
||
statLast.textContent = last ? fmtDate(last) : "–";
|
||
emptyEl.hidden = true;
|
||
tableWrap.hidden = false;
|
||
}
|
||
|
||
function makeRow(d) {
|
||
const tr = document.createElement("tr");
|
||
|
||
const sourceTd = document.createElement("td");
|
||
sourceTd.textContent = d.source; // document-derived text — never innerHTML
|
||
tr.appendChild(sourceTd);
|
||
|
||
// Path cell: a link to the document (phase 10), full path as the
|
||
// accessible/hover name (the column is ellipsized). Phase 26: the
|
||
// left-click opens the same-page modal — no new tab (document-modal.js);
|
||
// the href stays as the no-JS / context-menu escape hatch.
|
||
const pathTd = document.createElement("td");
|
||
pathTd.title = d.path; // full path on hover (column is ellipsized)
|
||
const link = document.createElement("a");
|
||
link.className = "doc-link";
|
||
link.href = documentUrl(d.source, d.path);
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault(); // no new tab (phase 26) — the modal takes over
|
||
e.stopPropagation();
|
||
openDocumentModal(d.source, d.path, link);
|
||
});
|
||
link.title = d.path; // full path as the link's hover/accessible name
|
||
link.textContent = d.path;
|
||
pathTd.appendChild(link);
|
||
tr.appendChild(pathTd);
|
||
|
||
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
|
||
const td = document.createElement("td");
|
||
td.textContent = value;
|
||
tr.appendChild(td);
|
||
}
|
||
return tr;
|
||
}
|
||
|
||
function showEmpty() {
|
||
statDocs.textContent = "0";
|
||
statChunks.textContent = "0";
|
||
statLast.textContent = "–";
|
||
emptyEl.hidden = false;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
}
|
||
|
||
/* ---------- Phase 32: the admin "Sync sources" button (§7.4) ----------
|
||
* The "never stale" lifecycle for a long background job:
|
||
*
|
||
* idle → click → POST /api/sync
|
||
* 202 → "Syncing…" (disabled, aria-busy, spinning icon) + a
|
||
* 2 s poll of GET /api/sync/status — the feedback loop;
|
||
* 409 adopts the in-flight run the same way (one poll
|
||
* loop at a time, never two);
|
||
* success → "Synced HH:MM" + last-result counts in #sync-result
|
||
* (aria-live — announced to screen readers) + the
|
||
* catalog re-fetches live (never a stale table);
|
||
* failed → "Sync sources" (retry-ready) + the role="alert"
|
||
* banner naming the error.
|
||
*
|
||
* NO client-side hard timeout (phase locked decision): a sync can
|
||
* legitimately run for minutes (clone + embed), so the 2 s poll is the
|
||
* feedback loop and the server state is authoritative — the button is
|
||
* disabled until the run reaches a terminal state, so it can never sit
|
||
* stale OR stuck. On load (admin only) the page re-attaches: a running
|
||
* run re-enters the running state (reload mid-sync), a terminal run
|
||
* renders its last result. A 403 anywhere hides the button (defense in
|
||
* depth — header.js's whoami reveal is the primary gate).
|
||
*/
|
||
const syncBtn = document.querySelector("#sync-btn");
|
||
const syncLabel = document.querySelector("#sync-label");
|
||
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; // the 2 s status poll (task 02)
|
||
let syncPollTimer = null; // at most ONE live poll loop
|
||
|
||
function stopSyncPolling() {
|
||
if (syncPollTimer !== null) {
|
||
clearTimeout(syncPollTimer);
|
||
syncPollTimer = null;
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/* The local HH:MM of finished_at — 24-hour, locale-independent, so the
|
||
* "Synced 14:32" last-result label is deterministic. */
|
||
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())}`;
|
||
}
|
||
|
||
/* The last-result line for #sync-result (aria-live). "added" is ALWAYS
|
||
* announced (the run's headline term); "updated" / "pruned" only when
|
||
* they happened (zero terms omitted); "unchanged" whenever it is
|
||
* non-zero — or whenever nothing was added or updated, so a no-op
|
||
* re-sync reads "0 added · 1 unchanged" instead of an empty live
|
||
* region (the story gate's idempotency check). */
|
||
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 enterRunningState() {
|
||
syncBtn.disabled = true;
|
||
syncBtn.setAttribute("aria-busy", "true");
|
||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||
syncLabel.textContent = "Syncing…";
|
||
if (syncResult) syncResult.textContent = "";
|
||
hideSyncError();
|
||
}
|
||
|
||
/* Settle the button back to clickable + un-spun with the given label. */
|
||
function settleSyncButton(label) {
|
||
syncBtn.disabled = false;
|
||
syncBtn.removeAttribute("aria-busy");
|
||
if (syncIcon) syncIcon.classList.remove("is-spinning");
|
||
syncLabel.textContent = label;
|
||
}
|
||
|
||
function applySyncSuccess(status) {
|
||
const time = fmtSyncTime(status.finished_at);
|
||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||
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();
|
||
}
|
||
|
||
function applySyncFailure(status) {
|
||
settleSyncButton("Sync sources"); // retry-ready
|
||
if (syncResult) syncResult.textContent = "";
|
||
showSyncError(status.error);
|
||
}
|
||
|
||
/* A run can only vanish with a server restart mid-sync (status resets
|
||
* to idle — the phase-accepted behavior): re-enable retry-ready with no
|
||
* banner (there is no error to name; the next click re-syncs).
|
||
* Idempotent — also the post-403 cleanup. */
|
||
function applySyncIdle() {
|
||
settleSyncButton("Sync sources");
|
||
if (syncResult) syncResult.textContent = "";
|
||
hideSyncError();
|
||
}
|
||
|
||
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
|
||
* timeout, phase locked decision). One tick at a time (re-scheduled
|
||
* only while the run is still live, so an in-flight fetch can never
|
||
* overlap the next tick), and startSyncPolling refuses to run a second
|
||
* loop (a 409 adoption or a reload never doubles the polling). */
|
||
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 — the next tick retries (no client timeout to trip) */
|
||
}
|
||
if (notAdmin) {
|
||
// Session lost mid-sync: defense in depth — hide the button.
|
||
stopSyncPolling();
|
||
syncBtn.hidden = true;
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
if (status && status.state === "success") {
|
||
stopSyncPolling();
|
||
applySyncSuccess(status);
|
||
return;
|
||
}
|
||
if (status && status.state === "failed") {
|
||
stopSyncPolling();
|
||
applySyncFailure(status);
|
||
return;
|
||
}
|
||
if (status && status.state === "idle") {
|
||
// The run died with a server restart — retry-ready, no banner.
|
||
stopSyncPolling();
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
};
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
}
|
||
|
||
/* Click → POST /api/sync. 202 starts the run; 409 adopts the in-flight
|
||
* one (started elsewhere — e.g. a second tab); 403 hides the button
|
||
* (defense in depth); anything else names the failure in the banner and
|
||
* leaves the button retry-ready (the never-stale contract). */
|
||
async function startSync() {
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/sync", { method: "POST" });
|
||
} catch {
|
||
showSyncError("Could not reach the server to start the sync — try again.");
|
||
return;
|
||
}
|
||
if (r.status === 403) {
|
||
stopSyncPolling();
|
||
syncBtn.hidden = true;
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
if (r.status === 202 || r.status === 409) {
|
||
enterRunningState();
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
let detail = "";
|
||
try {
|
||
detail = (await r.json()).detail || "";
|
||
} catch {
|
||
/* non-JSON error body */
|
||
}
|
||
showSyncError(detail || `The server refused to start the sync (${r.status}).`);
|
||
}
|
||
|
||
/* Load-time re-attach (admin only — the IIFE runs this after the
|
||
* whoami gate): a running run re-enters the running state (the user may
|
||
* have reloaded mid-sync), a terminal run renders its last result, idle
|
||
* renders nothing. */
|
||
async function initSyncButton() {
|
||
if (!syncBtn) return;
|
||
let status;
|
||
try {
|
||
const r = await fetch("/api/sync/status");
|
||
if (r.status === 403) {
|
||
syncBtn.hidden = true; // defense in depth
|
||
return;
|
||
}
|
||
if (!r.ok) return;
|
||
status = await r.json();
|
||
} catch {
|
||
return; // network blip — the button stays idle and clickable
|
||
}
|
||
if (status.state === "running") {
|
||
enterRunningState();
|
||
startSyncPolling();
|
||
} else if (status.state === "success") {
|
||
applySyncSuccess(status);
|
||
} else if (status.state === "failed") {
|
||
applySyncFailure(status);
|
||
}
|
||
/* idle → nothing to render */
|
||
}
|
||
|
||
if (syncBtn) syncBtn.addEventListener("click", startSync);
|
||
|
||
(async () => {
|
||
await initSharedHeader(); // phase 19: Sign in/out + Sources link in the shared bar
|
||
if (!(await isAdmin())) {
|
||
// Anonymous: gate in, catalog out, and no /api/docs request at all.
|
||
if (statCards) statCards.hidden = true;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
if (emptyEl) emptyEl.hidden = true;
|
||
if (gateEl) gateEl.hidden = false;
|
||
return;
|
||
}
|
||
if (gateEl) gateEl.hidden = true;
|
||
loadDocs();
|
||
initSyncButton(); // phase 32: re-attach to a running / last sync run
|
||
})();
|