**Phase 181 — final verification pass (all tasks already complete; verified + docs pass)**
- Verified: `app/rag/svg_text.py` (100 % cov), vector branch in `_index_image_file`, `_row_kind_mismatch` text→image migration, `IMAGE_MIMES[".svg"]`, sandboxed CSP on `GET /api/documents/{id}/image`, `.doc-image-img--vector` in `document.js`/`styles.css`, figure/thumbnail deliberate non-changes.
- Tests/lint (exact commands): `uv run pytest --cov=app --cov-report=term-missing` → **4265 passed, TOTAL 99 %** (exit 0) · `uv run pytest tests/e2e/test_svg_document_preview.py -v --no-cov` → **5 passed in isolation** · `uv run ruff check .` → clean · `uv run pyright` → **0 errors**.
- Regression (each run in isolation, `--no-cov`, all rc=0): `test_image_documents`, `test_chat_image_questions`, `test_answer_images`, `test_chat_answer_clusters`, `test_image_anchor_context`, `test_image_upload_feedback`, `test_markdown_inline_links_images`, `test_prose_word_wrap`, `test_pdf_support`, `test_pictorial_pdf`, `test_garbage_pdf`, `test_pdf_image_extraction`, `test_pdf_image_context`, `test_pdf_pages_in_sources`, `test_pdf_strip_additive_selfheal`, `test_sync_image_progress`.
- Completion criteria: ①SVG→`is_image`+`image_path`, no vision call, own `<title>/<desc>/<text>` — **met**; ②`image/svg+xml` + `default-src 'none'; sandbox` + `private, max-age=3600`, raster headers byte-unchanged — **met**; ③chat figure → modal `<img>`, no XML — **met**; ④`/document.html` full-width vector in `.doc-image` + text below — **met**; ⑤`` paints — **met**; ⑥raster/PDF unchanged — **met**; ⑦all gates green — **met**; ⑧commit + phase-dir move — harness-owned.
- Documentation: found and fixed README inconsistencies — stale forward reference "left to phase 181 / is phase 181's job" → completed-tense sizing note; "Updating the documents" standalone-image list missing `svg`; new **Raster only** bullet for question uploads (`.svg` → 422) documenting the `chat_images` guard; missing `svg_indexed=N` / `import: svg indexed` log contract; env-table heading `122/123/124` → `122/123/124/181`; new troubleshooting entry for an `.svg` showing raw XML. Full audit: every `Settings` field and every `.env.example` var is documented; pages/commands/scripts all exist. AGENTS.md verified accurate (phase-181 §5 clause present) — no changes needed beyond what the phase wrote.
- Notable deviation (kept from task 02): `app/api/chat_images.py` subtracts `_VECTOR_IMAGE_EXTENSIONS` from the question-image accept set — a required consequence of LOCKED A7 (`svg` in the default image set) since `_CHAT_IMAGE_FILENAME_RE` refuses to *serve* `.svg`; the regex itself is untouched, as the phase specified.
- Note for the commit: changes include `README.md`, `AGENTS.md` and `.env.example` — the phase file's `git add app/ frontend/ tests/ .env.example .agents/phases/` line omits the two markdown files; stage them too.
- Next pending phase: `182_verbatim_show_text`.
1236 lines
58 KiB
JavaScript
1236 lines
58 KiB
JavaScript
/* Brain of Reese — document viewer (phase 10) + the shared document
|
||
* renderer (phase 26).
|
||
*
|
||
* Two jobs:
|
||
*
|
||
* 1. renderDocument(doc, { titleEl, metaEl, contentEl }) — the
|
||
* EXPORTED rendering core. The /document.html page and the document
|
||
* modal (assets/document-modal.js, phase 26) both render through it,
|
||
* so the two surfaces can never drift:
|
||
*
|
||
* • md / markdown → the shared escape-first renderer (markdown.js)
|
||
* in the 72rem centered reading column;
|
||
* • prose (phase 165, LOCKED A1 — isProseDoc: an image doc's
|
||
* description or a txt/text doc) → the content
|
||
* as a text node inside <div class="doc-prose">,
|
||
* pre-wrap: it WRAPS at the reading column;
|
||
* • any other → the raw content as a text node inside
|
||
* <pre class="doc-raw"> (mono, horizontal
|
||
* scroll — locked A3: code never wraps);
|
||
* • doc.summary non-empty (phase 36 — phase 30 summaries exist
|
||
* only on non-markdown docs) → a labeled
|
||
* .doc-summary section ABOVE the content;
|
||
* null / empty / whitespace renders nothing, so
|
||
* markdown docs and fail-soft rows are
|
||
* byte-for-byte unchanged.
|
||
*
|
||
* 2. The /document.html page itself: reads `source`/`path` query
|
||
* params, fetches the stateless content endpoint
|
||
* (GET /api/documents/content — database only, no filesystem), and
|
||
* renders via renderDocument into #doc-title / #doc-meta /
|
||
* #doc-content. A missing document (unknown pair, missing params,
|
||
* network error) shows the designed not-found card with a link back
|
||
* to the Sources page. Phase 79 (task 05): the content endpoint is
|
||
* require_user-gated — a direct ANONYMOUS URL meets the inline
|
||
* token gate instead (the page document loads; the gated data does
|
||
* not): mountGate settles the auth (silent re-auth of a cached
|
||
* token, then the shared cached whoami) and the boot sequence
|
||
* (initSharedHeader + load) runs as its onAuthed — only a
|
||
* signed-in role (admin or token user) ever fetches the content.
|
||
*
|
||
* XSS-safe by construction: markdown is escaped before transform, raw
|
||
* formats are set via textContent, and every document-derived string
|
||
* (title, badges, path — and the phase-148 page strip's alt text and
|
||
* captions) is written with textContent or a property — never
|
||
* innerHTML (innerHTML goes through renderMarkdown, which escapes
|
||
* first).
|
||
*
|
||
* Phase 19: the viewer joins the shared header (assets/header.js) — the
|
||
* whoami fetch is the module's cached promise (one request per page,
|
||
* shared with initSharedHeader's toggling). Phase 34 task 02: the New
|
||
* chat binding is module-owned (assets/header.js, the SINGLE one) — on
|
||
* this non-chat page it clears the phase-14 conversation key and
|
||
* navigates to the chat's empty state.
|
||
*
|
||
* Phase 26 (import safety): the modal module imports renderDocument
|
||
* from THIS file on the chat/sources pages, so everything
|
||
* /document.html-specific runs only when #doc-title exists (the guard
|
||
* around the page block below). Importing renderDocument elsewhere has
|
||
* no side effects: no back-link resolution, no whoami, no content
|
||
* fetch, no New Chat binding.
|
||
*
|
||
* Phase 36: the renderer owns the optional summary panel — a non-empty
|
||
* doc.summary renders as a labeled .doc-summary section above the
|
||
* original content on BOTH surfaces (page + modal) through this one
|
||
* core; the summary text is a text node (XSS contract unchanged).
|
||
*
|
||
* Phase 57 (task 02, D4): the panel gains an ADMIN-ONLY edit
|
||
* affordance (docAdminReady() gate on the module-cached whoami): an
|
||
* Edit button in the panel's header row opens an inline editor
|
||
* (prefilled textarea + Save/Cancel + a role=status live region), and
|
||
* Save PATCHes /api/documents/summary. The section is built for
|
||
* everyone exactly as phase 36 — the public viewer stays
|
||
* byte-for-byte identical (no button, no wiring, no admin-only
|
||
* network call) until the admin gate resolves true.
|
||
*
|
||
* Phase 106 (task 08, D8): the .doc-meta badge row gains the Created
|
||
* badge BEFORE the Indexed one (the owner's verbatim position — the
|
||
* date at the top of a clicked document): `Created <locale date>` via
|
||
* fmtDate(doc.created_at), the full ISO timestamp on the badge's
|
||
* title (the titleEl ellipsis-precision idiom). ONE shared core —
|
||
* the modal and /document.html both render it (no per-surface copy);
|
||
* the date arrives in the same content payload (task 05 added
|
||
* created_at to GET /api/documents/content). The date EDITOR (the
|
||
* admin inline edit + PATCH /api/documents/date) is the task-09
|
||
* section below.
|
||
*
|
||
* Phase 106 (task 09, D7): the badge row gains the ADMIN-ONLY date
|
||
* editor (the phase-57 wireSummaryEdit idiom — the same
|
||
* docAdminReady() gate on the module-cached whoami promise, so no
|
||
* second request per page): an "Edit date" text button after the
|
||
* Created badge (task 08's insertion point) swaps in-place for an
|
||
* inline editor — a native type=date input + Save / Cancel + the
|
||
* muted "Revert to sync" clear affordance (the phase-57 "clear =
|
||
* explicit" contrast) + a role=status live line and a role=alert
|
||
* error line. Save PATCHes /api/documents/date with { source, path,
|
||
* date } and the badge re-renders from the RESPONSE's created_at
|
||
* (never the input's optimistic value); "Revert to sync" sends
|
||
* date: null (the D7 CLEAR — the manual flag drops, the stored date
|
||
* stands until the next sync). An EMPTY date input can't clear (Save
|
||
* disables itself — the explicit Revert is the only clear path, no
|
||
* accidental wipes). §7.4 never-stale: the controls disable
|
||
* immediately on submit (one PATCH at a time); a failure (non-2xx
|
||
* or network) lands the server detail (or the canned retry copy) in
|
||
* the role=alert line, reverts the input to the stored date, and
|
||
* re-enables — the UI never claims a state the server didn't save.
|
||
* A non-admin / token holder / failed whoami keeps the byte-for-byte
|
||
* task-08 badge row (no button, no wiring, no admin-only network
|
||
* call — the phase-57 split). ONE shared core — the modal and
|
||
* /document.html both get it (document-modal.js imports
|
||
* renderDocument from this module — no per-surface copy).
|
||
*
|
||
* Phase 122 (task 04): image documents — when doc.is_image is true,
|
||
* #doc-content renders the persistent bytes FIRST (the <img> block
|
||
* from doc.image_url — the /api/documents/{id}/image route — alt =
|
||
* the summary, the WCAG alt contract; a NULL summary falls back to
|
||
* the title), and the description (doc.content — the ONLY readable
|
||
* text of the doc) follows in the EXISTING plain-content slot below
|
||
* (phase 165: the .doc-prose path — a description IS prose, so it
|
||
* wraps; it was never markdown, and never belonged in the pre). The
|
||
* labeled Summary panel is suppressed for the verbatim-description
|
||
* case (summary === content — the importer invariant; the panel
|
||
* would duplicate the text right below the image); an admin-edited
|
||
* summary (different text) still renders in its panel with the
|
||
* phase-57 edit affordance. An <img> load failure (the route's 404 —
|
||
* the row exists but the copy was lost) swaps the block for a small
|
||
* "Image unavailable" note (role=status): the page still shows the
|
||
* description. ONE shared core — page + modal both get it.
|
||
*
|
||
* Phase 145 (task 03): the meta row's "Original" anchor — when the
|
||
* content payload carries origin_url (a git-sourced document — the
|
||
* task-01 resolver's sanitized repository URL; ABSENT on the wire
|
||
* for non-git documents, whose meta row stays byte-identical to
|
||
* pre-phase), an <a class="doc-origin"> joins the .doc-meta row
|
||
* AFTER the path code and BEFORE the Created badge: href + visible
|
||
* text = the full URL VERBATIM (the copyable-link contract — no
|
||
* decorative label inside the anchor text; the .doc-origin ellipsis
|
||
* clips the display), target=_blank + rel=noopener (the modal's
|
||
* "Full page" escape-hatch idiom), and the title carries the full
|
||
* URL plus ` — branch: <name>` when the payload pins a branch (the
|
||
* pathCode ellipsis-precision idiom). ONE shared core — the modal
|
||
* AND /document.html render it (no per-surface copy); createElement
|
||
* + properties/textContent only (the never-build-HTML rule — no
|
||
* innerHTML).
|
||
*
|
||
* Phase 148 (task 03): the page strip — when the payload carries
|
||
* page_urls (a page-qualifying PDF — the phase-133 fallback class
|
||
* or the phase-140 pictorial class — the task-02 endpoint's exact
|
||
* 1-based /api/documents/{id}/pages/{n} route list; ABSENT on the
|
||
* wire for every other document, so they render the EXACT pre-phase
|
||
* DOM on BOTH surfaces — the load-bearing byte-identity property),
|
||
* #doc-content renders a .doc-pages strip INSTEAD of the text
|
||
* content AND the .doc-summary panel FOR THE PRIMARY CLASS ONLY
|
||
* (phase 163 — the absent/`primary` mode; see the next paragraph):
|
||
* one <figure class="doc-page"> per URL — a lazy <img> (alt = "Page N of <title>", the WCAG alt
|
||
* contract — the title as the fallback context) + a "Page N"
|
||
* <figcaption> — while the meta badge row renders exactly as today.
|
||
* The owner's contract: the per-page descriptions stay in the DB for
|
||
* Q&A (the content/embeddings/summaries are untouched); they are
|
||
* simply not shown to the user — so the Summary panel is NOT built
|
||
* (its admin edit affordance is moot without it). A page whose copy
|
||
* was lost (the route's defensive 404s) swaps ONLY ITS figure's
|
||
* <img> for a "Page N unavailable." note (role=status — the
|
||
* phase-122 image-unavailable idiom, the same muted class); the rest
|
||
* of the strip is untouched (one lost copy never hides the other
|
||
* pages). ONE shared core — the modal AND /document.html get it (no
|
||
* per-surface copy); createElement + properties/textContent only (the
|
||
* never-build-HTML rule — no innerHTML).
|
||
*
|
||
* Phase 163 (task 04, LOCKED A1/A2/A6): the strip's CLASS decides the
|
||
* view. page_strip_mode === "additive" (the pictorial class — a USABLE
|
||
* text layer, so the pages are a convenience, not the content) keeps
|
||
* the TEXT doc EXACTLY as a non-qualifying doc renders it (Summary
|
||
* panel + content — the pre-148 branch reused), and the strip sits one
|
||
* Text/Pages toggle away: a .doc-view-toggle group (role=group,
|
||
* aria-label="Document view", two <button aria-pressed>) above a
|
||
* .doc-views wrapper holding the text view + the strip; Text is
|
||
* pressed by DEFAULT (A6 — never a sparse-ratio flip), Pages flips the
|
||
* wrapper's data-view (CSS shows the strip / hides the text — the
|
||
* strip node stays MOUNTED, its lazy imgs never refetch on flip-flop).
|
||
* The text is NEVER hidden for this class. The absent/"primary" mode
|
||
* (the fallback class — the screenshots ARE the content, and every
|
||
* pre-163 stamped row) keeps the strip-only DOM above byte-identically
|
||
* (the tolerant read: only the exact "additive" string switches on the
|
||
* toggle); falsy page_urls stay untouched. The wrapper exists ONLY on
|
||
* additive docs — every other document's DOM is unchanged. Toggle
|
||
* labels are fixed strings (no doc-derived text); createElement +
|
||
* properties/textContent only.
|
||
*
|
||
* Phase 153 (task 02): the meta row's "Copy path" control — the
|
||
* read-tool `source/path` form in the clipboard with one click
|
||
* (ASSUMPTION A1 — the exact `source/path` the read tool, the
|
||
* chips, and the Sources table use; the paste target is the
|
||
* owner's own tooling). The control is built by the ONE shared
|
||
* renderDocument core (both surfaces — modal AND /document.html —
|
||
* get the SAME icon+text button, AFTER the path code and BEFORE
|
||
* the origin anchor), and each surface wires it with its OWN
|
||
* page-local clipboard helper (the per-page duplication house style
|
||
* — document-modal.js carries the modal's copy; no new shared
|
||
* module): navigator.clipboard.writeText in a try — a non-secure
|
||
* (http) homelab origin rejects the clipboard, so the failure path
|
||
* renders an inline fallback field (input-like, prefilled with the
|
||
* path, selects its full content on focus — the .share-link-
|
||
* fallback pattern) + the status line (the owner-locked fallback
|
||
* wording, path-adapted); success shows a transient VISIBLE status
|
||
* (auto-clears ~4s, the toast idiom — the node is aria-hidden,
|
||
* visual only) and the surface's role=status live region reads the
|
||
* same text (one a11y announcer — the share pattern: #doc-copy-
|
||
* announcer on the page, #doc-modal-desc in the modal). A failed
|
||
* copy NEVER clears the previous state into nothing (the old state
|
||
* is replaced in place, never dropped); a re-click re-attempts.
|
||
* The page's status/field float in a .doc-copy-pop block anchored
|
||
* under the button (the meta row must never wrap or grow the bar
|
||
* — the phase-12/34 contract — so the block is absolutely
|
||
* positioned in the header, never in the row); the modal's sit
|
||
* inline in the wrapping #doc-modal-meta block.
|
||
*/
|
||
|
||
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||
import { mountGate } from "./token-gate.js"; // phase 79 (task 05): the inline token gate
|
||
|
||
// The shared header's control bindings (sign-out / mobile hamburger) —
|
||
// EXPLICIT init, once per document (header.js is bundle-inlined per
|
||
// entry; import-time side effects would double-bind — 2026-09-08 fix).
|
||
bindSharedHeaderControls();
|
||
|
||
/* Phase 39: the page title's display name — window.BOR_BRAND (set at
|
||
* parse time by the classic assets/brand.js, refreshed from
|
||
* /api/config). This is a module, so the global is set by the time this
|
||
* evaluates; the literal is the no-config fallback only. */
|
||
const brand = () => window.BOR_BRAND || "Brain of Reese";
|
||
|
||
function fmtDate(iso) {
|
||
try {
|
||
return new Date(iso).toLocaleString();
|
||
} catch {
|
||
return iso;
|
||
}
|
||
}
|
||
|
||
function contentUrl(s, p) {
|
||
return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
|
||
}
|
||
|
||
function metaBadge(cls, text, title) {
|
||
const el = document.createElement("span");
|
||
el.className = cls;
|
||
el.textContent = text;
|
||
// Phase 106 (task 08): optional hover precision — the meta row may
|
||
// clip (nowrap), so the exact value stays reachable on the badge's
|
||
// title (the titleEl ellipsis-precision idiom). Omitted → the
|
||
// pre-phase two-argument shape (the other badges are unchanged).
|
||
if (title !== undefined) el.setAttribute("title", title);
|
||
return el;
|
||
}
|
||
|
||
/* Phase 145 (task 03): the meta row's "Original" anchor — the git
|
||
* origin as a copyable external link (called only when the payload
|
||
* carries origin_url — a non-git document never gets one, so its
|
||
* meta row stays the pre-phase shape). The FULL URL is the anchor's
|
||
* visible text (the copyable-link contract — no decorative label
|
||
* inside the anchor text: a " (branch)" suffix would break
|
||
* copy-paste; the .doc-origin CSS ellipsis clips the display
|
||
* instead), target=_blank + rel=noopener (the modal's "Full page"
|
||
* escape-hatch idiom), and the title carries the full URL — plus
|
||
* ` — branch: <name>` when the payload pins a branch (the pathCode
|
||
* ellipsis-precision idiom). Properties + textContent only — never
|
||
* innerHTML (the XSS contract). */
|
||
function originAnchor(doc) {
|
||
const a = document.createElement("a");
|
||
a.className = "doc-origin";
|
||
a.href = doc.origin_url;
|
||
a.target = "_blank";
|
||
a.rel = "noopener";
|
||
a.textContent = doc.origin_url;
|
||
a.setAttribute(
|
||
"title",
|
||
doc.origin_branch ? doc.origin_url + " — branch: " + doc.origin_branch : doc.origin_url,
|
||
);
|
||
return a;
|
||
}
|
||
|
||
/* Phase 153 (task 02): the meta row's "Copy path" control — the
|
||
* icon+text row-action shape (the git-sources.js row-buttons idiom),
|
||
* createElementNS-built (the never-build-HTML rule stands — the
|
||
* module's ONE innerHTML stays the escape-first markdown render).
|
||
* The VISIBLE "Copy path" text IS the accessible name (a redundant
|
||
* aria-label is not carried — WCAG 4.1.2 via the text), the icon is
|
||
* aria-hidden, and type=button (no form submission). The shared
|
||
* renderDocument core builds it (both surfaces — modal AND
|
||
* /document.html — get the same shape; each surface wires it with
|
||
* its own page-local clipboard helper — the per-page duplication
|
||
* house style). */
|
||
function copyPathButton() {
|
||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||
const b = document.createElement("button");
|
||
b.type = "button";
|
||
b.className = "doc-copy-path";
|
||
const icon = document.createElementNS(SVG_NS, "svg");
|
||
icon.setAttribute("aria-hidden", "true");
|
||
icon.setAttribute("viewBox", "0 0 24 24");
|
||
icon.setAttribute("fill", "none");
|
||
icon.setAttribute("stroke", "currentColor");
|
||
icon.setAttribute("stroke-width", "1.8");
|
||
icon.setAttribute("stroke-linecap", "round");
|
||
icon.setAttribute("stroke-linejoin", "round");
|
||
const backRect = document.createElementNS(SVG_NS, "rect");
|
||
backRect.setAttribute("x", "9");
|
||
backRect.setAttribute("y", "9");
|
||
backRect.setAttribute("width", "11");
|
||
backRect.setAttribute("height", "11");
|
||
backRect.setAttribute("rx", "2");
|
||
const front = document.createElementNS(SVG_NS, "path");
|
||
front.setAttribute("d", "M5 15V5a2 2 0 0 1 2-2h10");
|
||
icon.append(backRect, front);
|
||
const label = document.createElement("span");
|
||
label.textContent = "Copy path";
|
||
b.append(icon, label);
|
||
return b;
|
||
}
|
||
|
||
/* ---------- prose set (phase 165, task 01 — LOCKED A1) ----------
|
||
* The ONE place the prose rule lives (TODO.md L3: "Need word wrap for
|
||
* image explanations and other long text rather than scrolling
|
||
* horizontally"). The defect: the description used to land in the
|
||
* `pre` and each generated line became one unbreakable line — a
|
||
* horizontal scrollbar where every other text surface wraps.
|
||
*
|
||
* The image arm is UNCONDITIONAL (phase 122 A3: an image doc's
|
||
* `content` IS the vision description — prose by construction,
|
||
* whatever its extension). The `format` arm covers plain-text docs;
|
||
* `doc.format` comes from the server's doc_format() (the lowercased
|
||
* suffix). Code and code-ish formats (py, yaml, json, j2, the quadlet
|
||
* family, pdf — locked A1/A3: phase 116's scroll contract stands)
|
||
* never enter this set. */
|
||
const PROSE_FORMATS = new Set(["txt", "text"]);
|
||
|
||
function isProseDoc(doc) {
|
||
return doc.is_image === true || PROSE_FORMATS.has(doc.format);
|
||
}
|
||
|
||
/* ---------- shared renderer (phase 26, task 02) ----------
|
||
* Populates the three elements every render surface provides: a title,
|
||
* a .doc-meta badge row (source · format · mono path · created ·
|
||
* indexed · chunks — phase 106 (task 08, D8) added created BEFORE
|
||
* indexed), and a content container — an optional .doc-summary
|
||
* section first (phase 36: only when doc.summary is non-empty —
|
||
* markdown docs and fail-soft rows carry none, so they render exactly
|
||
* as before), then .doc-md for md/markdown (the shared escape-first
|
||
* renderer), <div class="doc-prose"> for the prose set (phase 165 —
|
||
* isProseDoc), <pre class="doc-raw"> otherwise. The XSS contract:
|
||
* innerHTML only through renderMarkdown; every document-derived
|
||
* string (summary text included) is a text node. */
|
||
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||
titleEl.textContent = doc.title;
|
||
// Phase 34 task 04: the titlebar title ellipsizes — the full title
|
||
// stays reachable on hover via the `title` attribute.
|
||
titleEl.setAttribute("title", doc.title);
|
||
|
||
const pathCode = document.createElement("code");
|
||
pathCode.className = "doc-path";
|
||
pathCode.textContent = doc.path;
|
||
// Phase 153 (task 02): the "Copy path" control — the shared core
|
||
// builds it (BOTH surfaces — modal AND /document.html — get the
|
||
// SAME shape; each surface wires it with its own page-local
|
||
// clipboard helper). It lands AFTER the path code and BEFORE the
|
||
// origin anchor — one more inline item in the row (the modal's
|
||
// wrap rule and the page's clip rule both keep it on their
|
||
// shapes).
|
||
const copyBtn = copyPathButton();
|
||
// Phase 145 (task 03): the "Original" anchor — the git origin as a
|
||
// copyable external link, present ONLY when the content payload
|
||
// carries origin_url (a git-sourced document's sanitized repository
|
||
// URL — the task-01 resolver's output; ABSENT on the wire for
|
||
// non-git documents, so their meta row stays byte-identical to
|
||
// pre-phase). It lands AFTER the path code and BEFORE the Created
|
||
// badge — one more inline item in the row (the modal's wrap rule
|
||
// and the page's clip rule both keep it on their shapes).
|
||
const originItems = doc.origin_url ? [originAnchor(doc)] : [];
|
||
metaEl.replaceChildren(
|
||
metaBadge("doc-source-badge", doc.source),
|
||
metaBadge("format-badge", doc.format),
|
||
pathCode,
|
||
copyBtn,
|
||
...originItems,
|
||
// Phase 106 (task 08, D8): the Created badge BEFORE the Indexed
|
||
// one (the owner's verbatim position — the date at the top of a
|
||
// clicked document). textContent = the locale date (fmtDate — the
|
||
// Indexed idiom), and the badge's title = the FULL ISO timestamp
|
||
// (the titleEl ellipsis-precision idiom — the row clips, the exact
|
||
// value stays reachable).
|
||
metaBadge("doc-created", `Created ${fmtDate(doc.created_at)}`, doc.created_at),
|
||
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
|
||
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
||
);
|
||
|
||
// Phase 106 (task 09, D7): the ADMIN-ONLY date editor — the
|
||
// module-cached whoami promise (docAdminReady — the SAME single
|
||
// request per page the summary edit shares) gates the wiring, so a
|
||
// non-admin / token holder / failed whoami keeps EXACTLY the task-08
|
||
// badge row: no button, no wiring, no admin-only network call (the
|
||
// phase-57 split). The button lands after the Created badge (task
|
||
// 08's insertion point); the editor itself lives in wireDateEdit.
|
||
void docAdminReady().then((admin) => {
|
||
if (admin) wireDateEdit(metaEl, doc);
|
||
});
|
||
|
||
contentEl.replaceChildren();
|
||
// Phase 148 (task 03): the page strip — the payload's page_urls
|
||
// (task 02's endpoint: the exact 1-based route list for a
|
||
// page-qualifying PDF; ABSENT on the wire for every other
|
||
// document) is the ONE branch that replaces the content slot (phase
|
||
// 163: the PRIMARY class only — an "additive" doc falls through to
|
||
// the text rendering with the strip behind a toggle, D4/A1): the
|
||
// markdown/.doc-prose/.doc-raw content block AND the labeled Summary panel
|
||
// (including its admin edit wiring — moot without the panel) are
|
||
// NOT built. The per-page descriptions stay in the DB for Q&A (the
|
||
// importer's content/embeddings/summary are untouched); they are
|
||
// simply not shown to the user (the owner's contract). The meta
|
||
// badge row above renders exactly as pre-phase. null / absent /
|
||
// empty page_urls falls through to the EXACT pre-phase rendering
|
||
// below (byte-identical for every other doc — the load-bearing
|
||
// property).
|
||
const pageUrls = Array.isArray(doc.page_urls) ? doc.page_urls : null;
|
||
const stripEligible = pageUrls !== null && pageUrls.length > 0;
|
||
// Phase 163 (task 04, LOCKED A2): the strip's CLASS picks the view.
|
||
// Only the exact "additive" string switches on the toggle — absent
|
||
// (pre-163 rows, and the omission rule's non-qualifying docs) or
|
||
// "primary" read as primary (the tolerant-reading house rule), so
|
||
// the fallback class keeps the strip-only DOM byte-identically.
|
||
const additive = stripEligible && doc.page_strip_mode === "additive";
|
||
if (stripEligible && !additive) {
|
||
contentEl.appendChild(docPagesStrip(doc, pageUrls));
|
||
return;
|
||
}
|
||
// Phase 163 (task 04, LOCKED A1/A6): an ADDITIVE doc renders the
|
||
// text doc EXACTLY as a non-qualifying one does (Summary panel +
|
||
// content below, reused unchanged) INSIDE a .doc-views wrapper —
|
||
// the Text/Pages toggle above it flips the wrapper's data-view
|
||
// (CSS-driven show/hide, never a re-render); the strip mounts once
|
||
// AFTER the text view and stays mounted across flips (lazy imgs,
|
||
// display:none while hidden, never fetch). Every non-additive doc
|
||
// keeps slot === contentEl — its DOM is byte-identical to pre-phase.
|
||
let views = null;
|
||
let slot = contentEl;
|
||
if (additive) {
|
||
views = document.createElement("div");
|
||
views.className = "doc-views";
|
||
views.setAttribute("data-view", "text"); // LOCKED A6: Text is the default view
|
||
contentEl.append(docViewToggle(views), views);
|
||
slot = views;
|
||
}
|
||
// Phase 122 (task 04): an image document — the persistent bytes
|
||
// render as the <img> block FIRST; the description (= doc.content)
|
||
// follows in the normal content slot below (the plain-content path
|
||
// — the last branch in this function).
|
||
if (doc.is_image) {
|
||
slot.appendChild(docImageBlock(doc));
|
||
}
|
||
// Phase 36: the summary panel — labeled section ABOVE the original
|
||
// content, on BOTH surfaces (page + modal) through this one core.
|
||
// Only a non-empty summary renders: markdown docs carry none (phase
|
||
// 30) and the fail-soft path leaves summary NULL, so both are
|
||
// byte-for-byte unchanged here. Phase 122 (task 04): for an IMAGE
|
||
// doc whose summary IS the verbatim description (summary ===
|
||
// content — the importer invariant), the panel would duplicate the
|
||
// text right below the image, so it is suppressed; an admin-edited
|
||
// summary (different text) still renders with the phase-57 edit
|
||
// affordance.
|
||
const imageSummaryIsContent = doc.is_image && doc.summary === doc.content;
|
||
if (doc.summary && doc.summary.trim() !== "" && !imageSummaryIsContent) {
|
||
const section = document.createElement("section");
|
||
section.className = "doc-summary";
|
||
section.setAttribute("aria-label", "Summary");
|
||
const title = document.createElement("h2");
|
||
title.className = "doc-summary-title";
|
||
title.textContent = "Summary";
|
||
const body = document.createElement("p");
|
||
body.className = "doc-summary-text";
|
||
body.textContent = doc.summary; // text node — XSS contract unchanged
|
||
section.append(title, body);
|
||
slot.appendChild(section);
|
||
// Phase 57 (task 02, D4): the section above is the phase-36 shape
|
||
// for EVERYONE — only an authenticated admin (docAdminReady(), the
|
||
// module-cached whoami promise) then gains the header row + Edit
|
||
// button + editor wiring. Anonymous / fetch failure: the panel is
|
||
// exactly what phase 36 built (byte-for-byte unchanged).
|
||
void docAdminReady().then((admin) => {
|
||
if (admin) wireSummaryEdit(section, doc);
|
||
});
|
||
}
|
||
if (doc.format === "md" || doc.format === "markdown") {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "doc-md";
|
||
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
|
||
slot.appendChild(wrap);
|
||
} else if (isProseDoc(doc)) {
|
||
// Phase 165 (task 01, LOCKED A2): the prose arm — a TEXT NODE in
|
||
// the wrapping .doc-prose block (pre-wrap carries the newlines;
|
||
// no markdown parsing of a description). The XSS contract is
|
||
// untouched: the content is never parsed as HTML.
|
||
const prose = document.createElement("div");
|
||
prose.className = "doc-prose";
|
||
prose.textContent = doc.content; // text node: never parsed as HTML
|
||
slot.appendChild(prose);
|
||
} else {
|
||
const pre = document.createElement("pre");
|
||
pre.className = "doc-raw";
|
||
pre.textContent = doc.content; // text node: never parsed as HTML
|
||
slot.appendChild(pre);
|
||
}
|
||
// Phase 163 (task 04): the additive strip mounts once, AFTER the
|
||
// text view, inside the wrapper — hidden by [data-view="text"]
|
||
// (the lazy imgs never fetch until Pages is chosen; the node
|
||
// persists across flips — no refetch).
|
||
if (views !== null) {
|
||
views.appendChild(docPagesStrip(doc, pageUrls));
|
||
}
|
||
}
|
||
|
||
/* ---------- view toggle (phase 163, task 04 — LOCKED A1/A6) ----------
|
||
* The Text/Pages toggle of an ADDITIVE strip doc: one
|
||
* <div class="doc-view-toggle" role="group" aria-label="Document
|
||
* view"> with two <button type="button" class="doc-view-btn"
|
||
* aria-pressed>. Text is pressed initially (A6 — no sparse-ratio
|
||
* default flip). A click mutates ONLY the two aria-pressed states and
|
||
* the .doc-views wrapper's data-view attribute — the CSS shows/hides
|
||
* the text view and the strip (never a re-render, never a refetch).
|
||
* The labels are FIXED strings (no doc-derived text inside the
|
||
* toggle); createElement + properties/textContent only (the
|
||
* never-build-HTML rule — no innerHTML). */
|
||
function docViewToggle(viewsEl) {
|
||
const group = document.createElement("div");
|
||
group.className = "doc-view-toggle";
|
||
group.setAttribute("role", "group");
|
||
group.setAttribute("aria-label", "Document view");
|
||
const textBtn = viewToggleButton("Text");
|
||
const pagesBtn = viewToggleButton("Pages");
|
||
const select = (view) => {
|
||
viewsEl.setAttribute("data-view", view);
|
||
textBtn.setAttribute("aria-pressed", view === "text" ? "true" : "false");
|
||
pagesBtn.setAttribute("aria-pressed", view === "pages" ? "true" : "false");
|
||
};
|
||
textBtn.setAttribute("aria-pressed", "true"); // LOCKED A6: Text pressed by default
|
||
pagesBtn.setAttribute("aria-pressed", "false");
|
||
textBtn.addEventListener("click", () => select("text"));
|
||
pagesBtn.addEventListener("click", () => select("pages"));
|
||
group.append(textBtn, pagesBtn);
|
||
return group;
|
||
}
|
||
|
||
function viewToggleButton(label) {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "doc-view-btn";
|
||
btn.textContent = label; // fixed string — never doc-derived
|
||
return btn;
|
||
}
|
||
|
||
/* ---------- image block (phase 122, task 04) ----------
|
||
* The viewer's image block for an ``is_image`` document: the
|
||
* document's PERSISTENT bytes (doc.image_url — the
|
||
* /api/documents/{id}/image route) as a block <img> (max-width 100%;
|
||
* the theme's surface treatment lives in .doc-image). alt = the
|
||
* summary (the vision description — the WCAG alt contract everywhere);
|
||
* a NULL summary (the fail-soft backfill corner) falls back to the
|
||
* title. On a load failure (the route's 404 — the row exists but the
|
||
* copy was lost) the block shows a small "Image unavailable."
|
||
* note (role=status) in its place; the description in the content
|
||
* slot below still renders (the doc is still readable). Properties
|
||
* only (src, alt) — every document-derived value is a text node /
|
||
* property, never innerHTML (the XSS contract, unchanged). */
|
||
function docImageBlock(doc) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "doc-image";
|
||
const img = document.createElement("img");
|
||
// Phase 181 (task 04, design D6/D7): SVG is a VECTOR image doc. A
|
||
// drawing carrying only a viewBox has NO intrinsic size, so the
|
||
// default replaced-element box (300×150) would sit small inside the
|
||
// full-width .doc-image card; the --vector modifier (the base class
|
||
// is KEPT so every current rule still applies) lets width: 100%
|
||
// span the card — the full-size preview. doc.format is the ONLY
|
||
// place in the app that knows the type: the bytes URL is
|
||
// extension-less and SourceRef carries no format field by design
|
||
// (D7). LOCKED A3: the SVG is NEVER inlined into the DOM —
|
||
// img.src = doc.image_url stays createElement + property
|
||
// assignment (this file's XSS contract, unchanged). Legacy
|
||
// honesty (D6): a row still indexed as TEXT (an .svg under
|
||
// BOR_IMAGES=0, or not yet re-synced) keeps rendering its XML in
|
||
// .doc-raw — the truth about that row (there are no bytes behind
|
||
// it); task 02's kind-mismatch guard converts it on the next sync.
|
||
// Nobody "fixes" that by sniffing the content string.
|
||
img.className =
|
||
doc.format === "svg"
|
||
? "doc-image-img doc-image-img--vector"
|
||
: "doc-image-img";
|
||
img.src = doc.image_url;
|
||
img.alt =
|
||
typeof doc.summary === "string" && doc.summary.trim() !== ""
|
||
? doc.summary
|
||
: doc.title;
|
||
img.addEventListener("error", () => {
|
||
const note = document.createElement("p");
|
||
note.className = "doc-image-unavailable";
|
||
note.setAttribute("role", "status");
|
||
note.textContent = "Image unavailable — the file is missing.";
|
||
wrap.replaceChildren(note);
|
||
});
|
||
wrap.appendChild(img);
|
||
return wrap;
|
||
}
|
||
|
||
/* ---------- page strip (phase 148, task 03) ----------
|
||
* The viewer's page strip for a page-qualifying PDF (doc.page_urls
|
||
* non-empty — the task-02 payload: one
|
||
* /api/documents/{id}/pages/{n} route per rendered page, 1-based).
|
||
* A .doc-pages container goes into the content slot INSTEAD of the
|
||
* text content and the Summary panel (the renderDocument branch
|
||
* above) — the owner's contract: the per-page descriptions stay in
|
||
* the DB for Q&A, they are just not displayed. One
|
||
* <figure class="doc-page"> per URL: a lazy <img> (alt =
|
||
* "Page N of <title>" — the WCAG alt contract, the title as the
|
||
* fallback context, the phase-122 image idiom) + a "Page N"
|
||
* <figcaption class="doc-page-num">. A page whose copy was lost
|
||
* (the route's defensive 404) swaps ONLY ITS figure's <img> for a
|
||
* small "Page N unavailable." note (role=status — the phase-122
|
||
* image-unavailable note class, the same muted styling); the rest of
|
||
* the strip is untouched (one lost copy never hides the other pages).
|
||
* createElement + properties/textContent only — every document-
|
||
* derived string (the title in the alt, the note) lands via a
|
||
* property or textContent, never innerHTML (the XSS contract,
|
||
* unchanged). */
|
||
function docPagesStrip(doc, pageUrls) {
|
||
const wrap = document.createElement("div");
|
||
wrap.className = "doc-pages";
|
||
pageUrls.forEach((url, i) => {
|
||
const n = i + 1; // 1-based over page_urls (the route list's own numbering)
|
||
const figure = document.createElement("figure");
|
||
figure.className = "doc-page";
|
||
const img = document.createElement("img");
|
||
img.loading = "lazy"; // lazy imgs — a long manual streams down the strip
|
||
img.src = url;
|
||
img.alt = `Page ${n} of ${doc.title}`;
|
||
img.addEventListener("error", () => {
|
||
// The phase-122 idiom (its muted note class, reused): the lost
|
||
// copy's <img> becomes a small "Page N unavailable." note in
|
||
// ITS figure only — the caption and every other page stand.
|
||
const note = document.createElement("p");
|
||
note.className = "doc-image-unavailable";
|
||
note.setAttribute("role", "status");
|
||
note.textContent = `Page ${n} unavailable.`;
|
||
img.replaceWith(note);
|
||
});
|
||
const figcaption = document.createElement("figcaption");
|
||
figcaption.className = "doc-page-num";
|
||
figcaption.textContent = `Page ${n}`;
|
||
figure.append(img, figcaption);
|
||
wrap.appendChild(figure);
|
||
});
|
||
return wrap;
|
||
}
|
||
|
||
/* ---------- summary editing (phase 57, task 02 — D4, admin-only) ----------
|
||
* The .doc-summary panel is the ONE place the stored summary is edited
|
||
* (page + modal through this core). Only an admin (docAdminReady) ever
|
||
* gets the affordance; the public viewer is byte-for-byte unchanged. */
|
||
|
||
/* The admin gate (D4 — the viewer stays public): the module-cached
|
||
* /api/whoami promise (header.js's fetchIsAdmin — the SAME single
|
||
* request per page the shared header already makes on every surface,
|
||
* so this adds no request of its own). A non-admin or any fetch
|
||
* failure resolves false → the anonymous viewer. */
|
||
async function docAdminReady() {
|
||
try {
|
||
return (await fetchIsAdmin()) === true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/* The edit affordance on one rendered .doc-summary section. The bare
|
||
* h2 becomes a header row (label left, Edit button right). Edit swaps
|
||
* the .doc-summary-text node for the inline editor — a prefilled
|
||
* textarea (value, never innerHTML — XSS contract), Save / Cancel,
|
||
* and a role=status live region. Save PATCHes /api/documents/summary
|
||
* with { source, path, summary } — the pair comes from the doc object
|
||
* (the same values the modal core carries, document-modal.js). Success
|
||
* re-renders the text node via textContent and announces "Summary
|
||
* updated."; an empty save that clears announces "Summary cleared."
|
||
* and removes the panel a short beat later — the confirmation stays
|
||
* readable, and the renderer only draws the panel for non-empty
|
||
* summaries. Cancel restores the text node. A failure keeps the
|
||
* editor open with the user's text and shows neutral retry copy
|
||
* (phase-55 convention). */
|
||
function wireSummaryEdit(section, doc) {
|
||
const title = section.querySelector(".doc-summary-title");
|
||
const body = section.querySelector(".doc-summary-text");
|
||
if (!title || !body) return;
|
||
|
||
/* Header row: label left, Edit button right (admin-only — the
|
||
* anonymous section keeps its bare h2). */
|
||
const head = document.createElement("div");
|
||
head.className = "doc-summary-head";
|
||
const editBtn = document.createElement("button");
|
||
editBtn.type = "button";
|
||
editBtn.className = "doc-summary-edit";
|
||
editBtn.textContent = "Edit";
|
||
head.append(title, editBtn);
|
||
section.replaceChildren(head, body);
|
||
|
||
/* Editor parts (built once; the textarea is rebuilt on every open so
|
||
* it always starts from the CURRENT stored summary). */
|
||
let editor = null;
|
||
const actions = document.createElement("div");
|
||
actions.className = "doc-summary-actions";
|
||
const saveBtn = document.createElement("button");
|
||
saveBtn.type = "button";
|
||
saveBtn.className = "doc-summary-save";
|
||
saveBtn.textContent = "Save";
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.type = "button";
|
||
cancelBtn.className = "doc-summary-cancel";
|
||
cancelBtn.textContent = "Cancel";
|
||
actions.append(saveBtn, cancelBtn);
|
||
const status = document.createElement("p");
|
||
status.className = "doc-summary-status";
|
||
status.setAttribute("role", "status");
|
||
status.setAttribute("aria-live", "polite");
|
||
|
||
/* Back to the display state: the text node re-rendered from the
|
||
* doc object (the CURRENT stored summary), the live region (the
|
||
* announced message), the Edit button available again. If the
|
||
* summary is gone (a clear landed while the editor was open — e.g.
|
||
* Cancel right after a successful empty save) the panel is gone
|
||
* too: the renderer only draws it for non-empty summaries. */
|
||
function closeEditor(message) {
|
||
status.textContent = message;
|
||
editBtn.hidden = false;
|
||
if (typeof doc.summary !== "string" || doc.summary.trim() === "") {
|
||
section.remove();
|
||
return;
|
||
}
|
||
body.textContent = doc.summary; // text node — the CURRENT stored summary
|
||
section.replaceChildren(head, body, status);
|
||
editBtn.focus();
|
||
}
|
||
|
||
async function saveSummary() {
|
||
const value = editor.value;
|
||
saveBtn.disabled = true; // one PATCH at a time (never stale)
|
||
status.textContent = "";
|
||
try {
|
||
const res = await fetch("/api/documents/summary", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ source: doc.source, path: doc.path, summary: value }),
|
||
});
|
||
if (!res.ok) {
|
||
// Neutral retry copy (phase-55 convention) — the user's text
|
||
// stays in the editor (the editor stays open on failure).
|
||
status.textContent = "Couldn't update the summary — try again.";
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
if (data.summary === null) {
|
||
// Cleared (D4): the panel disappears — the renderer only draws
|
||
// it for non-empty summaries. The live-region confirmation
|
||
// stays visible for a short beat before the panel leaves the
|
||
// DOM (a screen reader must be able to read it; the removal
|
||
// is a no-op if the surface re-rendered or closed meanwhile).
|
||
doc.summary = null;
|
||
status.textContent = "Summary cleared.";
|
||
setTimeout(() => section.remove(), 2000);
|
||
return;
|
||
}
|
||
doc.summary = data.summary;
|
||
closeEditor("Summary updated.");
|
||
} catch {
|
||
// Network failure: same neutral shape, the reachable? copy.
|
||
status.textContent = "Couldn't update the summary — is the app reachable?";
|
||
} finally {
|
||
saveBtn.disabled = false;
|
||
}
|
||
}
|
||
|
||
function openEditor() {
|
||
editor = document.createElement("textarea");
|
||
editor.className = "doc-summary-editor";
|
||
editor.value = typeof doc.summary === "string" ? doc.summary : ""; // value, never innerHTML
|
||
status.textContent = "";
|
||
editBtn.hidden = true;
|
||
section.replaceChildren(head, editor, actions, status);
|
||
editor.focus();
|
||
}
|
||
|
||
editBtn.addEventListener("click", openEditor);
|
||
saveBtn.addEventListener("click", () => {
|
||
void saveSummary();
|
||
});
|
||
cancelBtn.addEventListener("click", () => closeEditor(""));
|
||
}
|
||
|
||
/* ---------- date editing (phase 106, task 09 — D7, admin-only) ----------
|
||
* The .doc-meta badge row (task 08's Created badge) is the ONE place
|
||
* the stored creation date is edited (page + modal through the
|
||
* shared renderDocument core — no per-surface copy). Only an admin
|
||
* (docAdminReady) ever gets the affordance; a non-admin / token
|
||
* holder / failed whoami keeps exactly the task-08 badge row (no
|
||
* button, no wiring, no admin-only network call — the phase-57
|
||
* split). Save PATCHes /api/documents/date with { source, path,
|
||
* date } — a non-empty date sets it (the server normalizes it, D3,
|
||
* and stores the manual flag, D1); the explicit "Revert to sync"
|
||
* affordance sends date: null (the D7 CLEAR — the manual flag drops
|
||
* and the stored date stands until the next sync refreshes it).
|
||
* The badge re-renders from the RESPONSE's created_at — the UI shows
|
||
* exactly what the server stored, never the input's optimistic
|
||
* value. §7.4 never-stale: the controls disable IMMEDIATELY on
|
||
* Save/Revert (no double-submit); a failure (non-2xx or network)
|
||
* lands the server detail (or the canned retry copy) in the
|
||
* role=alert line, reverts the input to the stored date, and
|
||
* re-enables — the UI never claims a state the server didn't save.
|
||
* An EMPTY date input can't clear: Save disables itself on an empty
|
||
* input (the explicit Revert is the only clear path — no accidental
|
||
* wipes). */
|
||
|
||
/* The server detail of a non-2xx response (the git-sources.js
|
||
* apiDetail shape — the phase-89 error-line idiom: a string detail
|
||
* or the 422 array's first msg), with the neutral fallback for a
|
||
* non-JSON body. */
|
||
async function apiDetail(res, fallback) {
|
||
try {
|
||
const data = await res.json();
|
||
if (Array.isArray(data.detail) && data.detail[0] && data.detail[0].msg) {
|
||
return String(data.detail[0].msg);
|
||
}
|
||
if (typeof data.detail === "string" && data.detail) return data.detail;
|
||
} catch {
|
||
/* non-JSON error body */
|
||
}
|
||
return fallback;
|
||
}
|
||
|
||
/* The date editor on one rendered .doc-meta badge row: the "Edit
|
||
* date" text button after the Created badge (admin-only — the public
|
||
* row keeps task 08's shape). Edit swaps the button for the inline
|
||
* editor in place — a native <input type="date"> (value, never
|
||
* innerHTML — XSS contract) prefilled with the stored date's UTC
|
||
* date part, Save / Cancel, the muted "Revert to sync" clear, and
|
||
* the role=status / role=alert live lines. One PATCH at a time (the
|
||
* controls disable before the fetch and re-enable in the finally —
|
||
* every outcome, never stale, PLAN §7.4). */
|
||
function wireDateEdit(metaEl, doc) {
|
||
const createdBadge = metaEl.querySelector(".doc-created");
|
||
if (!createdBadge) return;
|
||
|
||
/* Edit affordance (admin-only — the public badge row keeps exactly
|
||
* task 08's shape: no button, no wiring). setAttribute, never
|
||
* innerHTML (the document-derived pair is user-storable). */
|
||
const editBtn = document.createElement("button");
|
||
editBtn.type = "button";
|
||
editBtn.className = "doc-date-edit";
|
||
editBtn.textContent = "Edit date";
|
||
editBtn.setAttribute(
|
||
"aria-label",
|
||
`Edit creation date: ${doc.source}/${doc.path}`,
|
||
);
|
||
createdBadge.insertAdjacentElement("afterend", editBtn);
|
||
|
||
/* Editor parts (built once; the input is rebuilt on every open so
|
||
* it always starts from the CURRENT stored date). */
|
||
let input = null;
|
||
const box = document.createElement("span");
|
||
box.className = "doc-date-editor";
|
||
const saveBtn = document.createElement("button");
|
||
saveBtn.type = "button";
|
||
saveBtn.className = "doc-date-save";
|
||
saveBtn.textContent = "Save";
|
||
const cancelBtn = document.createElement("button");
|
||
cancelBtn.type = "button";
|
||
cancelBtn.className = "doc-date-cancel";
|
||
cancelBtn.textContent = "Cancel";
|
||
const revertBtn = document.createElement("button");
|
||
revertBtn.type = "button";
|
||
revertBtn.className = "doc-date-revert";
|
||
revertBtn.textContent = "Revert to sync";
|
||
const status = document.createElement("span");
|
||
status.className = "doc-date-status";
|
||
status.setAttribute("role", "status");
|
||
status.setAttribute("aria-live", "polite");
|
||
const errorLine = document.createElement("span");
|
||
errorLine.className = "doc-date-error";
|
||
errorLine.setAttribute("role", "alert");
|
||
errorLine.setAttribute("aria-live", "assertive");
|
||
|
||
/* The stored date as a native date-input value: the UTC date part
|
||
* of the stored ISO timestamp (D3 stores UTC timestamptz). */
|
||
function storedValue() {
|
||
return new Date(doc.created_at).toISOString().slice(0, 10);
|
||
}
|
||
|
||
/* One PATCH at a time (PLAN §7.4): the controls disable
|
||
* IMMEDIATELY on submit; Save ADDITIONALLY disables on an EMPTY
|
||
* input (the explicit Revert is the only clear path — no
|
||
* accidental wipes). */
|
||
function setControlsLocked(locked) {
|
||
if (input) input.disabled = locked;
|
||
saveBtn.disabled = locked || input.value === "";
|
||
cancelBtn.disabled = locked;
|
||
revertBtn.disabled = locked;
|
||
}
|
||
|
||
/* Back to the display state: the badge row + the Edit button (the
|
||
* task-08 shape restored) and focus back on the opener. Idempotent
|
||
* (a stale success-beat timer may call it after a Cancel — the
|
||
* re-insert is a same-position move and the box is already out).
|
||
* The last announcement stays readable a short beat because the
|
||
* caller defers the call (the phase-57 "confirmation stays
|
||
* readable" precedent). */
|
||
function closeEditor() {
|
||
editBtn.hidden = false;
|
||
createdBadge.insertAdjacentElement("afterend", editBtn);
|
||
box.remove();
|
||
editBtn.focus();
|
||
}
|
||
|
||
async function saveDate(dateValue) {
|
||
setControlsLocked(true); // one PATCH at a time (never stale)
|
||
errorLine.textContent = "";
|
||
try {
|
||
const r = await fetch("/api/documents/date", {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
source: doc.source,
|
||
path: doc.path,
|
||
date: dateValue,
|
||
}),
|
||
});
|
||
if (!r.ok) {
|
||
// The server detail into the role=alert line (the canned
|
||
// retry copy on a non-JSON body); the input reverts to the
|
||
// stored date; the controls re-enable in the finally. The
|
||
// editor stays open — the user's attempt is visible.
|
||
errorLine.textContent = await apiDetail(
|
||
r,
|
||
"Couldn't save the date — try again.",
|
||
);
|
||
input.value = storedValue();
|
||
return;
|
||
}
|
||
const res = await r.json();
|
||
// Response-driven: the badge shows exactly what the server
|
||
// stored (never the input's optimistic value), and the doc
|
||
// object syncs so a re-open prefills the CURRENT date.
|
||
doc.created_at = res.created_at;
|
||
doc.created_at_manual = res.created_at_manual;
|
||
createdBadge.textContent = `Created ${fmtDate(res.created_at)}`;
|
||
createdBadge.setAttribute("title", res.created_at);
|
||
// The confirmation lands AFTER the badge update (the phase-89
|
||
// last-announce order), then the row collapses back to the
|
||
// task-08 shape a short beat later (the announcement stays
|
||
// readable — the status line leaves with the box).
|
||
status.textContent =
|
||
dateValue === null
|
||
? "Reverted to sync-managed date."
|
||
: `Date saved for ${doc.source}/${doc.path}.`;
|
||
setTimeout(closeEditor, 2000);
|
||
} catch {
|
||
// Network failure: the canned retry copy (the phase-55
|
||
// neutral shape), the input reverts to the stored date, the
|
||
// editor stays open.
|
||
errorLine.textContent = "Couldn't save the date — try again.";
|
||
input.value = storedValue();
|
||
} finally {
|
||
setControlsLocked(false);
|
||
}
|
||
}
|
||
|
||
function openEditor() {
|
||
input = document.createElement("input");
|
||
input.type = "date";
|
||
input.className = "doc-date-input";
|
||
input.setAttribute("aria-label", "Document creation date");
|
||
input.value = storedValue(); // value, never innerHTML
|
||
input.addEventListener("input", () => {
|
||
// An EMPTY input can't clear: Save disables (the explicit
|
||
// Revert below is the only clear path — no accidental wipes).
|
||
// The locked state owns the controls while a PATCH is in
|
||
// flight (the input is disabled then, so this can't race it).
|
||
if (!input.disabled) saveBtn.disabled = input.value === "";
|
||
});
|
||
status.textContent = "";
|
||
errorLine.textContent = "";
|
||
box.replaceChildren(input, saveBtn, cancelBtn, revertBtn, status, errorLine);
|
||
editBtn.hidden = true;
|
||
createdBadge.insertAdjacentElement("afterend", box);
|
||
setControlsLocked(false);
|
||
input.focus();
|
||
}
|
||
|
||
editBtn.addEventListener("click", openEditor);
|
||
saveBtn.addEventListener("click", () => {
|
||
// Save is only ever enabled with a NON-empty input (the empty
|
||
// state disables it) — the clear path is the explicit Revert.
|
||
void saveDate(input.value);
|
||
});
|
||
cancelBtn.addEventListener("click", () => {
|
||
// Cancel: back to the display state, no PATCH (the stored value
|
||
// is untouched — the badge was never mutated).
|
||
status.textContent = "";
|
||
errorLine.textContent = "";
|
||
closeEditor();
|
||
});
|
||
revertBtn.addEventListener("click", () => {
|
||
void saveDate(null); // the D7 CLEAR: { source, path, date: null }
|
||
});
|
||
}
|
||
|
||
/* ---------- /document.html page (phases 10/13/19) ----------
|
||
* Phase 26: viewer-page-specific — see the import-safety note in the
|
||
* header. The guard is #doc-title: it exists only on this page, so the
|
||
* modal module's `import { renderDocument } from "./document.js"` on
|
||
* the chat/sources pages evaluates none of the code below. */
|
||
if (document.querySelector("#doc-title")) {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const source = params.get("source") || "";
|
||
const path = params.get("path") || "";
|
||
|
||
const titleEl = document.querySelector("#doc-title");
|
||
const metaEl = document.querySelector("#doc-meta");
|
||
const contentEl = document.querySelector("#doc-content");
|
||
const notFoundEl = document.querySelector("#doc-not-found");
|
||
const mainEl = document.querySelector("#main");
|
||
const backLink = document.querySelector("#doc-back");
|
||
|
||
/* Back button (phase 13): the return target comes from the `back`
|
||
* query param, not the browser history — the dedicated page is
|
||
* reachable directly (no history to go back to). The param is honored
|
||
* only for same-origin relative URLs (starts with "/" but not "//"),
|
||
* so absolute (https://…), protocol-relative (//…), and
|
||
* pseudo-protocol (javascript:…) values are rejected; anything else
|
||
* falls back to the Sources page. The static href="/sources.html" in
|
||
* document.html remains the no-JS fallback, and with the href set the
|
||
* anchor's default click behavior IS the deterministic navigation (no
|
||
* browser-history heuristics). */
|
||
const backParam = params.get("back") || "";
|
||
const backTarget =
|
||
backParam.startsWith("/") && !backParam.startsWith("//")
|
||
? backParam
|
||
: "/sources.html";
|
||
backLink.href = backTarget;
|
||
const backLabel = backLink.querySelector("span");
|
||
if (backLabel) {
|
||
backLabel.textContent =
|
||
backTarget === "/"
|
||
? "Chat"
|
||
: backTarget === "/sources.html"
|
||
? "Sources"
|
||
: "Back";
|
||
}
|
||
|
||
/* ---------- Copy path (phase 153, task 02) ----------
|
||
* The meta row's "Copy path" control (renderDocument — the shared
|
||
* core — built it after the path code; this page wires it with
|
||
* the page-local helper — the per-page duplication house style:
|
||
* document-modal.js carries the modal's own copy, no new shared
|
||
* module). The copied string is `source + "/" + path` (ASSUMPTION
|
||
* A1 — the exact read-tool form; the same two values the meta row
|
||
* renders). navigator.clipboard.writeText in a try — a non-secure
|
||
* (http) homelab origin rejects the clipboard, so the failure path
|
||
* renders the inline fallback field (input-like, prefilled with
|
||
* the full path, selects its full content on focus — the
|
||
* .share-link-fallback pattern) + the status line "Path ready —
|
||
* copy it from the field." (the owner-locked fallback wording,
|
||
* path-adapted). Success: a transient VISIBLE status "Path
|
||
* copied." (auto-clears ~4s, the toast idiom — the node is
|
||
* aria-hidden, visual only) + the page's #doc-copy-announcer
|
||
* (role=status, the visually-hidden node added for this phase) reads
|
||
* the same text — one a11y announcer (the share pattern). The
|
||
* status/field float in a .doc-copy-pop block anchored under the
|
||
* button (the meta row must never wrap or grow the bar — the
|
||
* phase-12/34 contract — so the block is absolutely positioned in
|
||
* the header, never in the row). A failed copy NEVER clears the
|
||
* previous state into nothing (the old block is replaced in
|
||
* place); a re-click re-attempts. */
|
||
let copyStatusTimer = 0; // the pending auto-clear (the success beat)
|
||
|
||
/* The inline fallback field (the .share-link-fallback pattern,
|
||
* input variant): input-like, prefilled with the full path,
|
||
* selects its full content on focus (click or Tab, then
|
||
* Ctrl/Cmd+C). readOnly — the field is a copy surface, not an
|
||
* editor. value, never innerHTML (the path is data — the XSS
|
||
* contract, unchanged). */
|
||
function makePathFallbackField(path) {
|
||
const field = document.createElement("input");
|
||
field.type = "text";
|
||
field.readOnly = true;
|
||
field.className = "doc-path-fallback";
|
||
field.value = path;
|
||
field.setAttribute("aria-label", "Document path");
|
||
field.title = "Document path — click, then copy (Ctrl/Cmd+C)";
|
||
field.addEventListener("focus", () => field.select());
|
||
return field;
|
||
}
|
||
|
||
async function copyPathWithFallback(btn, path) {
|
||
/* A re-click re-attempts: the previous block (success or
|
||
* failure) is replaced in place — a failed copy never clears
|
||
* the previous state into nothing (the field is swapped for a
|
||
* new one, never dropped). */
|
||
document.querySelectorAll(".doc-copy-pop").forEach((el) => el.remove());
|
||
let ok = true;
|
||
try {
|
||
await navigator.clipboard.writeText(path);
|
||
} catch {
|
||
ok = false;
|
||
}
|
||
const block = document.createElement("div");
|
||
block.className = "doc-copy-pop";
|
||
const status = document.createElement("p");
|
||
status.className = "doc-copy-status";
|
||
status.setAttribute("aria-hidden", "true"); // visual only — the announcer is the role=status node
|
||
status.textContent = ok ? "Path copied." : "Path ready — copy it from the field.";
|
||
let field = null;
|
||
if (ok) {
|
||
block.appendChild(status);
|
||
} else {
|
||
field = makePathFallbackField(path);
|
||
block.append(field, status);
|
||
}
|
||
/* Anchor under the button: the block floats in the header
|
||
* (sticky = positioned), so it never wraps the meta row or
|
||
* grows the bar (the phase-12/34 contract). */
|
||
const header = document.querySelector("header.doc-header");
|
||
if (header) {
|
||
const br = btn.getBoundingClientRect();
|
||
const hr = header.getBoundingClientRect();
|
||
block.style.top = `${br.bottom - hr.top + 6}px`;
|
||
block.style.left = `${br.left - hr.left}px`;
|
||
header.appendChild(block);
|
||
}
|
||
const announcer = document.querySelector("#doc-copy-announcer");
|
||
if (announcer) announcer.textContent = status.textContent;
|
||
if (ok) {
|
||
clearTimeout(copyStatusTimer);
|
||
copyStatusTimer = setTimeout(() => {
|
||
if (block.isConnected) block.remove();
|
||
}, 4000); // auto-clear ~4s (the toast idiom)
|
||
} else if (field) {
|
||
field.focus({ preventScroll: true }); // selects the path — ready to copy
|
||
}
|
||
return ok;
|
||
}
|
||
|
||
/* renderDocument fills the page elements; the page additionally owns
|
||
* the document.title (the modal keeps the page title untouched). */
|
||
function render(doc) {
|
||
renderDocument(doc, { titleEl, metaEl, contentEl });
|
||
/* Phase 153 (task 02): wire the shared core's "Copy path"
|
||
* control with the page-local helper — the copied string is the
|
||
* same two values the meta row renders. */
|
||
const copyBtn = metaEl.querySelector(".doc-copy-path");
|
||
if (copyBtn) {
|
||
copyBtn.addEventListener(
|
||
"click",
|
||
() => void copyPathWithFallback(copyBtn, doc.source + "/" + doc.path),
|
||
);
|
||
}
|
||
document.title = `${doc.title} · ${brand()}`;
|
||
}
|
||
|
||
function showNotFound() {
|
||
titleEl.textContent = "Document not found";
|
||
titleEl.removeAttribute("title"); // no stale full-title tooltip
|
||
document.title = `Document not found · ${brand()}`;
|
||
metaEl.replaceChildren();
|
||
contentEl.replaceChildren();
|
||
notFoundEl.hidden = false;
|
||
}
|
||
|
||
/* Phase 19 (phase 34 task 03, owner confirmation 2026-08-26): the
|
||
* viewer now carries the SAME standard bar as every other page —
|
||
* nav incl. the admin-only links, the auth pair — toggled on the
|
||
* module's cached whoami (the single whoami per page; fetchIsAdmin
|
||
* is imported for docAdminReady's parity — the same cached promise
|
||
* either way).
|
||
*
|
||
* Phase 79 (task 05): the gate settles the auth FIRST — silent
|
||
* re-auth of a cached token, then the role check (on the shared
|
||
* cached whoami) — and the EXISTING boot sequence runs only on the
|
||
* SETTLED role: onAuthed (the content load) for a signed-in role
|
||
* ONLY (anonymous never fetches the content — the inline gate is
|
||
* the surface, no not-found card for an auth failure; #main is
|
||
* inert while the gate is visible — WCAG, the inert-pair contract),
|
||
* and the shared header boots in the .then AFTER the gate settles
|
||
* for EVERY role (the gate locks #main, not the header — the
|
||
* anonymous contract is byte-identical to the shell: Sign in
|
||
* offered, admin links ship hidden, the steering panel removed).
|
||
* Awaiting the gate first is what makes the header race-free: a
|
||
* silent re-auth lands before the first whoami fires, so the header
|
||
* reads the post-auth role exactly once (no stale anonymous bar for
|
||
* a returning token user, no second whoami). An admin (or a
|
||
* validly cached token user) gets onAuthed immediately — the gate
|
||
* (which ships hidden + inert) never shows. The admin-only edit
|
||
* affordance (docAdminReady() inside renderDocument) stays
|
||
* admin-only — it runs only in the post-auth render path. */
|
||
mountGate(document.getElementById("main"), () => {
|
||
load();
|
||
}).then(() => {
|
||
void initSharedHeader(); // every role — on the SETTLED whoami
|
||
});
|
||
|
||
/* The New chat binding is module-owned (assets/header.js, phase 34
|
||
* task 02 — the SINGLE binding): on this non-chat page it clears the
|
||
* phase-14 conversation key and navigates to the chat's empty state. */
|
||
|
||
async function load() {
|
||
try {
|
||
if (!source || !path) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
const r = await fetch(contentUrl(source, path));
|
||
if (!r.ok) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
render(await r.json());
|
||
} catch {
|
||
showNotFound();
|
||
} finally {
|
||
mainEl.focus(); // move focus to main on load (a11y)
|
||
}
|
||
}
|
||
|
||
}
|