The correspondence timeline labels each row with its distance from today
("vor 86 Jahren"). Uses calendar-field math so the anniversary day
flips exactly — an ms-based 365.25d average misses by a day on leap
years. Invalid / future dates return "" so the caller can hide the
label rather than print "vor 0 Jahren".
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Drops the inline bilateral-distribution markup and the short-name /
percentage helpers that only existed to feed it. ConversationTimeline
now hands senderName, receiverName, and the two counts to the shared
component and lets it own the rendering.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lifts the inline distribution bar out of ConversationTimeline so the
same two-tone ratio widget can be reused on other bilateral surfaces
(e.g. the person detail page). Markup/styling is byte-identical to
the inline version; only the prop interface is new.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the backend entity additions so the frontend row components
can consume the aspect (portrait vs landscape tile) and the page count
(badge on the thumbnail) without any runtime guessing.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Groups the first-page BufferedImage and the source's total page count
into a SourcePreview record so both values travel through generate()
together. PDFs get pdf.getNumberOfPages(); image uploads always get 1
(a scan is one page from the user's perspective). The page badge on
the thumbnail row uses this value to show "1 / N" for multi-page
letters without a separate round-trip.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Computes aspect at generate-time from the loaded BufferedImage: w/h
above 1.1 → LANDSCAPE, otherwise PORTRAIT. The threshold keeps
near-square A4 scans in the portrait tile (ratio ≈ 1.0) rather than
flipping to landscape on a rounding error. Also hardens the pipeline
with an explicit dimension guard so width=0 / height=0 edge cases fail
cleanly instead of dividing by zero when the aspect is computed.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Both cases already return FAILED via the existing catch-Exception blocks
in readSourceImage. Pinning the behavior with regression tests before
thumbnailAspect and pageCount computation is added, so a future
refactor that removes the safety net is caught at compile/test time.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds ThumbnailAspect enum (PORTRAIT | LANDSCAPE) and maps the two
nullable columns from V53 as JPA fields so ThumbnailService can
populate them and the API can return them unchanged to the frontend.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two nullable metadata columns to documents, populated by
ThumbnailService when it generates the JPEG preview. Both remain null
until the existing admin backfill endpoint reruns the service.
Refs #305
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The archive has ~4 persons over 100 letters and ~90% with five or
fewer — the original spec's 851-letter default fit no one.
Redesign introduces three tiers gated on letterCount (Compact ≤ 5,
Standard 6–49, Rich ≥ 50) sharing one dashboard block: navy header +
4-cell stats strip at every non-Empty tier, with Standard appending
direction bar + top correspondents and Rich further appending
histogram + top locations + tag cloud. Backend skips expensive
aggregations for non-Rich persons; histogram and tag cloud ship
lazy-loaded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
extract_page_blocks() walked `record.boundary` and `record.baseline`
unconditionally, so a record that arrived without either (malformed
kraken output, or a MagicMock in tests that iterates to nothing)
crashed with "min() arg is an empty sequence".
Coerce both attributes through list(), require at least 3 points for
the polygon path, fall back to the baseline path when the polygon is
missing, and skip the record entirely when neither is usable —
emitting no block is safer than emitting one with garbage coordinates.
The test helper now sets `boundary` and `baseline` explicitly to
mirror real Kraken 7.0 records (and so the happy-path test exercises
the polygon branch). A new regression test covers the skip path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
main.py unifies the call to both engines and always passes
`sender_model_path` (None for non-Kurrent scripts). Surya's
extract_region_text / extract_page_blocks accepted one fewer positional
arg than Kraken's, so every guided-OCR run on a TYPEWRITER or
HANDWRITING_LATIN document raised "takes 5 positional arguments but 6
were given" and the stream returned 0 blocks / 1 skipped page.
Add an ignored `sender_model_path` kwarg to both Surya functions so the
signatures match Kraken's, and guard the regression with two signature
tests in test_engines.py that compare both engines' parameter lists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 12px text felt cramped next to the larger 120×168 thumbnail. Lift
the date / VON / AN / progress label to 14px so the row reads
comfortably without changing the width or the row height.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Nesting the tag <button> inside the row's <a href="…"> made the browser
treat any click on the button as a click on the anchor, sending the
user to the document detail page even though the tag handler called
goto() with the tag-filter URL. e.stopPropagation() doesn't cancel
the anchor's default navigation.
Refactor to the stretched-link pattern: the row-wide anchor sits as an
overlay (`absolute inset-0 z-0`) and the content wrapper sits above it
(`relative z-10` + `pointer-events-none`). Tag buttons re-enable
pointer events with `pointer-events-auto`, so they're true siblings of
the anchor and receive their own clicks. Empty content areas pass
through to the anchor for whole-row navigation.
The vitest-browser client project doesn't load Tailwind CSS, so the
z-index has no effect there and Playwright's coordinate-based click
hits the anchor instead of the button. Trigger the click directly on
the button DOM element in the unit test (with a comment explaining the
test-env constraint); the actual user-facing behavior is verified via
playwright against the running dev server.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Refill the columns that went visually empty after the previous dedup
commit (`fc0fc57`):
- Middle column gains the document `summary` (line-clamp-2, italic,
with `summaryOffsets` highlighting — the backend already populates
the offsets, the frontend just wasn't rendering them) and a row of
thin neutral chips for `archiveBox`, `archiveFolder`, and `location`
(~99% of docs in the corpus carry these). Chips are desktop-only
and skip empty values.
- Right column restores `VON sender` and `AN receivers`, now with
`<mark>` highlighting that the previous right-column copy lacked,
so search matches stay visible there.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The desktop document-list row showed sender/receiver twice — once
side-by-side in the middle column and again stacked in the right
column. Stack the middle-column block vertically (the side-by-side
grid wasted horizontal space and competed with the larger thumbnail)
and remove the now-redundant copy from the right column.
The middle-column block keeps the search-match highlighting, which the
right-column copy never had.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Add a `size` prop to DocumentThumbnail (default `sm` keeps the existing
60×84 tile used in person sublists; new `lg` is 120×168) and use `lg`
for the main document-list row, where the previous tile occupied less
than half of the row's vertical space.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Chunked requests omit Content-Length entirely. The previous guard
only checked the header and was bypassed. Now the body is buffered
first and its byteLength is checked, catching both cases.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Closes the two untested code paths flagged in review:
- PATCH method routes to backend with correct URL
- Requests without Content-Length header pass through (NaN > n = false)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The sendBeacon name was misleading after switching to keepalive fetch.
Also adds a test to confirm flush is a no-op when pendingTexts is empty.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Blocks requests with Content-Length > 1 048 576 bytes with 413.
Tests cover security guards, body limit, and response forwarding.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
sendBeacon always sends POST, but the backend expects PUT for block updates, so
saves were silently dropped on page unload. Replace with fetch({ keepalive: true,
method: 'PUT' }) which survives navigation and uses the correct HTTP method.
Add a catch-all SvelteKit server route at /api/[...path] so all client-side API
calls work in production (without the Vite dev proxy). More-specific routes
(/api/persons, /api/tags, /api/documents/[id]/file) keep precedence.
Closes#204
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Addresses @leonievoss and @felixbrandt — fix(ui): "the PDF icon
misleads for image documents" and "swap for a neutral file icon".
The fallback now shows a generic document-text glyph (page outline +
three text lines) instead of the PDF-specific icon with the folded
corner. Applies equally well to PDFs, JPEG/PNG scans, and TIFF
documents — all of which can land in the fallback path.
Also bumped the icon from h-6/w-6 to h-8/w-8 — the previous 24px
glyph looked sparse inside the 60×84 tile (Leonie, post-merge
iteration point #2).
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses @felixbrandt — fix(backend): "the two try blocks in generate()
overlap — a save failure logs 'generation failed' even though the
thumbnail is already in S3 as an orphan".
generate() now orchestrates four stages, each in its own try+log:
readSourceImage / encodeThumbnail / uploadToStorage / persistThumbnailMetadata
persistThumbnailMetadata emits the distinct "orphaned in storage as <key>"
log line so an operator can see database-side failures after the upload
completed. The deterministic key ensures the next run overwrites cleanly,
so the orphan is self-healing.
Also extracts THUMBNAIL_KEY_PREFIX/SUFFIX constants with a comment
explaining the deterministic-overwrite contract.
Adds test: generate_returnsFailed_whenPersistThrows_butUploadSucceeded.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Addresses @mkeller (Markus) — fixes(adr): "the ADR doesn't mention
in-memory BackfillStatus" and "treat this as a layering exception,
acknowledge it explicitly". Two new paragraphs under Operational caveats.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Captures why thumbnails render in-process rather than being delegated
to ocr-service. Prevents a future reviewer from rehashing the decision
or moving it to the Python side without knowing the trade-offs.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- admin.spec: click 'Thumbnails erzeugen', wait for status DONE
within 30s, screenshot the success message
- accessibility.spec: /admin/system joins the page list so the
thumbnail card is checked in light, system-dark, and manual-dark
axe-core runs
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Fourth card on /admin/system mirrors the mass-import pattern:
- POST /api/admin/generate-thumbnails to trigger
- 2000 ms polling on /api/admin/thumbnail-status while RUNNING
- processed / skipped / failed counters in the DONE message
- standalone pollInterval so import and thumbnail polling don't
interfere with each other
Paraglide keys added in de/en/es, mirroring admin_system_import_*.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Home search rows and person detail sidebars now show the real
first-page preview when one exists, falling back to the PDF icon
for documents the backfill hasn't processed yet. The old `variant`
prop on PersonDocumentList is removed — it tinted the icon
differently for sent vs received, which no longer applies with a
uniform thumbnail tile.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Renders the document thumbnail with object-cover + object-top so
letter salutations stay visible, empty alt (title nearby is the
accessible name), loading=lazy, decoding=async, and dark:mix-blend-multiply
for dark mode. Falls back to a PDF icon when thumbnailKey is null —
legacy documents, unsupported content types, or transient failures
all land here.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Pure function returning /api/documents/{id}/thumbnail?v=<timestamp>
or null when thumbnailKey is missing. The encoded timestamp changes
whenever the backend regenerates a thumbnail (file replace),
invalidating browser caches despite the immutable Cache-Control.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the backend Document entity's new optional fields. Both are
optional (no @Schema requiredMode on the backend side), so legacy
documents without thumbnails stay valid.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spins up a MinIO container (Testcontainers GenericContainer) alongside
the existing PostgresContainerConfig, uploads a sample PDF, runs the
real ThumbnailService, and reads the resulting JPEG back from the
object store. Catches S3 signing / path-style access issues a mocked
S3Client wouldn't — justifies the CI cost (~45s) per walkthrough T9b.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Streams the JPEG thumbnail from S3 with Cache-Control: private,
max-age=31536000, immutable — `private` (not `public`) prevents
shared caches from leaking one user's thumbnail to another (CWE-525).
`immutable` is safe because the URL carries ?v=<thumbnailGeneratedAt>
as a cache-buster that changes whenever the file is replaced.
Authentication falls back to the global .anyRequest().authenticated()
rule, matching the existing /file endpoint's permission model.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- POST /api/admin/generate-thumbnails → triggers async backfill, 202
- GET /api/admin/thumbnail-status → returns current BackfillStatus
Both gated by the class-level @RequirePermission(Permission.ADMIN).
Shape and polling semantics mirror the mass-import endpoints.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Sequentially processes all documents with a file but no thumbnail and
tallies processed / skipped / failed counts. Runs on thumbnailExecutor
so it shares back-pressure with live upload thumbnails but can never
saturate them (single-threaded loop).
Concurrent start rejected with THUMBNAIL_BACKFILL_ALREADY_RUNNING.
Emits a structured summary log line on completion for operator
visibility.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ODS/Excel imports that actually upload a file (file.isPresent()) now
trigger thumbnail generation alongside hash/metadata. Metadata-only
import rows produce no thumbnail — nothing to render.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
All four upload code paths (storeDocument, createDocument, updateDocument,
attachFile) now call thumbnailAsyncRunner.dispatchAfterCommit(id) after
the document save. createDocument and updateDocument only dispatch when a
file was actually provided/replaced.
The dispatch is afterCommit-safe: if the surrounding @Transactional
method rolls back, no thumbnail is generated for a document that never
reached the DB.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bridges @Transactional upload paths to the async thumbnail pipeline.
dispatchAfterCommit registers a TransactionSynchronization so the async
task only fires after the surrounding commit (and is silently skipped
on rollback) — mirrors the AuditService.logAfterCommit pattern.
generateAsync wraps the full ThumbnailService.generate call in a 30s
watchdog so a hung PDFBox render cannot occupy a thumbnailExecutor slot
indefinitely.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Renders a 240px-wide JPEG (quality 85) from either a PDF first page
via PDFBox or a JPEG/PNG/TIFF scan via ImageIO, then uploads to
S3 under thumbnails/{docId}.jpg and updates the Document entity.
Scaling uses Graphics2D.drawImage with VALUE_INTERPOLATION_BILINEAR
(not deprecated Image.getScaledInstance). Source is streamed via
FileService.downloadFileStream to avoid buffering 50MB PDFs.
Never throws — returns Outcome.SKIPPED for unsupported content types
and Outcome.FAILED for rendering/upload errors so the backfill can
tally them without aborting the run.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Dedicated thread pool (core=1, max=2, queue=200) with CallerRunsPolicy
for back-pressure. Keeps thumbnail rendering off the shared taskExecutor
used by OCR and out of the AbortPolicy queue that drops work on overflow.
Quick-upload batches (15+ files) now apply back-pressure instead of
silently dropping thumbnail jobs.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Thumbnail generation will call this for PDFs up to 50 MB — loading the
full byte[] via downloadFileBytes would cause real memory pressure on
the single-VPS deploy. Stream-based reads let PDFBox parse the first
page without holding the whole file in heap.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
JDK ImageIO handles JPEG, PNG, BMP, GIF out of the box but not TIFF.
Since the document upload allowlist permits image/tiff, the thumbnail
generator must also decode it.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Mirrors the IMPORT_ALREADY_RUNNING pattern for the concurrent-start
guard in ThumbnailBackfillService.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds findByFilePathIsNotNullAndThumbnailKeyIsNull() used by the
upcoming ThumbnailBackfillService to locate documents that have a
file attached but no thumbnail yet.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds two nullable columns to the documents table and their JPA mappings
on the Document entity. Both are left out of the OpenAPI required-mode
schema so the generated TypeScript type exposes them as optional.
Refs #307
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two production-ready specs following the chronik-spec format
(scaled wireframes × 3 viewports + impl-ref tables with exact Tailwind
classes and pixel values + WCAG contrast verification):
- briefwechsel-thumbnail-rows-spec.html — /briefwechsel row redesign
with PDF thumbnail, summary-as-quote, bilateral distribution bar;
drops status lifecycle and script-type indicators.
- person-dashboard-spec.html — new Korrespondenz-Überblick block on
/persons/[id] with stats, activity histogram, direction split, top
correspondents/locations, tag cloud. Every tile deep-links to
/briefwechsel with filters.
Both specs share the DistributionBar.svelte component.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brainstorming artifact: 5 HTML mockups comparing approaches to fill the
sparse right-hand space on /briefwechsel rows (reported by users as
"feels empty"):
1. Rich Rows — dense metadata, no images
2. Thumbnail Rows — PDF preview on the left
3. Master-Detail Split — list + persistent preview panel
4. Gallery Cards — grid of letter cards, album style
5. Person Dashboard — insights live on /persons/[id], not here
Picked: #2 (Thumbnail Rows) + #5 (Person Dashboard), followed up by
final specs in separate commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>