Files
tefter/SPEC.md
Senad Uka 175c89e400 Implement Tefter per SPEC.md: NV-style notes with server, sync, PWA, desktop
- frontend/: Svelte 4 + TS + Vite app — omnibar (search-and-create), ranked
  diacritic-insensitive filtering, CodeMirror 6 markdown editor with 400ms
  autosave, marked+DOMPurify preview with [[wiki-links]], tag filter, undo
  toast, light/dark, narrow-screen stacked layout, IndexedDB store, pull-then-
  push sync engine with conflict handling, versioned cache-first service
  worker + manifest (installable PWA)
- server/: tefterd — Go stdlib HTTP + modernc.org/sqlite, /api/v1 sync API
  (changes/batch/health/import), SHA-256 hashed bearer token, LWW-with-
  conflict-copies push rules, subcommands: init, token rotate, import
  simplenote, compact, backup (VACUUM INTO); embeds the frontend bundle
- desktop/: Wails v2 shell — single instance, hide-to-tray (fyne systray),
  global Ctrl+Shift+Space hotkey, quit-on-close flag
- Simplenote import (CLI + web upload), idempotent via source_id dedupe;
  verified against a real 242-note export
- Makefile (frontend/server/server-all/desktop/test), GitHub release workflow,
  README with systemd/Caddy/backup docs

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 07:16:01 +02:00

17 KiB

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

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
  • 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.

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).

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 <token>)

Method Path Purpose
GET /changes?since=<cursor>&limit=500 Pull notes with version > cursor, ordered by version. Returns {notes: [...], cursor: <max version returned>, more: bool}
POST /notes/batch Push local changes. Body: {notes: [{id, content, tags, created_at, modified_at, deleted, baseVersion}]}
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

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 servertefterd 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.