From 1e4939874a14d8abb13e82b2273bfc5f9d653295 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 15:16:47 +0200 Subject: bookmarks: web reader bookmarks with notes + tags (add/list/filter/jump/delete); v0.15.0 --- internal/bookmarks/bookmarks.go | 173 ++++++++++++++++++++++++++++++++++ internal/bookmarks/bookmarks_test.go | 46 +++++++++ internal/config/config.go | 2 +- internal/web/server.go | 94 +++++++++++++++++- internal/web/server_test.go | 34 +++++++ internal/web/static/base.css | 7 ++ internal/web/templates/bookmarks.html | 50 ++++++++++ internal/web/templates/index.html | 1 + internal/web/templates/reader.html | 9 ++ 9 files changed, 414 insertions(+), 2 deletions(-) create mode 100644 internal/bookmarks/bookmarks.go create mode 100644 internal/bookmarks/bookmarks_test.go create mode 100644 internal/web/templates/bookmarks.html 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) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index a4d2c3e..0648b66 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -25,7 +25,7 @@ var seedTOML []byte // Version is lectio's release version, shared by every binary's // -v/--version output (lectio, lectio-ui, lectio-web). -const Version = "0.14.0" +const Version = "0.15.0" // validVersions are the five scripture versions lectio understands. var validVersions = map[string]bool{ diff --git a/internal/web/server.go b/internal/web/server.go index d7f0ebc..cd50e5a 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -12,6 +12,7 @@ import ( "io/fs" "net" "net/http" + "net/url" "os" "os/exec" "regexp" @@ -22,6 +23,7 @@ import ( "time" "github.com/lukaszkasprzak/lectio/internal/bible" + "github.com/lukaszkasprzak/lectio/internal/bookmarks" "github.com/lukaszkasprzak/lectio/internal/config" "github.com/lukaszkasprzak/lectio/internal/i18n" "github.com/lukaszkasprzak/lectio/internal/liturgy" @@ -40,6 +42,7 @@ type server struct { mu sync.RWMutex cfg config.Config tbl *bible.BookTable + bm *bookmarks.Store } func (s *server) get() config.Config { s.mu.RLock(); defer s.mu.RUnlock(); return s.cfg } @@ -59,7 +62,7 @@ func (s *server) apply(cfg config.Config, tbl *bible.BookTable) { // live, mutable copy held by server, which every handler reads per request. func NewServer(cfg config.Config) http.Handler { tbl, _ := bible.LoadBookTable(config.UserBooksTOML()) - s := &server{cfg: cfg, tbl: tbl} + s := &server{cfg: cfg, tbl: tbl, bm: bookmarks.Open()} mux := http.NewServeMux() mux.HandleFunc("GET /{$}", func(w http.ResponseWriter, r *http.Request) { indexHandler(s.get())(w, r) }) @@ -68,6 +71,9 @@ func NewServer(cfg config.Config) http.Handler { mux.HandleFunc("GET /theme.css", func(w http.ResponseWriter, r *http.Request) { themeCSSHandler(s.get())(w, r) }) mux.HandleFunc("GET /settings", settingsGet(s)) mux.HandleFunc("POST /settings", settingsPost(s)) + mux.HandleFunc("POST /reader/bookmark", addBookmark(s)) + mux.HandleFunc("GET /bookmarks", listBookmarks(s)) + mux.HandleFunc("POST /bookmarks/delete", deleteBookmark(s)) staticSub, err := fs.Sub(staticFS, "static") if err != nil { @@ -403,6 +409,7 @@ func atoiDefault(s string, def int) int { type readerData struct { BookOpts []bookOpt + Book string // canonical name of the selected book (for the bookmark-add form) Chap int PrevChap, NextChap int ChapOpts []chapOpt @@ -488,6 +495,7 @@ func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { data := readerData{ BookOpts: bookOpts, + Book: info.Canonical, Chap: chap, PrevChap: prev, NextChap: next, @@ -509,6 +517,90 @@ func readerHandler(cfg config.Config, tbl *bible.BookTable) http.HandlerFunc { } } +// addBookmark saves the current reader book+chapter with a note and tags, then +// returns to the reader at that location. +func addBookmark(s *server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + book := r.PostForm.Get("book") + chap := atoiOr(r.PostForm.Get("chap"), 0) + if book != "" && chap > 0 { + _, _ = s.bm.Add(bookmarks.Bookmark{ + Book: book, + Chapter: chap, + Note: strings.TrimSpace(r.PostForm.Get("note")), + Tags: bookmarks.ParseTags(r.PostForm.Get("tags")), + }) + } + http.Redirect(w, r, "/reader?book="+url.QueryEscape(book)+"&chap="+strconv.Itoa(chap), http.StatusSeeOther) + } +} + +// bookmarkView is one row of templates/bookmarks.html. +type bookmarkView struct { + ID string + Book string // canonical (for the ?book= open link) + Display string // dialect display name + Chapter int + Note string + Tags []string + Created string +} + +type bookmarksData struct { + Items []bookmarkView + AllTags []string + Tag string // active filter ("" = all) + L i18n.UI + Lang string +} + +// listBookmarks renders the bookmarks page, optionally filtered by ?tag=. +func listBookmarks(s *server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + cfg := s.get() + tag := r.URL.Query().Get("tag") + items, err := s.bm.List(tag) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + names := map[string]string{} + for _, b := range s.table().Books(cfg.SiglaLang()) { + names[b.Canonical] = b.Name + } + views := make([]bookmarkView, 0, len(items)) + for _, b := range items { + disp := names[b.Book] + if disp == "" { + disp = b.Book + } + views = append(views, bookmarkView{ID: b.ID, Book: b.Book, Display: disp, Chapter: b.Chapter, Note: b.Note, Tags: b.Tags, Created: b.Created}) + } + allTags, _ := s.bm.Tags() + data := bookmarksData{Items: views, AllTags: allTags, Tag: tag, L: i18n.Get(cfg.UILanguage), Lang: cfg.UILanguage} + w.Header().Set("Content-Type", "text/html; charset=utf-8") + if err := tmpl.ExecuteTemplate(w, "bookmarks.html", data); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } + } +} + +// deleteBookmark removes a bookmark and returns to the list. +func deleteBookmark(s *server) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + _ = s.bm.Delete(r.PostForm.Get("id")) + http.Redirect(w, r, "/bookmarks", http.StatusSeeOther) + } +} + // themeCSSHandler serves one theme's stylesheet: the requested name, or // (unknown/invalid) cfg.WebTheme, or (that also unknown) the built-in // default -- so an unrecognized ?name= degrades to a working theme instead diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 61546b8..f6c050b 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -597,3 +597,37 @@ func TestSettingsPostEmptyBooksPreservesFile(t *testing.T) { t.Errorf("empty books submit clobbered books.toml: got %q", got) } } + +func TestBookmarksFlow(t *testing.T) { + t.Setenv("XDG_DATA_HOME", t.TempDir()) + srv := NewServer(config.Default()) + + // Add via the reader form. + form := url.Values{} + form.Set("book", "John") + form.Set("chap", "3") + form.Set("note", "for God so loved") + form.Set("tags", "grace, gospel") + post := httptest.NewRequest("POST", "/reader/bookmark", strings.NewReader(form.Encode())) + post.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + srv.ServeHTTP(rec, post) + if rec.Code != http.StatusSeeOther { + t.Fatalf("add status %d", rec.Code) + } + + // Listed on /bookmarks. + list := httptest.NewRecorder() + srv.ServeHTTP(list, httptest.NewRequest("GET", "/bookmarks", nil)) + b := list.Body.String() + if !strings.Contains(b, "John") || !strings.Contains(b, "for God so loved") || !strings.Contains(b, `book=John&chap=3`) { + t.Errorf("bookmark not listed:\n%s", b) + } + + // Tag filter. + byTag := httptest.NewRecorder() + srv.ServeHTTP(byTag, httptest.NewRequest("GET", "/bookmarks?tag=grace", nil)) + if !strings.Contains(byTag.Body.String(), "John") { + t.Errorf("tag filter dropped the bookmark") + } +} diff --git a/internal/web/static/base.css b/internal/web/static/base.css index c8e1bd4..36f2f4d 100644 --- a/internal/web/static/base.css +++ b/internal/web/static/base.css @@ -285,3 +285,10 @@ a { .settings .row { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: center; } .settings textarea { width: 100%; font-family: var(--font-mono); } .settings .saved { font-family: var(--font-ui); } + +/* Bookmarks. */ +.bookmark-add { display: flex; flex-wrap: wrap; gap: var(--space-1); align-items: center; margin: var(--space-2) 0; font-family: var(--font-ui); } +.bookmarks { display: flex; flex-direction: column; gap: var(--space-2); font-family: var(--font-ui); } +.bookmark { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: baseline; } +.bookmark .tags { font-size: 0.85rem; } +.tag-filter { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-bottom: var(--space-2); font-family: var(--font-ui); } diff --git a/internal/web/templates/bookmarks.html b/internal/web/templates/bookmarks.html new file mode 100644 index 0000000..f51515b --- /dev/null +++ b/internal/web/templates/bookmarks.html @@ -0,0 +1,50 @@ +{{/* bookmarks.html — lectio-web /bookmarks page: the saved book+chapter + bookmarks (note + tags), filterable by tag, each with an "open" link + back into the reader and its own delete form. A plain page like + index.html/settings.html's head; no htmx round trip needed for the + list itself (a plain GET with ?tag=), each delete is its own POST form. */}} + + + + + +lectio — bookmarks + + + + +
+ +

+ ← reader + home +

+ + + + {{if not .Items}} +

No bookmarks yet.

+ {{else}} +
+ {{range .Items}} +
+ {{.Display}} {{.Chapter}} + {{if .Note}}{{.Note}}{{end}} + {{if .Tags}}{{range .Tags}}#{{.}} {{end}}{{end}} +
+ + +
+
+ {{end}} +
+ {{end}} + +
+ + diff --git a/internal/web/templates/index.html b/internal/web/templates/index.html index 3058746..d3416e9 100644 --- a/internal/web/templates/index.html +++ b/internal/web/templates/index.html @@ -63,6 +63,7 @@ {{.L.NavReader}} → settings + bookmarks