- 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>
212 lines
4.9 KiB
Go
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)
|
|
}
|