From 102bf0c522c94d36f8363c38711621fe5ad18555 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Fri, 24 Jul 2026 16:39:35 +0200 Subject: 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 --- internal/bookmarks/bookmarks.go | 73 ++++++++++++++++++++++++++++++------ internal/bookmarks/bookmarks_test.go | 23 ++++++++++++ 2 files changed, 84 insertions(+), 12 deletions(-) (limited to 'internal/bookmarks') 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) + } +} -- cgit v1.3