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:
153
server/api/api.go
Normal file
153
server/api/api.go
Normal 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
256
server/api/api_test.go
Normal 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
17
server/go.mod
Normal 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
51
server/go.sum
Normal 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=
|
||||
126
server/importer/simplenote.go
Normal file
126
server/importer/simplenote.go
Normal 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]
|
||||
}
|
||||
124
server/importer/simplenote_test.go
Normal file
124
server/importer/simplenote_test.go
Normal 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
211
server/main.go
Normal 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
408
server/store/store.go
Normal 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
213
server/store/store_test.go
Normal 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 = ¬es[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
0
server/webdist/dist/.gitkeep
vendored
Normal file
17
server/webdist/webdist.go
Normal file
17
server/webdist/webdist.go
Normal 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")
|
||||
}
|
||||
Reference in New Issue
Block a user