diff --git a/CHANGELOG.md b/CHANGELOG.md index ee00780..ad262f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,33 @@ not from the output of `docker push`. --- +## Unreleased + +**Added** +- **A PDF is searchable on every device, not only on the one that opened it.** + The device that imports the file reads it there and then — the server still has + no PDF library and never opens one — and the extracted text is stored beside + the file, encrypted, so every other device can find the document without ever + downloading it. This is what makes an archive of scanned invoices work: they + are filed and then never opened, so waiting for someone to open one meant + waiting forever. +- **A drawing runs over as many pages as the note has.** The ink used to be stuck + on page one while the writing carried on without it. Pages are numbered the way + the PDF reader numbers them, and there is always one blank page past the end to + start a new one on. The bin clears the page being looked at; undo still works on + the drawing as a whole. +- A drawing that lives entirely on page one is still stored in the old shape, so + an ordinary note is written exactly as it always was and an older client still + shows it. + +**Fixed** +- **A drawing exported in a ZIP was lost on re-import.** The exporter has written + `drawings/.png` since the interface was put into English; the importer + was still looking for `zeichnungen/.png` and found nothing. Both names + are read now, and so are the further pages of a drawing. + +--- + ## 2026-08-11 — The writing column **Images** — web `sha256:ed11f034…`, server unchanged at `sha256:1648498f…` diff --git a/CLAUDE.md b/CLAUDE.md index 84ba6b8..8542396 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,10 +31,10 @@ Diese Punkte sind Entscheidungen von Marc, keine offenen Fragen: Beides muss vor einem Push grün sein. ```bash -# Unit (224 Tests, ~4 s) +# Unit (~4 s) docker run --rm -v "$PWD/frontend:/w" -w /w node:20-alpine npx vitest run -# Browser, zwölf Suiten (~263 Checks) – braucht den Dev-Stack +# Browser, zwölf Suiten – braucht den Dev-Stack ./e2e/run.sh # alle ./e2e/run.sh editor drawing # einzelne ``` diff --git a/e2e/sync.mjs b/e2e/sync.mjs index e317aa7..d63984f 100644 --- a/e2e/sync.mjs +++ b/e2e/sync.mjs @@ -9,7 +9,7 @@ * A second browser context is a second device: its own storage, its own cache, * its own session. */ -import { newPage, signIn, check, createNotebook, launch, report, withFreshUser } from "./lib.mjs"; +import { newPage, signIn, check, createNotebook, launch, report, search, withFreshUser } from "./lib.mjs"; import { writeFileSync } from "node:fs"; const PDF = "/tmp/sync-test.pdf"; @@ -71,6 +71,26 @@ try { await signIn(phone, s.user, s.pass); await phone.locator("button").filter({ hasText: "Sync" }).first().click(); await phone.waitForTimeout(1500); + + /* ── found by what is written inside it, without ever opening it ── + * The text is read on the device that imports the file and stored with it, so + * this device can search a document whose bytes it has not even downloaded. + * It used to be extracted on first open and kept in that one browser, which + * made a PDF filed on the desktop invisible to a search on the phone. */ + await phone.waitForTimeout(8000); // the backfill runs after the notes are in + await search(phone, "Handbuch Seite"); + check( + (await phone.locator("[data-note-row]").count()) === 1, + "the second device finds the PDF by a phrase inside it, having never opened it", + `${await phone.locator("[data-note-row]").count()} rows` + ); + await search(phone, "Wortdasnirgendwostehtxyz"); + check( + (await phone.locator("[data-note-row]").count()) === 0, + "and finds nothing for a phrase that is not in it" + ); + await search(phone, ""); + await phone.locator("[data-note-row]").first().click(); await phone.waitForTimeout(6000); diff --git a/frontend/src/features/pdf/pdfText.ts b/frontend/src/features/pdf/pdfText.ts index 25ac905..683339c 100644 --- a/frontend/src/features/pdf/pdfText.ts +++ b/frontend/src/features/pdf/pdfText.ts @@ -5,17 +5,26 @@ * same index that holds the notes — so one search box finds a sentence whether * it was typed or imported. * - * **The text is not sent to the server.** It is derivable from a file the server - * already has, so storing it again would cost space and a second thing to - * encrypt for no information gained. The price is that a PDF becomes searchable - * on a device once that device has opened it. That is the same shape as the - * limitation notes already have, and the honest trade for not duplicating data. + * **The reading happens on a device, never on the server.** The server has no + * PDF library and no way to open one: the bytes arrive encrypted and are stored + * that way. The device that imports the file does the work once, and the result + * is stored beside the file — encrypted at rest like everything else — so every + * other device can search a document it has never opened. + * + * It used to be kept only in the browser that had opened the file, on the + * reasoning that text derivable from bytes the server already holds should not + * be stored twice. That was true and useless: a PDF imported on the desktop was + * invisible to a search on the phone. Storing it is what makes an archive of + * scanned invoices findable from anywhere, and it costs a few hundred kilobytes + * against a file that is measured in megabytes. */ import * as db from "../../offlineStore"; +import * as sync from "../../syncEngine"; import * as searchIndex from "../../search"; +import type { NoteMeta } from "../../types"; import type { PDFDocumentProxy } from "./pdfjs"; -import { extractPageText } from "./pdfjs"; +import { extractPageText, openPdf } from "./pdfjs"; /** Cache key. Keyed by attachment, not note: the same file in two notes is extracted once. */ const keyFor = (attachmentId: string) => `pdftext:${attachmentId}`; @@ -34,6 +43,92 @@ export async function cachedPdfText(attachmentId: string): Promise(keyFor(attachmentId))) ?? null; } +const textOf = (pages: string[]): PdfText => ({ + pages, + full: pages.join("\n").slice(0, MAX_TEXT_CHARS), +}); + +const cache = async (attachmentId: string, text: PdfText): Promise => { + try { + await db.setMeta(keyFor(attachmentId), text); + } catch { + /* a full quota costs the cache, not the search */ + } +}; + +/** Stores the text with the file if nothing is stored there yet. */ +async function ensureStored(attachmentId: string, pages: string[]): Promise { + try { + if (await sync.fetchPdfText(attachmentId)) return; + await sync.storePdfText(attachmentId, pages); + } catch { + /* offline, or the file is gone — either way, nothing to do about it here */ + } +} + +/** + * The text of a file, from wherever it can be had cheapest. + * + * Locally first, then from the server — which is where the device that imported + * the file left it. Null means nobody has read this document yet, and whoever + * has the bytes should. + */ +export async function loadPdfText(attachmentId: string): Promise { + const local = await cachedPdfText(attachmentId); + if (local) return local; + try { + const pages = await sync.fetchPdfText(attachmentId); + if (!pages) return null; + const text = textOf(pages); + await cache(attachmentId, text); + return text; + } catch { + // Offline, or the file is gone. Either way there is nothing to index and + // nothing worth interrupting the user about. + return null; + } +} + +/** + * Reads a freshly imported PDF, so it is searchable everywhere immediately. + * + * Done on the device that has the file open anyway, rather than left until some + * device opens the document — which for an invoice filed and never looked at + * again may be never. Failure is silent on purpose: the import succeeded, and + * the text can still be extracted the next time the file is opened. + */ +export async function extractOnImport(file: Blob, attachmentId: string): Promise { + try { + const { doc } = await openPdf(new Uint8Array(await file.arrayBuffer())); + await extractAndCache(doc, attachmentId); + } catch (err) { + console.warn(`[pdf] could not read the text of ${attachmentId} on import:`, err); + } +} + +/** + * Fills the search index with the text of every PDF the account holds. + * + * Runs beside the note backfill after login. A document whose text nobody has + * extracted yet is left alone: this device may not have its bytes, and + * downloading every PDF to index it is not a thing to do in the background. + */ +export async function backfillPdfText( + notes: NoteMeta[], + signal?: { aborted: boolean } +): Promise { + let indexed = 0; + for (const note of notes) { + if (signal?.aborted) break; + if (!note.pdfAttachmentId) continue; + const text = await loadPdfText(note.pdfAttachmentId); + if (!text) continue; + indexPdfText(note.id, note.title || "", text); + indexed++; + } + return indexed; +} + /** * Extracts every page's text and remembers it. * @@ -47,7 +142,12 @@ export async function extractAndCache( signal?: { aborted: boolean } ): Promise { const existing = await cachedPdfText(attachmentId); - if (existing) return existing; + if (existing) { + // Extracted here before the text was ever stored with the file. Sending it + // now is what makes those documents findable on the other devices too. + void ensureStored(attachmentId, existing.pages); + return existing; + } const pages: string[] = []; let total = 0; @@ -66,11 +166,16 @@ export async function extractAndCache( await new Promise((r) => setTimeout(r, 0)); } - const result: PdfText = { pages, full: pages.join("\n").slice(0, MAX_TEXT_CHARS) }; + const result = textOf(pages); + await cache(attachmentId, result); + // Left with the file, so the next device does not have to read it again — and + // can find the document without ever opening it. A failure here is not worth + // reporting: the text is cached locally, and the next device to open the file + // will store it. try { - await db.setMeta(keyFor(attachmentId), result); - } catch { - /* a full quota costs the cache, not the search */ + await sync.storePdfText(attachmentId, pages); + } catch (err) { + console.warn(`[pdf] could not store the text of ${attachmentId}:`, err); } return result; } diff --git a/frontend/src/hooks/useNoteData.ts b/frontend/src/hooks/useNoteData.ts index 61d2f06..aa92a78 100644 --- a/frontend/src/hooks/useNoteData.ts +++ b/frontend/src/hooks/useNoteData.ts @@ -4,6 +4,7 @@ import * as sync from "../syncEngine"; import * as searchIndex from "../search"; import { sameStrokeDoc } from "../features/drawing/strokes.js"; import { reindexEntries } from "../features/notes/noteSort"; +import { extractOnImport } from "../features/pdf/pdfText"; import type { Backlink, NoteContent, NoteMeta, Notebook, StrokeDoc, User } from "../types"; /** How often the whole account is reconciled with the server. */ @@ -203,6 +204,12 @@ export function useNoteData(user: User | null, activeNote: string | null): NoteD const title = file.name.replace(/\.pdf$/i, "") || "Dokument"; const note = await sync.createPdfNote(folderId, title, attachment.id); setNotes((p) => [note, ...p]); + /* Read the document here, now, on the machine that already has it open. + * + * Waiting until someone opens it means a filed invoice may never be read at + * all, and until it is, no device can find it. Not awaited: the note exists + * and should appear at once — the text catches up. */ + void extractOnImport(file, attachment.id); return note; }, []); diff --git a/frontend/src/hooks/useSearchIndex.ts b/frontend/src/hooks/useSearchIndex.ts index 37f96d3..5dbbafa 100644 --- a/frontend/src/hooks/useSearchIndex.ts +++ b/frontend/src/hooks/useSearchIndex.ts @@ -2,6 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import * as sync from "../syncEngine"; import * as searchIndex from "../search"; +import { backfillPdfText } from "../features/pdf/pdfText"; import type { NoteMeta, SearchHit, User } from "../types"; const DEBOUNCE_MS = 120; @@ -52,6 +53,12 @@ export function useSearchIndex(user: User | null, notes: NoteMeta[], query: stri }, }); if (!cancelled) setVersion((v) => v + 1); + /* PDFs last: their text is stored with the file rather than derived from a + * note body, so it has to be fetched separately — and a document nobody + * has read yet is simply skipped. Searching a note is more urgent than + * searching a scan, so this waits until the notes are in. */ + const indexed = await backfillPdfText(notesRef.current, signal); + if (indexed && !cancelled) setVersion((v) => v + 1); running.current = false; })(); diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts index 3396239..0b43474 100644 --- a/frontend/src/syncEngine.ts +++ b/frontend/src/syncEngine.ts @@ -1127,6 +1127,22 @@ export async function loadPdfBytes(attachmentId: string): Promise { return new Uint8Array(await blob.arrayBuffer()); } +/* ─── The text inside a PDF ─── + * + * Read on a device, stored with the file, handed to every other device. The + * server never opens a PDF; it keeps this the way it keeps a note body — + * encrypted, and unread by it. */ + +/** The stored text of a PDF, page by page, or null if nobody has read it yet. */ +export async function fetchPdfText(attachmentId: string): Promise { + const r = await api.get<{ pages: string[] | null }>(`/attachments/${attachmentId}/text`); + return r.pages ?? null; +} + +export async function storePdfText(attachmentId: string, pages: string[]): Promise { + await api.put(`/attachments/${attachmentId}/text`, { pages }); +} + /** Creates a note that holds a PDF. */ export async function createPdfNote( folderId: string | null, diff --git a/readme.md b/readme.md index f0907f1..e7f5a72 100644 --- a/readme.md +++ b/readme.md @@ -333,14 +333,14 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running | `editor` | 31 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload | | `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes | | `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable | -| `drawing` | 7 | strokes survive the poller, and match across four viewport widths | +| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on | | `pdf` | 91 | import, page proportions, lazy loading, and reading with the network off | | `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does | | `conflict` | 10 | a late write wins but the state it buries survives as a version | | `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | | `organise` | 39 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole | | `offline` | 12 | a cold page load with the network off still opens notes and searches them | -| `sync` | 5 | a PDF written on one device — document, drawing and pinned notes — reaches a second one | +| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it | Each suite creates a throwaway account through the admin API and deletes it afterwards, so runs leave the database as they found it. They need an admin to @@ -399,8 +399,8 @@ and a test asserts none ever reaches the stored body. The current page is the on under the *middle* of the window: measuring from the top said "page 1" while you stood at the foot of a two-page note, because a window is shorter than a page. -The drawing still covers only the first page. On a PDF the strokes are kept per -page; on a note there is one sheet. +Each page can be drawn on — see [One drawing, as many pages as the +note](#one-drawing-as-many-pages-as-the-note). ### Alignment @@ -473,6 +473,11 @@ fresh device with nothing to search. After login a background pass backfills the missing ones into IndexedDB, slowly enough to stay out of the way, indexing each as it lands. Searching therefore also works offline. +PDFs come after the notes in that pass. Their text is not derived from a note +body but stored with the file, by the device that imported it — see [The reading +happens on a device, never on the +server](#the-reading-happens-on-a-device-never-on-the-server). + Matching is AND across terms, case- and diacritic-insensitive (`kuhlschrank` finds *Kühlschrank*), with title matches ranked above body matches. Results show a snippet around the first match. @@ -581,11 +586,34 @@ one search box finds a sentence whether it was typed or imported. Extraction run *after* the document is on screen and yields between pages: it must never stand between the reader and the first page. -**The extracted text is not sent to the server.** It is derivable from a file the -server already holds, so storing it again would cost space and a second thing to -encrypt for nothing gained. The price is that a PDF becomes searchable on a -device once that device has opened it — the same shape as the limitation notes -already have, and the honest trade for not duplicating data. +### The reading happens on a device, never on the server + +The server has no PDF library and no way to open one: the bytes arrive encrypted +and are stored that way. **The device that imports the file reads it**, at import +rather than at first open, and the result is stored beside the file in +`attachments.text_index` — encrypted at rest like everything else, and opaque to +the server. + +That is what lets a device search a document it has never opened. It is the +precondition for filing scanned invoices: they are imported, and then never +opened again, so waiting for someone to open one would mean waiting forever. + +``` +PUT /api/attachments/:id/text { "pages": ["page one text", "page two text"] } +GET /api/attachments/:id/text → { "pages": [...] } or { "pages": null } +``` + +`null` is not an error — it means nobody has read this file yet, and whichever +device holds the bytes should. After login the search backfill asks for the text +of every PDF the account holds, after the note bodies, and skips the ones nobody +has read: downloading every document just to index it is not a thing to do in the +background. + +This was deliberately *not* stored for a while, on the reasoning that text +derivable from bytes the server already has should not be kept twice. True, and +useless: a PDF imported on the desktop was invisible to a search on the phone +until the phone had opened it. A few hundred kilobytes of text against a file +measured in megabytes is a trade worth making. The minimal text-layer CSS is written out in `textLayer.css` rather than importing `pdfjs-dist/web/pdf_viewer.css`, which carries the whole reference @@ -993,6 +1021,41 @@ encrypted at rest like note content. Notes drawn before this format still carry a PNG in `drawing_data_path`; it renders underneath any newer strokes so nothing is lost. +### One drawing, as many pages as the note + +A note used to have exactly one sheet to draw on, which stopped making sense once +the writing could run to four pages: the ink was stuck on the first one while the +text carried on without it. Every page is exported anyway, so every page can be +drawn on. Pages are numbered the way the PDF reader numbers them: + +```json +{ "v": 2, "w": 1200, "h": 1600, "sheets": { + "1": [ { "t": "pen", "c": "#e94560", "s": 5.5, "p": [100,100, 200,180] } ], + "3": [ { "t": "highlighter", "c": "#f5c518", "s": 27, "p": [80,900, 900,910] } ] +}} +``` + +A page nobody has drawn on is not stored, so the keys *are* the drawn pages. + +**A drawing that lives entirely on page one is still written in the old shape**, +with a plain `strokes` list and no `sheets` at all. Nothing requires that — it +means an ordinary note's drawing is stored exactly as it always was, and a device +running an older build still shows it. + +While drawing there is always one blank page past the end. Pages otherwise come +from how far the writing runs, which would leave a drawing with nowhere to go: to +draw on page three you would first have to write three pages. Drawing on the +spare page makes it a real one, and the page count in the toolbar counts it. + +The bin clears **the page being looked at**, not the whole drawing. Undo and redo +work on the drawing as a whole, because one Undo button that means four different +things depending on where you scrolled is not a button anyone can use. + +In an export, page one keeps the name a drawing has always had — +`drawings/.png`, or `-drawing.png` beside a single note — and the +rest are numbered after it. A drawing of several pages exported on its own comes +out as a ZIP rather than as its first page alone. + --- ## Conflict Resolution