aboutsummaryrefslogtreecommitdiff
path: root/internal/bookmarks
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 15:16:47 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 15:16:47 +0200
commit1e4939874a14d8abb13e82b2273bfc5f9d653295 (patch)
tree9c9eecb37fad3dc78d5bd8f58bc405d6f9cbd672 /internal/bookmarks
parente6665bd3f9ae011f65788004e4eab4472a461e8f (diff)
downloadlectio-1e4939874a14d8abb13e82b2273bfc5f9d653295.tar.gz
lectio-1e4939874a14d8abb13e82b2273bfc5f9d653295.zip
bookmarks: web reader bookmarks with notes + tags (add/list/filter/jump/delete); v0.15.0
Diffstat (limited to 'internal/bookmarks')
-rw-r--r--internal/bookmarks/bookmarks.go173
-rw-r--r--internal/bookmarks/bookmarks_test.go46
2 files changed, 219 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)
+}
diff --git a/internal/bookmarks/bookmarks_test.go b/internal/bookmarks/bookmarks_test.go
new file mode 100644
index 0000000..40e10e0
--- /dev/null
+++ b/internal/bookmarks/bookmarks_test.go
@@ -0,0 +1,46 @@
+package bookmarks
+
+import (
+ "testing"
+)
+
+func TestStoreRoundTrip(t *testing.T) {
+ t.Setenv("XDG_DATA_HOME", t.TempDir())
+ s := Open()
+
+ if got, _ := s.List(""); len(got) != 0 {
+ t.Fatalf("empty store should list nothing, got %d", len(got))
+ }
+ b, err := s.Add(Bookmark{Book: "John", Chapter: 3, Note: "for God so loved", Tags: []string{"grace", "gospel"}})
+ if err != nil || b.ID == "" || b.Created == "" {
+ t.Fatalf("add: %+v err=%v", b, err)
+ }
+ if _, err := s.Add(Bookmark{Book: "Luke", Chapter: 15, Tags: []string{"gospel"}}); err != nil {
+ t.Fatal(err)
+ }
+
+ all, _ := s.List("")
+ if len(all) != 2 || all[0].Book != "Luke" { // newest first
+ t.Errorf("list = %+v", all)
+ }
+ tagged, _ := s.List("grace")
+ if len(tagged) != 1 || tagged[0].Book != "John" {
+ t.Errorf("tag filter = %+v", tagged)
+ }
+ if tags, _ := s.Tags(); len(tags) != 2 || tags[0] != "gospel" {
+ t.Errorf("tags = %v", tags)
+ }
+ if err := s.Delete(b.ID); err != nil {
+ t.Fatal(err)
+ }
+ if all, _ := s.List(""); len(all) != 1 || all[0].Book != "Luke" {
+ t.Errorf("after delete = %+v", all)
+ }
+}
+
+func TestParseTags(t *testing.T) {
+ got := ParseTags(" grace , gospel ,, mercy ")
+ if len(got) != 3 || got[0] != "grace" || got[2] != "mercy" {
+ t.Errorf("ParseTags = %v", got)
+ }
+}