diff options
Diffstat (limited to 'internal/bookmarks')
| -rw-r--r-- | internal/bookmarks/bookmarks.go | 173 | ||||
| -rw-r--r-- | internal/bookmarks/bookmarks_test.go | 46 |
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) + } +} |
