**Phase 122 (image documents) — final verification pass: all green. No code changes were needed; defects found: none.**
**Verified (implementation already complete in working tree, reviewed end-to-end):**
- Toggle (`BOR_IMAGES`/`BOR_IMAGE_EXTENSIONS`/`BOR_IMAGE_DIR`, off by default) + `GET /api/config` `images` flag
- Ingest: bytes digest, `image_dir` persistent copy, `content = summary = vision description` (chat-model call; only text embedded), fail-soft skip + `images_failed` counter
- Serve/display: `/api/documents/{id}/image` route (404 matrix), viewer `<img>` + description, Sources 48px lazy thumbnails, chat inline source figure (alt = summary), agent `read` marker
- Prune guard: images-off syncs never prune `is_image` docs
**Test / lint / coverage (exact commands & outcomes):**
- `uv run pytest` → exit 0 (green; note: pytest 9.1.1 `-q` omits the final count line in output — exit code authoritative)
- `uv run pytest --cov=app --cov-report=term-missing` → **2715 passed, exit 0, TOTAL 99%** (>90% gate)
- `uv run ruff check . && uv run pyright` → "All checks passed!" / "0 errors, 0 warnings, 0 informations"
- `uv run pytest tests/e2e/test_image_documents.py -v --no-cov` → **4 passed, exit 0** (isolation)
**Completion criteria:** (1) images=true → described/embedded/displayed docs: ✅ (E2E + integration) · (2) images=false byte-identical + image docs survive sync: ✅ (E2E negative app + unit/integration) · (3) viewer + chat rendering with alt text; failed description skips + logs, sync completes: ✅ · (4) test/lint/coverage gates: ✅ · (5) commit + phase move: deferred to harness per this pass's rules (working tree left uncommitted).
**Notable deviation (pre-existing, documented in code):** image route uses `require_user` (phase-79 posture, same gate as the document content endpoint) rather than the phase text's "public" parenthetical — matches the endpoint it mirrors.
**Next pending phase:** `123_chat_image_questions`.
1658 lines
77 KiB
JavaScript
1658 lines
77 KiB
JavaScript
/* Brain of Reese — RAG view (the knowledge base catalog; phase 76
|
||
* task 02: shell view module — formerly the standalone sources.html).
|
||
*
|
||
* 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 76 (task 02) — shell view module (the "RAG" view of the
|
||
* ONE-document shell; /sources.html now serves the shell, and
|
||
* assets/router.js lazy-imports THIS module on first show):
|
||
*
|
||
* • the top-level boot is now `export async function mount(root)` —
|
||
* root is the view's <section id="view-rag">, and every DOM lookup
|
||
* scopes to root (the view ids stay unique across the shell —
|
||
* scoped lookups keep the module honest and testable). The router
|
||
* mounts a view ONCE (mount-once, hide-forever), so the bindings,
|
||
* the sync state machine, and the in-flight poll survive every
|
||
* switch.
|
||
* • the initSharedHeader() call is DROPPED: in the shell the shared
|
||
* header boots exactly once, via the chat module (app.js) at shell
|
||
* boot — the view never re-boots it. The admin gate (sync button
|
||
* reveal + the anonymous catalog gate) keeps fetchIsAdmin() — the
|
||
* SAME cached /api/whoami promise header.js exports (zero extra
|
||
* requests).
|
||
* • the document modal needs NO wiring change: the shell keeps
|
||
* EXACTLY ONE #doc-modal-* skeleton (the chat's, body level), and
|
||
* both app.js (chat chips) and this module (RAG row links) open
|
||
* documents through the shared openDocumentModal(...) against that
|
||
* single instance (assets/document-modal.js, resolved by
|
||
* document-level querySelector at import).
|
||
*
|
||
* The sync button (phase 32) + the live two-job progress contract
|
||
* (phase 64 task 04) and the table rows (phase 26: same-page modal
|
||
* links) are unchanged in content — only the boot shape moved.
|
||
*
|
||
* Phase 77 (task 02) — the re-show refresh: the shell router
|
||
* dispatches `bor:view-refresh` on the view's section when the user
|
||
* RE-SHOWS an already-mounted view (a switch back onto it, a re-click
|
||
* of the RAG nav link, or back/forward) — the first show (mount) and
|
||
* boot never (the mount's own load is the first fetch). This module
|
||
* listens on root and re-runs the catalog load, which is re-entrant:
|
||
* a re-load clears the row containers' rows BEFORE filling them (the
|
||
* History pattern from task 01 — phase 97 moved the clear from the
|
||
* fetch top into the render, and from the one tbody to both), so a
|
||
* refresh from a populated level into a sparser one replaces the rows
|
||
* (no ghost rows). #sources-empty lives OUTSIDE the tbody (a
|
||
* .empty-state div, not a row), so the clear is a bare
|
||
* replaceChildren(). The listener is armed only in the ADMIN
|
||
* branch, after the whoami gate passes: anonymous shows the gate and
|
||
* never fetches the catalog (the phase-16 soft rule).
|
||
*
|
||
* Phase 97 (task 04) — the catalog becomes the DRILL-DOWN TREE the
|
||
* agent's `ls` sees (the phase-94 concept, one end to end). The load
|
||
* is now `loadTree()`: ONE fetch of GET /api/docs/tree returns the
|
||
* FULL recursive tree (sources → folders → files, each folder carrying
|
||
* its STORED description — the rows the agent reads), and the view
|
||
* drills CLIENT-SIDE from that single fetch — zero per-level fetches,
|
||
* no URL change (the navigation is a re-render of the already-fetched
|
||
* tree).
|
||
*
|
||
* • the drill state is module-scoped: `current` —
|
||
* { source: null, folder: null } = the top level (the rows ARE the
|
||
* sources themselves — the ls() equivalence: name, recursive count,
|
||
* the (source, "") description), `folder: ""` = the source root,
|
||
* `folder: "one/two"` = the nested folder. The last fetched tree
|
||
* lives in the module-scoped `kbTree`.
|
||
* • `renderLevel()` from `current` + `kbTree`: #kb-crumb (hidden at
|
||
* the top — one link per ancestor: the top level, the source, then
|
||
* the folder chain, the last segment an aria-current span), #kb-level
|
||
* (the current level's STORED description — title = the full
|
||
* source-relative path; HIDDEN when none is stored — the ls rule:
|
||
* count only, no placeholder), #folders-table = the level's direct
|
||
* subfolders (top level: the sources), #docs-table = the level's
|
||
* DIRECT files only — makeRow UNCHANGED (the path link still opens
|
||
* the same-page modal; the no-JS href escape hatch intact), hidden
|
||
* when the level has none (at the top level it is ALWAYS hidden —
|
||
* files are seen per source, as with ls(source)).
|
||
* • the stat cards walk the WHOLE tree (document count, chunks sum,
|
||
* max indexed_at) — the values identical to the former flat walk.
|
||
* • the empty state (#sources-empty) is now the "zero SOURCES"
|
||
* semantic (nothing registered, nothing indexed) — the deliberate
|
||
* phase-97 change: a registered 0-document source renders its row
|
||
* (`0` documents) instead (the ls invariant — the agent lists it
|
||
* too). A failed tree fetch renders that same no-data state (the
|
||
* former flat-load failure behavior, unchanged in kind).
|
||
* • Phase 77/79 carry over: the monotonic loadSeq race token (only
|
||
* the newest load may touch the DOM after its await) and the
|
||
* re-entrant render — renderLevel() clears BOTH row containers
|
||
* BEFORE filling them, so a refresh from a populated level into a
|
||
* sparser one leaves no ghost rows. NEVER-STALE (PLAN §7.4): after
|
||
* a re-fetch (re-show, sync success, upload success), if the current
|
||
* location no longer exists in the NEW tree (source unregistered/
|
||
* pruned, folder vanished), `current` RESETS to the top level BEFORE
|
||
* rendering — no stale breadcrumb, no stale block.
|
||
* • the refresh wirings move with the rename: `loadTree()` at the
|
||
* boot load, the bor:view-refresh listener, applySyncSuccess, and
|
||
* the upload-success branch of startSyncPolling — the anonymous
|
||
* branch still NEVER fetches (no /api/docs/tree request at all).
|
||
* • the sync button / poll / label / banner / error-modal machinery
|
||
* and the document-modal wiring are UNTOUCHED (the sync section
|
||
* above is byte-identical save its two catalog-refresh call
|
||
* sites, which now call loadTree() — the phase-97 rename).
|
||
*
|
||
* Phase 97 (task 05) — the folder-description EDITOR (the phase-57
|
||
* affordance, mirrored). The owner edits (or clears) any directory's
|
||
* stored description with the EXACT file-summary interaction: Edit →
|
||
* inline textarea (prefilled via .value) → Save / Cancel → a
|
||
* role=status live-region status.
|
||
*
|
||
* • ONE shared function, wireDescriptionEdit(), drives BOTH
|
||
* surfaces: the static #kb-level Edit button (the current level's
|
||
* description — the block ships the <h2> + a .kb-level-body holding
|
||
* the <p> + the Edit button) and the ALWAYS-present Edit button in
|
||
* every source/folder row's Description cell (makeDescCell builds
|
||
* it inside makeSourceRow / makeFolderRow — a description can be
|
||
* CREATED where none is stored: a < 2-document folder, the
|
||
* generator's fail-soft miss — the editor opens prefilled with the
|
||
* empty string). NO whoami gate in the view: the RAG view is
|
||
* admin-only already (the phase-16 gate) and the endpoint's
|
||
* require_admin is the API-level gate.
|
||
* • Save → PATCH /api/folders/summary with { source, folder_path,
|
||
* summary } — folder_path "" for the source root, the
|
||
* source-relative folder path otherwise (both known from the
|
||
* target: the row's node, or `current` for the level). 200 →
|
||
* re-render the description text (textContent ONLY — the XSS
|
||
* contract) in the surface where the edit happened + status
|
||
* "Description updated."; an empty save (the server echoes
|
||
* summary null) → the text goes away (level block hidden / row
|
||
* cell emptied) + "Description cleared."; the in-memory kbTree
|
||
* node's summary is updated IN PLACE (no re-fetch — the tree state
|
||
* stays coherent; the re-fetch is the safety net) — and, since
|
||
* Phase 98 (task 04), its summary_pending flag is cleared IN
|
||
* PLACE on the same success path (a created/updated description
|
||
* is no longer pending — the D4 marker clears where the edit
|
||
* happened, no re-fetch). Failure
|
||
* (non-2xx / network) → neutral retry copy (the phase-55
|
||
* convention), the editor stays open with the user's text, the
|
||
* stored text untouched. Cancel → restore the text node.
|
||
* • the level block is PERSISTENT (reused across levels as you
|
||
* drill), so wireDescriptionEdit takes a getTarget() getter (a
|
||
* row's is a constant) and returns a handle whose reset() tears
|
||
* down an open editor before every re-render (PLAN §7.4 — a
|
||
* navigate-away or refresh never leaves a stale open editor).
|
||
* • every editor part is static createElement; the description text
|
||
* is a text node (textContent / .value) — this module never builds
|
||
* HTML from document-derived data (the house rule, unchanged).
|
||
*
|
||
* Phase 98 (task 02) — the sync button's phase-aware labels: the
|
||
* post-import span of a sync (the KB overview + the long
|
||
* folder-summary span) no longer reads as a stuck file count. The
|
||
* SYNC job's running label is now phase-aware (fmtSyncPhaseLabel —
|
||
* the section header below carries the decision tree): "overview" →
|
||
* "Writing KB overview…", "summaries" → "Summarizing folders…
|
||
* <source/folder> (n/m)" (the folder part omitted while
|
||
* current_summary is null — the phase's first poll), anything else
|
||
* (the null prelude, "import") → today's "Syncing… <file> (n/m)"
|
||
* byte-identical. The UPLOAD job (its status has no phase) keeps the
|
||
* bare label. The untruncated label rides the button title (now set
|
||
* for EVERY running label, not just file labels) + #sync-result as
|
||
* before (A4).
|
||
*
|
||
* Phase 98 (task 04) — the "Summary pending" markers: the catalog
|
||
* tells the owner a summary is WAITING instead of showing an empty
|
||
* cell that reads as "missed". A source/folder node is
|
||
* summary_pending when its summary is due (recursive count >= 2 —
|
||
* the D3 rule, exactly the next sync's gap-fill set) but no stored
|
||
* description exists yet. The marker has TWO surfaces, both driven
|
||
* by the same node flag:
|
||
*
|
||
* • the row's Description cell (makeDescCell) — when the stored
|
||
* summary is empty AND node.summary_pending, the text span
|
||
* carries the class kb-summary-pending (the muted ink-soft pair,
|
||
* AA on --surface — text + color, never color alone; the row
|
||
* cell's font-size/line-height apply — no row-height change) +
|
||
* the text "Summary pending" + the D4 title, verbatim:
|
||
* "No stored description yet — the next sync will generate one."
|
||
* A stored summary
|
||
* shows the stored text (NEVER the marker); neither stored nor
|
||
* pending shows the empty cell (the ls rule, unchanged). The
|
||
* Edit button is UNCHANGED — always present (a manual save
|
||
* creates the row).
|
||
* • the level block (renderLevel) — #kb-level shows when the
|
||
* current level has a stored description (as today) OR is
|
||
* pending: title as today (the full source-relative path), and
|
||
* #kb-level-summary shows the stored text, or — when pending —
|
||
* the D4 pending note, verbatim:
|
||
* "No description stored yet — the next sync will generate one. (You can write one yourself.)"
|
||
* Neither stored nor pending stays hidden (the ls rule,
|
||
* unchanged). The block's
|
||
* Edit button (write one manually now) already ships in the
|
||
* static markup (phase 97, task 05).
|
||
* • the in-place clear: wireDescriptionEdit's success path sets
|
||
* node.summary_pending = false right after
|
||
* node.summary = data.summary — a created/updated description is
|
||
* no longer pending; the marker clears IN PLACE in the surface
|
||
* where the edit happened, no re-fetch (the re-fetch stays the
|
||
* safety net). closeEditor re-renders the display state from the
|
||
* node with the SAME three-state rule the surfaces use: the
|
||
* pending marker's muted class + tooltip CANNOT survive a save
|
||
* (a stale "next sync" tooltip under a just-created description),
|
||
* and a cancel restores the surface's pending display (each
|
||
* surface passes its pending copy via pendingText — the row's
|
||
* "Summary pending" marker, the level's D4 note — and its
|
||
* tooltip via pendingTitle, the row only).
|
||
*
|
||
* Phase 99 (task 01, D1) — the ONE-LINE Description clamp: the
|
||
* owner's long stored descriptions no longer stretch the catalog
|
||
* rows. A row's Description cell is now ONE flex wrapper
|
||
* (`.kb-desc-cell` — inside the <td>, which gains no new class) holding
|
||
* the text span (`.kb-desc-text` — flex: 1 1 auto + min-width: 0 (the
|
||
* flex item may shrink), overflow: hidden + text-overflow: ellipsis +
|
||
* white-space: nowrap: the first line that fits, ellipsized — the row
|
||
* height is ONE line, independent of the description's length) and the
|
||
* ALWAYS-present Edit button (`.kb-summary-edit`, flex: 0 0 auto — it
|
||
* never wraps to a second line; its spacing comes from the wrapper's
|
||
* gap, its old margin-left is gone). The clamp is VISUAL only: the
|
||
* full text stays in the DOM (the accessible name is unchanged) and
|
||
* rides the span's title (the hover escape hatch, the house pattern) —
|
||
* set for real description text whenever non-empty; the phase-98
|
||
* marker keeps its OWN D4 title (never overridden), the empty cell
|
||
* carries none. The editor's open/close swaps happen INSIDE the
|
||
* wrapper (wireDescriptionEdit's row container IS the wrapper — the
|
||
* <td> always keeps it; the level block's container stays
|
||
* .kb-level-body), and closeEditor toggles ONLY the
|
||
* kb-summary-pending class (the base classes survive) while re-deriving
|
||
* the clamped span's title from the stored text (a stale pre-edit
|
||
* title cannot survive a save). The level block (`.kb-level p`) keeps
|
||
* the FULL unclamped description — the escape hatch at the top when
|
||
* you click in.
|
||
*
|
||
* Phase 99 (task 02, D2) — the browser back/forward walks the
|
||
* breadcrumb: the drill state IS the history state. Every user-
|
||
* initiated drill (source-row click, folder-link click, breadcrumb-
|
||
* segment click — all through goTo) goes through applyTarget(target,
|
||
* true): when the target DIFFERS from the current level, it FIRST
|
||
* pushes a STATE-ONLY entry — history.pushState({ view: "rag", kb:
|
||
* target }, "") — then sets current + re-renders. The push is the
|
||
* STATE only: the second arg is "" and there is no third — the URL
|
||
* STAYS PUT (no new route, the phase-76 deep-link surface is
|
||
* untouched; the shell's pathname is the only route contract). A
|
||
* re-click of the current level pushes NOTHING (no duplicate history
|
||
* entries). Back/forward: the window popstate (armed ONCE at mount,
|
||
* admin branch only — the anonymous gate installs no listeners) ADOPTS
|
||
* the popped entry's state.kb via the no-push variant (applyTarget(kb,
|
||
* false) — the browser owns its own entries, so adopt/reset never
|
||
* push); an entry WITHOUT a kb (the boot entry, the router's view
|
||
* entries, any foreign state) RESETS the drill to the TOP level
|
||
* (applyTarget({ source: null, folder: null }, false)). Back at the
|
||
* top level pops the router's view entry — the view leaves, exactly
|
||
* as today (the router's pathname-based switch, UNCHANGED). The
|
||
* re-show (bor:view-refresh) aligns the same way BEFORE the re-fetch:
|
||
* a kb-carrying top entry (the active-link re-click pushed NOTHING —
|
||
* the drilled entry is still on top) KEEPS the drill; a kb-less entry
|
||
* (a fresh nav visit) starts at the top level. Boot pushes NOTHING
|
||
* (the browser's own entry is the base — the phase-76 boot contract),
|
||
* and the never-stale resetVanishedLocation is UNCHANGED (it resets
|
||
* current WITHOUT pushing when a re-fetch finds the level gone — it
|
||
* follows whatever the history state says; no new entry). The router
|
||
* module is UNTOUCHED: its popstate reads the pathname only, and a
|
||
* state-only push never changes it (so rag-internal entries never
|
||
* trigger a view switch; for a foreign entry the router has already
|
||
* switched views, and this listener's render of the now-hidden view
|
||
* is harmless).
|
||
*
|
||
* Phase 106 (task 08, D8) — the date COLUMNS (display only — the
|
||
* admin date EDITOR is task 09):
|
||
*
|
||
* • the file table gains the Created column BEFORE Indexed (the
|
||
* owner's verbatim position, D8): makeRow builds the date cells
|
||
* explicitly (the plain-td loop can't carry per-cell titles) —
|
||
* textContent = the locale date (fmtDate, the Indexed idiom), and
|
||
* the Created cell's title = the FULL ISO value (hover precision —
|
||
* the path-cell idiom; the E2E asserts on the locale-stable
|
||
* title, not on the toLocaleString output). The row object fed
|
||
* from the tree's file nodes carries created_at (task 05's tree
|
||
* shape — the flat GET /api/docs shape has the field too).
|
||
* • the folder/source table gains the Updated column BETWEEN
|
||
* Documents and Description (the owner's verbatim position): the
|
||
* subtree's MAX document created_at — the tree API DERIVES it
|
||
* (D9, never stored); makeSourceRow / makeFolderRow render
|
||
* fmtDate(updated_at) with the ISO value on the cell's title and
|
||
* the "–" null idiom (the statLast shape) for a 0-document
|
||
* source.
|
||
* • the stat cards are UNTOUCHED — they keep their indexed_at
|
||
* "last indexed" semantics (the owner asked for the column, not
|
||
* the cards).
|
||
*
|
||
* Phase 122 (task 04) — the image-doc thumbnail: a file node of the
|
||
* tree carries the image affordance (is_image / image_url / summary —
|
||
* OMITTED on text nodes, the wire-additive rule) when it is an image
|
||
* document. makeRow's Path cell then renders a FIXED 48px thumbnail
|
||
* box (object-fit: cover, loading="lazy", alt = the summary — the
|
||
* vision description; a NULL summary falls back to the title) BEFORE
|
||
* the path link (the .kb-doc-path flex wrapper — the link keeps its
|
||
* ellipsis). Progressive enhancement: a failed fetch (or the lazy
|
||
* first paint) swaps in the document glyph INSIDE the same fixed box
|
||
* (no layout shift beyond the box, no broken-image placeholder). Text
|
||
* rows never get a box (the pre-phase bare-link cell, byte-identical).
|
||
* The glyph is static SVG (aria-hidden — the alt text is the
|
||
* accessible content); the box + img are properties only, never
|
||
* innerHTML with document-derived data (the house rule).
|
||
*/
|
||
|
||
import { fetchIsAdmin } from "./header.js";
|
||
import { openDocumentModal } from "./document-modal.js"; // phase 26: row links open the same-page modal
|
||
|
||
/* 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);
|
||
}
|
||
|
||
/* ---------- folder-description editing (phase 97, task 05) ----------
|
||
* The phase-57 edit affordance, mirrored for the RAG view's folder
|
||
* descriptions (the owner edits/clears any directory's stored
|
||
* description exactly like a file summary): Edit → inline textarea
|
||
* (prefilled via .value — the XSS contract) → Save / Cancel → a
|
||
* role=status live-region status, wired to PATCH /api/folders/summary.
|
||
* Every part is static createElement; the description text is a text
|
||
* node (textContent / .value) — this module never builds HTML from
|
||
* document-derived data (the house rule). */
|
||
function mkBtn(cls, label) {
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = cls;
|
||
b.textContent = label;
|
||
return b;
|
||
}
|
||
|
||
/* The shared description editor. `container` holds ONLY the
|
||
* description UI (the row cell's .kb-desc-cell wrapper — Phase 99
|
||
* (task 01) — or the level block's .kb-level-body)
|
||
* — the swap is a bare replaceChildren on it, so any persistent
|
||
* sibling (the level's <h2>) is untouched. `getTarget()` returns the
|
||
* current { node, source, folder } at open/save time: a row's is a
|
||
* constant (the row's node), the level's reads `current` +
|
||
* currentLevelNode() (the block is reused across levels). `node` is
|
||
* the in-memory kbTree node: its .summary is read on open (prefill)
|
||
* and updated IN PLACE on success (no re-fetch — the tree state stays
|
||
* coherent; the re-fetch is the safety net). `folder` is "" for the
|
||
* source root, the source-relative folder path otherwise. `onCleared`
|
||
* (optional) runs after a successful clear — the level block hides
|
||
* itself (the ls rule); a row cell just goes empty (the always-present
|
||
* button stays). Returns a handle whose reset() tears down an OPEN
|
||
* editor before a re-render (PLAN §7.4 — never a stale open editor). */
|
||
function wireDescriptionEdit({
|
||
editBtn,
|
||
textEl,
|
||
container,
|
||
getTarget,
|
||
onCleared,
|
||
pendingText,
|
||
pendingTitle,
|
||
}) {
|
||
const actions = document.createElement("div");
|
||
actions.className = "kb-summary-actions";
|
||
const saveBtn = mkBtn("kb-summary-save", "Save");
|
||
const cancelBtn = mkBtn("kb-summary-cancel", "Cancel");
|
||
actions.append(saveBtn, cancelBtn);
|
||
const status = document.createElement("p");
|
||
status.className = "kb-summary-status";
|
||
status.setAttribute("role", "status");
|
||
status.setAttribute("aria-live", "polite");
|
||
let editor = null;
|
||
let isOpen = false;
|
||
|
||
/* Back to the display state: the display state re-rendered from the
|
||
* node with the SAME three-state rule the surfaces use (makeDescCell
|
||
* / renderLevel — textContent/class/title only, the house rule):
|
||
* a stored summary → the stored text with NO marker (the in-place
|
||
* clear — the success path cleared node.summary_pending, so the
|
||
* muted pending style + the stale "next sync" tooltip cannot survive
|
||
* a saved description); no stored text but PENDING → the surface's
|
||
* pending display (pendingText — the row's "Summary pending" marker
|
||
* + its D4 tooltip, the level's D4 note — a cancel restores exactly
|
||
* the pre-edit state, including the marker); neither → the empty
|
||
* text. The Edit button is available again. A cleared node empties
|
||
* the text and, for the level block, hides the whole block via
|
||
* onCleared.
|
||
* Phase 99 (task 01): the class line toggles ONLY kb-summary-pending
|
||
* (the base classes survive — the row's span keeps .kb-desc-text, the
|
||
* clamp; the level's <p> keeps none), and the CLAMPED row span's
|
||
* title is re-derived from the stored text (the D1 hover escape hatch
|
||
* — a stale pre-edit title cannot survive a save; the level's <p> is
|
||
* unclamped — it shows the full text and keeps its no-title
|
||
* behavior). */
|
||
function closeEditor(message) {
|
||
const target = getTarget();
|
||
const node = target ? target.node : null;
|
||
const stored = node && typeof node.summary === "string" ? node.summary : "";
|
||
const pending = node !== null && stored === "" && node.summary_pending;
|
||
const value = pending && pendingText ? pendingText : stored;
|
||
textEl.classList.toggle("kb-summary-pending", pending);
|
||
if (pending) {
|
||
if (pendingTitle) textEl.title = pendingTitle;
|
||
} else if (stored && textEl.classList.contains("kb-desc-text")) {
|
||
textEl.title = stored; // D1: full text on hover — the clamp is visual only
|
||
} else {
|
||
textEl.removeAttribute("title");
|
||
}
|
||
textEl.textContent = value; // text node — the CURRENT display state
|
||
editBtn.hidden = false;
|
||
status.textContent = message;
|
||
container.replaceChildren(textEl, editBtn, status);
|
||
isOpen = false;
|
||
if (value.trim() === "" && onCleared) onCleared(); // level: hide the block
|
||
else editBtn.focus(); // return focus to the opener
|
||
}
|
||
|
||
function openEditor() {
|
||
if (isOpen) return;
|
||
const target = getTarget();
|
||
if (!target) return;
|
||
editor = document.createElement("textarea");
|
||
editor.className = "kb-summary-editor";
|
||
editor.value = typeof target.node.summary === "string" ? target.node.summary : ""; // .value, never innerHTML
|
||
status.textContent = "";
|
||
editBtn.hidden = true;
|
||
container.replaceChildren(editor, actions, status);
|
||
editor.focus();
|
||
isOpen = true;
|
||
}
|
||
|
||
async function saveDescription() {
|
||
const target = getTarget();
|
||
if (!target) return;
|
||
const { node, source, folder } = target;
|
||
const value = editor.value;
|
||
saveBtn.disabled = true; // one PATCH at a time (never stale)
|
||
status.textContent = "";
|
||
try {
|
||
const res = await fetch("/api/folders/summary", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ source, folder_path: folder, summary: value }),
|
||
});
|
||
if (!res.ok) {
|
||
// Neutral retry copy (phase-55) — the editor stays OPEN with the
|
||
// user's text (no swap back, the stored text is untouched).
|
||
status.textContent = "Couldn't save the description — try again.";
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
node.summary = data.summary; // in-place kbTree update (no re-fetch)
|
||
node.summary_pending = false; // phase 98 (D4): a created/updated description is no longer pending — the marker clears in place (no re-fetch)
|
||
closeEditor(data.summary === null ? "Description cleared." : "Description updated.");
|
||
} catch {
|
||
// Network failure: the reachable? copy; the editor stays open.
|
||
status.textContent = "Couldn't save the description — is the app reachable?";
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
editBtn.addEventListener("click", openEditor);
|
||
saveBtn.addEventListener("click", () => {
|
||
void saveDescription();
|
||
});
|
||
cancelBtn.addEventListener("click", () => closeEditor(""));
|
||
|
||
return {
|
||
/* Tear down an OPEN editor with NO message and WITHOUT re-rendering
|
||
* the text from the (possibly stale) node — the surface is about to
|
||
* re-render for a DIFFERENT level (navigation / refresh): a stale
|
||
* open editor must never survive a re-render (PLAN §7.4). A closed
|
||
* editor is a no-op. */
|
||
reset() {
|
||
if (!isOpen) return;
|
||
editor = null;
|
||
saveBtn.disabled = false;
|
||
status.textContent = "";
|
||
editBtn.hidden = false;
|
||
container.replaceChildren(textEl, editBtn);
|
||
isOpen = false;
|
||
},
|
||
};
|
||
}
|
||
|
||
/* A source/folder row's Description cell (task 05): the stored
|
||
* description text (a text node, textContent only) + the ALWAYS-present
|
||
* Edit button (`.kb-summary-edit`) — a description can be CREATED where
|
||
* none is stored (a < 2-document folder, the generator's fail-soft
|
||
* miss), so the button is added unconditionally (the view is
|
||
* admin-only already — the endpoint's require_admin is the API gate).
|
||
* The shared editor is wired with a CONSTANT target (the row's node).
|
||
* `label` is the human name for the button's aria-label.
|
||
* Phase 98 (task 04, D4): the cell has THREE states — a stored summary
|
||
* (the stored text, textContent only), PENDING (no stored summary but
|
||
* node.summary_pending — the marker: the kb-summary-pending class +
|
||
* the "Summary pending" text + the D4 title; text + color, never color
|
||
* alone), and the empty cell (neither — the ls rule, unchanged). The
|
||
* Edit button is UNCHANGED in all three (a manual save creates the row
|
||
* and clears the marker in place — the editor's success path).
|
||
* Phase 99 (task 01, D1): the cell is ONE flex row — the <td> holds a
|
||
* single `div.kb-desc-cell` wrapper with the text span (`.kb-desc-text`
|
||
* — flexes + ellipsizes, so the row height is ONE line, independent of
|
||
* the description's length) and the Edit button (`.kb-summary-edit` —
|
||
* flex: 0 0 auto, it never wraps). The clamp is VISUAL only: the full
|
||
* text stays in the DOM and rides the span's title (the hover escape
|
||
* hatch, set for real description text whenever non-empty — the marker
|
||
* keeps its OWN D4 title, the empty cell carries none). The editor's
|
||
* open/close swaps happen INSIDE the wrapper (the container is the
|
||
* wrapper — the <td> always keeps it). */
|
||
function makeDescCell(node, source, folder, label) {
|
||
const td = document.createElement("td");
|
||
/* Phase 99 (task 01, D1): the ONE flex wrapper — the text flexes +
|
||
* ellipsizes, the button stays fixed (see the docstring above). */
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "kb-desc-cell";
|
||
const text = document.createElement("span");
|
||
text.className = "kb-desc-text";
|
||
if (node && node.summary) {
|
||
text.textContent = node.summary; // stored description — text node, never innerHTML
|
||
text.title = node.summary; // full text on hover — the clamp is visual only (D1)
|
||
} else if (node && node.summary_pending) {
|
||
// Phase 98 (D4): the summary is due but not stored yet — the marker
|
||
// keeps its OWN title (the hover rule never overrides it).
|
||
text.classList.add("kb-summary-pending");
|
||
text.textContent = "Summary pending";
|
||
text.title = "No stored description yet — the next sync will generate one.";
|
||
} else {
|
||
text.textContent = ""; // neither stored nor pending — the empty cell (the ls rule, no title)
|
||
}
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "kb-summary-edit";
|
||
btn.textContent = "Edit";
|
||
btn.setAttribute("aria-label", `Edit description: ${label}`);
|
||
wrap.append(text, btn);
|
||
td.append(wrap);
|
||
wireDescriptionEdit({
|
||
editBtn: btn,
|
||
textEl: text,
|
||
container: wrap, // Phase 99 (task 01): the swaps fill the WRAPPER (the <td> keeps it)
|
||
getTarget: () => ({ node, source, folder }),
|
||
// Phase 98 (D4): the row's pending display (closeEditor restores
|
||
// it on a cancel; a save clears the marker in place).
|
||
pendingText: "Summary pending",
|
||
pendingTitle: "No stored description yet — the next sync will generate one.",
|
||
});
|
||
return td;
|
||
}
|
||
|
||
export async function mount(root) {
|
||
/* ---------- 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 SHIPS hidden and the view boot below reveals
|
||
* it for the admin on the SAME cached whoami fetchIsAdmin() reads
|
||
* (no extra fetch); the 403 branches stay as defense in depth. A failed run
|
||
* opens an error modal (same as the former header.js module — recreated
|
||
* here since the navbar button is gone).
|
||
*
|
||
* Phase 64 (task 04): the button reports the FILE being processed, not
|
||
* just "Syncing…" — TWO jobs drive it. Each poll tick fetches BOTH
|
||
* status endpoints — GET /api/sync/status +
|
||
* GET /api/git-sources/upload/status — and applies this decision
|
||
* tree, in order (startSyncPolling):
|
||
* 1. sync running → the phase-aware label (Phase 98 task 02, D2)
|
||
* — the null-phase prelude + "import" →
|
||
* "Syncing… <file> (n/m)" — bare "Syncing…"
|
||
* until the import's first file (clone/pull,
|
||
* A4, byte-identical); "overview" → "Writing KB
|
||
* overview…"; "summaries" → "Summarizing
|
||
* folders… <source/folder> (n/m)" (the folder
|
||
* part omitted while current_summary is null —
|
||
* the phase's first poll) — the post-import span
|
||
* where the file count sits still;
|
||
* 2. upload running → BARE "Importing…" — the background upload
|
||
* RUN (phase 90: unpack + register only, no
|
||
* scan — its status never carries a file or
|
||
* counts; the "clicked upload, then opened
|
||
* sources" contract, A3);
|
||
* 3. sync success → the phase-32 settle (counts + catalog refresh);
|
||
* 4. sync failed → the phase-32 failure (banner + modal);
|
||
* 5. upload success → settle "Sync sources" + catalog refresh
|
||
* (loadTree — phase 90: an upload no longer
|
||
* changes the KB, the re-read is a no-op safety
|
||
* net); the upload's result line lives on the
|
||
* Sources page, never in #sync-result (A3);
|
||
* 6. upload failed → settle "Sync sources" — the failure is the
|
||
* Sources page's error banner, never this page's (A3);
|
||
* 7. both idle → retry-ready idle.
|
||
* The live label is the status endpoint's full source/relative/path
|
||
* (A4) — or, in the post-import phases, the phase's own copy (Phase
|
||
* 98 task 02): CSS ellipsizes #sync-label; the full untruncated
|
||
* label also rides the button title (hover — set for EVERY running
|
||
* label, not just file labels) and #sync-result (the aria-live
|
||
* announcer — screen readers hear it). The load-time re-attach
|
||
* (initSyncButton) re-enters a RUNNING sync the same way — with
|
||
* whatever phase the status reports (a mid-summaries reload shows
|
||
* the summaries label — the never-stale contract) — and a RUNNING
|
||
* upload the same way; a terminal upload is a no-op there (the
|
||
* boot-time loadTree() already shows the current catalog).
|
||
*
|
||
* 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 = root.querySelector("#sync-btn");
|
||
const syncLabel = syncBtn ? syncBtn.querySelector(".sync-label") : null;
|
||
const syncIcon = syncBtn ? syncBtn.querySelector(".sync-icon") : null;
|
||
const syncResult = root.querySelector("#sync-result");
|
||
const syncErrorBanner = root.querySelector("#sync-error-banner");
|
||
const syncErrorText = root.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())}`;
|
||
}
|
||
|
||
/* Phase 64 (task 04): the live-file label. `kind` picks the prefix —
|
||
* "sync" → "Syncing…", "upload" → "Importing" (the background run's
|
||
* word, A3). The current file — the status endpoint's full
|
||
* source/relative/path (A4) — is appended while one is being processed;
|
||
* the BARE prefix shows during the clone/pull (sync), before any file
|
||
* is indexed. Phase 90: the upload run is unpack + register only (no
|
||
* scan), so its status never carries a file or counts — the
|
||
* "Importing" label is always the bare one. The counts appear only
|
||
* once the import has started (total > 0). CSS ellipsizes the button
|
||
* label; the same untruncated text goes to the button title +
|
||
* #sync-result (the aria-live announcer). Phase 98 (task 02): the
|
||
* SYNC job's post-import phases (overview / summaries) take their
|
||
* own label via fmtSyncPhaseLabel (below) — this builder keeps that
|
||
* prelude/import fall-through byte-identical and serves the UPLOAD
|
||
* job unchanged (its status has no phase — D2). */
|
||
function fmtSyncLabel(kind, currentFile, done, total) {
|
||
const prefix = kind === "upload" ? "Importing" : "Syncing…";
|
||
let label = currentFile ? `${prefix} ${currentFile}` : prefix;
|
||
if (total > 0) label += ` (${done}/${total})`;
|
||
return label;
|
||
}
|
||
|
||
/* Phase 98 (task 02, D2): the SYNC job's phase-aware running label
|
||
* — the post-import span where the file count sits still (the
|
||
* owner's "the number pauses for a really long time"): "overview"
|
||
* → "Writing KB overview…"; "summaries" → "Summarizing folders…
|
||
* <source/folder> (n/m)" — the folder part is omitted while
|
||
* current_summary is null (the phase's first poll); anything else
|
||
* (the null prelude, "import") → the phase-64 fmtSyncLabel
|
||
* fall-through, byte-identical (same builder, same fields, same
|
||
* order). The UPLOAD job never passes a status (its endpoint has no
|
||
* phase — D2) and keeps the bare fmtSyncLabel label. The untruncated
|
||
* result rides the button title + #sync-result like every running
|
||
* label (A4). */
|
||
function fmtSyncPhaseLabel(status) {
|
||
if (status.phase === "overview") return "Writing KB overview…";
|
||
if (status.phase === "summaries") {
|
||
const folder = status.current_summary ? ` ${status.current_summary}` : "";
|
||
return `Summarizing folders…${folder} (${status.summaries_done}/${status.summaries_total})`;
|
||
}
|
||
// null phase (the prelude) or "import" — today's byte-identical
|
||
// sync label (the phase-64 contract, unchanged).
|
||
return fmtSyncLabel("sync", status.current_file, status.files_done, status.files_total);
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
/* The single running-state entry point (phase 64 task 04: the label
|
||
* carries the live file — `kind` "sync" | "upload", the status
|
||
* endpoint's current_file + done/total). Phase 98 (task 02, D2): the
|
||
* SYNC job passes the status object as `status` — its running label
|
||
* is phase-aware (fmtSyncPhaseLabel: "Writing KB overview…" /
|
||
* "Summarizing folders… <folder> (n/m)"); the UPLOAD job and the
|
||
* 202/409 click (no status yet) keep the bare fmtSyncLabel label.
|
||
* Same mechanics as before (disabled, aria-busy, spinning icon, no
|
||
* is-error) plus: the FULL untruncated label on the button title
|
||
* (set for EVERY running label — the phase-64 file-only rule is
|
||
* adjusted for the phase labels) and in #sync-result (the aria-live
|
||
* announcer reads it; CSS ellipsizes the button's label span only).
|
||
*/
|
||
function enterSyncRunningState(kind, currentFile, done, total, status) {
|
||
if (!syncBtn) return;
|
||
syncBtn.disabled = true;
|
||
syncBtn.setAttribute("aria-busy", "true");
|
||
const label =
|
||
kind === "sync" && status
|
||
? fmtSyncPhaseLabel(status)
|
||
: fmtSyncLabel(kind, currentFile, done, total);
|
||
syncBtn.title = label; // A4: the untruncated label rides the title, always
|
||
syncBtn.setAttribute("aria-label", "Sync sources");
|
||
syncBtn.classList.remove("is-error");
|
||
if (syncIcon) syncIcon.classList.add("is-spinning");
|
||
if (syncLabel) syncLabel.textContent = label;
|
||
if (syncResult) syncResult.textContent = label;
|
||
}
|
||
|
||
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.detail);
|
||
hideSyncError();
|
||
emitSyncStatus(status);
|
||
// Refresh the catalog live — the KB just changed.
|
||
loadTree();
|
||
}
|
||
|
||
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" });
|
||
}
|
||
|
||
/* The 2 s poll (phase 64 task 04): each tick fetches BOTH jobs — the
|
||
* sync AND the background upload run (phase 90: unpack + register,
|
||
* no scan) — and applies the two-job decision tree in order (see the
|
||
* section header). The 403 on the SYNC fetch hides the button (the
|
||
* whoami backstop); a 403 on the UPLOAD fetch is simply "no upload"
|
||
* (never a hide), and a network blip on either fetch retries next
|
||
* tick. */
|
||
function startSyncPolling() {
|
||
if (syncPollTimer !== null) return;
|
||
const tick = async () => {
|
||
let syncStatus = null;
|
||
let uploadStatus = null;
|
||
let notAdmin = false;
|
||
try {
|
||
const r = await fetch("/api/sync/status");
|
||
if (r.status === 403) notAdmin = true;
|
||
else if (r.ok) syncStatus = await r.json();
|
||
} catch { /* network blip — retry next tick */ }
|
||
if (notAdmin) {
|
||
stopSyncPolling();
|
||
if (syncBtn) syncBtn.hidden = true;
|
||
applySyncIdle();
|
||
return;
|
||
}
|
||
// The SECOND job: the background upload run (phase 90: unpack +
|
||
// register only — no scan; admin-only surface).
|
||
try {
|
||
const ur = await fetch("/api/git-sources/upload/status");
|
||
if (ur.ok) uploadStatus = await ur.json();
|
||
} catch { /* network blip — retry next tick */ }
|
||
if (!syncStatus) {
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
return;
|
||
}
|
||
// 1. sync running: the phase-aware label (phase 98 task 02 — the
|
||
// null prelude + "import" keep the byte-identical file label,
|
||
// "overview" / "summaries" name themselves; the WHOLE status
|
||
// goes in for the phase fields).
|
||
if (syncStatus.state === "running") {
|
||
enterSyncRunningState(
|
||
"sync", syncStatus.current_file, syncStatus.files_done, syncStatus.files_total,
|
||
syncStatus
|
||
);
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
return;
|
||
}
|
||
// 2. upload running: the same animation, the upload's file (A3).
|
||
if (uploadStatus && uploadStatus.state === "running") {
|
||
enterSyncRunningState(
|
||
"upload", uploadStatus.current_file, uploadStatus.files_done, uploadStatus.files_total
|
||
);
|
||
syncPollTimer = setTimeout(tick, SYNC_POLL_MS);
|
||
return;
|
||
}
|
||
if (syncStatus.state === "success") {
|
||
stopSyncPolling();
|
||
applySyncSuccess(syncStatus);
|
||
return;
|
||
}
|
||
if (syncStatus.state === "failed") {
|
||
stopSyncPolling();
|
||
applySyncFailure(syncStatus);
|
||
return;
|
||
}
|
||
// 5. upload success: settle + catalog refresh (A3 — the upload's
|
||
// counts live on the Sources page; #sync-result stays empty).
|
||
if (uploadStatus && uploadStatus.state === "success") {
|
||
stopSyncPolling();
|
||
settleSyncButton("Sync sources");
|
||
if (syncResult) syncResult.textContent = "";
|
||
hideSyncError();
|
||
emitSyncStatus({ state: "idle" });
|
||
loadTree();
|
||
return;
|
||
}
|
||
// 6. upload failed: settle only — the failure is the Sources page's
|
||
// error banner, never this page's (A3).
|
||
if (uploadStatus && uploadStatus.state === "failed") {
|
||
stopSyncPolling();
|
||
settleSyncButton("Sync sources");
|
||
if (syncResult) syncResult.textContent = "";
|
||
hideSyncError();
|
||
emitSyncStatus({ state: "idle" });
|
||
return;
|
||
}
|
||
// 7. both idle: settle retry-ready.
|
||
stopSyncPolling();
|
||
applySyncIdle(syncStatus);
|
||
};
|
||
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) {
|
||
// Phase 64: the run is just starting (model check / clone-pull) —
|
||
// bare "Syncing…" until the first polled file (A4); entering the
|
||
// running state also clears #sync-result with the same label.
|
||
enterSyncRunningState("sync", null, 0, 0);
|
||
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. Phase 64 (A3): with
|
||
* the sync IDLE, an in-flight background upload RUN (phase 90: unpack
|
||
* + register — the bare "Importing…" label) adopts the button the
|
||
* same way — the "user clicked upload, then opened sources" case; a
|
||
* terminal upload is a no-op (the boot-time loadTree() already shows
|
||
* the current catalog). Phase 98 (task 02): a RUNNING sync re-enters
|
||
* with the WHOLE status — the label is phase-aware, so a mid-
|
||
* summaries reload re-enters with the summaries label (the
|
||
* never-stale contract); a RUNNING upload re-attach stays on the
|
||
* bare label (its status has no phase — D2). */
|
||
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(
|
||
"sync", status.current_file, status.files_done, status.files_total,
|
||
status
|
||
);
|
||
emitSyncStatus(status);
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
if (status.state === "success") {
|
||
applySyncSuccess(status);
|
||
return;
|
||
}
|
||
if (status.state === "failed") {
|
||
applySyncFailure(status);
|
||
return;
|
||
}
|
||
// Sync idle: check the SECOND job — an in-flight upload run
|
||
// re-attaches (the bare "Importing…" label — phase 90: unpack +
|
||
// register only).
|
||
let upload;
|
||
try {
|
||
const ur = await fetch("/api/git-sources/upload/status");
|
||
if (ur.ok) upload = await ur.json();
|
||
} catch { /* network blip — the idle settle below is still honest */ }
|
||
if (upload && upload.state === "running") {
|
||
enterSyncRunningState(
|
||
"upload", upload.current_file, upload.files_done, upload.files_total
|
||
);
|
||
emitSyncStatus({ state: "running" });
|
||
startSyncPolling();
|
||
return;
|
||
}
|
||
applySyncIdle(status);
|
||
}
|
||
|
||
if (syncBtn) {
|
||
syncBtn.addEventListener("click", startSync);
|
||
initSyncButton();
|
||
}
|
||
|
||
const tbody = root.querySelector("#docs-tbody");
|
||
const emptyEl = root.querySelector("#sources-empty");
|
||
/* Phase 97 (task 04): the new #folders-wrap ALSO carries the shared
|
||
* .table-wrap card class — the FILE table's wrap is therefore looked
|
||
* up through its own table, not the class (a class lookup would hit
|
||
* #folders-wrap first in document order). */
|
||
const tableWrap = root.querySelector("#docs-table").parentElement;
|
||
const statCards = root.querySelector("#stat-cards");
|
||
const gateEl = root.querySelector("#sources-gate");
|
||
const statDocs = root.querySelector("#stat-docs");
|
||
const statChunks = root.querySelector("#stat-chunks");
|
||
const statLast = root.querySelector("#stat-last");
|
||
const crumbEl = root.querySelector("#kb-crumb");
|
||
const levelEl = root.querySelector("#kb-level");
|
||
const levelTitleEl = root.querySelector("#kb-level-title");
|
||
const levelSummaryEl = root.querySelector("#kb-level-summary");
|
||
const foldersWrap = root.querySelector("#folders-wrap");
|
||
const foldersTbody = root.querySelector("#folders-tbody");
|
||
/* Phase 97 (task 05): the level block's static Edit button + the
|
||
* .kb-level-body that holds the description UI (the <p> + the
|
||
* button) — the shared editor swaps INSIDE the body, leaving the
|
||
* block's <h2> untouched. */
|
||
const levelEditBtn = root.querySelector("#kb-level-edit");
|
||
const levelBody = levelEl ? levelEl.querySelector(".kb-level-body") : null;
|
||
|
||
/* 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 76 (task 02): the whoami request is the shared header module's
|
||
* cached promise — fetchIsAdmin(), the same single request the shell's
|
||
* header boots (zero extra requests). */
|
||
function fmtDate(iso) {
|
||
try {
|
||
return new Date(iso).toLocaleString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
|
||
/* Phase 97 (task 04): the drill-down tree (the module docstring
|
||
carries the full contract). `kbTree` = the last fetched tree;
|
||
`current` = the drill state ({ null, null } = top, `folder: ""`
|
||
= the source root). Phase 79 carries over: the monotonic loadSeq
|
||
race token invalidates an in-flight load the moment a newer one
|
||
starts — only the newest load may touch the DOM after its await
|
||
(the boot re-attach (applySyncSuccess → loadTree) and the boot-time
|
||
loadTree interleave exactly as the flat load once did). Re-entrancy
|
||
moves from the fetch top into the render: renderLevel() clears
|
||
BOTH row containers BEFORE filling them (the phase-77 History
|
||
pattern, extended to the second container), so a refresh from a
|
||
populated level into a sparser one leaves no ghost rows. */
|
||
let kbTree = { sources: [] };
|
||
let current = { source: null, folder: null };
|
||
let loadSeq = 0;
|
||
let levelEditor = null; // phase 97 (task 05): the level editor handle
|
||
|
||
async function loadTree() {
|
||
const my = ++loadSeq;
|
||
let r;
|
||
try {
|
||
r = await fetch("/api/docs/tree");
|
||
} catch {
|
||
if (my === loadSeq) renderEmpty();
|
||
return;
|
||
}
|
||
if (!r.ok) {
|
||
if (my === loadSeq) renderEmpty();
|
||
return;
|
||
}
|
||
const tree = await r.json();
|
||
if (my !== loadSeq) return; // a newer load owns the DOM now
|
||
kbTree = tree && Array.isArray(tree.sources) ? tree : { sources: [] };
|
||
resetVanishedLocation();
|
||
renderLevel();
|
||
}
|
||
|
||
/* Never-stale (PLAN §7.4, phase 97): after a re-fetch, the drilled
|
||
* location may no longer exist in the NEW tree (the source was
|
||
* unregistered/pruned, the folder vanished) — reset to the top level
|
||
* BEFORE rendering: no stale breadcrumb, no stale block. */
|
||
function resetVanishedLocation() {
|
||
if (current.source === null) return;
|
||
const src = kbTree.sources.find((s) => s.name === current.source);
|
||
if (!src) {
|
||
current = { source: null, folder: null };
|
||
return;
|
||
}
|
||
if (current.folder !== null && !folderExistsIn(src, current.folder)) {
|
||
current = { source: null, folder: null };
|
||
}
|
||
}
|
||
|
||
/* The phase-94 existence rule, mirrored client-side over the tree's
|
||
* file paths: a folder exists under the source iff some of the
|
||
* source's indexed paths starts with `folder + "/"` (the source root
|
||
* — `""` — always exists). */
|
||
function folderExistsIn(src, folderPath) {
|
||
if (folderPath === "") return true;
|
||
const paths = [];
|
||
const collect = (node) => {
|
||
for (const child of node.children || []) {
|
||
if (child.kind === "file") paths.push(child.path);
|
||
else collect(child);
|
||
}
|
||
};
|
||
collect(src);
|
||
return paths.some((p) => p.startsWith(folderPath + "/"));
|
||
}
|
||
|
||
/* The node for `current`: the source node at the root, or the folder
|
||
* node found by walking the tree with the folder's cumulative
|
||
* source-relative path (the builder's folder paths are
|
||
* source-relative, so `one/two` resolves one level at a time). */
|
||
function currentLevelNode() {
|
||
const src = kbTree.sources.find((s) => s.name === current.source);
|
||
if (!src) return null;
|
||
if (current.folder === null || current.folder === "") return src;
|
||
let node = src;
|
||
let acc = "";
|
||
for (const part of current.folder.split("/")) {
|
||
acc = acc ? acc + "/" + part : part;
|
||
const next = (node.children || []).find(
|
||
(c) => c.kind === "folder" && c.path === acc
|
||
);
|
||
if (!next) return null;
|
||
node = next;
|
||
}
|
||
return node;
|
||
}
|
||
|
||
/* The KB-wide stat cards (phase 97): walk the WHOLE tree — document
|
||
* count, chunks sum, max indexed_at — the values identical to the
|
||
* former flat /api/docs walk (the same documents, one level deeper).
|
||
* fmtDate reuse for the max. */
|
||
function treeStats() {
|
||
let docs = 0;
|
||
let totalChunks = 0;
|
||
let last = "";
|
||
const walk = (node) => {
|
||
for (const child of node.children || []) {
|
||
if (child.kind === "file") {
|
||
docs += 1;
|
||
totalChunks += child.chunks;
|
||
if (child.indexed_at > last) last = child.indexed_at;
|
||
} else {
|
||
walk(child);
|
||
}
|
||
}
|
||
};
|
||
for (const s of kbTree.sources) walk(s);
|
||
return { docs, totalChunks, last };
|
||
}
|
||
|
||
/* The breadcrumb (phase 97): hidden at the top level; when drilled
|
||
* in, one link per ancestor — the top level (back to the sources
|
||
* list), the source, then the folder chain — the LAST segment a
|
||
* span with aria-current="page". Client-side only: no fetch, no URL
|
||
* change (the navigation is a re-render of the fetched tree). */
|
||
function renderCrumb() {
|
||
if (!crumbEl) return;
|
||
crumbEl.replaceChildren();
|
||
if (current.source === null) {
|
||
crumbEl.hidden = true;
|
||
return;
|
||
}
|
||
crumbEl.hidden = false;
|
||
let appended = false;
|
||
const append = (el) => {
|
||
if (appended) {
|
||
const sep = document.createElement("span");
|
||
sep.className = "kb-crumb-sep";
|
||
sep.setAttribute("aria-hidden", "true");
|
||
sep.textContent = "/";
|
||
crumbEl.appendChild(sep);
|
||
}
|
||
crumbEl.appendChild(el);
|
||
appended = true;
|
||
};
|
||
append(crumbSegment("Knowledge base", { source: null, folder: null }));
|
||
if (current.folder === null || current.folder === "") {
|
||
append(crumbCurrent(current.source));
|
||
} else {
|
||
append(crumbSegment(current.source, { source: current.source, folder: "" }));
|
||
let acc = "";
|
||
const parts = current.folder.split("/");
|
||
parts.forEach((part, i) => {
|
||
acc = acc ? acc + "/" + part : part;
|
||
if (i === parts.length - 1) append(crumbCurrent(part));
|
||
else append(crumbSegment(part, { source: current.source, folder: acc }));
|
||
});
|
||
}
|
||
}
|
||
|
||
function crumbSegment(label, target) {
|
||
const a = document.createElement("a");
|
||
a.className = "kb-crumb-link";
|
||
a.href = "#"; // client-side navigation only — no URL change
|
||
a.textContent = label; // document-derived text — never innerHTML
|
||
a.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
goTo(target);
|
||
});
|
||
return a;
|
||
}
|
||
|
||
function crumbCurrent(label) {
|
||
const span = document.createElement("span");
|
||
span.className = "kb-crumb-current";
|
||
span.setAttribute("aria-current", "page");
|
||
span.textContent = label; // document-derived text — never innerHTML
|
||
return span;
|
||
}
|
||
|
||
/* The drill navigation (phase 99, task 02, D2 — client-side, no
|
||
* fetch; the drill state IS the history state). `applyTarget(target,
|
||
* push)`: when `push` is true the target DIFFERS from the current
|
||
* level (a re-click of the current row/segment pushes NO duplicate
|
||
* entry), it FIRST records a STATE-ONLY entry — history.pushState(
|
||
* { view: "rag", kb: target }, "") — the second arg "" + no third:
|
||
* the URL stays the shell's pathname (no new route, the phase-76
|
||
* deep-link surface is untouched) — then sets `current` +
|
||
* re-renders. Back/forward (popstate) and the re-show alignment
|
||
* (bor:view-refresh) adopt with push=false: they never push (the
|
||
* browser owns those entries). */
|
||
function applyTarget(target, push) {
|
||
if (push && (target.source !== current.source || target.folder !== current.folder)) {
|
||
history.pushState({ view: "rag", kb: target }, "");
|
||
}
|
||
current = { source: target.source, folder: target.folder };
|
||
renderLevel();
|
||
}
|
||
|
||
/* Every user-initiated drill (source row, folder link, breadcrumb
|
||
* segment) — push a history entry, then drill (D2, phase 99 task
|
||
* 02). */
|
||
function goTo(target) {
|
||
applyTarget(target, true);
|
||
}
|
||
|
||
/* ONE table for every level (phase 97): at the TOP level the rows
|
||
* are the SOURCES themselves (the ls() equivalence — name, recursive
|
||
* count, the stored (source, "") description). textContent only. */
|
||
function makeSourceRow(s) {
|
||
const tr = document.createElement("tr");
|
||
const nameTd = document.createElement("td");
|
||
const link = document.createElement("a");
|
||
link.className = "folder-link";
|
||
link.href = "#"; // client-side drill — no URL change
|
||
link.title = s.name; // hover name (the cell may ellipsize)
|
||
link.textContent = s.name; // document-derived text — never innerHTML
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
goTo({ source: s.name, folder: "" });
|
||
});
|
||
nameTd.appendChild(link);
|
||
tr.appendChild(nameTd);
|
||
const countTd = document.createElement("td");
|
||
countTd.textContent = String(s.documents);
|
||
tr.appendChild(countTd);
|
||
// Phase 106 (task 08, D8/D9): the Updated cell — the source's
|
||
// subtree MAX document created_at (the tree API derives it),
|
||
// BETWEEN the count and the Description (the owner's verbatim
|
||
// position). A 0-document source has none → the statLast "–" null
|
||
// idiom; the ISO value rides the title (hover precision — the
|
||
// makeRow path-cell idiom).
|
||
const updatedTd = document.createElement("td");
|
||
updatedTd.textContent = s.updated_at ? fmtDate(s.updated_at) : "–";
|
||
if (s.updated_at) updatedTd.title = s.updated_at; // full ISO on hover
|
||
tr.appendChild(updatedTd);
|
||
tr.appendChild(makeDescCell(s, s.name, "", s.name)); // Description + ALWAYS-present Edit (task 05)
|
||
return tr;
|
||
}
|
||
|
||
/* A level's subfolder row: the folder's LAST path segment as the
|
||
* label (the full source-relative path rides the title — the cell
|
||
* ellipsizes), the recursive count, the stored description (AI or
|
||
* manual — any row) or an empty cell. textContent only. */
|
||
function makeFolderRow(f) {
|
||
const tr = document.createElement("tr");
|
||
const nameTd = document.createElement("td");
|
||
const link = document.createElement("a");
|
||
link.className = "folder-link";
|
||
link.href = "#"; // client-side drill — no URL change
|
||
link.title = current.source + "/" + f.path; // full path on hover
|
||
link.textContent = f.path.split("/").pop(); // never innerHTML
|
||
link.addEventListener("click", (e) => {
|
||
e.preventDefault();
|
||
goTo({ source: current.source, folder: f.path });
|
||
});
|
||
nameTd.appendChild(link);
|
||
tr.appendChild(nameTd);
|
||
const countTd = document.createElement("td");
|
||
countTd.textContent = String(f.documents);
|
||
tr.appendChild(countTd);
|
||
// Phase 106 (task 08, D8/D9): the Updated cell — the folder's
|
||
// subtree MAX document created_at (the tree API derives it),
|
||
// BETWEEN the count and the Description (the owner's verbatim
|
||
// position); the ISO value rides the title (hover precision — the
|
||
// makeRow path-cell idiom). The "–" branch is defensive: a folder
|
||
// node always has ≥1 document, so its max is never null (a
|
||
// 0-document source IS the null case, makeSourceRow).
|
||
const updatedTd = document.createElement("td");
|
||
updatedTd.textContent = f.updated_at ? fmtDate(f.updated_at) : "–";
|
||
if (f.updated_at) updatedTd.title = f.updated_at; // full ISO on hover
|
||
tr.appendChild(updatedTd);
|
||
tr.appendChild(
|
||
makeDescCell(f, current.source, f.path, current.source + "/" + f.path)
|
||
); // Description + ALWAYS-present Edit (task 05)
|
||
return tr;
|
||
}
|
||
|
||
/* Render the CURRENT level from `current` + `kbTree` (phase 97).
|
||
* Clears BOTH row containers FIRST (re-entrancy — no ghost rows),
|
||
* then: zero sources → the no-data state; top level → the source
|
||
* rows (level block + file table hidden); inside a source/folder →
|
||
* the breadcrumb, the level block (the level's stored description —
|
||
* hidden when none is stored, the ls rule), the direct subfolders,
|
||
* and the direct files (makeRow, unchanged — the file node carries
|
||
* no source, the row object restores the flat shape makeRow reads). */
|
||
function renderLevel() {
|
||
if (foldersTbody) foldersTbody.replaceChildren();
|
||
if (tbody) tbody.replaceChildren();
|
||
if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4)
|
||
|
||
if (!kbTree.sources.length) {
|
||
renderEmpty();
|
||
return;
|
||
}
|
||
emptyEl.hidden = true;
|
||
|
||
const st = treeStats();
|
||
statDocs.textContent = String(st.docs);
|
||
statChunks.textContent = String(st.totalChunks);
|
||
statLast.textContent = st.last ? fmtDate(st.last) : "–";
|
||
|
||
renderCrumb();
|
||
|
||
if (current.source === null) {
|
||
// Top level: the level block is hidden (nothing above the
|
||
// sources); the folders table lists the SOURCES themselves and
|
||
// the file table is ALWAYS hidden (files are seen per source,
|
||
// as with ls(source)).
|
||
levelEl.hidden = true;
|
||
for (const s of kbTree.sources) foldersTbody.appendChild(makeSourceRow(s));
|
||
foldersWrap.hidden = false;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
return;
|
||
}
|
||
|
||
const node = currentLevelNode();
|
||
if (node === null) {
|
||
// Defensive: the reset above guarantees the level exists in the
|
||
// tree this render reads — a null means the tree changed under
|
||
// us, in which case the top level is the honest view.
|
||
current = { source: null, folder: null };
|
||
renderLevel();
|
||
return;
|
||
}
|
||
|
||
// The level block: the current level's STORED description (source:
|
||
// the (source, "") row; folder: its row) — OR, since Phase 98
|
||
// (task 04, D4), the PENDING note when the level is summary_pending
|
||
// (the summary is due but not stored yet — the next sync's gap-fill
|
||
// will generate it, or the owner can write one now via the block's
|
||
// Edit button). Hidden only when NEITHER is stored nor pending
|
||
// (the ls rule: count only, no placeholder). Title = the full
|
||
// source-relative path (e.g. `alpha/two`).
|
||
if (node.summary || node.summary_pending) {
|
||
levelTitleEl.textContent = current.folder
|
||
? current.source + "/" + current.folder
|
||
: current.source;
|
||
levelSummaryEl.textContent = node.summary ||
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)";
|
||
// Phase 98 (D4): the pending text is the muted marker style —
|
||
// and the block is REUSED across levels, so a stored level must
|
||
// clear a previous pending level's class (the same state
|
||
// closeEditor manages on the editor's close).
|
||
levelSummaryEl.className = node.summary ? "" : "kb-summary-pending";
|
||
levelEl.hidden = false;
|
||
} else {
|
||
levelEl.hidden = true;
|
||
}
|
||
|
||
const children = node.children || [];
|
||
const subfolders = children.filter((c) => c.kind === "folder");
|
||
const files = children.filter((c) => c.kind === "file");
|
||
for (const f of subfolders) foldersTbody.appendChild(makeFolderRow(f));
|
||
foldersWrap.hidden = subfolders.length === 0;
|
||
for (const f of files) {
|
||
tbody.appendChild(
|
||
makeRow({
|
||
source: current.source,
|
||
path: f.path,
|
||
title: f.title,
|
||
chunks: f.chunks,
|
||
created_at: f.created_at, // phase 106 (task 08, D8): the tree's file date
|
||
indexed_at: f.indexed_at,
|
||
// Phase 122 (task 04): the image affordance — the tree's
|
||
// file node carries is_image / image_url / summary on an
|
||
// image doc (the omission rule: a TEXT node's wire shape
|
||
// carries none, so these stay undefined there and makeRow
|
||
// keeps the bare-link cell, byte-identical to pre-phase).
|
||
is_image: f.is_image,
|
||
image_url: f.image_url,
|
||
summary: f.summary,
|
||
})
|
||
);
|
||
}
|
||
if (tableWrap) tableWrap.hidden = files.length === 0;
|
||
}
|
||
|
||
/* Phase 122 (task 04): the document glyph — the fallback INSIDE the
|
||
* fixed thumbnail box (a failed fetch, or a node with no servable
|
||
* image_url). Static SVG, aria-hidden (the img's alt is the
|
||
* accessible content; this is decoration for the box). Built with
|
||
* createElementNS — the module keeps its ONE innerHTML (the static
|
||
* sync-modal skeleton, the test_kb_tree_ui pin). */
|
||
function docThumbGlyph() {
|
||
const span = document.createElement("span");
|
||
span.className = "kb-doc-thumb-glyph";
|
||
span.setAttribute("aria-hidden", "true");
|
||
const NS = "http://www.w3.org/2000/svg";
|
||
const svg = document.createElementNS(NS, "svg");
|
||
svg.setAttribute("viewBox", "0 0 48 48");
|
||
svg.setAttribute("fill", "none");
|
||
svg.setAttribute("stroke", "currentColor");
|
||
svg.setAttribute("stroke-width", "2.4");
|
||
svg.setAttribute("stroke-linecap", "round");
|
||
svg.setAttribute("stroke-linejoin", "round");
|
||
const sheet = document.createElementNS(NS, "path");
|
||
sheet.setAttribute(
|
||
"d",
|
||
"M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"
|
||
);
|
||
const fold = document.createElementNS(NS, "path");
|
||
fold.setAttribute("d", "M28 4v8h8");
|
||
svg.append(sheet, fold);
|
||
span.appendChild(svg);
|
||
return span;
|
||
}
|
||
|
||
/* Phase 122 (task 04): the image-doc thumbnail — the FIXED 48px box
|
||
* (object-fit: cover via CSS, loading="lazy", alt = the summary —
|
||
* the vision description; a NULL/blank summary falls back to the
|
||
* title, then the path). A failed fetch swaps in the document
|
||
* glyph in the SAME box (progressive enhancement — no layout shift
|
||
* beyond the fixed box, no broken-image placeholder). Called only
|
||
* for image rows (makeRow gates on d.is_image). */
|
||
function docThumb(d) {
|
||
const box = document.createElement("span");
|
||
box.className = "kb-doc-thumb";
|
||
const alt =
|
||
typeof d.summary === "string" && d.summary.trim() !== ""
|
||
? d.summary
|
||
: d.title || d.path;
|
||
if (d.image_url) {
|
||
const img = document.createElement("img");
|
||
img.className = "kb-doc-thumb-img";
|
||
img.loading = "lazy";
|
||
img.src = d.image_url;
|
||
img.alt = alt;
|
||
img.addEventListener("error", () => box.replaceChildren(docThumbGlyph()));
|
||
box.appendChild(img);
|
||
} else {
|
||
box.appendChild(docThumbGlyph());
|
||
}
|
||
return box;
|
||
}
|
||
|
||
/* The no-data state (phase 97): zero sources (nothing registered,
|
||
* nothing indexed) OR a failed tree fetch (the former showEmpty
|
||
* failure behavior, unchanged in kind) — every catalog surface
|
||
* hidden, the stat cards read zero. A registered 0-document source
|
||
* does NOT land here: it renders its `0 documents` row (the ls
|
||
* invariant — the deliberate semantic change, module docstring). */
|
||
function renderEmpty() {
|
||
if (levelEditor) levelEditor.reset(); // a re-render never keeps a stale open editor (§7.4)
|
||
statDocs.textContent = "0";
|
||
statChunks.textContent = "0";
|
||
statLast.textContent = "–";
|
||
if (crumbEl) crumbEl.hidden = true;
|
||
levelEl.hidden = true;
|
||
if (foldersTbody) foldersTbody.replaceChildren();
|
||
if (tbody) tbody.replaceChildren();
|
||
if (foldersWrap) foldersWrap.hidden = true;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
emptyEl.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;
|
||
// Phase 122 (task 04): an image row gets the FIXED 48px thumbnail
|
||
// box before the path link (the .kb-doc-path flex wrapper — the
|
||
// link keeps its ellipsis). Text rows keep the bare-link cell,
|
||
// byte-identical to pre-phase (no box at all).
|
||
if (d.is_image) {
|
||
const pathWrap = document.createElement("div");
|
||
pathWrap.className = "kb-doc-path";
|
||
pathWrap.append(docThumb(d), link);
|
||
pathTd.appendChild(pathWrap);
|
||
} else {
|
||
pathTd.appendChild(link);
|
||
}
|
||
tr.appendChild(pathTd);
|
||
|
||
// Phase 106 (task 08, D8): the cell order is [title, chunks,
|
||
// created, indexed] — the Created cell lands BEFORE Indexed (the
|
||
// owner's verbatim position). The date cells are built EXPLICITLY
|
||
// (the plain-td loop can't carry per-cell titles): textContent is
|
||
// the locale date (fmtDate — the Indexed idiom), and the Created
|
||
// cell carries the FULL ISO value as its title (hover precision —
|
||
// the path-cell idiom; the E2E asserts on the locale-stable title,
|
||
// not on the toLocaleString output).
|
||
for (const value of [d.title, String(d.chunks)]) {
|
||
const td = document.createElement("td");
|
||
td.textContent = value;
|
||
tr.appendChild(td);
|
||
}
|
||
const createdTd = document.createElement("td");
|
||
createdTd.textContent = fmtDate(d.created_at);
|
||
createdTd.title = d.created_at; // full ISO on hover (locale-stable)
|
||
tr.appendChild(createdTd);
|
||
const indexedTd = document.createElement("td");
|
||
indexedTd.textContent = fmtDate(d.indexed_at);
|
||
tr.appendChild(indexedTd);
|
||
return tr;
|
||
}
|
||
|
||
|
||
|
||
/* Phase 97 (task 05): wire the LEVEL block's editor (the static
|
||
* #kb-level-edit button). The level block is PERSISTENT — reused for
|
||
* whichever level is current — so the target is a GETTER (a row's is
|
||
* a constant): it reads `current` + currentLevelNode() at open/save
|
||
* time. onCleared hides the whole block (the ls rule: no description
|
||
* → no block). The handle's reset() runs on every re-render so a
|
||
* navigate-away / refresh never leaves a stale open editor (§7.4). */
|
||
if (levelEditBtn && levelBody) {
|
||
levelEditor = wireDescriptionEdit({
|
||
editBtn: levelEditBtn,
|
||
textEl: levelSummaryEl,
|
||
container: levelBody,
|
||
getTarget: () => {
|
||
const node = currentLevelNode();
|
||
return node ? { node, source: current.source, folder: current.folder } : null;
|
||
},
|
||
// Phase 98 (D4): the level's pending display (the D4 note — no
|
||
// tooltip on the level's <p>: D4's title is the row cell's).
|
||
pendingText:
|
||
"No description stored yet — the next sync will generate one. (You can write one yourself.)",
|
||
onCleared: () => {
|
||
// The phase-57 announcement beat: keep the block visible a short
|
||
// beat so the role=status "Description cleared." is still
|
||
// readable, THEN hide it (the ls rule: no description → no
|
||
// block) — GUARDED: if a newer level is current by then (a
|
||
// navigate-away first) or its node has a description again (a
|
||
// re-create within the beat), the hide is a no-op.
|
||
setTimeout(() => {
|
||
const n = currentLevelNode();
|
||
if (n && !n.summary) levelEl.hidden = true;
|
||
}, 2000);
|
||
},
|
||
});
|
||
}
|
||
|
||
/* ---------- view boot (phase 76 task 02) ----------
|
||
* The shared header is NOT booted here — in the shell it runs
|
||
* exactly once, via the chat module (app.js) at shell boot. The
|
||
* admin gate reads fetchIsAdmin() — the SAME cached whoami promise
|
||
* header.js exports (zero extra requests): the sync button joins
|
||
* the admin reveal on that one whoami (no extra fetch), and the
|
||
* anonymous branch gates the catalog in / out with NO
|
||
* /api/docs/tree request at all (the phase-16 soft rule — the
|
||
* tree IS the catalog; phase 97). */
|
||
const admin = await fetchIsAdmin();
|
||
if (syncBtn) syncBtn.hidden = !admin; // admin-only: ship-hidden, revealed on the same whoami
|
||
if (!admin) {
|
||
// Anonymous: gate in, catalog out, and no /api/docs/tree request
|
||
// at all — the tree surfaces ship hidden and never fill.
|
||
if (statCards) statCards.hidden = true;
|
||
if (foldersWrap) foldersWrap.hidden = true;
|
||
if (tableWrap) tableWrap.hidden = true;
|
||
if (emptyEl) emptyEl.hidden = true;
|
||
if (gateEl) gateEl.hidden = false;
|
||
return;
|
||
}
|
||
if (gateEl) gateEl.hidden = true;
|
||
/* Phase 77 (task 02) + phase 97 (task 04) + phase 99 (task 02,
|
||
D2): a user-initiated re-show of this already-mounted view
|
||
makes the router dispatch bor:view-refresh on the section —
|
||
FIRST align the drill state with the history state (a
|
||
kb-carrying entry KEEPS the drill — the active-link re-click
|
||
pushed nothing, the drilled entry is still on top; a kb-less
|
||
entry — a fresh nav visit — starts at the TOP level), THEN
|
||
re-load the catalog tree (loadTree is race-tokened: only the
|
||
newest load touches the DOM). Armed ONLY here, after the whoami
|
||
gate passed: anonymous shows the gate and must never fetch
|
||
/api/docs/tree (the phase-16 soft rule the story E2E pins). */
|
||
root.addEventListener("bor:view-refresh", () => {
|
||
const kb = history.state && history.state.kb;
|
||
if (kb) applyTarget(kb, false);
|
||
else applyTarget({ source: null, folder: null }, false);
|
||
loadTree();
|
||
});
|
||
/* Phase 99 (task 02, D2): the browser back/forward walks the
|
||
breadcrumb — a popstate ADOPTS the popped entry's kb via the
|
||
no-push variant (the browser owns its entries — adopt/reset
|
||
never push) or, for a kb-LESS entry (the boot entry, the
|
||
router's view entries, any foreign state), RESETS the drill to
|
||
the TOP level. The router's own popstate (registered earlier,
|
||
at shell boot) reads the pathname only: rag-internal entries
|
||
never change it, so its listener stays a no-op and THIS
|
||
listener's render is the visible one; for a foreign entry it
|
||
has already switched the visible view, so this render is a
|
||
harmless hidden one. Back at the top level pops the router's
|
||
view entry — the view leaves, exactly as today. Armed ONCE
|
||
here, admin branch only: the anonymous gate installs no
|
||
listeners. */
|
||
window.addEventListener("popstate", (event) => {
|
||
const kb = event.state && event.state.kb;
|
||
if (kb) applyTarget(kb, false);
|
||
else applyTarget({ source: null, folder: null }, false);
|
||
});
|
||
loadTree();
|
||
}
|