summaryrefslogtreecommitdiff
path: root/internal/bookmarks/bookmarks.go
blob: 9fe39c112ecda6dd85cd6106800ca28dc7757d88 (plain) (blame)
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
// Package bookmarks is lectio's small persistent store of reader bookmarks
// (a book+chapter with a note and tags), a JSON file in the data dir so the
// web reader -- and, later, the TUI/CLI -- can share it.
package bookmarks

import (
	"encoding/json"
	"fmt"
	"os"
	"path/filepath"
	"sort"
	"strconv"
	"strings"
	"sync"
	"time"
)

// Bookmark is one saved place in the Bible: a book+chapter with a note and tags.
type Bookmark struct {
	ID      string   `json:"id"`
	Book    string   `json:"book"` // canonical English name (Bible-corpus key)
	Chapter int      `json:"chapter"`
	Verse   int      `json:"verse,omitempty"` // 0 = whole chapter; >0 = a specific verse
	Note    string   `json:"note"`
	Tags    []string `json:"tags"`
	Created string   `json:"created"` // RFC3339
}

// Store is the bookmarks file, guarded by a mutex (each op load-modify-saves).
type Store struct {
	path string
	mu   sync.Mutex
}

// Open resolves the store path (${XDG_DATA_HOME:-~/.local/share}/lectio/
// bookmarks.tsv -- a plain, universal TSV like the sigla store) and returns a
// ready Store. It never fails: a missing file simply reads as an empty list.
// A one-time migration converts a pre-existing bookmarks.json to TSV.
func Open() *Store {
	s := &Store{path: dataFile("bookmarks.tsv")}
	s.migrate()
	return s
}

// migrate converts an old bookmarks.json to the TSV store once (backing the
// JSON up as bookmarks.json.migrated), if no TSV file exists yet.
func (s *Store) migrate() {
	if _, err := os.Stat(s.path); err == nil {
		return // TSV already present
	}
	jsonPath := dataFile("bookmarks.json")
	data, err := os.ReadFile(jsonPath)
	if err != nil {
		return // no legacy JSON
	}
	var list []Bookmark
	if len(data) > 0 {
		if err := json.Unmarshal(data, &list); err != nil {
			return
		}
	}
	if s.save(list) == nil {
		_ = os.Rename(jsonPath, jsonPath+".migrated")
	}
}

// ParseTags splits a comma-separated tag string into trimmed, non-empty tags.
func ParseTags(s string) []string {
	var out []string
	for _, t := range strings.Split(s, ",") {
		if t = strings.TrimSpace(t); t != "" {
			out = append(out, t)
		}
	}
	return out
}

// List returns all bookmarks, newest first. A missing file is an empty list,
// not an error. If tag is non-empty, only bookmarks carrying that tag are returned.
func (s *Store) List(tag string) ([]Bookmark, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	all, err := s.load()
	if err != nil {
		return nil, err
	}
	sort.SliceStable(all, func(i, j int) bool { return all[i].Created > all[j].Created })
	if tag == "" {
		return all, nil
	}
	var out []Bookmark
	for _, b := range all {
		for _, t := range b.Tags {
			if t == tag {
				out = append(out, b)
				break
			}
		}
	}
	return out, nil
}

// Add assigns an ID + Created timestamp, appends the bookmark, and saves.
func (s *Store) Add(b Bookmark) (Bookmark, error) {
	s.mu.Lock()
	defer s.mu.Unlock()
	all, err := s.load()
	if err != nil {
		return Bookmark{}, err
	}
	b.ID = strconv.FormatInt(time.Now().UnixNano(), 36)
	// RFC3339Nano (not plain RFC3339): two Adds can land in the same
	// wall-clock second, and List's newest-first sort needs the extra
	// precision to break the tie correctly (RFC3339Nano is still valid
	// RFC3339 -- fractional seconds are permitted, just usually omitted).
	b.Created = time.Now().Format(time.RFC3339Nano)
	all = append(all, b)
	if err := s.save(all); err != nil {
		return Bookmark{}, err
	}
	return b, nil
}

// Delete removes the bookmark with id (a no-op if absent).
func (s *Store) Delete(id string) error {
	s.mu.Lock()
	defer s.mu.Unlock()
	all, err := s.load()
	if err != nil {
		return err
	}
	kept := all[:0]
	for _, b := range all {
		if b.ID != id {
			kept = append(kept, b)
		}
	}
	return s.save(kept)
}

// Tags returns the sorted, de-duplicated set of all tags in use.
func (s *Store) Tags() ([]string, error) {
	all, err := s.List("")
	if err != nil {
		return nil, err
	}
	set := map[string]bool{}
	for _, b := range all {
		for _, t := range b.Tags {
			set[t] = true
		}
	}
	tags := make([]string, 0, len(set))
	for t := range set {
		tags = append(tags, t)
	}
	sort.Strings(tags)
	return tags, nil
}

// bookmarksHeader is the TSV column header (also skipped when reading).
const bookmarksHeader = "id\tbook\tchapter\tverse\ttags\tcreated\tnote"

// tsvClean strips tab/newline characters so free text stays in one TSV field.
func tsvClean(s string) string {
	r := strings.NewReplacer("\t", " ", "\n", " ", "\r", " ")
	return r.Replace(s)
}

func (s *Store) load() ([]Bookmark, error) {
	data, err := os.ReadFile(s.path)
	if err != nil {
		if os.IsNotExist(err) {
			return nil, nil
		}
		return nil, err
	}
	var list []Bookmark
	for _, line := range strings.Split(string(data), "\n") {
		if line == "" || strings.HasPrefix(line, "#") {
			continue
		}
		f := strings.Split(line, "\t")
		if len(f) < 7 || f[0] == "id" { // skip the header row / malformed lines
			continue
		}
		chap, _ := strconv.Atoi(f[2])
		verse, _ := strconv.Atoi(f[3])
		list = append(list, Bookmark{
			ID: f[0], Book: f[1], Chapter: chap, Verse: verse,
			Tags: ParseTags(f[4]), Created: f[5], Note: f[6],
		})
	}
	return list, nil
}

// save writes the store as TSV with a header row (note is the last column, so
// stray content never shifts the columns; tabs/newlines are sanitized).
func (s *Store) save(list []Bookmark) error {
	if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
		return err
	}
	var b strings.Builder
	b.WriteString(bookmarksHeader + "\n")
	for _, bm := range list {
		fmt.Fprintf(&b, "%s\t%s\t%d\t%d\t%s\t%s\t%s\n",
			bm.ID, tsvClean(bm.Book), bm.Chapter, bm.Verse,
			tsvClean(strings.Join(bm.Tags, ",")), bm.Created, tsvClean(bm.Note))
	}
	tmp := s.path + ".tmp"
	if err := os.WriteFile(tmp, []byte(b.String()), 0o644); err != nil {
		return err
	}
	return os.Rename(tmp, s.path)
}