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:
2026-07-12 07:16:01 +02:00
commit 175c89e400
54 changed files with 7229 additions and 0 deletions

113
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
name: release
on:
push:
tags: ['v*']
workflow_dispatch:
env:
NODE_VERSION: '22'
GO_VERSION: 'stable'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- uses: actions/setup-go@v5
with: { go-version: 'stable' }
- name: Build frontend
run: make frontend && rm -rf server/webdist/dist && cp -r frontend/dist server/webdist/dist
- name: Go tests
run: cd server && go vet ./... && go test ./...
server:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- uses: actions/setup-go@v5
with: { go-version: 'stable' }
- name: Build tefterd for all platforms
run: make server-all VERSION=${{ github.ref_name }}
- uses: actions/upload-artifact@v4
with:
name: tefterd
path: build/tefterd-*
desktop:
needs: test
strategy:
matrix:
include:
- os: ubuntu-latest
artifact: tefter-desktop-linux
- os: macos-latest
artifact: tefter-desktop-macos
- os: windows-latest
artifact: tefter-desktop-windows
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- uses: actions/setup-go@v5
with: { go-version: 'stable' }
- name: Linux deps
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libx11-dev
- name: Install Wails
run: go install github.com/wailsapp/wails/v2/cmd/wails@latest
- name: Build frontend
run: make frontend VERSION=${{ github.ref_name }}
- name: Copy bundle into desktop
shell: bash
run: |
rm -rf desktop/frontend/dist
mkdir -p desktop/frontend
cp -r frontend/dist desktop/frontend/dist
- name: Wails build (Linux)
if: runner.os == 'Linux'
run: cd desktop && wails build -tags webkit2_41 -ldflags "-X main.Version=${{ github.ref_name }}"
- name: Wails build (macOS)
if: runner.os == 'macOS'
run: |
cd desktop && wails build -ldflags "-X main.Version=${{ github.ref_name }}"
cd build/bin && zip -r Tefter.app.zip Tefter.app
- name: Wails build (Windows)
if: runner.os == 'Windows'
run: cd desktop && wails build -ldflags "-X main.Version=${{ github.ref_name }}"
- name: Package Linux artifact (.desktop file included)
if: runner.os == 'Linux'
run: |
mkdir -p pkg
cp desktop/build/bin/tefter-desktop pkg/
cp desktop/tefter.desktop pkg/
cp frontend/public/icon-512.png pkg/tefter.png
tar -C pkg -czf tefter-desktop-linux-amd64.tar.gz .
- uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: |
tefter-desktop-linux-amd64.tar.gz
desktop/build/bin/Tefter.app.zip
desktop/build/bin/tefter-desktop.exe
if-no-files-found: ignore
release:
needs: [server, desktop]
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/')
permissions:
contents: write
steps:
- uses: actions/download-artifact@v4
with: { merge-multiple: true, path: dist }
- uses: softprops/action-gh-release@v2
with:
files: dist/**
generate_release_notes: true

12
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
frontend/dist/
server/webdist/dist/*
desktop/frontend/dist/*
!desktop/frontend/dist/.gitkeep
desktop/build/bin/
build/
*.db
*.db-wal
*.db-shm
notes.zip
!server/webdist/dist/.gitkeep

43
Makefile Normal file
View File

@@ -0,0 +1,43 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS = -s -w -X main.Version=$(VERSION)
DIST = build
.PHONY: all frontend server server-all desktop test clean dev
all: server
frontend:
cd frontend && npm install --no-audit --no-fund && TEFTER_VERSION=$(VERSION) npx vite build
# go:embed cannot reach outside the module dir, so the bundle is copied in.
server/webdist/dist: frontend
rm -rf server/webdist/dist
cp -r frontend/dist server/webdist/dist
touch server/webdist/dist/.gitkeep
server: server/webdist/dist
cd server && CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o ../$(DIST)/tefterd .
server-all: server/webdist/dist
mkdir -p $(DIST)
cd server && for target in linux/amd64 linux/arm64 darwin/arm64 windows/amd64; do \
os=$${target%/*}; arch=$${target#*/}; ext=; [ $$os = windows ] && ext=.exe; \
echo "building tefterd-$$os-$$arch"; \
CGO_ENABLED=0 GOOS=$$os GOARCH=$$arch go build -trimpath -ldflags '$(LDFLAGS)' \
-o ../$(DIST)/tefterd-$$os-$$arch$$ext . || exit 1; \
done
desktop: server/webdist/dist
rm -rf desktop/frontend/dist
mkdir -p desktop/frontend
cp -r frontend/dist desktop/frontend/dist
cd desktop && wails build -ldflags '-X main.Version=$(VERSION)'
test:
cd server && go vet ./... && TEFTER_REAL_EXPORT=$(CURDIR)/notes.zip go test ./...
dev:
cd frontend && npx vite
clean:
rm -rf $(DIST) frontend/dist server/webdist/dist desktop/frontend/dist desktop/build/bin

110
README.md Normal file
View File

@@ -0,0 +1,110 @@
# Tefter
Self-hosted, single-user notes with the classic **Notational Velocity** interaction model:
one omnibar that searches *and* creates, instant filtering on every keystroke, no save
buttons, no dialogs, everything on the keyboard. Markdown editing with preview,
offline-first sync across Linux/macOS/Windows/Android/web.
One Go binary (`tefterd`) + one SQLite file. The web app (PWA) is embedded in the binary;
the desktop app is the same frontend in a Wails shell.
## Quick start (server)
```sh
make server # builds frontend + tefterd into build/tefterd
./build/tefterd init # creates tefter.db, prints your auth token — save it
./build/tefterd # serves on :8420
```
Open `http://localhost:8420`, click `⋯` → set server URL + token → Save.
### Keyboard model
| Key | Action |
|---|---|
| type in omnibar | filter notes instantly (title > prefix > contains > body; diacritic-insensitive) |
| `Enter` | open selected note / create note titled with the query |
| `↑` `↓` | move list selection (editor live-previews) |
| `Esc` | editor → omnibar; omnibar → clear |
| `Ctrl/Cmd+L` | focus omnibar, select text |
| `Ctrl/Cmd+Delete` | delete note (undo toast, no confirm) |
| `Ctrl/Cmd+Shift+P` | toggle markdown preview (`[[wiki-links]]` open/create notes) |
| `Ctrl/Cmd+K` | cycle tag filter |
| `Ctrl/Cmd+J` / `+Shift` | next / previous note while editing |
## CLI
```sh
tefterd # serve (default), flags: --listen :8420 --db tefter.db
tefterd init # create db + print token
tefterd token rotate # new token (old one stops working)
tefterd import simplenote export.zip # idempotent Simplenote import (also in the web UI)
tefterd compact --days 90 # purge old tombstones
tefterd backup /backups/tefter.db # consistent snapshot (VACUUM INTO)
tefterd version
```
Environment: `TEFTER_DB`, `TEFTER_LISTEN` override the flag defaults. No config file.
## Deployment
`tefterd` listens on plain HTTP; put TLS in front of it.
**systemd**`/etc/systemd/system/tefterd.service`:
```ini
[Unit]
Description=Tefter notes server
After=network.target
[Service]
User=tefter
ExecStart=/usr/local/bin/tefterd --db /var/lib/tefter/tefter.db --listen 127.0.0.1:8420
Restart=on-failure
[Install]
WantedBy=multi-user.target
```
**Caddy** (two lines):
```
notes.example.com {
reverse_proxy 127.0.0.1:8420
}
```
**Backup** = one file:
```sh
tefterd backup /backups/tefter-$(date +%F).db --db /var/lib/tefter/tefter.db
```
## Clients
- **Web / Android:** open the server URL, install as PWA (Chrome: “Add to home screen”).
Fully offline-capable; edits sync on reconnect.
- **Desktop:** `tefter-desktop` (Wails). Single instance, closes to tray,
global shortcut `Ctrl+Shift+Space` (change with `--hotkey` / `TEFTER_HOTKEY`;
`--quit-on-close` disables the tray behavior). Works with no server at all —
configure sync later under `⋯`.
## Sync model
Pull-then-push, last-write-wins with **conflict copies** (Simplenote-style). Concurrent
edits of the same note never silently overwrite: the loser comes back as a new note
tagged `conflict` with “(conflicted copy …)” in its title. Delete-vs-edit: the edit wins.
Every client keeps a full offline copy in IndexedDB; a crash mid-sync loses nothing.
## Development
```sh
make dev # vite dev server (proxies /api to :8420)
make test # go vet + go test (uses notes.zip for a real-import test when present)
make server # build/tefterd for this machine
make server-all # linux/amd64, linux/arm64, darwin/arm64, windows/amd64
make desktop # Wails build (needs wails CLI + GTK/WebKit dev packages on Linux)
```
Repo layout: `frontend/` (Svelte + TS, all app logic incl. offline store + sync engine),
`server/` (Go, stdlib HTTP + modernc.org/sqlite, no CGO), `desktop/` (Wails shell).

251
SPEC.md Normal file
View 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.

View File

@@ -0,0 +1,24 @@
{
"name": "@wailsapp/runtime",
"version": "2.0.0",
"description": "Wails Javascript runtime library",
"main": "runtime.js",
"types": "runtime.d.ts",
"scripts": {
},
"repository": {
"type": "git",
"url": "git+https://github.com/wailsapp/wails.git"
},
"keywords": [
"Wails",
"Javascript",
"Go"
],
"author": "Lea Anthony <lea.anthony@gmail.com>",
"license": "MIT",
"bugs": {
"url": "https://github.com/wailsapp/wails/issues"
},
"homepage": "https://github.com/wailsapp/wails#readme"
}

View File

@@ -0,0 +1,330 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export interface Position {
x: number;
y: number;
}
export interface Size {
w: number;
h: number;
}
export interface Screen {
isCurrent: boolean;
isPrimary: boolean;
width : number
height : number
}
// Environment information such as platform, buildtype, ...
export interface EnvironmentInfo {
buildType: string;
platform: string;
arch: string;
}
// [EventsEmit](https://wails.io/docs/reference/runtime/events#eventsemit)
// emits the given event. Optional data may be passed with the event.
// This will trigger any event listeners.
export function EventsEmit(eventName: string, ...data: any): void;
// [EventsOn](https://wails.io/docs/reference/runtime/events#eventson) sets up a listener for the given event name.
export function EventsOn(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOnMultiple](https://wails.io/docs/reference/runtime/events#eventsonmultiple)
// sets up a listener for the given event name, but will only trigger a given number times.
export function EventsOnMultiple(eventName: string, callback: (...data: any) => void, maxCallbacks: number): () => void;
// [EventsOnce](https://wails.io/docs/reference/runtime/events#eventsonce)
// sets up a listener for the given event name, but will only trigger once.
export function EventsOnce(eventName: string, callback: (...data: any) => void): () => void;
// [EventsOff](https://wails.io/docs/reference/runtime/events#eventsoff)
// unregisters the listener for the given event name.
export function EventsOff(eventName: string, ...additionalEventNames: string[]): void;
// [EventsOffAll](https://wails.io/docs/reference/runtime/events#eventsoffall)
// unregisters all listeners.
export function EventsOffAll(): void;
// [LogPrint](https://wails.io/docs/reference/runtime/log#logprint)
// logs the given message as a raw message
export function LogPrint(message: string): void;
// [LogTrace](https://wails.io/docs/reference/runtime/log#logtrace)
// logs the given message at the `trace` log level.
export function LogTrace(message: string): void;
// [LogDebug](https://wails.io/docs/reference/runtime/log#logdebug)
// logs the given message at the `debug` log level.
export function LogDebug(message: string): void;
// [LogError](https://wails.io/docs/reference/runtime/log#logerror)
// logs the given message at the `error` log level.
export function LogError(message: string): void;
// [LogFatal](https://wails.io/docs/reference/runtime/log#logfatal)
// logs the given message at the `fatal` log level.
// The application will quit after calling this method.
export function LogFatal(message: string): void;
// [LogInfo](https://wails.io/docs/reference/runtime/log#loginfo)
// logs the given message at the `info` log level.
export function LogInfo(message: string): void;
// [LogWarning](https://wails.io/docs/reference/runtime/log#logwarning)
// logs the given message at the `warning` log level.
export function LogWarning(message: string): void;
// [WindowReload](https://wails.io/docs/reference/runtime/window#windowreload)
// Forces a reload by the main application as well as connected browsers.
export function WindowReload(): void;
// [WindowReloadApp](https://wails.io/docs/reference/runtime/window#windowreloadapp)
// Reloads the application frontend.
export function WindowReloadApp(): void;
// [WindowSetAlwaysOnTop](https://wails.io/docs/reference/runtime/window#windowsetalwaysontop)
// Sets the window AlwaysOnTop or not on top.
export function WindowSetAlwaysOnTop(b: boolean): void;
// [WindowSetSystemDefaultTheme](https://wails.io/docs/next/reference/runtime/window#windowsetsystemdefaulttheme)
// *Windows only*
// Sets window theme to system default (dark/light).
export function WindowSetSystemDefaultTheme(): void;
// [WindowSetLightTheme](https://wails.io/docs/next/reference/runtime/window#windowsetlighttheme)
// *Windows only*
// Sets window to light theme.
export function WindowSetLightTheme(): void;
// [WindowSetDarkTheme](https://wails.io/docs/next/reference/runtime/window#windowsetdarktheme)
// *Windows only*
// Sets window to dark theme.
export function WindowSetDarkTheme(): void;
// [WindowCenter](https://wails.io/docs/reference/runtime/window#windowcenter)
// Centers the window on the monitor the window is currently on.
export function WindowCenter(): void;
// [WindowSetTitle](https://wails.io/docs/reference/runtime/window#windowsettitle)
// Sets the text in the window title bar.
export function WindowSetTitle(title: string): void;
// [WindowFullscreen](https://wails.io/docs/reference/runtime/window#windowfullscreen)
// Makes the window full screen.
export function WindowFullscreen(): void;
// [WindowUnfullscreen](https://wails.io/docs/reference/runtime/window#windowunfullscreen)
// Restores the previous window dimensions and position prior to full screen.
export function WindowUnfullscreen(): void;
// [WindowIsFullscreen](https://wails.io/docs/reference/runtime/window#windowisfullscreen)
// Returns the state of the window, i.e. whether the window is in full screen mode or not.
export function WindowIsFullscreen(): Promise<boolean>;
// [WindowSetSize](https://wails.io/docs/reference/runtime/window#windowsetsize)
// Sets the width and height of the window.
export function WindowSetSize(width: number, height: number): void;
// [WindowGetSize](https://wails.io/docs/reference/runtime/window#windowgetsize)
// Gets the width and height of the window.
export function WindowGetSize(): Promise<Size>;
// [WindowSetMaxSize](https://wails.io/docs/reference/runtime/window#windowsetmaxsize)
// Sets the maximum window size. Will resize the window if the window is currently larger than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMaxSize(width: number, height: number): void;
// [WindowSetMinSize](https://wails.io/docs/reference/runtime/window#windowsetminsize)
// Sets the minimum window size. Will resize the window if the window is currently smaller than the given dimensions.
// Setting a size of 0,0 will disable this constraint.
export function WindowSetMinSize(width: number, height: number): void;
// [WindowSetPosition](https://wails.io/docs/reference/runtime/window#windowsetposition)
// Sets the window position relative to the monitor the window is currently on.
export function WindowSetPosition(x: number, y: number): void;
// [WindowGetPosition](https://wails.io/docs/reference/runtime/window#windowgetposition)
// Gets the window position relative to the monitor the window is currently on.
export function WindowGetPosition(): Promise<Position>;
// [WindowHide](https://wails.io/docs/reference/runtime/window#windowhide)
// Hides the window.
export function WindowHide(): void;
// [WindowShow](https://wails.io/docs/reference/runtime/window#windowshow)
// Shows the window, if it is currently hidden.
export function WindowShow(): void;
// [WindowMaximise](https://wails.io/docs/reference/runtime/window#windowmaximise)
// Maximises the window to fill the screen.
export function WindowMaximise(): void;
// [WindowToggleMaximise](https://wails.io/docs/reference/runtime/window#windowtogglemaximise)
// Toggles between Maximised and UnMaximised.
export function WindowToggleMaximise(): void;
// [WindowUnmaximise](https://wails.io/docs/reference/runtime/window#windowunmaximise)
// Restores the window to the dimensions and position prior to maximising.
export function WindowUnmaximise(): void;
// [WindowIsMaximised](https://wails.io/docs/reference/runtime/window#windowismaximised)
// Returns the state of the window, i.e. whether the window is maximised or not.
export function WindowIsMaximised(): Promise<boolean>;
// [WindowMinimise](https://wails.io/docs/reference/runtime/window#windowminimise)
// Minimises the window.
export function WindowMinimise(): void;
// [WindowUnminimise](https://wails.io/docs/reference/runtime/window#windowunminimise)
// Restores the window to the dimensions and position prior to minimising.
export function WindowUnminimise(): void;
// [WindowIsMinimised](https://wails.io/docs/reference/runtime/window#windowisminimised)
// Returns the state of the window, i.e. whether the window is minimised or not.
export function WindowIsMinimised(): Promise<boolean>;
// [WindowIsNormal](https://wails.io/docs/reference/runtime/window#windowisnormal)
// Returns the state of the window, i.e. whether the window is normal or not.
export function WindowIsNormal(): Promise<boolean>;
// [WindowSetBackgroundColour](https://wails.io/docs/reference/runtime/window#windowsetbackgroundcolour)
// Sets the background colour of the window to the given RGBA colour definition. This colour will show through for all transparent pixels.
export function WindowSetBackgroundColour(R: number, G: number, B: number, A: number): void;
// [ScreenGetAll](https://wails.io/docs/reference/runtime/window#screengetall)
// Gets the all screens. Call this anew each time you want to refresh data from the underlying windowing system.
export function ScreenGetAll(): Promise<Screen[]>;
// [BrowserOpenURL](https://wails.io/docs/reference/runtime/browser#browseropenurl)
// Opens the given URL in the system browser.
export function BrowserOpenURL(url: string): void;
// [Environment](https://wails.io/docs/reference/runtime/intro#environment)
// Returns information about the environment
export function Environment(): Promise<EnvironmentInfo>;
// [Quit](https://wails.io/docs/reference/runtime/intro#quit)
// Quits the application.
export function Quit(): void;
// [Hide](https://wails.io/docs/reference/runtime/intro#hide)
// Hides the application.
export function Hide(): void;
// [Show](https://wails.io/docs/reference/runtime/intro#show)
// Shows the application.
export function Show(): void;
// [ClipboardGetText](https://wails.io/docs/reference/runtime/clipboard#clipboardgettext)
// Returns the current text stored on clipboard
export function ClipboardGetText(): Promise<string>;
// [ClipboardSetText](https://wails.io/docs/reference/runtime/clipboard#clipboardsettext)
// Sets a text on the clipboard
export function ClipboardSetText(text: string): Promise<boolean>;
// [OnFileDrop](https://wails.io/docs/reference/runtime/draganddrop#onfiledrop)
// OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
export function OnFileDrop(callback: (x: number, y: number ,paths: string[]) => void, useDropTarget: boolean) :void
// [OnFileDropOff](https://wails.io/docs/reference/runtime/draganddrop#dragandddropoff)
// OnFileDropOff removes the drag and drop listeners and handlers.
export function OnFileDropOff() :void
// Check if the file path resolver is available
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View File

@@ -0,0 +1,298 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
export function LogPrint(message) {
window.runtime.LogPrint(message);
}
export function LogTrace(message) {
window.runtime.LogTrace(message);
}
export function LogDebug(message) {
window.runtime.LogDebug(message);
}
export function LogInfo(message) {
window.runtime.LogInfo(message);
}
export function LogWarning(message) {
window.runtime.LogWarning(message);
}
export function LogError(message) {
window.runtime.LogError(message);
}
export function LogFatal(message) {
window.runtime.LogFatal(message);
}
export function EventsOnMultiple(eventName, callback, maxCallbacks) {
return window.runtime.EventsOnMultiple(eventName, callback, maxCallbacks);
}
export function EventsOn(eventName, callback) {
return EventsOnMultiple(eventName, callback, -1);
}
export function EventsOff(eventName, ...additionalEventNames) {
return window.runtime.EventsOff(eventName, ...additionalEventNames);
}
export function EventsOffAll() {
return window.runtime.EventsOffAll();
}
export function EventsOnce(eventName, callback) {
return EventsOnMultiple(eventName, callback, 1);
}
export function EventsEmit(eventName) {
let args = [eventName].slice.call(arguments);
return window.runtime.EventsEmit.apply(null, args);
}
export function WindowReload() {
window.runtime.WindowReload();
}
export function WindowReloadApp() {
window.runtime.WindowReloadApp();
}
export function WindowSetAlwaysOnTop(b) {
window.runtime.WindowSetAlwaysOnTop(b);
}
export function WindowSetSystemDefaultTheme() {
window.runtime.WindowSetSystemDefaultTheme();
}
export function WindowSetLightTheme() {
window.runtime.WindowSetLightTheme();
}
export function WindowSetDarkTheme() {
window.runtime.WindowSetDarkTheme();
}
export function WindowCenter() {
window.runtime.WindowCenter();
}
export function WindowSetTitle(title) {
window.runtime.WindowSetTitle(title);
}
export function WindowFullscreen() {
window.runtime.WindowFullscreen();
}
export function WindowUnfullscreen() {
window.runtime.WindowUnfullscreen();
}
export function WindowIsFullscreen() {
return window.runtime.WindowIsFullscreen();
}
export function WindowGetSize() {
return window.runtime.WindowGetSize();
}
export function WindowSetSize(width, height) {
window.runtime.WindowSetSize(width, height);
}
export function WindowSetMaxSize(width, height) {
window.runtime.WindowSetMaxSize(width, height);
}
export function WindowSetMinSize(width, height) {
window.runtime.WindowSetMinSize(width, height);
}
export function WindowSetPosition(x, y) {
window.runtime.WindowSetPosition(x, y);
}
export function WindowGetPosition() {
return window.runtime.WindowGetPosition();
}
export function WindowHide() {
window.runtime.WindowHide();
}
export function WindowShow() {
window.runtime.WindowShow();
}
export function WindowMaximise() {
window.runtime.WindowMaximise();
}
export function WindowToggleMaximise() {
window.runtime.WindowToggleMaximise();
}
export function WindowUnmaximise() {
window.runtime.WindowUnmaximise();
}
export function WindowIsMaximised() {
return window.runtime.WindowIsMaximised();
}
export function WindowMinimise() {
window.runtime.WindowMinimise();
}
export function WindowUnminimise() {
window.runtime.WindowUnminimise();
}
export function WindowSetBackgroundColour(R, G, B, A) {
window.runtime.WindowSetBackgroundColour(R, G, B, A);
}
export function ScreenGetAll() {
return window.runtime.ScreenGetAll();
}
export function WindowIsMinimised() {
return window.runtime.WindowIsMinimised();
}
export function WindowIsNormal() {
return window.runtime.WindowIsNormal();
}
export function BrowserOpenURL(url) {
window.runtime.BrowserOpenURL(url);
}
export function Environment() {
return window.runtime.Environment();
}
export function Quit() {
window.runtime.Quit();
}
export function Hide() {
window.runtime.Hide();
}
export function Show() {
window.runtime.Show();
}
export function ClipboardGetText() {
return window.runtime.ClipboardGetText();
}
export function ClipboardSetText(text) {
return window.runtime.ClipboardSetText(text);
}
/**
* Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
*
* @export
* @callback OnFileDropCallback
* @param {number} x - x coordinate of the drop
* @param {number} y - y coordinate of the drop
* @param {string[]} paths - A list of file paths.
*/
/**
* OnFileDrop listens to drag and drop events and calls the callback with the coordinates of the drop and an array of path strings.
*
* @export
* @param {OnFileDropCallback} callback - Callback for OnFileDrop returns a slice of file path strings when a drop is finished.
* @param {boolean} [useDropTarget=true] - Only call the callback when the drop finished on an element that has the drop target style. (--wails-drop-target)
*/
export function OnFileDrop(callback, useDropTarget) {
return window.runtime.OnFileDrop(callback, useDropTarget);
}
/**
* OnFileDropOff removes the drag and drop listeners and handlers.
*/
export function OnFileDropOff() {
return window.runtime.OnFileDropOff();
}
export function CanResolveFilePaths() {
return window.runtime.CanResolveFilePaths();
}
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

40
desktop/go.mod Normal file
View File

@@ -0,0 +1,40 @@
module tefter/desktop
go 1.25.0
require (
fyne.io/systray v1.12.2
github.com/wailsapp/wails/v2 v2.13.0
golang.design/x/hotkey v0.6.1
)
require (
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/labstack/echo/v4 v4.13.3 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
github.com/leaanthony/gosod v1.0.4 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect
github.com/leaanthony/u v1.1.1 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/samber/lo v1.49.1 // indirect
github.com/tkrajina/go-reflector v0.5.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/wailsapp/go-webview2 v1.0.22 // indirect
github.com/wailsapp/mimetype v1.4.1 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.37.0 // indirect
)

89
desktop/go.sum Normal file
View File

@@ -0,0 +1,89 @@
fyne.io/systray v1.12.2 h1:Y8DZxgLHsVQt6rY9Zrkkg+j67S7vv/1F2viOWKPpVeA=
fyne.io/systray v1.12.2/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY=
github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e/go.mod h1:alcuEEnZsY1WQsagKhZDsoPCRoOijYqhZvPwLG0kzVs=
github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/leaanthony/debme v1.2.1 h1:9Tgwf+kjcrbMQ4WnPcEIUcQuIZYqdWftzZkBr+i/oOc=
github.com/leaanthony/debme v1.2.1/go.mod h1:3V+sCm5tYAgQymvSOfYQ5Xx2JCr+OXiD9Jkw3otUjiA=
github.com/leaanthony/go-ansi-parser v1.6.1 h1:xd8bzARK3dErqkPFtoF9F3/HgN8UQk0ed1YDKpEz01A=
github.com/leaanthony/go-ansi-parser v1.6.1/go.mod h1:+vva/2y4alzVmmIEpk9QDhA7vLC5zKDTRwfZGOp3IWU=
github.com/leaanthony/gosod v1.0.4 h1:YLAbVyd591MRffDgxUOU1NwLhT9T1/YiwjKZpkNFeaI=
github.com/leaanthony/gosod v1.0.4/go.mod h1:GKuIL0zzPj3O1SdWQOdgURSuhkF+Urizzxh26t9f1cw=
github.com/leaanthony/slicer v1.6.0 h1:1RFP5uiPJvT93TAHi+ipd3NACobkW53yUiBqZheE/Js=
github.com/leaanthony/slicer v1.6.0/go.mod h1:o/Iz29g7LN0GqH3aMjWAe90381nyZlDNquK+mtH2Fj8=
github.com/leaanthony/u v1.1.1 h1:TUFjwDGlNX+WuwVEzDqQwC2lOv0P4uhTQw7CMFdiK7M=
github.com/leaanthony/u v1.1.1/go.mod h1:9+o6hejoRljvZ3BzdYlVL0JYCwtnAsVuN9pVTQcaRfI=
github.com/matryer/is v1.4.0/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/matryer/is v1.4.1 h1:55ehd8zaGABKLXQUe2awZ99BD/PTc2ls+KV/dXphgEQ=
github.com/matryer/is v1.4.1/go.mod h1:8I/i5uYgLzgsgEloJE1U6xx5HkBQpAZvepWuujKwMRU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew=
github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ=
github.com/tkrajina/go-reflector v0.5.8/go.mod h1:ECbqLgccecY5kPmPmXg1MrHW585yMcDkVl6IvJe64T4=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo=
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/wailsapp/go-webview2 v1.0.22 h1:YT61F5lj+GGaat5OB96Aa3b4QA+mybD0Ggq6NZijQ58=
github.com/wailsapp/go-webview2 v1.0.22/go.mod h1:qJmWAmAmaniuKGZPWwne+uor3AHMB5PFhqiK0Bbj8kc=
github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhwHs=
github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o=
github.com/wailsapp/wails/v2 v2.13.0 h1:S7OgXWpj72V91unF8iDWJKbcS9ZpwCT3R0QVru4v2Mg=
github.com/wailsapp/wails/v2 v2.13.0/go.mod h1:nVr/wSIEZ7xxKPkzK65mjpKpaOPQI2k4pvLwGR/i4kc=
golang.design/x/hotkey v0.6.1 h1:mR3kp6L7eykJnEH7jvYQ9kc00LTq8edeWRFDsXfWp6Q=
golang.design/x/hotkey v0.6.1/go.mod h1:+CUQy3N+t1b8HbhsDScVWWuUpXiRPNRIKugECCiW0Po=
golang.design/x/mainthread v0.3.0 h1:UwFus0lcPodNpMOGoQMe87jSFwbSsEY//CA7yVmu4j8=
golang.design/x/mainthread v0.3.0/go.mod h1:vYX7cF2b3pTJMGM/hc13NmN6kblKnf4/IyvHeu259L0=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

10
desktop/hotkey_darwin.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build darwin
package main
import "golang.design/x/hotkey"
const (
modAlt = hotkey.ModOption
modCmd = hotkey.ModCmd
)

10
desktop/hotkey_linux.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build linux
package main
import "golang.design/x/hotkey"
const (
modAlt = hotkey.Mod1
modCmd = hotkey.Mod4
)

10
desktop/hotkey_windows.go Normal file
View File

@@ -0,0 +1,10 @@
//go:build windows
package main
import "golang.design/x/hotkey"
const (
modAlt = hotkey.ModAlt
modCmd = hotkey.ModWin
)

196
desktop/main.go Normal file
View File

@@ -0,0 +1,196 @@
// tefter-desktop — Wails v2 shell around the shared frontend bundle.
// Window + tray + global shortcut + single-instance lock; all app logic
// (storage, sync) lives in the frontend, so it works fully offline.
package main
import (
"context"
"embed"
"flag"
"log"
"os"
"strings"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/assetserver"
"github.com/wailsapp/wails/v2/pkg/options/mac"
wruntime "github.com/wailsapp/wails/v2/pkg/runtime"
"fyne.io/systray"
"golang.design/x/hotkey"
)
// Version is stamped via -ldflags "-X main.Version=…".
var Version = "dev"
//go:embed all:frontend/dist
var assets embed.FS
//go:embed build/appicon.png
var trayIcon []byte
type appShell struct {
ctx context.Context
quitOnClose bool
hotkeyCombo string
endTray func()
quitting bool
}
func main() {
quitOnClose := flag.Bool("quit-on-close", envBool("TEFTER_QUIT_ON_CLOSE"), "quit when the window closes instead of hiding to tray")
combo := flag.String("hotkey", envOr("TEFTER_HOTKEY", "ctrl+shift+space"), "global show/hide shortcut, e.g. ctrl+shift+space")
flag.Parse()
s := &appShell{quitOnClose: *quitOnClose, hotkeyCombo: *combo}
err := wails.Run(&options.App{
Title: "Tefter",
Width: 980,
Height: 700,
AssetServer: &assetserver.Options{
Assets: assets,
},
HideWindowOnClose: !*quitOnClose,
OnStartup: s.startup,
OnShutdown: s.shutdown,
SingleInstanceLock: &options.SingleInstanceLock{
UniqueId: "life.uka.tefter",
OnSecondInstanceLaunch: s.onSecondInstance,
},
Mac: &mac.Options{
About: &mac.AboutInfo{
Title: "Tefter " + Version,
Message: "Self-hosted Notational Velocity style notes",
},
},
})
if err != nil {
log.Fatal(err)
}
}
func (s *appShell) startup(ctx context.Context) {
s.ctx = ctx
// Tray: open, sync now, quit (SPEC §7). RunWithExternalLoop coexists with
// the Wails main loop.
start, end := systray.RunWithExternalLoop(s.trayReady, nil)
s.endTray = end
start()
// Global show/hide shortcut. Best effort: requires X11 on Linux.
go s.registerHotkey()
}
func (s *appShell) shutdown(ctx context.Context) {
if s.endTray != nil {
s.endTray()
}
}
func (s *appShell) onSecondInstance(data options.SecondInstanceData) {
s.showAndFocus()
}
func (s *appShell) trayReady() {
systray.SetIcon(trayIcon)
systray.SetTitle("Tefter")
systray.SetTooltip("Tefter — notes")
mOpen := systray.AddMenuItem("Open", "Show the Tefter window")
mSync := systray.AddMenuItem("Sync now", "Trigger a sync")
systray.AddSeparator()
mQuit := systray.AddMenuItem("Quit", "Quit Tefter")
go func() {
for {
select {
case <-mOpen.ClickedCh:
s.showAndFocus()
case <-mSync.ClickedCh:
wruntime.EventsEmit(s.ctx, "tefter:sync")
case <-mQuit.ClickedCh:
s.quitting = true
wruntime.Quit(s.ctx)
return
}
}
}()
}
func (s *appShell) registerHotkey() {
mods, key, ok := parseHotkey(s.hotkeyCombo)
if !ok {
log.Printf("hotkey: cannot parse %q, global shortcut disabled", s.hotkeyCombo)
return
}
hk := hotkey.New(mods, key)
if err := hk.Register(); err != nil {
log.Printf("hotkey: register %q: %v (global shortcut disabled)", s.hotkeyCombo, err)
return
}
log.Printf("hotkey: %s toggles the window", s.hotkeyCombo)
for range hk.Keydown() {
s.showAndFocus()
}
}
func (s *appShell) showAndFocus() {
wruntime.WindowShow(s.ctx)
// The classic NV "always at hand" flow: land in the omnibar.
wruntime.EventsEmit(s.ctx, "tefter:focus-omnibar")
}
func envOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
func envBool(k string) bool {
v := strings.ToLower(os.Getenv(k))
return v == "1" || v == "true" || v == "yes"
}
// parseHotkey turns "ctrl+shift+space" into golang.design/x/hotkey values.
// modAlt/modCmd are defined per-OS in hotkey_*.go.
func parseHotkey(combo string) ([]hotkey.Modifier, hotkey.Key, bool) {
var mods []hotkey.Modifier
var key hotkey.Key
haveKey := false
for _, part := range strings.Split(strings.ToLower(combo), "+") {
switch p := strings.TrimSpace(part); p {
case "ctrl", "control":
mods = append(mods, hotkey.ModCtrl)
case "shift":
mods = append(mods, hotkey.ModShift)
case "alt", "option":
mods = append(mods, modAlt)
case "cmd", "meta", "super", "win":
mods = append(mods, modCmd)
case "space":
key, haveKey = hotkey.KeySpace, true
default:
if len(p) == 1 {
if k, found := charKeys[rune(p[0])]; found {
key, haveKey = k, true
}
}
}
}
return mods, key, haveKey
}
var charKeys = map[rune]hotkey.Key{
'a': hotkey.KeyA, 'b': hotkey.KeyB, 'c': hotkey.KeyC, 'd': hotkey.KeyD,
'e': hotkey.KeyE, 'f': hotkey.KeyF, 'g': hotkey.KeyG, 'h': hotkey.KeyH,
'i': hotkey.KeyI, 'j': hotkey.KeyJ, 'k': hotkey.KeyK, 'l': hotkey.KeyL,
'm': hotkey.KeyM, 'n': hotkey.KeyN, 'o': hotkey.KeyO, 'p': hotkey.KeyP,
'q': hotkey.KeyQ, 'r': hotkey.KeyR, 's': hotkey.KeyS, 't': hotkey.KeyT,
'u': hotkey.KeyU, 'v': hotkey.KeyV, 'w': hotkey.KeyW, 'x': hotkey.KeyX,
'y': hotkey.KeyY, 'z': hotkey.KeyZ,
'0': hotkey.Key0, '1': hotkey.Key1, '2': hotkey.Key2, '3': hotkey.Key3,
'4': hotkey.Key4, '5': hotkey.Key5, '6': hotkey.Key6, '7': hotkey.Key7,
'8': hotkey.Key8, '9': hotkey.Key9,
}

9
desktop/tefter.desktop Normal file
View File

@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Tefter
Comment=Self-hosted Notational Velocity style notes
Exec=tefter-desktop
Icon=tefter
Terminal=false
Categories=Utility;Office;
StartupWMClass=Tefter

16
desktop/wails.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://wails.io/schemas/config.v2.json",
"name": "Tefter",
"outputfilename": "tefter-desktop",
"frontend:install": "",
"frontend:build": "",
"frontend:dev:watcher": "",
"frontend:dev:serverUrl": "auto",
"author": {
"name": "senad"
},
"info": {
"productName": "Tefter",
"comments": "Self-hosted Notational Velocity style notes"
}
}

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#1a1a1a" media="(prefers-color-scheme: dark)" />
<meta name="theme-color" content="#fafafa" media="(prefers-color-scheme: light)" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icon-192.png" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<title>Tefter</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

2136
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "tefter-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^3.1.2",
"@types/node": "^26.1.1",
"svelte": "^4.2.19",
"svelte-check": "^3.8.6",
"tslib": "^2.8.1",
"typescript": "^5.6.3",
"vite": "^5.4.11"
},
"dependencies": {
"@codemirror/commands": "^6.7.1",
"@codemirror/lang-markdown": "^6.3.1",
"@codemirror/language": "^6.10.6",
"@codemirror/state": "^6.5.0",
"@codemirror/view": "^6.35.3",
"@lezer/highlight": "^1.2.1",
"dompurify": "^3.2.3",
"marked": "^15.0.4"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

8
frontend/public/icon.svg Normal file
View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#2f6fde"/>
<rect x="96" y="120" width="320" height="52" rx="26" fill="#ffffff" opacity="0.95"/>
<rect x="96" y="216" width="320" height="14" rx="7" fill="#ffffff" opacity="0.7"/>
<rect x="96" y="258" width="250" height="14" rx="7" fill="#ffffff" opacity="0.55"/>
<rect x="96" y="300" width="290" height="14" rx="7" fill="#ffffff" opacity="0.4"/>
<rect x="96" y="342" width="200" height="14" rx="7" fill="#ffffff" opacity="0.3"/>
</svg>

After

Width:  |  Height:  |  Size: 556 B

View File

@@ -0,0 +1,15 @@
{
"name": "Tefter",
"short_name": "Tefter",
"description": "Self-hosted Notational Velocity style notes",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#1a1a1a",
"theme_color": "#2f6fde",
"icons": [
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

134
frontend/src/app.css Normal file
View File

@@ -0,0 +1,134 @@
:root {
--bg: #fafafa;
--bg-panel: #ffffff;
--fg: #1a1a1a;
--fg-dim: #767676;
--sep: #e2e2e2;
--accent: #2f6fde;
--accent-bg: #e8f0fe;
--sel-bg: #dce8fb;
--mark-bg: #ffe9a8;
--mark-fg: inherit;
--dot-green: #2e9e44;
--dot-yellow: #d9a400;
--dot-gray: #9a9a9a;
--dot-red: #cc3333;
--mono: ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
--sans: system-ui, -apple-system, 'Segoe UI', Roboto, Ubuntu, Cantarell, sans-serif;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1a1a;
--bg-panel: #212121;
--fg: #e4e4e4;
--fg-dim: #8f8f8f;
--sep: #333333;
--accent: #6ba0f2;
--accent-bg: #263450;
--sel-bg: #2c3e5c;
--mark-bg: #6b5a1a;
--mark-fg: #ffe9a8;
}
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
body {
font-family: var(--sans);
background: var(--bg);
color: var(--fg);
}
#app {
height: 100%;
}
mark {
background: var(--mark-bg);
color: var(--mark-fg);
border-radius: 2px;
}
button {
font: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
input,
select {
font: inherit;
color: var(--fg);
background: var(--bg-panel);
border: 1px solid var(--sep);
border-radius: 6px;
}
/* CodeMirror chrome */
.cm-editor {
height: 100%;
font-size: 14px;
background: var(--bg-panel);
}
.cm-editor .cm-content {
font-family: var(--mono);
caret-color: var(--fg);
padding: 12px 0;
}
.cm-editor .cm-line {
padding: 0 16px;
}
.cm-editor.cm-focused {
outline: none;
}
.cm-editor .cm-cursor {
border-left-color: var(--fg);
}
.cm-editor .cm-selectionBackground,
.cm-editor.cm-focused .cm-selectionBackground {
background: var(--sel-bg) !important;
}
.cm-search-hit {
background: var(--mark-bg);
color: var(--mark-fg);
border-radius: 2px;
}
/* markdown token styling: bold headings, dim syntax marks */
.tok-heading {
font-weight: 700;
}
.tok-meta,
.tok-processingInstruction {
color: var(--fg-dim);
}
.tok-emphasis {
font-style: italic;
}
.tok-strong {
font-weight: 700;
}
.tok-link,
.tok-url {
color: var(--accent);
}
.tok-strikethrough {
text-decoration: line-through;
}
.tok-monospace {
color: var(--fg-dim);
}

67
frontend/src/db/idb.ts Normal file
View File

@@ -0,0 +1,67 @@
import type { Note } from '../types';
const DB_NAME = 'tefter';
const DB_VERSION = 1;
let dbp: Promise<IDBDatabase> | null = null;
function open(): Promise<IDBDatabase> {
if (dbp) return dbp;
dbp = new Promise((resolve, reject) => {
const req = indexedDB.open(DB_NAME, DB_VERSION);
req.onupgradeneeded = () => {
const db = req.result;
if (!db.objectStoreNames.contains('notes')) db.createObjectStore('notes', { keyPath: 'id' });
if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta');
};
req.onsuccess = () => resolve(req.result);
req.onerror = () => reject(req.error);
});
return dbp;
}
function tx<T>(store: string, mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
return open().then(
(db) =>
new Promise<T>((resolve, reject) => {
const t = db.transaction(store, mode);
const req = fn(t.objectStore(store));
t.oncomplete = () => resolve(req.result);
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error);
})
);
}
export function idbGetAllNotes(): Promise<Note[]> {
return tx('notes', 'readonly', (s) => s.getAll());
}
export function idbPutNote(n: Note): Promise<unknown> {
return tx('notes', 'readwrite', (s) => s.put(n));
}
export async function idbPutNotes(notes: Note[]): Promise<void> {
if (!notes.length) return;
const db = await open();
await new Promise<void>((resolve, reject) => {
const t = db.transaction('notes', 'readwrite');
const s = t.objectStore('notes');
for (const n of notes) s.put(n);
t.oncomplete = () => resolve();
t.onerror = () => reject(t.error);
t.onabort = () => reject(t.error);
});
}
export function idbDeleteNote(id: string): Promise<unknown> {
return tx('notes', 'readwrite', (s) => s.delete(id));
}
export function idbGetMeta<T>(key: string): Promise<T | undefined> {
return tx('meta', 'readonly', (s) => s.get(key)) as Promise<T | undefined>;
}
export function idbSetMeta(key: string, value: unknown): Promise<unknown> {
return tx('meta', 'readwrite', (s) => s.put(value, key));
}

137
frontend/src/db/store.ts Normal file
View File

@@ -0,0 +1,137 @@
import { writable, derived, get } from 'svelte/store';
import type { Note, ServerNote, Settings } from '../types';
import { idbGetAllNotes, idbPutNote, idbPutNotes, idbGetMeta, idbSetMeta } from './idb';
// All note metadata + content lives in RAM (SPEC §5: filter in memory, <10ms @ 10k notes).
export const notes = writable<Map<string, Note>>(new Map());
export const settings = writable<Settings>({ serverUrl: '', token: '', vertical: false });
export const cursor = writable<number>(0);
export const pendingCount = derived(notes, ($n) => {
let c = 0;
for (const note of $n.values()) if (note.dirty) c++;
return c;
});
export function uuidv4(): string {
if (crypto.randomUUID) return crypto.randomUUID();
const b = crypto.getRandomValues(new Uint8Array(16));
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, '0')).join('');
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
export async function loadAll(): Promise<void> {
const all = await idbGetAllNotes();
const map = new Map<string, Note>();
for (const n of all) map.set(n.id, n);
notes.set(map);
cursor.set((await idbGetMeta<number>('cursor')) ?? 0);
const s = await idbGetMeta<Partial<Settings>>('settings');
if (s) settings.set({ serverUrl: s.serverUrl ?? '', token: s.token ?? '', vertical: s.vertical ?? false });
}
export async function saveSettings(patch: Partial<Settings>): Promise<void> {
settings.update((s) => ({ ...s, ...patch }));
await idbSetMeta('settings', get(settings));
}
export async function setCursor(v: number): Promise<void> {
cursor.set(v);
await idbSetMeta('cursor', v);
}
function mutate(n: Note): void {
notes.update((m) => {
m.set(n.id, n);
return m;
});
}
/** Persist an edit: IndexedDB write happens before any network activity (SPEC §4.4). */
export async function updateContent(id: string, content: string): Promise<void> {
const n = get(notes).get(id);
if (!n || n.content === content) return; // no-op saves must not dirty the note
const upd: Note = { ...n, content, modified_at: Date.now(), dirty: true };
mutate(upd);
await idbPutNote(upd);
}
export async function createNote(firstLine: string): Promise<Note> {
const now = Date.now();
const n: Note = {
id: uuidv4(),
content: firstLine ? firstLine + '\n' : '',
tags: [],
created_at: now,
modified_at: now,
deleted: false,
version: 0,
baseVersion: 0,
dirty: true,
};
mutate(n);
await idbPutNote(n);
return n;
}
export async function deleteNote(id: string): Promise<Note | null> {
const n = get(notes).get(id);
if (!n) return null;
const upd: Note = { ...n, deleted: true, modified_at: Date.now(), dirty: true };
mutate(upd);
await idbPutNote(upd);
return n; // pre-delete copy, for undo
}
export async function restoreNote(prev: Note): Promise<void> {
const cur = get(notes).get(prev.id);
// Undo the tombstone but keep it dirty so the restore syncs too.
const upd: Note = { ...(cur ?? prev), content: prev.content, deleted: false, modified_at: Date.now(), dirty: true };
mutate(upd);
await idbPutNote(upd);
}
/** Apply a pulled server note. Local dirty copies win until pushed (SPEC §4 client loop). */
export async function applyServerNote(sn: ServerNote): Promise<void> {
await applyServerNotes([sn]);
}
/** Batch variant: one store update + one IndexedDB transaction per pulled page. */
export async function applyServerNotes(sns: ServerNote[]): Promise<void> {
const m = get(notes);
const updates: Note[] = [];
for (const sn of sns) {
const local = m.get(sn.id);
if (local && local.dirty) continue; // push will resolve
updates.push({ ...sn, baseVersion: sn.version, dirty: false });
}
if (!updates.length) return;
notes.update((map) => {
for (const u of updates) map.set(u.id, u);
return map;
});
await idbPutNotes(updates);
}
/** After an accepted push: clear dirty unless the note changed again mid-flight. */
export async function markPushed(id: string, sentModifiedAt: number, version: number): Promise<void> {
const n = get(notes).get(id);
if (!n) return;
const stillSame = n.modified_at === sentModifiedAt;
const upd: Note = { ...n, version, baseVersion: version, dirty: stillSame ? false : n.dirty };
mutate(upd);
await idbPutNote(upd);
}
/** After a conflict: server truth replaces local; conflict copy arrives via next pull. */
export async function replaceWithServer(sn: ServerNote): Promise<void> {
const upd: Note = { ...sn, baseVersion: sn.version, dirty: false };
mutate(upd);
await idbPutNote(upd);
}
export async function persistNotes(list: Note[]): Promise<void> {
await idbPutNotes(list);
}

2
frontend/src/globals.d.ts vendored Normal file
View File

@@ -0,0 +1,2 @@
/** Injected by Vite `define` (see vite.config.ts). */
declare const __TEFTER_VERSION__: string;

37
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,37 @@
import './app.css';
import App from './ui/App.svelte';
import { loadAll } from './db/store';
import { startSync } from './sync/engine';
async function boot() {
await loadAll();
const app = new App({ target: document.getElementById('app')! });
startSync();
// Desktop shell (Wails) integration: tray "Sync now" and the global shortcut.
const rt = (window as unknown as {
runtime?: { EventsOn(name: string, cb: () => void): void };
}).runtime;
if (rt?.EventsOn) {
rt.EventsOn('tefter:focus-omnibar', () => app.externalFocusOmnibar());
rt.EventsOn('tefter:sync', () => {
void import('./sync/engine').then((m) => m.syncNow());
});
}
// IndexedDB persistence (SPEC §8)
if (navigator.storage?.persist) {
navigator.storage.persist().catch(() => {});
}
// Service worker only where it exists and makes sense (not in the Wails webview)
if (import.meta.env.PROD && 'serviceWorker' in navigator && location.protocol.startsWith('http')) {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
return app;
}
void boot();

119
frontend/src/search.ts Normal file
View File

@@ -0,0 +1,119 @@
import type { Note } from './types';
// Diacritic folding: NFD decomposition strips combining marks (č→c, š→s, ž→z, ć→c…);
// đ/Đ don't decompose so they are mapped explicitly.
const EXTRA: Record<string, string> = { đ: 'd', Đ: 'd', ø: 'o', Ø: 'o', ł: 'l', Ł: 'l', ß: 'ss' };
export function fold(s: string): string {
return s
.toLowerCase()
.replace(/[đĐøØłŁß]/g, (c) => EXTRA[c])
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '');
}
/** First non-empty line, markdown heading markers stripped. */
export function noteTitle(content: string): string {
for (const raw of content.split('\n')) {
const line = raw.replace(/^#{1,6}\s+/, '').trim();
if (line) return line;
}
return 'Untitled';
}
export function noteBody(content: string): string {
const lines = content.split('\n');
let i = 0;
while (i < lines.length && !lines[i].trim()) i++;
i++; // skip the title line
return lines
.slice(i)
.join(' ')
.replace(/\s+/g, ' ')
.trim();
}
// Folded title/content are cached per note object; notes are replaced (not
// mutated) on change, so WeakMap entries invalidate themselves.
const foldCache = new WeakMap<Note, { title: string; body: string }>();
function foldedOf(n: Note): { title: string; body: string } {
let c = foldCache.get(n);
if (!c) {
c = { title: fold(noteTitle(n.content)), body: fold(n.content) };
foldCache.set(n, c);
}
return c;
}
/**
* Rank categories: 0 exact title match, 1 title prefix, 2 all terms in title,
* 3 terms matched across title+body. Terms are AND-ed, case- and diacritic-insensitive.
* Returns matching notes sorted by (rank asc, modified_at desc).
*/
export function filterNotes(notes: Note[], query: string, tagFilter: string | null): Note[] {
let pool = notes.filter((n) => !n.deleted);
if (tagFilter) pool = pool.filter((n) => n.tags.includes(tagFilter));
const q = fold(query.trim());
if (!q) return pool.sort((a, b) => b.modified_at - a.modified_at);
const terms = q.split(/\s+/).filter(Boolean);
const ranked: { n: Note; rank: number }[] = [];
for (const n of pool) {
const { title, body } = foldedOf(n);
let ok = true;
let allInTitle = true;
for (const t of terms) {
if (title.includes(t)) continue;
allInTitle = false;
if (!body.includes(t)) {
ok = false;
break;
}
}
if (!ok) continue;
let rank = 3;
if (title === q) rank = 0;
else if (title.startsWith(q)) rank = 1;
else if (allInTitle) rank = 2;
ranked.push({ n, rank });
}
ranked.sort((a, b) => a.rank - b.rank || b.n.modified_at - a.n.modified_at);
return ranked.map((r) => r.n);
}
/**
* Build a case/diacritic-insensitive RegExp matching any of the query terms,
* for highlighting in the original (unfolded) text.
*/
export function highlightRegex(query: string): RegExp | null {
const terms = query.trim().split(/\s+/).filter(Boolean);
if (!terms.length) return null;
const classes: Record<string, string> = {
a: '[aàáâãäåā]', c: '[cçćč]', d: '[dđď]', e: '[eèéêëē]', g: '[gğ]',
i: '[iìíîïī]', l: '[lł]', n: '[nñń]', o: '[oòóôõöøō]', s: '[sšś]',
u: '[uùúûüū]', y: '[yýÿ]', z: '[zžźż]',
};
const pat = terms
.map((t) =>
fold(t)
.split('')
.map((ch) => classes[ch] || ch.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
.join('')
)
.join('|');
try {
return new RegExp(pat, 'giu');
} catch {
return null;
}
}
export function allTags(notes: Note[]): string[] {
const set = new Set<string>();
for (const n of notes) if (!n.deleted) for (const t of n.tags) set.add(t);
return [...set].sort();
}

113
frontend/src/sync/engine.ts Normal file
View File

@@ -0,0 +1,113 @@
import { writable, get } from 'svelte/store';
import type { PushResult, ServerNote, SyncState } from '../types';
import { notes, settings, cursor, setCursor, applyServerNotes, markPushed, replaceWithServer } from '../db/store';
export const syncState = writable<SyncState>('local');
export const lastSyncAt = writable<number | null>(null);
export const syncError = writable<string>('');
let editTimer: ReturnType<typeof setTimeout> | null = null;
let interval: ReturnType<typeof setInterval> | null = null;
let running = false;
let queued = false;
function configured(): boolean {
const s = get(settings);
return !!s.serverUrl;
}
function api(path: string, init: RequestInit = {}): Promise<Response> {
const s = get(settings);
const base = s.serverUrl.replace(/\/+$/, '');
return fetch(base + path, {
...init,
headers: {
...(init.headers || {}),
Authorization: 'Bearer ' + s.token,
'Content-Type': 'application/json',
},
});
}
async function pull(): Promise<void> {
for (;;) {
const since = get(cursor);
const res = await api(`/api/v1/changes?since=${since}&limit=500`);
if (!res.ok) throw new Error(`pull: HTTP ${res.status}`);
const body: { notes: ServerNote[]; cursor: number; more: boolean } = await res.json();
await applyServerNotes(body.notes);
if (body.cursor > since) await setCursor(body.cursor);
if (!body.more) break;
}
}
async function push(): Promise<void> {
const dirty = [...get(notes).values()].filter((n) => n.dirty);
if (!dirty.length) return;
const payload = dirty.map((n) => ({
id: n.id,
content: n.content,
tags: n.tags,
created_at: n.created_at,
modified_at: n.modified_at,
deleted: n.deleted,
baseVersion: n.baseVersion,
}));
const res = await api('/api/v1/notes/batch', { method: 'POST', body: JSON.stringify({ notes: payload }) });
if (!res.ok) throw new Error(`push: HTTP ${res.status}`);
const body: { results: PushResult[] } = await res.json();
const sentAt = new Map(dirty.map((n) => [n.id, n.modified_at]));
for (const r of body.results) {
if (r.status === 'accepted' && r.version) {
await markPushed(r.id, sentAt.get(r.id) ?? 0, r.version);
} else if (r.status === 'conflict' && r.serverNote) {
await replaceWithServer(r.serverNote); // conflict copy arrives on next pull
}
}
}
/** Pull-then-push; safe to interrupt at any point (SPEC §4). */
export async function syncNow(): Promise<void> {
if (!configured()) {
syncState.set('local');
return;
}
if (running) {
queued = true;
return;
}
running = true;
syncState.set('syncing');
try {
await pull();
await push();
await pull(); // pick up conflict copies + versions created by our own push
syncState.set(navigator.onLine ? 'synced' : 'offline');
lastSyncAt.set(Date.now());
syncError.set('');
} catch (e) {
syncState.set(navigator.onLine ? 'error' : 'offline');
syncError.set(e instanceof Error ? e.message : String(e));
} finally {
running = false;
if (queued) {
queued = false;
void syncNow();
}
}
}
/** Debounced trigger: sync 3 s after the last edit (SPEC §4 client loop). */
export function notifyEdit(): void {
if (!configured()) return;
if (editTimer) clearTimeout(editTimer);
editTimer = setTimeout(() => void syncNow(), 3000);
}
export function startSync(): void {
window.addEventListener('online', () => void syncNow());
window.addEventListener('offline', () => syncState.set('offline'));
if (interval) clearInterval(interval);
interval = setInterval(() => void syncNow(), 60_000);
void syncNow();
}

37
frontend/src/types.ts Normal file
View File

@@ -0,0 +1,37 @@
export interface Note {
id: string;
content: string;
tags: string[];
created_at: number; // unix ms
modified_at: number; // unix ms
deleted: boolean;
version: number; // server-assigned version of the copy we derived from (0 = never synced)
baseVersion: number; // server version this local copy was derived from
dirty: boolean; // locally modified, not yet pushed
}
export interface ServerNote {
id: string;
content: string;
tags: string[];
created_at: number;
modified_at: number;
deleted: boolean;
version: number;
}
export interface PushResult {
id: string;
status: 'accepted' | 'conflict';
version?: number;
conflictCopyId?: string;
serverNote?: ServerNote;
}
export type SyncState = 'synced' | 'syncing' | 'offline' | 'error' | 'local';
export interface Settings {
serverUrl: string;
token: string;
vertical: boolean; // vertical split (list left, editor right)
}

338
frontend/src/ui/App.svelte Normal file
View File

@@ -0,0 +1,338 @@
<script lang="ts">
import { onMount } from 'svelte';
import { notes, settings, createNote, deleteNote, restoreNote } from '../db/store';
import { filterNotes, noteTitle, fold, allTags } from '../search';
import { notifyEdit } from '../sync/engine';
import type { Note } from '../types';
import Omnibar from './Omnibar.svelte';
import NoteList from './NoteList.svelte';
import Editor from './Editor.svelte';
import Preview from './Preview.svelte';
import StatusDot from './StatusDot.svelte';
import SettingsMenu from './SettingsMenu.svelte';
import Toast from './Toast.svelte';
let query = '';
let selectedId: string | null = null;
let tagFilter: string | null = null;
let preview = false;
let menuOpen = false;
let narrow = false;
let mobileView: 'list' | 'editor' = 'list';
let toast: { message: string; onAction: () => void } | null = null;
let toastTimer: ReturnType<typeof setTimeout> | null = null;
let omnibar: Omnibar;
let editor: Editor;
$: notesArr = [...$notes.values()];
$: filtered = filterNotes(notesArr, query, tagFilter);
$: tags = allTags(notesArr);
$: selected = selectedId ? $notes.get(selectedId) ?? null : null;
$: vertical = $settings.vertical;
// Auto-select the best match while typing (NV behavior); empty query deselects.
let lastQuery = '';
$: if (query !== lastQuery) {
lastQuery = query;
selectedId = query.trim() ? (filtered[0]?.id ?? null) : null;
}
// Drop selection if the note vanished (deleted / filtered out by sync).
$: if (selectedId) {
const cur = $notes.get(selectedId);
if (!cur || cur.deleted) selectedId = null;
}
function openNote(id: string, focusEditor = true) {
selectedId = id;
preview = false;
if (narrow) mobileView = 'editor';
if (focusEditor) queueMicrotask(() => editor?.focusEnd());
}
async function onEnter() {
if (selectedId && filtered.some((n) => n.id === selectedId)) {
openNote(selectedId);
} else if (query.trim()) {
const n = await createNote(query.trim());
notifyEdit();
openNote(n.id);
}
}
function moveSelection(delta: number) {
if (!filtered.length) return;
const idx = filtered.findIndex((n) => n.id === selectedId);
const next = idx < 0 ? (delta > 0 ? 0 : filtered.length - 1) : Math.min(filtered.length - 1, Math.max(0, idx + delta));
selectedId = filtered[next].id;
}
function clearOmnibar() {
query = '';
selectedId = null;
omnibar?.focus();
}
function backToOmnibar() {
if (narrow) mobileView = 'list';
omnibar?.focus();
}
async function doDelete() {
if (!selectedId) return;
const idx = filtered.findIndex((n) => n.id === selectedId);
const prev = await deleteNote(selectedId);
if (!prev) return;
notifyEdit();
const rest = filtered.filter((n) => n.id !== prev.id);
selectedId = rest[Math.min(idx, rest.length - 1)]?.id ?? null;
showToast(`Deleted “${noteTitle(prev.content)}”`, () => {
void restoreNote(prev).then(() => notifyEdit());
});
}
function showToast(message: string, onAction: () => void) {
if (toastTimer) clearTimeout(toastTimer);
toast = { message, onAction };
toastTimer = setTimeout(() => (toast = null), 5000);
}
function cycleTag() {
if (!tags.length) return;
if (tagFilter === null) tagFilter = tags[0];
else {
const i = tags.indexOf(tagFilter);
tagFilter = i < 0 || i === tags.length - 1 ? null : tags[i + 1];
}
}
/** Open a [[wiki-link]] target: existing note by title, or create it. */
async function openWikiLink(title: string) {
const want = fold(title.trim());
const hit = notesArr.find((n) => !n.deleted && fold(noteTitle(n.content)) === want);
if (hit) {
openNote(hit.id);
} else {
const n = await createNote(title.trim());
notifyEdit();
openNote(n.id);
}
}
/** Called from the desktop shell (tray / global shortcut) via Wails events. */
export function externalFocusOmnibar() {
if (narrow) mobileView = 'list';
omnibar?.focusSelect();
}
function inEditor(): boolean {
return !!document.activeElement?.closest('.cm-editor');
}
function onKeydown(e: KeyboardEvent) {
const mod = e.metaKey || e.ctrlKey;
if (mod && e.key.toLowerCase() === 'l') {
e.preventDefault();
if (narrow) mobileView = 'list';
omnibar?.focusSelect();
} else if (mod && (e.key === 'Delete' || e.key === 'Backspace')) {
e.preventDefault();
void doDelete();
} else if (mod && e.shiftKey && e.key.toLowerCase() === 'p') {
e.preventDefault();
if (selected) preview = !preview;
} else if (mod && !e.shiftKey && e.key.toLowerCase() === 'k') {
e.preventDefault();
cycleTag();
} else if (mod && e.key.toLowerCase() === 'j') {
e.preventDefault();
const wasInEditor = inEditor();
moveSelection(e.shiftKey ? -1 : 1);
if (wasInEditor) queueMicrotask(() => editor?.focusEnd());
} else if (e.key === 'Escape' && inEditor()) {
e.preventDefault();
backToOmnibar(); // query preserved (SPEC §5.7)
}
}
onMount(() => {
const mq = window.matchMedia('(max-width: 639px)');
narrow = mq.matches;
const onMq = () => (narrow = mq.matches);
mq.addEventListener('change', onMq);
omnibar?.focus();
// First run: offer server config; Cancel = work locally only (SPEC §7).
if (!localStorage.getItem('tefter-first-run-done')) {
localStorage.setItem('tefter-first-run-done', '1');
if (!notesArr.length && !$settings.serverUrl) menuOpen = true;
}
return () => mq.removeEventListener('change', onMq);
});
</script>
<svelte:window on:keydown={onKeydown} />
<div class="app" class:vertical class:narrow>
{#if !narrow || mobileView === 'list'}
<header>
<Omnibar
bind:this={omnibar}
bind:value={query}
on:enter={onEnter}
on:up={() => moveSelection(-1)}
on:down={() => moveSelection(1)}
on:clear={clearOmnibar}
/>
{#if tagFilter}
<button class="tagchip" title="Clear tag filter (Ctrl+K cycles)" on:click={() => (tagFilter = null)}>
#{tagFilter}
</button>
{/if}
<StatusDot />
<button class="menu-btn" title="Settings" on:click={() => (menuOpen = !menuOpen)}>⋯</button>
</header>
{/if}
<div class="panes">
{#if !narrow || mobileView === 'list'}
<div class="list-pane">
<NoteList {filtered} {selectedId} {query} on:open={(e) => openNote(e.detail)} />
</div>
{/if}
{#if !narrow || mobileView === 'editor'}
<div class="editor-pane">
{#if narrow && mobileView === 'editor'}
<div class="mobile-bar">
<button on:click={backToOmnibar}> Notes</button>
<span class="mobile-title">{selected ? noteTitle(selected.content) : ''}</span>
<button on:click={() => (preview = !preview)}>{preview ? 'Edit' : 'MD'}</button>
</div>
{/if}
{#if selected && preview}
<Preview content={selected.content} on:wikilink={(e) => void openWikiLink(e.detail)} />
{:else if selected}
<Editor bind:this={editor} note={selected} {query} on:escape={backToOmnibar} />
{:else}
<div class="empty">
<p>Type to search — <kbd>Enter</kbd> creates a note when nothing matches.</p>
</div>
{/if}
</div>
{/if}
</div>
{#if menuOpen}
<SettingsMenu on:close={() => (menuOpen = false)} />
{/if}
{#if toast}
<Toast message={toast.message} actionLabel="Undo" onAction={toast.onAction} on:done={() => (toast = null)} />
{/if}
</div>
<style>
.app {
height: 100%;
display: flex;
flex-direction: column;
}
header {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--sep);
background: var(--bg);
}
.menu-btn {
font-size: 18px;
line-height: 1;
padding: 4px 8px;
border-radius: 6px;
color: var(--fg-dim);
}
.menu-btn:hover {
background: var(--sel-bg);
}
.tagchip {
white-space: nowrap;
background: var(--accent-bg);
color: var(--accent);
padding: 3px 8px;
border-radius: 10px;
font-size: 12px;
}
.panes {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.list-pane {
height: 34%;
min-height: 90px;
overflow-y: auto;
border-bottom: 1px solid var(--sep);
background: var(--bg);
}
.editor-pane {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--bg-panel);
}
.app.vertical .panes {
flex-direction: row;
}
.app.vertical .list-pane {
height: auto;
width: 320px;
min-width: 220px;
border-bottom: none;
border-right: 1px solid var(--sep);
}
.app.narrow .list-pane {
flex: 1;
height: auto;
width: auto;
border: none;
}
.mobile-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-bottom: 1px solid var(--sep);
}
.mobile-bar button {
color: var(--accent);
font-size: 15px;
}
.mobile-title {
flex: 1;
text-align: center;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--fg-dim);
padding: 20px;
text-align: center;
}
kbd {
border: 1px solid var(--sep);
border-radius: 4px;
padding: 1px 5px;
font-family: var(--mono);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import { onMount, onDestroy, createEventDispatcher } from 'svelte';
import { EditorState, Compartment } from '@codemirror/state';
import {
EditorView,
keymap,
highlightSpecialChars,
drawSelection,
MatchDecorator,
Decoration,
ViewPlugin,
type DecorationSet,
type ViewUpdate,
} from '@codemirror/view';
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { updateContent } from '../db/store';
import { notifyEdit } from '../sync/engine';
import { highlightRegex } from '../search';
import type { Note } from '../types';
export let note: Note;
export let query = '';
const dispatch = createEventDispatcher<{ escape: void }>();
let host: HTMLElement;
let view: EditorView | null = null;
let currentId = '';
let saveTimer: ReturnType<typeof setTimeout> | null = null;
// The last content this editor and the store agreed on. A store value equal to
// it is an echo of our own save; anything else is an external (sync) change.
let lastKnown = '';
const searchMark = new Compartment();
function searchExtension(q: string) {
const rx = highlightRegex(q);
if (!rx) return [];
const deco = new MatchDecorator({
regexp: new RegExp(rx.source, 'gi'),
decoration: Decoration.mark({ class: 'cm-search-hit' }),
});
return ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(v: EditorView) {
this.decorations = deco.createDeco(v);
}
update(u: ViewUpdate) {
this.decorations = deco.updateDeco(u, this.decorations);
}
},
{ decorations: (v) => v.decorations }
);
}
function flushSave(id: string, content: string) {
if (id === currentId) lastKnown = content;
void updateContent(id, content).then(() => notifyEdit());
}
function makeState(content: string): EditorState {
return EditorState.create({
doc: content,
extensions: [
history(),
highlightSpecialChars(),
drawSelection(),
EditorView.lineWrapping,
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
markdown({ base: markdownLanguage }),
searchMark.of(searchExtension(query)),
keymap.of([
{
key: 'Escape',
run: () => {
dispatch('escape');
return true;
},
},
...defaultKeymap,
...historyKeymap,
]),
EditorView.updateListener.of((u) => {
if (u.docChanged) {
const id = currentId;
const text = u.state.doc.toString();
if (saveTimer) clearTimeout(saveTimer);
// Autosave: 400 ms after last keystroke → IndexedDB, mark dirty (SPEC §5).
saveTimer = setTimeout(() => {
saveTimer = null;
if (id === currentId) flushSave(id, text);
}, 400);
}
}),
],
});
}
export function focusEnd() {
if (!view) return;
view.focus();
view.dispatch({ selection: { anchor: view.state.doc.length } });
}
// Swap document when a different note is opened; apply external (sync) changes
// without clobbering in-flight typing.
$: if (view && note) {
if (note.id !== currentId) {
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
flushSave(currentId, view.state.doc.toString());
}
currentId = note.id;
lastKnown = note.content;
view.setState(makeState(note.content));
} else if (note.content !== lastKnown) {
// Store changed underneath us (sync pull / conflict replacement) — not an
// echo of our own autosave. Replace the doc.
lastKnown = note.content;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
if (note.content !== view.state.doc.toString()) {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: note.content } });
}
}
}
let lastQuery = '';
$: if (view && query !== lastQuery) {
lastQuery = query;
view.dispatch({ effects: searchMark.reconfigure(searchExtension(query)) });
}
onMount(() => {
currentId = note.id;
lastKnown = note.content;
view = new EditorView({ state: makeState(note.content), parent: host });
});
onDestroy(() => {
if (saveTimer && view) {
clearTimeout(saveTimer);
saveTimer = null;
flushSave(currentId, view.state.doc.toString());
}
view?.destroy();
view = null;
});
</script>
<div class="editor-host" bind:this={host}></div>
<style>
.editor-host {
flex: 1;
min-height: 0;
overflow: hidden;
}
.editor-host :global(.cm-editor) {
height: 100%;
}
.editor-host :global(.cm-scroller) {
overflow: auto;
}
</style>

View File

@@ -0,0 +1,138 @@
<script lang="ts">
import { createEventDispatcher, tick } from 'svelte';
import { noteTitle, noteBody, highlightRegex } from '../search';
import type { Note } from '../types';
export let filtered: Note[] = [];
export let selectedId: string | null = null;
export let query = '';
// DOM cap: filtering is in-memory over everything, but rendering thousands of
// rows would blow the <10 ms keystroke budget. Selection stays within the cap.
const MAX_ROWS = 100;
const dispatch = createEventDispatcher<{ open: string }>();
let listEl: HTMLElement;
$: visible = filtered.length > MAX_ROWS ? filtered.slice(0, MAX_ROWS) : filtered;
$: rx = highlightRegex(query);
$: if (selectedId) {
void tick().then(() => {
listEl?.querySelector('.row.selected')?.scrollIntoView({ block: 'nearest' });
});
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function hl(s: string): string {
if (!rx) return esc(s);
rx.lastIndex = 0;
let out = '';
let last = 0;
let m: RegExpExecArray | null;
while ((m = rx.exec(s))) {
out += esc(s.slice(last, m.index)) + '<mark>' + esc(m[0]) + '</mark>';
last = m.index + m[0].length;
if (m.index === rx.lastIndex) rx.lastIndex++;
}
return out + esc(s.slice(last));
}
function when(ms: number): string {
const d = new Date(ms);
const now = new Date();
if (d.toDateString() === now.toDateString())
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
if (d.getFullYear() === now.getFullYear())
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
}
</script>
<div class="list" bind:this={listEl} role="listbox" aria-label="Notes">
{#each visible as n (n.id)}
<div
class="row"
class:selected={n.id === selectedId}
role="option"
aria-selected={n.id === selectedId}
tabindex="-1"
on:click={() => dispatch('open', n.id)}
on:keydown={(e) => e.key === 'Enter' && dispatch('open', n.id)}
>
<span class="title">{@html hl(noteTitle(n.content))}</span>
<span class="snippet">{@html hl(noteBody(n.content).slice(0, 120))}</span>
<span class="meta">
{#each n.tags as t}<span class="tag">{t}</span>{/each}
<span class="date">{when(n.modified_at)}</span>
</span>
</div>
{:else}
<div class="none">{query.trim() ? 'No matches — Enter creates this note' : 'No notes yet'}</div>
{/each}
{#if filtered.length > MAX_ROWS}
<div class="none">…and {filtered.length - MAX_ROWS} more — keep typing to narrow down</div>
{/if}
</div>
<style>
.list {
min-height: 100%;
}
.row {
display: flex;
align-items: baseline;
gap: 10px;
padding: 6px 12px;
line-height: 1.7;
border-bottom: 1px solid var(--sep);
cursor: default;
white-space: nowrap;
overflow: hidden;
}
.row.selected {
background: var(--sel-bg);
}
.title {
font-weight: 600;
flex-shrink: 0;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
}
.snippet {
flex: 1;
color: var(--fg-dim);
font-size: 12.5px;
overflow: hidden;
text-overflow: ellipsis;
}
.meta {
margin-left: auto;
flex-shrink: 0;
display: flex;
align-items: center;
gap: 6px;
}
.tag {
background: var(--accent-bg);
color: var(--accent);
font-size: 11px;
padding: 1px 7px;
border-radius: 9px;
}
.date {
color: var(--fg-dim);
font-size: 12px;
font-variant-numeric: tabular-nums;
}
.none {
padding: 16px;
color: var(--fg-dim);
text-align: center;
}
</style>

View File

@@ -0,0 +1,59 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let value = '';
const dispatch = createEventDispatcher<{ enter: void; up: void; down: void; clear: void }>();
let input: HTMLInputElement;
export function focus() {
input?.focus();
}
export function focusSelect() {
input?.focus();
input?.select();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
dispatch('enter');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
dispatch('down');
} else if (e.key === 'ArrowUp') {
e.preventDefault();
dispatch('up');
} else if (e.key === 'Escape') {
e.preventDefault();
dispatch('clear');
}
}
</script>
<input
bind:this={input}
bind:value
type="text"
class="omnibar"
placeholder="Search or Create"
spellcheck="false"
autocomplete="off"
autocapitalize="off"
enterkeyhint="go"
on:keydown={onKeydown}
/>
<style>
.omnibar {
flex: 1;
min-width: 0;
font-size: 16px; /* ≥16px: no zoom-on-focus on Android (SPEC §8) */
padding: 7px 12px;
border-radius: 8px;
}
.omnibar:focus {
outline: none;
border-color: var(--accent);
}
</style>

View File

@@ -0,0 +1,131 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { Marked } from 'marked';
import DOMPurify from 'dompurify';
export let content = '';
const dispatch = createEventDispatcher<{ wikilink: string }>();
function escAttr(s: string): string {
return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// GFM + [[wiki-link]] inline extension (SPEC §5 editor: NV Alt behavior)
const marked = new Marked({
gfm: true,
breaks: true,
extensions: [
{
name: 'wikilink',
level: 'inline',
start(src: string) {
const i = src.indexOf('[[');
return i < 0 ? undefined : i;
},
tokenizer(src: string) {
const m = /^\[\[([^\]\n]+)\]\]/.exec(src);
if (m) return { type: 'wikilink', raw: m[0], title: m[1].trim() };
return undefined;
},
renderer(tok) {
const t = escAttr(String((tok as { title?: string }).title ?? ''));
return `<a href="#" class="wikilink" data-title="${t}">${t}</a>`;
},
},
],
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && !node.classList.contains('wikilink')) {
node.setAttribute('target', '_blank');
node.setAttribute('rel', 'noopener noreferrer');
}
});
$: html = DOMPurify.sanitize(marked.parse(content, { async: false }) as string, {
ADD_ATTR: ['data-title', 'target'],
});
function onClick(e: MouseEvent) {
const a = (e.target as HTMLElement).closest('a');
if (!a) return;
if (a.classList.contains('wikilink')) {
e.preventDefault();
const title = a.getAttribute('data-title');
if (title) dispatch('wikilink', title);
return;
}
const href = a.getAttribute('href') || '';
// In the Wails webview, open external links in the system browser.
const rt = (window as unknown as { runtime?: { BrowserOpenURL(u: string): void } }).runtime;
if (rt?.BrowserOpenURL && /^https?:/i.test(href)) {
e.preventDefault();
rt.BrowserOpenURL(href);
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="preview" on:click={onClick}>
{@html html}
</div>
<style>
.preview {
flex: 1;
overflow-y: auto;
padding: 16px 20px;
line-height: 1.55;
font-size: 15px;
}
.preview :global(h1),
.preview :global(h2),
.preview :global(h3) {
line-height: 1.25;
}
.preview :global(pre) {
background: var(--bg);
border: 1px solid var(--sep);
border-radius: 6px;
padding: 10px 12px;
overflow-x: auto;
font-family: var(--mono);
font-size: 13px;
}
.preview :global(code) {
font-family: var(--mono);
font-size: 0.9em;
background: var(--bg);
border-radius: 4px;
padding: 1px 4px;
}
.preview :global(pre code) {
background: none;
padding: 0;
}
.preview :global(blockquote) {
margin: 0.6em 0;
padding: 0 1em;
border-left: 3px solid var(--sep);
color: var(--fg-dim);
}
.preview :global(table) {
border-collapse: collapse;
}
.preview :global(th),
.preview :global(td) {
border: 1px solid var(--sep);
padding: 4px 10px;
}
.preview :global(a) {
color: var(--accent);
}
.preview :global(input[type='checkbox']) {
margin-right: 6px;
}
.preview :global(.wikilink) {
text-decoration: none;
border-bottom: 1px dashed var(--accent);
}
</style>

View File

@@ -0,0 +1,182 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
import { settings, saveSettings } from '../db/store';
import { syncNow, syncState, lastSyncAt } from '../sync/engine';
import { get } from 'svelte/store';
const dispatch = createEventDispatcher<{ close: void }>();
const version: string = __TEFTER_VERSION__;
let serverUrl = get(settings).serverUrl;
let token = get(settings).token;
let vertical = get(settings).vertical;
let importing = false;
let importResult = '';
let fileInput: HTMLInputElement;
async function save() {
await saveSettings({ serverUrl: serverUrl.trim(), token: token.trim(), vertical });
void syncNow();
dispatch('close');
}
async function importZip() {
const f = fileInput?.files?.[0];
if (!f) return;
const s = get(settings);
const base = (serverUrl || s.serverUrl).replace(/\/+$/, '');
if (!base) {
importResult = 'Set a server URL first.';
return;
}
importing = true;
importResult = '';
try {
const fd = new FormData();
fd.append('file', f);
const res = await fetch(base + '/api/v1/import/simplenote', {
method: 'POST',
headers: { Authorization: 'Bearer ' + (token || s.token) },
body: fd,
});
if (!res.ok) throw new Error('HTTP ' + res.status);
const r = await res.json();
importResult = `Imported ${r.imported}, skipped ${r.skipped} duplicates, ${r.trashed} trashed.`;
void syncNow();
} catch (e) {
importResult = 'Import failed: ' + (e instanceof Error ? e.message : String(e));
} finally {
importing = false;
}
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events a11y-no-static-element-interactions -->
<div class="backdrop" on:click|self={() => dispatch('close')}>
<div class="panel">
<h2>Settings</h2>
<section>
<h3>Sync</h3>
<label>
Server URL
<input type="url" bind:value={serverUrl} placeholder="https://notes.example.com" />
</label>
<label>
Token
<input type="password" bind:value={token} placeholder="paste token from tefterd init" />
</label>
<div class="hint">Status: {$syncState}{$lastSyncAt ? ` · last sync ${new Date($lastSyncAt).toLocaleTimeString()}` : ''}</div>
</section>
<section>
<h3>Layout</h3>
<label class="row">
<input type="checkbox" bind:checked={vertical} />
Vertical split (list left, editor right)
</label>
</section>
<section>
<h3>Import</h3>
<input type="file" accept=".zip" bind:this={fileInput} />
<button class="btn" disabled={importing} on:click={importZip}>
{importing ? 'Importing…' : 'Import Simplenote zip'}
</button>
{#if importResult}<div class="hint">{importResult}</div>{/if}
</section>
<section class="about">Tefter {version}</section>
<div class="actions">
<button class="btn" on:click={() => dispatch('close')}>Cancel</button>
<button class="btn primary" on:click={save}>Save</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(0, 0, 0, 0.3);
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8vh;
}
.panel {
background: var(--bg-panel);
border: 1px solid var(--sep);
border-radius: 10px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
width: min(440px, 92vw);
max-height: 84vh;
overflow-y: auto;
padding: 18px 22px;
}
h2 {
margin: 0 0 6px;
font-size: 17px;
}
h3 {
margin: 14px 0 6px;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--fg-dim);
}
label {
display: block;
font-size: 13px;
color: var(--fg-dim);
margin-bottom: 8px;
}
label input[type='url'],
label input[type='password'] {
display: block;
width: 100%;
margin-top: 3px;
padding: 6px 10px;
font-size: 16px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
color: var(--fg);
}
.hint {
font-size: 12.5px;
color: var(--fg-dim);
margin-top: 6px;
}
.btn {
border: 1px solid var(--sep);
border-radius: 6px;
padding: 5px 14px;
margin-top: 8px;
background: var(--bg);
}
.btn.primary {
background: var(--accent);
border-color: var(--accent);
color: #fff;
}
.btn:disabled {
opacity: 0.5;
}
.about {
margin-top: 14px;
font-size: 12px;
color: var(--fg-dim);
}
.actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 10px;
}
</style>

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { syncState, lastSyncAt, syncError, syncNow } from '../sync/engine';
import { pendingCount } from '../db/store';
let open = false;
const labels: Record<string, string> = {
synced: 'Synced',
syncing: 'Syncing…',
offline: 'Offline',
error: 'Sync error',
local: 'Local only (no server configured)',
};
$: color =
$syncState === 'synced'
? 'var(--dot-green)'
: $syncState === 'syncing'
? 'var(--dot-yellow)'
: $syncState === 'error'
? 'var(--dot-red)'
: 'var(--dot-gray)';
function fmt(ms: number | null): string {
if (!ms) return 'never';
return new Date(ms).toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
</script>
<div class="wrap">
<button class="dot" style="background:{color}" title={labels[$syncState]} aria-label="Sync status" on:click={() => (open = !open)}></button>
{#if open}
<div class="pop">
<div><strong>{labels[$syncState]}</strong></div>
<div>Last sync: {fmt($lastSyncAt)}</div>
<div>Pending changes: {$pendingCount}</div>
{#if $syncError}<div class="err">{$syncError}</div>{/if}
<button class="now" on:click={() => void syncNow()}>Sync now</button>
</div>
{/if}
</div>
<style>
.wrap {
position: relative;
display: flex;
align-items: center;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.pop {
position: absolute;
top: 22px;
right: 0;
z-index: 30;
background: var(--bg-panel);
border: 1px solid var(--sep);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
padding: 10px 14px;
font-size: 13px;
line-height: 1.7;
white-space: nowrap;
}
.err {
color: var(--dot-red);
max-width: 240px;
white-space: normal;
}
.now {
margin-top: 4px;
color: var(--accent);
}
</style>

View File

@@ -0,0 +1,55 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let message = '';
export let actionLabel = '';
export let onAction: () => void = () => {};
const dispatch = createEventDispatcher<{ done: void }>();
function act() {
onAction();
dispatch('done');
}
</script>
<div class="toast" role="status">
<span>{message}</span>
{#if actionLabel}
<button on:click={act}>{actionLabel}</button>
{/if}
</div>
<style>
.toast {
position: fixed;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
z-index: 50;
display: flex;
align-items: center;
gap: 14px;
background: var(--fg);
color: var(--bg);
padding: 9px 16px;
border-radius: 8px;
font-size: 14px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
animation: rise 0.15s ease-out;
}
.toast button {
color: var(--mark-bg);
font-weight: 600;
}
@keyframes rise {
from {
opacity: 0;
transform: translateX(-50%) translateY(8px);
}
to {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
</style>

View File

@@ -0,0 +1,5 @@
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
export default {
preprocess: vitePreprocess(),
};

17
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": false,
"isolatedModules": true,
"strict": true,
"noUnusedLocals": false,
"skipLibCheck": true,
"types": ["svelte", "vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.svelte", "vite.config.ts"]
}

67
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,67 @@
import { defineConfig, type Plugin } from 'vite';
import { svelte } from '@sveltejs/vite-plugin-svelte';
import { createHash } from 'node:crypto';
const version = process.env.TEFTER_VERSION || 'dev';
// Emits sw.js at build time with the precache list of all emitted assets,
// versioned by a hash of the file names (which themselves carry content hashes).
function serviceWorker(): Plugin {
return {
name: 'tefter-sw',
apply: 'build',
generateBundle(_opts, bundle) {
const assets = Object.keys(bundle)
.filter((f) => !f.endsWith('.map'))
.map((f) => '/' + f);
assets.push('/', '/manifest.webmanifest', '/icon-192.png', '/icon-512.png');
const hash = createHash('sha256').update(assets.join(',') + version).digest('hex').slice(0, 12);
const sw = `// generated by vite.config.ts — do not edit
const CACHE = 'tefter-${'${'}HASH}';
const ASSETS = ${'${'}ASSETS};
self.addEventListener('install', (e) => {
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(ASSETS)).then(() => self.skipWaiting()));
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin) return;
if (url.pathname.startsWith('/api/')) return; // network-only; sync engine handles offline
e.respondWith(
caches.match(url.pathname === '/' || url.pathname === '/index.html' ? '/' : e.request).then(
(hit) => hit || fetch(e.request)
)
);
});
`
.replace('${HASH}', hash)
.replace('${ASSETS}', JSON.stringify(assets));
this.emitFile({ type: 'asset', fileName: 'sw.js', source: sw });
},
};
}
export default defineConfig({
plugins: [svelte(), serviceWorker()],
define: {
__TEFTER_VERSION__: JSON.stringify(version),
},
build: {
target: 'es2020',
sourcemap: false,
},
server: {
proxy: {
'/api': 'http://127.0.0.1:8420',
},
},
});

153
server/api/api.go Normal file
View File

@@ -0,0 +1,153 @@
// Package api exposes the /api/v1 sync endpoints and serves the embedded web app.
package api
import (
"bytes"
"encoding/json"
"io"
"io/fs"
"log"
"net/http"
"strconv"
"strings"
"time"
"tefter/server/importer"
"tefter/server/store"
)
type Server struct {
Store *store.Store
Version string
WebFS fs.FS // frontend dist root, may be nil (API-only)
}
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /api/v1/health", s.handleHealth)
mux.Handle("GET /api/v1/changes", s.auth(s.handleChanges))
mux.Handle("POST /api/v1/notes/batch", s.auth(s.handleBatch))
mux.Handle("POST /api/v1/import/simplenote", s.auth(s.handleImport))
mux.HandleFunc("OPTIONS /api/v1/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
if s.WebFS != nil {
mux.HandleFunc("/", s.handleStatic)
}
return withCORS(mux)
}
// withCORS allows the desktop (wails://) and dev-server origins to call the API.
func withCORS(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Authorization, Content-Type")
w.Header().Set("Access-Control-Max-Age", "86400")
}
next.ServeHTTP(w, r)
})
}
func (s *Server) auth(next http.HandlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
tok, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || !s.Store.CheckToken(strings.TrimSpace(tok)) {
jsonError(w, http.StatusUnauthorized, "invalid token")
return
}
next(w, r)
})
}
func jsonError(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
func writeJSON(w http.ResponseWriter, v any) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(v)
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"ok": true, "version": s.Version, "schema": store.SchemaVersion})
}
func (s *Server) handleChanges(w http.ResponseWriter, r *http.Request) {
since, _ := strconv.ParseInt(r.URL.Query().Get("since"), 10, 64)
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
notes, cursor, more, err := s.Store.Changes(since, limit)
if err != nil {
log.Printf("changes: %v", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, map[string]any{"notes": notes, "cursor": cursor, "more": more})
}
func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) {
var body struct {
Notes []store.IncomingNote `json:"notes"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<20)).Decode(&body); err != nil {
jsonError(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
results, err := s.Store.ApplyBatch(body.Notes, time.Now())
if err != nil {
log.Printf("batch: %v", err)
jsonError(w, http.StatusInternalServerError, "internal error")
return
}
writeJSON(w, map[string]any{"results": results})
}
func (s *Server) handleImport(w http.ResponseWriter, r *http.Request) {
if err := r.ParseMultipartForm(64 << 20); err != nil {
jsonError(w, http.StatusBadRequest, "bad multipart form: "+err.Error())
return
}
f, _, err := r.FormFile("file")
if err != nil {
jsonError(w, http.StatusBadRequest, "missing 'file' field")
return
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
jsonError(w, http.StatusBadRequest, "read upload: "+err.Error())
return
}
sum, err := importer.ImportZip(s.Store, bytes.NewReader(data), int64(len(data)))
if err != nil {
jsonError(w, http.StatusBadRequest, "import: "+err.Error())
return
}
log.Printf("import via API: %s", sum)
writeJSON(w, sum)
}
// handleStatic serves the embedded PWA: exact file if present, index.html otherwise.
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
path := strings.TrimPrefix(r.URL.Path, "/")
if path == "" {
path = "index.html"
}
if f, err := s.WebFS.Open(path); err == nil {
f.Close()
if path == "sw.js" {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFileFS(w, r, s.WebFS, path)
return
}
// SPA fallback
w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, s.WebFS, "index.html")
}

256
server/api/api_test.go Normal file
View File

@@ -0,0 +1,256 @@
package api
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"tefter/server/store"
)
// client is a minimal reimplementation of the frontend sync loop (pull-then-push,
// last-write-wins with conflict copies) used to prove two devices converge.
type client struct {
t *testing.T
base string
token string
cursor int64
notes map[string]*clientNote
}
type clientNote struct {
store.Note
BaseVersion int64
Dirty bool
}
func newClient(t *testing.T, base, token string) *client {
return &client{t: t, base: base, token: token, notes: map[string]*clientNote{}}
}
func (c *client) req(method, path string, body any, out any) int {
var rdr *bytes.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
} else {
rdr = bytes.NewReader(nil)
}
req, _ := http.NewRequest(method, c.base+path, rdr)
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
c.t.Fatal(err)
}
defer res.Body.Close()
if out != nil {
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
c.t.Fatal(err)
}
}
return res.StatusCode
}
func (c *client) edit(id, content string) {
n, ok := c.notes[id]
if !ok {
n = &clientNote{}
n.ID = id
c.notes[id] = n
}
n.Content = content
n.Dirty = true
}
func (c *client) pull() {
for {
var body struct {
Notes []store.Note `json:"notes"`
Cursor int64 `json:"cursor"`
More bool `json:"more"`
}
if code := c.req("GET", fmt.Sprintf("/api/v1/changes?since=%d&limit=2", c.cursor), nil, &body); code != 200 {
c.t.Fatalf("pull HTTP %d", code)
}
for _, sn := range body.Notes {
if local, ok := c.notes[sn.ID]; ok && local.Dirty {
continue // dirty local copy wins until pushed
}
c.notes[sn.ID] = &clientNote{Note: sn, BaseVersion: sn.Version}
}
c.cursor = body.Cursor
if !body.More {
return
}
}
}
func (c *client) push() {
var dirty []map[string]any
for _, n := range c.notes {
if n.Dirty {
dirty = append(dirty, map[string]any{
"id": n.ID, "content": n.Content, "tags": n.Tags,
"created_at": n.CreatedAt, "modified_at": n.ModifiedAt,
"deleted": n.Deleted, "baseVersion": n.BaseVersion,
})
}
}
if dirty == nil {
return
}
var body struct {
Results []store.PushResult `json:"results"`
}
if code := c.req("POST", "/api/v1/notes/batch", map[string]any{"notes": dirty}, &body); code != 200 {
c.t.Fatalf("push HTTP %d", code)
}
for _, r := range body.Results {
n := c.notes[r.ID]
switch r.Status {
case "accepted":
n.Dirty = false
n.BaseVersion = r.Version
n.Version = r.Version
case "conflict":
if r.ServerNote != nil {
c.notes[r.ID] = &clientNote{Note: *r.ServerNote, BaseVersion: r.ServerNote.Version}
}
}
}
}
func (c *client) sync() { c.pull(); c.push(); c.pull() }
func (c *client) visible() map[string]string {
out := map[string]string{}
for id, n := range c.notes {
if !n.Deleted {
out[id] = n.Content
}
}
return out
}
func newTestServer(t *testing.T) (*httptest.Server, string) {
st, err := store.Open(filepath.Join(t.TempDir(), "api.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
tok, err := st.EnsureToken()
if err != nil {
t.Fatal(err)
}
srv := httptest.NewServer((&Server{Store: st, Version: "test"}).Handler())
t.Cleanup(srv.Close)
return srv, tok
}
func TestAuthRequired(t *testing.T) {
srv, _ := newTestServer(t)
res, err := http.Get(srv.URL + "/api/v1/changes?since=0")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusUnauthorized {
t.Fatalf("want 401, got %d", res.StatusCode)
}
res, err = http.Get(srv.URL + "/api/v1/health")
if err != nil {
t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusOK {
t.Fatalf("health must be open, got %d", res.StatusCode)
}
}
func TestTwoClientsConverge(t *testing.T) {
srv, tok := newTestServer(t)
a := newClient(t, srv.URL, tok)
b := newClient(t, srv.URL, tok)
// A creates three notes, B pulls them.
a.edit("n1", "groceries\nmilk")
a.edit("n2", "ideas")
a.edit("n3", "scratch")
a.sync()
b.sync()
if len(b.visible()) != 3 {
t.Fatalf("B should see 3 notes, sees %d", len(b.visible()))
}
// Independent edits on different notes converge cleanly.
a.edit("n1", "groceries\nmilk, bread")
b.edit("n2", "ideas\nbuild tefter")
a.sync()
b.sync()
a.sync()
if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) {
t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible())
}
// Concurrent edit of the SAME note → conflict copy, never silent overwrite.
a.edit("n3", "scratch from A")
b.edit("n3", "scratch from B")
a.sync()
b.sync() // B's push conflicts; server truth restored + copy created
a.sync()
b.sync()
av, bv := a.visible(), b.visible()
if fmt.Sprint(av) != fmt.Sprint(bv) {
t.Fatalf("divergence after conflict:\nA=%v\nB=%v", av, bv)
}
if len(av) != 4 {
t.Fatalf("want 4 notes (3 + conflict copy), got %d: %v", len(av), av)
}
foundCopy := false
for _, content := range av {
if strings.Contains(content, "conflicted copy") && strings.Contains(content, "scratch from B") {
foundCopy = true
}
}
if !foundCopy {
t.Fatalf("conflict copy missing: %v", av)
}
// Both edits survived somewhere — no data loss.
joined := fmt.Sprint(av)
if !strings.Contains(joined, "scratch from A") || !strings.Contains(joined, "scratch from B") {
t.Fatalf("an edit was silently lost: %v", av)
}
}
func TestDeleteEditConflictAcrossClients(t *testing.T) {
srv, tok := newTestServer(t)
a := newClient(t, srv.URL, tok)
b := newClient(t, srv.URL, tok)
a.edit("n1", "keep me")
a.sync()
b.sync()
// A deletes while B edits concurrently; edit must win.
an := a.notes["n1"]
an.Deleted = true
an.Dirty = true
b.edit("n1", "keep me — edited")
b.sync() // B's edit lands first
a.sync() // A's stale delete is dropped, server truth restored
b.sync()
if fmt.Sprint(a.visible()) != fmt.Sprint(b.visible()) {
t.Fatalf("divergence:\nA=%v\nB=%v", a.visible(), b.visible())
}
if a.visible()["n1"] != "keep me — edited" {
t.Fatalf("edit did not win: %v", a.visible())
}
}

17
server/go.mod Normal file
View File

@@ -0,0 +1,17 @@
module tefter/server
go 1.25.0
require modernc.org/sqlite v1.53.0
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

51
server/go.sum Normal file
View File

@@ -0,0 +1,51 @@
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@@ -0,0 +1,126 @@
// Package importer reads Simplenote export archives into the store.
package importer
import (
"archive/zip"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"strings"
"time"
"tefter/server/store"
)
// Verified against a real export (2026): the zip contains source/notes.json with
// {activeNotes: [...], trashedNotes: [...]}; each note has id, content,
// creationDate, lastModified (ISO 8601) and optionally tags. There is no
// `markdown` field in current exports.
type snNote struct {
ID string `json:"id"`
Content string `json:"content"`
CreationDate string `json:"creationDate"`
LastModified string `json:"lastModified"`
Tags []string `json:"tags"`
}
type snExport struct {
ActiveNotes []snNote `json:"activeNotes"`
TrashedNotes []snNote `json:"trashedNotes"`
}
type Summary struct {
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Trashed int `json:"trashed"`
}
func (s Summary) String() string {
return fmt.Sprintf("imported %d, skipped %d duplicates, %d trashed", s.Imported, s.Skipped, s.Trashed)
}
func parseTime(iso string, fallback time.Time) int64 {
if t, err := time.Parse(time.RFC3339, iso); err == nil {
return t.UnixMilli()
}
return fallback.UnixMilli()
}
// ImportZip imports a Simplenote export zip (as a reader) into st. Idempotent:
// notes are deduped by Simplenote id kept in the source_id column.
func ImportZip(st *store.Store, r io.ReaderAt, size int64) (Summary, error) {
var sum Summary
zr, err := zip.NewReader(r, size)
if err != nil {
return sum, fmt.Errorf("open zip: %w", err)
}
var data []byte
for _, f := range zr.File {
if f.Name == "source/notes.json" || strings.HasSuffix(f.Name, "/notes.json") || f.Name == "notes.json" {
rc, err := f.Open()
if err != nil {
return sum, err
}
data, err = io.ReadAll(rc)
rc.Close()
if err != nil {
return sum, err
}
break
}
}
if data == nil {
return sum, fmt.Errorf("notes.json not found in archive (expected source/notes.json)")
}
var exp snExport
if err := json.Unmarshal(data, &exp); err != nil {
return sum, fmt.Errorf("parse notes.json: %w", err)
}
now := time.Now()
doImport := func(notes []snNote, deleted bool) error {
for _, sn := range notes {
if sn.ID == "" {
// No id to dedupe on — synthesize one from content+creation date.
sn.ID = fmt.Sprintf("synth-%x", contentHash(sn))
}
n := store.Note{
Content: strings.ReplaceAll(sn.Content, "\r\n", "\n"),
Tags: sn.Tags,
CreatedAt: parseTime(sn.CreationDate, now),
ModifiedAt: parseTime(sn.LastModified, now),
Deleted: deleted,
}
inserted, err := st.UpsertImported("simplenote:"+sn.ID, n)
if err != nil {
return err
}
if inserted {
if deleted {
sum.Trashed++
} else {
sum.Imported++
}
} else {
sum.Skipped++
}
}
return nil
}
if err := doImport(exp.ActiveNotes, false); err != nil {
return sum, err
}
if err := doImport(exp.TrashedNotes, true); err != nil {
return sum, err
}
return sum, nil
}
func contentHash(sn snNote) []byte {
h := sha256.Sum256([]byte(sn.CreationDate + "\x00" + sn.Content))
return h[:16]
}

View File

@@ -0,0 +1,124 @@
package importer
import (
"archive/zip"
"bytes"
"os"
"path/filepath"
"testing"
"tefter/server/store"
)
func makeZip(t *testing.T, notesJSON string) *bytes.Reader {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
w, err := zw.Create("source/notes.json")
if err != nil {
t.Fatal(err)
}
if _, err := w.Write([]byte(notesJSON)); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return bytes.NewReader(buf.Bytes())
}
const sample = `{
"activeNotes": [
{"id": "n1", "content": "Title one\r\nbody", "creationDate": "2019-03-06T04:32:52.000Z", "lastModified": "2026-07-11T19:42:01.000Z", "tags": ["work"]},
{"id": "n2", "content": "Second", "creationDate": "2020-01-01T00:00:00.000Z", "lastModified": "2020-01-02T00:00:00.000Z"}
],
"trashedNotes": [
{"id": "n3", "content": "", "creationDate": "2025-12-12T07:27:05.000Z", "lastModified": "2026-02-11T12:53:42.000Z"}
]
}`
func TestImportAndIdempotency(t *testing.T) {
st, err := store.Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
r := makeZip(t, sample)
sum, err := ImportZip(st, r, r.Size())
if err != nil {
t.Fatal(err)
}
if sum.Imported != 2 || sum.Trashed != 1 || sum.Skipped != 0 {
t.Fatalf("first import: %+v", sum)
}
// Re-import must not duplicate (SPEC §6).
r2 := makeZip(t, sample)
sum2, err := ImportZip(st, r2, r2.Size())
if err != nil {
t.Fatal(err)
}
if sum2.Imported != 0 || sum2.Trashed != 0 || sum2.Skipped != 3 {
t.Fatalf("re-import: %+v", sum2)
}
notes, _, _, err := st.Changes(0, 500)
if err != nil {
t.Fatal(err)
}
if len(notes) != 3 {
t.Fatalf("want 3 notes, got %d", len(notes))
}
byContent := map[string]store.Note{}
for _, n := range notes {
byContent[n.Content] = n
}
one, ok := byContent["Title one\nbody"] // CRLF normalized
if !ok {
t.Fatalf("CRLF not normalized: %+v", notes)
}
if one.CreatedAt != 1551846772000 || one.ModifiedAt != 1783798921000 {
t.Fatalf("timestamps: %d %d", one.CreatedAt, one.ModifiedAt)
}
if len(one.Tags) != 1 || one.Tags[0] != "work" {
t.Fatalf("tags: %v", one.Tags)
}
}
// TestImportRealExport runs against the actual Simplenote export when present.
func TestImportRealExport(t *testing.T) {
path := os.Getenv("TEFTER_REAL_EXPORT")
if path == "" {
t.Skip("set TEFTER_REAL_EXPORT=/path/to/notes.zip to run")
}
st, err := store.Open(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
f, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer f.Close()
info, _ := f.Stat()
sum, err := ImportZip(st, f, info.Size())
if err != nil {
t.Fatal(err)
}
t.Logf("real export: %s", sum)
if sum.Imported == 0 {
t.Fatal("expected notes from real export")
}
sum2, err := ImportZip(st, f, info.Size())
if err != nil {
t.Fatal(err)
}
if sum2.Imported != 0 || sum2.Trashed != 0 {
t.Fatalf("real re-import not idempotent: %+v", sum2)
}
}

211
server/main.go Normal file
View File

@@ -0,0 +1,211 @@
// tefterd — single-binary server for Tefter (see SPEC.md).
package main
import (
"flag"
"fmt"
"log"
"net/http"
"os"
"tefter/server/api"
"tefter/server/importer"
"tefter/server/store"
"tefter/server/webdist"
)
// Version is stamped via -ldflags "-X main.Version=…".
var Version = "dev"
func usage() {
fmt.Fprintf(os.Stderr, `tefterd %s — self-hosted Notational Velocity style notes server
Usage:
tefterd [serve] [--listen :8420] [--db tefter.db] run the server (default)
tefterd init [--db tefter.db] create the database, print the auth token
tefterd token rotate [--db tefter.db] generate and print a new auth token
tefterd import simplenote <export.zip> [--db ...] import a Simplenote export archive
tefterd compact [--days 90] [--db ...] purge tombstones older than N days
tefterd backup <dest.db> [--db ...] consistent snapshot via VACUUM INTO
tefterd version print version
`, Version)
}
func main() {
log.SetFlags(log.LstdFlags)
args := os.Args[1:]
cmd := "serve"
if len(args) > 0 && !isFlag(args[0]) {
cmd = args[0]
args = args[1:]
}
switch cmd {
case "serve":
cmdServe(args)
case "init":
cmdInit(args)
case "token":
if len(args) < 1 || args[0] != "rotate" {
usage()
os.Exit(2)
}
cmdTokenRotate(args[1:])
case "import":
if len(args) < 2 || args[0] != "simplenote" {
usage()
os.Exit(2)
}
cmdImport(args[1], args[2:])
case "compact":
cmdCompact(args)
case "backup":
if len(args) < 1 || isFlag(args[0]) {
usage()
os.Exit(2)
}
cmdBackup(args[0], args[1:])
case "version":
fmt.Println(Version)
case "help", "-h", "--help":
usage()
default:
usage()
os.Exit(2)
}
}
func isFlag(s string) bool { return len(s) > 0 && s[0] == '-' }
func dbFlag(fs *flag.FlagSet) *string {
def := os.Getenv("TEFTER_DB")
if def == "" {
def = "tefter.db"
}
return fs.String("db", def, "path to SQLite database")
}
func openStore(path string) *store.Store {
st, err := store.Open(path)
if err != nil {
log.Fatalf("open database %s: %v", path, err)
}
return st
}
func cmdServe(args []string) {
fs := flag.NewFlagSet("serve", flag.ExitOnError)
db := dbFlag(fs)
listenDef := os.Getenv("TEFTER_LISTEN")
if listenDef == "" {
listenDef = ":8420"
}
listen := fs.String("listen", listenDef, "listen address")
_ = fs.Parse(args)
st := openStore(*db)
defer st.Close()
if tok, err := st.EnsureToken(); err != nil {
log.Fatalf("token: %v", err)
} else if tok != "" {
fmt.Printf("First start: generated auth token (store it safely, it is shown only once):\n\n %s\n\n", tok)
}
web, err := webdist.FS()
if err != nil {
log.Fatalf("embedded frontend: %v", err)
}
srv := &api.Server{Store: st, Version: Version, WebFS: web}
total, deleted, _ := st.NoteCount()
log.Printf("tefterd %s listening on %s (db %s, %d notes, %d tombstones)", Version, *listen, *db, total, deleted)
if err := http.ListenAndServe(*listen, srv.Handler()); err != nil {
log.Fatal(err)
}
}
func cmdInit(args []string) {
fs := flag.NewFlagSet("init", flag.ExitOnError)
db := dbFlag(fs)
_ = fs.Parse(args)
st := openStore(*db)
defer st.Close()
tok, err := st.EnsureToken()
if err != nil {
log.Fatalf("token: %v", err)
}
if tok == "" {
fmt.Println("Database already initialized. Use `tefterd token rotate` for a new token.")
return
}
fmt.Printf("Initialized %s\nAuth token (store it safely, it is shown only once):\n\n %s\n\n", *db, tok)
}
func cmdTokenRotate(args []string) {
fs := flag.NewFlagSet("token rotate", flag.ExitOnError)
db := dbFlag(fs)
_ = fs.Parse(args)
st := openStore(*db)
defer st.Close()
tok, err := st.RotateToken()
if err != nil {
log.Fatalf("rotate: %v", err)
}
fmt.Printf("New auth token (old token is now invalid):\n\n %s\n\n", tok)
}
func cmdImport(zipPath string, args []string) {
fs := flag.NewFlagSet("import", flag.ExitOnError)
db := dbFlag(fs)
_ = fs.Parse(args)
st := openStore(*db)
defer st.Close()
f, err := os.Open(zipPath)
if err != nil {
log.Fatalf("open %s: %v", zipPath, err)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
log.Fatal(err)
}
sum, err := importer.ImportZip(st, f, info.Size())
if err != nil {
log.Fatalf("import: %v", err)
}
fmt.Println(sum)
}
func cmdCompact(args []string) {
fs := flag.NewFlagSet("compact", flag.ExitOnError)
db := dbFlag(fs)
days := fs.Int("days", 90, "purge tombstones older than this many days")
_ = fs.Parse(args)
st := openStore(*db)
defer st.Close()
n, err := st.Compact(*days)
if err != nil {
log.Fatalf("compact: %v", err)
}
fmt.Printf("purged %d tombstones older than %d days\n", n, *days)
}
func cmdBackup(dest string, args []string) {
fs := flag.NewFlagSet("backup", flag.ExitOnError)
db := dbFlag(fs)
_ = fs.Parse(args)
st := openStore(*db)
defer st.Close()
if err := st.Backup(dest); err != nil {
log.Fatalf("backup: %v", err)
}
fmt.Printf("backup written to %s\n", dest)
}

408
server/store/store.go Normal file
View File

@@ -0,0 +1,408 @@
// Package store is the SQLite access layer for tefterd.
package store
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"time"
_ "modernc.org/sqlite"
)
const SchemaVersion = 1
type Note struct {
ID string `json:"id"`
Content string `json:"content"`
Tags []string `json:"tags"`
CreatedAt int64 `json:"created_at"`
ModifiedAt int64 `json:"modified_at"`
Deleted bool `json:"deleted"`
Version int64 `json:"version"`
}
type IncomingNote struct {
ID string `json:"id"`
Content string `json:"content"`
Tags []string `json:"tags"`
CreatedAt int64 `json:"created_at"`
ModifiedAt int64 `json:"modified_at"`
Deleted bool `json:"deleted"`
BaseVersion int64 `json:"baseVersion"`
}
type PushResult struct {
ID string `json:"id"`
Status string `json:"status"` // accepted | conflict
Version int64 `json:"version,omitempty"`
ConflictCopyID string `json:"conflictCopyId,omitempty"`
ServerNote *Note `json:"serverNote,omitempty"`
}
type Store struct {
db *sql.DB
}
func Open(path string) (*Store, error) {
// _pragma via DSN keeps every pool connection configured identically.
dsn := fmt.Sprintf("file:%s?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(ON)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1) // single writer; personal scale
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) migrate() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS notes (
id TEXT PRIMARY KEY,
content TEXT NOT NULL DEFAULT '',
tags TEXT NOT NULL DEFAULT '[]',
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL,
deleted INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL,
source_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_notes_version ON notes(version);
CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_source ON notes(source_id) WHERE source_id IS NOT NULL;
CREATE TABLE IF NOT EXISTS meta (k TEXT PRIMARY KEY, v TEXT);
INSERT OR IGNORE INTO meta (k, v) VALUES ('schema_version', '1');
INSERT OR IGNORE INTO meta (k, v) VALUES ('next_version', '1');
`)
return err
}
// --- meta helpers ---
func (s *Store) getMeta(k string) (string, error) {
var v string
err := s.db.QueryRow(`SELECT v FROM meta WHERE k = ?`, k).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
func (s *Store) setMeta(k, v string) error {
_, err := s.db.Exec(`INSERT INTO meta (k, v) VALUES (?, ?) ON CONFLICT(k) DO UPDATE SET v = excluded.v`, k, v)
return err
}
func nextVersionTx(tx *sql.Tx) (int64, error) {
var cur string
if err := tx.QueryRow(`SELECT v FROM meta WHERE k = 'next_version'`).Scan(&cur); err != nil {
return 0, err
}
n, err := strconv.ParseInt(cur, 10, 64)
if err != nil {
return 0, err
}
if _, err := tx.Exec(`UPDATE meta SET v = ? WHERE k = 'next_version'`, strconv.FormatInt(n+1, 10)); err != nil {
return 0, err
}
return n, nil
}
// --- token auth ---
// EnsureToken returns a freshly generated token if none exists yet ("" otherwise).
func (s *Store) EnsureToken() (string, error) {
h, err := s.getMeta("token_hash")
if err != nil {
return "", err
}
if h != "" {
return "", nil
}
return s.RotateToken()
}
// RotateToken generates a new bearer token, stores its SHA-256, returns the plaintext.
func (s *Store) RotateToken() (string, error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
tok := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(tok))
if err := s.setMeta("token_hash", hex.EncodeToString(sum[:])); err != nil {
return "", err
}
return tok, nil
}
func (s *Store) CheckToken(tok string) bool {
h, err := s.getMeta("token_hash")
if err != nil || h == "" {
return false
}
sum := sha256.Sum256([]byte(tok))
return subtle.ConstantTimeCompare([]byte(hex.EncodeToString(sum[:])), []byte(h)) == 1
}
// --- notes ---
func tagsJSON(tags []string) string {
if tags == nil {
tags = []string{}
}
b, _ := json.Marshal(tags)
return string(b)
}
func scanNote(scan func(dest ...any) error) (Note, error) {
var n Note
var tags string
var deleted int
if err := scan(&n.ID, &n.Content, &tags, &n.CreatedAt, &n.ModifiedAt, &deleted, &n.Version); err != nil {
return n, err
}
n.Deleted = deleted != 0
if err := json.Unmarshal([]byte(tags), &n.Tags); err != nil || n.Tags == nil {
n.Tags = []string{}
}
return n, nil
}
// Changes returns notes with version > since, ordered by version, up to limit.
func (s *Store) Changes(since int64, limit int) (notes []Note, cursor int64, more bool, err error) {
if limit <= 0 || limit > 500 {
limit = 500
}
rows, err := s.db.Query(
`SELECT id, content, tags, created_at, modified_at, deleted, version
FROM notes WHERE version > ? ORDER BY version LIMIT ?`, since, limit+1)
if err != nil {
return nil, 0, false, err
}
defer rows.Close()
notes = []Note{}
for rows.Next() {
n, err := scanNote(rows.Scan)
if err != nil {
return nil, 0, false, err
}
notes = append(notes, n)
}
if err := rows.Err(); err != nil {
return nil, 0, false, err
}
if len(notes) > limit {
notes = notes[:limit]
more = true
}
cursor = since
if len(notes) > 0 {
cursor = notes[len(notes)-1].Version
}
return notes, cursor, more, nil
}
func getNoteTx(tx *sql.Tx, id string) (*Note, error) {
row := tx.QueryRow(`SELECT id, content, tags, created_at, modified_at, deleted, version FROM notes WHERE id = ?`, id)
n, err := scanNote(row.Scan)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return &n, nil
}
// conflictTitle appends the marker to the first non-empty line of content.
func conflictTitle(content string, at time.Time) string {
marker := at.Format(" (conflicted copy 2006-01-02 15:04)")
lines := strings.Split(content, "\n")
for i, l := range lines {
if strings.TrimSpace(l) != "" {
lines[i] = l + marker
return strings.Join(lines, "\n")
}
}
return strings.TrimSpace(marker)
}
func newUUID() string {
b := make([]byte, 16)
_, _ = rand.Read(b)
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
h := hex.EncodeToString(b)
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:]
}
// ApplyBatch applies pushed notes under the SPEC §4 conflict rules, in one transaction.
func (s *Store) ApplyBatch(in []IncomingNote, now time.Time) ([]PushResult, error) {
tx, err := s.db.Begin()
if err != nil {
return nil, err
}
defer tx.Rollback()
results := make([]PushResult, 0, len(in))
for _, inc := range in {
cur, err := getNoteTx(tx, inc.ID)
if err != nil {
return nil, err
}
switch {
case cur == nil:
// Rule 1: unknown id → insert.
v, err := nextVersionTx(tx)
if err != nil {
return nil, err
}
if _, err := tx.Exec(
`INSERT INTO notes (id, content, tags, created_at, modified_at, deleted, version) VALUES (?,?,?,?,?,?,?)`,
inc.ID, inc.Content, tagsJSON(inc.Tags), inc.CreatedAt, inc.ModifiedAt, b2i(inc.Deleted), v); err != nil {
return nil, err
}
results = append(results, PushResult{ID: inc.ID, Status: "accepted", Version: v})
case inc.BaseVersion == cur.Version:
// Rule 2: clean fast-forward.
v, err := nextVersionTx(tx)
if err != nil {
return nil, err
}
if _, err := tx.Exec(
`UPDATE notes SET content=?, tags=?, modified_at=?, deleted=?, version=? WHERE id=?`,
inc.Content, tagsJSON(inc.Tags), inc.ModifiedAt, b2i(inc.Deleted), v, inc.ID); err != nil {
return nil, err
}
results = append(results, PushResult{ID: inc.ID, Status: "accepted", Version: v})
case inc.Deleted:
// Rule 4a: stale delete vs newer server change → edit wins, delete dropped.
sn := *cur
results = append(results, PushResult{ID: inc.ID, Status: "conflict", ServerNote: &sn})
case cur.Deleted:
// Rule 4b: stale edit vs server tombstone → edit wins, overwrites the tombstone.
v, err := nextVersionTx(tx)
if err != nil {
return nil, err
}
if _, err := tx.Exec(
`UPDATE notes SET content=?, tags=?, modified_at=?, deleted=0, version=? WHERE id=?`,
inc.Content, tagsJSON(inc.Tags), inc.ModifiedAt, v, inc.ID); err != nil {
return nil, err
}
results = append(results, PushResult{ID: inc.ID, Status: "accepted", Version: v})
default:
// Rule 3: true edit/edit conflict → server content stays; incoming becomes a
// conflict copy with a new id, `conflict` tag and a marker on its first line.
copyID := newUUID()
v, err := nextVersionTx(tx)
if err != nil {
return nil, err
}
tags := inc.Tags
if !contains(tags, "conflict") {
tags = append(append([]string{}, tags...), "conflict")
}
if _, err := tx.Exec(
`INSERT INTO notes (id, content, tags, created_at, modified_at, deleted, version) VALUES (?,?,?,?,?,?,?)`,
copyID, conflictTitle(inc.Content, now), tagsJSON(tags), inc.ModifiedAt, inc.ModifiedAt, 0, v); err != nil {
return nil, err
}
sn := *cur
results = append(results, PushResult{ID: inc.ID, Status: "conflict", ConflictCopyID: copyID, ServerNote: &sn})
}
}
if err := tx.Commit(); err != nil {
return nil, err
}
return results, nil
}
// UpsertImported inserts an imported note keyed by source_id; returns false if it
// already exists (dedupe — SPEC §6 idempotent import).
func (s *Store) UpsertImported(sourceID string, n Note) (bool, error) {
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
var existing string
err = tx.QueryRow(`SELECT id FROM notes WHERE source_id = ?`, sourceID).Scan(&existing)
if err == nil {
return false, tx.Commit() // already imported
}
if !errors.Is(err, sql.ErrNoRows) {
return false, err
}
v, err := nextVersionTx(tx)
if err != nil {
return false, err
}
if n.ID == "" {
n.ID = newUUID()
}
if _, err := tx.Exec(
`INSERT INTO notes (id, content, tags, created_at, modified_at, deleted, version, source_id) VALUES (?,?,?,?,?,?,?,?)`,
n.ID, n.Content, tagsJSON(n.Tags), n.CreatedAt, n.ModifiedAt, b2i(n.Deleted), v, sourceID); err != nil {
return false, err
}
return true, tx.Commit()
}
// Compact purges tombstones older than the given number of days.
func (s *Store) Compact(olderThanDays int) (int64, error) {
cutoff := time.Now().AddDate(0, 0, -olderThanDays).UnixMilli()
res, err := s.db.Exec(`DELETE FROM notes WHERE deleted = 1 AND modified_at < ?`, cutoff)
if err != nil {
return 0, err
}
return res.RowsAffected()
}
// Backup writes a consistent snapshot via VACUUM INTO.
func (s *Store) Backup(path string) error {
_, err := s.db.Exec(`VACUUM INTO ?`, path)
return err
}
func (s *Store) NoteCount() (total, deleted int64, err error) {
err = s.db.QueryRow(`SELECT COUNT(*), COALESCE(SUM(deleted), 0) FROM notes`).Scan(&total, &deleted)
return
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
func contains(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

213
server/store/store_test.go Normal file
View File

@@ -0,0 +1,213 @@
package store
import (
"path/filepath"
"strings"
"testing"
"time"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
s, err := Open(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { s.Close() })
return s
}
func push(t *testing.T, s *Store, n IncomingNote) PushResult {
t.Helper()
res, err := s.ApplyBatch([]IncomingNote{n}, time.Date(2026, 7, 12, 10, 30, 0, 0, time.UTC))
if err != nil {
t.Fatal(err)
}
if len(res) != 1 {
t.Fatalf("want 1 result, got %d", len(res))
}
return res[0]
}
func TestInsertAndFastForward(t *testing.T) {
s := newTestStore(t)
r := push(t, s, IncomingNote{ID: "a", Content: "hello\nworld", CreatedAt: 1, ModifiedAt: 1})
if r.Status != "accepted" || r.Version != 1 {
t.Fatalf("insert: %+v", r)
}
r = push(t, s, IncomingNote{ID: "a", Content: "hello v2", ModifiedAt: 2, BaseVersion: r.Version})
if r.Status != "accepted" || r.Version != 2 {
t.Fatalf("fast-forward: %+v", r)
}
notes, cursor, more, err := s.Changes(0, 500)
if err != nil {
t.Fatal(err)
}
if len(notes) != 1 || notes[0].Content != "hello v2" || cursor != 2 || more {
t.Fatalf("changes: %+v cursor=%d more=%v", notes, cursor, more)
}
}
func TestEditEditConflictCreatesCopy(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "base note", ModifiedAt: 1})
// device B fast-forwards
push(t, s, IncomingNote{ID: "a", Content: "B's edit", ModifiedAt: 2, BaseVersion: r1.Version})
// device A pushes a stale edit
r := push(t, s, IncomingNote{ID: "a", Content: "A's edit", Tags: []string{"x"}, ModifiedAt: 3, BaseVersion: r1.Version})
if r.Status != "conflict" || r.ConflictCopyID == "" {
t.Fatalf("want conflict with copy, got %+v", r)
}
if r.ServerNote == nil || r.ServerNote.Content != "B's edit" {
t.Fatalf("server truth missing: %+v", r.ServerNote)
}
notes, _, _, err := s.Changes(0, 500)
if err != nil {
t.Fatal(err)
}
if len(notes) != 2 {
t.Fatalf("want original + conflict copy, got %d notes", len(notes))
}
var copyNote *Note
for i := range notes {
if notes[i].ID == r.ConflictCopyID {
copyNote = &notes[i]
}
}
if copyNote == nil {
t.Fatal("conflict copy not in changes feed")
}
if !strings.Contains(copyNote.Content, "A's edit (conflicted copy 2026-07-12 10:30)") {
t.Fatalf("conflict marker missing: %q", copyNote.Content)
}
if !containsStr(copyNote.Tags, "conflict") || !containsStr(copyNote.Tags, "x") {
t.Fatalf("conflict tags wrong: %v", copyNote.Tags)
}
}
func TestStaleDeleteLosesToEdit(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1})
push(t, s, IncomingNote{ID: "a", Content: "v2 edited", ModifiedAt: 2, BaseVersion: r1.Version})
r := push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: 3, BaseVersion: r1.Version})
if r.Status != "conflict" || r.ConflictCopyID != "" {
t.Fatalf("stale delete must be dropped without a copy: %+v", r)
}
notes, _, _, _ := s.Changes(0, 500)
if len(notes) != 1 || notes[0].Deleted || notes[0].Content != "v2 edited" {
t.Fatalf("edit must survive: %+v", notes)
}
}
func TestStaleEditBeatsTombstone(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1})
push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version})
r := push(t, s, IncomingNote{ID: "a", Content: "resurrected", ModifiedAt: 3, BaseVersion: r1.Version})
if r.Status != "accepted" {
t.Fatalf("edit must overwrite tombstone: %+v", r)
}
notes, _, _, _ := s.Changes(0, 500)
if len(notes) != 1 || notes[0].Deleted || notes[0].Content != "resurrected" {
t.Fatalf("tombstone must be overwritten: %+v", notes)
}
}
func TestCleanDeleteAccepted(t *testing.T) {
s := newTestStore(t)
r1 := push(t, s, IncomingNote{ID: "a", Content: "v1", ModifiedAt: 1})
r := push(t, s, IncomingNote{ID: "a", Content: "v1", Deleted: true, ModifiedAt: 2, BaseVersion: r1.Version})
if r.Status != "accepted" {
t.Fatalf("clean delete: %+v", r)
}
notes, _, _, _ := s.Changes(0, 500)
if len(notes) != 1 || !notes[0].Deleted {
t.Fatalf("tombstone expected: %+v", notes)
}
}
func TestChangesPaging(t *testing.T) {
s := newTestStore(t)
for i := 0; i < 7; i++ {
push(t, s, IncomingNote{ID: string(rune('a' + i)), Content: "n", ModifiedAt: int64(i)})
}
var got int
var cursor int64
for {
notes, c, more, err := s.Changes(cursor, 3)
if err != nil {
t.Fatal(err)
}
got += len(notes)
cursor = c
if !more {
break
}
}
if got != 7 {
t.Fatalf("paged total = %d, want 7", got)
}
}
func TestTokenRoundtrip(t *testing.T) {
s := newTestStore(t)
tok, err := s.EnsureToken()
if err != nil || tok == "" {
t.Fatalf("EnsureToken: %q %v", tok, err)
}
if again, _ := s.EnsureToken(); again != "" {
t.Fatal("EnsureToken must not regenerate")
}
if !s.CheckToken(tok) {
t.Fatal("valid token rejected")
}
if s.CheckToken("nope") {
t.Fatal("invalid token accepted")
}
tok2, err := s.RotateToken()
if err != nil {
t.Fatal(err)
}
if s.CheckToken(tok) || !s.CheckToken(tok2) {
t.Fatal("rotation did not invalidate old token")
}
}
func TestCompact(t *testing.T) {
s := newTestStore(t)
old := time.Now().AddDate(0, 0, -200).UnixMilli()
r1 := push(t, s, IncomingNote{ID: "a", Content: "x", ModifiedAt: old})
push(t, s, IncomingNote{ID: "a", Deleted: true, ModifiedAt: old, BaseVersion: r1.Version})
push(t, s, IncomingNote{ID: "b", Content: "keep", ModifiedAt: time.Now().UnixMilli()})
n, err := s.Compact(90)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatalf("purged %d, want 1", n)
}
total, deleted, _ := s.NoteCount()
if total != 1 || deleted != 0 {
t.Fatalf("after compact: total=%d deleted=%d", total, deleted)
}
}
func containsStr(ss []string, s string) bool {
for _, x := range ss {
if x == s {
return true
}
}
return false
}

0
server/webdist/dist/.gitkeep vendored Normal file
View File

17
server/webdist/webdist.go Normal file
View File

@@ -0,0 +1,17 @@
// Package webdist embeds the built frontend bundle. The Makefile copies
// ../frontend/dist into this directory before `go build` (go:embed cannot
// reach outside the module directory).
package webdist
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
// FS returns the frontend bundle rooted at its dist directory.
func FS() (fs.FS, error) {
return fs.Sub(dist, "dist")
}