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

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")
}