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:
113
frontend/src/sync/engine.ts
Normal file
113
frontend/src/sync/engine.ts
Normal 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();
|
||||
}
|
||||
Reference in New Issue
Block a user