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
|
// 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"
"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"`
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.json, mirroring the sigla store) and returns a ready Store. It never
// fails: a missing file simply reads as an empty list.
func Open() *Store {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
home = "."
}
base = filepath.Join(home, ".local", "share")
}
return &Store{path: filepath.Join(base, "lectio", "bookmarks.json")}
}
// 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
}
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
if len(data) == 0 {
return nil, nil
}
if err := json.Unmarshal(data, &list); err != nil {
return nil, err
}
return list, nil
}
func (s *Store) save(list []Bookmark) error {
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
|