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
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();
}