chore: added tui to jotts-go 75e24d41
Steve Simkins · 2026-05-16 18:11 19 file(s) · +1655 −144
apps/jotts-go/app.go +3 −13
6 6
	"html/template"
7 7
	"log/slog"
8 8
9 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/internal/store"
9 10
	"github.com/stevedylandev/andromeda/crates-go/auth"
10 11
)
11 12
22 23
	CookieSecure bool
23 24
}
24 25
25 -
type Note struct {
26 -
	ID        int64  `json:"id"`
27 -
	ShortID   string `json:"short_id"`
28 -
	Title     string `json:"title"`
29 -
	Content   string `json:"content"`
30 -
	CreatedAt string `json:"created_at"`
31 -
	UpdatedAt string `json:"updated_at"`
32 -
}
33 -
34 -
type NoteInput struct {
35 -
	Title   string `json:"title"`
36 -
	Content string `json:"content"`
37 -
}
26 +
type Note = store.Note
27 +
type NoteInput = store.NoteInput
38 28
39 29
type indexPageData struct {
40 30
	Notes []Note
apps/jotts-go/cmd_auth.go (added) +48 −0
1 +
package main
2 +
3 +
import (
4 +
	"bufio"
5 +
	"fmt"
6 +
	"os"
7 +
	"strings"
8 +
	"syscall"
9 +
10 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/tui"
11 +
	"golang.org/x/term"
12 +
)
13 +
14 +
func runAuth(_ []string) {
15 +
	cfg, _ := tui.LoadConfig()
16 +
	reader := bufio.NewReader(os.Stdin)
17 +
18 +
	defaultURL := cfg.RemoteURL
19 +
	if defaultURL == "" {
20 +
		defaultURL = "http://localhost:3000"
21 +
	}
22 +
	fmt.Printf("Remote URL [%s]: ", defaultURL)
23 +
	line, _ := reader.ReadString('\n')
24 +
	line = strings.TrimSpace(line)
25 +
	if line != "" {
26 +
		cfg.RemoteURL = line
27 +
	} else {
28 +
		cfg.RemoteURL = defaultURL
29 +
	}
30 +
31 +
	fmt.Print("API key (hidden): ")
32 +
	keyBytes, err := term.ReadPassword(int(syscall.Stdin))
33 +
	fmt.Println()
34 +
	if err != nil {
35 +
		fmt.Fprintln(os.Stderr, "read api key:", err)
36 +
		os.Exit(1)
37 +
	}
38 +
	if k := strings.TrimSpace(string(keyBytes)); k != "" {
39 +
		cfg.APIKey = k
40 +
	}
41 +
42 +
	if err := tui.SaveConfig(cfg); err != nil {
43 +
		fmt.Fprintln(os.Stderr, "save config:", err)
44 +
		os.Exit(1)
45 +
	}
46 +
	path, _ := tui.ConfigPath()
47 +
	fmt.Println("Saved", path)
48 +
}
apps/jotts-go/cmd_server.go (added) +58 −0
1 +
package main
2 +
3 +
import (
4 +
	"html/template"
5 +
	"log"
6 +
	"log/slog"
7 +
	"net/http"
8 +
	"os"
9 +
10 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
11 +
	"github.com/stevedylandev/andromeda/crates-go/config"
12 +
)
13 +
14 +
func runServer(args []string) {
15 +
	config.LoadDotEnv(".env")
16 +
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 +
18 +
	dbPath := config.Getenv("JOTTS_DB_PATH", "jotts.sqlite")
19 +
	db, err := openDB(dbPath)
20 +
	if err != nil {
21 +
		log.Fatal(err)
22 +
	}
23 +
	defer db.Close()
24 +
25 +
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
26 +
	if err := sessions.EnsureSchema(); err != nil {
27 +
		log.Fatal(err)
28 +
	}
29 +
	sessions.PruneExpired()
30 +
31 +
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
32 +
33 +
	password := os.Getenv("JOTTS_PASSWORD")
34 +
	if password == "" {
35 +
		logger.Warn("JOTTS_PASSWORD not set, using default 'changeme'")
36 +
		password = "changeme"
37 +
	}
38 +
	apiKey := os.Getenv("JOTTS_API_KEY")
39 +
	if apiKey == "" {
40 +
		logger.Info("JOTTS_API_KEY not set, /api/* will return 403")
41 +
	}
42 +
43 +
	app := &App{
44 +
		DB:           db,
45 +
		Log:          logger,
46 +
		Templates:    tmpl,
47 +
		Sessions:     sessions,
48 +
		Password:     password,
49 +
		APIKey:       apiKey,
50 +
		CookieSecure: sessions.CookieSecure,
51 +
	}
52 +
53 +
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
54 +
	logger.Info("jotts-go server running", "addr", addr)
55 +
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
56 +
		log.Fatal(err)
57 +
	}
58 +
}
apps/jotts-go/cmd_upload.go (added) +47 −0
1 +
package main
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"path/filepath"
7 +
	"strings"
8 +
9 +
	"github.com/atotto/clipboard"
10 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/tui"
11 +
)
12 +
13 +
func runUpload(args []string) {
14 +
	path := args[0]
15 +
	data, err := os.ReadFile(path)
16 +
	if err != nil {
17 +
		fmt.Fprintln(os.Stderr, "read file:", err)
18 +
		os.Exit(1)
19 +
	}
20 +
	title := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
21 +
	if title == "" {
22 +
		title = "Untitled"
23 +
	}
24 +
25 +
	backend, err := tui.ResolveBackend(tui.ParseArgs(args[1:]))
26 +
	if err != nil {
27 +
		fmt.Fprintln(os.Stderr, "backend:", err)
28 +
		os.Exit(1)
29 +
	}
30 +
	defer backend.Close()
31 +
32 +
	note, err := backend.Create(title, string(data))
33 +
	if err != nil {
34 +
		fmt.Fprintln(os.Stderr, "create note:", err)
35 +
		os.Exit(1)
36 +
	}
37 +
38 +
	if remote := backend.RemoteURL(); remote != "" {
39 +
		link := strings.TrimRight(remote, "/") + "/notes/" + note.ShortID
40 +
		fmt.Println(link)
41 +
		if err := clipboard.WriteAll(link); err == nil {
42 +
			fmt.Println("(copied to clipboard)")
43 +
		}
44 +
	} else {
45 +
		fmt.Println("created:", note.ShortID)
46 +
	}
47 +
}
apps/jotts-go/db.go +7 −89
2 2
3 3
import (
4 4
	"database/sql"
5 -
	"errors"
6 5
7 -
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 -
	_ "modernc.org/sqlite"
6 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/internal/store"
9 7
)
10 8
11 -
const noteColumns = `id, short_id, title, content, created_at, updated_at`
12 -
13 -
const schema = `
14 -
CREATE TABLE IF NOT EXISTS notes (
15 -
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
16 -
    short_id   TEXT NOT NULL UNIQUE,
17 -
    title      TEXT NOT NULL,
18 -
    content    TEXT NOT NULL,
19 -
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
20 -
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
21 -
);
22 -
`
23 -
24 -
func openDB(path string) (*sql.DB, error) {
25 -
	db, err := sql.Open("sqlite", path)
26 -
	if err != nil {
27 -
		return nil, err
28 -
	}
29 -
	db.SetMaxOpenConns(1)
30 -
	db.SetMaxIdleConns(1)
31 -
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
32 -
		return nil, err
33 -
	}
34 -
	if _, err := db.Exec(schema); err != nil {
35 -
		return nil, err
36 -
	}
37 -
	return db, nil
38 -
}
39 -
40 -
func scanNote(scanner interface{ Scan(dest ...any) error }) (*Note, error) {
41 -
	var n Note
42 -
	err := scanner.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt)
43 -
	if errors.Is(err, sql.ErrNoRows) {
44 -
		return nil, nil
45 -
	}
46 -
	if err != nil {
47 -
		return nil, err
48 -
	}
49 -
	return &n, nil
50 -
}
9 +
func openDB(path string) (*sql.DB, error) { return store.Open(path) }
51 10
52 11
func createNote(db *sql.DB, title, content string) (*Note, error) {
53 -
	shortID, err := auth.GenerateShortID(10)
54 -
	if err != nil {
55 -
		return nil, err
56 -
	}
57 -
	res, err := db.Exec(`INSERT INTO notes (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
58 -
	if err != nil {
59 -
		return nil, err
60 -
	}
61 -
	id, err := res.LastInsertId()
62 -
	if err != nil {
63 -
		return nil, err
64 -
	}
65 -
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE id = ?`, id))
12 +
	return store.Create(db, title, content)
66 13
}
67 14
68 15
func getNoteByShortID(db *sql.DB, shortID string) (*Note, error) {
69 -
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE short_id = ?`, shortID))
16 +
	return store.GetByShortID(db, shortID)
70 17
}
71 18
72 -
func listNotes(db *sql.DB) ([]Note, error) {
73 -
	rows, err := db.Query(`SELECT ` + noteColumns + ` FROM notes ORDER BY id DESC`)
74 -
	if err != nil {
75 -
		return nil, err
76 -
	}
77 -
	defer rows.Close()
78 -
	var out []Note
79 -
	for rows.Next() {
80 -
		var n Note
81 -
		if err := rows.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt); err != nil {
82 -
			return nil, err
83 -
		}
84 -
		out = append(out, n)
85 -
	}
86 -
	return out, rows.Err()
87 -
}
19 +
func listNotes(db *sql.DB) ([]Note, error) { return store.List(db) }
88 20
89 21
func updateNoteByShortID(db *sql.DB, shortID, title, content string) (*Note, error) {
90 -
	res, err := db.Exec(`UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE short_id = ?`, title, content, shortID)
91 -
	if err != nil {
92 -
		return nil, err
93 -
	}
94 -
	n, _ := res.RowsAffected()
95 -
	if n == 0 {
96 -
		return nil, nil
97 -
	}
98 -
	return getNoteByShortID(db, shortID)
22 +
	return store.UpdateByShortID(db, shortID, title, content)
99 23
}
100 24
101 25
func deleteNoteByShortID(db *sql.DB, shortID string) (bool, error) {
102 -
	res, err := db.Exec(`DELETE FROM notes WHERE short_id = ?`, shortID)
103 -
	if err != nil {
104 -
		return false, err
105 -
	}
106 -
	n, _ := res.RowsAffected()
107 -
	return n > 0, nil
26 +
	return store.DeleteByShortID(db, shortID)
108 27
}
109 -
apps/jotts-go/go.mod +37 −3
1 1
module github.com/stevedylandev/andromeda/apps/jotts-go
2 2
3 -
go 1.24.4
3 +
go 1.25.0
4 4
5 5
require (
6 6
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 7
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 8
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
9 9
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 -
	github.com/yuin/goldmark v1.7.8
10 +
	github.com/yuin/goldmark v1.7.13
11 11
	modernc.org/sqlite v1.37.1
12 12
)
13 13
19 19
)
20 20
21 21
require (
22 +
	github.com/BurntSushi/toml v1.6.0 // indirect
23 +
	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
24 +
	github.com/atotto/clipboard v0.1.4 // indirect
25 +
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
26 +
	github.com/aymerick/douceur v0.2.0 // indirect
27 +
	github.com/charmbracelet/bubbles v1.0.0 // indirect
28 +
	github.com/charmbracelet/bubbletea v1.3.10 // indirect
29 +
	github.com/charmbracelet/colorprofile v0.4.1 // indirect
30 +
	github.com/charmbracelet/glamour v1.0.0 // indirect
31 +
	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect
32 +
	github.com/charmbracelet/x/ansi v0.11.6 // indirect
33 +
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
34 +
	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
35 +
	github.com/charmbracelet/x/term v0.2.2 // indirect
36 +
	github.com/clipperhouse/displaywidth v0.9.0 // indirect
37 +
	github.com/clipperhouse/stringish v0.1.1 // indirect
38 +
	github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
39 +
	github.com/dlclark/regexp2 v1.11.5 // indirect
22 40
	github.com/dustin/go-humanize v1.0.1 // indirect
41 +
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
23 42
	github.com/google/uuid v1.6.0 // indirect
43 +
	github.com/gorilla/css v1.0.1 // indirect
44 +
	github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
24 45
	github.com/mattn/go-isatty v0.0.20 // indirect
46 +
	github.com/mattn/go-localereader v0.0.1 // indirect
47 +
	github.com/mattn/go-runewidth v0.0.19 // indirect
48 +
	github.com/microcosm-cc/bluemonday v1.0.27 // indirect
49 +
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
50 +
	github.com/muesli/cancelreader v0.2.2 // indirect
51 +
	github.com/muesli/reflow v0.3.0 // indirect
52 +
	github.com/muesli/termenv v0.16.0 // indirect
25 53
	github.com/ncruces/go-strftime v0.1.9 // indirect
26 54
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
55 +
	github.com/rivo/uniseg v0.4.7 // indirect
56 +
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
57 +
	github.com/yuin/goldmark-emoji v1.0.6 // indirect
27 58
	golang.org/x/crypto v0.39.0 // indirect
28 59
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
29 -
	golang.org/x/sys v0.33.0 // indirect
60 +
	golang.org/x/net v0.38.0 // indirect
61 +
	golang.org/x/sys v0.44.0 // indirect
62 +
	golang.org/x/term v0.43.0 // indirect
63 +
	golang.org/x/text v0.30.0 // indirect
30 64
	modernc.org/libc v1.65.7 // indirect
31 65
	modernc.org/mathutil v1.7.1 // indirect
32 66
	modernc.org/memory v1.11.0 // indirect
apps/jotts-go/go.sum +76 −0
1 +
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 +
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 +
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
4 +
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
5 +
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
6 +
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8 +
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
9 +
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
10 +
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
11 +
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
12 +
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
13 +
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
14 +
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
15 +
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
16 +
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
17 +
github.com/charmbracelet/glamour v1.0.0 h1:AWMLOVFHTsysl4WV8T8QgkQ0s/ZNZo7CiE4WKhk8l08=
18 +
github.com/charmbracelet/glamour v1.0.0/go.mod h1:DSdohgOBkMr2ZQNhw4LZxSGpx3SvpeujNoXrQyH2hxo=
19 +
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 h1:ZR7e0ro+SZZiIZD7msJyA+NjkCNNavuiPBLgerbOziE=
20 +
github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834/go.mod h1:aKC/t2arECF6rNOnaKaVU6y4t4ZeHQzqfxedE/VkVhA=
21 +
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
22 +
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
23 +
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
24 +
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
25 +
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
26 +
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
27 +
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
28 +
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
29 +
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
30 +
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
31 +
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
32 +
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
33 +
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
34 +
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
35 +
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
36 +
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
1 37
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 38
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
39 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
40 +
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
3 41
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
4 42
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
5 43
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6 44
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
45 +
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
46 +
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
47 +
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
48 +
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
7 49
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8 50
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
51 +
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
52 +
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
53 +
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
54 +
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
55 +
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
56 +
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
57 +
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
58 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
59 +
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
60 +
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
61 +
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
62 +
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
63 +
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
64 +
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
65 +
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
9 66
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
10 67
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
11 68
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
12 69
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
70 +
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
71 +
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
72 +
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
73 +
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
74 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
75 +
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
13 76
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
14 77
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
78 +
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
79 +
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
80 +
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
81 +
github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA=
15 82
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
16 83
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
17 84
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
18 85
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
19 86
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
20 87
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
88 +
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
89 +
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
21 90
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
22 91
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
92 +
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
23 93
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
24 94
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
25 95
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
96 +
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
97 +
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
98 +
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
99 +
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
100 +
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
101 +
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
26 102
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
27 103
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
28 104
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
apps/jotts-go/internal/store/store.go (added) +122 −0
1 +
package store
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
7 +
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 +
	_ "modernc.org/sqlite"
9 +
)
10 +
11 +
type Note struct {
12 +
	ID        int64  `json:"id"`
13 +
	ShortID   string `json:"short_id"`
14 +
	Title     string `json:"title"`
15 +
	Content   string `json:"content"`
16 +
	CreatedAt string `json:"created_at"`
17 +
	UpdatedAt string `json:"updated_at"`
18 +
}
19 +
20 +
type NoteInput struct {
21 +
	Title   string `json:"title"`
22 +
	Content string `json:"content"`
23 +
}
24 +
25 +
const noteColumns = `id, short_id, title, content, created_at, updated_at`
26 +
27 +
const schema = `
28 +
CREATE TABLE IF NOT EXISTS notes (
29 +
    id         INTEGER PRIMARY KEY AUTOINCREMENT,
30 +
    short_id   TEXT NOT NULL UNIQUE,
31 +
    title      TEXT NOT NULL,
32 +
    content    TEXT NOT NULL,
33 +
    created_at TEXT NOT NULL DEFAULT (datetime('now')),
34 +
    updated_at TEXT NOT NULL DEFAULT (datetime('now'))
35 +
);
36 +
`
37 +
38 +
func Open(path string) (*sql.DB, error) {
39 +
	db, err := sql.Open("sqlite", path)
40 +
	if err != nil {
41 +
		return nil, err
42 +
	}
43 +
	db.SetMaxOpenConns(1)
44 +
	db.SetMaxIdleConns(1)
45 +
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
46 +
		return nil, err
47 +
	}
48 +
	if _, err := db.Exec(schema); err != nil {
49 +
		return nil, err
50 +
	}
51 +
	return db, nil
52 +
}
53 +
54 +
func scanNote(scanner interface{ Scan(dest ...any) error }) (*Note, error) {
55 +
	var n Note
56 +
	err := scanner.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt)
57 +
	if errors.Is(err, sql.ErrNoRows) {
58 +
		return nil, nil
59 +
	}
60 +
	if err != nil {
61 +
		return nil, err
62 +
	}
63 +
	return &n, nil
64 +
}
65 +
66 +
func Create(db *sql.DB, title, content string) (*Note, error) {
67 +
	shortID, err := auth.GenerateShortID(10)
68 +
	if err != nil {
69 +
		return nil, err
70 +
	}
71 +
	res, err := db.Exec(`INSERT INTO notes (short_id, title, content) VALUES (?, ?, ?)`, shortID, title, content)
72 +
	if err != nil {
73 +
		return nil, err
74 +
	}
75 +
	id, err := res.LastInsertId()
76 +
	if err != nil {
77 +
		return nil, err
78 +
	}
79 +
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE id = ?`, id))
80 +
}
81 +
82 +
func GetByShortID(db *sql.DB, shortID string) (*Note, error) {
83 +
	return scanNote(db.QueryRow(`SELECT `+noteColumns+` FROM notes WHERE short_id = ?`, shortID))
84 +
}
85 +
86 +
func List(db *sql.DB) ([]Note, error) {
87 +
	rows, err := db.Query(`SELECT ` + noteColumns + ` FROM notes ORDER BY id DESC`)
88 +
	if err != nil {
89 +
		return nil, err
90 +
	}
91 +
	defer rows.Close()
92 +
	var out []Note
93 +
	for rows.Next() {
94 +
		var n Note
95 +
		if err := rows.Scan(&n.ID, &n.ShortID, &n.Title, &n.Content, &n.CreatedAt, &n.UpdatedAt); err != nil {
96 +
			return nil, err
97 +
		}
98 +
		out = append(out, n)
99 +
	}
100 +
	return out, rows.Err()
101 +
}
102 +
103 +
func UpdateByShortID(db *sql.DB, shortID, title, content string) (*Note, error) {
104 +
	res, err := db.Exec(`UPDATE notes SET title = ?, content = ?, updated_at = datetime('now') WHERE short_id = ?`, title, content, shortID)
105 +
	if err != nil {
106 +
		return nil, err
107 +
	}
108 +
	n, _ := res.RowsAffected()
109 +
	if n == 0 {
110 +
		return nil, nil
111 +
	}
112 +
	return GetByShortID(db, shortID)
113 +
}
114 +
115 +
func DeleteByShortID(db *sql.DB, shortID string) (bool, error) {
116 +
	res, err := db.Exec(`DELETE FROM notes WHERE short_id = ?`, shortID)
117 +
	if err != nil {
118 +
		return false, err
119 +
	}
120 +
	n, _ := res.RowsAffected()
121 +
	return n > 0, nil
122 +
}
apps/jotts-go/main.go +35 −39
1 1
package main
2 2
3 3
import (
4 -
	"html/template"
5 -
	"log"
6 -
	"log/slog"
7 -
	"net/http"
4 +
	"fmt"
8 5
	"os"
9 6
10 -
	"github.com/stevedylandev/andromeda/crates-go/auth"
7 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/tui"
11 8
	"github.com/stevedylandev/andromeda/crates-go/config"
12 9
)
13 10
14 11
func main() {
15 12
	config.LoadDotEnv(".env")
16 -
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 13
18 -
	dbPath := config.Getenv("JOTTS_DB_PATH", "jotts.sqlite")
19 -
	db, err := openDB(dbPath)
20 -
	if err != nil {
21 -
		log.Fatal(err)
14 +
	args := os.Args[1:]
15 +
	if len(args) == 0 {
16 +
		runTUI(nil)
17 +
		return
22 18
	}
23 -
	defer db.Close()
24 19
25 -
	sessions := &auth.Store{DB: db, CookieName: "session", CookieSecure: config.GetenvBool("COOKIE_SECURE", false)}
26 -
	if err := sessions.EnsureSchema(); err != nil {
27 -
		log.Fatal(err)
20 +
	switch args[0] {
21 +
	case "server":
22 +
		runServer(args[1:])
23 +
	case "tui":
24 +
		runTUI(args[1:])
25 +
	case "auth":
26 +
		runAuth(args[1:])
27 +
	case "-h", "--help", "help":
28 +
		printUsage()
29 +
	default:
30 +
		if _, err := os.Stat(args[0]); err == nil {
31 +
			runUpload(args)
32 +
			return
33 +
		}
34 +
		runTUI(args)
28 35
	}
29 -
	sessions.PruneExpired()
30 -
31 -
	tmpl := template.Must(template.ParseFS(appFS, "templates/*.html"))
36 +
}
32 37
33 -
	password := os.Getenv("JOTTS_PASSWORD")
34 -
	if password == "" {
35 -
		logger.Warn("JOTTS_PASSWORD not set, using default 'changeme'")
36 -
		password = "changeme"
38 +
func runTUI(args []string) {
39 +
	if err := tui.Run(tui.ParseArgs(args)); err != nil {
40 +
		fmt.Fprintln(os.Stderr, "tui error:", err)
41 +
		os.Exit(1)
37 42
	}
38 -
	apiKey := os.Getenv("JOTTS_API_KEY")
39 -
	if apiKey == "" {
40 -
		logger.Info("JOTTS_API_KEY not set, /api/* will return 403")
41 -
	}
43 +
}
42 44
43 -
	app := &App{
44 -
		DB:           db,
45 -
		Log:          logger,
46 -
		Templates:    tmpl,
47 -
		Sessions:     sessions,
48 -
		Password:     password,
49 -
		APIKey:       apiKey,
50 -
		CookieSecure: sessions.CookieSecure,
51 -
	}
45 +
func printUsage() {
46 +
	fmt.Println(`jotts-go — minimal markdown notes
52 47
53 -
	addr := config.Getenv("HOST", "127.0.0.1") + ":" + config.Getenv("PORT", "3000")
54 -
	logger.Info("jotts-go server running", "addr", addr)
55 -
	if err := http.ListenAndServe(addr, app.routes()); err != nil {
56 -
		log.Fatal(err)
57 -
	}
48 +
usage:
49 +
  jotts-go                            launch TUI (default)
50 +
  jotts-go tui  [--remote URL --api-key KEY]
51 +
  jotts-go server                     run HTTP server
52 +
  jotts-go auth                       configure remote URL + API key
53 +
  jotts-go <file.md>                  upload file as a new note`)
58 54
}
apps/jotts-go/tui/backend.go (added) +210 −0
1 +
package tui
2 +
3 +
import (
4 +
	"bytes"
5 +
	"database/sql"
6 +
	"encoding/json"
7 +
	"fmt"
8 +
	"io"
9 +
	"net/http"
10 +
	"os"
11 +
	"strings"
12 +
	"time"
13 +
14 +
	"github.com/stevedylandev/andromeda/apps/jotts-go/internal/store"
15 +
	"github.com/stevedylandev/andromeda/crates-go/config"
16 +
)
17 +
18 +
type Note = store.Note
19 +
20 +
type Backend interface {
21 +
	List() ([]Note, error)
22 +
	Get(shortID string) (*Note, error)
23 +
	Create(title, content string) (*Note, error)
24 +
	Update(shortID, title, content string) (*Note, error)
25 +
	Delete(shortID string) (bool, error)
26 +
	RemoteURL() string
27 +
	Close() error
28 +
}
29 +
30 +
type LocalBackend struct {
31 +
	DB *sql.DB
32 +
}
33 +
34 +
func (b *LocalBackend) List() ([]Note, error)               { return store.List(b.DB) }
35 +
func (b *LocalBackend) Get(s string) (*Note, error)         { return store.GetByShortID(b.DB, s) }
36 +
func (b *LocalBackend) Create(t, c string) (*Note, error)   { return store.Create(b.DB, t, c) }
37 +
func (b *LocalBackend) Update(s, t, c string) (*Note, error) {
38 +
	return store.UpdateByShortID(b.DB, s, t, c)
39 +
}
40 +
func (b *LocalBackend) Delete(s string) (bool, error) { return store.DeleteByShortID(b.DB, s) }
41 +
func (b *LocalBackend) RemoteURL() string             { return "" }
42 +
func (b *LocalBackend) Close() error                  { return b.DB.Close() }
43 +
44 +
type RemoteBackend struct {
45 +
	BaseURL string
46 +
	APIKey  string
47 +
	Client  *http.Client
48 +
}
49 +
50 +
func (r *RemoteBackend) RemoteURL() string { return r.BaseURL }
51 +
func (r *RemoteBackend) Close() error      { return nil }
52 +
53 +
func (r *RemoteBackend) do(method, path string, body any, out any) error {
54 +
	var reader io.Reader
55 +
	if body != nil {
56 +
		buf, err := json.Marshal(body)
57 +
		if err != nil {
58 +
			return err
59 +
		}
60 +
		reader = bytes.NewReader(buf)
61 +
	}
62 +
	req, err := http.NewRequest(method, strings.TrimRight(r.BaseURL, "/")+path, reader)
63 +
	if err != nil {
64 +
		return err
65 +
	}
66 +
	if body != nil {
67 +
		req.Header.Set("Content-Type", "application/json")
68 +
	}
69 +
	if r.APIKey != "" {
70 +
		req.Header.Set("x-api-key", r.APIKey)
71 +
	}
72 +
	resp, err := r.Client.Do(req)
73 +
	if err != nil {
74 +
		return err
75 +
	}
76 +
	defer resp.Body.Close()
77 +
	if resp.StatusCode == http.StatusNotFound {
78 +
		return errNotFound
79 +
	}
80 +
	if resp.StatusCode >= 400 {
81 +
		b, _ := io.ReadAll(resp.Body)
82 +
		return fmt.Errorf("%s: %s", resp.Status, strings.TrimSpace(string(b)))
83 +
	}
84 +
	if out == nil || resp.StatusCode == http.StatusNoContent {
85 +
		return nil
86 +
	}
87 +
	return json.NewDecoder(resp.Body).Decode(out)
88 +
}
89 +
90 +
var errNotFound = fmt.Errorf("not found")
91 +
92 +
func (r *RemoteBackend) List() ([]Note, error) {
93 +
	var out []Note
94 +
	if err := r.do("GET", "/api/notes", nil, &out); err != nil {
95 +
		return nil, err
96 +
	}
97 +
	return out, nil
98 +
}
99 +
100 +
func (r *RemoteBackend) Get(shortID string) (*Note, error) {
101 +
	var n Note
102 +
	if err := r.do("GET", "/api/notes/"+shortID, nil, &n); err != nil {
103 +
		if err == errNotFound {
104 +
			return nil, nil
105 +
		}
106 +
		return nil, err
107 +
	}
108 +
	return &n, nil
109 +
}
110 +
111 +
func (r *RemoteBackend) Create(title, content string) (*Note, error) {
112 +
	var n Note
113 +
	if err := r.do("POST", "/api/notes", store.NoteInput{Title: title, Content: content}, &n); err != nil {
114 +
		return nil, err
115 +
	}
116 +
	return &n, nil
117 +
}
118 +
119 +
func (r *RemoteBackend) Update(shortID, title, content string) (*Note, error) {
120 +
	var n Note
121 +
	if err := r.do("PUT", "/api/notes/"+shortID, store.NoteInput{Title: title, Content: content}, &n); err != nil {
122 +
		if err == errNotFound {
123 +
			return nil, nil
124 +
		}
125 +
		return nil, err
126 +
	}
127 +
	return &n, nil
128 +
}
129 +
130 +
func (r *RemoteBackend) Delete(shortID string) (bool, error) {
131 +
	if err := r.do("DELETE", "/api/notes/"+shortID, nil, nil); err != nil {
132 +
		if err == errNotFound {
133 +
			return false, nil
134 +
		}
135 +
		return false, err
136 +
	}
137 +
	return true, nil
138 +
}
139 +
140 +
type Options struct {
141 +
	RemoteURL string
142 +
	APIKey    string
143 +
	DBPath    string
144 +
}
145 +
146 +
func ParseArgs(args []string) Options {
147 +
	opts := Options{}
148 +
	for i := 0; i < len(args); i++ {
149 +
		a := args[i]
150 +
		switch {
151 +
		case a == "--remote" && i+1 < len(args):
152 +
			opts.RemoteURL = args[i+1]
153 +
			i++
154 +
		case strings.HasPrefix(a, "--remote="):
155 +
			opts.RemoteURL = strings.TrimPrefix(a, "--remote=")
156 +
		case a == "--api-key" && i+1 < len(args):
157 +
			opts.APIKey = args[i+1]
158 +
			i++
159 +
		case strings.HasPrefix(a, "--api-key="):
160 +
			opts.APIKey = strings.TrimPrefix(a, "--api-key=")
161 +
		case a == "--db" && i+1 < len(args):
162 +
			opts.DBPath = args[i+1]
163 +
			i++
164 +
		}
165 +
	}
166 +
	return opts
167 +
}
168 +
169 +
func ResolveBackend(opts Options) (Backend, error) {
170 +
	cfg, _ := LoadConfig()
171 +
172 +
	remoteURL := opts.RemoteURL
173 +
	if remoteURL == "" {
174 +
		remoteURL = os.Getenv("JOTTS_REMOTE_URL")
175 +
	}
176 +
	apiKey := opts.APIKey
177 +
	if apiKey == "" {
178 +
		apiKey = os.Getenv("JOTTS_API_KEY")
179 +
	}
180 +
	if apiKey == "" {
181 +
		apiKey = cfg.APIKey
182 +
	}
183 +
184 +
	dbPath := opts.DBPath
185 +
	if dbPath == "" {
186 +
		dbPath = config.Getenv("JOTTS_DB_PATH", "jotts.sqlite")
187 +
	}
188 +
189 +
	useRemote := remoteURL != ""
190 +
	if !useRemote {
191 +
		if _, err := os.Stat(dbPath); err != nil && cfg.RemoteURL != "" {
192 +
			remoteURL = cfg.RemoteURL
193 +
			useRemote = true
194 +
		}
195 +
	}
196 +
197 +
	if useRemote {
198 +
		return &RemoteBackend{
199 +
			BaseURL: remoteURL,
200 +
			APIKey:  apiKey,
201 +
			Client:  &http.Client{Timeout: 15 * time.Second},
202 +
		}, nil
203 +
	}
204 +
205 +
	db, err := store.Open(dbPath)
206 +
	if err != nil {
207 +
		return nil, err
208 +
	}
209 +
	return &LocalBackend{DB: db}, nil
210 +
}
apps/jotts-go/tui/config.go (added) +56 −0
1 +
package tui
2 +
3 +
import (
4 +
	"os"
5 +
	"path/filepath"
6 +
7 +
	"github.com/BurntSushi/toml"
8 +
)
9 +
10 +
type Config struct {
11 +
	RemoteURL string `toml:"remote_url"`
12 +
	APIKey    string `toml:"api_key"`
13 +
}
14 +
15 +
func ConfigPath() (string, error) {
16 +
	dir, err := os.UserConfigDir()
17 +
	if err != nil {
18 +
		return "", err
19 +
	}
20 +
	return filepath.Join(dir, "jotts", "config.toml"), nil
21 +
}
22 +
23 +
func LoadConfig() (Config, error) {
24 +
	var cfg Config
25 +
	path, err := ConfigPath()
26 +
	if err != nil {
27 +
		return cfg, err
28 +
	}
29 +
	data, err := os.ReadFile(path)
30 +
	if err != nil {
31 +
		if os.IsNotExist(err) {
32 +
			return cfg, nil
33 +
		}
34 +
		return cfg, err
35 +
	}
36 +
	if err := toml.Unmarshal(data, &cfg); err != nil {
37 +
		return cfg, err
38 +
	}
39 +
	return cfg, nil
40 +
}
41 +
42 +
func SaveConfig(cfg Config) error {
43 +
	path, err := ConfigPath()
44 +
	if err != nil {
45 +
		return err
46 +
	}
47 +
	if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
48 +
		return err
49 +
	}
50 +
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o600)
51 +
	if err != nil {
52 +
		return err
53 +
	}
54 +
	defer f.Close()
55 +
	return toml.NewEncoder(f).Encode(cfg)
56 +
}
apps/jotts-go/tui/editor.go (added) +55 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"os"
6 +
	"os/exec"
7 +
	"path/filepath"
8 +
	"runtime"
9 +
10 +
	tea "github.com/charmbracelet/bubbletea"
11 +
)
12 +
13 +
func openExternalEditor(shortID, content string) tea.Cmd {
14 +
	editor := os.Getenv("EDITOR")
15 +
	if editor == "" {
16 +
		return func() tea.Msg {
17 +
			return statusMsg{text: "$EDITOR not set", ok: false}
18 +
		}
19 +
	}
20 +
21 +
	tmp := filepath.Join(os.TempDir(), fmt.Sprintf("jotts-%s.md", shortID))
22 +
	if err := os.WriteFile(tmp, []byte(content), 0o600); err != nil {
23 +
		return func() tea.Msg {
24 +
			return statusMsg{text: "tempfile: " + err.Error(), ok: false}
25 +
		}
26 +
	}
27 +
28 +
	cmd := exec.Command(editor, tmp)
29 +
	return tea.ExecProcess(cmd, func(err error) tea.Msg {
30 +
		defer os.Remove(tmp)
31 +
		if err != nil {
32 +
			return editorFinishedMsg{shortID: shortID, err: err}
33 +
		}
34 +
		b, rerr := os.ReadFile(tmp)
35 +
		if rerr != nil {
36 +
			return editorFinishedMsg{shortID: shortID, err: rerr}
37 +
		}
38 +
		return editorFinishedMsg{shortID: shortID, content: string(b)}
39 +
	})
40 +
}
41 +
42 +
func openURL(url string) error {
43 +
	var cmd *exec.Cmd
44 +
	switch runtime.GOOS {
45 +
	case "linux":
46 +
		cmd = exec.Command("xdg-open", url)
47 +
	case "darwin":
48 +
		cmd = exec.Command("open", url)
49 +
	case "windows":
50 +
		cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url)
51 +
	default:
52 +
		return fmt.Errorf("unsupported platform %s", runtime.GOOS)
53 +
	}
54 +
	return cmd.Start()
55 +
}
apps/jotts-go/tui/keys.go (added) +63 −0
1 +
package tui
2 +
3 +
import "github.com/charmbracelet/bubbles/key"
4 +
5 +
type keyMap struct {
6 +
	Up          key.Binding
7 +
	Down        key.Binding
8 +
	Open        key.Binding
9 +
	Back        key.Binding
10 +
	Quit        key.Binding
11 +
	Create      key.Binding
12 +
	Edit        key.Binding
13 +
	ExtEdit     key.Binding
14 +
	Delete      key.Binding
15 +
	Copy        key.Binding
16 +
	CopyLink    key.Binding
17 +
	OpenBrowser key.Binding
18 +
	Search      key.Binding
19 +
	Refresh     key.Binding
20 +
	Help        key.Binding
21 +
	Save        key.Binding
22 +
	ToggleWrap  key.Binding
23 +
	SwitchField key.Binding
24 +
	Cancel      key.Binding
25 +
}
26 +
27 +
func defaultKeys() keyMap {
28 +
	return keyMap{
29 +
		Up:          key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")),
30 +
		Down:        key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")),
31 +
		Open:        key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("⏎/l", "open")),
32 +
		Back:        key.NewBinding(key.WithKeys("h", "esc", " "), key.WithHelp("h/␣", "back")),
33 +
		Quit:        key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
34 +
		Create:      key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "create")),
35 +
		Edit:        key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
36 +
		ExtEdit:     key.NewBinding(key.WithKeys("E"), key.WithHelp("E", "$EDITOR")),
37 +
		Delete:      key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
38 +
		Copy:        key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy text")),
39 +
		CopyLink:    key.NewBinding(key.WithKeys("Y"), key.WithHelp("Y", "copy link")),
40 +
		OpenBrowser: key.NewBinding(key.WithKeys("o"), key.WithHelp("o", "browser")),
41 +
		Search:      key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "search")),
42 +
		Refresh:     key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
43 +
		Help:        key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
44 +
		Save:        key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("⌃s", "save")),
45 +
		ToggleWrap:  key.NewBinding(key.WithKeys("ctrl+w"), key.WithHelp("⌃w", "wrap")),
46 +
		SwitchField: key.NewBinding(key.WithKeys("tab"), key.WithHelp("⇥", "switch field")),
47 +
		Cancel:      key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
48 +
	}
49 +
}
50 +
51 +
func (k keyMap) ShortHelp() []key.Binding {
52 +
	return []key.Binding{k.Open, k.Create, k.Edit, k.Delete, k.Search, k.Help, k.Quit}
53 +
}
54 +
55 +
func (k keyMap) FullHelp() [][]key.Binding {
56 +
	return [][]key.Binding{
57 +
		{k.Up, k.Down, k.Open, k.Back},
58 +
		{k.Create, k.Edit, k.ExtEdit, k.Delete},
59 +
		{k.Copy, k.CopyLink, k.OpenBrowser, k.Search},
60 +
		{k.Refresh, k.Help, k.Save, k.ToggleWrap},
61 +
		{k.SwitchField, k.Cancel, k.Quit},
62 +
	}
63 +
}
apps/jotts-go/tui/messages.go (added) +29 −0
1 +
package tui
2 +
3 +
type notesLoadedMsg struct {
4 +
	notes []Note
5 +
	err   error
6 +
}
7 +
8 +
type noteSavedMsg struct {
9 +
	note *Note
10 +
	err  error
11 +
}
12 +
13 +
type noteDeletedMsg struct {
14 +
	shortID string
15 +
	err     error
16 +
}
17 +
18 +
type editorFinishedMsg struct {
19 +
	shortID string
20 +
	content string
21 +
	err     error
22 +
}
23 +
24 +
type statusMsg struct {
25 +
	text string
26 +
	ok   bool
27 +
}
28 +
29 +
type clearStatusMsg struct{}
apps/jotts-go/tui/model.go (added) +140 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
	"time"
6 +
7 +
	"github.com/charmbracelet/bubbles/help"
8 +
	"github.com/charmbracelet/bubbles/textarea"
9 +
	"github.com/charmbracelet/bubbles/textinput"
10 +
	"github.com/charmbracelet/bubbles/viewport"
11 +
	tea "github.com/charmbracelet/bubbletea"
12 +
)
13 +
14 +
type Focus int
15 +
16 +
const (
17 +
	FocusList Focus = iota
18 +
	FocusContent
19 +
	FocusCreateTitle
20 +
	FocusCreateContent
21 +
	FocusEditTitle
22 +
	FocusEditContent
23 +
	FocusSearch
24 +
)
25 +
26 +
type Model struct {
27 +
	backend     Backend
28 +
	isRemote    bool
29 +
30 +
	notes    []Note
31 +
	filtered []int
32 +
	cursor   int
33 +
34 +
	focus         Focus
35 +
	showHelp      bool
36 +
	confirmDelete bool
37 +
38 +
	titleInput  textinput.Model
39 +
	contentArea textarea.Model
40 +
	searchInput textinput.Model
41 +
	contentVP   viewport.Model
42 +
	help        help.Model
43 +
	keys        keyMap
44 +
45 +
	renderer *mdRenderer
46 +
	wrap     bool
47 +
48 +
	editShortID string
49 +
50 +
	status      string
51 +
	statusOK    bool
52 +
	statusUntil time.Time
53 +
54 +
	width, height int
55 +
	ready         bool
56 +
	loading       bool
57 +
	err           error
58 +
}
59 +
60 +
func newModel(backend Backend) Model {
61 +
	ti := textinput.New()
62 +
	ti.Placeholder = "Title"
63 +
	ti.Prompt = ""
64 +
	ti.CharLimit = 200
65 +
66 +
	ta := textarea.New()
67 +
	ta.Placeholder = "Write markdown..."
68 +
	ta.ShowLineNumbers = false
69 +
	ta.Prompt = ""
70 +
71 +
	si := textinput.New()
72 +
	si.Placeholder = "search titles"
73 +
	si.Prompt = "/ "
74 +
75 +
	vp := viewport.New(0, 0)
76 +
77 +
	return Model{
78 +
		backend:     backend,
79 +
		isRemote:    backend.RemoteURL() != "",
80 +
		focus:       FocusList,
81 +
		titleInput:  ti,
82 +
		contentArea: ta,
83 +
		searchInput: si,
84 +
		contentVP:   vp,
85 +
		help:        help.New(),
86 +
		keys:        defaultKeys(),
87 +
		wrap:        true,
88 +
	}
89 +
}
90 +
91 +
func (m Model) Init() tea.Cmd {
92 +
	return loadNotesCmd(m.backend)
93 +
}
94 +
95 +
func (m *Model) visibleNotes() []Note {
96 +
	if m.filtered == nil {
97 +
		return m.notes
98 +
	}
99 +
	out := make([]Note, 0, len(m.filtered))
100 +
	for _, i := range m.filtered {
101 +
		out = append(out, m.notes[i])
102 +
	}
103 +
	return out
104 +
}
105 +
106 +
func (m *Model) currentNote() *Note {
107 +
	notes := m.visibleNotes()
108 +
	if m.cursor < 0 || m.cursor >= len(notes) {
109 +
		return nil
110 +
	}
111 +
	return &notes[m.cursor]
112 +
}
113 +
114 +
func (m *Model) applyFilter(q string) {
115 +
	q = strings.TrimSpace(strings.ToLower(q))
116 +
	if q == "" {
117 +
		m.filtered = nil
118 +
		if m.cursor >= len(m.notes) {
119 +
			m.cursor = 0
120 +
		}
121 +
		return
122 +
	}
123 +
	idx := []int{}
124 +
	for i, n := range m.notes {
125 +
		if strings.Contains(strings.ToLower(n.Title), q) {
126 +
			idx = append(idx, i)
127 +
		}
128 +
	}
129 +
	m.filtered = idx
130 +
	if m.cursor >= len(idx) {
131 +
		m.cursor = 0
132 +
	}
133 +
}
134 +
135 +
func (m *Model) setStatus(text string, ok bool) tea.Cmd {
136 +
	m.status = text
137 +
	m.statusOK = ok
138 +
	m.statusUntil = time.Now().Add(2 * time.Second)
139 +
	return tea.Tick(2*time.Second, func(time.Time) tea.Msg { return clearStatusMsg{} })
140 +
}
apps/jotts-go/tui/render_md.go (added) +56 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
6 +
	"github.com/charmbracelet/glamour"
7 +
)
8 +
9 +
type mdRenderer struct {
10 +
	r     *glamour.TermRenderer
11 +
	width int
12 +
	cache map[string]string
13 +
}
14 +
15 +
func newRenderer(width int) *mdRenderer {
16 +
	if width < 20 {
17 +
		width = 80
18 +
	}
19 +
	r, _ := glamour.NewTermRenderer(
20 +
		glamour.WithAutoStyle(),
21 +
		glamour.WithWordWrap(width-2),
22 +
	)
23 +
	return &mdRenderer{r: r, width: width, cache: map[string]string{}}
24 +
}
25 +
26 +
func (m *mdRenderer) resize(width int) {
27 +
	if width == m.width || width < 20 {
28 +
		return
29 +
	}
30 +
	r, _ := glamour.NewTermRenderer(
31 +
		glamour.WithAutoStyle(),
32 +
		glamour.WithWordWrap(width-2),
33 +
	)
34 +
	m.r = r
35 +
	m.width = width
36 +
	m.cache = map[string]string{}
37 +
}
38 +
39 +
func (m *mdRenderer) render(key, body string) string {
40 +
	if m.r == nil {
41 +
		return body
42 +
	}
43 +
	if v, ok := m.cache[key]; ok {
44 +
		return v
45 +
	}
46 +
	out, err := m.r.Render(body)
47 +
	if err != nil {
48 +
		out = fmt.Sprintf("render error: %v\n\n%s", err, body)
49 +
	}
50 +
	m.cache[key] = out
51 +
	return out
52 +
}
53 +
54 +
func (m *mdRenderer) invalidate(key string) {
55 +
	delete(m.cache, key)
56 +
}
apps/jotts-go/tui/tui.go (added) +17 −0
1 +
package tui
2 +
3 +
import (
4 +
	tea "github.com/charmbracelet/bubbletea"
5 +
)
6 +
7 +
func Run(opts Options) error {
8 +
	backend, err := ResolveBackend(opts)
9 +
	if err != nil {
10 +
		return err
11 +
	}
12 +
	defer backend.Close()
13 +
14 +
	p := tea.NewProgram(newModel(backend), tea.WithAltScreen())
15 +
	_, err = p.Run()
16 +
	return err
17 +
}
apps/jotts-go/tui/update.go (added) +413 −0
1 +
package tui
2 +
3 +
import (
4 +
	"strings"
5 +
6 +
	"github.com/atotto/clipboard"
7 +
	"github.com/charmbracelet/bubbles/key"
8 +
	tea "github.com/charmbracelet/bubbletea"
9 +
)
10 +
11 +
func loadNotesCmd(b Backend) tea.Cmd {
12 +
	return func() tea.Msg {
13 +
		notes, err := b.List()
14 +
		return notesLoadedMsg{notes: notes, err: err}
15 +
	}
16 +
}
17 +
18 +
func saveNoteCmd(b Backend, shortID, title, content string) tea.Cmd {
19 +
	return func() tea.Msg {
20 +
		var (
21 +
			note *Note
22 +
			err  error
23 +
		)
24 +
		if shortID == "" {
25 +
			note, err = b.Create(title, content)
26 +
		} else {
27 +
			note, err = b.Update(shortID, title, content)
28 +
		}
29 +
		return noteSavedMsg{note: note, err: err}
30 +
	}
31 +
}
32 +
33 +
func deleteNoteCmd(b Backend, shortID string) tea.Cmd {
34 +
	return func() tea.Msg {
35 +
		_, err := b.Delete(shortID)
36 +
		return noteDeletedMsg{shortID: shortID, err: err}
37 +
	}
38 +
}
39 +
40 +
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
41 +
	switch msg := msg.(type) {
42 +
43 +
	case tea.WindowSizeMsg:
44 +
		m.width, m.height = msg.Width, msg.Height
45 +
		m.ready = true
46 +
		m.resizePanes()
47 +
		return m, nil
48 +
49 +
	case notesLoadedMsg:
50 +
		m.loading = false
51 +
		if msg.err != nil {
52 +
			cmd := m.setStatus("load: "+msg.err.Error(), false)
53 +
			return m, cmd
54 +
		}
55 +
		m.notes = msg.notes
56 +
		m.applyFilter(m.searchInput.Value())
57 +
		m.refreshPreview()
58 +
		return m, nil
59 +
60 +
	case noteSavedMsg:
61 +
		if msg.err != nil {
62 +
			return m, m.setStatus("save: "+msg.err.Error(), false)
63 +
		}
64 +
		m.focus = FocusList
65 +
		m.titleInput.Reset()
66 +
		m.contentArea.Reset()
67 +
		m.editShortID = ""
68 +
		cmds := []tea.Cmd{loadNotesCmd(m.backend), m.setStatus("saved", true)}
69 +
		return m, tea.Batch(cmds...)
70 +
71 +
	case noteDeletedMsg:
72 +
		if msg.err != nil {
73 +
			return m, m.setStatus("delete: "+msg.err.Error(), false)
74 +
		}
75 +
		if m.renderer != nil {
76 +
			m.renderer.invalidate(msg.shortID)
77 +
		}
78 +
		return m, tea.Batch(loadNotesCmd(m.backend), m.setStatus("deleted", true))
79 +
80 +
	case editorFinishedMsg:
81 +
		if msg.err != nil {
82 +
			return m, m.setStatus("editor: "+msg.err.Error(), false)
83 +
		}
84 +
		if msg.shortID == "" {
85 +
			m.contentArea.SetValue(msg.content)
86 +
			return m, nil
87 +
		}
88 +
		var orig *Note
89 +
		for i := range m.notes {
90 +
			if m.notes[i].ShortID == msg.shortID {
91 +
				orig = &m.notes[i]
92 +
				break
93 +
			}
94 +
		}
95 +
		if orig == nil || strings.TrimRight(orig.Content, "\n") == strings.TrimRight(msg.content, "\n") {
96 +
			return m, nil
97 +
		}
98 +
		return m, saveNoteCmd(m.backend, msg.shortID, orig.Title, msg.content)
99 +
100 +
	case statusMsg:
101 +
		return m, m.setStatus(msg.text, msg.ok)
102 +
103 +
	case clearStatusMsg:
104 +
		m.status = ""
105 +
		return m, nil
106 +
107 +
	case tea.KeyMsg:
108 +
		return m.handleKey(msg)
109 +
	}
110 +
111 +
	return m, nil
112 +
}
113 +
114 +
func (m *Model) resizePanes() {
115 +
	if !m.ready {
116 +
		return
117 +
	}
118 +
	listW := m.width * 30 / 100
119 +
	if listW < 24 {
120 +
		listW = 24
121 +
	}
122 +
	contentW := m.width - listW - 2
123 +
	if contentW < 20 {
124 +
		contentW = 20
125 +
	}
126 +
	bodyH := m.height - 2
127 +
	if bodyH < 5 {
128 +
		bodyH = 5
129 +
	}
130 +
131 +
	m.contentVP.Width = contentW - 2
132 +
	m.contentVP.Height = bodyH - 2
133 +
134 +
	m.titleInput.Width = contentW - 4
135 +
	m.contentArea.SetWidth(contentW - 2)
136 +
	m.contentArea.SetHeight(bodyH - 5)
137 +
138 +
	m.searchInput.Width = listW - 4
139 +
140 +
	if m.renderer == nil {
141 +
		m.renderer = newRenderer(contentW)
142 +
	} else {
143 +
		m.renderer.resize(contentW)
144 +
	}
145 +
	m.refreshPreview()
146 +
}
147 +
148 +
func (m *Model) refreshPreview() {
149 +
	if m.renderer == nil {
150 +
		return
151 +
	}
152 +
	n := m.currentNote()
153 +
	if n == nil {
154 +
		m.contentVP.SetContent("")
155 +
		return
156 +
	}
157 +
	body := n.Content
158 +
	if !m.wrap {
159 +
		// raw view: no rendering
160 +
		m.contentVP.SetContent(body)
161 +
		return
162 +
	}
163 +
	m.contentVP.SetContent(m.renderer.render(n.ShortID, body))
164 +
}
165 +
166 +
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
167 +
	if m.confirmDelete {
168 +
		switch msg.String() {
169 +
		case "y", "Y":
170 +
			n := m.currentNote()
171 +
			m.confirmDelete = false
172 +
			if n == nil {
173 +
				return m, nil
174 +
			}
175 +
			return m, deleteNoteCmd(m.backend, n.ShortID)
176 +
		case "n", "N", "esc", "q":
177 +
			m.confirmDelete = false
178 +
			return m, nil
179 +
		}
180 +
		return m, nil
181 +
	}
182 +
183 +
	if m.showHelp {
184 +
		if key.Matches(msg, m.keys.Help) || msg.String() == "esc" || msg.String() == "q" {
185 +
			m.showHelp = false
186 +
		}
187 +
		return m, nil
188 +
	}
189 +
190 +
	switch m.focus {
191 +
	case FocusList:
192 +
		return m.keyList(msg)
193 +
	case FocusContent:
194 +
		return m.keyContent(msg)
195 +
	case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent:
196 +
		return m.keyForm(msg)
197 +
	case FocusSearch:
198 +
		return m.keySearch(msg)
199 +
	}
200 +
	return m, nil
201 +
}
202 +
203 +
func (m Model) keyList(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
204 +
	notes := m.visibleNotes()
205 +
	switch {
206 +
	case key.Matches(msg, m.keys.Quit):
207 +
		return m, tea.Quit
208 +
	case key.Matches(msg, m.keys.Down):
209 +
		if m.cursor < len(notes)-1 {
210 +
			m.cursor++
211 +
			m.refreshPreview()
212 +
		}
213 +
	case key.Matches(msg, m.keys.Up):
214 +
		if m.cursor > 0 {
215 +
			m.cursor--
216 +
			m.refreshPreview()
217 +
		}
218 +
	case key.Matches(msg, m.keys.Open):
219 +
		if len(notes) > 0 {
220 +
			m.focus = FocusContent
221 +
			m.contentVP.GotoTop()
222 +
		}
223 +
	case key.Matches(msg, m.keys.Create):
224 +
		m.focus = FocusCreateTitle
225 +
		m.editShortID = ""
226 +
		m.titleInput.SetValue("")
227 +
		m.contentArea.SetValue("")
228 +
		m.titleInput.Focus()
229 +
		m.contentArea.Blur()
230 +
	case key.Matches(msg, m.keys.Edit):
231 +
		n := m.currentNote()
232 +
		if n != nil {
233 +
			m.focus = FocusEditTitle
234 +
			m.editShortID = n.ShortID
235 +
			m.titleInput.SetValue(n.Title)
236 +
			m.contentArea.SetValue(n.Content)
237 +
			m.titleInput.Focus()
238 +
			m.contentArea.Blur()
239 +
		}
240 +
	case key.Matches(msg, m.keys.ExtEdit):
241 +
		n := m.currentNote()
242 +
		if n != nil {
243 +
			return m, openExternalEditor(n.ShortID, n.Content)
244 +
		}
245 +
	case key.Matches(msg, m.keys.Delete):
246 +
		if m.currentNote() != nil {
247 +
			m.confirmDelete = true
248 +
		}
249 +
	case key.Matches(msg, m.keys.Copy):
250 +
		n := m.currentNote()
251 +
		if n != nil {
252 +
			if err := clipboard.WriteAll(n.Content); err != nil {
253 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
254 +
			}
255 +
			return m, m.setStatus("copied text", true)
256 +
		}
257 +
	case key.Matches(msg, m.keys.CopyLink):
258 +
		n := m.currentNote()
259 +
		if n != nil && m.isRemote {
260 +
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
261 +
			if err := clipboard.WriteAll(link); err != nil {
262 +
				return m, m.setStatus("clipboard: "+err.Error(), false)
263 +
			}
264 +
			return m, m.setStatus("copied link", true)
265 +
		}
266 +
		return m, m.setStatus("local mode: no link", false)
267 +
	case key.Matches(msg, m.keys.OpenBrowser):
268 +
		n := m.currentNote()
269 +
		if n != nil && m.isRemote {
270 +
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
271 +
			if err := openURL(link); err != nil {
272 +
				return m, m.setStatus("open: "+err.Error(), false)
273 +
			}
274 +
			return m, m.setStatus("opened "+link, true)
275 +
		}
276 +
	case key.Matches(msg, m.keys.Search):
277 +
		m.focus = FocusSearch
278 +
		m.searchInput.Focus()
279 +
	case key.Matches(msg, m.keys.Refresh):
280 +
		if m.isRemote {
281 +
			m.loading = true
282 +
			return m, loadNotesCmd(m.backend)
283 +
		}
284 +
	case key.Matches(msg, m.keys.Help):
285 +
		m.showHelp = true
286 +
	}
287 +
	return m, nil
288 +
}
289 +
290 +
func (m Model) keyContent(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
291 +
	switch {
292 +
	case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.Back):
293 +
		m.focus = FocusList
294 +
		return m, nil
295 +
	case key.Matches(msg, m.keys.Down):
296 +
		m.contentVP.LineDown(1)
297 +
	case key.Matches(msg, m.keys.Up):
298 +
		m.contentVP.LineUp(1)
299 +
	case key.Matches(msg, m.keys.Edit):
300 +
		n := m.currentNote()
301 +
		if n != nil {
302 +
			m.focus = FocusEditTitle
303 +
			m.editShortID = n.ShortID
304 +
			m.titleInput.SetValue(n.Title)
305 +
			m.contentArea.SetValue(n.Content)
306 +
			m.titleInput.Focus()
307 +
		}
308 +
	case key.Matches(msg, m.keys.ExtEdit):
309 +
		n := m.currentNote()
310 +
		if n != nil {
311 +
			return m, openExternalEditor(n.ShortID, n.Content)
312 +
		}
313 +
	case key.Matches(msg, m.keys.Copy):
314 +
		n := m.currentNote()
315 +
		if n != nil {
316 +
			clipboard.WriteAll(n.Content)
317 +
			return m, m.setStatus("copied text", true)
318 +
		}
319 +
	case key.Matches(msg, m.keys.CopyLink):
320 +
		n := m.currentNote()
321 +
		if n != nil && m.isRemote {
322 +
			link := strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID
323 +
			clipboard.WriteAll(link)
324 +
			return m, m.setStatus("copied link", true)
325 +
		}
326 +
	case key.Matches(msg, m.keys.OpenBrowser):
327 +
		n := m.currentNote()
328 +
		if n != nil && m.isRemote {
329 +
			openURL(strings.TrimRight(m.backend.RemoteURL(), "/") + "/notes/" + n.ShortID)
330 +
		}
331 +
	case key.Matches(msg, m.keys.Help):
332 +
		m.showHelp = true
333 +
	case key.Matches(msg, m.keys.ToggleWrap):
334 +
		m.wrap = !m.wrap
335 +
		m.refreshPreview()
336 +
	}
337 +
	var cmd tea.Cmd
338 +
	m.contentVP, cmd = m.contentVP.Update(msg)
339 +
	return m, cmd
340 +
}
341 +
342 +
func (m Model) keyForm(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
343 +
	switch {
344 +
	case key.Matches(msg, m.keys.Cancel):
345 +
		m.focus = FocusList
346 +
		m.titleInput.Blur()
347 +
		m.contentArea.Blur()
348 +
		return m, nil
349 +
	case key.Matches(msg, m.keys.Save):
350 +
		title := strings.TrimSpace(m.titleInput.Value())
351 +
		if title == "" {
352 +
			return m, m.setStatus("title required", false)
353 +
		}
354 +
		return m, saveNoteCmd(m.backend, m.editShortID, title, m.contentArea.Value())
355 +
	case key.Matches(msg, m.keys.SwitchField):
356 +
		switch m.focus {
357 +
		case FocusCreateTitle:
358 +
			m.focus = FocusCreateContent
359 +
		case FocusCreateContent:
360 +
			m.focus = FocusCreateTitle
361 +
		case FocusEditTitle:
362 +
			m.focus = FocusEditContent
363 +
		case FocusEditContent:
364 +
			m.focus = FocusEditTitle
365 +
		}
366 +
		m.applyFormFocus()
367 +
		return m, nil
368 +
	case key.Matches(msg, m.keys.ToggleWrap):
369 +
		m.wrap = !m.wrap
370 +
		return m, nil
371 +
	}
372 +
373 +
	var cmd tea.Cmd
374 +
	switch m.focus {
375 +
	case FocusCreateTitle, FocusEditTitle:
376 +
		m.titleInput, cmd = m.titleInput.Update(msg)
377 +
	case FocusCreateContent, FocusEditContent:
378 +
		m.contentArea, cmd = m.contentArea.Update(msg)
379 +
	}
380 +
	return m, cmd
381 +
}
382 +
383 +
func (m *Model) applyFormFocus() {
384 +
	switch m.focus {
385 +
	case FocusCreateTitle, FocusEditTitle:
386 +
		m.titleInput.Focus()
387 +
		m.contentArea.Blur()
388 +
	case FocusCreateContent, FocusEditContent:
389 +
		m.contentArea.Focus()
390 +
		m.titleInput.Blur()
391 +
	}
392 +
}
393 +
394 +
func (m Model) keySearch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
395 +
	switch msg.String() {
396 +
	case "esc":
397 +
		m.searchInput.SetValue("")
398 +
		m.searchInput.Blur()
399 +
		m.focus = FocusList
400 +
		m.applyFilter("")
401 +
		m.refreshPreview()
402 +
		return m, nil
403 +
	case "enter":
404 +
		m.searchInput.Blur()
405 +
		m.focus = FocusList
406 +
		return m, nil
407 +
	}
408 +
	var cmd tea.Cmd
409 +
	m.searchInput, cmd = m.searchInput.Update(msg)
410 +
	m.applyFilter(m.searchInput.Value())
411 +
	m.refreshPreview()
412 +
	return m, cmd
413 +
}
apps/jotts-go/tui/view.go (added) +183 −0
1 +
package tui
2 +
3 +
import (
4 +
	"fmt"
5 +
	"strings"
6 +
7 +
	"github.com/charmbracelet/lipgloss"
8 +
)
9 +
10 +
var (
11 +
	borderStyle = lipgloss.NewStyle().
12 +
			Border(lipgloss.RoundedBorder()).
13 +
			BorderForeground(lipgloss.Color("240"))
14 +
	borderActive = lipgloss.NewStyle().
15 +
			Border(lipgloss.RoundedBorder()).
16 +
			BorderForeground(lipgloss.Color("214"))
17 +
	titleStyle = lipgloss.NewStyle().
18 +
			Bold(true).
19 +
			Foreground(lipgloss.Color("214")).
20 +
			Padding(0, 1)
21 +
	itemStyle = lipgloss.NewStyle().Padding(0, 1)
22 +
	itemSelected = lipgloss.NewStyle().
23 +
			Padding(0, 1).
24 +
			Bold(true).
25 +
			Foreground(lipgloss.Color("214"))
26 +
	statusOK = lipgloss.NewStyle().
27 +
			Foreground(lipgloss.Color("82")).
28 +
			Bold(true)
29 +
	statusErr = lipgloss.NewStyle().
30 +
			Foreground(lipgloss.Color("196")).
31 +
			Bold(true)
32 +
	hintStyle = lipgloss.NewStyle().
33 +
			Foreground(lipgloss.Color("244"))
34 +
	modalStyle = lipgloss.NewStyle().
35 +
			Border(lipgloss.RoundedBorder()).
36 +
			BorderForeground(lipgloss.Color("214")).
37 +
			Padding(1, 2).
38 +
			Background(lipgloss.Color("236"))
39 +
)
40 +
41 +
func (m Model) View() string {
42 +
	if !m.ready {
43 +
		return "loading..."
44 +
	}
45 +
46 +
	listW := m.width * 30 / 100
47 +
	if listW < 24 {
48 +
		listW = 24
49 +
	}
50 +
	contentW := m.width - listW - 2
51 +
	bodyH := m.height - 2
52 +
53 +
	left := m.renderList(listW, bodyH)
54 +
	right := m.renderRight(contentW, bodyH)
55 +
56 +
	body := lipgloss.JoinHorizontal(lipgloss.Top, left, right)
57 +
	footer := m.renderFooter()
58 +
59 +
	view := lipgloss.JoinVertical(lipgloss.Left, body, footer)
60 +
61 +
	if m.showHelp {
62 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
63 +
			modalStyle.Render(m.help.FullHelpView(m.keys.FullHelp())),
64 +
			lipgloss.WithWhitespaceChars(" "))
65 +
	}
66 +
	if m.confirmDelete {
67 +
		n := m.currentNote()
68 +
		title := ""
69 +
		if n != nil {
70 +
			title = n.Title
71 +
		}
72 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center,
73 +
			modalStyle.Render(fmt.Sprintf("Delete %q?\n\ny / n", title)),
74 +
			lipgloss.WithWhitespaceChars(" "))
75 +
	}
76 +
	if m.status != "" {
77 +
		st := statusOK
78 +
		if !m.statusOK {
79 +
			st = statusErr
80 +
		}
81 +
		view = lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Bottom,
82 +
			modalStyle.Render(st.Render(m.status)),
83 +
			lipgloss.WithWhitespaceChars(" "))
84 +
	}
85 +
	return view
86 +
}
87 +
88 +
func (m Model) renderList(w, h int) string {
89 +
	style := borderStyle
90 +
	if m.focus == FocusList || m.focus == FocusSearch {
91 +
		style = borderActive
92 +
	}
93 +
94 +
	notes := m.visibleNotes()
95 +
	rows := make([]string, 0, len(notes)+2)
96 +
	rows = append(rows, titleStyle.Render("notes"))
97 +
	if len(notes) == 0 {
98 +
		rows = append(rows, hintStyle.Render("  (empty — press c)"))
99 +
	}
100 +
	for i, n := range notes {
101 +
		line := truncate(n.Title, w-6)
102 +
		if i == m.cursor {
103 +
			rows = append(rows, itemSelected.Render("▶ "+line))
104 +
		} else {
105 +
			rows = append(rows, itemStyle.Render("  "+line))
106 +
		}
107 +
	}
108 +
109 +
	if m.focus == FocusSearch || m.searchInput.Value() != "" {
110 +
		rows = append(rows, "", hintStyle.Render(m.searchInput.View()))
111 +
	}
112 +
113 +
	content := strings.Join(rows, "\n")
114 +
	return style.Width(w).Height(h).Render(content)
115 +
}
116 +
117 +
func (m Model) renderRight(w, h int) string {
118 +
	switch m.focus {
119 +
	case FocusCreateTitle, FocusCreateContent, FocusEditTitle, FocusEditContent:
120 +
		return m.renderForm(w, h)
121 +
	}
122 +
	return m.renderContent(w, h)
123 +
}
124 +
125 +
func (m Model) renderContent(w, h int) string {
126 +
	style := borderStyle
127 +
	if m.focus == FocusContent {
128 +
		style = borderActive
129 +
	}
130 +
	header := "preview"
131 +
	n := m.currentNote()
132 +
	if n != nil {
133 +
		header = n.Title
134 +
	}
135 +
	body := m.contentVP.View()
136 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), body)
137 +
	return style.Width(w).Height(h).Render(inner)
138 +
}
139 +
140 +
func (m Model) renderForm(w, h int) string {
141 +
	header := "new note"
142 +
	if m.editShortID != "" {
143 +
		header = "edit"
144 +
	}
145 +
	title := m.titleInput.View()
146 +
	if m.focus == FocusCreateTitle || m.focus == FocusEditTitle {
147 +
		title = borderActive.Render(title)
148 +
	} else {
149 +
		title = borderStyle.Render(title)
150 +
	}
151 +
152 +
	body := m.contentArea.View()
153 +
	if m.focus == FocusCreateContent || m.focus == FocusEditContent {
154 +
		body = borderActive.Render(body)
155 +
	} else {
156 +
		body = borderStyle.Render(body)
157 +
	}
158 +
159 +
	inner := lipgloss.JoinVertical(lipgloss.Left, titleStyle.Render(header), title, body)
160 +
	return borderStyle.Width(w).Height(h).Render(inner)
161 +
}
162 +
163 +
func (m Model) renderFooter() string {
164 +
	mode := "local"
165 +
	if m.isRemote {
166 +
		mode = "remote " + m.backend.RemoteURL()
167 +
	}
168 +
	help := m.help.ShortHelpView(m.keys.ShortHelp())
169 +
	return hintStyle.Render(fmt.Sprintf("[%s] %s", mode, help))
170 +
}
171 +
172 +
func truncate(s string, n int) string {
173 +
	if n < 1 {
174 +
		return ""
175 +
	}
176 +
	if len(s) <= n {
177 +
		return s
178 +
	}
179 +
	if n <= 1 {
180 +
		return "…"
181 +
	}
182 +
	return s[:n-1] + "…"
183 +
}