code.pablomurad.com

patchwork

Publishes the revisions of your pages as a feed, with notes and readable diffs, written in Go

internal/store/pages.go raw
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
}