# Tefter — Self-Hosted Notational Velocity Clone **Spec version:** 1.0 · **Audience:** Claude Code (implementation agent) ## 1. Overview Tefter is a personal, self-hosted note-taking system that replicates the Notational Velocity (NV) interaction model — a single search/create field, instant incremental filtering, and modeless keyboard-driven editing — with Markdown support and offline-first sync across Linux, macOS, Windows, Android, and web. It replaces Simplenote + Notational Velocity for a single user who hosts their own data. ### Goals - Pixel-faithful NV *interaction model* (not pixel-faithful visuals): one omnibar that searches AND creates, zero modal dialogs, zero save buttons, everything reachable by keyboard. - Markdown editing with optional preview. - Offline-first on every client; sync when connectivity returns. - Single-user, single-server, trivial deployment: **one Go binary + one SQLite file**. - Import of an existing Simplenote export archive. ### Non-Goals - Multi-user accounts, sharing, collaboration, realtime co-editing. - End-to-end encryption (TLS in transit + disk encryption at rest is the model). - Rich text, attachments, images (v1 is plain Markdown text; attachments may come in v2). - Native Android app (PWA is the Android client). ## 2. Architecture ``` ┌─────────────────────────────── your data center ───────────────┐ │ tefterd (single Go binary) │ │ ├─ REST sync API /api/v1/... │ │ ├─ static web app (PWA) embedded via go:embed → / │ │ └─ SQLite database (modernc.org/sqlite, CGO-free) │ └─────────────────────────────────────────────────────────────────┘ ▲ HTTPS (reverse proxy: Caddy/nginx, user-provided) │ ┌────────┼─────────────┬───────────────────┐ │ │ │ │ Web Android Desktop (Win/mac/Linux) browser (installed tefter-desktop: Wails v2 binary (PWA) PWA) wrapping the SAME frontend bundle ``` **Key decision — one frontend, three shells.** The web app is the product. It runs: 1. In a browser (PWA, served by `tefterd`). 2. Installed on Android (PWA install prompt). 3. Inside a Wails v2 webview window on desktop (frontend bundle embedded in the desktop binary; works fully offline, no `tefterd` needed to launch). Offline storage and the sync engine live in the **frontend** (TypeScript + IndexedDB), so the exact same offline/sync code runs on all five platforms. The Wails Go layer is a thin shell: window, tray, global shortcut, single-instance lock. ### Components / repo layout ``` tefter/ ├─ server/ # tefterd │ ├─ main.go │ ├─ api/ # HTTP handlers │ ├─ store/ # SQLite access layer │ ├─ importer/ # Simplenote import │ └─ webdist/ # go:embed of ../frontend/dist ├─ frontend/ # TypeScript + Svelte, Vite build │ ├─ src/ │ │ ├─ ui/ # omnibar, list, editor, preview │ │ ├─ db/ # IndexedDB local store │ │ └─ sync/ # sync engine │ └─ dist/ # built bundle (embedded by server & desktop) ├─ desktop/ # Wails v2 app │ ├─ main.go │ └─ wails.json ├─ Makefile └─ .github/workflows/release.yml ``` ### Tech choices (fixed — do not substitute) - **Server:** Go ≥1.22, stdlib `net/http` (Go 1.22 routing), `modernc.org/sqlite` (pure Go, no CGO → painless cross-compilation and static binaries). - **Frontend:** Svelte 4 + TypeScript + Vite. Keep dependencies minimal. Markdown rendering: `marked` + `DOMPurify`. Editor: CodeMirror 6 (markdown language package, minimal setup). - **Desktop:** Wails v2. - **No ORM, no heavy frameworks, no CSS framework** — hand-written CSS, this UI is small. ## 3. Data Model ### Server SQLite schema ```sql CREATE TABLE notes ( id TEXT PRIMARY KEY, -- UUIDv4, client-generated content TEXT NOT NULL DEFAULT '', -- full markdown; title = first line tags TEXT NOT NULL DEFAULT '[]',-- JSON array of strings created_at INTEGER NOT NULL, -- unix ms, client clock at creation modified_at INTEGER NOT NULL, -- unix ms, client clock at last edit deleted INTEGER NOT NULL DEFAULT 0,-- tombstone version INTEGER NOT NULL -- server-assigned, globally monotonic ); CREATE INDEX idx_notes_version ON notes(version); CREATE TABLE meta (k TEXT PRIMARY KEY, v TEXT); -- schema_version, next_version counter CREATE TABLE note_history ( -- schema v2: version history note_id TEXT NOT NULL, version INTEGER NOT NULL, -- version of the superseded revision content TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', modified_at INTEGER NOT NULL, replaced_at INTEGER NOT NULL, -- unix ms when it was overwritten PRIMARY KEY (note_id, version) ); ``` - **Title is derived**, never stored: first non-empty line of `content`, markdown heading markers stripped for display. - `version` is a single global monotonically increasing counter (like a Lamport clock per server). Every accepted write bumps the global counter and stamps the note. This makes "give me everything changed since cursor X" trivial. - Tombstones are kept forever (personal scale; millions of notes are not expected). A `tefterd compact` subcommand may purge tombstones older than N days. - **Version history:** whenever an accepted push overwrites an existing note (fast-forward or edit-over-tombstone), the superseded state is copied into `note_history`, capped at the newest 50 revisions per note. Conflict pushes touch nothing, so they record nothing. `compact` purges history rows of notes that no longer exist. Rollback is client-side: fetch a revision, apply its content as a normal edit. ### Client store (IndexedDB) Object store `notes`: same fields as server, plus: - `dirty: boolean` — locally modified, not yet pushed. - `baseVersion: number` — server version this local copy was derived from (0 for never-synced). - `synced_at?: number` — unix ms of the last confirmed exchange with the server for this note (accepted push or applied pull); absent = never synced. Object store `meta`: `cursor` (last server version pulled), `serverUrl`, `token`. ## 4. Sync Protocol Design: **pull-then-push, last-write-wins with conflict copies** (Simplenote-style). Single user across a handful of devices; CRDTs are overkill. Sync must be safe to interrupt at any point. ### Endpoints (all under `/api/v1`, JSON, `Authorization: Bearer `) | Method | Path | Purpose | |---|---|---| | GET | `/changes?since=&limit=500` | Pull notes with `version > cursor`, ordered by version. Returns `{notes: [...], cursor: , more: bool}` | | POST | `/notes/batch` | Push local changes. Body: `{notes: [{id, content, tags, created_at, modified_at, deleted, baseVersion}]}` | | GET | `/notes/{id}/history` | Superseded revisions of a note, newest first: `{revisions: [{note_id, version, content, tags, modified_at, replaced_at}]}` (empty list for unknown ids) | | GET | `/health` | Liveness + schema version | | POST | `/import/simplenote` | Multipart upload of Simplenote export zip (also available as CLI) | ### Push conflict rule (server-side, per note) 1. If note id unknown → insert, assign new version. **Accepted.** 2. If `baseVersion == current version` → clean fast-forward. Update, assign new version. **Accepted.** 3. Else **conflict**: another device changed the note since this client last pulled. - Keep the server's current content in the original note. - Apply the incoming content as a **new note** with a new UUID, content prefixed by nothing (content unchanged), and tag `conflict` added; its first line gets ` (conflicted copy YYYY-MM-DD HH:MM)` appended. - Response marks the note `{id, status: "conflict", conflictCopyId: ...}`. 4. Deletion conflicts: delete vs edit → edit wins (tombstone is overwritten by the edit as a conflict copy is NOT created; the edited version simply survives). ### Client sync loop 1. On startup, on network regain, after 3 s of edit inactivity, and every 60 s: run sync if online. 2. **Pull**: page through `/changes` from local `cursor`. For each incoming note: if local copy is not dirty → overwrite; if dirty and incoming version > baseVersion → keep local dirty copy, remember conflict will be resolved by server at push. Update `cursor`. 3. **Push**: send all dirty notes in one batch. On `accepted`, clear dirty, set `baseVersion` to returned version. On `conflict`, replace local with server truth and add the returned conflict copy on next pull. 4. All steps idempotent; a crash mid-sync must never lose an edit (edits are persisted to IndexedDB on every debounced change, before any network activity). ### Auth - Single bearer token, generated at first server start (`tefterd init` prints it), stored hashed (SHA-256) in `meta`. Rotate with `tefterd token rotate`. - No accounts, no sessions, no OAuth. TLS is delegated to the user's reverse proxy; `tefterd` listens on localhost/HTTP by default (`--listen :8420`). ## 5. UI Specification — the Notational Velocity Model This section is the heart of the product. The NV model must be reproduced exactly. ### Layout ``` ┌──────────────────────────────────────────────┐ │ [ omnibar: Search or Create ] │ ← always the same field ├──────────────────────────────────────────────┤ │ Note title · modified · tags │ ← results list │ Note title · modified · tags │ (selected row highlighted) │ ... │ ├──────────────────────────────────────────────┤ │ │ │ editor (CodeMirror, markdown) │ ← or rendered preview │ │ └──────────────────────────────────────────────┘ ``` - Default: horizontal split (list above editor). Setting for vertical split (list left, editor right). On narrow screens (<640 px, i.e., Android), list and editor become two stacked views with back navigation. - No toolbar. No save button. No menus except a small `⋯` for settings/sync status. - A tiny status dot next to the omnibar: green = synced, yellow = syncing, gray = offline with pending changes. Clicking shows last sync time and pending count. ### Omnibar behavior (exact) 1. Focus is in the omnibar when the app opens. 2. Typing filters the list **on every keystroke** (target: <10 ms for 10k notes — filter in memory; all note metadata + content is loaded into RAM from IndexedDB at startup). 3. Ranking: exact title match > title prefix > title contains > body contains. Secondary sort: `modified_at` desc. Empty query shows all notes by `modified_at` desc. 4. Matching is case-insensitive, diacritic-insensitive (č/c, š/s, ž/z fold together), across space-separated terms (AND semantics). 5. **Enter** in the omnibar: - If a list row is highlighted → open that note in the editor (focus editor, caret at end). - If the query matches nothing → **create a note whose first line is the query text**, open it in the editor. 6. **↓/↑** from the omnibar moves the list selection without leaving the field; the editor live-previews the selected note (NV behavior). 7. **Esc**: clear omnibar, deselect, focus omnibar. Pressing Esc in the editor returns focus to the omnibar (query preserved). 8. Search term highlighting in list rows and in the editor. ### Keyboard shortcuts (Cmd on macOS, Ctrl elsewhere) | Shortcut | Action | |---|---| | Cmd/Ctrl+L | Focus omnibar (select existing text) | | Enter (omnibar) | Open selection / create note | | ↑ ↓ (omnibar) | Move list selection | | Esc | Editor→omnibar; omnibar→clear | | Cmd/Ctrl+Delete | Delete selected note (moves to tombstone; brief undo toast, no confirm dialog) | | Cmd/Ctrl+Shift+P | Toggle markdown preview for current note | | Cmd/Ctrl+K | Cycle tag filter (simple tag dropdown) | | Cmd/Ctrl+J / Cmd/Ctrl+Shift+J | Next / previous note in list while in editor | | Cmd/Ctrl+I | Note info (created / modified / last synced) + version history panel; a revision can be previewed and restored (restore = normal edit, non-destructive) | ### Editor - CodeMirror 6, markdown mode, light syntax styling only (bold headings, dim syntax marks). Monospace or user-set font. No WYSIWYG. - **Autosave**: debounce 400 ms after last keystroke → write to IndexedDB, mark dirty. Never a save action. - Preview: `Cmd/Ctrl+Shift+P` swaps the editor pane for rendered markdown (`marked` + `DOMPurify`, GFM: tables, task lists, fenced code, strikethrough). Same shortcut toggles back. Links open in system browser/new tab. Internal `[[Note Title]]` wiki-links open/create that note (NV Alt behavior — nice-to-have, milestone 4). ### Visual style - Minimal, native-feeling: system font stack for UI chrome, generous line height in the list, thin 1 px separators. Light and dark theme following OS preference. No animation except the undo toast. ## 6. Simplenote Import Input: the official Simplenote export zip (contains `source/notes.json` with `activeNotes` and `trashedNotes`, each having `id, content, creationDate, lastModified, tags, markdown` fields — verify actual field names against a real export at implementation time and adapt). - CLI: `tefterd import simplenote /path/to/export.zip` (server-side, direct to SQLite). - Web: Settings → Import → upload zip (uses `POST /api/v1/import/simplenote`). - Mapping: content → content; creationDate/lastModified (ISO 8601) → unix ms; tags → tags; trashedNotes → imported with `deleted=1` tombstones (recoverable via a future trash view; v1 just keeps them synced-invisible). - Idempotent: re-importing the same zip must not duplicate notes (dedupe by Simplenote id stored in a `source_id` column, or by exact content+creation date hash). - Print a summary: imported N, skipped M duplicates, T trashed. ## 7. Desktop Shell (Wails v2) - One window containing the frontend bundle (same `frontend/dist` embedded via Wails assets). - Single-instance lock; second launch focuses the existing window. - Global OS shortcut (default Cmd/Ctrl+Shift+Space, configurable) shows/hides the window and focuses the omnibar — the classic NV "always at hand" flow. - Closing the window hides to tray (tray icon: open, sync now, quit). Setting to quit-on-close instead. - First-run screen: server URL + token fields, "work locally only" option (sync can be configured later). - Storage is the webview's IndexedDB — the desktop app is fully functional offline and without any server. ## 8. PWA Requirements (web + Android) - `manifest.json` (standalone display, icons 192/512, theme color) + service worker: precache the app shell (cache-first, versioned by build hash); API calls network-only (sync engine handles offline). - Must pass Lighthouse "installable" check. - IndexedDB persistence: request `navigator.storage.persist()` on first run. - Android keyboard ergonomics: omnibar `enterkeyhint="go"`, no zoom-on-focus (font-size ≥16 px). ## 9. Build, Release, Deployment - `make server` → `tefterd` binaries for linux/amd64, linux/arm64, darwin/arm64, windows/amd64 (pure Go, `CGO_ENABLED=0`). - `make desktop` → Wails builds per platform (requires per-OS runners; GitHub Actions matrix in `release.yml` producing: `tefter-desktop.exe`, `Tefter.app` zip, linux binary + `.desktop` file). - `make frontend` → Vite build into `frontend/dist`, embedded by both server and desktop builds. - Versioning: single version stamp injected via `-ldflags` into both binaries and the frontend; `/health` and the About screen show it. - Server deployment docs (README): systemd unit example, Caddy reverse-proxy example (2 lines), backup = copy one SQLite file (use `tefterd backup /path` which runs `VACUUM INTO` for a consistent snapshot). ## 10. Milestones 1. **M1 — Core local app:** frontend with omnibar/list/editor per §5, IndexedDB persistence, markdown preview. Runs from `vite dev`. Acceptance: full NV keyboard flow works offline in a browser. 2. **M2 — Server + sync:** `tefterd` with schema, API, token auth; sync engine per §4. Acceptance: two browser profiles converge; conflict copy created on concurrent edit; kill -9 during sync loses nothing. 3. **M3 — Import + PWA:** Simplenote import (CLI + web), service worker, installable on Android. Acceptance: real export imports idempotently; airplane-mode edit on Android syncs on reconnect. 4. **M4 — Desktop:** Wails shell with tray, global shortcut, single instance; release pipeline. Acceptance: fresh Windows/macOS/Linux machine runs the single artifact with zero prerequisites. 5. **M5 — Polish:** diacritic folding, dark mode, tag filter, `[[wiki-links]]`, `tefterd compact`, undo toast. ## 11. Acceptance Criteria (global) - Search latency <10 ms at 10,000 notes on a mid-range laptop. - Cold start to focused omnibar <1 s (desktop), <2 s (PWA warm cache). - Zero data loss under: offline edits, mid-sync crash, concurrent edits on two devices (conflict copy, never silent overwrite). - `tefterd` runs as one static binary + one `.db` file; no Docker, no external DB, no config file required (flags/env only). - Entire frontend bundle <500 KB gzipped. ## 12. Explicitly Out of Scope for the Agent Do not add: user registration, e-mail, websockets/realtime, plugins, themes beyond light/dark, mobile-native builds, Electron, ORMs, Redux-style state libraries, CSS frameworks. When in doubt, choose the smaller solution.