phase: 148_pdf_pages_in_sources
**Phase 148 — final verification pass: complete, 100% green.**
- **Verified (tasks 01–04 already implemented):** page render/persist at index (`_persist_page_copies`, fail-soft, stale-replace, qualifier-loss, prune hook via `_delete_page_copies`); public `GET /api/documents/{id}/pages/{n}` (uuid4-credential, 404-all-non-servable, `image/png` + cache header); `DocContent.page_urls` additive-omitted; shared `renderDocument` strip (lazy imgs, "Page N" captions, text + Summary suppressed, onerror note, createElement-only); migration 0026; `pdf_page_dir` setting; `.env.example` entry
- **Completion criteria:** (1) pictorial + fallback classes get `p{N}.png` + `page_count`, text-heavy gets neither — pinned, pass; (2) unchanged re-sync renders zero (fast path) — pass; (3) changed re-index replaces copies, qualifier-loss/prune delete — pass; (4) route 404 matrix + payload key absent-never-null — pass; (5) modal AND /document.html strip with suppression, text-heavy byte-identical — E2E pass; (6) see below; (7) no completed-phase drift — full suite green
- **Tests:** `uv run pytest --cov=app --cov-report=term-missing` → **3461 passed, 0 failed**, `app/` coverage **99%** (>90%); `uv run pytest tests/e2e/test_pdf_pages_in_sources.py -v --no-cov` → **4 passed** in isolation; `uv run ruff check .` clean; `uv run pyright` → 0 errors, 0 warnings
- **Documentation:** README had **1 pre-existing inconsistency** — the Production Deployment section's standalone `podman run` example (and compose note) omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the fail-loud boot gate the README's own Admin section documents — the container would refuse to start); fixed with the two `-e` flags + a note for the compose path. All else verified consistent: 70/70 `.env.example` vars documented, all defaults match `config.py`, pages table matches routes, Containerfile/esbuild/uid-10001/healthcheck/entrypoint claims accurate, QA commands match pytest config. **AGENTS.md: verified consistent — no changes needed**
- **Notable:** repo uses `Containerfile` (Podman) — README's `podman build` example is valid as-is; no live infrastructure touched
- **Next pending phase:** `149_sync_progress_box`
This commit is contained in:
@@ -36,8 +36,10 @@
|
||||
*
|
||||
* XSS-safe by construction: markdown is escaped before transform, raw
|
||||
* formats are set via textContent, and every document-derived string
|
||||
* (title, badges, path) is written with textContent — never innerHTML
|
||||
* (innerHTML goes through renderMarkdown, which escapes first).
|
||||
* (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,
|
||||
@@ -134,6 +136,29 @@
|
||||
* 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: 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).
|
||||
*/
|
||||
|
||||
import { bindSharedHeaderControls, fetchIsAdmin, initSharedHeader } from "./header.js";
|
||||
@@ -257,6 +282,24 @@ export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
||||
});
|
||||
|
||||
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: the
|
||||
// markdown/.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;
|
||||
if (pageUrls && pageUrls.length > 0) {
|
||||
contentEl.appendChild(docPagesStrip(doc, pageUrls));
|
||||
return;
|
||||
}
|
||||
// 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
|
||||
@@ -343,6 +386,56 @@ function docImageBlock(doc) {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user