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 |
package store
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base32"
"errors"
"strings"
"time"
)
// Page is one content file, identified by source and path.
type Page struct {
ID int64
UID string // random and permanent; feed entry ids are built from it
Source string // "folder" or "git"
Path string // relative, '/'-separated, NFC
Slug string
// OriginalDate is the calendar date the page was first published,
// YYYY-MM-DD in the site's time zone.
OriginalDate string
FirstSeenAt time.Time
RemovedAt *time.Time
MissingScans int
}
// NewUID returns a random page uid such as "p_k3x9q2m7d4tb".
func NewUID() string {
b := make([]byte, 8)
if _, err := rand.Read(b); err != nil {
panic(err)
}
return "p_" + strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(b))[:12]
}
const pageCols = `id, uid, source, path, slug, original_date, first_seen_at, removed_at, missing_scans`
func scanPage(row interface{ Scan(...any) error }) (*Page, error) {
var p Page
var first string
var removed sql.NullString
if err := row.Scan(&p.ID, &p.UID, &p.Source, &p.Path, &p.Slug, &p.OriginalDate, &first, &removed, &p.MissingScans); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, err
}
var err error
if p.FirstSeenAt, err = parseTime(first); err != nil {
return nil, err
}
if p.RemovedAt, err = parseTimePtr(removed); err != nil {
return nil, err
}
return &p, nil
}
// PageByPath finds a page, removed or not.
func (q *Queries) PageByPath(ctx context.Context, source, path string) (*Page, error) {
return scanPage(q.q.QueryRowContext(ctx, `SELECT `+pageCols+` FROM pages WHERE source = ? AND path = ?`, source, path))
}
// PageBySlug finds the active page with this slug. When two files share a
// slug (doctor reports it), the one seen first wins.
func (q *Queries) PageBySlug(ctx context.Context, slug string) (*Page, error) {
return scanPage(q.q.QueryRowContext(ctx,
`SELECT `+pageCols+` FROM pages WHERE slug = ? AND removed_at IS NULL ORDER BY first_seen_at, id LIMIT 1`, slug))
}
// Pages lists pages ordered by path.
func (q *Queries) Pages(ctx context.Context, source string, includeRemoved bool) ([]Page, error) {
query := `SELECT ` + pageCols + ` FROM pages WHERE (? = '' OR source = ?)`
if !includeRemoved {
query += ` AND removed_at IS NULL`
}
rows, err := q.q.QueryContext(ctx, query+` ORDER BY path`, source, source)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Page
for rows.Next() {
p, err := scanPage(rows)
if err != nil {
return nil, err
}
out = append(out, *p)
}
return out, rows.Err()
}
// InsertPage stores a new page and sets its ID.
func (q *Queries) InsertPage(ctx context.Context, p *Page) error {
res, err := q.q.ExecContext(ctx,
`INSERT INTO pages (uid, source, path, slug, original_date, first_seen_at, removed_at, missing_scans) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
p.UID, p.Source, p.Path, p.Slug, p.OriginalDate, fmtTime(p.FirstSeenAt), fmtTimePtr(p.RemovedAt), p.MissingScans)
if err != nil {
return err
}
p.ID, err = res.LastInsertId()
return err
}
// UpdatePage saves the mutable fields of a page.
func (q *Queries) UpdatePage(ctx context.Context, p *Page) error {
_, err := q.q.ExecContext(ctx,
`UPDATE pages SET slug = ?, original_date = ?, removed_at = ?, missing_scans = ? WHERE id = ?`,
p.Slug, p.OriginalDate, fmtTimePtr(p.RemovedAt), p.MissingScans, p.ID)
return err
}
|