// 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) }