feat(cards): say what the linked material is — type, size, and PDF page count (#57) #75

Merged
laoc merged 2 commits from fix/issue-57-linked-materials-hover into dev 2026-07-31 10:12:12 +00:00
Owner

Closes #57 slices 1 and 2. Slices 3 and 4 are deliberately out of scope and need a product call — see the bottom.

The hover badge over a resource cover read 📎 1 Materialien verlinkt — a count, with the wrong plural. It now reads 📎 PDF · 12 Seiten · 2,4 MB.

Slice 1 — 2d5eb5e3

Type and size from data already on the event (encoding:encodingFormat, encoding:contentSize). No format change, no migration, no network call. Fixes the 1 Materialien plural while in there.

One judgement worth flagging: materialTypeFromMime returns null, not 'file', for application/octet-stream — the publish path defaults to octet-stream when the uploader did not know, so treating it as a known type would have every unlabelled upload claim to be a binary.

Slice 2 — 88776df9

AMB's encoding:* has no page count, so it has to be read from the file. Nearly free in the common case: /api/pdf-thumbnail already parses the document to render the cover, so renderPdfThumbnail now returns numPages alongside the WebP and the endpoint writes a pages.json sidecar. /api/pdf-info reads it. A card whose cover rendered costs no second fetch and no second parse; the fetch-and-parse path is only for files no thumbnail was ever rendered for, and it skips canvas and sharp entirely.

Guardrails are shared, not reimplemented. $lib/server/pdfSource.js now owns the fetch, size cap, redirect re-validation, content-type check and cache paths for both endpoints. That is the mistake #31 fixed in /api/reader — a second private copy of the private-IP check drifted from the first, and the weaker copy is what shipped.

Rights model reused deliberately. Reading a page count means fetching the file, which is the same act canDeriveThumbnail exists to authorise, so the card gates on it first. Attached files only — an r link carries no attestation, so an external PDF gets no page count.

Fetched on first pointerenter, not on render: the badge is hover-only and a feed must not fire a request per card just to sit there. Memoised per URL including failures — a card must not retry a 404 on every hover.

The cover div is given no ARIA role on purpose. It is not interactive; the handler is a cache warm-up and the badge renders type and size without it, so a user who never produces a pointer event loses nothing that group-hover was not already hiding.

Verification

Four full-suite runs at 88776df9, clean tree, on dev e0aa0aba:

run result
1 477 files / 5279 tests pass
2 3 failed / 2 files — incl. gift-wrap-publish.test.js (#70)
3 477 files / 5279 tests pass
4 7 files failed — 3 in #70's inbox family, 4 collect-errors from a paraglide regeneration race (Failed to resolve import "../runtime.js")

Not one FAIL line in any run names a file this branch touches, and the new tests passed in all four. The failures are #70's known DM/inbox family plus a generated-output race under parallel load. Stating the spread rather than quoting the best run, because one reading of a flaky suite is not a baseline.

lint clean    pnpm check 0 errors (4 warnings, all pre-existing fixtures)

New tests confirmed load-bearing by reverting the code they cover:

sidecar validation weakened to `if (cached)`  -> 1 fail (serves numPages: 0)
failures no longer memoised                   -> 1 fail (retries the 404)

The cross-endpoint claim is pinned too: render the cover, then ask for the count, assert exactly one upstream fetch. If the two endpoints ever disagree on the cache key that test fails rather than silently doubling third-party traffic.

Known risk, stated rather than discovered later

src/routes/api/pdf-info/__tests__/server.test.js does a real pdf.js parse, and its cross-endpoint case does a real canvas + sharp render. pdf-thumbnail/__tests__/server.test.js is already named in #70 as joining the timeout-prone set under heavier load, so this new file is a plausible future member of it. It did not fail in any of the four runs above.

Deliberately not in this PR

  • External r links — a bare URL with no type. Extension guess vs an HTTP HEAD per card. The HEAD means an outbound request to a third party on every feed render, which is a privacy and rights question, not a perf one. Recommendation: extension guess, no network call.
  • PPTX slide counts — no PPTX handling exists anywhere in the app; it means a new server dependency to unzip OOXML. Recommendation: say "PowerPoint" and stop.
  • Several materials at once — a per-item list does not fit in a badge, so it falls back to the count. The resource page already lists them.
Closes #57 slices 1 and 2. Slices 3 and 4 are deliberately out of scope and need a product call — see the bottom. The hover badge over a resource cover read `📎 1 Materialien verlinkt` — a count, with the wrong plural. It now reads `📎 PDF · 12 Seiten · 2,4 MB`. ## Slice 1 — `2d5eb5e3` Type and size from data **already on the event** (`encoding:encodingFormat`, `encoding:contentSize`). No format change, no migration, no network call. Fixes the `1 Materialien` plural while in there. One judgement worth flagging: `materialTypeFromMime` returns `null`, not `'file'`, for `application/octet-stream` — the publish path defaults to octet-stream when the uploader did not know, so treating it as a known type would have every unlabelled upload claim to be a binary. ## Slice 2 — `88776df9` AMB's `encoding:*` has no page count, so it has to be read from the file. Nearly free in the common case: `/api/pdf-thumbnail` already parses the document to render the cover, so `renderPdfThumbnail` now returns `numPages` alongside the WebP and the endpoint writes a `pages.json` sidecar. `/api/pdf-info` reads it. A card whose cover rendered costs **no second fetch and no second parse**; the fetch-and-parse path is only for files no thumbnail was ever rendered for, and it skips canvas and sharp entirely. **Guardrails are shared, not reimplemented.** `$lib/server/pdfSource.js` now owns the fetch, size cap, redirect re-validation, content-type check and cache paths for both endpoints. That is the mistake #31 fixed in `/api/reader` — a second private copy of the private-IP check drifted from the first, and the weaker copy is what shipped. **Rights model reused deliberately.** Reading a page count means fetching the file, which is the same act `canDeriveThumbnail` exists to authorise, so the card gates on it first. Attached files only — an `r` link carries no attestation, so an external PDF gets no page count. Fetched on first `pointerenter`, not on render: the badge is hover-only and a feed must not fire a request per card just to sit there. Memoised per URL **including failures** — a card must not retry a 404 on every hover. The cover div is given no ARIA role on purpose. It is not interactive; the handler is a cache warm-up and the badge renders type and size without it, so a user who never produces a pointer event loses nothing that `group-hover` was not already hiding. ## Verification Four full-suite runs at `88776df9`, clean tree, on `dev` `e0aa0aba`: | run | result | |---|---| | 1 | `477 files / 5279 tests pass` | | 2 | 3 failed / 2 files — incl. `gift-wrap-publish.test.js` (#70) | | 3 | `477 files / 5279 tests pass` | | 4 | 7 files failed — 3 in #70's inbox family, 4 collect-errors from a paraglide regeneration race (`Failed to resolve import "../runtime.js"`) | **Not one FAIL line in any run names a file this branch touches**, and the new tests passed in all four. The failures are #70's known DM/inbox family plus a generated-output race under parallel load. Stating the spread rather than quoting the best run, because one reading of a flaky suite is not a baseline. ``` lint clean pnpm check 0 errors (4 warnings, all pre-existing fixtures) ``` New tests confirmed load-bearing by reverting the code they cover: ``` sidecar validation weakened to `if (cached)` -> 1 fail (serves numPages: 0) failures no longer memoised -> 1 fail (retries the 404) ``` The cross-endpoint claim is pinned too: render the cover, then ask for the count, assert **exactly one** upstream fetch. If the two endpoints ever disagree on the cache key that test fails rather than silently doubling third-party traffic. ## Known risk, stated rather than discovered later `src/routes/api/pdf-info/__tests__/server.test.js` does a real pdf.js parse, and its cross-endpoint case does a real canvas + sharp render. `pdf-thumbnail/__tests__/server.test.js` is already named in #70 as joining the timeout-prone set under heavier load, so this new file is a plausible future member of it. It did not fail in any of the four runs above. ## Deliberately not in this PR - **External `r` links** — a bare URL with no type. Extension guess vs an HTTP `HEAD` per card. The `HEAD` means an outbound request to a third party on every feed render, which is a privacy and rights question, not a perf one. Recommendation: extension guess, no network call. - **PPTX slide counts** — no PPTX handling exists anywhere in the app; it means a new server dependency to unzip OOXML. Recommendation: say "PowerPoint" and stop. - **Several materials at once** — a per-item list does not fit in a badge, so it falls back to the count. The resource page already lists them.
The hover badge over a resource cover read "📎 1 Materialien verlinkt" — a
count, with the wrong plural. It now reads "📎 PDF · 2,4 MB" for a single
material, from data already on the event: `encoding:encodingFormat` and
`encoding:contentSize`. No format change, no migration, no network call.

New pure helper `linkedMaterials.js`:

- `materialTypeFromMime` maps mime → material type. It returns null, not
  'file', for the unknown *and* for `application/octet-stream`, because the
  publish path defaults to octet-stream when the uploader did not know — so
  treating it as a known type would have every unlabelled upload claim to be
  a binary.
- `materialTypeFromFilename` is the fallback, matching the mime-first,
  extension-second convention in EncodingPreview.svelte and
  pdfThumbnailGate.js. It strips the query and fragment first: `?file=x.pdf`
  is not evidence about the resource being fetched.
- `formatMaterialSize` treats 0 as unknown rather than as an empty file,
  because 0 is exactly what a missing `encoding:contentSize` parses to.

The positional-pairing hazard the publish path documents is handled rather
than inherited. `encoding:contentUrl`, `:encodingFormat` and `:contentSize`
are three lists aligned by *position*, and the optional two are emitted only
when known — so two files with one format have no recoverable mapping. When
the lists do not line up, the ambiguous list is dropped entirely and the type
comes from the URL extension, which is per-item and cannot be mis-attributed.
A single file is never ambiguous.

Scope, deliberately: several materials still fall back to the count. A
per-item list does not fit in a badge, and a type breakdown ("2 PDFs, 1
image") needs a plural form per type per language — see below. The resource
page already lists them.

No ICU plurals in this project, and not for want of trying: paraglide 2.16
with plugin-message-format v4 flattens a `match` object into *separate*
messages (`key.match.count=other`) with no runtime selector, and the
declarations/selectors/variants shape fails to compile outright. The existing
workaround — a `{plural}` parameter the caller fills in — cannot serve two
languages from one placeholder, and does not: CalendarView passes 's' into a
German string that wants 'en'. So the singular is its own key.

Tests: 23 for the helper, 8 for the rendered badge. The badge ones assert
rendered text, not the helper, because a correct helper behind a badge that
shows something else is precisely how the plural bug survived; one of them
pins the exact reported wording as forbidden. Negative control: reverting the
badge to the count-only expression fails 5 of the 8.

Suite 5211/5211, 469 files (dev: 5180/5180, 468) — +31, exactly the new
tests. Lint clean, `pnpm check` 0 errors.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Slice 2 of #57. The badge now reads "PDF · 12 Seiten · 2,4 MB" where slice 1
could only say "PDF · 2,4 MB". Type and size come off the event; AMB's
`encoding:*` carries no page count, so it has to be read from the file.

Cheap in the common case. `/api/pdf-thumbnail` already parses the document to
render the cover, so `renderPdfThumbnail` now returns `numPages` alongside the
WebP and the endpoint writes it to a `pages.json` sidecar. A card whose cover
rendered answers from disk — no second fetch, no second parse. The
fetch-and-parse path in `/api/pdf-info` is only for files no thumbnail was
ever rendered for, and it skips canvas and sharp entirely.

Guardrails are SHARED, not reimplemented — `$lib/server/pdfSource.js` now owns
the fetch, the size cap, the redirect re-validation, the content-type check
and the cache paths for both endpoints. That is the mistake #31 fixed in
`/api/reader`: a second private copy of the private-IP check drifted from the
first, and the weaker copy is what shipped. One implementation.

Rights model is unchanged and deliberately reused: reading a page count means
fetching the file, which is the same act `canDeriveThumbnail` exists to
authorise, so the card gates on it before asking. Attached files only — an `r`
link carries no attestation, so an external PDF gets no page count.

Fetched on first pointerenter rather than on render: the badge is hover-only,
and a feed must not fire a request per card just to sit there. Memoised per
URL, including failures — a card must not retry a 404 on every hover.

The cover div is deliberately given no ARIA role. It is not interactive; the
handler is a cache warm-up and the badge renders type and size without it, so
a user who never produces a pointer event loses nothing that `group-hover`
was not already hiding.

Verification at a clean tree on dev e0aa0aba:

  477 files / 5279 tests pass (+48)
  lint clean, pnpm check 0 errors (4 warnings, all pre-existing fixtures)

New tests confirmed load-bearing by reverting the code they cover:

  sidecar validation weakened to `if (cached)`  -> 1 fail (serves numPages: 0)
  failures no longer memoised                   -> 1 fail (retries the 404)

The cross-endpoint claim is pinned too: render the cover, then ask for the
count, and assert exactly ONE upstream fetch — if the two endpoints ever
disagree on the cache key, that test fails rather than silently doubling the
traffic.

Still open on #57, deliberately not in this slice: external `r` links (a
policy call — extension guess vs an HTTP HEAD per card), PPTX slide counts (a
new server dependency), and what the badge shows for several items.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
laoc merged commit 65a16f1b9e into dev 2026-07-31 10:12:12 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
edufeed/edufeed-app!75
No description provided.