| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "database/sql" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | ) |
| 9 | |
| 10 | const feedsSchema = ` |
| 11 | CREATE TABLE IF NOT EXISTS categories ( |
| 12 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 13 | name TEXT NOT NULL UNIQUE, |
| 14 | created_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 15 | ); |
| 16 | |
| 17 | CREATE TABLE IF NOT EXISTS subscriptions ( |
| 18 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 19 | feed_url TEXT NOT NULL UNIQUE, |
| 20 | title TEXT NOT NULL, |
| 21 | site_url TEXT, |
| 22 | favicon_url TEXT, |
| 23 | category_id INTEGER REFERENCES categories(id) ON DELETE SET NULL, |
| 24 | etag TEXT, |
| 25 | last_modified TEXT, |
| 26 | last_fetched_at TEXT, |
| 27 | last_error TEXT, |
| 28 | added_at TEXT NOT NULL DEFAULT (datetime('now')) |
| 29 | ); |
| 30 | CREATE INDEX IF NOT EXISTS idx_subs_category ON subscriptions(category_id); |
| 31 | |
| 32 | CREATE TABLE IF NOT EXISTS items ( |
| 33 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 34 | subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE, |
| 35 | guid TEXT NOT NULL, |
| 36 | title TEXT NOT NULL, |
| 37 | link TEXT NOT NULL, |
| 38 | author TEXT, |
| 39 | published_at INTEGER NOT NULL, |
| 40 | is_read INTEGER NOT NULL DEFAULT 0, |
| 41 | fetched_at TEXT NOT NULL DEFAULT (datetime('now')), |
| 42 | UNIQUE(subscription_id, guid) |
| 43 | ); |
| 44 | CREATE INDEX IF NOT EXISTS idx_items_sub_pub ON items(subscription_id, published_at DESC); |
| 45 | CREATE INDEX IF NOT EXISTS idx_items_pub ON items(published_at DESC); |
| 46 | CREATE INDEX IF NOT EXISTS idx_items_unread ON items(is_read, published_at DESC); |
| 47 | |
| 48 | CREATE TABLE IF NOT EXISTS settings ( |
| 49 | key TEXT PRIMARY KEY, |
| 50 | value TEXT NOT NULL |
| 51 | ); |
| 52 | ` |
| 53 | |
| 54 | func seedSettings(db *sql.DB, defaultPoll int) error { |
| 55 | _, err := db.Exec(`INSERT INTO settings (key, value) VALUES ('poll_interval_minutes', ?) |
| 56 | ON CONFLICT(key) DO NOTHING`, fmt.Sprintf("%d", defaultPoll)) |
| 57 | return err |
| 58 | } |
| 59 | |
| 60 | func getSetting(db *sql.DB, key string) (string, bool, error) { |
| 61 | var value string |
| 62 | err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&value) |
| 63 | if errors.Is(err, sql.ErrNoRows) { |
| 64 | return "", false, nil |
| 65 | } |
| 66 | if err != nil { |
| 67 | return "", false, err |
| 68 | } |
| 69 | return value, true, nil |
| 70 | } |
| 71 | |
| 72 | func setSetting(db *sql.DB, key, value string) error { |
| 73 | _, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value) |
| 74 | return err |
| 75 | } |
| 76 | |
| 77 | func nullableString(s *string) any { |
| 78 | if s == nil || strings.TrimSpace(*s) == "" { |
| 79 | return nil |
| 80 | } |
| 81 | return *s |
| 82 | } |
| 83 | |
| 84 | func nullableInt64(v *int64) any { |
| 85 | if v == nil { |
| 86 | return nil |
| 87 | } |
| 88 | return *v |
| 89 | } |
| 90 | |
| 91 | func stringPtr(s string) *string { |
| 92 | if strings.TrimSpace(s) == "" { |
| 93 | return nil |
| 94 | } |
| 95 | return &s |
| 96 | } |