apps/library/db.go 5.8 K raw
1
package main
2
3
import (
4
	"database/sql"
5
	"errors"
6
	"strings"
7
	"time"
8
)
9
10
const booksSchema = `
11
CREATE TABLE IF NOT EXISTS books (
12
    id            INTEGER PRIMARY KEY AUTOINCREMENT,
13
    google_id     TEXT UNIQUE,
14
    title         TEXT NOT NULL,
15
    authors       TEXT NOT NULL,
16
    isbn          TEXT,
17
    cover_url     TEXT,
18
    notes         TEXT,
19
    status        TEXT NOT NULL CHECK (status IN ('read','reading','want')),
20
    added_at      INTEGER NOT NULL,
21
    updated_at    INTEGER NOT NULL
22
);
23
CREATE INDEX IF NOT EXISTS idx_books_status_added ON books(status, added_at DESC);
24
25
CREATE TABLE IF NOT EXISTS settings (
26
    key   TEXT PRIMARY KEY,
27
    value TEXT NOT NULL
28
);
29
`
30
31
type Book struct {
32
	ID        int64   `json:"id"`
33
	GoogleID  *string `json:"google_id,omitempty"`
34
	Title     string  `json:"title"`
35
	Authors   string  `json:"authors"`
36
	ISBN      *string `json:"isbn,omitempty"`
37
	CoverURL  *string `json:"cover_url,omitempty"`
38
	Notes     *string `json:"notes,omitempty"`
39
	Status    string  `json:"status"`
40
	AddedAt   int64   `json:"added_at"`
41
	UpdatedAt int64   `json:"updated_at"`
42
}
43
44
type NewBook struct {
45
	GoogleID *string
46
	Title    string
47
	Authors  string
48
	ISBN     *string
49
	CoverURL *string
50
	Notes    *string
51
	Status   string
52
}
53
54
type CategoryLabels struct {
55
	Reading string
56
	Read    string
57
	Want    string
58
}
59
60
func defaultLabels() CategoryLabels {
61
	return CategoryLabels{Reading: "Reading", Read: "Read", Want: "Want to Read"}
62
}
63
64
const selectCols = `id, google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at`
65
66
func scanBook(s interface{ Scan(...any) error }) (*Book, error) {
67
	var b Book
68
	var gid, isbn, cover, notes sql.NullString
69
	err := s.Scan(&b.ID, &gid, &b.Title, &b.Authors, &isbn, &cover, &notes, &b.Status, &b.AddedAt, &b.UpdatedAt)
70
	if errors.Is(err, sql.ErrNoRows) {
71
		return nil, nil
72
	}
73
	if err != nil {
74
		return nil, err
75
	}
76
	if gid.Valid {
77
		v := gid.String
78
		b.GoogleID = &v
79
	}
80
	if isbn.Valid {
81
		v := isbn.String
82
		b.ISBN = &v
83
	}
84
	if cover.Valid {
85
		v := cover.String
86
		b.CoverURL = &v
87
	}
88
	if notes.Valid {
89
		v := notes.String
90
		b.Notes = &v
91
	}
92
	return &b, nil
93
}
94
95
func listBooks(db *sql.DB, status string) ([]Book, error) {
96
	var rows *sql.Rows
97
	var err error
98
	if status != "" {
99
		rows, err = db.Query(`SELECT `+selectCols+` FROM books WHERE status = ? ORDER BY added_at DESC`, status)
100
	} else {
101
		rows, err = db.Query(`SELECT ` + selectCols + ` FROM books ORDER BY added_at DESC`)
102
	}
103
	if err != nil {
104
		return nil, err
105
	}
106
	defer rows.Close()
107
	var out []Book
108
	for rows.Next() {
109
		b, err := scanBook(rows)
110
		if err != nil {
111
			return nil, err
112
		}
113
		out = append(out, *b)
114
	}
115
	return out, rows.Err()
116
}
117
118
func getBook(db *sql.DB, id int64) (*Book, error) {
119
	return scanBook(db.QueryRow(`SELECT `+selectCols+` FROM books WHERE id = ?`, id))
120
}
121
122
func insertBook(db *sql.DB, b NewBook) (int64, error) {
123
	now := time.Now().UTC().Unix()
124
	var gid, isbn, cover, notes any
125
	if b.GoogleID != nil && *b.GoogleID != "" {
126
		gid = *b.GoogleID
127
	}
128
	if b.ISBN != nil && *b.ISBN != "" {
129
		isbn = *b.ISBN
130
	}
131
	if b.CoverURL != nil && *b.CoverURL != "" {
132
		cover = *b.CoverURL
133
	}
134
	if b.Notes != nil && *b.Notes != "" {
135
		notes = *b.Notes
136
	}
137
	res, err := db.Exec(
138
		`INSERT INTO books (google_id, title, authors, isbn, cover_url, notes, status, added_at, updated_at)
139
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
140
		 ON CONFLICT(google_id) DO UPDATE SET status = excluded.status, updated_at = excluded.updated_at`,
141
		gid, b.Title, b.Authors, isbn, cover, notes, b.Status, now, now,
142
	)
143
	if err != nil {
144
		return 0, err
145
	}
146
	return res.LastInsertId()
147
}
148
149
func updateBookStatus(db *sql.DB, id int64, status string) error {
150
	_, err := db.Exec(`UPDATE books SET status = ?, updated_at = ? WHERE id = ?`, status, time.Now().UTC().Unix(), id)
151
	return err
152
}
153
154
func updateBookNotes(db *sql.DB, id int64, notes *string) error {
155
	var v any
156
	if notes != nil && *notes != "" {
157
		v = *notes
158
	}
159
	_, err := db.Exec(`UPDATE books SET notes = ?, updated_at = ? WHERE id = ?`, v, time.Now().UTC().Unix(), id)
160
	return err
161
}
162
163
func deleteBook(db *sql.DB, id int64) error {
164
	_, err := db.Exec(`DELETE FROM books WHERE id = ?`, id)
165
	return err
166
}
167
168
func searchBooks(db *sql.DB, q string) ([]Book, error) {
169
	term := strings.TrimSpace(q)
170
	if term == "" {
171
		return nil, nil
172
	}
173
	pattern := "%" + strings.ToLower(term) + "%"
174
	rows, err := db.Query(
175
		`SELECT `+selectCols+` FROM books
176
		 WHERE LOWER(title) LIKE ? OR LOWER(authors) LIKE ? OR LOWER(IFNULL(isbn,'')) LIKE ?
177
		 ORDER BY added_at DESC LIMIT 50`,
178
		pattern, pattern, pattern,
179
	)
180
	if err != nil {
181
		return nil, err
182
	}
183
	defer rows.Close()
184
	var out []Book
185
	for rows.Next() {
186
		b, err := scanBook(rows)
187
		if err != nil {
188
			return nil, err
189
		}
190
		out = append(out, *b)
191
	}
192
	return out, rows.Err()
193
}
194
195
func getSetting(db *sql.DB, key string) (string, bool, error) {
196
	var v string
197
	err := db.QueryRow(`SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
198
	if errors.Is(err, sql.ErrNoRows) {
199
		return "", false, nil
200
	}
201
	if err != nil {
202
		return "", false, err
203
	}
204
	return v, true, nil
205
}
206
207
func setSetting(db *sql.DB, key, value string) error {
208
	_, err := db.Exec(`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
209
	return err
210
}
211
212
func getCategoryLabels(db *sql.DB) (CategoryLabels, error) {
213
	labels := defaultLabels()
214
	if v, ok, err := getSetting(db, "category_label.reading"); err == nil && ok {
215
		labels.Reading = v
216
	} else if err != nil {
217
		return labels, err
218
	}
219
	if v, ok, err := getSetting(db, "category_label.read"); err == nil && ok {
220
		labels.Read = v
221
	}
222
	if v, ok, err := getSetting(db, "category_label.want"); err == nil && ok {
223
		labels.Want = v
224
	}
225
	return labels, nil
226
}
227
228
func labelFor(l CategoryLabels, status string) string {
229
	switch status {
230
	case "reading":
231
		return l.Reading
232
	case "read":
233
		return l.Read
234
	case "want":
235
		return l.Want
236
	}
237
	return status
238
}
239
240
func validStatus(s string) bool {
241
	return s == "read" || s == "reading" || s == "want"
242
}