# ✏️ InkNote A self-hosted note-taking application with rich text editing, freehand drawing, full-text search and encrypted sync — inspired by Samsung Notes. --- ## Features ### Core - **Rich Text Notes** — Bold, italic, underline, headings, bullet/numbered lists, task lists, quotes, code blocks - **Tables** — Insert, resize columns, add/remove rows and columns, merge cells, toggle header row - **Note Links** — Type `@` to link to another note or notebook; every note shows what links back to it - **Version History** — Past states of every note, restorable; restoring is itself reversible - **Full-Text Search** — Searches note bodies, not just titles, and works offline - **Image Attachments** — Pasted images are stored as deduplicated blobs, not inlined into the note - **Freehand Drawing** — Pen, highlighter, eraser with adjustable thickness and color palette (20 colors, 7 pen sizes) - **Undo/Redo** — Full history stack for drawing operations - **Notebooks & Folders** — Covers in colour or pattern — ruled paper, graph paper, stripes, dots, weave — drawn in CSS, no image files - **Star/Favorite** — Quick access to important notes - **PDF Reading & Annotation** — Import a PDF, read it, select its text, draw on it, pin notes to individual passages, write notes beside it, and export it with or without your marks - **Export** — Any note, notebook or the whole account as Markdown or PDF, entirely in the browser - **Import** — Markdown files, or a ZIP this app exported, come back as notes with their images - **Sorting** — Each notebook remembers how its notes are ordered, including an arrangement made by hand - **Dark & Light Mode** — Full theme support with refined color schemes ### Sync & Storage - **Offline-First** — Works fully offline, syncs when connection returns - **Conflict-Aware Sync** — A write from a device that missed another's changes keeps the state it replaces as a version, rather than burying it - **WebSocket Real-Time Push** — Changes appear on other devices instantly - **Encrypted at Rest** — AES-256-GCM for note content *and* uploaded files on the server - **JWT Authentication** — Username + password accounts with bcrypt password hashing, and optionally an identity provider (OpenID Connect). No public sign-up: accounts come from an administrator or from the provider - **Vector Drawings** — Strokes are stored as vectors in one canonical page space, so a drawing looks identical on every screen and stays sharp at any pixel density ### Architecture - **Self-Hosted Backend** — Node.js + Express + SQLite (or PostgreSQL) - **Docker Ready** — docker-compose up and you're running - **Client Sync Engine** — Portable JS library for any frontend (React Native, Flutter JS bridge, Electron, web) --- ## Project Structure ``` inknote/ ├── docker-compose.yml # Deploy from the Gitea registry ├── docker-compose.dev.yml # Build from source for local work ├── .env.example # Copy to .env — Compose reads it from here ├── build.sh # Build & push images to Gitea ├── CHANGELOG.md # What changed per released image, with digests ├── CLAUDE.md # Conventions and standing decisions for this repo ├── backend/ │ ├── server.js # Express API + WebSocket server │ ├── sync-client.js # Client-side sync engine library │ ├── package.json # Node dependencies │ └── Dockerfile # Server container ├── frontend/ │ ├── src/ │ │ ├── App.jsx # Root: where the user is, which dialog is open │ │ ├── types.ts # Shapes shared by server, IndexedDB and UI │ │ ├── syncEngine.ts # Offline-first sync + API client │ │ ├── offlineStore.ts # IndexedDB wrapper │ │ ├── search.ts # Local full-text index │ │ ├── hooks/ # Note data, search, layout, history, drag │ │ ├── export/ # Markdown, ZIP and print-to-PDF │ │ ├── ui/ # Sidebar, dialogs, icons, palette │ │ ├── features/notes/ # List, editor pane, version history │ │ ├── features/notebooks/ # Dashboard │ │ ├── features/drawing/ # Canonical stroke model + render layer │ │ └── editor/ # TipTap schema, toolbar, note links, attachments │ ├── public/sw.js # PWA service worker │ ├── nginx.conf # Static serving + API/WS proxy │ └── dockerfile # Web container ├── e2e/ # Browser suites — run with ./e2e/run.sh └── readme.md # This file ``` --- ## Quick Start ### Where the data lives Both compose files use **bind mounts**, not named volumes, so everything sits in a plain directory on the Docker host and is picked up by whatever backs that host up: ``` $INKNOTE_DATA_DIR/ ├── db/ # inknote.db — notes, drawings, version history └── uploads/ # image attachments, legacy raster drawings ``` Default is `./data` for development and `/srv/inknote` in production; override with `INKNOTE_DATA_DIR`. It is a default, not an extra path — set it and the default location is never touched. **Create the directory before the first start.** Docker will happily create a missing bind-mount source itself, but it creates it as `root:root`, and the container runs as uid 1000 and then cannot write: ```bash mkdir -p /srv/inknote/{db,uploads} && chown -R 1000:1000 /srv/inknote ``` The path has to start with `/` or `./`. Anything else Docker reads as the name of a named volume, and a name containing a slash fails outright. #### Keeping it under `/root` `/root` is mode `0700`, so uid 1000 cannot pass through it — the server fails to write no matter who owns the directory underneath. Two ways out: ```bash # A) let uid 1000 traverse /root, without making it readable mkdir -p /root/inknote/{db,uploads} chown -R 1000:1000 /root/inknote chmod o+x /root # traverse only — `ls /root` is still refused # .env: INKNOTE_DATA_DIR=/root/inknote # B) run the container as root instead — no chown, no chmod, but a break-out # lands on root rather than on an unprivileged user # .env: INKNOTE_DATA_DIR=/root/inknote # INKNOTE_UID=0 # INKNOTE_GID=0 ``` A is the better trade: it keeps the unprivileged container, which is the layer that matters if the app is ever exploited. Neither is needed under `/srv` or `/opt`, which is why those are the default. > Back up the `.env` holding `ENCRYPTION_KEY` too. Note content is encrypted at > rest, so a copy of the database without that key cannot be read. ### 1. Local development (Docker) ```bash # Generate secrets once printf 'JWT_SECRET=%s\nENCRYPTION_KEY=%s\n' "$(openssl rand -base64 32)" "$(openssl rand -hex 32)" > .env # Build and run backend + nginx-served frontend docker compose -f docker-compose.dev.yml up -d --build # → http://localhost:3000 # Same, plus a Vite dev server with hot reload docker compose -f docker-compose.dev.yml --profile dev up -d --build # → http://localhost:5173 ``` `docker-compose.dev.yml` builds from source and uses its own volumes, so it never touches production data. ### 2. Docker Deployment ```bash mkdir -p /srv/inknote/{db,uploads} && chown -R 1000:1000 /srv/inknote cp .env.example .env # then fill it in, see below docker login gitea.honeyy.duckdns.org docker compose pull && docker compose up -d docker compose logs inknote-server | head # says which account became admin ``` `.env` needs `JWT_SECRET`, `ENCRYPTION_KEY` and — on a fresh install — `INKNOTE_ADMIN_USER` plus `INKNOTE_ADMIN_PASSWORD`, since there is no sign-up to create the first account with. On an install that already has accounts, `INKNOTE_ADMIN_USER` alone decides which of them is promoted; without it the oldest one is, which is rarely the one you meant. The web UI listens on port `3000`, the API only inside the compose network. If a reverse proxy sits in front, bind the port to localhost (`"127.0.0.1:3000:80"`), otherwise the app is reachable on the host's address without TLS. --- ## API Reference ### Authentication | Method | Endpoint | Body | Response | |--------|-----------------------|-------------------------------|---------------------| | POST | `/api/auth/login` | `{username, password}` | `{token, user}` | | GET | `/api/auth/me` | — | `{id, username, displayName, isAdmin}` | | PUT | `/api/auth/username` | `{newUsername, password}` | updated user | | PUT | `/api/auth/password` | `{currentPassword, newPassword}` | `{success}` | | GET | `/api/auth/methods` | — *(no token needed)* | `{password, oidc:{enabled,label}}` | | GET | `/api/auth/oidc/start` | — | redirect to the provider | | GET | `/api/auth/oidc/callback` | *(the provider's `code` and `state`)* | redirect home with a one-time ticket | | POST | `/api/auth/oidc/claim` | `{ticket}` | `{token, user}` | **There is no registration endpoint.** This is a private instance: accounts are created by an administrator or by the identity provider, and nothing on the login screen suggests otherwise. Usernames are 3–32 characters of letters, digits, `.`, `_` or `-`, and match case-insensitively. Servers created before this carried e-mail logins; on first start they migrate automatically, deriving each username from the local part of the address (logged to the console). ### Administration | Method | Endpoint | Body | Response | |--------|-----------------------------------|-----------------------------|----------| | GET | `/api/admin/users` | — | user list with note counts | | POST | `/api/admin/users` | `{username, password, displayName}` | created user | | PUT | `/api/admin/users/:id/password` | `{newPassword}` | `{success}` | | DELETE | `/api/admin/users/:id` | — | `{success}` | All four require an account flagged `is_admin`; the server checks this itself, so hiding the menu entry is presentation, not access control. An admin may not delete their own account. **Where the first admin comes from.** `INKNOTE_ADMIN_USER` and `INKNOTE_ADMIN_PASSWORD` in `.env`: | Database state | What happens at start | | --- | --- | | no users at all | the account is created from both values | | users, but no admin | `INKNOTE_ADMIN_USER` picks which one is promoted; without it, the oldest | | an admin exists | both are ignored | So the variables can never reset an existing password or take rights away — on a running instance the worst they can do is nothing. Set `INKNOTE_ADMIN_USER` *before* upgrading an older installation if the oldest account is not the one that should hold the keys. Deleting an account destroys its notes, notebooks, drawings and version history through `ON DELETE CASCADE`; the files behind attachments are collected by the hourly orphan sweep. The deleted person's session stops working immediately: tokens are signed rather than stored, so `authMiddleware` checks on every request that the account still exists. ### Signing in through an identity provider Optional, and off unless four settings are present (`.env.example` lists them): the issuer, a client id and secret, and the public URL this app is reached at. The provider is only ever named on the sign-in screen — nothing else about it is told to somebody who is not signed in. **It is a second way to the same token.** The password login mints a signed JWT and every route, including the WebSocket, knows only that; the provider route ends at the same `jwt.sign`. Nothing downstream had to learn what OIDC is, which is why the whole thing is one file ([`backend/oidc.js`](backend/oidc.js)) and no new dependency: discovery is one document, the exchange is one form post, and checking an ID token is a signature and five claims — Node imports a JWK directly. **Authorization Code with PKCE, exchanged on the server.** The client secret never reaches the browser, and neither does the session token: the callback redirects home with a **one-time ticket**, valid for a minute and consumed on first use, which the app swaps for its token. A token in a redirect URL would land in the history, in the proxy's log and in the referrer of the next request. **Accounts.** The provider's `sub` is stored on the account, and that is the link from then on — either side may be renamed without breaking it. On a first sign-in the subject is bound to an account of the same name if there is one, and otherwise **a new account is made**. Never an administrator: rights are granted in this app, and a group at the provider must not be a way around that. That first binding trusts the provider's usernames, and it is worth being explicit about what that means: whoever can set `preferred_username` over there can decide which account here they land in the first time. With a provider whose names are set by *its* administrator — PocketID, Authentik, Keycloak as they are normally run — that is exactly the intent. Do not point this at one where people choose their own name freely. **Both doors stay open.** An account created by the provider has no password here at all — the interface stops offering to change one, and the login route refuses it as it refuses a wrong one, at the same speed. An administrator can still *set* a password for such an account, which is deliberate: it is the way back in the day the provider is unreachable. Two things about the setting-up, both easy to get wrong: - the redirect URI at the provider must be exactly `/api/auth/oidc/callback`; - the **server** has to reach the issuer itself — for the token exchange and the key set — not merely your browser. Behind a reverse proxy those are different networks. The refresh token is deliberately not used. The provider is asked once, at sign-in; InkNote's own token carries the session from then on, and nothing here needs the provider to be reachable afterwards. That is what keeps the app working offline, which is most of the point of it. ### Notebooks | Method | Endpoint | Description | |--------|-----------------------|--------------------------| | GET | `/api/notebooks` | List all notebooks | | POST | `/api/notebooks` | Create notebook | | PUT | `/api/notebooks/:id` | Update notebook | | DELETE | `/api/notebooks/:id` | Soft-delete notebook | ### Notes | Method | Endpoint | Description | |--------|---------------------------|-------------------------------| | GET | `/api/notes` | List all notes (metadata) | | GET | `/api/notes/:id` | Get note with decrypted content | | POST | `/api/notes` | Create note | | PUT | `/api/notes/:id` | Update note (`{content, links}`) | | GET | `/api/notes/:id/backlinks`| Notes linking to this one | | GET | `/api/links` | Every link, for backlink counts | | DELETE | `/api/notes/:id` | Soft-delete note | | PUT | `/api/notes/:id/drawing` | Save vector strokes | ### Sync | Method | Endpoint | Description | |--------|------------- |------------------------------------------| ### WebSocket Connect to `ws://localhost:4000/ws`, then send: ```json {"type": "auth", "token": "JWT_TOKEN", "deviceId": "device-123"} ``` Receive real-time events: `note_created`, `note_updated`, `note_deleted`, `notebook_created`, etc. --- ## Sync Engine (Client) ```javascript import { SyncEngine } from './sync-client'; const sync = new SyncEngine('https://your-server.com', 'device-abc'); // Authenticate await sync.authenticate('username', 'password'); // Listen for events sync.on('status', (status) => console.log('Sync:', status)); sync.on('conflict', (conflict) => { // Present both versions to user, let them choose console.log('Conflict:', conflict); }); sync.on('remote_change', (change) => { // Update local state with remote changes console.log('Remote change:', change); }); // Start syncing (every 30s + WebSocket push) sync.start(); // CRUD operations auto-queue for sync await sync.createNote({ type: 'text', title: 'Hello', content: '

World

' }); await sync.updateNote('note-id', { title: 'Updated' }); await sync.deleteNote('note-id'); await sync.uploadPDF(fileBlob); await sync.saveDrawing('note-id', strokeDoc); ``` --- ## Working on the frontend ```bash npm run test # Vitest — store, search and content migration npm run typecheck # tsc --noEmit npm run format # Prettier npm run build # production bundle ``` TypeScript is enabled with `allowJs`, so `.js` and `.ts` coexist and modules can be converted one at a time. The whole data layer is typed — `types`, `offlineStore`, `search`, `editor/content` and `syncEngine` — because that is where the bugs have been: every serious one so far has been a shape bug, or a failure distinguished by comparing an error's `.message` to a magic string. Failures now carry a typed `SyncError.code`; `.message` still holds the same string, so the remaining JavaScript callers are unaffected. The 218 unit tests concentrate on the same places, and several exist because the bug they cover actually happened: - a queue entry that fails must stay queued, with an attempt counter - `fullSync` must not drop notes created offline - `hasPendingWrites` must cover the debounce window, not just requests in flight - v1 note bodies must migrate without losing task state or smuggling in markup ### Reordering, and menus, with a finger Both were built for a mouse and quietly did nothing on a phone. **Dragging** went through the HTML5 drag-and-drop API, which mobile browsers never fire from touch — so the arrangement could not be changed at all, with nothing on screen to say why. [`useReorderDrag`](frontend/src/hooks/useReorderDrag.ts) uses pointer events instead, which mouse, pen and finger all produce. The two devices get different starts, because they want different things: - **A mouse** begins a drag by pressing anywhere on a row and moving a few pixels. - **A finger** begins one only from the grip. Pressing the row has to go on scrolling the list, and holding it has to go on opening the menu; a drag that started from anywhere would take both. The row under the finger is found with `elementFromPoint`, not with enter/leave handlers — a finger generates no `pointerover` on anything it did not press. **Two lines in the same gap keep the order they were written in.** A separator's position counts the rows above it, so two lines with nothing between them carry the same number, and only the order they are stored in can tell them apart. That took two fixes for one symptom. The comparator used to say "a separator comes first" without asking what the other one was — claiming both directions at once for that case, and a comparator that contradicts itself has no defined result. And the write was then thrown away: the "did anything change?" test **sorted** the separators before comparing them, to ignore the other chapters' lines riding along in the same field — and a sorted comparison cannot see a sequence. The move was computed correctly, drawn for a frame, and discarded. It compares this list's own lines, in order. **A drop lands the row under the one it was released on, and only ever there.** Dropping above a row and dropping below the row before it are the same move described twice, so every gap could be aimed at from two sides — which made a drag hard to aim and harder to read. One direction, one line, drawn under the row it will land under. The two ends of the list have strips of their own: the one below the last row was always there, and there is now one above the first, because that is the single place a drop *under* something cannot reach. **Rearranging is a mode**, switched on by the handle button beside the sort menu and off again by itself when the list is left or the order stops being a hand-made one. Only while it is on does a row wear its grip or move at all. Rows are read constantly and dragged rarely, so six dots beside every title was clutter that also made an ordinary press able to carry a row off. Filing a note somewhere else is not part of it: that is one of the things done to a row from the row's own menu, like starring, exporting and discarding. **The context menu** was placed with `innerWidth - 220`, a stand-in for a width nobody had measured, and its submenu was pinned to `left: 100%` and opened only on hover. On a phone the parent already sits against the right edge, so the list of notebooks under *Move to…* appeared past the edge of the display — and with no hover, there was no way to open it in the first place. It is now placed by measuring, opens on a tap, unfolds in place on a narrow screen, and flips to the other side on a wide one when the right would not fit. *Move to…* holds two different kinds of place, so it **says which is which**: the notebook itself, then *Chapters here*, then *Other notebooks*. The headings are deliberately not shaped like entries — small, faint and set apart — because a heading built like a menu row is a button that does nothing when pressed. Each row carries the mark its chapter was given or the cover its notebook wears, so a place is recognised in the menu the same way it is recognised in the list. ### Installing it The install entry used to appear only when the browser had offered a prompt (`beforeinstallprompt`). Where installing was impossible there was nothing on screen at all — no button, no reason, and no way to find out short of knowing how PWAs work. **The prompt itself was being missed.** `beforeinstallprompt` fires once, early — usually before React has mounted — and is never fired again. The listener lived in a component, so on a correctly served site the event arrived, found nobody listening, and was gone for the session. It is caught at the top of `index.html` now and handed over when the app is ready. **And the manifest was being fetched without credentials.** A `` is fetched with credentials omitted unless it says otherwise, so behind a reverse proxy that authenticates in front of the app that one request comes back as the proxy's login page. The browser reads it as an invalid manifest and offers no install — while the app itself works perfectly, because its own requests carry the cookie. The link now carries `crossorigin="use-credentials"`, which is harmless without a proxy. The service worker had the matching problem: it cached whatever came back. A lapsed session could be stored as the application itself, and the app would then "work offline" by showing a login screen it could never get past — curable only by clearing the site's data. It now caches only responses that are successful and not redirected, and `Cache.put` is guarded, since it rejects on a redirect and that rejection was going unhandled on every navigation. Behind such a proxy, three paths have to reach InkNote intact: `/manifest.json` and `/sw.js` must return the file rather than a login page — a **redirect** on `sw.js` fails registration outright, taking offline with it — and `/ws` needs its WebSocket upgrade passed through, or changes from another device only arrive with the 30-second reconciliation. The entry is always there, and when there is no prompt it says which of the fixable causes applies. The common one is worth naming: **a browser will only install a site served over HTTPS**, and on plain HTTP the service worker never registers either — so one cause takes the install *and* offline with it, which otherwise looks like two unrelated mysteries. ### Notebook covers **A cover is a colour and a pattern, chosen in turn** — `COVERS` and `PATTERNS` in [`ui/constants.jsx`](frontend/src/ui/constants.jsx), stored as `notebooks.cover_index` and `notebooks.cover_pattern`. Two decisions rather than one list. Offering every combination as its own entry would mean fifteen colours times a dozen patterns in a grid nobody could take in — and in practice only the handful someone had thought to write out. Picking each in turn is shorter *and* gives every combination. Each pattern is previewed over the colour currently chosen, so what is on screen is the cover as it will be. The patterns are CSS gradient layers laid over the colour — no image files, nothing to upload, nothing to fetch, crisp at any size, offline like the rest of the app. They are deliberately faint: a cover is read at the size of a thumb with a title across it, and anything louder fights the title. One idea each, too — two crossed diagonals meant as a chevron came out as noisy plaid at that size. `COVERS` is an index, so entries are only ever appended: renumbering them would silently repaint every notebook anyone already has. For one day the patterns *were* twelve appended entries, before they became their own axis; `coverParts()` reads those indices back as the colour-and-pattern pair they were drawn as, so nothing that was chosen then looks different now. Both grids scroll. Without that the dialog runs past the bottom of a laptop screen and takes the *Create* button with it, which a test checks at 1440×800. ### Browser suites The unit tests cover the data layer; these drive a real browser against a real server, because the failures that hurt most in this app were never visible in a unit test — a stale response overwriting fresh strokes, a note link surviving in memory but not through a reload. ```bash docker compose -f docker-compose.dev.yml up -d --build # must be running ./e2e/run.sh # all suites ./e2e/run.sh editor drawing # only these ``` | Suite | Checks | What it holds down | | --- | --- | --- | | `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly | | `oidc` | 26 | the whole redirect journey against a provider that signs real tokens — and the four ways it must fail: a refusal, a replayed ticket, a token signed with an unpublished key, a token addressed to another client | | `editor` | 40 | 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` | 22 | 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` | 23 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back | | `organise` | 75 | a hand-made order survives a search; a new note lands on top and a deleted one takes only itself; one line goes under another and stays there | | `chapters` | 65 | a filed note stays filed across a sync; a chapter is dragged among its neighbours *and* takes what is dropped in it; its own order is set in its own dialog | | `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection | | `tables` | 11 | a long table is broken between its rows, with none left lying across the gap, no page furniture reaches the stored note, and the column edge being dragged is drawn on the cell rather than in it | | `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 do that with — `marc`/`geheim123` by default, override with `INKNOTE_ADMIN` and `INKNOTE_ADMIN_PASS`. Screenshots land in `e2e/shots/`, which is worth a look when a check fails. ### How the frontend is arranged `App.jsx` holds only *where the user is* — the open notebook, the open note, the search box, whichever dialog is up — and hands each of the three panes what it needs. Everything else lives behind a hook: | Hook | Owns | | --- | --- | | `useNoteData` | notes, notebooks, bodies, every mutation, background sync | | `useSearchIndex` | the local index and the background backfill that fills it | | `useResponsive` | phone vs desktop, sidebar and which pane a phone shows | | `useNavigationHistory` | mapping the current location onto browser history | | `useNotebookDnd` | drag-to-reorder, shared by the sidebar, the dashboard and the note list | | `usePwaInstall` | the browser's install offer | `useNoteData` deliberately has no confirmation prompts in it: whether to ask before deleting something is a question for the interface, not the store. The panes (`Sidebar`, `Dashboard`, `NoteList`, `EditorPane`) take props and hold no state of their own, so they can be read on their own. --- ## Setting a note **A centred column, about 80 characters to the line** — measured, not guessed. Past roughly 84 the eye stops reliably finding the start of the next line, so the column is capped a little short of that. It leaves roughly 8% of the sheet as margin down either side: a document's margin, not a picture frame. At 73 characters it left 117px of empty paper down each edge and the writing looked lost on the page. It was pinned to the left margin for a while, on the reasoning that a column which floats in the middle moves as the window is resized. True, but it traded that for something worse: on a wide window the entire right half of the pane became one dead block. A page surface was then drawn behind the text to give that block a meaning, which only moved the problem — the whole sheet sat hard against the left edge. Centred, the same leftover space reads as two margins and needs no explaining. **The typeface is a per-account setting** (Account → Profile): serif, sans or mono. How a chapter opens is chosen there too, for the same reason. On the account rather than in the browser, because a choice about how your own notes are set should follow you to the phone rather than be made again on every device. The server accepts those three values and drops anything else — that column is written by a browser, so it is an input like any other. **You write on paper.** Each page is a sheet of its own, centred, with a gap after it — the same way the PDF reader draws its pages, because a page in a note and a page in a document should read as the same kind of thing. It also answers what a bare column of text could not: the space either side of the writing is the sheet's margin, rather than an unexplained void. The sheet used to appear only once you started drawing, which made the writing mode look like text floating in an empty pane. **The sheet is measured before the first paint**, not after. It starts at the canonical page the drawing lives in — 1200 × 1600 — and is fitted to the pane by a measurement; as an ordinary effect that ran a frame late, so every note opened by drawing one frame of a sheet sixteen hundred pixels tall and then righting itself. A `useLayoutEffect` runs between the DOM being built and the browser painting it, which is exactly the gap that flash lived in. A test samples the sheet's height every frame across a note switch, because one frame is all it was. **A quote holds whole blocks.** A heading, a list and a closing line inside one are ordinary blocks of the document, and they are spaced like ordinary blocks: the rule that gives the writing its rhythm reaches only the editor's own children, so inside a quote everything stood jammed against its neighbour — with the items of a list further apart than the blocks around them, which read as the quote being broken. The rule for a quote's children is written *after* the ones for paragraphs and headings on purpose: they are exactly as specific, so earlier it would lose. A paragraph inside a list item is more specific still and keeps its zero. **Pages are counted, not stored.** The toolbar says *Page 2 of 4* in the same words the PDF reader uses. A page is the same 3:4 the canvas uses, so "page 2" means the same thing writing or drawing. The note itself stays one continuous document — Markdown has no page, and a test asserts none ever reaches the stored body. The current page is the one 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. ### Holding the gap open The browser knows nothing about the sheets. Left alone it lays the text out as one continuous flow and puts a line straight across the gap, cut in half by it. So [`editor/pagination.js`](frontend/src/editor/pagination.js) measures the flow and pushes any block that would straddle a gap onto the next page, by inserting a spacer of exactly the right height in front of it. The spacers are **decorations, not content**: they never enter the document, never reach the server and never appear in an export. Two tests hold that down — one asks whether any block sits in a gap, the other reads the stored body back and looks for them. The unit is a whole block. A paragraph that would be split moves down entire, which is what a page break means on paper. **A table is the exception, and has to be.** Moved whole it either jumps to the next sheet leaving the rest of this one blank, or — being longer than a sheet — has nowhere to jump to and lies straight across the gap with a row cut in half. So a table is broken **between its rows**, which is where a table breaks on paper too. The spacer there is a row of its own: a `div` inside a `tbody` is not a thing the browser will keep, while a `tr` with one borderless cell pushes the rows after it down exactly as far as needed. Its own spacer rows are subtracted again on the next measurement, and counted into the shift every block below it inherits — miss that and everything under a broken table is measured a page too high. Anything else too tall to fit anywhere — a full-page image — is left where it is and runs over the gap: moving it down only puts the same problem one sheet further on. **The column edge you drag is drawn on the cell, not in it.** The editor marks the column under the pointer by putting a small div in each of its cells, and the obvious thing to do is position that div along the cell's right edge. It works, and it paints grey letters down the bar: an absolutely positioned child inside a cell of a `border-collapse: collapse` table sends Chromium down another painting path, and the cell's own text comes out a second time, squeezed into the two pixels under the bar, one letter to a line. It appeared with the bar and went with it — which named the culprit, since the DOM at that spot held nothing but three empty markers. The marker is `display: none` now and only says which cells the column is made of; the line is an inset shadow on those cells, matched with `:has()`. Nothing is positioned, so nothing paints twice, and the line looks the same — continuous down the column, because each cell carries its own piece. Two details that are easy to get wrong and were: - **The spacer's height is not the distance the text moves.** Margins that used to collapse into each other no longer do once something sits between them, so the layout gains `min(marginBottom, marginTop)` for free and the spacer is made exactly that much shorter. The spacer carries the intended shift in a data attribute, because the next measurement has to subtract it again to recover where the text would sit with no spacers at all. - **Heights change without a transaction to announce it** — a webfont finishing loading, an image arriving, the window resizing. A `ResizeObserver` on the editor element catches all three. Each page can be drawn on — see [One drawing, as many pages as the note](#one-drawing-as-many-pages-as-the-note). ### Alignment Markdown is often said to "basically be HTML". It is not — it allows *embedded* HTML, and has no alignment of its own. So alignment works fully in the app, in print and in the PDF, and in a `.md` file it can only travel as a tag: - Non-left blocks are wrapped in `
`, the one form GitHub and most other readers still honour. A reader that does not know it shows the text, which is the right way to fail. - Left is never written. It is the default, and wrapping every paragraph in a div would turn an ordinary note into markup for nothing. - The importer reads the wrapper back and puts the alignment **on the paragraphs**, which is where the editor keeps it — not on a container. ## Interface language English throughout. It had drifted into two languages — the frame in English, everything added later in German — which is worse than either: the same idea appeared under two names depending on when that part was written. The one place German is deliberately kept is sorting by title, which compares with the German collation so that *Übung 2* comes before *Übung 10* and umlauts sort where a German reader expects. That is about the notes, not the chrome. Two archive folders were renamed with the interface (`bilder/` → `images/`, `zeichnungen/` → `drawings/`). **The importer still reads both**, along with the old German marker line for a drawing — archives exported before the change are on people's disks, and an import that quietly dropped their drawings would be the worst kind of tidy-up. --- ## Note Content The body of a note is a **TipTap/ProseMirror document** stored as JSON: ```json { "v": 2, "doc": { "type": "doc", "content": [ … ] } } ``` Tables, task lists and note links are real nodes rather than HTML that happened to survive `document.execCommand`. Notes written before this were `{ html, tasks }`; they are converted to a document the first time they are opened and rewritten in the new format on the next save, so nothing needs migrating up front. ### Note links and backlinks Typing `@` opens a search over notes and notebooks and inserts a `noteLink` node holding the target's id — so a link survives the target being renamed, and the label is refreshed from the current title whenever the note is opened. The server cannot read encrypted note bodies, so it cannot discover links itself. The client sends the ids it references alongside the content, and the server maintains a `note_links` index from them. That index is what powers `GET /api/notes/:id/backlinks` and the "Linked from N notes" bar under the editor. --- ## Full-Text Search Search runs entirely on the client. The server stores note bodies encrypted and deliberately keeps no plaintext index, so it cannot search them — and a server-side index would undo the point of encrypting at rest. Bodies are normally fetched only when a note is opened, which would leave a 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. The box sits above the note list, always, with nothing to open first. It was behind a magnifying glass, which 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. 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. > Bodies of notes edited on another device are refreshed when that note is > opened, so search results for untouched notes can lag behind until then. --- ## Reading PDFs An imported PDF becomes a note of type `pdf` and is shown by a viewer instead of the text editor. Pages are laid out at their true proportions before anything is drawn, so the scrollbar is honest from the first frame; a document mixing portrait and landscape keeps both, rather than stretching the narrow pages. Only pages near the viewport are rendered, and each keeps its own canvas — a PDF page is expensive to draw and cheap to hold, so scrolling back up repaints nothing. Canvases are sized in device pixels and scaled down by CSS, which is what keeps text sharp on a high-density screen. **pdf.js is not in the main bundle.** It is larger than the rest of the application put together, so it sits behind a dynamic import: a session that never opens a PDF never downloads it. The worker is bundled too, not fetched from a CDN — a viewer that stops working during someone else's outage is not one you can rely on offline. The **legacy** pdf.js build is used deliberately. Version 6 calls `Uint8Array.prototype.toHex`, which only very recent browsers have; anything older fails to parse the document with a message that says nothing about why. Bytes are read through the authenticated attachment route and cached in IndexedDB, so a document opened once stays readable with no connection. Because storage is content-addressed, a cached copy can never go stale. > nginx needs a MIME type for `.mjs` — without one it serves the worker as > `application/octet-stream` and the browser refuses to run it as a module. --- ## Exporting an annotated PDF A PDF note offers three things rather than the note formats: the document with your drawings written into it, the untouched original, or just the notes you wrote beside it as Markdown. pdf.js can only read, so producing a file needs a second library — `pdf-lib`, loaded the same way, behind a dynamic import, so the bundle everyone downloads carries no PDF writer for the sake of the people who never export one. This is where the coordinate decision pays off. Strokes were stored in page points from the start, which is the only space a PDF understands: nothing has to be guessed back from screen pixels, and the stamping is a direct translation apart from flipping the origin, which PDF puts at the bottom-left. **The stored file is never modified.** `pdf-lib` builds a new document, so a clean copy of the original stays available however often it has been drawn on — checked by the suite, which re-fetches the stored bytes after exporting and compares them. Erased areas cannot come along. On screen an eraser removes ink by drawing in `destination-out`; a PDF content stream has no equivalent, so those strokes are dropped rather than stamped as visible grey scribbles where something was rubbed out. The suite renders both files and counts dark pixels — 1633 against 2557 — because a larger file proves bytes were added, not that any ink can be seen. --- ## Notes beside a PDF A PDF note is a note that happens to have a document attached, not a separate kind of thing: what you write beside it goes into the note's ordinary `content`, so it is encrypted, versioned, linkable and searchable like any other note. The panel is composed from the editor pieces directly rather than reusing `NoteEditor`, which carries a whole drawing mode with its own canvas and undo stack — the PDF has its own drawing layer, and a second one hidden behind the panel would be two places to draw with one of them invisible. Side by side on a wide screen, two tabs on a narrow one. Both halves can be hidden: reading the original without one's own marks in the way is a normal thing to want, and so is writing without the document taking two thirds of the screen. **One trap this uncovered.** A PDF note has two sources of searchable text — what the reader typed and what the document says — written at different moments by different code. Both called `setEntry`, so whichever ran last was the only half you could search: typing a note dropped the document's text, and re-reading the document dropped the note. The index now keeps them apart and only replaces the half it was given. --- ## Selecting and searching PDF text pdf.js draws pages as pictures, so selecting means laying transparent spans over them exactly where each word was painted. The layer carries the same scale as the canvas — the two drift apart otherwise and a selection lands on the wrong words. While drawing it stops taking pointer events entirely, or a stroke would come out as a text selection. The text is also pulled out and handed to the same index that holds the notes, so one search box finds a sentence whether it was typed or imported. Extraction runs *after* the document is on screen and yields between pages: it must never stand between the reader and the first page. ### 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 viewer's toolbars and dialogs. --- ## Drawing on a PDF Strokes are stored **per page, in that page's own coordinate space** — PDF points at scale 1, exactly what pdf.js reports — rather than in the fixed 1200×1600 space a note's drawing uses. A PDF cannot use one space: every page has its own size, and a stroke belongs to the page it was drawn on. That choice is what keeps a mark on the word it was drawn over at any zoom, on any screen, and it is the only form that can be stamped back into the file later, where page points are the only coordinates that mean anything. The suite measures it: zooming in moves a stroke by 0.001 of the page. ```jsonc { "v": 2, "pages": { "1": { "w": 595, "h": 842, "strokes": [...] } } } ``` Only pages that carry ink get an entry. Pen sizes are scaled from the note page width, or the same number would draw twice as thick on an A4 page half as wide. Reading and drawing are separate modes. While reading, the ink layer takes no pointer events at all, so scrolling and selecting text reach the page beneath; while drawing, a finger still scrolls and only a pen or mouse leaves a mark. The drawing can be hidden without being erased, for reading the original clean. --- ## PDF storage An imported PDF is stored as an attachment: encrypted, named by the hash of its contents, and shared by any note holding the same file. The note points at it through `notes.pdf_attachment_id`. It goes up as a raw body rather than the base64 JSON images use — a scanned book runs to tens of megabytes and base64 would add a third to that for nothing. The bytes are checked for the `%PDF-` signature before anything is stored, so the declared content type is never taken on trust. Images and PDFs are limited separately, because a 25 MB picture is nearly always a mistake while a 25 MB document is not: `INKNOTE_MAX_IMAGE_MB` (25) and `INKNOTE_MAX_PDF_MB` (200). The limits are there so a runaway upload cannot fill the disk, not to ration anything. > An earlier `POST /api/notes/pdf` stored a *filesystem path* in the note, which > is why it never survived a container rebuild. That route is gone. --- ## Encryption at rest Note bodies and uploaded files are both encrypted with AES-256-GCM under `ENCRYPTION_KEY`. Losing that key loses the notes — back it up with the database, not next to it. Files carry an 8-byte `INKENC01` header, so a file says for itself whether it is encrypted. That is what lets the startup sweep run on every boot and encrypt only what is still in the clear: files are shared between users by content hash, so a flag on a database row could not have answered the question. Two consequences worth knowing: - **Deduplication still works.** Files are named by the hash of the *plaintext*, so two identical uploads share one copy — encrypting each with a fresh IV would otherwise produce two different files from the same picture. - **Reads are not streamed.** GCM only proves a file is intact once the final block is in, so an attachment is read whole and verified before anything is sent. Half of a corrupt image is worse than an error. Uploads are written to a temporary name and renamed into place. A rename is atomic on one filesystem, so a crash mid-write leaves the old file or the new one, never a half-written one that would fail its tag forever after. --- ## Freeing attachments Take a picture out of a note and the file cannot simply be deleted: the versions from before still show it, and restoring one would find a hole where the image was. So an attachment is released only once **no current note and no kept version** refers to it — which, with retention by count, is the moment the last version holding it is pushed out by newer ones. The server cannot look for itself: bodies are encrypted. So the client reports which attachments a note uses on every save, exactly as it already does for note links, and a version inherits whatever the note held at the moment it was snapshotted — which is by definition what that version contains. Two things make this safe rather than merely plausible: - **Silence is not "uses nothing".** A note written before references existed has no rows, which looks identical to a note with no pictures. Nothing is released for a user while any of their notes has yet to report; the background prefetch that fills the search index reports for them on its first pass. - **An upload in flight has a row before the note that uses it does.** Attachments younger than the grace period are left alone. `POST /api/admin/housekeeping` runs the sweep on demand instead of waiting for its timer — admin only, and it accepts `graceSeconds` to shorten that wait. --- ## Attachments Images used to be inlined into the note body as base64 data URIs — a few screenshots turned one note into megabytes that were re-encrypted and re-uploaded on every keystroke. They are now separate blobs; the body only keeps an id. - Stored on disk under the SHA-256 of their bytes, so the same image pasted into ten notes is kept once - Uploads are type-checked against the actual bytes, not the declared MIME type - Downloads require the owner's token, so `` cannot fetch them directly — the client fetches with its token, caches the blob in IndexedDB and renders a blob URL, which also makes images work offline - Files with no database row are swept up daily, one hour after last write | Method | Endpoint | Description | |--------|-------------------------|------------------------------------| | POST | `/api/attachments/pdf` | raw PDF body, `?filename=` | `{id, mime, size, deduplicated}` | | POST | `/api/attachments` | Upload a base64 data URL | | GET | `/api/attachments/:id` | Fetch the bytes (owner only) | ### Where it all actually sits Two directories, both bind-mounted from the host so that whatever backs up the host backs up InkNote: ``` /srv/inknote/db/inknote.db every note, encrypted body and all /srv/inknote/uploads/attachments/ one file per distinct upload, encrypted /srv/inknote/uploads/drawing_.png legacy raster drawings ``` **There is no folder per user, and there is deliberately nothing on disk to read.** Who owns what is a column in the database (`attachments.user_id`), not a path. The file name is the SHA-256 of the *plaintext* — encryption uses a fresh IV each time, so hashing the ciphertext would store the same photo twice — and the bytes beside it are AES-256-GCM. So a file name tells you that two people uploaded the same document, and nothing else: not who, not what, not which note. Sorting uploads into per-user folders would name the owner in the clear on a disk where nothing else is readable, and it would break the sharing that keeps one copy of a file two accounts happen to have. Deleting stays safe without it: a blob goes only when **no** row in `attachments` points at it any more, whoever owned them. --- ## Version History Every write snapshots the state it is about to replace into `note_versions`, encrypted the same way the note itself is. Saves arrive about once a second while typing, so snapshots **coalesce**: within a 5-minute window the existing newest version already represents "before this editing session". Two cases bypass the window, because they are exactly the ones worth undoing: - a write that shrinks a note to under half its size (`large-deletion`) - clearing a drawing (`drawing-cleared`) Restoring snapshots the current state first (`before-restore`), so picking the wrong version is not a one-way door. A restored body also rebuilds its entries in the backlink index. **Retention is by count, not by age.** The newest N versions of a note are kept and trimmed on every write; nothing expires merely for being old. There used to be a second rule dropping anything past 90 days, and it was a bad one: the count already bounds how much a note can accumulate, so age only ever deleted history that nothing had replaced — and it bit hardest exactly where history is most wanted. A note left alone for a quarter of a year lost its whole past for having done nothing at all. `VERSION_RETENTION_DAYS` still exists and still works, but it is off unless set. **How many is decided per notebook.** Edit a notebook to give it its own number; leave the field empty and it follows the server's default. A reference notebook can keep fifty versions of everything while a scratch one keeps three. | Variable | Default | Meaning | |----------------------------|---------|------------------------------------------| | `MAX_VERSIONS_PER_NOTE` | `50` | Default for notebooks with no setting | | `VERSION_RETENTION_DAYS` | *off* | Optional age limit; unset means never | | `VERSION_COALESCE_MINUTES` | `5` | Window in which saves share one snapshot | **Deleting a note** — moving one to the trash keeps its history, because the note can still be restored. Deleting it permanently, or emptying the trash, removes its versions along with it (`ON DELETE CASCADE`), leaving no orphaned rows behind. | Method | Endpoint | Description | |--------|-------------------------------------------|----------------------------| | GET | `/api/notes/:id/versions` | List (metadata only) | | GET | `/api/notes/:id/versions/:versionId` | One version, decrypted | | POST | `/api/notes/:id/versions/:versionId/restore` | Restore it | > Version history protects against **overwritten** data. It does not protect > against a lost disk, because it lives in the same database — that is what > backing up the data directory is for. --- ## Chapters Folders inside a notebook. A note is in exactly one chapter, or in none — which means the notebook itself. Chapters are listed above the loose notes; tapping one opens its own list, and the menu button becomes a way back out, which on a phone is the only way, since the list fills the screen. **A chapter is not a wastebasket.** Removing one never removes what it held: the notes come back to the notebook, where they can be seen and filed again. That is `ON DELETE SET NULL` on `notes.chapter_id`, and it is also written out explicitly in the delete route — it is the promise the whole feature rests on, and it should be visible where it happens rather than inferred from a schema. The dialog says so too, with the number of notes affected. Three rules that keep the two levels honest: - **A note filed into a chapter leaves the notebook's own list.** Showing it in both would mean filing it appeared to do nothing. - **A chapter belongs to its notebook.** Filing a note into a chapter of a book it is not in is refused by the server; moving a note to another notebook clears its chapter, since the old one is in the book it just left. - **Separators belong to the list they were drawn in.** A separator's `order` counts the notes before it, which only means anything inside one list, so each carries the chapter it belongs to — `null` for the notebook itself. The notebook still stores one list of them, and a change made in one chapter writes the others back untouched. A note made while a chapter is open is filed there without being moved. A chapter is **named, marked and coloured in one dialog** — the same dialog whether it is being made or changed, with the row drawn at the top as the list will draw it. Asked one at a time from three menu entries, getting a new chapter to look right took three visits. Its menu therefore holds one *Edit* and one *Remove*. **A row is put into a chapter by dragging it onto it.** While rearranging is on, a chapter row takes what is dropped on it and lights up to say so, rather than growing a drop line: a chapter is a place, not a neighbour. Notes and separators both go in this way. But it is a neighbour as well, and taking the *whole* row as "into" quietly took that away — a chapter could no longer be moved among its neighbours, and nothing could be put in the gap under one. So the **bottom edge of a chapter's header is the gap** and the rest of it is the chapter. The header, not the row: an open chapter carries its notes inside that row, and holding over those plainly means the chapter. A chapter shows even when the notebook holds nothing else. "Nothing here" is asked of every row, not of the notes alone — asked of the notes, a notebook whose only contents were chapters showed the empty-list panel with the chapters hidden behind it, and making a chapter in a fresh notebook appeared to do nothing. ### How a chapter opens Two ways, chosen per account under Account → Profile, beside the typeface and for the same reason: it is a choice about how your own notes are laid out, and it should follow you to the phone. - **Beside the list** — its notes in a further column, the way OneNote lays out its pages. The notebook's own list carries on to the left, chapter rows and all. - **Under the chapter** — its notes unfold in place, indented against a rule in the chapter's colour. A phone has room for neither. There the chapter fills the list and the header button becomes a way back out, which is what it did before there was a choice. **Which list is on screen is not the same as which chapter is open.** Beside and below, the middle column still shows the notebook; only the phone's fallback replaces it. The two were one value for a while, and a separator drawn in a notebook vanished the moment a chapter was opened — the list still showed the notebook, but everything around it had switched to the chapter, so the notebook's own separators were filtered out and a drag in that list wrote nothing at all. **Which of the two you are in is a separate question again**, because both are on screen at once. Opening a chapter puts you in it; pressing in the notebook's own list puts you back in the notebook without closing the chapter. Whichever it is wears a line along its top, and the + button names the place it would make the note in. Before, a new note went into the chapter however far away you had since clicked, and nothing on screen said so. ### A chapter is a row like any other Chapters sit at the same level as notes and separators: same grip, same drag, same place in a hand-made arrangement. That takes **one counter for both** in `toArrangement` — numbering notes and chapters separately would leave two sequences that cannot be interleaved again, and a chapter dragged between two notes would spring back to the top. Positions therefore have to be *named* rather than taken from an array index, which is why `POST /notes/reorder` accepts `positions: [{id, order}]` beside the older `ids`, and why chapters have a reorder route of their own. --- ## Sorting a notebook Each notebook remembers how its notes are ordered, and remembers it on the server rather than per device — the order is a property of the notebook, and finding one sorted differently on the phone than on the desktop is its own small annoyance. A reference book can stay alphabetical while a journal stays newest-first. **A notebook starts in the hand-made order.** That is where a notebook's own arrangement lives: separators show, rows wear their handles once rearranging is switched on, and nothing slides about by itself. A notebook nobody has arranged still reads newest-first — untouched rows all carry position 0 and fall back to it — so nothing is lost by starting there, while starting in "last changed" made both separators and dragging look broken until the sort menu was found. **A chapter orders its own notes.** The notebook's setting governs the notebook's own list; a chapter is a place someone made and filled, so it carries a mode of its own — set in the same dialog as its name, mark and colour, because they are one decision about one thing. It starts in the hand-made order, which is what filing a note into a chapter means: it stays where it was put. A chapter in one of the other orders loses its lines and its handles, exactly as the notebook does, because neither means anything against an order the app is choosing. While it *is* in the hand-made order, its rows are dragged with the **same switch** as the list beside it — two switches for two lists looked at together is one too many. The two lists keep out of each other's way by scope: a chapter unfolded under its row holds rows *inside* a row of the notebook's list, so "the nearest row above the pointer" cannot say which list a drag belongs to. The two strips at the ends of a chapter's list appear only while something is being carried; standing open for the whole of the mode they added half a finger of empty space to every open chapter. **A row leaving is the same problem as a row arriving, in reverse.** Deleting a note above a line left the line counting rows that were no longer there, so everything under it appeared to jump up past it — which is what made deleting feel as though it scrambled the notebook. `withoutRow` renumbers the list the row is leaving, and every place a row leaves one says so: the bin, a move to another notebook or chapter, and a chapter being removed. Restoring from the bin and moving into a list are arrivals, and arrive at the top like anything else — an old position belongs to an arrangement that has moved on. Removing a chapter is both at once: its row leaves the count, and the notes it held arrive at the top in the order they had inside it. **A new note is put at the very top, and the list is renumbered to say so.** Left to the tie-break it landed near the top by age, which is not the same thing: a separator's position counts the rows above it, so a row appearing without a renumber left every line one row too high, and the arrangement came apart a little more with each new note. `placeOnTop` builds the order from the list *without* the new note — measuring the room after the furniture has moved would write the misreading back as the truth — and puts the note in front of it. Every other row keeps its place, and the rows that had never been dragged stop sliding about, because now they carry a position of their own. The views are different: "All Notes", "Starred" and the bin are questions asked of every notebook at once, so there is nothing for an arrangement to belong to and nowhere to keep it. They start in "last changed" and are not offered the hand-made order at all. | Mode | Order | | --- | --- | | `manual` | the user's own arrangement, dragged into place — notebooks only, *and what a notebook starts in* | | `updated` | most recently changed first *(the default for the views: All Notes, Starred, the bin)* | | `oldest` | least recently changed first | | `created` | most recently created first | | `title` | A–Z, case- and umlaut-aware, with numbers read as numbers | **The manual order has to survive being looked away from.** Searching imposes its own order — relevance — and switching back has to show exactly what was arranged. That is only keepable if the arrangement lives in the notes and is never re-derived from what is on screen, so two rules hold it up: - `reindex` renumbers **every note in the notebook**, taken from the store, not the list being displayed. Renumbering a filtered subset would give a hidden note the same position as a visible one and quietly scramble the rest. - Dragging is switched off while a search is narrowing the list, and the list says so rather than silently ignoring the drag. "All Notes" and "Starred" are not offered a manual order at all. They are questions asked of every notebook at once, so there is nothing for an arrangement to belong to and nowhere to keep it. **Icons.** A note and a chapter can each wear a mark of their own beside the name — chosen from a short searchable list rather than the system emoji keyboard, which is a different thing on every machine and offers three thousand pictures for a decision that is "which of these is the invoice notebook". The list is searched by what a thing is *for* ("invoice", "house", "travel"), because nobody hunts a pictogram grid by shape. The same glyph shows in the list, above the editor **and in every link pointing at the note**, so it is recognised by one sign everywhere. A note's mark takes a colour too; a chapter's uses the colour the chapter already has. Marks get in two ways. **Anything can be typed into the search box** — it is a text field, which on a phone is exactly where the system emoji keyboard lives, so a mark the list never thought of is offered as a tile and used like any other. Eight code points at most, the same limit the server keeps, so one glyph with its modifiers fits and a pasted sentence does not; letters and digits mean a word was typed and a search is meant. The list itself lives in one place, [`ui/IconChooser.jsx`](frontend/src/ui/IconChooser.jsx), and **adding one is a single line** — a `{ char, words }` in whichever group it belongs to. Nothing else has to be touched: what is stored on a note is the character itself and not a number into the list, so entries may be added, moved or dropped without disturbing anything already chosen. The words are the whole point of the entry, so give it the ones somebody would actually type — what the thing is *for*, not what it depicts. Both dialogs that offer a mark render that same component; there were two copies of the list once, and only one of them ever grew. The groups are headings, not filters — a search looks through all of them at once, and a group with no match is not drawn. **Separators** are lines drawn between notes, optionally named, stored on the notebook beside the sort mode. Renaming and removing are both right-click, which is how everything else in that list is acted on: a left-click on the label used to start renaming, so the row could not be clicked at all without falling into an edit, and the × for removal sat a few pixels from that same target. Removal asks first, and says the notes are not affected. They move by dragging like a note does — a divider that cannot move with what it divides is worse than none — and their position is recorded as "how many notes come before me", so they stay put as the notes around them are renamed, edited or opened. One press draws the line. Everything about it — name, colour, and one of five shapes — is then set in **one dialog** reached by right-click, which draws the line as it will look and draws each shape beside its name. It used to be three menu entries and an input inside the row: three shapes of interaction for one small thing, none of them showing the result. The preview is built from the very rules the list draws with, because a sample that can disagree with the result is worse than none. **A chapter can be divided the same way**, and a line is put into one by dragging it there — there is no second "+ Separator" inside a chapter, because a second way to make the same thing in a second place is a second thing to keep straight. They are shown only in the manual order: in "newest first" the notes would slide past the line on every edit — and **the button that draws one is only there in that order too**. Standing in the others it could do nothing at all when pressed, which reads exactly like a button that is broken. Reordering also has its own route, `POST /api/notes/reorder`. Going through `PUT /api/notes/:id` would bump `updated_at` and snapshot a version — so dragging a note would move it to the top of "last changed" and spend a slot of its history on a change that is not to the note at all. --- ## Notes pinned to a passage The panel beside a PDF is for notes about the document. A note that belongs to *one paragraph* is a different thing — and a note that has to begin "on page 14, third paragraph" has already lost its place. So a note can be pinned to a spot: click **Pin note**, click the passage, write. **The note is written beside the page, not on it.** Only a small numbered marker sits on the document, with a hairline out to a card in the margin, level with the passage. Writing onto the page would cover the sentence the note is about, which is the one thing it must not do. Cards keep one height, so two notes close together are laid out by pushing the second down until they no longer overlap; the one being written in grows and rises above the rest. The column is 288px wide and a card holds about five lines. It was 208 by 74 — three short lines, which is a caption, not a remark — while the space beside the page sat mostly empty. **On a phone there is no margin.** A page narrow enough to read leaves no white space beside it, and taking a column that wide would make the document itself unreadable. There, tapping a marker brings the note up from the bottom — over the page, but only while it is open, within reach of a thumb, and with the passage still visible above it. The column is kept as soon as the document has one pinned note, or while one is being placed, so the page does not jump sideways at the moment of writing the first. Reserving it also exposed a loop worth remembering: a flex item is as wide as its content unless told otherwise, so the column made the viewer wider, which made there be room for the column — the page ended up at 254px inside a 420px phone while the layout insisted it had 611px. Positions are stored in **PDF points**, the same space as the drawings, so a marker stays on its sentence at every zoom and on every screen. Being in the overlay rather than in the note body has three consequences, each of which needed handling rather than assuming: - **Search.** Nothing else would index them, so the viewer feeds them to the local index itself, with their page numbers — finding the words is only half of finding the note. - **Clearing the ink.** "Seite leeren" empties the drawing. It must not take the notes pinned to that page: same page, different thing. - **"Is this overlay empty?"** The client and the server each held their own answer, and both counted strokes only. An overlay carrying nothing but pinned notes was therefore stored as `NULL` — every note on it lost on the next save. Both now ask about strokes *and* pins. **The margin notes can be linked from the notes.** A remark in the margin is short by design; when there is more to say about that passage it belongs in the notes beside the document — and it has to be possible to say *which* passage. So the pins are offered to the editor's `@` menu exactly like notes and notebooks are, labelled with their page, and following one scrolls the document to the spot and opens the remark. They are unhidden first: a link that led to something invisible would read as broken. A pin link is the same node with `kind: "pin"`, and is deliberately left out of the links reported to the server. Its target is a marker inside this note's own overlay, so no note has that id, and reporting it would fill the backlink table with rows that match nothing. **They can be hidden**, and so can the drawings — by a pair of switches at the right-hand end of the toolbar. They are two halves of one question, *how much of my own work do I want to see right now*, and they used to sit at opposite ends of the bar, each beside the tool that made them. Each appears only once there is something of its kind to hide. Hiding the margin notes takes the markers and the column together, and the page takes the width back while they are away. The drawing tools sit directly after the button that turns them on. They had drifted to the far side of two other controls, so switching to Zeichnen made them appear somewhere other than where the eye already was. They come along when the document is exported with annotations: each becomes a numbered marker plus a small box with its text, placed on whichever side of the pin has room. Only Latin-1 survives — the standard PDF fonts cannot encode more, and one em dash must not cost the whole export, so anything else is replaced. --- ## Import The other half of the promise that notes are not hostage to this program. A `.md` file becomes a note; a `.zip` this app exported comes back nearly whole — text, tables, task boxes, and the images, re-uploaded as attachments so they are encrypted at rest again like anything else. The parser is a deliberate subset: what the exporter emits, plus what a hand-written note is likely to use. It is not CommonMark and does not try to be. A full parser is thousands of lines and a permanent maintenance surface, and the failure mode here is mild — an unrecognised construct arrives as the literal text that was written, which is still the user's words in the user's order. Two things do not come back, and the import says so afterwards rather than leaving them to be discovered: - **A drawing returns as a picture, not as strokes.** It was exported as a PNG, and vectors cannot be recovered from pixels. It stays visible, printable and exportable, but the pen can no longer edit it. - **An image the archive does not contain cannot be restored.** The place it sat in the note is kept, so what used to be there is at least recorded. Writing the exporter also turned up a real bug in it: blocks were emitted without a blank line between them, so a list written straight after another list became more items of the first, and a quote or code fence on the next line was read as part of the item above. That is not one reader's quirk — it is what the format says — so the file rendered wrongly everywhere, not only here. The same rule was missing again one level down, inside a quote, until quotes were made to carry more than a sentence. Coming back the other way, a quote is read as a unit across the `>` lines that divide it: a quoted blank line separates the blocks inside the quote, it does not end it. Everything the exporter can put in a quote — headings, lists, tables, code, further quotes — is read back out of one. --- ## Export Notes should not be hostage to this program, so everything can leave it — from a single note up to every note in the account, reachable from the note's toolbar, a notebook's context menu, and Account → *Alle Notizen exportieren*. A single note is asked two questions rather than one, because the answers are not independent — Markdown holds text only, a PNG holds the drawing only, and PDF is the one format that holds both: | Was soll mit? | In welcher Form? | | --- | --- | | Notiz mit Zeichnung | PDF *(Markdown puts the drawing in a PNG beside it)* | | Nur den Notiztext | PDF · Markdown | | Nur die Zeichnung | PNG · PDF | A note without a drawing skips the first question instead of showing it with two thirds greyed out. **Markdown** keeps the notes editable elsewhere. Headings, lists, quotes, code, tables and task state all have equivalents and are carried over. A quote may hold whole blocks — a heading, a list, a closing line — and inside it they are divided by a **quoted** blank line (`>` on its own), the same way blocks are divided everywhere else: written on consecutive lines, the sentence after a quoted list is read as more of the last bullet by every other reader, and a truly empty line would end the quote instead of dividing it. A link to another exported note becomes a relative link to its file, and a link to a note outside the export degrades to its plain title rather than a dead reference. A note with images or a drawing arrives as a ZIP so the files travel with it, and a whole notebook gets a `README.md` index grouped by notebook. **PDF** goes through the browser's own print dialog rather than a bundled PDF library — every browser already has a good one behind *Save as PDF*, and it handles fonts and page breaks better than anything that would fit in here. This is the format that keeps drawings inside the document. Two things Markdown cannot represent, and what happens instead: | | | | --- | --- | | drawings | rendered to PNG beside the note, with a line in the text saying so | | underline | written as ``, which every Markdown renderer passes through | The ZIP writer is [`export/zip.ts`](frontend/src/export/zip.ts) — about eighty lines for stored (uncompressed) entries, rather than a dependency. Note text is small and images are already compressed, so there was nothing to gain by compressing, and this is one fewer thing to keep updated. Everything runs in the browser. Nothing is uploaded anywhere to produce a file. --- ## Drawings Drawings are **vector strokes**, not bitmaps. Every stroke is recorded in one canonical page space of 1200 × 1600 units: ```json { "v": 1, "w": 1200, "h": 1600, "strokes": [ { "t": "pen", "c": "#e94560", "s": 5.5, "p": [100,100, 200,180, 300,140] } ]} ``` `t` is the tool (`pen` | `highlighter` | `eraser`), `c` the colour, `s` the width in canonical units, and `p` a flat `x,y` point list. Each viewer scales that page to the room it has and rasterizes at its own device pixel ratio, which is what makes a note look the same on a phone and on a desktop — and stay crisp on both. Stroke data is 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. --- ## Made offline, and what it is called Something created with no connection carries an id this device invented — `local_x9k2…`. When its create finally reaches the server, the server names it something else, and the local database is repointed at the new name. **The queue has to be repointed too**, and for a long time it was not. The create arrived; every edit still queued behind it went on addressing the name only this device ever knew; the server answered 404; the entry was retried five times and thrown away. A note written and drawn on offline reached the server *empty and untitled*, with nothing to say that anything had been lost. The same held for a notebook made offline and everything filed into it. `repointQueue(oldId, newId)` in [`offlineStore.ts`](frontend/src/offlineStore.ts) now rewrites the waiting entries alongside the database. It replaces the id as a plain string across the whole queued payload rather than in a list of named fields — ids are long random strings, so a false match is not a practical worry, and a list of fields is a list to forget to add to. The `offline` suite makes a note with the network off, titles it, writes in it, draws on it, reconnects, and then asks the *server* for all three. --- ## Conflict Resolution Two devices can edit the same note without knowing about each other — a phone that was offline all day, a laptop that was not. When the phone reconnects, its save arrives last, and without care it would simply bury the newer work. Each write carries `baseUpdatedAt`: the revision the editing device had when it started. The server compares it with the note's current revision: 1. **Same** — an ordinary save, nothing to think about. 2. **Server is newer** — someone else saved in between. The incoming edit still wins, because rejecting it would throw away whatever was just typed, and on a phone that has been offline for a day that is the worse loss. But the state it replaces is kept as a version marked `conflict`, so both exist, and the history dialog is where you pick which one to keep. This replaced vector clocks, which sat in the server unused: no client ever sent one. They can tell a genuine conflict from a device merely catching up, which is strictly more accurate — but that accuracy only pays for itself with a merge interface to spend it on, and comparing revisions needs one field and reuses the version history that already exists. **What this does not do:** it will not tell you at the moment it happens. The conflicting version appears in the history, labelled, and that is where you find it. A notification is worth adding if it ever actually bites. --- ## Extending to Native Platforms ### React Native Use `note-app.jsx` components with React Native adaptations (replace HTML canvas with `react-native-canvas` or `@shopify/react-native-skia` for drawing). The sync engine works as-is. ### Flutter Use the sync engine via a JavaScript bridge, or port the API calls to Dart. For drawing, use Flutter's `CustomPainter`. ### Electron Wrap the React app directly in Electron for desktop. Add native file system access for export. --- ## Security Notes - All note content is encrypted with AES-256-CBC before database storage - Passwords are hashed with bcrypt (12 rounds) - JWT tokens expire after 30 days - HTTPS should be enforced in production (use a reverse proxy like Nginx or Pangolin) - The encryption key should be stored securely (e.g., environment variable, secrets manager) --- ## Tech Stack | Layer | Technology | |-----------|-------------------------------------| | Frontend | React 18 + Vite | | Editor | TipTap (ProseMirror) | | Backend | Node.js, Express | | Database | SQLite (dev), PostgreSQL (prod) | | Real-time | WebSocket (ws library) | | Auth | JWT + bcrypt | | Encryption| AES-256-CBC | | Container | Docker + docker-compose |