608da16ac228f07fc1ccd8710f8b37991182432d
15
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
608da16ac2 |
Make the phone work: touch reordering, reachable menus, and nothing lost offline
Four things that were built for a mouse on a desk and quietly did nothing useful on a phone. The last one is the serious one. **A note made offline lost everything written into it.** Its create reached the server, the server named it something else, and every edit still queued behind it went on addressing the name only that device ever knew — 404, five retries, thrown away. The note arrived empty and untitled, with nothing to say that anything had been lost. The database was repointed at the new id all along; the queue was not. It is now, by replacing the id across the whole queued payload rather than in a list of named fields, because a list of fields is a list to forget to add to. The same held for a notebook made offline and everything filed into it. **The order of notes could not be changed on a phone**, because the list went through the HTML5 drag API, which mobile browsers never fire from touch. Pointer events instead, which mouse, pen and finger all produce. A mouse still drags a row from anywhere; a finger drags from a grip, because pressing the row has to go on scrolling and holding it has to go on opening the menu. The row under the finger is found with elementFromPoint — a finger raises no pointerover on anything it did not press. **"Move to..." opened off the side of the screen**, and could not be opened at all without a hover. It is a tap now, unfolding in place where there is no room beside the menu and flipping sides where there is. The menu itself is placed by measuring rather than by assuming it is 220px wide, its rows are tall enough to hit with a thumb, and Escape closes it. **Installing said nothing when it could not be done.** The entry appeared only when the browser had offered a prompt, so where installing was impossible there was no button and no reason. It is always there now and names the cause — most usefully that a browser will only install a site served over HTTPS, which is also why the service worker would not have registered, so one cause was showing up as two mysteries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
9e4677c70b |
Patterns on the covers, a search box that is simply there, roomier margin notes
Four small things about the shape of the app. Covers can be patterns as well as colours — ruled paper, graph paper, pinstripe, bands, dots, cross-hatch, linen, washes, a ledger — drawn entirely with CSS gradient layers. No image files, nothing to upload, crisp at any size, and offline like everything else. A cover is stored as an index into that list, so the new ones are appended and never inserted: renumbering would silently repaint every notebook anyone already has. Two things the patterns taught. One idea per cover: two crossed diagonals meant as a chevron came out as noisy plaid at the size a cover is actually read, and were replaced with plain bands. And the grid has to scroll — with 27 covers the dialog ran past the bottom of a laptop screen and took the Create button with it, which a check now holds at 1440x800. The search box in a notebook is simply there now, and the button that used to open it is gone. A magnifying glass cost a click and having to remember that searching was possible at all; a search box is one line and says for itself what it does. Margin notes went from 208x74 to 288x112 — from three short lines to about five. Three lines is a caption, not a remark on a passage, and the space beside the page was sitting mostly empty. And the notebook edit dialog was still part German, and would not say what the server's default number of versions actually is. It names it now, read from the server rather than written into the interface: it is set with MAX_VERSIONS_PER_NOTE, and a hardcoded "50" would quietly start lying the day that changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fed53772aa |
Set the note left, count its pages, choose its typeface, align its text
The column sat in the middle, so it moved as the window was resized and the start of every line landed somewhere new. It is against the margin now. The toolbar counts pages in the words the PDF reader uses, with a hairline where each ends. Pages are drawn, never stored: the note stays one document, Markdown has no page, and a check asserts none reaches the stored body. The current page is the one under the middle of the window — measuring from the top claimed "page 1" while you stood at the foot of a two-page note, because a window is shorter than a page. The typeface is a per-account setting rather than a per-browser one, because a choice about how your own notes are set should follow you to the phone. The server takes serif, sans or mono and drops anything else; that column is written by a browser, so it is an input like any other. Alignment is a small extension of our own — one attribute, a parse rule, a render rule — so the schema every stored note is validated against stays in files this project owns. Markdown has no alignment: what CommonMark allows is embedded HTML, so non-left blocks travel as `<div align="…">`, the one form most readers still honour, and the importer puts it back on the paragraphs. Left is never written. The full run caught a crash I had put in: PdfWorkspace used `noteFont` without taking it as a prop, which the build cannot see and the type checker does not reach in .jsx. It killed the notes panel beside a PDF, and `offline` failed with it because the whole app was broken. 224 unit tests, 263 browser checks across twelve suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
830af6532a |
Take the paper back out, and quiet the surfaces around it
The sheet was not what it wanted to be, so writing is plain text again: about 73 characters to the line, centred, no border and no shadow. The fixed page belongs to the canvas — a stroke has to land in the same place on every screen, which needs one agreed page — so it appears exactly when something is on it or about to be: in Draw, or when the note already has a drawing. The cap lives in the stylesheet rather than on a container, so it holds on a page as well as off one. Emoji are gone from the note rows and the sidebar. They are drawn by the operating system, so they looked different on every machine and were loud enough that a row's picture caught the eye before its title did. Rows are tighter by three pixels of padding. The trash button no longer stands permanently in the danger colour beside the star, which made the most destructive control the loudest thing on screen. It sits behind a divider and turns red under the pointer, when it is about to be used. On a phone the toolbar's fourteen controls scrolled sideways, so the ones at the end could only be reached by dragging the bar and nothing said they were there. Nine remain, plus a menu holding the headings, quote, code and undo. A new `sync` suite proves what was assumed: a PDF written on one device — the document, the drawing and the pinned notes — reaches a second one. Two browser contexts are two devices. Both search suites read a row's title as the second line of its text, which only worked while the icon was itself a text emoji; they read it by name now. 218 unit tests, 249 browser checks across twelve suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
c12ffa250e |
Make the writing surface a sheet of paper
It was a page-shaped box that was not page-coloured: the same fill as everything around it with a hairline border, so it read as an empty panel. Now it is warm off-white in daylight and a raised surface at night — deliberately not a white rectangle, since a page that glows brighter than the room is what makes an app unusable in the evening. The line ran to 84 characters, past the width at which the eye reliably finds the start of the next line, and that was the single biggest reason the surface read badly. Measured, not guessed: 620px of sheet with a 56px margin gives about 73. It is centred now — it used to sit 28px from the left and stop short of the right — with almost square corners instead of a dialog's 14px radius. Blocks on the sheet are tinted from paper rather than from the app background, or they read as sitting underneath it. Five German strings survived the earlier pass, "Alle Notizen exportieren" among them: that sweep filtered on umlauts, so sentences without one went straight through. Swept again by word. 218 unit tests, 244 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
52bd2b8f3d |
Put the interface into English, and give the notebook one way in
The interface had drifted into two languages — the frame in English, everything added later in German — which is worse than either, since the same idea appeared under two names depending on when that part was written. Sorting by title still uses the German collation, so Übung 2 comes before Übung 10: that is about the notes, not the chrome. Two archive folders were renamed with it (bilder/ → images/, zeichnungen/ → drawings/). The importer reads both spellings, and both the old and new marker line for a drawing — archives exported before this are on disk, and an import that quietly dropped their drawings would be the worst kind of tidy-up. Making a note, uploading a PDF and importing Markdown were three buttons side by side, two of them icons you had to hover to identify. They are the same act — putting something new in this notebook — so they are now one + menu. The search box moves out of the sidebar and into the note list, labelled with the notebook it narrows. Nothing changed about what it does: it has always searched only the notebook you are in, but from the sidebar it read as searching everything. A separator no longer carries a sentence explaining the order the sort menu already names. Renaming joins removing on the right-click menu — a left-click on the label started an edit, so the row could not be touched without falling into one. 218 unit tests, 238 browser checks across eleven suites. Two of those caught real breakage from this change rather than needing a translation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6438648313 |
Pin notes to passages in a PDF, and put the notes control with the rest
The panel beside a PDF is for notes about the document; a note that belongs to one paragraph is a different thing, and one that has to begin "on page 14, third paragraph" has already lost its place. Click Randnotiz, click the passage, write. Pins ride in the same per-page overlay as the drawings, in PDF points, so one stays on its sentence at every zoom and in the exported file. Living outside the note body has consequences that each needed handling: nothing else would index them for search, so the viewer feeds them in with their page numbers; clearing a page's ink must not take them; and "is this overlay empty?" was answered by counting strokes — in the client *and* independently in the server, so an overlay holding only pinned notes was stored as NULL and lost every note on it. They are stamped into the annotated export as a numbered marker plus a box of text, on whichever side of the pin has room. Standard PDF fonts are Latin-1, so characters outside it are replaced rather than failing the whole export. The Notizen control moves into the viewer's toolbar. A second row for one button cost reading height on every screen and put one of the document's controls somewhere none of the others were. On a phone, where the viewer is not on screen while the notes are, the way back sits in the panel. Renaming a separator no longer shifts the row: the two rules always share what is left over equally and the middle keeps one width in both states. 215 unit tests, 206 browser checks across eleven suites. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0a0864c58b |
Sort a notebook, import Markdown back, and stop the page shrinking in Draw
The paper measurably shrank on switching to Draw — 733px to 460px on the same screen. The editor was a flex item at its default `flex: 0 1 auto`, so its width was its widest row's: the toolbar. Swapping a dozen formatting buttons for six drawing ones collapsed it. It now fills the pane, so the width is a property of the window. Each notebook remembers how its notes are ordered, on the server rather than per device. The manual order has to survive being looked away from, so `reindex` renumbers every note in the notebook rather than the filtered view, and dragging is switched off while a search narrows the list. Reordering has its own route: `PUT /notes/:id` would bump `updated_at` and snapshot a version, so a drag would move a note to the top of "last changed" and spend a slot of its history. Markdown and our own ZIP archives come back as notes, with their images re-uploaded as attachments. A drawing returns as the PNG it was exported as — vectors are not recoverable from pixels — and the import says so rather than leaving it to be discovered. Writing the parser turned up a real bug in the exporter: blocks were emitted with no blank line between them, so a list after a list became one list and a quote after an item was read as part of it. Creating or importing from "All Notes" now asks which notebook instead of silently taking the first one. The web-link prompt is a real dialog: a bare domain becomes https, javascript: is refused with a reason. The PDF viewer's hide-drawing control is a labelled button rather than a filled-or-hollow dot, and the export icon points down into a tray like every other download. 186 unit tests, 179 browser checks across eleven suites, including a new `offline` suite that loads the app cold with the network off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
3acb8b66a4 |
Free unused attachments, and keep versions by count rather than by age
Two rules governed version retention: the newest 50 per note, and a sweep of anything past 90 days. The second was wrong. The count already bounds what a note can accumulate, so age only ever deleted history nothing had replaced — and it bit hardest exactly where history is most wanted, a note left alone for a quarter of a year losing its whole past for having done nothing. It is off unless VERSION_RETENTION_DAYS is set. How many versions to keep is now a notebook's own setting, falling back to the server default when left empty. A reference notebook can keep fifty of everything while a scratch one keeps three. That makes freeing attachments answerable. A picture taken out of a note cannot be deleted straight away: the versions from before still show it, and restoring one would find a hole. It is released once no current note and no kept version refers to it — which, with retention by count, is when the last version holding it is pushed out. The server cannot look for itself, bodies being encrypted, so the client reports a note's attachments on every save as it already does for links, and a version inherits what the note held when it was snapshotted — by definition what that version contains. Two things make it safe rather than merely plausible. Silence is not "uses nothing": a note written before this existed has no rows, which looks identical to one with no pictures, so nothing is released for a user while any of their notes has yet to report — the prefetch that fills the search index reports for them on its first pass. And an upload has a row before the note using it does, so recent attachments are left alone. POST /api/admin/housekeeping runs the sweep on demand. Caught while testing: the same block of markup had been pasted into the create-notebook dialog as well, where the state it referenced did not exist — the dialog crashed on open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a32d6eba99 |
Export a PDF with the drawings written into it
Stage seven, and the last of the PDF work. A PDF note now offers the document with your marks stamped in, the untouched original, or just the notes written beside it. pdf.js only reads, so writing needs pdf-lib — behind a dynamic import like pdf.js, so the bundle everyone downloads carries no PDF writer for the sake of people who never export one. Main bundle grew 6 kB; the 180 kB writer arrives only when used. The coordinate decision from stage four is what makes this a translation rather than a reconstruction: strokes were stored in page points from the start, the only space a PDF understands, so nothing has to be guessed back from screen pixels. Only the origin moves, PDF putting it at the bottom-left. The stored file is never modified — pdf-lib builds a new document — so a clean copy stays available however often it has been drawn on. The suite re-fetches the stored bytes after exporting and compares them, and checks the plain export is byte-identical to what was imported. Eraser strokes are dropped. On screen they remove ink by drawing in destination-out; a PDF content stream has no equivalent, and stamping them would put grey scribbles exactly where something had been rubbed out. The suite renders both files with pdf.js and counts dark pixels, 1633 against 2557. A larger file only proves bytes were added; this proves ink can be seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2986bcb620 |
Read imported PDFs in the app
Stage three: the viewer. Drawing on pages comes next. pdf.js stays out of the bundle everyone downloads — it is larger than the rest of the application put together, so it is behind a dynamic import and a session that never opens a PDF never fetches it. Main bundle grew by 8 kB for the viewer shell; the 162 kB library arrives only when needed. The worker is bundled rather than pulled from a CDN, because a viewer that stops working during someone else's outage is not one you can rely on offline. Two things that only showed up once it ran in a browser: - pdf.js 6 calls Uint8Array.prototype.toHex, which only very recent browsers have. Anything older failed with "n.toHex is not a function", which says nothing about the cause. Now on the legacy build, which compiles those away — a little larger, and the difference between working everywhere and working on this year's browsers. - nginx has no MIME type for .mjs and served the worker as octet-stream, which a browser refuses to run as a module. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest immediately and a document mixing portrait and landscape keeps both — the first version scaled every page to the widest one, which the suite caught by checking the two aspect ratios differ. Bytes come from the authenticated attachment route and are cached in IndexedDB. Content-addressed storage means a cached copy can never be stale, so a document opened once needs no connection again. Verified with the network switched off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
8c36c6121b |
Ask what a single note's export should contain, and fix drawings never being in one
Two questions instead of one, because the answers depend on each other: Markdown holds text only, a PNG holds the drawing only, PDF holds both. Listing every format flat would have offered combinations that cannot exist. A note without a drawing skips the first step rather than showing it mostly greyed out. While testing that, the drawings turned out never to have made it into any export at all — not the ZIP, not the PDF. strokeDocOf() *builds* a stroke document from a bare array, and it was being handed one that was already a document, so the strokes ended up nested one level too deep and every length check read undefined. Nothing complained; the export simply came out without the drawing. It went unnoticed because the notes the earlier suite exported had none. Now uses docStrokes(), and the suite draws on a note before exporting it, checks the PNG's magic bytes, and checks it is 159 KB rather than an empty white page — a check on the file merely existing would have passed against the broken version too. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2611d87f8c |
Export notes as Markdown or PDF
The reason to build this is not a missing feature — it is that trusting a program you maintain alone with years of notes should not mean the notes can only ever be read by that program. Reachable from a note's toolbar, a notebook's context menu, and Account → Alle Notizen exportieren. Markdown carries over everything with an equivalent: headings, lists, quotes, code, tables, and task state as `- [x]`. A link to another note in the same export becomes a relative link to its file; a link to a note outside it degrades to the plain title rather than leaving a dead reference. Notes with images or a drawing arrive as a ZIP so the files travel along, and a multi-note export gets a README index. PDF goes through the browser's print dialog rather than a bundled PDF library — the browser already has a good one, and it is the only format where drawings sit inside the document. Markdown gets them as a PNG beside the text, with a line saying so, because dropping them silently would be the wrong kind of quiet. Two decisions worth recording: - the ZIP writer is eighty lines here rather than a dependency. Note text is small and images are already compressed, so compression bought nothing, and stored entries are a handful of fixed-layout records. - Markdown escaping only covers what changes meaning mid-line, plus a leading list or heading marker. Escaping every character Markdown ever uses turns a sentence into `Hallo\. Alles gut\!`, which defeats the point of choosing a readable format. 42 unit tests on the serialiser and the archive format, plus a browser suite that unpacks a real download and reads the bytes back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
68e184163e |
Remove public sign-up; accounts are created by an administrator
This is a private instance, so anyone able to reach the login page could previously give themselves an account. The registration route is gone rather than hidden, and the login screen no longer hints that accounts can be made. Accounts now come from an admin under Account → Administration: create, delete, and reset a password. Appointing further admins is deliberately not offered — with one admin it is not needed, and it is the one power that turns a compromised account into a lost instance. The first admin comes from INKNOTE_ADMIN_USER / INKNOTE_ADMIN_PASSWORD, used only when the database has no users, or to choose whom to promote when an older database has users but no admin. Once an admin exists both are ignored, so leaving them in .env cannot reset anything. Two things that would otherwise have been quietly wrong: - a deleted user's token stayed valid until it expired, because tokens are signed rather than stored and there was no session to revoke. authMiddleware now checks the account still exists, so an open tab stops working the moment the account is deleted. - deleting an account takes its notes, notebooks, drawings and version history with it via ON DELETE CASCADE. Verified: no orphaned rows in notes, notebooks, sync_log, attachments or note_versions. Deletion asks for the username to be typed back, since it destroys data that version history cannot bring back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
92d525b3ab |
TipTap editor, version history, search, attachments — and a cleanup
The app is meant to run in daily private use now, so this pass was mostly
about the ways it could quietly lose data.
Reliability and security:
- the server no longer invents a JWT_SECRET or ENCRYPTION_KEY when one is
missing; it refuses to start. The old fallback silently made every stored
note unreadable after a restart.
- notes are encrypted with AES-256-GCM (v2:); existing CBC bodies still read.
- login is rate limited and compares against a dummy hash when the user does
not exist, so timing does not reveal who is registered.
- a server response issued before a local save is never adopted. This had
already eaten drawing strokes and note links; sync.hasPendingWrites() now
guards every path that takes a body from the server.
Editor and features:
- TipTap replaces the contenteditable editor: tables, task lists, and links
to other notes and notebooks, with a backlinks bar on the target.
- content format v2 ({v:2,doc}); v1 {html,tasks} bodies migrate on open.
- version history: the state before each write is snapshotted, coalesced
into 5-minute windows, 50 versions or 90 days per note.
- full-text search runs in the client, because the server cannot read
encrypted bodies and a server index would defeat the encryption.
- image attachments are stored by SHA-256 and deduplicated; the client
fetches them authenticated and caches the blob, so they work offline.
- drawings are vector strokes in a fixed 1200x1600 page space and now look
the same on every screen (0.0-0.2% deviation across four widths).
- login is by username; existing email accounts migrate automatically.
Operations:
- data lives on host bind mounts, not Docker volumes, so Proxmox Backup
covers it. Back up .env with it — without ENCRYPTION_KEY the dump is
unreadable.
Structure:
- NoteApp.jsx is gone. App.jsx now holds only where the user is; note data,
search, layout, history and drag each live behind a hook, and the four
panes are stateless.
- the whole data layer is TypeScript, with 81 unit tests concentrated on
the places that have actually broken.
- fixed along the way: the browser back button had a dead press, because
restoring a history entry pushed a duplicate one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|