chore: refactor apps to web and db crates
1a944ed9
44 file(s) · +619 −574
| 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`) |
|
| 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 | ) |
|
| 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) |
|
| 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 | } |
|
| 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 |
|
| 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 | ) |
|
| 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) |
|
| 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 | } |
|
| 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 |
|
| 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 | ) |
|
| 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)) |
|
| 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 | } |
|
| 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 { |
|
| 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 | + | } |
| 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 | + | } |
| 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 | + | } |
| 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 { |
|
| 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 | ) |
|
| 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 |
|
| 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}) |
|
| 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 |
| 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 | } |
|
| 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 | + | } |
| 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 "" |
| 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 | ) |
|
| 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= |
|
| 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 { |
|
| 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) { |
|
| 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` |
|
| 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 | ) |
|
| 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) |
|
| 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 { |
| 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 | } |
|
| 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` |
|
| 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 | ) |
|
| 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)) |
|
| 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 { |
|
| 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 | ) |
|
| 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) { |
|
| 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}) |
|
| 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 | + | ) |
| 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= |
| 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 | + | } |
| 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. |
|