summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 20:10:44 +0200
committerLukasz Kasprzak <lukas@labunix.xyz>2026-07-27 20:10:44 +0200
commit643d01c388eece9f3a00d2db16cb421f5483b229 (patch)
treeaba6cbd5abf75d4aa6f8fce465ad31c4259cda0a
parent91b54ab94a0e227eeaf093d34ec2bf2a07978b55 (diff)
downloadlectio-643d01c388eece9f3a00d2db16cb421f5483b229.tar.gz
lectio-643d01c388eece9f3a00d2db16cb421f5483b229.zip
feat(bible): user corpus discovery + override registry
-rw-r--r--internal/bible/bible.go11
-rw-r--r--internal/bible/registry.go110
-rw-r--r--internal/bible/registry_test.go35
-rw-r--r--internal/config/config.go11
4 files changed, 165 insertions, 2 deletions
diff --git a/internal/bible/bible.go b/internal/bible/bible.go
index 1bc8983..ac746e1 100644
--- a/internal/bible/bible.go
+++ b/internal/bible/bible.go
@@ -2,6 +2,7 @@ package bible
import (
"embed"
+ "os"
"sort"
"strconv"
"strings"
@@ -32,8 +33,14 @@ func load(version string) *corpus {
if c, ok := corpora[version]; ok {
return c
}
- data, err := corporaFS.ReadFile("corpora/" + version + ".tsv")
- if err != nil {
+ var data []byte
+ if p := userTSVPath(version); p != "" {
+ data, _ = os.ReadFile(p)
+ }
+ if data == nil {
+ data, _ = corporaFS.ReadFile("corpora/" + version + ".tsv")
+ }
+ if data == nil {
corpora[version] = &corpus{books: map[string]map[int][]Verse{}}
return corpora[version]
}
diff --git a/internal/bible/registry.go b/internal/bible/registry.go
new file mode 100644
index 0000000..2ab721b
--- /dev/null
+++ b/internal/bible/registry.go
@@ -0,0 +1,110 @@
+package bible
+
+import (
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+)
+
+var (
+ regMu sync.Mutex
+ userDir string
+ regBuilt bool
+ regMeta map[string]CorpusMeta // code -> meta (user overrides embed)
+ regUserTSV map[string]string // code -> user .tsv path (override source)
+)
+
+// SetUserCorporaDir sets the drop-in dir and invalidates the registry cache.
+// Empty string disables user corpora (used by tests).
+func SetUserCorporaDir(dir string) {
+ regMu.Lock()
+ userDir = dir
+ regBuilt = false
+ regMu.Unlock()
+
+ corporaMu.Lock()
+ corpora = map[string]*corpus{} // drop cached loads so overrides take effect
+ corporaMu.Unlock()
+}
+
+func buildRegistry() {
+ if regBuilt {
+ return
+ }
+ regMeta = map[string]CorpusMeta{}
+ regUserTSV = map[string]string{}
+ // embedded first
+ ents, _ := corporaFS.ReadDir("corpora")
+ for _, e := range ents {
+ if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok {
+ if m, ok := embeddedCorpusMeta(code); ok {
+ regMeta[code] = m
+ }
+ }
+ }
+ // user dir overrides
+ if userDir != "" {
+ ents, _ := os.ReadDir(userDir)
+ for _, e := range ents {
+ name := e.Name()
+ if code, ok := strings.CutSuffix(name, ".tsv"); ok {
+ regUserTSV[code] = filepath.Join(userDir, name)
+ if _, have := regMeta[code]; !have {
+ regMeta[code] = CorpusMeta{Code: code, PsalmSystem: "vulgate"}
+ }
+ }
+ }
+ for _, e := range ents {
+ if code, ok := strings.CutSuffix(e.Name(), ".ini"); ok {
+ data, err := os.ReadFile(filepath.Join(userDir, e.Name()))
+ if err == nil {
+ regMeta[code] = parseCorpusMeta(code, data)
+ }
+ }
+ }
+ }
+ regBuilt = true
+}
+
+// Corpora returns all known corpora, sorted by code.
+func Corpora() []CorpusMeta {
+ regMu.Lock()
+ defer regMu.Unlock()
+ buildRegistry()
+ out := make([]CorpusMeta, 0, len(regMeta))
+ for _, m := range regMeta {
+ out = append(out, m)
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Code < out[j].Code })
+ return out
+}
+
+// Meta returns a corpus's metadata.
+func Meta(code string) (CorpusMeta, bool) {
+ regMu.Lock()
+ defer regMu.Unlock()
+ buildRegistry()
+ m, ok := regMeta[code]
+ return m, ok
+}
+
+// CorporaForLang returns corpora whose lang == lang, sorted by code.
+func CorporaForLang(lang string) []CorpusMeta {
+ var out []CorpusMeta
+ for _, m := range Corpora() {
+ if m.Lang == lang {
+ out = append(out, m)
+ }
+ }
+ return out
+}
+
+// userTSVPath returns the override .tsv path for code, or "".
+func userTSVPath(code string) string {
+ regMu.Lock()
+ defer regMu.Unlock()
+ buildRegistry()
+ return regUserTSV[code]
+}
diff --git a/internal/bible/registry_test.go b/internal/bible/registry_test.go
new file mode 100644
index 0000000..0e9aab2
--- /dev/null
+++ b/internal/bible/registry_test.go
@@ -0,0 +1,35 @@
+package bible
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+)
+
+func TestUserCorpusOverride(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "drb.tsv"), []byte("Genesis\tGn\t1\t1\t1\tUSER TEXT\n"), 0o644)
+ os.WriteFile(filepath.Join(dir, "drb.ini"), []byte("lang = en\nname = User DRB\npsalm_system = drb\n"), 0o644)
+ SetUserCorporaDir(dir)
+ t.Cleanup(func() { SetUserCorporaDir("") })
+
+ if got := Verses("drb", "Genesis", 1); len(got) == 0 || got[0].Text != "USER TEXT" {
+ t.Fatalf("user drb.tsv did not override embed: %+v", got)
+ }
+ m, _ := Meta("drb")
+ if m.Name != "User DRB" {
+ t.Fatalf("user sidecar not used: %+v", m)
+ }
+}
+
+func TestUserCorpusNewCode(t *testing.T) {
+ dir := t.TempDir()
+ os.WriteFile(filepath.Join(dir, "fr-x.tsv"), []byte("Genesis\tGn\t1\t1\t1\tAu commencement\n"), 0o644)
+ os.WriteFile(filepath.Join(dir, "fr-x.ini"), []byte("lang = fr\nname = Test FR\npsalm_system = vulgate\n"), 0o644)
+ SetUserCorporaDir(dir)
+ t.Cleanup(func() { SetUserCorporaDir("") })
+
+ if l := CorporaForLang("fr"); len(l) != 1 || l[0].Code != "fr-x" {
+ t.Fatalf("new fr corpus not registered: %+v", l)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 0f99a3a..55abd72 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -274,6 +274,17 @@ func CalendarsDir() (string, error) {
return filepath.Join(filepath.Dir(p), "calendars"), nil
}
+// CorporaDir is the user directory for drop-in bible corpora (<code>.tsv +
+// <code>.ini), alongside calendars/ under the lectio config dir. Mirrors
+// CalendarsDir exactly (verified: it uses configPath() then filepath.Dir).
+func CorporaDir() (string, error) {
+ p, _, err := configPath()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(filepath.Dir(p), "corpora"), nil
+}
+
// BooksPath returns the path to the optional user books.ini (in the same
// directory as the config file). There is no embedded seed on disk -- absence
// means "use the built-in defaults".