aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:39:35 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-24 16:39:35 +0200
commit102bf0c522c94d36f8363c38711621fe5ad18555 (patch)
treec5be424abf0b2a2316e318502ff698f6029228a5
parent3eb99c1dd63565fbc1f5bf5af7fc8e835aad182e (diff)
downloadlectio-102bf0c522c94d36f8363c38711621fe5ad18555.tar.gz
lectio-102bf0c522c94d36f8363c38711621fe5ad18555.zip
bookmarks: store as universal TSV (header row) with one-time JSON migration; tui note box wraps long notes with hanging indent + hard-break; v0.21.0
-rw-r--r--internal/bookmarks/bookmarks.go73
-rw-r--r--internal/bookmarks/bookmarks_test.go23
-rw-r--r--internal/config/config.go2
-rw-r--r--internal/tui/reader.go83
4 files changed, 160 insertions, 21 deletions
diff --git a/internal/bookmarks/bookmarks.go b/internal/bookmarks/bookmarks.go
index 55a9e55..9fe39c1 100644
--- a/internal/bookmarks/bookmarks.go
+++ b/internal/bookmarks/bookmarks.go
@@ -5,6 +5,7 @@ package bookmarks
import (
"encoding/json"
+ "fmt"
"os"
"path/filepath"
"sort"
@@ -32,10 +33,35 @@ type Store struct {
}
// 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.
+// 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 {
- return &Store{path: dataFile("bookmarks.json")}
+ 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.
@@ -132,6 +158,15 @@ func (s *Store) Tags() ([]string, error) {
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 {
@@ -141,25 +176,39 @@ func (s *Store) load() ([]Bookmark, error) {
return nil, err
}
var list []Bookmark
- if len(data) == 0 {
- return nil, nil
- }
- if err := json.Unmarshal(data, &list); err != nil {
- return nil, err
+ 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
}
- data, err := json.MarshalIndent(list, "", " ")
- if 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, data, 0o644); err != nil {
+ if err := os.WriteFile(tmp, []byte(b.String()), 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
index 40e10e0..337d50d 100644
--- a/internal/bookmarks/bookmarks_test.go
+++ b/internal/bookmarks/bookmarks_test.go
@@ -1,6 +1,8 @@
package bookmarks
import (
+ "os"
+ "path/filepath"
"testing"
)
@@ -44,3 +46,24 @@ func TestParseTags(t *testing.T) {
t.Errorf("ParseTags = %v", got)
}
}
+
+func TestMigrateJSONToTSV(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("XDG_DATA_HOME", dir)
+ os.MkdirAll(filepath.Join(dir, "lectio"), 0o755)
+ if err := os.WriteFile(filepath.Join(dir, "lectio", "bookmarks.json"),
+ []byte(`[{"id":"a1","book":"John","chapter":3,"verse":16,"note":"a\tnote","tags":["grace"],"created":"2026-07-24T10:00:00Z"}]`), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s := Open() // migrates JSON -> TSV
+ got, _ := s.List("")
+ if len(got) != 1 || got[0].Book != "John" || got[0].Verse != 16 || got[0].Note != "a note" {
+ t.Fatalf("migrated = %+v (tab in note should be sanitized)", got)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "lectio", "bookmarks.tsv")); err != nil {
+ t.Errorf("tsv not written: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(dir, "lectio", "bookmarks.json.migrated")); err != nil {
+ t.Errorf("json not backed up: %v", err)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index d21cf5c..8847678 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.20.0"
+const Version = "0.21.0"
// validVersions are the five scripture versions lectio understands.
var validVersions = map[string]bool{
diff --git a/internal/tui/reader.go b/internal/tui/reader.go
index 72b7d0a..df92349 100644
--- a/internal/tui/reader.go
+++ b/internal/tui/reader.go
@@ -741,21 +741,88 @@ func (m ReaderModel) View() string {
}
// viewMark renders the bookmark note/tags box as a prominent centered dialog.
+// The note and tags wrap (with a hanging indent) within a bounded width, so a
+// long note flows down inside the box instead of overrunning the border.
func (m ReaderModel) viewMark() string {
ui := i18n.Get(m.cfg.UILanguage)
b := m.books[m.bookIdx]
- title := modalTitleStyle.Render(fmt.Sprintf("★ %s %d:%d", b.Name, m.currentChapter(), m.markVerse))
- note := " " + ui.ReaderMarkNote + ": " + m.markNote
- tags := " " + ui.ReaderMarkTags + ": " + m.markTags
- if m.markField == 0 {
- note += "▏"
- } else {
- tags += "▏"
+ cw := m.width - 12
+ if cw > 56 {
+ cw = 56
+ }
+ if cw < 24 {
+ cw = 24
}
- inner := title + "\n\n" + note + "\n" + tags + "\n\n" + citationStyle.Render(ui.ReaderMarkHelp)
+ title := modalTitleStyle.Render(fmt.Sprintf("★ %s %d:%d", b.Name, m.currentChapter(), m.markVerse))
+ lines := wrapField(ui.ReaderMarkNote, m.markNote, cw, m.markField == 0)
+ lines = append(lines, wrapField(ui.ReaderMarkTags, m.markTags, cw, m.markField == 1)...)
+ inner := title + "\n\n" + strings.Join(lines, "\n") + "\n\n" + citationStyle.Render(ui.ReaderMarkHelp)
return m.modal(inner)
}
+// wrapField renders "label: value" wrapping value to width with a hanging
+// indent under the label; a cursor is appended when the field is active.
+func wrapField(label, value string, width int, active bool) []string {
+ prefix := label + ": "
+ pw := len([]rune(prefix))
+ text := value
+ if active {
+ text += "▏"
+ }
+ tw := width - pw
+ if tw < 8 {
+ tw = 8
+ }
+ wrapped := hardWrap(text, tw)
+ indent := strings.Repeat(" ", pw)
+ out := make([]string, 0, len(wrapped))
+ for i, ln := range wrapped {
+ if i == 0 {
+ out = append(out, prefix+ln)
+ } else {
+ out = append(out, indent+ln)
+ }
+ }
+ return out
+}
+
+// hardWrap wraps on spaces but, unlike render.Wrap, also hard-breaks a single
+// token longer than width (notes can contain arbitrary unbroken text).
+func hardWrap(s string, width int) []string {
+ if width < 1 {
+ width = 1
+ }
+ var lines []string
+ cur := ""
+ for _, word := range strings.Fields(s) {
+ for len([]rune(word)) > width {
+ if cur != "" {
+ lines = append(lines, cur)
+ cur = ""
+ }
+ r := []rune(word)
+ lines = append(lines, string(r[:width]))
+ word = string(r[width:])
+ }
+ switch {
+ case cur == "":
+ cur = word
+ case len([]rune(cur))+1+len([]rune(word)) <= width:
+ cur += " " + word
+ default:
+ lines = append(lines, cur)
+ cur = word
+ }
+ }
+ if cur != "" {
+ lines = append(lines, cur)
+ }
+ if len(lines) == 0 {
+ return []string{""}
+ }
+ return lines
+}
+
// viewBookmarks renders the saved-bookmarks list.
func (m ReaderModel) viewBookmarks() string {
w := m.width