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:
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)
|
||||
}
|
||||
Reference in New Issue
Block a user