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>
This commit is contained in:
251
SPEC.md
Normal file
251
SPEC.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
- **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 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.
|
||||
Reference in New Issue
Block a user