chore: refactor apps to web and db crates 1a944ed9
Steve Simkins · 2026-05-16 18:53 44 file(s) · +619 −574
apps/bookmarks-go/db.go +0 −17
7 7
	"time"
8 8
9 9
	"github.com/stevedylandev/andromeda/crates-go/auth"
10 -
	_ "modernc.org/sqlite"
11 10
)
12 11
13 12
const schema = `
31 30
32 31
CREATE INDEX IF NOT EXISTS idx_links_category ON links(category_id, created_at DESC);
33 32
`
34 -
35 -
func openDB(path string) (*sql.DB, error) {
36 -
	db, err := sql.Open("sqlite", path)
37 -
	if err != nil {
38 -
		return nil, err
39 -
	}
40 -
	db.SetMaxOpenConns(1)
41 -
	db.SetMaxIdleConns(1)
42 -
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
43 -
		return nil, err
44 -
	}
45 -
	if _, err := db.Exec(schema); err != nil {
46 -
		return nil, err
47 -
	}
48 -
	return db, nil
49 -
}
50 33
51 34
func listCategories(db *sql.DB) ([]Category, error) {
52 35
	rows, err := db.Query(`SELECT id, short_id, name, position FROM categories ORDER BY position ASC, name COLLATE NOCASE`)
apps/bookmarks-go/go.mod +3 −1
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 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
9 10
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 11
	golang.org/x/net v0.41.0
11 -
	modernc.org/sqlite v1.37.1
12 12
)
13 13
14 14
require (
23 23
	modernc.org/libc v1.65.7 // indirect
24 24
	modernc.org/mathutil v1.7.1 // indirect
25 25
	modernc.org/memory v1.11.0 // indirect
26 +
	modernc.org/sqlite v1.37.1 // indirect
26 27
)
27 28
28 29
replace (
29 30
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
30 31
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
31 32
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
33 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
32 34
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
33 35
)
apps/bookmarks-go/handlers_api.go +3 −3
40 40
			}
41 41
		}
42 42
		if found == nil {
43 -
			web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "unknown category"})
43 +
			web.WriteError(w, http.StatusNotFound, "unknown category")
44 44
			return
45 45
		}
46 46
		out := []Link{}
73 73
	title := strings.TrimSpace(body.Title)
74 74
	url := strings.TrimSpace(body.URL)
75 75
	if title == "" || url == "" {
76 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "title and url required"})
76 +
		web.WriteError(w, http.StatusBadRequest, "title and url required")
77 77
		return
78 78
	}
79 79
	cat, err := getCategoryByName(a.DB, body.Category)
83 83
		return
84 84
	}
85 85
	if cat == nil {
86 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "unknown category"})
86 +
		web.WriteError(w, http.StatusNotFound, "unknown category")
87 87
		return
88 88
	}
89 89
	link, err := createLink(a.DB, title, url, nil, cat.ID)
apps/bookmarks-go/main.go +2 −1
11 11
12 12
	"github.com/stevedylandev/andromeda/crates-go/auth"
13 13
	"github.com/stevedylandev/andromeda/crates-go/config"
14 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
14 15
)
15 16
16 17
func main() {
18 19
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
19 20
20 21
	dbPath := config.Getenv("BOOKMARKS_DB_PATH", "bookmarks.sqlite")
21 -
	db, err := openDB(dbPath)
22 +
	db, err := sqlite.Open(dbPath, schema)
22 23
	if err != nil {
23 24
		log.Fatal(err)
24 25
	}
apps/cellar-go/db.go +0 −14
5 5
	"errors"
6 6
7 7
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 -
	_ "modernc.org/sqlite"
9 8
)
10 9
11 10
const cellarSchema = `
34 33
`
35 34
36 35
const wineCols = `id, short_id, name, origin, grape, notes, (image IS NOT NULL) AS has_image, COALESCE(image_mime, ''), sweetness, acidity, tannin, alcohol, body, clarity, color_intensity, aroma_intensity, nose_complexity, background, created_at, wishlist`
37 -
38 -
func openDB(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(cellarSchema); err != nil {
46 -
		return nil, err
47 -
	}
48 -
	return db, nil
49 -
}
50 36
51 37
func scanWine(s interface{ Scan(...any) error }) (*Wine, error) {
52 38
	var w Wine
apps/cellar-go/go.mod +3 −1
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 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
9 10
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 -
	modernc.org/sqlite v1.37.1
11 11
)
12 12
13 13
require (
22 22
	modernc.org/libc v1.65.7 // indirect
23 23
	modernc.org/mathutil v1.7.1 // indirect
24 24
	modernc.org/memory v1.11.0 // indirect
25 +
	modernc.org/sqlite v1.37.1 // indirect
25 26
)
26 27
27 28
replace (
28 29
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
29 30
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
30 31
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
32 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
31 33
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
32 34
)
apps/cellar-go/handlers_admin.go +5 −5
207 207
208 208
func (a *App) analyzeImage(w http.ResponseWriter, r *http.Request) {
209 209
	if a.AnthropicAPIKey == "" {
210 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "No API key configured"})
210 +
		web.WriteError(w, http.StatusBadRequest, "No API key configured")
211 211
		return
212 212
	}
213 213
	r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
214 214
	if err := r.ParseMultipartForm(maxUploadBytes); err != nil {
215 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
215 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
216 216
		return
217 217
	}
218 218
	file, header, err := r.FormFile("image")
219 219
	if err != nil {
220 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "No image provided"})
220 +
		web.WriteError(w, http.StatusBadRequest, "No image provided")
221 221
		return
222 222
	}
223 223
	defer file.Close()
224 224
	raw, err := io.ReadAll(file)
225 225
	if err != nil || len(raw) == 0 {
226 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "No image provided"})
226 +
		web.WriteError(w, http.StatusBadRequest, "No image provided")
227 227
		return
228 228
	}
229 229
	mediaType := "image/jpeg"
233 233
	result, err := analyzeWineImage(r.Context(), a.AnthropicAPIKey, raw, mediaType)
234 234
	if err != nil {
235 235
		a.Log.Error("Claude analysis failed", "err", err)
236 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
236 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
237 237
		return
238 238
	}
239 239
	web.WriteJSON(w, http.StatusOK, result)
apps/cellar-go/main.go +2 −1
10 10
11 11
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 12
	"github.com/stevedylandev/andromeda/crates-go/config"
13 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
13 14
)
14 15
15 16
func main() {
17 18
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
18 19
19 20
	dbPath := config.Getenv("CELLAR_DB_PATH", "cellar.sqlite")
20 -
	db, err := openDB(dbPath)
21 +
	db, err := sqlite.Open(dbPath, cellarSchema)
21 22
	if err != nil {
22 23
		log.Fatal(err)
23 24
	}
apps/easel-go/db.go +0 −15
3 3
import (
4 4
	"database/sql"
5 5
	"errors"
6 -
7 -
	_ "modernc.org/sqlite"
8 6
)
9 7
10 8
const easelSchema = `
45 43
}
46 44
47 45
const dailyCols = `date, artwork_id, title, artist_display, artist_title, date_display, medium_display, dimensions, place_of_origin, credit_line, description, short_description, image_id, fetched_at`
48 -
49 -
func openDB(path string) (*sql.DB, error) {
50 -
	db, err := sql.Open("sqlite", path)
51 -
	if err != nil {
52 -
		return nil, err
53 -
	}
54 -
	db.SetMaxOpenConns(1)
55 -
	db.SetMaxIdleConns(1)
56 -
	if _, err := db.Exec(easelSchema); err != nil {
57 -
		return nil, err
58 -
	}
59 -
	return db, nil
60 -
}
61 46
62 47
func scanDaily(s interface{ Scan(...any) error }) (*DailyArtwork, error) {
63 48
	var d DailyArtwork
apps/easel-go/go.mod +3 −1
5 5
require (
6 6
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
7 7
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
8 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
8 9
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
9 -
	modernc.org/sqlite v1.37.1
10 10
)
11 11
12 12
require (
20 20
	modernc.org/libc v1.65.7 // indirect
21 21
	modernc.org/mathutil v1.7.1 // indirect
22 22
	modernc.org/memory v1.11.0 // indirect
23 +
	modernc.org/sqlite v1.37.1 // indirect
23 24
)
24 25
25 26
replace (
26 27
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
27 28
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
29 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
28 30
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
29 31
)
apps/easel-go/handlers_api.go +4 −4
70 70
		return
71 71
	}
72 72
	if d == nil {
73 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "today not yet populated"})
73 +
		web.WriteError(w, http.StatusNotFound, "today not yet populated")
74 74
		return
75 75
	}
76 76
	web.WriteJSON(w, http.StatusOK, toAPI(*d))
79 79
func (a *App) apiDay(w http.ResponseWriter, r *http.Request) {
80 80
	date := r.PathValue("date")
81 81
	if _, ok := parseDate(date); !ok {
82 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid date format"})
82 +
		web.WriteError(w, http.StatusBadRequest, "invalid date format")
83 83
		return
84 84
	}
85 85
	if date > a.todayInTZ() {
86 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "future date"})
86 +
		web.WriteError(w, http.StatusNotFound, "future date")
87 87
		return
88 88
	}
89 89
	d, err := getDaily(a.DB, date)
93 93
		return
94 94
	}
95 95
	if d == nil {
96 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "no record for date"})
96 +
		web.WriteError(w, http.StatusNotFound, "no record for date")
97 97
		return
98 98
	}
99 99
	web.WriteJSON(w, http.StatusOK, toAPI(*d))
apps/easel-go/main.go +2 −1
11 11
	"time"
12 12
13 13
	"github.com/stevedylandev/andromeda/crates-go/config"
14 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
14 15
)
15 16
16 17
func splitCommaTrim(s string) []string {
28 29
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
29 30
30 31
	dbPath := config.Getenv("EASEL_DB_PATH", "easel.sqlite")
31 -
	db, err := openDB(dbPath)
32 +
	db, err := sqlite.Open(dbPath, easelSchema)
32 33
	if err != nil {
33 34
		log.Fatal(err)
34 35
	}
apps/feeds-go/db.go +0 −318
5 5
	"errors"
6 6
	"fmt"
7 7
	"strings"
8 -
	"time"
9 -
10 -
	_ "modernc.org/sqlite"
11 8
)
12 -
13 -
const subscriptionSelectColumns = `id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at`
14 9
15 10
const feedsSchema = `
16 11
CREATE TABLE IF NOT EXISTS categories (
56 51
);
57 52
`
58 53
59 -
type Category struct {
60 -
	ID        int64
61 -
	Name      string
62 -
	CreatedAt string
63 -
}
64 -
65 -
type Subscription struct {
66 -
	ID            int64
67 -
	FeedURL       string
68 -
	Title         string
69 -
	SiteURL       sql.NullString
70 -
	FaviconURL    sql.NullString
71 -
	CategoryID    sql.NullInt64
72 -
	ETag          sql.NullString
73 -
	LastModified  sql.NullString
74 -
	LastFetchedAt sql.NullString
75 -
	LastError     sql.NullString
76 -
	AddedAt       string
77 -
}
78 -
79 -
type ItemWithFeed struct {
80 -
	ID             int64   `json:"id"`
81 -
	SubscriptionID int64   `json:"subscription_id"`
82 -
	GUID           string  `json:"guid"`
83 -
	Title          string  `json:"title"`
84 -
	Link           string  `json:"link"`
85 -
	Author         *string `json:"author,omitempty"`
86 -
	PublishedAt    int64   `json:"published_at"`
87 -
	IsRead         bool    `json:"is_read"`
88 -
	FetchedAt      string  `json:"fetched_at"`
89 -
	FeedTitle      string  `json:"feed_title"`
90 -
	FeedURL        string  `json:"feed_url"`
91 -
	CategoryID     *int64  `json:"category_id,omitempty"`
92 -
	CategoryName   *string `json:"category_name,omitempty"`
93 -
}
94 -
95 -
type ListItemsFilter struct {
96 -
	Limit          int
97 -
	UnreadOnly     bool
98 -
	CategoryID     *int64
99 -
	SubscriptionID *int64
100 -
}
101 -
102 -
type NewItem struct {
103 -
	SubscriptionID int64
104 -
	GUID           string
105 -
	Title          string
106 -
	Link           string
107 -
	Author         string
108 -
	PublishedAt    int64
109 -
}
110 -
111 -
func openDB(path string) (*sql.DB, error) {
112 -
	db, err := sql.Open("sqlite", path)
113 -
	if err != nil {
114 -
		return nil, err
115 -
	}
116 -
	db.SetMaxOpenConns(1)
117 -
	db.SetMaxIdleConns(1)
118 -
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
119 -
		return nil, err
120 -
	}
121 -
	if _, err := db.Exec(feedsSchema); err != nil {
122 -
		return nil, err
123 -
	}
124 -
	return db, nil
125 -
}
126 -
127 54
func seedSettings(db *sql.DB, defaultPoll int) error {
128 55
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('poll_interval_minutes', ?)
129 56
		ON CONFLICT(key) DO NOTHING`, fmt.Sprintf("%d", defaultPoll))
130 57
	return err
131 58
}
132 59
133 -
func listItems(db *sql.DB, filter ListItemsFilter) ([]ItemWithFeed, error) {
134 -
	limit := filter.Limit
135 -
	if limit <= 0 {
136 -
		limit = 100
137 -
	}
138 -
	if limit > 1000 {
139 -
		limit = 1000
140 -
	}
141 -
	var b strings.Builder
142 -
	b.WriteString(`SELECT i.id, i.subscription_id, i.guid, i.title, i.link, i.author, i.published_at,
143 -
		i.is_read, i.fetched_at, s.title, s.feed_url, s.category_id, c.name
144 -
		FROM items i
145 -
		JOIN subscriptions s ON s.id = i.subscription_id
146 -
		LEFT JOIN categories c ON c.id = s.category_id
147 -
		WHERE 1=1`)
148 -
	args := []any{}
149 -
	if filter.UnreadOnly {
150 -
		b.WriteString(" AND i.is_read = 0")
151 -
	}
152 -
	if filter.CategoryID != nil {
153 -
		b.WriteString(" AND s.category_id = ?")
154 -
		args = append(args, *filter.CategoryID)
155 -
	}
156 -
	if filter.SubscriptionID != nil {
157 -
		b.WriteString(" AND i.subscription_id = ?")
158 -
		args = append(args, *filter.SubscriptionID)
159 -
	}
160 -
	b.WriteString(" ORDER BY i.published_at DESC, i.id DESC LIMIT ?")
161 -
	args = append(args, limit)
162 -
	rows, err := db.Query(b.String(), args...)
163 -
	if err != nil {
164 -
		return nil, err
165 -
	}
166 -
	defer rows.Close()
167 -
	var items []ItemWithFeed
168 -
	for rows.Next() {
169 -
		var it ItemWithFeed
170 -
		var author sql.NullString
171 -
		var categoryID sql.NullInt64
172 -
		var categoryName sql.NullString
173 -
		var isRead int
174 -
		if err := rows.Scan(&it.ID, &it.SubscriptionID, &it.GUID, &it.Title, &it.Link, &author, &it.PublishedAt, &isRead, &it.FetchedAt, &it.FeedTitle, &it.FeedURL, &categoryID, &categoryName); err != nil {
175 -
			return nil, err
176 -
		}
177 -
		if author.Valid {
178 -
			it.Author = &author.String
179 -
		}
180 -
		if categoryID.Valid {
181 -
			v := categoryID.Int64
182 -
			it.CategoryID = &v
183 -
		}
184 -
		if categoryName.Valid {
185 -
			v := categoryName.String
186 -
			it.CategoryName = &v
187 -
		}
188 -
		it.IsRead = isRead != 0
189 -
		items = append(items, it)
190 -
	}
191 -
	return items, rows.Err()
192 -
}
193 -
194 -
func listSubscriptions(db *sql.DB) ([]Subscription, error) {
195 -
	rows, err := db.Query(`SELECT ` + subscriptionSelectColumns + `
196 -
		FROM subscriptions ORDER BY title COLLATE NOCASE ASC`)
197 -
	if err != nil {
198 -
		return nil, err
199 -
	}
200 -
	defer rows.Close()
201 -
	var subs []Subscription
202 -
	for rows.Next() {
203 -
		s, err := scanSubscription(rows)
204 -
		if err != nil {
205 -
			return nil, err
206 -
		}
207 -
		subs = append(subs, *s)
208 -
	}
209 -
	return subs, rows.Err()
210 -
}
211 -
212 -
func getSubscriptionByURL(db *sql.DB, feedURL string) (*Subscription, error) {
213 -
	return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE feed_url = ?`, feedURL)
214 -
}
215 -
216 -
func insertSubscription(db *sql.DB, feedURL, title string, siteURL *string, categoryID *int64) (*Subscription, error) {
217 -
	res, err := db.Exec(`INSERT INTO subscriptions (feed_url, title, site_url, category_id) VALUES (?, ?, ?, ?)`, feedURL, title, siteURL, categoryID)
218 -
	if err != nil {
219 -
		return nil, err
220 -
	}
221 -
	id, err := res.LastInsertId()
222 -
	if err != nil {
223 -
		return nil, err
224 -
	}
225 -
	return getSubscription(db, id)
226 -
}
227 -
228 -
func getSubscription(db *sql.DB, id int64) (*Subscription, error) {
229 -
	return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE id = ?`, id)
230 -
}
231 -
232 -
func updateSubscriptionMeta(db *sql.DB, id int64, etag, lastModified *string, lastError *string) error {
233 -
	_, err := db.Exec(`UPDATE subscriptions SET etag = ?, last_modified = ?, last_fetched_at = ?, last_error = ? WHERE id = ?`,
234 -
		nullableString(etag), nullableString(lastModified), time.Now().UTC().Format("2006-01-02 15:04:05"), nullableString(lastError), id)
235 -
	return err
236 -
}
237 -
238 -
func updateSubscriptionTitle(db *sql.DB, id int64, title string) error {
239 -
	_, err := db.Exec(`UPDATE subscriptions SET title = ? WHERE id = ?`, title, id)
240 -
	return err
241 -
}
242 -
243 -
func updateSubscriptionSiteURL(db *sql.DB, id int64, siteURL *string) error {
244 -
	_, err := db.Exec(`UPDATE subscriptions SET site_url = ? WHERE id = ?`, nullableString(siteURL), id)
245 -
	return err
246 -
}
247 -
248 -
func updateSubscriptionFavicon(db *sql.DB, id int64, favicon *string) error {
249 -
	_, err := db.Exec(`UPDATE subscriptions SET favicon_url = ? WHERE id = ?`, nullableString(favicon), id)
250 -
	return err
251 -
}
252 -
253 -
func updateSubscriptionCategory(db *sql.DB, id int64, categoryID *int64) error {
254 -
	_, err := db.Exec(`UPDATE subscriptions SET category_id = ? WHERE id = ?`, nullableInt64(categoryID), id)
255 -
	return err
256 -
}
257 -
258 -
func deleteSubscription(db *sql.DB, id int64) (bool, error) {
259 -
	res, err := db.Exec(`DELETE FROM subscriptions WHERE id = ?`, id)
260 -
	if err != nil {
261 -
		return false, err
262 -
	}
263 -
	n, _ := res.RowsAffected()
264 -
	return n > 0, nil
265 -
}
266 -
267 -
func insertItemIgnoreDup(db *sql.DB, item NewItem) (bool, error) {
268 -
	res, err := db.Exec(`INSERT OR IGNORE INTO items (subscription_id, guid, title, link, author, published_at) VALUES (?, ?, ?, ?, ?, ?)`,
269 -
		item.SubscriptionID, item.GUID, item.Title, item.Link, nullableString(stringPtr(strings.TrimSpace(item.Author))), item.PublishedAt)
270 -
	if err != nil {
271 -
		return false, err
272 -
	}
273 -
	n, _ := res.RowsAffected()
274 -
	return n > 0, nil
275 -
}
276 -
277 -
func pruneSubscription(db *sql.DB, subscriptionID int64, keepN int) error {
278 -
	_, err := db.Exec(`DELETE FROM items
279 -
		WHERE subscription_id = ?
280 -
		  AND id NOT IN (
281 -
			SELECT id FROM items WHERE subscription_id = ? ORDER BY published_at DESC, id DESC LIMIT ?
282 -
		)`, subscriptionID, subscriptionID, keepN)
283 -
	return err
284 -
}
285 -
286 -
func markItemRead(db *sql.DB, id int64, isRead bool) (bool, error) {
287 -
	val := 0
288 -
	if isRead {
289 -
		val = 1
290 -
	}
291 -
	res, err := db.Exec(`UPDATE items SET is_read = ? WHERE id = ?`, val, id)
292 -
	if err != nil {
293 -
		return false, err
294 -
	}
295 -
	n, _ := res.RowsAffected()
296 -
	return n > 0, nil
297 -
}
298 -
299 -
func listCategories(db *sql.DB) ([]Category, error) {
300 -
	rows, err := db.Query(`SELECT id, name, created_at FROM categories ORDER BY name ASC`)
301 -
	if err != nil {
302 -
		return nil, err
303 -
	}
304 -
	defer rows.Close()
305 -
	var out []Category
306 -
	for rows.Next() {
307 -
		var c Category
308 -
		if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt); err != nil {
309 -
			return nil, err
310 -
		}
311 -
		out = append(out, c)
312 -
	}
313 -
	return out, rows.Err()
314 -
}
315 -
316 -
func getOrCreateCategory(db *sql.DB, name string) (*Category, error) {
317 -
	name = strings.TrimSpace(name)
318 -
	if name == "" {
319 -
		return nil, nil
320 -
	}
321 -
	var c Category
322 -
	err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&c.ID, &c.Name, &c.CreatedAt)
323 -
	if err == nil {
324 -
		return &c, nil
325 -
	}
326 -
	if !errors.Is(err, sql.ErrNoRows) {
327 -
		return nil, err
328 -
	}
329 -
	res, err := db.Exec(`INSERT INTO categories (name) VALUES (?)`, name)
330 -
	if err != nil {
331 -
		var existing Category
332 -
		if err2 := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&existing.ID, &existing.Name, &existing.CreatedAt); err2 == nil {
333 -
			return &existing, nil
334 -
		}
335 -
		return nil, err
336 -
	}
337 -
	id, _ := res.LastInsertId()
338 -
	return getCategory(db, id)
339 -
}
340 -
341 -
func getCategory(db *sql.DB, id int64) (*Category, error) {
342 -
	var c Category
343 -
	err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE id = ?`, id).Scan(&c.ID, &c.Name, &c.CreatedAt)
344 -
	if errors.Is(err, sql.ErrNoRows) {
345 -
		return nil, nil
346 -
	}
347 -
	if err != nil {
348 -
		return nil, err
349 -
	}
350 -
	return &c, nil
351 -
}
352 -
353 -
func deleteCategory(db *sql.DB, id int64) (bool, error) {
354 -
	res, err := db.Exec(`DELETE FROM categories WHERE id = ?`, id)
355 -
	if err != nil {
356 -
		return false, err
357 -
	}
358 -
	n, _ := res.RowsAffected()
359 -
	return n > 0, nil
360 -
}
361 -
362 60
func getSetting(db *sql.DB, key string) (string, bool, error) {
363 61
	var value string
364 62
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value)
374 72
func setSetting(db *sql.DB, key, value string) error {
375 73
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
376 74
	return err
377 -
}
378 -
379 -
func querySubscription(db *sql.DB, query string, args ...any) (*Subscription, error) {
380 -
	return scanSubscription(db.QueryRow(query, args...))
381 -
}
382 -
383 -
func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*Subscription, error) {
384 -
	var s Subscription
385 -
	err := scanner.Scan(&s.ID, &s.FeedURL, &s.Title, &s.SiteURL, &s.FaviconURL, &s.CategoryID, &s.ETag, &s.LastModified, &s.LastFetchedAt, &s.LastError, &s.AddedAt)
386 -
	if errors.Is(err, sql.ErrNoRows) {
387 -
		return nil, nil
388 -
	}
389 -
	if err != nil {
390 -
		return nil, err
391 -
	}
392 -
	return &s, nil
393 75
}
394 76
395 77
func nullableString(s *string) any {
apps/feeds-go/db_categories.go (added) +76 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"strings"
7 +
)
8 +
9 +
type Category struct {
10 +
	ID        int64
11 +
	Name      string
12 +
	CreatedAt string
13 +
}
14 +
15 +
func listCategories(db *sql.DB) ([]Category, error) {
16 +
	rows, err := db.Query(`SELECT id, name, created_at FROM categories ORDER BY name ASC`)
17 +
	if err != nil {
18 +
		return nil, err
19 +
	}
20 +
	defer rows.Close()
21 +
	var out []Category
22 +
	for rows.Next() {
23 +
		var c Category
24 +
		if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt); err != nil {
25 +
			return nil, err
26 +
		}
27 +
		out = append(out, c)
28 +
	}
29 +
	return out, rows.Err()
30 +
}
31 +
32 +
func getOrCreateCategory(db *sql.DB, name string) (*Category, error) {
33 +
	name = strings.TrimSpace(name)
34 +
	if name == "" {
35 +
		return nil, nil
36 +
	}
37 +
	var c Category
38 +
	err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&c.ID, &c.Name, &c.CreatedAt)
39 +
	if err == nil {
40 +
		return &c, nil
41 +
	}
42 +
	if !errors.Is(err, sql.ErrNoRows) {
43 +
		return nil, err
44 +
	}
45 +
	res, err := db.Exec(`INSERT INTO categories (name) VALUES (?)`, name)
46 +
	if err != nil {
47 +
		var existing Category
48 +
		if err2 := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE name = ?`, name).Scan(&existing.ID, &existing.Name, &existing.CreatedAt); err2 == nil {
49 +
			return &existing, nil
50 +
		}
51 +
		return nil, err
52 +
	}
53 +
	id, _ := res.LastInsertId()
54 +
	return getCategory(db, id)
55 +
}
56 +
57 +
func getCategory(db *sql.DB, id int64) (*Category, error) {
58 +
	var c Category
59 +
	err := db.QueryRow(`SELECT id, name, created_at FROM categories WHERE id = ?`, id).Scan(&c.ID, &c.Name, &c.CreatedAt)
60 +
	if errors.Is(err, sql.ErrNoRows) {
61 +
		return nil, nil
62 +
	}
63 +
	if err != nil {
64 +
		return nil, err
65 +
	}
66 +
	return &c, nil
67 +
}
68 +
69 +
func deleteCategory(db *sql.DB, id int64) (bool, error) {
70 +
	res, err := db.Exec(`DELETE FROM categories WHERE id = ?`, id)
71 +
	if err != nil {
72 +
		return false, err
73 +
	}
74 +
	n, _ := res.RowsAffected()
75 +
	return n > 0, nil
76 +
}
apps/feeds-go/db_items.go (added) +131 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"strings"
6 +
)
7 +
8 +
type ItemWithFeed struct {
9 +
	ID             int64   `json:"id"`
10 +
	SubscriptionID int64   `json:"subscription_id"`
11 +
	GUID           string  `json:"guid"`
12 +
	Title          string  `json:"title"`
13 +
	Link           string  `json:"link"`
14 +
	Author         *string `json:"author,omitempty"`
15 +
	PublishedAt    int64   `json:"published_at"`
16 +
	IsRead         bool    `json:"is_read"`
17 +
	FetchedAt      string  `json:"fetched_at"`
18 +
	FeedTitle      string  `json:"feed_title"`
19 +
	FeedURL        string  `json:"feed_url"`
20 +
	CategoryID     *int64  `json:"category_id,omitempty"`
21 +
	CategoryName   *string `json:"category_name,omitempty"`
22 +
}
23 +
24 +
type ListItemsFilter struct {
25 +
	Limit          int
26 +
	UnreadOnly     bool
27 +
	CategoryID     *int64
28 +
	SubscriptionID *int64
29 +
}
30 +
31 +
type NewItem struct {
32 +
	SubscriptionID int64
33 +
	GUID           string
34 +
	Title          string
35 +
	Link           string
36 +
	Author         string
37 +
	PublishedAt    int64
38 +
}
39 +
40 +
func listItems(db *sql.DB, filter ListItemsFilter) ([]ItemWithFeed, error) {
41 +
	limit := filter.Limit
42 +
	if limit <= 0 {
43 +
		limit = 100
44 +
	}
45 +
	if limit > 1000 {
46 +
		limit = 1000
47 +
	}
48 +
	var b strings.Builder
49 +
	b.WriteString(`SELECT i.id, i.subscription_id, i.guid, i.title, i.link, i.author, i.published_at,
50 +
		i.is_read, i.fetched_at, s.title, s.feed_url, s.category_id, c.name
51 +
		FROM items i
52 +
		JOIN subscriptions s ON s.id = i.subscription_id
53 +
		LEFT JOIN categories c ON c.id = s.category_id
54 +
		WHERE 1=1`)
55 +
	args := []any{}
56 +
	if filter.UnreadOnly {
57 +
		b.WriteString(" AND i.is_read = 0")
58 +
	}
59 +
	if filter.CategoryID != nil {
60 +
		b.WriteString(" AND s.category_id = ?")
61 +
		args = append(args, *filter.CategoryID)
62 +
	}
63 +
	if filter.SubscriptionID != nil {
64 +
		b.WriteString(" AND i.subscription_id = ?")
65 +
		args = append(args, *filter.SubscriptionID)
66 +
	}
67 +
	b.WriteString(" ORDER BY i.published_at DESC, i.id DESC LIMIT ?")
68 +
	args = append(args, limit)
69 +
	rows, err := db.Query(b.String(), args...)
70 +
	if err != nil {
71 +
		return nil, err
72 +
	}
73 +
	defer rows.Close()
74 +
	var items []ItemWithFeed
75 +
	for rows.Next() {
76 +
		var it ItemWithFeed
77 +
		var author sql.NullString
78 +
		var categoryID sql.NullInt64
79 +
		var categoryName sql.NullString
80 +
		var isRead int
81 +
		if err := rows.Scan(&it.ID, &it.SubscriptionID, &it.GUID, &it.Title, &it.Link, &author, &it.PublishedAt, &isRead, &it.FetchedAt, &it.FeedTitle, &it.FeedURL, &categoryID, &categoryName); err != nil {
82 +
			return nil, err
83 +
		}
84 +
		if author.Valid {
85 +
			it.Author = &author.String
86 +
		}
87 +
		if categoryID.Valid {
88 +
			v := categoryID.Int64
89 +
			it.CategoryID = &v
90 +
		}
91 +
		if categoryName.Valid {
92 +
			v := categoryName.String
93 +
			it.CategoryName = &v
94 +
		}
95 +
		it.IsRead = isRead != 0
96 +
		items = append(items, it)
97 +
	}
98 +
	return items, rows.Err()
99 +
}
100 +
101 +
func insertItemIgnoreDup(db *sql.DB, item NewItem) (bool, error) {
102 +
	res, err := db.Exec(`INSERT OR IGNORE INTO items (subscription_id, guid, title, link, author, published_at) VALUES (?, ?, ?, ?, ?, ?)`,
103 +
		item.SubscriptionID, item.GUID, item.Title, item.Link, nullableString(stringPtr(strings.TrimSpace(item.Author))), item.PublishedAt)
104 +
	if err != nil {
105 +
		return false, err
106 +
	}
107 +
	n, _ := res.RowsAffected()
108 +
	return n > 0, nil
109 +
}
110 +
111 +
func pruneSubscription(db *sql.DB, subscriptionID int64, keepN int) error {
112 +
	_, err := db.Exec(`DELETE FROM items
113 +
		WHERE subscription_id = ?
114 +
		  AND id NOT IN (
115 +
			SELECT id FROM items WHERE subscription_id = ? ORDER BY published_at DESC, id DESC LIMIT ?
116 +
		)`, subscriptionID, subscriptionID, keepN)
117 +
	return err
118 +
}
119 +
120 +
func markItemRead(db *sql.DB, id int64, isRead bool) (bool, error) {
121 +
	val := 0
122 +
	if isRead {
123 +
		val = 1
124 +
	}
125 +
	res, err := db.Exec(`UPDATE items SET is_read = ? WHERE id = ?`, val, id)
126 +
	if err != nil {
127 +
		return false, err
128 +
	}
129 +
	n, _ := res.RowsAffected()
130 +
	return n > 0, nil
131 +
}
apps/feeds-go/db_subscriptions.go (added) +112 −0
1 +
package main
2 +
3 +
import (
4 +
	"database/sql"
5 +
	"errors"
6 +
	"time"
7 +
)
8 +
9 +
const subscriptionSelectColumns = `id, feed_url, title, site_url, favicon_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at`
10 +
11 +
type Subscription struct {
12 +
	ID            int64
13 +
	FeedURL       string
14 +
	Title         string
15 +
	SiteURL       sql.NullString
16 +
	FaviconURL    sql.NullString
17 +
	CategoryID    sql.NullInt64
18 +
	ETag          sql.NullString
19 +
	LastModified  sql.NullString
20 +
	LastFetchedAt sql.NullString
21 +
	LastError     sql.NullString
22 +
	AddedAt       string
23 +
}
24 +
25 +
func listSubscriptions(db *sql.DB) ([]Subscription, error) {
26 +
	rows, err := db.Query(`SELECT ` + subscriptionSelectColumns + `
27 +
		FROM subscriptions ORDER BY title COLLATE NOCASE ASC`)
28 +
	if err != nil {
29 +
		return nil, err
30 +
	}
31 +
	defer rows.Close()
32 +
	var subs []Subscription
33 +
	for rows.Next() {
34 +
		s, err := scanSubscription(rows)
35 +
		if err != nil {
36 +
			return nil, err
37 +
		}
38 +
		subs = append(subs, *s)
39 +
	}
40 +
	return subs, rows.Err()
41 +
}
42 +
43 +
func getSubscriptionByURL(db *sql.DB, feedURL string) (*Subscription, error) {
44 +
	return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE feed_url = ?`, feedURL)
45 +
}
46 +
47 +
func insertSubscription(db *sql.DB, feedURL, title string, siteURL *string, categoryID *int64) (*Subscription, error) {
48 +
	res, err := db.Exec(`INSERT INTO subscriptions (feed_url, title, site_url, category_id) VALUES (?, ?, ?, ?)`, feedURL, title, siteURL, categoryID)
49 +
	if err != nil {
50 +
		return nil, err
51 +
	}
52 +
	id, err := res.LastInsertId()
53 +
	if err != nil {
54 +
		return nil, err
55 +
	}
56 +
	return getSubscription(db, id)
57 +
}
58 +
59 +
func getSubscription(db *sql.DB, id int64) (*Subscription, error) {
60 +
	return querySubscription(db, `SELECT `+subscriptionSelectColumns+` FROM subscriptions WHERE id = ?`, id)
61 +
}
62 +
63 +
func updateSubscriptionMeta(db *sql.DB, id int64, etag, lastModified *string, lastError *string) error {
64 +
	_, err := db.Exec(`UPDATE subscriptions SET etag = ?, last_modified = ?, last_fetched_at = ?, last_error = ? WHERE id = ?`,
65 +
		nullableString(etag), nullableString(lastModified), time.Now().UTC().Format("2006-01-02 15:04:05"), nullableString(lastError), id)
66 +
	return err
67 +
}
68 +
69 +
func updateSubscriptionTitle(db *sql.DB, id int64, title string) error {
70 +
	_, err := db.Exec(`UPDATE subscriptions SET title = ? WHERE id = ?`, title, id)
71 +
	return err
72 +
}
73 +
74 +
func updateSubscriptionSiteURL(db *sql.DB, id int64, siteURL *string) error {
75 +
	_, err := db.Exec(`UPDATE subscriptions SET site_url = ? WHERE id = ?`, nullableString(siteURL), id)
76 +
	return err
77 +
}
78 +
79 +
func updateSubscriptionFavicon(db *sql.DB, id int64, favicon *string) error {
80 +
	_, err := db.Exec(`UPDATE subscriptions SET favicon_url = ? WHERE id = ?`, nullableString(favicon), id)
81 +
	return err
82 +
}
83 +
84 +
func updateSubscriptionCategory(db *sql.DB, id int64, categoryID *int64) error {
85 +
	_, err := db.Exec(`UPDATE subscriptions SET category_id = ? WHERE id = ?`, nullableInt64(categoryID), id)
86 +
	return err
87 +
}
88 +
89 +
func deleteSubscription(db *sql.DB, id int64) (bool, error) {
90 +
	res, err := db.Exec(`DELETE FROM subscriptions WHERE id = ?`, id)
91 +
	if err != nil {
92 +
		return false, err
93 +
	}
94 +
	n, _ := res.RowsAffected()
95 +
	return n > 0, nil
96 +
}
97 +
98 +
func querySubscription(db *sql.DB, query string, args ...any) (*Subscription, error) {
99 +
	return scanSubscription(db.QueryRow(query, args...))
100 +
}
101 +
102 +
func scanSubscription(scanner interface{ Scan(dest ...any) error }) (*Subscription, error) {
103 +
	var s Subscription
104 +
	err := scanner.Scan(&s.ID, &s.FeedURL, &s.Title, &s.SiteURL, &s.FaviconURL, &s.CategoryID, &s.ETag, &s.LastModified, &s.LastFetchedAt, &s.LastError, &s.AddedAt)
105 +
	if errors.Is(err, sql.ErrNoRows) {
106 +
		return nil, nil
107 +
	}
108 +
	if err != nil {
109 +
		return nil, err
110 +
	}
111 +
	return &s, nil
112 +
}
apps/feeds-go/feeds.go +0 −45
2 2
3 3
import (
4 4
	"context"
5 -
	"encoding/xml"
6 5
	"errors"
7 6
	"fmt"
8 7
	"io"
41 40
	Link      string
42 41
	Author    string
43 42
	Published int64
44 -
}
45 -
46 -
type OPMLEntry struct {
47 -
	XMLURL   string
48 -
	Title    string
49 -
	HTMLURL  string
50 -
	Category string
51 43
}
52 44
53 45
const appUserAgent = "andromeda-feeds-go/0.1 (+https://github.com/stevedylandev/andromeda)"
305 297
		return nil, errors.New("no feeds found at this URL")
306 298
	}
307 299
	return feeds, nil
308 -
}
309 -
310 -
func parseOPML(content string) []OPMLEntry {
311 -
	dec := xml.NewDecoder(strings.NewReader(content))
312 -
	type outline struct {
313 -
		Title   string    `xml:"title,attr"`
314 -
		Text    string    `xml:"text,attr"`
315 -
		XMLURL  string    `xml:"xmlUrl,attr"`
316 -
		HTMLURL string    `xml:"htmlUrl,attr"`
317 -
		Nodes   []outline `xml:"outline"`
318 -
	}
319 -
	type opml struct {
320 -
		Body struct {
321 -
			Nodes []outline `xml:"outline"`
322 -
		} `xml:"body"`
323 -
	}
324 -
	var doc opml
325 -
	if err := dec.Decode(&doc); err != nil {
326 -
		return nil
327 -
	}
328 -
	var out []OPMLEntry
329 -
	var walk func(nodes []outline, category string)
330 -
	walk = func(nodes []outline, category string) {
331 -
		for _, node := range nodes {
332 -
			title := firstNonEmpty(node.Title, node.Text)
333 -
			if strings.TrimSpace(node.XMLURL) != "" {
334 -
				out = append(out, OPMLEntry{XMLURL: strings.TrimSpace(node.XMLURL), Title: title, HTMLURL: strings.TrimSpace(node.HTMLURL), Category: strings.TrimSpace(category)})
335 -
				if len(node.Nodes) > 0 {
336 -
					walk(node.Nodes, title)
337 -
				}
338 -
				continue
339 -
			}
340 -
			walk(node.Nodes, title)
341 -
		}
342 -
	}
343 -
	walk(doc.Body.Nodes, "")
344 -
	return out
345 300
}
346 301
347 302
func findAlternateFeedLinks(doc string) []string {
apps/feeds-go/go.mod +3 −1
7 7
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
8 8
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
9 9
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
10 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
10 11
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
11 12
	golang.org/x/net v0.41.0
12 -
	modernc.org/sqlite v1.37.1
13 13
)
14 14
15 15
replace (
16 16
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
17 17
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
18 18
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
19 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
19 20
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
20 21
)
21 22
38 39
	modernc.org/libc v1.65.7 // indirect
39 40
	modernc.org/mathutil v1.7.1 // indirect
40 41
	modernc.org/memory v1.11.0 // indirect
42 +
	modernc.org/sqlite v1.37.1 // indirect
41 43
)
apps/feeds-go/handlers_admin.go +5 −5
61 61
62 62
func (a *App) discoverFeedsHandler(w http.ResponseWriter, r *http.Request) {
63 63
	if err := r.ParseForm(); err != nil {
64 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "bad request"})
64 +
		web.WriteError(w, http.StatusBadRequest, "bad request")
65 65
		return
66 66
	}
67 67
	feeds, err := discoverFeeds(r.Context(), r.FormValue("base_url"))
68 68
	if err != nil {
69 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
69 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
70 70
		return
71 71
	}
72 72
	web.WriteJSON(w, http.StatusOK, feeds)
90 90
}
91 91
92 92
func (a *App) deleteFeedHandler(w http.ResponseWriter, r *http.Request) {
93 -
	id, ok := pathInt64(r, "id")
93 +
	id, ok := web.PathInt64(r, "id")
94 94
	if !ok {
95 95
		web.RedirectWithError(w, r, "/admin", "Invalid feed ID")
96 96
		return
104 104
}
105 105
106 106
func (a *App) updateSubCategoryHandler(w http.ResponseWriter, r *http.Request) {
107 -
	id, ok := pathInt64(r, "id")
107 +
	id, ok := web.PathInt64(r, "id")
108 108
	if !ok {
109 109
		web.RedirectWithError(w, r, "/admin", "Invalid feed ID")
110 110
		return
143 143
}
144 144
145 145
func (a *App) deleteCategoryHandler(w http.ResponseWriter, r *http.Request) {
146 -
	id, ok := pathInt64(r, "id")
146 +
	id, ok := web.PathInt64(r, "id")
147 147
	if !ok {
148 148
		web.RedirectWithError(w, r, "/admin", "Invalid category ID")
149 149
		return
apps/feeds-go/handlers_api.go +25 −25
9 9
func (a *App) listItemsAPI(w http.ResponseWriter, r *http.Request) {
10 10
	items, err := listItems(a.DB, itemFilterFromRequest(r))
11 11
	if err != nil {
12 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
12 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
13 13
		return
14 14
	}
15 15
	web.WriteJSON(w, http.StatusOK, map[string]any{"items": items})
17 17
18 18
func (a *App) markItemReadAPI(isRead bool) http.HandlerFunc {
19 19
	return func(w http.ResponseWriter, r *http.Request) {
20 -
		id, ok := pathInt64(r, "id")
20 +
		id, ok := web.PathInt64(r, "id")
21 21
		if !ok {
22 -
			web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid item id"})
22 +
			web.WriteError(w, http.StatusBadRequest, "invalid item id")
23 23
			return
24 24
		}
25 25
		updated, err := markItemRead(a.DB, id, isRead)
26 26
		if err != nil {
27 -
			web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
27 +
			web.WriteError(w, http.StatusInternalServerError, err.Error())
28 28
			return
29 29
		}
30 30
		if !updated {
31 -
			web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "item not found"})
31 +
			web.WriteError(w, http.StatusNotFound, "item not found")
32 32
			return
33 33
		}
34 34
		web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true, "is_read": isRead})
38 38
func (a *App) listSubscriptionsAPI(w http.ResponseWriter, r *http.Request) {
39 39
	subs, err := listSubscriptions(a.DB)
40 40
	if err != nil {
41 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
41 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
42 42
		return
43 43
	}
44 44
	views := make([]subscriptionView, 0, len(subs))
59 59
		if isAlreadySubscribedError(err) {
60 60
			status = http.StatusConflict
61 61
		}
62 -
		web.WriteJSON(w, status, map[string]any{"error": err.Error()})
62 +
		web.WriteError(w, status, err.Error())
63 63
		return
64 64
	}
65 65
	web.WriteJSON(w, http.StatusCreated, map[string]any{"subscription": toSubscriptionView(*sub)})
66 66
}
67 67
68 68
func (a *App) updateSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
69 -
	id, ok := pathInt64(r, "id")
69 +
	id, ok := web.PathInt64(r, "id")
70 70
	if !ok {
71 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"})
71 +
		web.WriteError(w, http.StatusBadRequest, "invalid subscription id")
72 72
		return
73 73
	}
74 74
	var body updateSubscriptionBody
77 77
	}
78 78
	categoryID, err := a.resolveSubscriptionCategory(body)
79 79
	if err != nil {
80 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
80 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
81 81
		return
82 82
	}
83 83
	if err := updateSubscriptionCategory(a.DB, id, categoryID); err != nil {
84 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
84 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
85 85
		return
86 86
	}
87 87
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
88 88
}
89 89
90 90
func (a *App) deleteSubscriptionAPI(w http.ResponseWriter, r *http.Request) {
91 -
	id, ok := pathInt64(r, "id")
91 +
	id, ok := web.PathInt64(r, "id")
92 92
	if !ok {
93 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid subscription id"})
93 +
		web.WriteError(w, http.StatusBadRequest, "invalid subscription id")
94 94
		return
95 95
	}
96 96
	deleted, err := deleteSubscription(a.DB, id)
97 97
	if err != nil {
98 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
98 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
99 99
		return
100 100
	}
101 101
	if !deleted {
102 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "subscription not found"})
102 +
		web.WriteError(w, http.StatusNotFound, "subscription not found")
103 103
		return
104 104
	}
105 105
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
108 108
func (a *App) listCategoriesAPI(w http.ResponseWriter, r *http.Request) {
109 109
	cats, err := listCategories(a.DB)
110 110
	if err != nil {
111 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
111 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
112 112
		return
113 113
	}
114 114
	web.WriteJSON(w, http.StatusOK, map[string]any{"categories": cats})
121 121
	}
122 122
	cat, err := getOrCreateCategory(a.DB, body.Name)
123 123
	if err != nil {
124 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
124 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
125 125
		return
126 126
	}
127 127
	web.WriteJSON(w, http.StatusCreated, map[string]any{"category": cat})
128 128
}
129 129
130 130
func (a *App) deleteCategoryAPI(w http.ResponseWriter, r *http.Request) {
131 -
	id, ok := pathInt64(r, "id")
131 +
	id, ok := web.PathInt64(r, "id")
132 132
	if !ok {
133 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid category id"})
133 +
		web.WriteError(w, http.StatusBadRequest, "invalid category id")
134 134
		return
135 135
	}
136 136
	deleted, err := deleteCategory(a.DB, id)
137 137
	if err != nil {
138 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
138 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
139 139
		return
140 140
	}
141 141
	if !deleted {
142 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "category not found"})
142 +
		web.WriteError(w, http.StatusNotFound, "category not found")
143 143
		return
144 144
	}
145 145
	web.WriteJSON(w, http.StatusOK, map[string]any{"ok": true})
148 148
func (a *App) importOPMLAPI(w http.ResponseWriter, r *http.Request) {
149 149
	summary, err := a.readAndImportOPML(r)
150 150
	if err != nil {
151 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
151 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
152 152
		return
153 153
	}
154 154
	web.WriteJSON(w, http.StatusOK, summary)
165 165
	}
166 166
	if body.PollIntervalMinutes != nil {
167 167
		if !validPollMinutes(*body.PollIntervalMinutes) {
168 -
			web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "poll_interval_minutes must be between 1 and 1440"})
168 +
			web.WriteError(w, http.StatusBadRequest, "poll_interval_minutes must be between 1 and 1440")
169 169
			return
170 170
		}
171 171
		if err := setSetting(a.DB, "poll_interval_minutes", itoa(*body.PollIntervalMinutes)); err != nil {
172 -
			web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
172 +
			web.WriteError(w, http.StatusInternalServerError, err.Error())
173 173
			return
174 174
		}
175 175
	}
183 183
	}
184 184
	feeds, err := discoverFeeds(r.Context(), body.BaseURL)
185 185
	if err != nil {
186 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": err.Error()})
186 +
		web.WriteError(w, http.StatusBadRequest, err.Error())
187 187
		return
188 188
	}
189 189
	web.WriteJSON(w, http.StatusOK, map[string]any{"feeds": feeds})
apps/feeds-go/handlers_public.go +1 −1
70 70
	case "opml":
71 71
		a.writeOPMLExport(w, subs)
72 72
	default:
73 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "Invalid format. Use ?format=json or ?format=opml"})
73 +
		web.WriteError(w, http.StatusBadRequest, "Invalid format. Use ?format=json or ?format=opml")
74 74
	}
75 75
}
76 76
apps/feeds-go/main.go +2 −1
10 10
11 11
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 12
	"github.com/stevedylandev/andromeda/crates-go/config"
13 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
13 14
)
14 15
15 16
func main() {
17 18
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
18 19
19 20
	dbPath := config.Getenv("FEEDS_DB_PATH", "feeds.sqlite")
20 -
	db, err := openDB(dbPath)
21 +
	db, err := sqlite.Open(dbPath, feedsSchema)
21 22
	if err != nil {
22 23
		log.Fatal(err)
23 24
	}
apps/feeds-go/opml.go (added) +50 −0
1 +
package main
2 +
3 +
import (
4 +
	"encoding/xml"
5 +
	"strings"
6 +
)
7 +
8 +
type OPMLEntry struct {
9 +
	XMLURL   string
10 +
	Title    string
11 +
	HTMLURL  string
12 +
	Category string
13 +
}
14 +
15 +
func parseOPML(content string) []OPMLEntry {
16 +
	dec := xml.NewDecoder(strings.NewReader(content))
17 +
	type outline struct {
18 +
		Title   string    `xml:"title,attr"`
19 +
		Text    string    `xml:"text,attr"`
20 +
		XMLURL  string    `xml:"xmlUrl,attr"`
21 +
		HTMLURL string    `xml:"htmlUrl,attr"`
22 +
		Nodes   []outline `xml:"outline"`
23 +
	}
24 +
	type opml struct {
25 +
		Body struct {
26 +
			Nodes []outline `xml:"outline"`
27 +
		} `xml:"body"`
28 +
	}
29 +
	var doc opml
30 +
	if err := dec.Decode(&doc); err != nil {
31 +
		return nil
32 +
	}
33 +
	var out []OPMLEntry
34 +
	var walk func(nodes []outline, category string)
35 +
	walk = func(nodes []outline, category string) {
36 +
		for _, node := range nodes {
37 +
			title := firstNonEmpty(node.Title, node.Text)
38 +
			if strings.TrimSpace(node.XMLURL) != "" {
39 +
				out = append(out, OPMLEntry{XMLURL: strings.TrimSpace(node.XMLURL), Title: title, HTMLURL: strings.TrimSpace(node.HTMLURL), Category: strings.TrimSpace(category)})
40 +
				if len(node.Nodes) > 0 {
41 +
					walk(node.Nodes, title)
42 +
				}
43 +
				continue
44 +
			}
45 +
			walk(node.Nodes, title)
46 +
		}
47 +
	}
48 +
	walk(doc.Body.Nodes, "")
49 +
	return out
50 +
}
apps/feeds-go/util.go +0 −8
9 9
	"time"
10 10
)
11 11
12 -
func pathInt64(r *http.Request, name string) (int64, bool) {
13 -
	id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
14 -
	if err != nil || id <= 0 {
15 -
		return 0, false
16 -
	}
17 -
	return id, true
18 -
}
19 -
20 12
func formatDate(ts int64) string {
21 13
	if ts <= 0 {
22 14
		return ""
apps/jotts-go/go.mod +10 −8
3 3
go 1.25.0
4 4
5 5
require (
6 +
	github.com/BurntSushi/toml v1.6.0
7 +
	github.com/atotto/clipboard v0.1.4
8 +
	github.com/charmbracelet/bubbles v1.0.0
9 +
	github.com/charmbracelet/bubbletea v1.3.10
10 +
	github.com/charmbracelet/glamour v1.0.0
11 +
	github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834
6 12
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
7 13
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
8 14
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
15 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
9 16
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 17
	github.com/yuin/goldmark v1.7.13
11 -
	modernc.org/sqlite v1.37.1
18 +
	golang.org/x/term v0.43.0
12 19
)
13 20
14 21
replace (
15 22
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
16 23
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
17 24
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
25 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
18 26
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
19 27
)
20 28
21 29
require (
22 -
	github.com/BurntSushi/toml v1.6.0 // indirect
23 30
	github.com/alecthomas/chroma/v2 v2.20.0 // indirect
24 -
	github.com/atotto/clipboard v0.1.4 // indirect
25 31
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
26 32
	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 33
	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 34
	github.com/charmbracelet/x/ansi v0.11.6 // indirect
33 35
	github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
34 36
	github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect
59 61
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
60 62
	golang.org/x/net v0.38.0 // indirect
61 63
	golang.org/x/sys v0.44.0 // indirect
62 -
	golang.org/x/term v0.43.0 // indirect
63 64
	golang.org/x/text v0.30.0 // indirect
64 65
	modernc.org/libc v1.65.7 // indirect
65 66
	modernc.org/mathutil v1.7.1 // indirect
66 67
	modernc.org/memory v1.11.0 // indirect
68 +
	modernc.org/sqlite v1.37.1 // indirect
67 69
)
apps/jotts-go/go.sum +18 −10
1 1
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
2 2
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
3 +
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
4 +
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
5 +
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
6 +
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
3 7
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
4 8
github.com/alecthomas/chroma/v2 v2.20.0/go.mod h1:e7tViK0xh/Nf4BYHl00ycY6rV7b8iXBksI9E359yNmA=
9 +
github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg=
10 +
github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
5 11
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
6 12
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
7 13
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
8 14
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
15 +
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
16 +
github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E=
9 17
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
10 18
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
11 19
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
22 30
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
23 31
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
24 32
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
33 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payRxjMjKgx2PaCWLZ4p3ro9y97+TVLZNaRZgJwSVDQ=
34 +
github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
25 35
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI=
26 36
github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU=
27 37
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
44 54
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
45 55
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
46 56
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
57 +
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
58 +
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
47 59
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
48 60
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
49 61
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
73 85
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
74 86
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
75 87
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
76 -
github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic=
77 -
github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
78 88
github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
79 89
github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
80 90
github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs=
83 93
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
84 94
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
85 95
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
86 -
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
87 -
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
96 +
golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
97 +
golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
88 98
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
89 99
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
90 -
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
91 -
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
100 +
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
101 +
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
92 102
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
93 103
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
94 -
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
95 -
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
96 104
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
97 105
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
98 106
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
99 107
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
100 108
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
101 109
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
102 -
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
103 -
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
110 +
golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
111 +
golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
104 112
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
105 113
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
106 114
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
apps/jotts-go/handlers_api.go +5 −5
10 10
func (a *App) apiListNotes(w http.ResponseWriter, r *http.Request) {
11 11
	notes, err := listNotes(a.DB)
12 12
	if err != nil {
13 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
13 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
14 14
		return
15 15
	}
16 16
	if notes == nil {
23 23
	shortID := r.PathValue("short_id")
24 24
	note, err := getNoteByShortID(a.DB, shortID)
25 25
	if err != nil {
26 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
26 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
27 27
		return
28 28
	}
29 29
	if note == nil {
45 45
	}
46 46
	note, err := createNote(a.DB, title, body.Content)
47 47
	if err != nil {
48 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
48 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
49 49
		return
50 50
	}
51 51
	web.WriteJSON(w, http.StatusCreated, note)
64 64
	}
65 65
	note, err := updateNoteByShortID(a.DB, shortID, title, body.Content)
66 66
	if err != nil {
67 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
67 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
68 68
		return
69 69
	}
70 70
	if note == nil {
78 78
	shortID := r.PathValue("short_id")
79 79
	ok, err := deleteNoteByShortID(a.DB, shortID)
80 80
	if err != nil {
81 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
81 +
		web.WriteError(w, http.StatusInternalServerError, err.Error())
82 82
		return
83 83
	}
84 84
	if !ok {
apps/jotts-go/internal/store/store.go +2 −14
5 5
	"errors"
6 6
7 7
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 -
	_ "modernc.org/sqlite"
8 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
9 9
)
10 10
11 11
type Note struct {
36 36
`
37 37
38 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
39 +
	return sqlite.Open(path, schema)
52 40
}
53 41
54 42
func scanNote(scanner interface{ Scan(dest ...any) error }) (*Note, error) {
apps/library-go/db.go +0 −18
5 5
	"errors"
6 6
	"strings"
7 7
	"time"
8 -
9 -
	_ "modernc.org/sqlite"
10 8
)
11 9
12 10
const booksSchema = `
61 59
62 60
func defaultLabels() CategoryLabels {
63 61
	return CategoryLabels{Reading: "Reading", Read: "Read", Want: "Want to Read"}
64 -
}
65 -
66 -
func openDB(path string) (*sql.DB, error) {
67 -
	db, err := sql.Open("sqlite", path)
68 -
	if err != nil {
69 -
		return nil, err
70 -
	}
71 -
	db.SetMaxOpenConns(1)
72 -
	db.SetMaxIdleConns(1)
73 -
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
74 -
		return nil, err
75 -
	}
76 -
	if _, err := db.Exec(booksSchema); err != nil {
77 -
		return nil, err
78 -
	}
79 -
	return db, nil
80 62
}
81 63
82 64
const selectCols = `id, google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at`
apps/library-go/go.mod +3 −1
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 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
9 10
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 -
	modernc.org/sqlite v1.37.1
11 11
)
12 12
13 13
require (
22 22
	modernc.org/libc v1.65.7 // indirect
23 23
	modernc.org/mathutil v1.7.1 // indirect
24 24
	modernc.org/memory v1.11.0 // indirect
25 +
	modernc.org/sqlite v1.37.1 // indirect
25 26
)
26 27
27 28
replace (
28 29
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
29 30
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
30 31
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
32 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
31 33
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
32 34
)
apps/library-go/handlers_api.go +5 −6
2 2
3 3
import (
4 4
	"net/http"
5 -
	"strconv"
6 5
7 6
	"github.com/stevedylandev/andromeda/crates-go/web"
8 7
)
14 13
		status = ""
15 14
	default:
16 15
		if !validStatus(status) {
17 -
			web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid status"})
16 +
			web.WriteError(w, http.StatusBadRequest, "invalid status")
18 17
			return
19 18
		}
20 19
	}
31 30
}
32 31
33 32
func (a *App) apiGetBook(w http.ResponseWriter, r *http.Request) {
34 -
	id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
35 -
	if err != nil {
36 -
		web.WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid id"})
33 +
	id, ok := web.PathInt64(r, "id")
34 +
	if !ok {
35 +
		web.WriteError(w, http.StatusBadRequest, "invalid id")
37 36
		return
38 37
	}
39 38
	b, err := getBook(a.DB, id)
43 42
		return
44 43
	}
45 44
	if b == nil {
46 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
45 +
		web.WriteError(w, http.StatusNotFound, "not found")
47 46
		return
48 47
	}
49 48
	web.WriteJSON(w, http.StatusOK, b)
apps/library-go/handlers_web.go +1 −1
179 179
	hits, err := googleBooksSearch(r.Context(), r.URL.Query().Get("q"), a.GoogleBooksKey)
180 180
	if err != nil {
181 181
		a.Log.Warn("google books search failed", "err", err)
182 -
		web.WriteJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
182 +
		web.WriteError(w, http.StatusBadGateway, err.Error())
183 183
		return
184 184
	}
185 185
	if hits == nil {
apps/library-go/main.go +2 −1
9 9
10 10
	"github.com/stevedylandev/andromeda/crates-go/auth"
11 11
	"github.com/stevedylandev/andromeda/crates-go/config"
12 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
12 13
)
13 14
14 15
func main() {
16 17
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
17 18
18 19
	dbPath := config.Getenv("LIBRARY_DB_PATH", "library.sqlite")
19 -
	db, err := openDB(dbPath)
20 +
	db, err := sqlite.Open(dbPath, booksSchema)
20 21
	if err != nil {
21 22
		log.Fatal(err)
22 23
	}
apps/posts-go/db.go +1 −12
7 7
	"time"
8 8
9 9
	"github.com/stevedylandev/andromeda/crates-go/auth"
10 -
	_ "modernc.org/sqlite"
11 10
)
12 11
13 12
const postsSchema = `
72 71
</div>`},
73 72
}
74 73
75 -
func openDB(path string) (*sql.DB, error) {
76 -
	db, err := sql.Open("sqlite", path)
77 -
	if err != nil {
78 -
		return nil, err
79 -
	}
80 -
	db.SetMaxOpenConns(1)
81 -
	db.SetMaxIdleConns(1)
82 -
	if _, err := db.Exec(postsSchema); err != nil {
83 -
		return nil, err
84 -
	}
74 +
func seedDefaultSettings(db *sql.DB) {
85 75
	for _, kv := range defaultSettings {
86 76
		_, _ = db.Exec(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, ?)`, kv[0], kv[1])
87 77
	}
88 -
	return db, nil
89 78
}
90 79
91 80
const postCols = `id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at`
apps/posts-go/go.mod +3 −1
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 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
9 10
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
10 11
	github.com/yuin/goldmark v1.7.8
11 -
	modernc.org/sqlite v1.37.1
12 12
)
13 13
14 14
require (
23 23
	modernc.org/libc v1.65.7 // indirect
24 24
	modernc.org/mathutil v1.7.1 // indirect
25 25
	modernc.org/memory v1.11.0 // indirect
26 +
	modernc.org/sqlite v1.37.1 // indirect
26 27
)
27 28
28 29
replace (
29 30
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
30 31
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
31 32
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
33 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
32 34
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
33 35
)
apps/posts-go/handlers_api.go +3 −3
69 69
	}
70 70
	posts, err := getPublishedPosts(a.DB, limit)
71 71
	if err != nil {
72 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "internal server error"})
72 +
		web.WriteError(w, http.StatusInternalServerError, "internal server error")
73 73
		return
74 74
	}
75 75
	out := make([]apiPostSummary, 0, len(posts))
82 82
func (a *App) apiGetPost(w http.ResponseWriter, r *http.Request) {
83 83
	post, err := getPostBySlug(a.DB, r.PathValue("slug"))
84 84
	if err != nil {
85 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "internal server error"})
85 +
		web.WriteError(w, http.StatusInternalServerError, "internal server error")
86 86
		return
87 87
	}
88 88
	if post == nil || post.Status != "published" {
89 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
89 +
		web.WriteError(w, http.StatusNotFound, "not found")
90 90
		return
91 91
	}
92 92
	web.WriteJSON(w, http.StatusOK, toDetail(*post))
apps/posts-go/main.go +3 −1
10 10
11 11
	"github.com/stevedylandev/andromeda/crates-go/auth"
12 12
	"github.com/stevedylandev/andromeda/crates-go/config"
13 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
13 14
)
14 15
15 16
func main() {
17 18
	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
18 19
19 20
	dbPath := config.Getenv("POSTS_DB_PATH", "posts.sqlite")
20 -
	db, err := openDB(dbPath)
21 +
	db, err := sqlite.Open(dbPath, postsSchema)
21 22
	if err != nil {
22 23
		log.Fatal(err)
23 24
	}
24 25
	defer db.Close()
26 +
	seedDefaultSettings(db)
25 27
26 28
	uploadsDir := config.Getenv("UPLOADS_DIR", "uploads")
27 29
	if err := ensureDir(uploadsDir); err != nil {
apps/sipp-go/go.mod +3 −1
12 12
	github.com/stevedylandev/andromeda/crates-go/auth v0.0.0
13 13
	github.com/stevedylandev/andromeda/crates-go/config v0.0.0
14 14
	github.com/stevedylandev/andromeda/crates-go/darkmatter v0.0.0
15 +
	github.com/stevedylandev/andromeda/crates-go/sqlite v0.0.0
15 16
	github.com/stevedylandev/andromeda/crates-go/web v0.0.0
16 -
	modernc.org/sqlite v1.37.1
17 17
)
18 18
19 19
require (
47 47
	modernc.org/libc v1.65.7 // indirect
48 48
	modernc.org/mathutil v1.7.1 // indirect
49 49
	modernc.org/memory v1.11.0 // indirect
50 +
	modernc.org/sqlite v1.37.1 // indirect
50 51
)
51 52
52 53
replace (
53 54
	github.com/stevedylandev/andromeda/crates-go/auth => ../../crates-go/auth
54 55
	github.com/stevedylandev/andromeda/crates-go/config => ../../crates-go/config
55 56
	github.com/stevedylandev/andromeda/crates-go/darkmatter => ../../crates-go/darkmatter
57 +
	github.com/stevedylandev/andromeda/crates-go/sqlite => ../../crates-go/sqlite
56 58
	github.com/stevedylandev/andromeda/crates-go/web => ../../crates-go/web
57 59
)
apps/sipp-go/internal/store/store.go +2 −11
5 5
	"errors"
6 6
7 7
	"github.com/stevedylandev/andromeda/crates-go/auth"
8 -
	_ "modernc.org/sqlite"
8 +
	"github.com/stevedylandev/andromeda/crates-go/sqlite"
9 9
)
10 10
11 11
type Snippet struct {
30 30
`
31 31
32 32
func Open(path string) (*sql.DB, error) {
33 -
	db, err := sql.Open("sqlite", path)
34 -
	if err != nil {
35 -
		return nil, err
36 -
	}
37 -
	db.SetMaxOpenConns(1)
38 -
	db.SetMaxIdleConns(1)
39 -
	if _, err := db.Exec(schema); err != nil {
40 -
		return nil, err
41 -
	}
42 -
	return db, nil
33 +
	return sqlite.Open(path, schema)
43 34
}
44 35
45 36
func scanSnippet(s interface{ Scan(...any) error }) (*Snippet, error) {
apps/sipp-go/server/server.go +12 −12
219 219
func (a *App) requireAPIKey(next http.HandlerFunc) http.HandlerFunc {
220 220
	return func(w http.ResponseWriter, r *http.Request) {
221 221
		if a.APIKey == "" {
222 -
			web.WriteJSON(w, http.StatusForbidden, map[string]any{"error": "No API key configured on server"})
222 +
			web.WriteError(w, http.StatusForbidden, "No API key configured on server")
223 223
			return
224 224
		}
225 225
		if auth.SecureEqual(r.Header.Get("x-api-key"), a.APIKey) {
230 230
			next(w, r)
231 231
			return
232 232
		}
233 -
		web.WriteJSON(w, http.StatusUnauthorized, map[string]any{"error": "Invalid or missing API key"})
233 +
		web.WriteError(w, http.StatusUnauthorized, "Invalid or missing API key")
234 234
	}
235 235
}
236 236
237 237
func (a *App) apiList(w http.ResponseWriter, r *http.Request) {
238 238
	snippets, err := getAllSnippets(a.DB)
239 239
	if err != nil {
240 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
240 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
241 241
		return
242 242
	}
243 243
	web.WriteJSON(w, http.StatusOK, snippets)
246 246
func (a *App) apiGet(w http.ResponseWriter, r *http.Request) {
247 247
	s, err := getSnippetByShortID(a.DB, r.PathValue("short_id"))
248 248
	if err != nil {
249 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
249 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
250 250
		return
251 251
	}
252 252
	if s == nil {
253 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Snippet not found"})
253 +
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
254 254
		return
255 255
	}
256 256
	web.WriteJSON(w, http.StatusOK, s)
267 267
		return
268 268
	}
269 269
	if len(body.Content) > a.MaxContentSize {
270 -
		web.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Content too large. Maximum size is " + strconv.Itoa(a.MaxContentSize) + " bytes"})
270 +
		web.WriteError(w, http.StatusRequestEntityTooLarge, "Content too large. Maximum size is "+strconv.Itoa(a.MaxContentSize)+" bytes")
271 271
		return
272 272
	}
273 273
	s, err := createSnippet(a.DB, body.Name, body.Content)
274 274
	if err != nil {
275 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
275 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
276 276
		return
277 277
	}
278 278
	web.WriteJSON(w, http.StatusCreated, s)
284 284
		return
285 285
	}
286 286
	if len(body.Content) > a.MaxContentSize {
287 -
		web.WriteJSON(w, http.StatusRequestEntityTooLarge, map[string]any{"error": "Content too large"})
287 +
		web.WriteError(w, http.StatusRequestEntityTooLarge, "Content too large")
288 288
		return
289 289
	}
290 290
	s, err := updateSnippetByShortID(a.DB, r.PathValue("short_id"), body.Name, body.Content)
291 291
	if err != nil {
292 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
292 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
293 293
		return
294 294
	}
295 295
	if s == nil {
296 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Snippet not found"})
296 +
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
297 297
		return
298 298
	}
299 299
	web.WriteJSON(w, http.StatusOK, s)
302 302
func (a *App) apiDelete(w http.ResponseWriter, r *http.Request) {
303 303
	ok, err := deleteSnippetByShortID(a.DB, r.PathValue("short_id"))
304 304
	if err != nil {
305 -
		web.WriteJSON(w, http.StatusInternalServerError, map[string]any{"error": "Internal server error"})
305 +
		web.WriteError(w, http.StatusInternalServerError, "Internal server error")
306 306
		return
307 307
	}
308 308
	if !ok {
309 -
		web.WriteJSON(w, http.StatusNotFound, map[string]any{"error": "Snippet not found"})
309 +
		web.WriteError(w, http.StatusNotFound, "Snippet not found")
310 310
		return
311 311
	}
312 312
	web.WriteJSON(w, http.StatusOK, map[string]any{"deleted": true})
crates-go/sqlite/go.mod (added) +18 −0
1 +
module github.com/stevedylandev/andromeda/crates-go/sqlite
2 +
3 +
go 1.24
4 +
5 +
require modernc.org/sqlite v1.37.1
6 +
7 +
require (
8 +
	github.com/dustin/go-humanize v1.0.1 // indirect
9 +
	github.com/google/uuid v1.6.0 // indirect
10 +
	github.com/mattn/go-isatty v0.0.20 // indirect
11 +
	github.com/ncruces/go-strftime v0.1.9 // indirect
12 +
	github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
13 +
	golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
14 +
	golang.org/x/sys v0.33.0 // indirect
15 +
	modernc.org/libc v1.65.7 // indirect
16 +
	modernc.org/mathutil v1.7.1 // indirect
17 +
	modernc.org/memory v1.11.0 // indirect
18 +
)
crates-go/sqlite/go.sum (added) +47 −0
1 +
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
2 +
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
3 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
4 +
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
5 +
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
6 +
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
7 +
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
8 +
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
9 +
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
10 +
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
11 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
12 +
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
13 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
14 +
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
15 +
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
16 +
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
17 +
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
18 +
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
19 +
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20 +
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
21 +
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
22 +
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
23 +
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
24 +
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
25 +
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
26 +
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
27 +
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
28 +
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
29 +
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
30 +
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
31 +
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
32 +
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
33 +
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
34 +
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
35 +
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
36 +
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
37 +
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
38 +
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
39 +
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
40 +
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
41 +
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
42 +
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
43 +
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
44 +
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
45 +
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
46 +
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
47 +
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
crates-go/sqlite/sqlite.go (added) +31 −0
1 +
// Package sqlite provides a shared SQLite bootstrap for andromeda Go apps.
2 +
package sqlite
3 +
4 +
import (
5 +
	"database/sql"
6 +
7 +
	_ "modernc.org/sqlite"
8 +
)
9 +
10 +
// Open opens a SQLite database at path with the connection settings and
11 +
// PRAGMAs used across andromeda Go apps. If schema is non-empty it is
12 +
// executed once after opening (typically CREATE TABLE IF NOT EXISTS ...).
13 +
func Open(path string, schema string) (*sql.DB, error) {
14 +
	db, err := sql.Open("sqlite", path)
15 +
	if err != nil {
16 +
		return nil, err
17 +
	}
18 +
	db.SetMaxOpenConns(1)
19 +
	db.SetMaxIdleConns(1)
20 +
	if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil {
21 +
		db.Close()
22 +
		return nil, err
23 +
	}
24 +
	if schema != "" {
25 +
		if _, err := db.Exec(schema); err != nil {
26 +
			db.Close()
27 +
			return nil, err
28 +
		}
29 +
	}
30 +
	return db, nil
31 +
}
crates-go/web/web.go +18 −1
10 10
	"net/http"
11 11
	"net/url"
12 12
	"path/filepath"
13 +
	"strconv"
13 14
	"strings"
14 15
)
15 16
55 56
func DecodeJSON(w http.ResponseWriter, r *http.Request, dst any) bool {
56 57
	defer r.Body.Close()
57 58
	if err := json.NewDecoder(r.Body).Decode(dst); err != nil {
58 -
		WriteJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON"})
59 +
		WriteError(w, http.StatusBadRequest, "invalid JSON")
59 60
		return false
60 61
	}
61 62
	return true
63 +
}
64 +
65 +
// WriteError writes a JSON error response of the form {"error": msg} with the
66 +
// given status code.
67 +
func WriteError(w http.ResponseWriter, status int, msg string) {
68 +
	WriteJSON(w, status, map[string]any{"error": msg})
69 +
}
70 +
71 +
// PathInt64 parses a positive int64 path value from r. Returns (0, false) if
72 +
// missing, unparseable, or non-positive.
73 +
func PathInt64(r *http.Request, name string) (int64, bool) {
74 +
	id, err := strconv.ParseInt(r.PathValue(name), 10, 64)
75 +
	if err != nil || id <= 0 {
76 +
		return 0, false
77 +
	}
78 +
	return id, true
62 79
}
63 80
64 81
// RedirectWithError issues a 303 redirect to target with ?error=msg appended.