Let a drawing run over as many pages as the note
The ink was stuck on page one while the writing carried on without it, which stopped making sense the moment a note could run to four pages. Every page is exported anyway, so every page can be drawn on. Pages are numbered the way the PDF reader numbers them, and only pages that carry ink are stored, so the keys are the drawn pages. While drawing there is always one blank page past the end: pages otherwise come from how far the writing runs, and to draw on page three you would first have had to write three pages. A drawing that lives entirely on page one is still written in the old single-page shape. Nothing requires that — it means an ordinary note is stored exactly as it always was, and a device running an older build still shows it. The bin clears the page being looked at. 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. And a real bug found on the way out: the exporter has written drawings/<note>.png since the interface was put into English, while the importer was still looking for zeichnungen/<note>.png. A drawing exported by this very app came back from its own archive as nothing at all. Both names are read now, and so are the further pages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b8c5c42217
commit
228552d453
9 files changed
+671
-136
No files matched your search
@@ -391,6 +391,26 @@ if (!tableColumns("users").includes("preferences")) {
|
||||
db.exec("ALTER TABLE users ADD COLUMN preferences TEXT");
|
||||
}
|
||||
|
||||
/* The text of an imported PDF, extracted by whichever device imported it.
|
||||
*
|
||||
* The server never opens a PDF. It has no PDF library and no way to read one:
|
||||
* the bytes arrive encrypted and are stored that way, and this column arrives
|
||||
* the same — already extracted, encrypted at rest like everything else, and
|
||||
* opaque here.
|
||||
*
|
||||
* It exists so a PDF is findable on a device that has never opened it. The text
|
||||
* used to be extracted on first open and kept in that browser's IndexedDB, which
|
||||
* meant a document imported on the desktop was invisible to a search on the
|
||||
* phone until the phone had opened it. Doing the work once, where the file
|
||||
* already is, and letting the result sync is what makes an archive of scanned
|
||||
* invoices searchable from anywhere.
|
||||
*
|
||||
* On `attachments` rather than in a table of its own: it is a property of the
|
||||
* file, it is deduplicated with the file, and it is freed with the file. */
|
||||
if (!tableColumns("attachments").includes("text_index")) {
|
||||
db.exec("ALTER TABLE attachments ADD COLUMN text_index BLOB");
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes sure exactly one way in exists.
|
||||
*
|
||||
@@ -1284,6 +1304,17 @@ const MAX_STROKES = 20000;
|
||||
function countStrokes(doc) {
|
||||
if (!doc || typeof doc !== "object") return null;
|
||||
if (Array.isArray(doc.strokes)) return doc.strokes.length;
|
||||
/* A note drawn over several pages: page number → strokes. Flatter than the
|
||||
* PDF's shape below, because every page of a note is the same size and there
|
||||
* is nothing per page to record besides the ink. */
|
||||
if (doc.sheets && typeof doc.sheets === "object") {
|
||||
let n = 0;
|
||||
for (const sheet of Object.values(doc.sheets)) {
|
||||
if (!Array.isArray(sheet)) return null;
|
||||
n += sheet.length;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
if (doc.pages && typeof doc.pages === "object") {
|
||||
let n = 0;
|
||||
for (const page of Object.values(doc.pages)) {
|
||||
@@ -1530,6 +1561,54 @@ app.get("/api/attachments/:id", authMiddleware, (req, res) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.end(bytes);
|
||||
});
|
||||
|
||||
/* ─── The text inside a PDF ───
|
||||
*
|
||||
* Extracted by the device that has the file, never here. The server has no PDF
|
||||
* library, and adding one would mean it could read documents it currently only
|
||||
* stores — which is the opposite of the point. What arrives is a list of page
|
||||
* texts; what is stored is that list, encrypted, and it is handed back to any
|
||||
* other device of the same account so it can search a document it has never
|
||||
* opened.
|
||||
*/
|
||||
|
||||
/* A cap, so one enormous scan cannot fill the database. The client caps its own
|
||||
* extraction at the same size; this is the second lock, on the side that cannot
|
||||
* be edited by whoever is holding the browser. */
|
||||
const MAX_PDF_TEXT_CHARS = 2_000_000;
|
||||
|
||||
app.put("/api/attachments/:id/text", authMiddleware, (req, res) => {
|
||||
const a = db.prepare("SELECT id FROM attachments WHERE id = ? AND user_id = ?").get(req.params.id, req.userId);
|
||||
if (!a) return res.status(404).json({ error: "Not found" });
|
||||
|
||||
const { pages } = req.body || {};
|
||||
if (!Array.isArray(pages) || pages.some((p) => typeof p !== "string")) {
|
||||
return res.status(400).json({ error: "pages must be a list of strings" });
|
||||
}
|
||||
const total = pages.reduce((n, p) => n + p.length, 0);
|
||||
if (total > MAX_PDF_TEXT_CHARS) return res.status(413).json({ error: "Extracted text is too large" });
|
||||
|
||||
db.prepare("UPDATE attachments SET text_index = ? WHERE id = ? AND user_id = ?")
|
||||
.run(Buffer.from(encrypt(JSON.stringify({ pages }))), req.params.id, req.userId);
|
||||
res.json({ ok: true, pages: pages.length });
|
||||
});
|
||||
|
||||
app.get("/api/attachments/:id/text", authMiddleware, (req, res) => {
|
||||
const a = db
|
||||
.prepare("SELECT text_index FROM attachments WHERE id = ? AND user_id = ?")
|
||||
.get(req.params.id, req.userId);
|
||||
if (!a) return res.status(404).json({ error: "Not found" });
|
||||
// No text yet is not an error: it means no device has read this file. The
|
||||
// client answers that by reading it, if it has the bytes.
|
||||
if (!a.text_index) return res.json({ pages: null });
|
||||
try {
|
||||
res.json(JSON.parse(decrypt(a.text_index.toString())));
|
||||
} catch (err) {
|
||||
console.error(` ❌ could not decrypt the text of attachment ${req.params.id}: ${err.message}`);
|
||||
res.json({ pages: null }); // it can be extracted again; a 500 helps nobody
|
||||
}
|
||||
});
|
||||
|
||||
/* Two routes used to sit here and were removed on 2026-08-08:
|
||||
*
|
||||
* POST /api/notes/pdf accepted a PDF and stored it, but nothing could ever
|
||||
|
||||
Reference in new issue
Block a user