Files
kjv_api/internal/kjv/store.go
T
2026-10-26 20:30:43 -07:00

228 lines
5.5 KiB
Go

package kjv
import (
"database/sql"
"fmt"
"sort"
_ "modernc.org/sqlite"
)
// Verse is one row of the fts_kjv table. ID is book*1_000_000 + chapter*1000 + verse.
type Verse struct {
ID int64
Book int
Chapter int
Verse int
Text string
}
type BookInfo struct {
Title string
Category string
OTNT string
Chapters int
}
// Store holds the companion database plus everything read from it at startup.
// The whole text is only ~4MB, so verse lookups never touch SQLite; the FTS
// index is still queried for keyword search.
type Store struct {
db *sql.DB
verses []Verse // sorted by ID
verseIDs []int64
chapterList []int64 // chapter keys (ID/1000), sorted and unique
bookNames []string
bookName map[int]string
bookByName map[string]int
chapterLen map[int]int
bookInfo []BookInfo
abbrevs []abbrev
stateOK bool // reading plans and shortcodes can be written
}
type abbrev struct {
Name string
Book int
}
func OpenStore(path string) (*Store, error) {
dsn := "file:" + path + "?_pragma=busy_timeout(5000)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
s := &Store{db: db, bookName: map[int]string{}, bookByName: map[string]int{}, chapterLen: map[int]int{}}
if err := s.load(); err != nil {
db.Close()
return nil, fmt.Errorf("%s: %w", path, err)
}
s.stateOK = s.initState() == nil
return s, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) load() error {
rows, err := s.db.Query("SELECT b, n FROM key_english ORDER BY b")
if err != nil {
return err
}
for rows.Next() {
var id int
var name string
if err := rows.Scan(&id, &name); err != nil {
rows.Close()
return err
}
s.bookNames = append(s.bookNames, name)
s.bookName[id] = name
s.bookByName[name] = id
}
rows.Close()
if len(s.bookNames) == 0 {
return fmt.Errorf("key_english is empty")
}
rows, err = s.db.Query("SELECT id, b, c, v, t FROM fts_kjv ORDER BY id")
if err != nil {
return err
}
for rows.Next() {
var v Verse
if err := rows.Scan(&v.ID, &v.Book, &v.Chapter, &v.Verse, &v.Text); err != nil {
rows.Close()
return err
}
s.verses = append(s.verses, v)
}
rows.Close()
if len(s.verses) == 0 {
return fmt.Errorf("fts_kjv is empty")
}
for _, v := range s.verses {
s.verseIDs = append(s.verseIDs, v.ID)
if key := v.ID / 1000; len(s.chapterList) == 0 || s.chapterList[len(s.chapterList)-1] != key {
s.chapterList = append(s.chapterList, key)
}
if v.Chapter > s.chapterLen[v.Book] {
s.chapterLen[v.Book] = v.Chapter
}
}
rows, err = s.db.Query(`SELECT title_short, category, otnt, chapters FROM book_info ORDER BY "order"`)
if err != nil {
return err
}
for rows.Next() {
var b BookInfo
if err := rows.Scan(&b.Title, &b.Category, &b.OTNT, &b.Chapters); err != nil {
rows.Close()
return err
}
s.bookInfo = append(s.bookInfo, b)
}
rows.Close()
rows, err = s.db.Query("SELECT a, b FROM key_abbreviations_english ORDER BY id")
if err != nil {
return err
}
for rows.Next() {
var a abbrev
if err := rows.Scan(&a.Name, &a.Book); err != nil {
rows.Close()
return err
}
s.abbrevs = append(s.abbrevs, a)
}
rows.Close()
return nil
}
// initState creates the tables that hold user-generated data (reading plans and
// shortcodes) inside the companion database.
func (s *Store) initState() error {
for _, stmt := range []string{
"CREATE TABLE IF NOT EXISTS reading_tracker (uid TEXT PRIMARY KEY, verse INTEGER, last_used TEXT)",
"CREATE TABLE IF NOT EXISTS shortcodes (shortcode TEXT PRIMARY KEY, query TEXT UNIQUE, creation_date TEXT)",
} {
if _, err := s.db.Exec(stmt); err != nil {
return err
}
}
return nil
}
func (s *Store) BookName(id int) string { return s.bookName[id] }
func (s *Store) FormatRef(verseID int64) string {
return fmt.Sprintf("%s %d:%d", s.BookName(int(verseID/1_000_000)), verseID/1000%1000, verseID%1000)
}
// Range returns the verses with start <= id <= end (end <= 0 means just start).
func (s *Store) Range(start, end int64) []Verse {
i := sort.Search(len(s.verses), func(i int) bool { return s.verses[i].ID >= start })
if end <= 0 {
if i < len(s.verses) && s.verses[i].ID == start {
return s.verses[i : i+1]
}
return nil
}
j := sort.Search(len(s.verses), func(j int) bool { return s.verses[j].ID > end })
if i >= j {
return nil
}
return s.verses[i:j]
}
// VerseIndex is the position of a verse id in the whole Bible, or -1.
func (s *Store) VerseIndex(id int64) int {
i := sort.Search(len(s.verseIDs), func(i int) bool { return s.verseIDs[i] >= id })
if i < len(s.verseIDs) && s.verseIDs[i] == id {
return i
}
return -1
}
// FTS runs a full text query and returns the matching verses in Bible order.
func (s *Store) FTS(query string) ([]Verse, error) {
rows, err := s.db.Query("SELECT id FROM fts_kjv(?)", query)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Verse
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
if v := s.Range(id, 0); len(v) == 1 {
out = append(out, v[0])
}
}
return out, rows.Err()
}
type crossRef struct{ Start, End int64 }
func (s *Store) CrossReferences(verseID int64) ([]crossRef, error) {
rows, err := s.db.Query("SELECT sv, ev FROM cross_reference WHERE vid = ? ORDER BY r DESC LIMIT 15", verseID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []crossRef
for rows.Next() {
var c crossRef
if err := rows.Scan(&c.Start, &c.End); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}