diff options
| -rw-r--r-- | internal/bookmarks/bookmarks.go | 73 | ||||
| -rw-r--r-- | internal/bookmarks/bookmarks_test.go | 23 | ||||
| -rw-r--r-- | internal/config/config.go | 2 | ||||
| -rw-r--r-- | internal/tui/reader.go | 83 |
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 |
