Skip to content

Save PDFs as searchable documents (upload, paste, or link) - #611

Merged
fredrivett merged 15 commits into
mainfrom
fredrivett/pdf-upload-add
Oct 8, 2026
Merged

fredrivett merged 15 commits into
mainfrom
fredrivett/pdf-upload-add

Conversation

@fredrivett

@fredrivett fredrivett commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

What & why

PDFs become searchable document items, reusing the scanned-document pipeline. You can upload one from the file picker, by drag and drop, or by pasting it onto the dashboard (25MB cap), or save a link that points to one. A new import-pdf Trigger.dev task renders each page with mupdf (WebAssembly, AGPL) and uses the page's embedded text where it has some. analyze-document then only OCRs the scanned pages, at most 30 per document, each counted against the daily ingestion allowance. Titling, cover analysis, search text and enrichment work as they do for scans, and the original PDF is kept in the new Item.sourceFileKey for Download PDF. A damaged, password-protected or 1,000+ page PDF fails with a clear reason and isn't retried.

Checklist

  • User-facing change → added/updated a PostHog event (and a funnel/insight for a new flow), or N/A — pdf_uploaded, pdf_upload_rejected, pdf_imported, pdf_import_failed; the funnel insight is still to build once events arrive
  • User-facing or service change → docs updated (README features/roadmap/services, .env.example, in-app help, CONTRIBUTING.md, onboarding checklist), or N/A — README, .env.example and a new Saving PDFs help page; no new milestone, since milestones are a DB enum and that would need another migration
  • New error paths report via captureServerException / the error boundary, or N/A
  • New behavior is covered by tests, or N/A — unit (validation, text-layer heuristic, real mupdf rendering, import task, URL handler/routing, route, retry, hook, viewer), integration on Postgres (upload → import → search → delete, URL PDF re-capture with no storage leak) and E2E (picker, paste, size cap)
  • If this fixes a recurring defect, considered a guardrail via the learn skill, or N/A — N/A

Notes

  • Migration 20260929204337_add_pdf_documents: adds items.source_file_key, plus the file_unreadable and document_too_long failure reasons.
  • New dependency mupdf: loaded on demand inside import-pdf only, and marked external in trigger.config.ts because it loads its WebAssembly from beside its own module. A dry-run Trigger.dev deploy build succeeds. import-pdf gets its own document-import queue so long PDFs don't hold image-analysis slots, and retries on a small-2x machine if it runs out of memory.
  • Re-capturing a URL-saved PDF replaces it whole: reclaimReplacedStorage now also deletes the old pages and their files.
  • Also fixes the document pager being invisible in light mode (it was hard-coded white on the theme background).
  • Before deploying: check that the production items bucket's allowed MIME types include application/pdf. The bucket migration sets it, but it's ON CONFLICT DO NOTHING.

🤖 Generated with Claude Code


Summary by cubic

PDFs can now be saved as searchable document items — uploaded from the file picker, by drag and drop, or pasting onto the dashboard (25MB cap), or via a URL that serves a PDF. The original file is kept so it can be re-downloaded, and Download PDF works even before the pages exist.

A new import-pdf Trigger.dev task renders each page with mupdf (WebAssembly) and uses the page's embedded text layer where one exists; analyze-document only OCRs the scanned pages, at most 30 per document, each charged once per PDF against the daily ingestion allowance. Past the cap, pages stay viewable but unsearchable and the owner is told how many. Damaged, password-protected, or 1,000+ page PDFs fail with a clear reason and aren't retried; retrying a failed PDF re-runs the import rather than bare analysis. A saved link to a PDF is detected by content type (or .pdf path with a generic type), and the browser's own PDF viewer is recognized from the page alone — a capture of a real page keeps its markup. An import only saves its pages if the item still holds the PDF it rendered, so a re-capture can't bring back the old pages.

Notes for merge and deploy

  • Migration 20260929204337_add_pdf_documents adds items.source_file_key and the file_unreadable / document_too_long failure reasons.
  • New dependency mupdf (AGPL, WebAssembly) is marked external in trigger.config.ts and loaded on demand only inside import-pdf, which runs on its own document-import queue.
  • The OCR allowance is charged once per PDF and kept on meta.ocrPagesCharged, so a retry after a failed enqueue doesn't pay twice.
  • Re-capturing a URL-saved PDF replaces it whole: reclaimReplacedStorage now also deletes the old pages and their files, removing the stored PDF if the save fails so retries don't orphan it.
  • Before deploying, check the production items bucket's allowed MIME types include application/pdf — the bucket migration sets it, but only via ON CONFLICT DO NOTHING.
  • Search results now include source_file_key, and retrying a URL-saved PDF re-imports from its stored file instead of downloading the URL again.
  • Also fixes the document pager being invisible in light mode (it was hard-coded white on the theme background).

Written for commit daffed8. Summary will update on new commits.

Review in cubic

fredrivett and others added 8 commits September 29, 2026 21:45
Item.sourceFileKey holds the original file a document was made from (an
uploaded PDF) so it can be downloaded; fileKey stays the cover image.
Classified in the file-key inventory, so delete, reclaim and the image
proxy cover it. file_unreadable / document_too_long give damaged or
over-long PDFs a clear failure message.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A new import-pdf task renders each page with mupdf (WebAssembly, AGPL,
loaded on demand in Trigger.dev) and keeps a page's embedded text layer
as its text, so analyze-document only OCRs scanned pages. The document
pipeline is shared: title, cover analysis, search text and enrichment
work as they do for scans.

OCR is capped at 30 pages per document and charged to the daily
ingestion allowance per scanned page; past either, pages are kept
without text and counted on meta.ocrSkippedPages. A damaged, encrypted
or 1,000+ page PDF fails without retrying.

POST /api/v1/items/documents/pdf saves an uploaded PDF (25MB cap) as a
processing document; retrying a PDF document re-runs the import, which
skips rendering once pages exist.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Every upload path accepts a PDF (25MB) alongside images and saves it as
a document. Pasting a copied file onto the dashboard now uploads it too.
A PDF shows a placeholder card until its pages render, downloads as the
original PDF, and the owner sees how many scanned pages search can't
find when the OCR cap was hit. pdf_uploaded / pdf_upload_rejected track
the flow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The pager and its load error were hard-coded white on the detail pane,
which is the theme background — invisible in light mode. They now use
the theme's foreground colours, and the story renders on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
README features, roadmap and services (Vision and OpenAI now also OCR
scanned PDF pages; without Trigger.dev PDFs aren't imported), the
.env.example Vision note, and a Saving PDFs help page linked from the
help index, nav and the scanning guide.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Covers picking a PDF in the Add item dialog (saved as a document with the
PDF as its source and a placeholder card), pasting one onto the
dashboard, and rejecting one over 25MB before it uploads. Rendering runs
in Trigger.dev, outside E2E, and is covered by the import-pdf tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
classify-url hands a URL that serves a PDF (by content type, or a .pdf
path served as a generic file; checked on HEAD and again on GET for
servers that skip HEAD) to a new handlePdfUrl. It downloads the PDF
through safeFetch under the 25MB cap, stores it as the item's source
file and triggers import-pdf, so it renders and reads like an upload.
An extension capture of the browser's PDF viewer fetches the PDF itself.

Re-capturing a URL-saved PDF replaces it whole: reclaimReplacedStorage
now also deletes its pages and their files and clears its source and
cover, so nothing leaks and the storage counter stays exact.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
pdf_imported (pages, scanned pages, pages OCR'd, whether it was a retry)
and pdf_import_failed (reason) follow pdf_uploaded, so the upload to
import funnel and why imports fail are measurable.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
abode Ignored Ignored Preview Oct 5, 2026 9:40pm UTC

Request Review

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 63 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/trigger/import-pdf.ts
Comment thread app/src/app/(app)/_components/dashboard-dropzone.tsx Outdated
Comment thread app/trigger/handle-pdf-url.ts Outdated
Comment thread app/trigger/handle-pdf-url.ts Outdated
Comment thread app/src/lib/documents/pdf-text.test.ts Outdated
Comment thread app/trigger/import-pdf.ts
Comment thread app/test/pdf-fixtures.ts
Comment thread app/trigger/classify-url.test.ts Outdated
Comment thread app/src/lib/items/download-file-name.ts Outdated
Comment thread app/trigger/import-pdf.test.ts Outdated
fredrivett and others added 4 commits October 5, 2026 22:22
…-add

# Conflicts:
#	README.md
#	app/src/components/document/document-pages.tsx
#	app/trigger/reclaim-item-storage.ts
An import only saves its pages if the item still holds the PDF it
rendered, so one still running when a URL is re-captured can't bring back
the old pages. The OCR allowance is charged once per PDF and kept on
meta.ocrPagesCharged, so a retry after a failed enqueue doesn't pay
again. Analysis re-reads meta just before saving its skipped-page count
rather than writing back a snapshot from before the analysis.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Recognise the browser's PDF viewer from the captured DOM alone (Chrome's
  lone embed, Firefox's pdf.js shell), so a real page at a .pdf path or one
  embedding a PDF keeps its capture
- Prefer Content-Disposition's UTF-8 filename* over the ASCII fallback
- Remove the stored PDF if saving the item fails, so retries don't orphan it
- Enqueue the import through enqueueUserProcessing, like uploads

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A page MuPDF fails to parse or render fails the import as unreadable
  (no retry) instead of unknown, and its pixmap is always freed
- Show Download PDF before pages exist, e.g. while importing or after a
  failed import
- Don't upload a pasted file from the dashboard while a dialog is open
- Strip path separators from an uploaded PDF's name on download
- Make the garbled-text test reach the garble check, and guard the PDF
  fixture's password against mupdf's option syntax

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 66 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread app/trigger/analyze-document.ts Outdated
Comment thread app/prisma/schema.prisma
Comment thread app/src/lib/documents/pdf-text.ts
Comment thread app/trigger/import-pdf.ts Outdated
Comment thread app/trigger/classify-url.ts
Comment thread app/trigger/import-pdf.test.ts
Comment thread app/src/components/document/document-pages.tsx
fredrivett and others added 3 commits October 5, 2026 22:40
…anup

analyze-document now carries the PDF it was started for: if a URL
re-capture replaced it meanwhile, the run saves nothing and doesn't fail
the item, even when the replacement broke it (e.g. deleted its pages).
A failed page upload stops rendering and lets in-flight uploads finish
before the import cleans up, so none can land after its key is removed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Search results now include sourceFileKey, so a PDF found by search shows
and downloads as a PDF. Retrying a PDF document, including one saved from
a URL, reruns its import from the stored file instead of downloading and
re-capturing the URL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Count supplementary private-use and C1 control characters as garbled
  when deciding whether to trust a page's text layer
- Say the daily limit can also leave scanned pages unread, in the viewer
  note and the help page
- Wait for hydration before pasting in the E2E test: the button is
  server-rendered before the paste listener is attached, which raced on
  CI

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@fredrivett
fredrivett merged commit 95e28c6 into main Oct 8, 2026
13 checks passed
@fredrivett
fredrivett deleted the fredrivett/pdf-upload-add branch October 8, 2026 18:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant