TODO.md L4: with a dead model endpoint the sync discovered it only mid-import, after slow clones — and a tooltip on the button is not a readable error. - app/rag/llm.py: ModelUnavailableError + check_models(llm) — a tiny pre-sync probe (one short embedding + one 1-token-scale completion) that fails naming the unavailable model (embed first, then the summary model); the sync sanitizer still masks credentials. - app/api/sync.py: the probe is step 1 of _run_sync — before source resolution and before any clone_or_pull; a model failure is just another 'failed' state (no new endpoint, A10/A12 untouched). - frontend/assets/header.js: applySyncFailure now also opens the module-owned error modal (every page carrying #sync-btn, zero page-markup changes): lazily built backdrop + role=alertdialog panel, error text via textContent, close via button / Esc / backdrop, focus in-and-out to #sync-btn (with a body→#sync-btn fallback — the run's disabled button drops focus to <body>). - frontend/assets/styles.css: the modal on the phase-08 error palette (z-index above the header, .is-open open/close, reduced-motion stilling, 44px close target). - Tests: probe unit tests (both up / embed down / summary down / custom model names), sync integration (fail-fast before any clone, probe-before-effective_sources ordering, credential masking, healthy regression), the phase-41 source pins, and the story E2E (two module apps on distinct ports — dead endpoint on a closed loopback port vs session mock: ≤10 s fail-fast + modal contract, all three dismissal paths with focus out to #sync-btn, button title/.is-error + Sources banner untouched, healthy phase-32 lifecycle regression to 'Synced HH:MM'). E2E (isolation): test_sync_model_down.py 4/4, test_sync_button.py 3/3, test_git_sources_admin.py 6/6, test_local_directory_sources.py 3/3; unit+integration 721 passed, app/ coverage 99%; ruff + pyright clean.
694 lines
30 KiB
JavaScript
694 lines
30 KiB
JavaScript
/* Brain of Reese — shared header module (phase 19).
|
||
*
|
||
* Owner report 2026-08-23: clicking "Sources" made New Chat and Sign in
|
||
* vanish — the user expects ONE consistent bar on every page. This module
|
||
* is the single owner of the shared header controls:
|
||
*
|
||
* • the Sign in / Sign out auth pair (phase 16, exactly one visible —
|
||
* decided by /api/whoami at load);
|
||
* • the admin-only nav links — "Sources" (#nav-sources, phase 19),
|
||
* "Git sources" (#nav-git-sources, phase 35) and "Tuning"
|
||
* (#nav-tuning, phase 29) — phase 19 UX revision
|
||
* (owner permission 2026-08-23): hidden for anonymous on EVERY
|
||
* page, revealed for admin. Phase 34 task 03 (owner confirmation
|
||
* 2026-08-26): the SAME nav ships on all five pages (chat,
|
||
* sources, document viewer, tuning, login) — the viewer's
|
||
* "no nav" bar is gone. The links SHIP hidden in the HTML
|
||
* (anonymous-safe default — the phase-16 "absent, not hidden"
|
||
* spirit), so no anonymous user ever sees one for a frame; and
|
||
* the "Sync sources" button (#sync-btn, phase 32 — every page
|
||
* from phase 34 task 03) — the same ship-hidden / reveal-for-admin
|
||
* contract on the SAME cached whoami (one fetch, no extra request);
|
||
* • the sign-out click binding (POST /api/logout → reload) — moved
|
||
* here from app.js so there is exactly one implementation;
|
||
* • the steering-notes controls (phase 15, moved here from app.js in
|
||
* phase 34) — the #steering-toggle open/close + the #steering-panel
|
||
* list (newest-first, textContent-rendered, per-note delete, count
|
||
* badge, the #steering-announcer live region) — so the toggle can
|
||
* sit in every page's header with zero page-script duplication.
|
||
* The toggle SHIPS hidden in every page (phase 40, 2026-08-27,
|
||
* TODO.md L3 — the exact ship-hidden / reveal-for-admin contract
|
||
* the admin-only nav links use: anonymous never sees it for a
|
||
* single frame) and initSharedHeader unhides it only when whoami
|
||
* says admin. refreshSteering() / announceSteering() are exported
|
||
* for the chat page's per-bubble Tune form (which stays in app.js);
|
||
* anonymous visitors get the phase-16 "absent, not hidden"
|
||
* treatment (toggle + panel removed from the DOM, /api/steering
|
||
* never fetched);
|
||
* • the Sync sources state machine (phase 32, moved here from
|
||
* sources.js in phase 34 task 02) — the §7.4 never-stale lifecycle
|
||
* for #sync-btn (idle → running → success | failed): admin-only
|
||
* boot re-attach on the SAME cached whoami (non-admins never poll),
|
||
* POST /api/sync (202 start / 409 adopt), the 2 s
|
||
* GET /api/sync/status poll (one live timer, NO client-side hard
|
||
* timeout — the server state is authoritative). Every state change
|
||
* dispatches window "bor:sync-status" (detail = the status object)
|
||
* so the Sources page renders its #sync-result line +
|
||
* #sync-error-banner off the event; the button title/aria are the
|
||
* secondary failure surfaces — and a failed run ALSO opens the
|
||
* module-owned error modal (phase 41, 2026-08-27, TODO.md L4, the
|
||
* primary readable failure surface): lazily built by this module,
|
||
* appended to <body>, error text textContent-rendered, closable via
|
||
* its button / Esc / backdrop, focus in-and-out to #sync-btn —
|
||
* every page carrying #sync-btn gets it with zero page-markup
|
||
* changes;
|
||
* • the SINGLE New chat binding (phase 34 task 02 — it was
|
||
* duplicated across app.js / sources.js / tuning.js / document.js):
|
||
* on the chat page (#messages exists) the module dispatches
|
||
* window "bor:new-chat" and app.js acts (it owns the in-flight-turn
|
||
* guard + the list reset); on every other page it means "go to the
|
||
* chat, fresh" — clearChatStorage() + navigate to "/";
|
||
* • the sign-in ?next= rewrite (phase 34 task 02) — initSharedHeader
|
||
* points #sign-in-link at /login.html?next=<current pathname>
|
||
* (default "/"), so the admin lands back on the page they signed in
|
||
* from; the page markup keeps its own href as the no-JS fallback;
|
||
* • clearChatStorage() — the phase-14 conversation key, for the
|
||
* New chat action on the NON-CHAT pages (sources / document viewer /
|
||
* tuning / login): a new chat means going to the chat, fresh.
|
||
*
|
||
* Every page loads this module (type="module", before its page script)
|
||
* and its page script calls initSharedHeader() once at boot. init…
|
||
* toggles ONLY the controls that exist on the page — a missing element
|
||
* is a no-op. Phase 34 task 03 ships the SAME full header block on all
|
||
* five pages (the login page included), so every control resolves on
|
||
* every page; a page that lacks one simply skips it.
|
||
*
|
||
* whoami is fetched at most ONCE per page load: the promise is cached in
|
||
* the module-level `adminPromise`, so app.js's tuning gate, the sources
|
||
* page's catalog gate, and the header toggling all share one request.
|
||
* Anonymous-safe: any network failure resolves to false (the anonymous
|
||
* UI), mirroring the per-page catch the pages used before phase 19.
|
||
*
|
||
* A10/A11 untouched: no API change, no CDN, no state beyond the cached
|
||
* promise; the soft gate page and the A10 API split are unchanged —
|
||
* this is UI visibility only.
|
||
*/
|
||
|
||
let adminPromise = null;
|
||
|
||
/* The SINGLE /api/whoami call site for the whole frontend. First call
|
||
stores the promise in `adminPromise`; every later call — on this page
|
||
— returns the same promise, i.e. exactly one request per page load.
|
||
Anonymous-safe: non-2xx or a network failure resolves to false. */
|
||
export function fetchIsAdmin() {
|
||
if (!adminPromise) {
|
||
adminPromise = fetch("/api/whoami")
|
||
.then(async (r) => (r.ok ? (await r.json()).authenticated === true : false))
|
||
.catch(() => false);
|
||
}
|
||
return adminPromise;
|
||
}
|
||
|
||
/* Toggle the shared header controls, only the ones present on this page
|
||
(querySelector, null-safe — missing → no-op). Returns the admin flag
|
||
so callers can reuse it instead of awaiting fetchIsAdmin() again (the
|
||
cached promise makes both awaits the same single request). */
|
||
export async function initSharedHeader() {
|
||
const admin = await fetchIsAdmin();
|
||
// The Sign in link: hidden for the admin, visible otherwise — and its
|
||
// href is rewritten to return the admin to THIS page after login
|
||
// (phase 34 task 02: "return to where you were"). The markup keeps its
|
||
// own static ?next= as the no-JS fallback. location.pathname is always
|
||
// a query-safe "/…" string (never "//"; ? # and spaces stay
|
||
// percent-encoded in it), so it rides in next= as-is — the same shape
|
||
// the static fallbacks use (login.js safeNext re-validates it).
|
||
const signIn = document.querySelector("#sign-in-link");
|
||
if (signIn) {
|
||
signIn.hidden = admin;
|
||
signIn.href = "/login.html?next=" + (window.location.pathname || "/");
|
||
}
|
||
const signOut = document.querySelector("#sign-out-btn");
|
||
if (signOut) signOut.hidden = !admin;
|
||
const navSources = document.querySelector("#nav-sources");
|
||
if (navSources) navSources.hidden = !admin;
|
||
// Phase 35 (owner permission 2026-08-26): the Git sources nav link —
|
||
// admin-only, the same ship-hidden / reveal-for-admin contract as
|
||
// the Sources link above.
|
||
const navGitSources = document.querySelector("#nav-git-sources");
|
||
if (navGitSources) navGitSources.hidden = !admin;
|
||
// Phase 29: the Global Tuning nav link (every page from phase 34
|
||
// task 03) — admin-only, the same ship-hidden / reveal-for-admin
|
||
// contract as the Sources link.
|
||
const navTuning = document.querySelector("#nav-tuning");
|
||
if (navTuning) navTuning.hidden = !admin;
|
||
// Phase 32: the "Sync sources" button — admin-only, revealed on this
|
||
// same cached whoami (anonymous users never see it).
|
||
if (syncBtn) syncBtn.hidden = !admin;
|
||
// Phase 34: the steering controls (phase 15) are module-owned. Admin:
|
||
// unhide the toggle — it SHIPS hidden (phase 40, 2026-08-27, TODO.md
|
||
// L3, the same ship-hidden / reveal-for-admin contract as the
|
||
// admin-only nav links) — then refresh the list so the count badge is
|
||
// right before the panel is ever opened (fire-and-forget, as the chat
|
||
// page did before the move). Anonymous: the toggle + panel are REMOVED
|
||
// from the DOM entirely — the phase-16 contract says "absent", not
|
||
// just hidden — and /api/steering is never fetched.
|
||
if (admin) {
|
||
if (steeringToggle) steeringToggle.hidden = false;
|
||
if (steeringPanel) refreshSteering();
|
||
} else {
|
||
steeringToggle?.remove();
|
||
steeringPanel?.remove();
|
||
}
|
||
return admin;
|
||
}
|
||
|
||
/* Remove the phase-14 conversation key — same key + fail-silence
|
||
contract as app.js's clearStoredConversation: private mode or a
|
||
storage error is swallowed, the navigation still happens. */
|
||
export function clearChatStorage() {
|
||
try {
|
||
localStorage.removeItem("bor.chat.v1");
|
||
} catch {
|
||
/* nothing was stored */
|
||
}
|
||
}
|
||
|
||
/* Sign-out binding (phase 16 behavior, now module-owned): runs at module
|
||
import, so every page that loads header.js gets it exactly once.
|
||
Disable during the call, POST /api/logout (the result is ignored —
|
||
the reload resets the UI either way), then reload so the header
|
||
re-resolves to the anonymous state (Sign in back, Sources gone). */
|
||
const signOutBtn = document.querySelector("#sign-out-btn");
|
||
if (signOutBtn) {
|
||
signOutBtn.addEventListener("click", async () => {
|
||
signOutBtn.disabled = true;
|
||
try {
|
||
await fetch("/api/logout", { method: "POST" });
|
||
} catch {
|
||
/* the reload resets the UI either way */
|
||
}
|
||
window.location.reload();
|
||
});
|
||
}
|
||
|
||
/* ---------- steering notes (phase 15; module-owned from phase 34) ----------
|
||
*
|
||
* The owner's tuning notes steer every future answer: they live in
|
||
* Postgres (stateless API, A10) and the chat turn reads them into the
|
||
* system prompt. The header panel — toggle, list, per-note delete, count
|
||
* badge, announcer — is owned by THIS module: every page that ships the
|
||
* panel markup gets exactly this behavior, with zero page-script
|
||
* duplication. The chat page keeps only its per-bubble Tune form
|
||
* (app.js), which refreshes the panel through refreshSteering() and
|
||
* announces through announceSteering().
|
||
*
|
||
* All elements are looked up null-safe (querySelector + guard): a page
|
||
* that lacks the panel markup is a no-op — the same contract as
|
||
* initSharedHeader().
|
||
*/
|
||
const steeringToggle = document.querySelector("#steering-toggle");
|
||
const steeringCount = document.querySelector("#steering-count");
|
||
const steeringPanel = document.querySelector("#steering-panel");
|
||
const steeringList = document.querySelector("#steering-list");
|
||
const steeringEmpty = document.querySelector("#steering-empty");
|
||
const steeringAnnouncer = document.querySelector("#steering-announcer");
|
||
|
||
/* Announce a steering change through the polite live region
|
||
(role="status", aria-live="polite") — exported so the chat page's
|
||
per-bubble Tune form (app.js) announces on the exact same channel. */
|
||
export function announceSteering(message) {
|
||
if (steeringAnnouncer) steeringAnnouncer.textContent = message;
|
||
}
|
||
|
||
/* Fetch + render the note list (exported — the chat page's per-bubble
|
||
Tune form calls it on save, so the panel + count badge update without
|
||
owning the fetch itself). Non-2xx (the anonymous 403) or an
|
||
unreachable API render the empty state: count badge 0, the "no notes
|
||
yet" text visible — the safe fallback in either case. */
|
||
export async function refreshSteering() {
|
||
let notes = [];
|
||
try {
|
||
const r = await fetch("/api/steering");
|
||
if (r.ok) notes = (await r.json()).notes || [];
|
||
} catch {
|
||
/* API unreachable: the empty list state is the safe fallback */
|
||
}
|
||
renderSteeringPanel(notes);
|
||
return notes;
|
||
}
|
||
|
||
/* Newest-first list — the note is ALWAYS rendered with textContent
|
||
(XSS-safe, never innerHTML), a per-note Remove button with a labeled
|
||
aria-label, the empty text toggled on notes.length, and the header
|
||
count badge. */
|
||
function renderSteeringPanel(notes) {
|
||
if (!steeringList) return;
|
||
steeringList.textContent = "";
|
||
for (const n of notes) {
|
||
const li = document.createElement("li");
|
||
li.className = "steering-note";
|
||
const text = document.createElement("span");
|
||
text.className = "steering-note-text";
|
||
text.textContent = n.note; // rendered as text, never as HTML
|
||
li.appendChild(text);
|
||
const del = document.createElement("button");
|
||
del.type = "button";
|
||
del.className = "steering-delete";
|
||
del.setAttribute("aria-label", `Delete tuning note: ${n.note}`);
|
||
del.innerHTML =
|
||
'<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><path d="M5 7h14M10 7V5h4v2M8.5 7l.7 12h5.6l.7-12"/></svg>';
|
||
del.addEventListener("click", () => deleteSteeringNote(n.id, del));
|
||
li.appendChild(del);
|
||
steeringList.appendChild(li);
|
||
}
|
||
if (steeringEmpty) steeringEmpty.hidden = notes.length > 0;
|
||
if (steeringCount) steeringCount.textContent = String(notes.length);
|
||
}
|
||
|
||
/* Per-note delete: disable the row button (no double-fire), DELETE
|
||
/api/steering/{id}, re-load the list, announce through
|
||
#steering-announcer. A 404 means the note was already gone — say so
|
||
and still refresh; any other failure re-enables the button so the
|
||
user can retry. */
|
||
async function deleteSteeringNote(id, btn) {
|
||
btn.disabled = true;
|
||
try {
|
||
const r = await fetch(`/api/steering/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||
if (r.status === 404) {
|
||
announceSteering("That note was already removed.");
|
||
await refreshSteering();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
announceSteering("Could not delete the note — try again.");
|
||
btn.disabled = false;
|
||
return;
|
||
}
|
||
await refreshSteering();
|
||
announceSteering("Tuning note deleted.");
|
||
} catch {
|
||
announceSteering("Could not delete the note — is the app reachable?");
|
||
btn.disabled = false;
|
||
}
|
||
}
|
||
|
||
/* Open/close the panel, keeping the toggle's aria-expanded in sync —
|
||
the exact phase-15 chat-page contract (open on click, close on click;
|
||
the panel itself is a plain region — no Esc / outside-click close in
|
||
the original, so none here). Re-opening refreshes the list, so notes
|
||
changed elsewhere (the Tuning page, another tab) show up. */
|
||
function setSteeringPanel(open) {
|
||
if (!steeringPanel || !steeringToggle) return;
|
||
steeringPanel.hidden = !open;
|
||
steeringToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||
}
|
||
/* Toggle binding (module-owned, like the sign-out binding): runs at
|
||
module import, so a page with the toggle markup gets exactly one
|
||
implementation. */
|
||
if (steeringToggle && steeringPanel) {
|
||
steeringToggle.addEventListener("click", () => {
|
||
setSteeringPanel(steeringPanel.hidden);
|
||
if (!steeringPanel.hidden) refreshSteering(); // refresh when (re)opened
|
||
});
|
||
}
|
||
|
||
/* ---------- New chat (the SINGLE binding — module-owned from phase 34
|
||
* task 02) ----------
|
||
*
|
||
* The binding used to be duplicated across app.js / sources.js /
|
||
* tuning.js / document.js with the same page-kind branch. It lives here
|
||
* exactly once (module import, like the sign-out binding): on the chat
|
||
* page (#messages exists) the module dispatches window "bor:new-chat"
|
||
* and app.js acts — the chat script owns the in-flight-turn guard and
|
||
* the rendered-list reset; on every other page "new chat" means go to
|
||
* the chat, fresh: clear the phase-14 conversation key, then navigate
|
||
* to "/" (its empty state, since the conversation is gone from storage).
|
||
*/
|
||
const newChatBtn = document.querySelector("#new-chat-btn");
|
||
if (newChatBtn) {
|
||
newChatBtn.addEventListener("click", () => {
|
||
if (document.querySelector("#messages")) {
|
||
window.dispatchEvent(new CustomEvent("bor:new-chat"));
|
||
return;
|
||
}
|
||
clearChatStorage();
|
||
window.location.href = "/";
|
||
});
|
||
}
|
||
|
||
/* ---------- sync sources (phase 32; module-owned from phase 34 task 02) ----------
|
||
*
|
||
* The "never stale" lifecycle for the long background sync job, moved
|
||
* here from sources.js so the SAME #sync-btn markup on ANY page (phase
|
||
* 34 task 03) behaves identically. The button is the module's; the
|
||
* Sources page's #sync-result line + #sync-error-banner render off the
|
||
* "bor:sync-status" event this machine dispatches (sources.js
|
||
* subscribes):
|
||
*
|
||
* idle → click → POST /api/sync
|
||
* 202 → running (disabled, aria-busy, spinning icon, "Syncing…")
|
||
* + a 2 s poll of GET /api/sync/status;
|
||
* 409 → the in-flight run is ADOPTED the same way (one sync
|
||
* at a time, one poll loop at a time);
|
||
* success → "Synced HH:MM"; failed → retry-ready "Sync sources"
|
||
* + the sanitized error in the button's title +
|
||
* aria-label + the module-owned error modal (phase 41,
|
||
* 2026-08-27, TODO.md L4 — the primary readable failure
|
||
* surface on every page; the button affordance and the
|
||
* Sources banner stay the secondary surfaces).
|
||
*
|
||
* Boot (admin only — non-admins never poll, the status endpoint is
|
||
* admin-only): one GET /api/sync/status on the SAME cached whoami —
|
||
* running re-enters the running state (reload mid-sync), a terminal
|
||
* state renders its last result. NO client-side hard timeout (phase 32
|
||
* locked decision): a sync can legitimately outlive the page, so the
|
||
* 2 s poll is the feedback loop and the server state is authoritative.
|
||
*
|
||
* All elements are looked up null-safe: a page that doesn't (yet) carry
|
||
* the #sync-btn markup is a complete no-op, exactly like the rest of
|
||
* this module.
|
||
*/
|
||
const syncBtn = document.querySelector("#sync-btn");
|
||
const syncLabel = document.querySelector("#sync-label");
|
||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||
|
||
const SYNC_POLL_MS = 2000; // the 2 s status poll (phase 32 contract)
|
||
let syncPollTimer = null; // at most ONE live poll loop
|
||
let lastSyncState = null; // the last state emitted on bor:sync-status
|
||
|
||
/* The module → page channel: detail is the GET /api/sync/status object
|
||
(or the synthetic { state: "running" } frame the click path emits
|
||
before the first poll tick — the Sources handlers only need the
|
||
state, the next real object carries the full fields). */
|
||
function emitSyncStatus(status) {
|
||
lastSyncState = status.state;
|
||
window.dispatchEvent(new CustomEvent("bor:sync-status", { detail: status }));
|
||
}
|
||
|
||
function stopSyncPolling() {
|
||
if (syncPollTimer !== null) {
|
||
clearTimeout(syncPollTimer);
|
||
syncPollTimer = null;
|
||
}
|
||
}
|
||
|
||
/* 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 the Sources page's #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). Exported so the
|
||
* Sources page renders the counts from ONE implementation. */
|
||
export 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(" · ");
|
||
}
|
||
|
||
/* The failed-state affordance text for the button's title + aria-label
|
||
* (non-Sources pages: that is where the failure is visible). The server
|
||
* already masks any embedded credentials (sync.py _sanitize_error);
|
||
* here the string is collapsed to a single line and capped so a chatty
|
||
* git stderr can't bloat the attributes. */
|
||
function sanitizeSyncError(message) {
|
||
const text = String(message || "The sync failed.").replace(/\s+/g, " ").trim();
|
||
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
||
}
|
||
|
||
/* §7.4 running state: disabled + aria-busy + spinning icon + the
|
||
* "Syncing…" label — and a fresh run starts clean: the previous
|
||
* failure's affordances (title / aria-label / .is-error) come off NOW,
|
||
* not when the run settles. The button only — the Sources page's
|
||
* result line / banner clear off the matching "running" event (no
|
||
* 2 s lag). */
|
||
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…";
|
||
}
|
||
|
||
/* Settle the button back to clickable + un-spun with the given label,
|
||
* dropping the failed-state affordances (a fresh run starts clean). */
|
||
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 applySyncSuccess(status) {
|
||
const time = fmtSyncTime(status.finished_at);
|
||
settleSyncButton(time ? `Synced ${time}` : "Synced");
|
||
emitSyncStatus(status);
|
||
}
|
||
|
||
function applySyncFailure(status) {
|
||
const error = sanitizeSyncError(status.error);
|
||
settleSyncButton("Sync sources"); // retry-ready
|
||
if (syncBtn) {
|
||
// The failed look: error text in title + aria-label (and the
|
||
// .is-error class for the non-Sources pages' visible error state).
|
||
syncBtn.title = error;
|
||
syncBtn.setAttribute("aria-label", error);
|
||
syncBtn.classList.add("is-error");
|
||
}
|
||
emitSyncStatus(status);
|
||
// Phase 41 (2026-08-27, TODO.md L4): the module-owned error modal —
|
||
// the primary readable failure surface (the button title/aria and the
|
||
// Sources banner above stay as the secondary surfaces).
|
||
showSyncModal(error);
|
||
}
|
||
|
||
/* ---------- sync failure modal (phase 41, 2026-08-27, TODO.md L4) ----------
|
||
*
|
||
* A tooltip on the button is not a readable error — a failed sync ALSO
|
||
* opens a modal dialog. It is built by THIS module (the owner of the
|
||
* sync state machine), so every page carrying #sync-btn gets it with
|
||
* zero page-markup changes: created lazily ONCE (module-level
|
||
* `syncModal`) and appended to <body> — a .sync-modal-backdrop (fixed,
|
||
* full-viewport dim) holding the .sync-modal panel
|
||
* (role="alertdialog", aria-modal, labelled + described). The error
|
||
* text is ALWAYS set via textContent (XSS-safe — never innerHTML with
|
||
* user data); a second failure while open updates the text IN PLACE
|
||
* (no stacking). Closes via the close button, Esc (ONE document
|
||
* keydown binding, acting only while open), or a click on the backdrop
|
||
* itself (never the panel); focus moves to the close button on open
|
||
* and back to the remembered element (#sync-btn — the control that
|
||
* started the run) on close. Null-safe: no #sync-btn (or no <body>) →
|
||
* no modal, exactly like the rest of this module.
|
||
*/
|
||
let syncModal = null; // the backdrop element — created once, lazily
|
||
let syncModalReturnFocus = null; // the element to refocus on close
|
||
|
||
function createSyncModal() {
|
||
const backdrop = document.createElement("div");
|
||
backdrop.className = "sync-modal-backdrop";
|
||
// Static skeleton — no user data anywhere in it; the error text is
|
||
// filled via textContent in showSyncModal, never interpolated here.
|
||
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);
|
||
// Close path 1: the close button (×).
|
||
backdrop.querySelector(".sync-modal-close").addEventListener("click", closeSyncModal);
|
||
// Close path 2: a click on the backdrop element itself — never one
|
||
// that bubbles up from the panel (event.target check).
|
||
backdrop.addEventListener("click", (e) => {
|
||
if (e.target === backdrop) closeSyncModal();
|
||
});
|
||
// Close path 3: Esc — ONE document-level keydown binding for the
|
||
// life of the page, acting only while the modal is open.
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && backdrop.classList.contains("is-open")) closeSyncModal();
|
||
});
|
||
return backdrop;
|
||
}
|
||
|
||
function showSyncModal(error) {
|
||
if (!syncBtn || !document.body) return; // null-safe: pages without the button
|
||
if (!syncModal) syncModal = createSyncModal();
|
||
// The sanitized error as TEXT (XSS-safe) — a second failure while
|
||
// open updates the text in place (no stacking, no focus jump).
|
||
syncModal.querySelector("#sync-modal-error").textContent = error;
|
||
if (syncModal.classList.contains("is-open")) return;
|
||
// First open: remember the focused element and move focus into the
|
||
// dialog (the close button). While the run was in flight the button
|
||
// was disabled (focus had fallen to <body>), so a body-level active
|
||
// element means "no meaningful focus target" — remember #sync-btn,
|
||
// the control that started the run, so the close returns focus there.
|
||
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");
|
||
// Focus returns to the remembered element — #sync-btn when present.
|
||
const target = syncModalReturnFocus;
|
||
syncModalReturnFocus = null;
|
||
if (target && document.contains(target)) target.focus();
|
||
}
|
||
|
||
/* A run can only vanish with a server restart mid-sync (status resets
|
||
* to idle — the phase-accepted behavior): retry-ready, no error to
|
||
* name. Also the post-403 cleanup. */
|
||
function applySyncIdle(status) {
|
||
settleSyncButton("Sync sources");
|
||
emitSyncStatus(status || { state: "idle" });
|
||
}
|
||
|
||
/* The 2 s poll loop — the ONLY feedback timer (no client-side hard
|
||
* timeout, phase 32 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();
|
||
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") {
|
||
// The run died with a server restart — retry-ready, no banner.
|
||
stopSyncPolling();
|
||
applySyncIdle(status);
|
||
return;
|
||
}
|
||
// Still running: keep the button state honest (idempotent) and
|
||
// re-schedule. No event — the running frame was already emitted
|
||
// when the state entered (click / boot), and the Sources handlers
|
||
// are no-ops for repeated running frames anyway.
|
||
enterSyncRunningState();
|
||
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 (banner on
|
||
* Sources via the event, button affordance everywhere). */
|
||
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();
|
||
// The synthetic running frame clears the Sources result line /
|
||
// banner IMMEDIATELY (before the first poll tick, 2 s away) — the
|
||
// exact sources.js enterRunningState behavior, now event-driven.
|
||
if (lastSyncState !== "running") emitSyncStatus({ state: "running" });
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
let detail = "";
|
||
try {
|
||
detail = (await r.json()).detail || "";
|
||
} catch {
|
||
/* non-JSON error body */
|
||
}
|
||
applySyncFailure({
|
||
state: "failed",
|
||
error: detail || `The server refused to start the sync (${r.status}).`,
|
||
});
|
||
}
|
||
|
||
/* Load-time re-attach (ADMIN ONLY — non-admins never poll, the status
|
||
* endpoint is admin-only): a running run re-enters the running state
|
||
* (the user may have reloaded mid-sync), a terminal run renders its
|
||
* last result, idle settles nothing visible. Awaits the SAME cached
|
||
* whoami promise — exactly one /api/whoami per page load, unchanged. */
|
||
async function initSyncButton() {
|
||
if (!syncBtn) return;
|
||
if (!(await fetchIsAdmin())) return; // anonymous: the button stays hidden
|
||
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") {
|
||
enterSyncRunningState();
|
||
emitSyncStatus(status);
|
||
startSyncPolling();
|
||
} else if (status.state === "success") {
|
||
applySyncSuccess(status);
|
||
} else if (status.state === "failed") {
|
||
applySyncFailure(status);
|
||
} else {
|
||
applySyncIdle(status); // idle: settle + the idle frame
|
||
}
|
||
}
|
||
|
||
if (syncBtn) {
|
||
syncBtn.addEventListener("click", startSync);
|
||
initSyncButton(); // re-attach to a running / last sync run (admin only)
|
||
}
|