Files
tefter/server/main.go
Senad Uka 175c89e400 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>
2026-07-12 07:16:01 +02:00

212 lines
4.9 KiB
Go

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