aboutsummaryrefslogtreecommitdiff
path: root/internal/bookmarks/bookmarks.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/bookmarks/bookmarks.go')
-rw-r--r--internal/bookmarks/bookmarks.go173
1 files changed, 173 insertions, 0 deletions
diff --git a/internal/bookmarks/bookmarks.go b/internal/bookmarks/bookmarks.go
new file mode 100644
index 0000000..4b839ed
--- /dev/null
+++ b/internal/bookmarks/bookmarks.go
@@ -0,0 +1,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)
+}