-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathstore.go
More file actions
290 lines (270 loc) · 8.05 KB
/
store.go
File metadata and controls
290 lines (270 loc) · 8.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
package pubengine
import (
"database/sql"
"os"
"path/filepath"
"sort"
"strings"
)
// Store wraps a SQLite database and provides CRUD operations for blog posts.
type Store struct {
db *sql.DB
}
// NewStore opens (or creates) the SQLite database at path, ensures the data
// directory exists, and runs schema migrations.
func NewStore(path string) (*Store, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
// Enable WAL mode for concurrent read/write access, set a busy timeout
// so writers wait instead of returning SQLITE_BUSY immediately, and tune
// performance: synchronous=NORMAL is safe with WAL and avoids an fsync
// per transaction; larger cache and mmap reduce disk I/O.
if _, err := db.Exec(`
PRAGMA journal_mode=WAL;
PRAGMA busy_timeout=5000;
PRAGMA synchronous=NORMAL;
PRAGMA cache_size=-8000;
PRAGMA mmap_size=268435456;
`); err != nil {
return nil, err
}
db.SetMaxOpenConns(4)
db.SetMaxIdleConns(4)
s := &Store{db: db}
if err := s.ensureSchema(); err != nil {
return nil, err
}
return s, nil
}
// Close closes the underlying database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) ensureSchema() error {
_, err := s.db.Exec(`
CREATE TABLE IF NOT EXISTS posts (
slug TEXT PRIMARY KEY,
title TEXT NOT NULL,
date TEXT NOT NULL,
tags TEXT NOT NULL,
summary TEXT NOT NULL,
content TEXT NOT NULL,
published INTEGER NOT NULL DEFAULT 1
);
`)
if err != nil {
return err
}
if _, err := s.db.Exec(`ALTER TABLE posts ADD COLUMN published INTEGER NOT NULL DEFAULT 1;`); err != nil {
if !strings.Contains(strings.ToLower(err.Error()), "duplicate column") {
return err
}
}
_, err = s.db.Exec(`
CREATE TABLE IF NOT EXISTS images (
filename TEXT PRIMARY KEY,
original_name TEXT NOT NULL,
width INTEGER NOT NULL,
height INTEGER NOT NULL,
size INTEGER NOT NULL,
uploaded_at TEXT NOT NULL
);
`)
return err
}
// ListPosts returns all published posts ordered by date descending.
// If tag is non-empty, results are filtered to posts containing that tag.
func (s *Store) ListPosts(tag string) ([]BlogPost, error) {
var rows *sql.Rows
var err error
if tag == "" {
rows, err = s.db.Query(`SELECT slug, title, date, tags, summary, content, published FROM posts WHERE published = 1 ORDER BY date DESC`)
} else {
normalizedTag := strings.ToLower(strings.TrimSpace(tag))
rows, err = s.db.Query(`SELECT slug, title, date, tags, summary, content, published FROM posts WHERE published = 1 AND instr(lower(tags), ',' || ? || ',') > 0 ORDER BY date DESC`, normalizedTag)
}
if err != nil {
return nil, err
}
defer rows.Close()
var posts []BlogPost
for rows.Next() {
var slug, title, date, tags, summary, content string
var published int
if err := rows.Scan(&slug, &title, &date, &tags, &summary, &content, &published); err != nil {
return nil, err
}
post := BlogPost{
Slug: slug,
Title: title,
Date: date,
Tags: ParseTags(tags),
Summary: summary,
Content: content,
Link: "/blog/" + slug,
Published: published == 1,
}
posts = append(posts, post)
}
return posts, nil
}
// ListTags returns a sorted, deduplicated slice of all tags from published posts.
func (s *Store) ListTags() ([]string, error) {
rows, err := s.db.Query(`SELECT tags FROM posts WHERE published = 1`)
if err != nil {
return nil, err
}
defer rows.Close()
set := make(map[string]struct{})
for rows.Next() {
var tags string
if err := rows.Scan(&tags); err != nil {
return nil, err
}
for _, t := range ParseTags(tags) {
set[strings.ToLower(t)] = struct{}{}
}
}
if err := rows.Err(); err != nil {
return nil, err
}
var result []string
for t := range set {
result = append(result, t)
}
sort.Strings(result)
return result, nil
}
// GetPost returns a single published post by slug.
func (s *Store) GetPost(slug string) (BlogPost, error) {
var title, date, tags, summary, content string
var published int
err := s.db.QueryRow(`SELECT title, date, tags, summary, content, published FROM posts WHERE slug = ? AND published = 1`, slug).
Scan(&title, &date, &tags, &summary, &content, &published)
if err != nil {
return BlogPost{}, err
}
return BlogPost{
Slug: slug,
Title: title,
Date: date,
Tags: ParseTags(tags),
Summary: summary,
Content: content,
Link: "/blog/" + slug,
Published: published == 1,
}, nil
}
// GetPostAny returns a post by slug regardless of published status (for admin).
func (s *Store) GetPostAny(slug string) (BlogPost, error) {
var title, date, tags, summary, content string
var published int
err := s.db.QueryRow(`SELECT title, date, tags, summary, content, published FROM posts WHERE slug = ?`, slug).
Scan(&title, &date, &tags, &summary, &content, &published)
if err != nil {
return BlogPost{}, err
}
return BlogPost{
Slug: slug,
Title: title,
Date: date,
Tags: ParseTags(tags),
Summary: summary,
Content: content,
Link: "/blog/" + slug,
Published: published == 1,
}, nil
}
// ListAllPosts returns every post (published and drafts) ordered by date descending.
func (s *Store) ListAllPosts() ([]BlogPost, error) {
rows, err := s.db.Query(`SELECT slug, title, date, tags, summary, content, published FROM posts ORDER BY date DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var posts []BlogPost
for rows.Next() {
var slug, title, date, tags, summary, content string
var published int
if err := rows.Scan(&slug, &title, &date, &tags, &summary, &content, &published); err != nil {
return nil, err
}
posts = append(posts, BlogPost{
Slug: slug,
Title: title,
Date: date,
Tags: ParseTags(tags),
Summary: summary,
Content: content,
Link: "/blog/" + slug,
Published: published == 1,
})
}
return posts, nil
}
// SavePost upserts a blog post. Tags are normalized to lowercase.
func (s *Store) SavePost(p BlogPost) error {
normalizedTags := make([]string, len(p.Tags))
for i, t := range p.Tags {
normalizedTags[i] = strings.ToLower(strings.TrimSpace(t))
}
tagString := "," + strings.Join(normalizedTags, ",") + ","
published := 0
if p.Published {
published = 1
}
_, err := s.db.Exec(`INSERT OR REPLACE INTO posts (slug, title, date, tags, summary, content, published) VALUES (?, ?, ?, ?, ?, ?, ?)`,
p.Slug, p.Title, p.Date, tagString, p.Summary, p.Content, published)
return err
}
// DeletePost removes a post by slug.
func (s *Store) DeletePost(slug string) error {
_, err := s.db.Exec(`DELETE FROM posts WHERE slug = ?`, slug)
return err
}
// SaveImage inserts image metadata into the database.
func (s *Store) SaveImage(img Image) error {
_, err := s.db.Exec(`INSERT INTO images (filename, original_name, width, height, size, uploaded_at) VALUES (?, ?, ?, ?, ?, ?)`,
img.Filename, img.OriginalName, img.Width, img.Height, img.Size, img.UploadedAt)
return err
}
// ListImages returns all images ordered by upload time descending.
func (s *Store) ListImages() ([]Image, error) {
rows, err := s.db.Query(`SELECT filename, original_name, width, height, size, uploaded_at FROM images ORDER BY uploaded_at DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var images []Image
for rows.Next() {
var img Image
if err := rows.Scan(&img.Filename, &img.OriginalName, &img.Width, &img.Height, &img.Size, &img.UploadedAt); err != nil {
return nil, err
}
images = append(images, img)
}
return images, rows.Err()
}
// DeleteImage removes image metadata from the database.
func (s *Store) DeleteImage(filename string) error {
_, err := s.db.Exec(`DELETE FROM images WHERE filename = ?`, filename)
return err
}
// ParseTags splits a comma-delimited tag string (e.g. ",go,web,") into a slice.
func ParseTags(tagString string) []string {
tagString = strings.Trim(tagString, ",")
if tagString == "" {
return nil
}
parts := strings.Split(tagString, ",")
for i := range parts {
parts[i] = strings.TrimSpace(parts[i])
}
return parts
}