code.pablomurad.com

patchwork

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

internal/store/revisions.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
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
package store

import (
	"context"
	"database/sql"
	"errors"
	"strings"
	"time"
)

// Revision is a public version of a page. Number 1 is the baseline.
type Revision struct {
	ID          int64
	PageID      int64
	Number      int
	SnapshotID  int64
	Note        string
	Kind        string
	Maturity    string
	PublishedAt time.Time
	UpdatedAt   time.Time
	RetractedAt *time.Time
	// DiffJSON caches the diff against the previous public version, encoded
	// by diff version DiffVersion; rebuild-diffs regenerates it.
	DiffJSON     string
	DiffVersion  int
	WordsAdded   int
	WordsRemoved int
}

// Baseline reports whether this is the first, unannounced version.
func (r *Revision) Baseline() bool { return r.Number == 1 }

const revisionCols = `id, page_id, number, snapshot_id, note, kind, maturity, published_at, updated_at, retracted_at, diff_json, diff_version, words_added, words_removed`

// revisionColsR is revisionCols qualified with the alias r, for joins.
var revisionColsR = "r." + strings.ReplaceAll(revisionCols, ", ", ", r.")

func scanRevision(row interface{ Scan(...any) error }) (*Revision, error) {
	var r Revision
	var published, updated string
	var retracted sql.NullString
	if err := row.Scan(&r.ID, &r.PageID, &r.Number, &r.SnapshotID, &r.Note, &r.Kind, &r.Maturity, &published, &updated,
		&retracted, &r.DiffJSON, &r.DiffVersion, &r.WordsAdded, &r.WordsRemoved); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, ErrNotFound
		}
		return nil, err
	}
	var err error
	if r.PublishedAt, err = parseTime(published); err != nil {
		return nil, err
	}
	if r.UpdatedAt, err = parseTime(updated); err != nil {
		return nil, err
	}
	if r.RetractedAt, err = parseTimePtr(retracted); err != nil {
		return nil, err
	}
	return &r, nil
}

func (q *Queries) revisions(ctx context.Context, query string, args ...any) ([]Revision, error) {
	rows, err := q.q.QueryContext(ctx, query, args...)
	if err != nil {
		return nil, err
	}
	defer rows.Close()
	var out []Revision
	for rows.Next() {
		r, err := scanRevision(rows)
		if err != nil {
			return nil, err
		}
		out = append(out, *r)
	}
	return out, rows.Err()
}

// InsertRevision stores a revision and sets its ID.
func (q *Queries) InsertRevision(ctx context.Context, r *Revision) error {
	res, err := q.q.ExecContext(ctx,
		`INSERT INTO revisions (page_id, number, snapshot_id, note, kind, maturity, published_at, updated_at, retracted_at, diff_json, diff_version, words_added, words_removed)
		 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
		r.PageID, r.Number, r.SnapshotID, r.Note, r.Kind, r.Maturity, fmtTime(r.PublishedAt), fmtTime(r.UpdatedAt),
		fmtTimePtr(r.RetractedAt), r.DiffJSON, r.DiffVersion, r.WordsAdded, r.WordsRemoved)
	if err != nil {
		return err
	}
	r.ID, err = res.LastInsertId()
	return err
}

// UpdateRevision saves every mutable field of a revision.
func (q *Queries) UpdateRevision(ctx context.Context, r *Revision) error {
	_, err := q.q.ExecContext(ctx,
		`UPDATE revisions SET snapshot_id = ?, note = ?, kind = ?, maturity = ?, updated_at = ?, retracted_at = ?,
		 diff_json = ?, diff_version = ?, words_added = ?, words_removed = ? WHERE id = ?`,
		r.SnapshotID, r.Note, r.Kind, r.Maturity, fmtTime(r.UpdatedAt), fmtTimePtr(r.RetractedAt),
		r.DiffJSON, r.DiffVersion, r.WordsAdded, r.WordsRemoved, r.ID)
	return err
}

// LastRevision returns the page's latest revision that isn't retracted.
func (q *Queries) LastRevision(ctx context.Context, pageID int64) (*Revision, error) {
	return scanRevision(q.q.QueryRowContext(ctx,
		`SELECT `+revisionCols+` FROM revisions WHERE page_id = ? AND retracted_at IS NULL ORDER BY number DESC LIMIT 1`, pageID))
}

// RevisionBefore returns the latest revision older than number that isn't
// retracted: the base a revision's diff is computed against.
func (q *Queries) RevisionBefore(ctx context.Context, pageID int64, number int) (*Revision, error) {
	return scanRevision(q.q.QueryRowContext(ctx,
		`SELECT `+revisionCols+` FROM revisions WHERE page_id = ? AND number < ? AND retracted_at IS NULL ORDER BY number DESC LIMIT 1`,
		pageID, number))
}

// RevisionByNumber returns one revision, retracted or not.
func (q *Queries) RevisionByNumber(ctx context.Context, pageID int64, number int) (*Revision, error) {
	return scanRevision(q.q.QueryRowContext(ctx,
		`SELECT `+revisionCols+` FROM revisions WHERE page_id = ? AND number = ?`, pageID, number))
}

// MaxRevisionNumber counts retracted revisions too, so numbers never repeat.
func (q *Queries) MaxRevisionNumber(ctx context.Context, pageID int64) (int, error) {
	var n int
	err := q.q.QueryRowContext(ctx, `SELECT COALESCE(MAX(number), 0) FROM revisions WHERE page_id = ?`, pageID).Scan(&n)
	return n, err
}

// Revisions lists all of a page's revisions by number, retracted included.
func (q *Queries) Revisions(ctx context.Context, pageID int64) ([]Revision, error) {
	return q.revisions(ctx, `SELECT `+revisionCols+` FROM revisions WHERE page_id = ? ORDER BY number`, pageID)
}

// RecentRevisions lists published, non-retracted revisions of active pages,
// newest first. before, when non-zero, pages back through older ones.
func (q *Queries) RecentRevisions(ctx context.Context, limit int, before time.Time) ([]Revision, error) {
	cutoff := ""
	if !before.IsZero() {
		cutoff = fmtTime(before)
	}
	return q.revisions(ctx,
		`SELECT `+revisionColsR+` FROM revisions r JOIN pages p ON p.id = r.page_id
		 WHERE r.number > 1 AND r.retracted_at IS NULL AND p.removed_at IS NULL AND (? = '' OR r.published_at < ?)
		 ORDER BY r.published_at DESC, r.id DESC LIMIT ?`, cutoff, cutoff, limit)
}