purebible 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,119 @@
1
+ Metadata-Version: 2.4
2
+ Name: purebible
3
+ Version: 0.1.0
4
+ Summary: PureBible terminal client for nvim/btop/terminal lovers — vim-keys, btop-style TUI, zero dependencies
5
+ License-Expression: GPL-3.0-or-later
6
+ Project-URL: Homepage, https://github.com/ripekern/purebible-py
7
+ Project-URL: Repository, https://github.com/ripekern/purebible-py.git
8
+ Requires-Python: >=3.9
9
+ Description-Content-Type: text/markdown
10
+
11
+ # purebible (Python) — for people who live in nvim, btop & the terminal
12
+
13
+ Zero-dependency Python remake of the `purebible` terminal client.
14
+ Vim keys. btop-style bars. Pipes like a good Unix citizen. No Qt, no build.
15
+
16
+ ```
17
+ purebible "God | Jesus" # OR search
18
+ purebible "God Jesus" # exact phrase
19
+ purebible "James* & John*" # AND in same verse (new: old CLI gave 0 rows)
20
+ purebible "John 3:16" # smart-dispatch → lookup
21
+ purebible lookup "Rom 12:1-2" # ranges + whole chapters ("John 3")
22
+ purebible tui "love*" # fullscreen: j/k, /, :, n/N, y, q
23
+ ```
24
+
25
+ ## Install from GitHub (Arch Linux)
26
+
27
+ ```sh
28
+ sudo pacman -S python-pipx # isolated CLI installs, the Arch-friendly way
29
+ pipx install git+https://github.com/ripekern/purebible-py.git
30
+ mkdir -p ~/.local/share/purebible # KJV text, 9 MB, public domain
31
+ curl -L https://raw.githubusercontent.com/dewhisna/KingJamesPureBibleSearch/master/text/complete/SW1769Bible_both.txt \
32
+ -o ~/.local/share/purebible/SW1769Bible_both.txt
33
+ purebible "John 3:16" # verify
34
+ purebible tui # full-screen mode
35
+ ```
36
+
37
+ No AUR package (yet). `pipx` keeps it off system python — Arch is
38
+ externally-managed, so plain `pip` would need `--break-system-packages`.
39
+ Without pipx: `pip install --user --break-system-packages
40
+ git+https://github.com/ripekern/purebible-py.git`. Zero runtime
41
+ dependencies either way (curses ships with Arch's `python`).
42
+
43
+ ## Install for development
44
+
45
+ ```sh
46
+ cd ~/Work/purebible-py
47
+ pip install -e . --break-system-packages # gives `purebible` + short alias `pb`
48
+ # or without install:
49
+ python3 -m purebible "John 3:16"
50
+ ```
51
+
52
+ Needs the KJV text (ships with purebiblesearch, 8.8 MB) — one-time setup:
53
+
54
+ ```sh
55
+ mkdir -p ~/.local/share/purebible
56
+ ln -s ~/Work/purebiblesearch/text/complete/SW1769Bible_both.txt \
57
+ ~/.local/share/purebible/SW1769Bible_both.txt
58
+ # alternatives: export PUREBIBLE_TEXT=/path/to/SW1769Bible_both.txt,
59
+ # or copy it to ./data/ or ~/.config/purebible/
60
+ ```
61
+
62
+ ## Why this exists
63
+
64
+ `purebible-cli` (C++/Qt) is exact but heavy: CMake, Qt6, daemon
65
+ sockets, 30 MB servers. This is the same *terminal client* rewritten in
66
+ pure Python for terminal-first users:
67
+
68
+ - **nvim**: `purebible` prints `ref: text`, one per line — `:r !pb "Rom 12:1-2"`,
69
+ quickfix-friendly, `--refs-only` + `fzf` ready, `--no-color` for pipes.
70
+ - **btop**: `purebible tui` is a live dashboard — header stats bar,
71
+ reverse-video statusline, instant ms timings.
72
+ - **terminal**: nvim-style NORMAL/INSERT/COMMAND modes (`/` fresh search,
73
+ `i` caret before current query, `a` caret after it, arrows + `C-u`/`C-w`
74
+ editing, `:` for commands, `Esc` drops back to NORMAL and never quits — quitting
75
+ is `q`, `ZZ`/`ZQ` or `:q`, NORMAL mode only), `:` opens a centered
76
+ noice-style popup in the upper third, and the bar stays minimal —
77
+ mode tag + current search only (confirmations flash briefly).
78
+ Jumplist (`Enter` opens a
79
+ chapter, `C-o` jumps back), `↑`/`↓` prompt history, `C-u`/`C-w` line
80
+ editing, even `:w` politely refuses. Lualine-style mode tags and refs
81
+ colored for your system theme (auto-detected,
82
+ `PUREBIBLE_THEME=dark|light` to override), `:help` opens a scrollable
83
+ screen with the key list + every search and lookup pattern, `:clear` to
84
+ clear results, `NO_COLOR` respected, works over SSH with zero deps.
85
+
86
+ ## Query language
87
+
88
+ | syntax | meaning | example |
89
+ |---|---|---|
90
+ | `a b` | phrase (consecutive) | `"God said"` → 9 hits |
91
+ | `a \| b` | OR (union) | `"God \| Jesus"` |
92
+ | `a & b` | AND (same verse) | `"James* & John*"` |
93
+ | `a & -b` | NOT (verse must lack `b`) | `"love & -loved"` |
94
+ | `love* ?eth [a-z]` | wildcards per word | `"four*"` |
95
+ | `\c` / `\C` | vim case override (`\C` sensitive) | `"Amen\C"` → 77 |
96
+ | bare `*` | any single word gap | `"God * heaven"` |
97
+
98
+ Flags mirror the old client: `-c/--case`, `-A/--abbrev`,
99
+ `-w/--no-wordindex`, `-d/--no-dup`, `--comma`, `--refs-only`,
100
+ `--book/--chapter/--verse` (scope for `&`), plus `--count`, `--limit N`.
101
+
102
+ ## License
103
+
104
+ Open source. Code is GPL-3.0-or-later (same as the project it was ported
105
+ from); the King James Bible text itself is public domain.
106
+
107
+ ## Notes / differences
108
+
109
+ - Native build ships **KJV 1769 only** (`bibles` → `1`). Asking for another
110
+ id prints a note and maps to 1 (the old Qt backend had 40+).
111
+ - Search runs on the whole-Bible word stream like KJPBS (phrases may span
112
+ verse boundaries); `*` inside a phrase is a single-word skip. Hyphenated
113
+ compounds (`Bar-jesus`), ligatures (`Cæsar`→`Caesar`), psalm
114
+ superscriptions and Pauline colophons all match the C++ concordance —
115
+ verified identical counts and word indexes on a battery of queries
116
+ (`Jesus` 973, `God` 4444, `David` 930 deduped, …). `-y` enables
117
+ hyphen-sensitive mode.
118
+ - `&` is new (old CLI treated it as a literal word → always 0). `|` and
119
+ phrase/wildcard semantics match the original.
@@ -0,0 +1,109 @@
1
+ # purebible (Python) — for people who live in nvim, btop & the terminal
2
+
3
+ Zero-dependency Python remake of the `purebible` terminal client.
4
+ Vim keys. btop-style bars. Pipes like a good Unix citizen. No Qt, no build.
5
+
6
+ ```
7
+ purebible "God | Jesus" # OR search
8
+ purebible "God Jesus" # exact phrase
9
+ purebible "James* & John*" # AND in same verse (new: old CLI gave 0 rows)
10
+ purebible "John 3:16" # smart-dispatch → lookup
11
+ purebible lookup "Rom 12:1-2" # ranges + whole chapters ("John 3")
12
+ purebible tui "love*" # fullscreen: j/k, /, :, n/N, y, q
13
+ ```
14
+
15
+ ## Install from GitHub (Arch Linux)
16
+
17
+ ```sh
18
+ sudo pacman -S python-pipx # isolated CLI installs, the Arch-friendly way
19
+ pipx install git+https://github.com/ripekern/purebible-py.git
20
+ mkdir -p ~/.local/share/purebible # KJV text, 9 MB, public domain
21
+ curl -L https://raw.githubusercontent.com/dewhisna/KingJamesPureBibleSearch/master/text/complete/SW1769Bible_both.txt \
22
+ -o ~/.local/share/purebible/SW1769Bible_both.txt
23
+ purebible "John 3:16" # verify
24
+ purebible tui # full-screen mode
25
+ ```
26
+
27
+ No AUR package (yet). `pipx` keeps it off system python — Arch is
28
+ externally-managed, so plain `pip` would need `--break-system-packages`.
29
+ Without pipx: `pip install --user --break-system-packages
30
+ git+https://github.com/ripekern/purebible-py.git`. Zero runtime
31
+ dependencies either way (curses ships with Arch's `python`).
32
+
33
+ ## Install for development
34
+
35
+ ```sh
36
+ cd ~/Work/purebible-py
37
+ pip install -e . --break-system-packages # gives `purebible` + short alias `pb`
38
+ # or without install:
39
+ python3 -m purebible "John 3:16"
40
+ ```
41
+
42
+ Needs the KJV text (ships with purebiblesearch, 8.8 MB) — one-time setup:
43
+
44
+ ```sh
45
+ mkdir -p ~/.local/share/purebible
46
+ ln -s ~/Work/purebiblesearch/text/complete/SW1769Bible_both.txt \
47
+ ~/.local/share/purebible/SW1769Bible_both.txt
48
+ # alternatives: export PUREBIBLE_TEXT=/path/to/SW1769Bible_both.txt,
49
+ # or copy it to ./data/ or ~/.config/purebible/
50
+ ```
51
+
52
+ ## Why this exists
53
+
54
+ `purebible-cli` (C++/Qt) is exact but heavy: CMake, Qt6, daemon
55
+ sockets, 30 MB servers. This is the same *terminal client* rewritten in
56
+ pure Python for terminal-first users:
57
+
58
+ - **nvim**: `purebible` prints `ref: text`, one per line — `:r !pb "Rom 12:1-2"`,
59
+ quickfix-friendly, `--refs-only` + `fzf` ready, `--no-color` for pipes.
60
+ - **btop**: `purebible tui` is a live dashboard — header stats bar,
61
+ reverse-video statusline, instant ms timings.
62
+ - **terminal**: nvim-style NORMAL/INSERT/COMMAND modes (`/` fresh search,
63
+ `i` caret before current query, `a` caret after it, arrows + `C-u`/`C-w`
64
+ editing, `:` for commands, `Esc` drops back to NORMAL and never quits — quitting
65
+ is `q`, `ZZ`/`ZQ` or `:q`, NORMAL mode only), `:` opens a centered
66
+ noice-style popup in the upper third, and the bar stays minimal —
67
+ mode tag + current search only (confirmations flash briefly).
68
+ Jumplist (`Enter` opens a
69
+ chapter, `C-o` jumps back), `↑`/`↓` prompt history, `C-u`/`C-w` line
70
+ editing, even `:w` politely refuses. Lualine-style mode tags and refs
71
+ colored for your system theme (auto-detected,
72
+ `PUREBIBLE_THEME=dark|light` to override), `:help` opens a scrollable
73
+ screen with the key list + every search and lookup pattern, `:clear` to
74
+ clear results, `NO_COLOR` respected, works over SSH with zero deps.
75
+
76
+ ## Query language
77
+
78
+ | syntax | meaning | example |
79
+ |---|---|---|
80
+ | `a b` | phrase (consecutive) | `"God said"` → 9 hits |
81
+ | `a \| b` | OR (union) | `"God \| Jesus"` |
82
+ | `a & b` | AND (same verse) | `"James* & John*"` |
83
+ | `a & -b` | NOT (verse must lack `b`) | `"love & -loved"` |
84
+ | `love* ?eth [a-z]` | wildcards per word | `"four*"` |
85
+ | `\c` / `\C` | vim case override (`\C` sensitive) | `"Amen\C"` → 77 |
86
+ | bare `*` | any single word gap | `"God * heaven"` |
87
+
88
+ Flags mirror the old client: `-c/--case`, `-A/--abbrev`,
89
+ `-w/--no-wordindex`, `-d/--no-dup`, `--comma`, `--refs-only`,
90
+ `--book/--chapter/--verse` (scope for `&`), plus `--count`, `--limit N`.
91
+
92
+ ## License
93
+
94
+ Open source. Code is GPL-3.0-or-later (same as the project it was ported
95
+ from); the King James Bible text itself is public domain.
96
+
97
+ ## Notes / differences
98
+
99
+ - Native build ships **KJV 1769 only** (`bibles` → `1`). Asking for another
100
+ id prints a note and maps to 1 (the old Qt backend had 40+).
101
+ - Search runs on the whole-Bible word stream like KJPBS (phrases may span
102
+ verse boundaries); `*` inside a phrase is a single-word skip. Hyphenated
103
+ compounds (`Bar-jesus`), ligatures (`Cæsar`→`Caesar`), psalm
104
+ superscriptions and Pauline colophons all match the C++ concordance —
105
+ verified identical counts and word indexes on a battery of queries
106
+ (`Jesus` 973, `God` 4444, `David` 930 deduped, …). `-y` enables
107
+ hyphen-sensitive mode.
108
+ - `&` is new (old CLI treated it as a literal word → always 0). `|` and
109
+ phrase/wildcard semantics match the original.
@@ -0,0 +1,11 @@
1
+ """purebible — Python terminal client for the King James Pure Bible Search.
2
+
3
+ Zero-dependency, for people who live in nvim / btop / tmux.
4
+ """
5
+ from .bible import Bible, Verse, find_text_file
6
+ from .search import search, parse_query
7
+ from .refs import parse_reference, resolve_reference
8
+
9
+ __all__ = ["Bible", "Verse", "find_text_file", "search", "parse_query",
10
+ "parse_reference", "resolve_reference"]
11
+ __version__ = "0.1.0"
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -0,0 +1,367 @@
1
+ """Bible text model + SW1769 text loader (stdlib only).
2
+
3
+ Source of truth is the plain-text edition shipped with purebiblesearch:
4
+ text/complete/SW1769Bible_both.txt
5
+
6
+ Format per verse (lines may wrap — a block starts with '@' and ends
7
+ with a trailing '@'):
8
+
9
+ $$$Gen.1.1
10
+ @<plain, no pilcrows, no markup>@
11
+ @<rich, with ¶ pilcrows + <i>…</i> italics>@
12
+
13
+ We keep both: plain for searching, rich (converted to the familiar
14
+ ``¶`` / ``[…]`` style used by KJVSearch) for display.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import html
19
+ import os
20
+ import re
21
+ import unicodedata
22
+ from dataclasses import dataclass, field
23
+
24
+ # SW-code -> (Full name, short/abbrev)
25
+ BOOKS: list[tuple[str, str, str]] = [
26
+ ("Gen", "Genesis", "Gen"),
27
+ ("Exod", "Exodus", "Ex"),
28
+ ("Lev", "Leviticus", "Lev"),
29
+ ("Num", "Numbers", "Num"),
30
+ ("Deut", "Deuteronomy", "Deut"),
31
+ ("Josh", "Joshua", "Josh"),
32
+ ("Judg", "Judges", "Judg"),
33
+ ("Ruth", "Ruth", "Ruth"),
34
+ ("1Sam", "1 Samuel", "1Sam"),
35
+ ("2Sam", "2 Samuel", "2Sam"),
36
+ ("1Kgs", "1 Kings", "1Kgs"),
37
+ ("2Kgs", "2 Kings", "2Kgs"),
38
+ ("1Chr", "1 Chronicles", "1Chr"),
39
+ ("2Chr", "2 Chronicles", "2Chr"),
40
+ ("Ezra", "Ezra", "Ezra"),
41
+ ("Neh", "Nehemiah", "Neh"),
42
+ ("Esth", "Esther", "Esth"),
43
+ ("Job", "Job", "Job"),
44
+ ("Ps", "Psalms", "Ps"),
45
+ ("Prov", "Proverbs", "Prov"),
46
+ ("Eccl", "Ecclesiastes", "Eccl"),
47
+ ("Song", "Song Of Solomon", "Song"),
48
+ ("Isa", "Isaiah", "Isa"),
49
+ ("Jer", "Jeremiah", "Jer"),
50
+ ("Lam", "Lamentations", "Lam"),
51
+ ("Ezek", "Ezekiel", "Ezek"),
52
+ ("Dan", "Daniel", "Dan"),
53
+ ("Hos", "Hosea", "Hos"),
54
+ ("Joel", "Joel", "Joel"),
55
+ ("Amos", "Amos", "Amos"),
56
+ ("Obad", "Obadiah", "Obad"),
57
+ ("Jonah", "Jonah", "Jonah"),
58
+ ("Mic", "Micah", "Mic"),
59
+ ("Nah", "Nahum", "Nah"),
60
+ ("Hab", "Habakkuk", "Hab"),
61
+ ("Zeph", "Zephaniah", "Zeph"),
62
+ ("Hag", "Haggai", "Hag"),
63
+ ("Zech", "Zechariah", "Zech"),
64
+ ("Mal", "Malachi", "Mal"),
65
+ ("Matt", "Matthew", "Matt"),
66
+ ("Mark", "Mark", "Mark"),
67
+ ("Luke", "Luke", "Luke"),
68
+ ("John", "John", "John"),
69
+ ("Acts", "Acts", "Acts"),
70
+ ("Rom", "Romans", "Rom"),
71
+ ("1Cor", "1 Corinthians", "1Cor"),
72
+ ("2Cor", "2 Corinthians", "2Cor"),
73
+ ("Gal", "Galatians", "Gal"),
74
+ ("Eph", "Ephesians", "Eph"),
75
+ ("Phil", "Philippians", "Phil"),
76
+ ("Col", "Colossians", "Col"),
77
+ ("1Thess", "1 Thessalonians", "1Thess"),
78
+ ("2Thess", "2 Thessalonians", "2Thess"),
79
+ ("1Tim", "1 Timothy", "1Tim"),
80
+ ("2Tim", "2 Timothy", "2Tim"),
81
+ ("Titus", "Titus", "Titus"),
82
+ ("Phlm", "Philemon", "Phlm"),
83
+ ("Heb", "Hebrews", "Heb"),
84
+ ("Jas", "James", "Jas"),
85
+ ("1Pet", "1 Peter", "1Pet"),
86
+ ("2Pet", "2 Peter", "2Pet"),
87
+ ("1John", "1 John", "1Jn"),
88
+ ("2John", "2 John", "2Jn"),
89
+ ("3John", "3 John", "3Jn"),
90
+ ("Jude", "Jude", "Jude"),
91
+ ("Rev", "Revelation", "Rev"),
92
+ ]
93
+
94
+ BOOK_ORDER = [code for code, _, _ in BOOKS]
95
+ FULLNAME = {code: full for code, full, _ in BOOKS}
96
+ ABBREV = {code: ab for code, _, ab in BOOKS}
97
+
98
+ _WORD_RE = re.compile(r"[A-Za-z0-9']+")
99
+ _WORD_RE_HYPHEN = re.compile(r"[A-Za-z0-9'\-]+")
100
+
101
+ ITALIC_RE = re.compile(r"<i>(.*?)</i>", re.DOTALL)
102
+ TRANS_RE = re.compile(r"<transChange[^>]*>(.*?)</transChange>", re.DOTALL)
103
+ FONT_RE = re.compile(r"</?font[^>]*>", re.DOTALL)
104
+ TAG_RE = re.compile(r"<[^>]+>")
105
+ _CORE_RE = re.compile(r"^([^A-Za-z0-9']*)([A-Za-z0-9']+)(.*)$", re.DOTALL)
106
+
107
+ # ligatures NFKD won't split + dash characters (hyphen, en/em dashes, …)
108
+ _LIGATURES = {"æ": "ae", "Æ": "Ae", "œ": "oe", "Œ": "Oe", "ß": "ss", "ẞ": "SS"}
109
+ _QUOTES = {"‘": "'", "’": "'", "‚": "'", "“": '"', "”": '"', "„": '"'}
110
+ _DASHES = "‐‑‒–—―" # U+2010 U+2011 U+2012 U+2013 U+2014 U+2015
111
+
112
+
113
+ def fold_text(text: str, hyphen_sensitive: bool = False) -> str:
114
+ """Normalize for searching: split ligatures, strip accents, handle dashes.
115
+
116
+ Matches the C++ default (hyphen-insensitive): hyphenated compounds are
117
+ single words (``Bar-jesus`` → ``Barjesus``), so ``Jesus`` won't match
118
+ inside them. Hyphen-sensitive keeps ``-`` inside words instead.
119
+ """
120
+ for a, b in _LIGATURES.items():
121
+ text = text.replace(a, b)
122
+ for a, b in _QUOTES.items():
123
+ text = text.replace(a, b)
124
+ text = "".join(c for c in unicodedata.normalize("NFKD", text)
125
+ if not unicodedata.combining(c))
126
+ if hyphen_sensitive:
127
+ for d in _DASHES:
128
+ text = text.replace(d, "-")
129
+ else:
130
+ text = text.replace("-", "")
131
+ for d in _DASHES:
132
+ text = text.replace(d, "")
133
+ return text
134
+
135
+
136
+ def _bracket_words(inner: str) -> str:
137
+ """Wrap each word of an added-text span: ``tarry: for`` → ``[tarry]: [for]``.
138
+
139
+ Punctuation stays outside the brackets, like the C++ richifier (which
140
+ tags whole concordance words, never the punctuation around them).
141
+ """
142
+ out = []
143
+ for tok in inner.split():
144
+ m = _CORE_RE.match(tok)
145
+ out.append(f"{m.group(1)}[{m.group(2)}]{m.group(3)}" if m else tok)
146
+ return " ".join(out)
147
+
148
+
149
+ def display_text(rich_block: str) -> str:
150
+ """Convert a rich block to KJVSearch-style display text (¶ + […] style)."""
151
+ t = rich_block.strip()
152
+ if t.startswith("@"):
153
+ t = t[1:]
154
+ if t.endswith("@"):
155
+ t = t[:-1]
156
+ t = t.replace("\n", " ")
157
+ t = ITALIC_RE.sub(lambda m: _bracket_words(m.group(1)), t)
158
+ t = TRANS_RE.sub(lambda m: _bracket_words(m.group(1)), t)
159
+ # red-letter (<font color="red">) and small-caps LORD (<font size="-1">):
160
+ # keep inner text, drop the tags (terminal has no red-letter type)
161
+ t = FONT_RE.sub("", t)
162
+ t = TAG_RE.sub("", t)
163
+ t = html.unescape(t)
164
+ return re.sub(r"\s+", " ", t).strip()
165
+
166
+
167
+ def plain_text(plain_block: str) -> str:
168
+ """Searchable text: all markup stripped, inner words kept."""
169
+ t = plain_block.strip()
170
+ if t.startswith("@"):
171
+ t = t[1:]
172
+ if t.endswith("@"):
173
+ t = t[:-1]
174
+ t = t.replace("\n", " ")
175
+ t = TAG_RE.sub("", t)
176
+ t = html.unescape(t)
177
+ return re.sub(r"\s+", " ", t).strip()
178
+
179
+
180
+ def tokenize(text: str, hyphen_sensitive: bool = False) -> list[str]:
181
+ return (_WORD_RE_HYPHEN if hyphen_sensitive else _WORD_RE).findall(
182
+ fold_text(text, hyphen_sensitive))
183
+
184
+
185
+ @dataclass
186
+ class Verse:
187
+ book: str # SW code, e.g. "Gen"
188
+ chapter: int
189
+ verse: int # 0 = superscription/colophon (extra-biblical title)
190
+ text: str # display text (¶ + […] style)
191
+ plain: str = "" # search text (no markup)
192
+ words: list[str] = field(default_factory=list) # tokenized plain words
193
+ lowered: list[str] = field(default_factory=list)
194
+ kind: str = "verse" # "verse" | "superscription" | "colophon"
195
+
196
+ def __post_init__(self) -> None:
197
+ if not self.plain:
198
+ self.plain = re.sub(r"[¶\[\]]", "", self.text)
199
+ self.plain = re.sub(r"\s+", " ", self.plain).strip()
200
+ if not self.words:
201
+ self.words = tokenize(self.plain)
202
+ self.lowered = [w.lower() for w in self.words]
203
+
204
+ @property
205
+ def book_name(self) -> str:
206
+ return FULLNAME.get(self.book, self.book)
207
+
208
+ def ref(self, abbreviated: bool = False) -> str:
209
+ name = ABBREV.get(self.book, self.book) if abbreviated else FULLNAME.get(self.book, self.book)
210
+ if self.kind == "superscription":
211
+ return f"{name} {self.chapter} Superscription"
212
+ if self.kind == "colophon":
213
+ return f"{name} Colophon"
214
+ return f"{name} {self.chapter}:{self.verse}"
215
+
216
+
217
+ def find_text_file(explicit: str | None = None) -> str | None:
218
+ """Locate SW1769Bible_both.txt. Returns path or None."""
219
+ candidates: list[str] = []
220
+ if explicit:
221
+ candidates.append(explicit)
222
+ env = os.environ.get("PUREBIBLE_TEXT")
223
+ if env:
224
+ candidates.append(env)
225
+ here = os.path.dirname(os.path.abspath(__file__))
226
+ candidates += [
227
+ os.path.join(os.getcwd(), "data", "SW1769Bible_both.txt"),
228
+ os.path.join(here, "..", "data", "SW1769Bible_both.txt"),
229
+ os.path.expanduser("~/.local/share/purebible/SW1769Bible_both.txt"),
230
+ os.path.expanduser("~/.config/purebible/SW1769Bible_both.txt"),
231
+ # original checkout locations (dev convenience)
232
+ os.path.expanduser("~/Work/purebiblesearch/text/complete/SW1769Bible_both.txt"),
233
+ ]
234
+ # legacy env used by the C++ tools
235
+ base = os.environ.get("KJPBS_BASE_PATH")
236
+ if base:
237
+ candidates.append(os.path.join(base, "SW1769Bible_both.txt"))
238
+ for c in candidates:
239
+ if c and os.path.isfile(c):
240
+ return c
241
+ return None
242
+
243
+
244
+ class Bible:
245
+ """Full KJV text with per-verse token cache."""
246
+
247
+ def __init__(self, verses: list[Verse]):
248
+ self.verses = verses
249
+ self._index: dict[tuple[str, int, int], Verse] = {
250
+ (v.book, v.chapter, v.verse): v for v in verses
251
+ }
252
+ self._chapters: dict[tuple[str, int], list[Verse]] = {}
253
+ for v in verses:
254
+ if v.kind == "verse":
255
+ self._chapters.setdefault((v.book, v.chapter), []).append(v)
256
+
257
+ @classmethod
258
+ def load(cls, path: str | None = None) -> "Bible":
259
+ found = find_text_file(path)
260
+ if not found:
261
+ raise FileNotFoundError(
262
+ "SW1769Bible_both.txt not found. Set PUREBIBLE_TEXT=/path/to/SW1769Bible_both.txt "
263
+ "or copy it to ./data/ or ~/.local/share/purebible/."
264
+ )
265
+ return cls.load_file(found)
266
+
267
+ @classmethod
268
+ def load_file(cls, path: str) -> "Bible":
269
+ verses: list[Verse] = []
270
+ with open(path, encoding="utf-8", errors="replace") as fh:
271
+ cur_ref: str | None = None
272
+ buf: list[str] = []
273
+ blocks: list[str] = [] # completed @…@ blocks for cur_ref
274
+ in_block = False
275
+
276
+ def flush_block() -> None:
277
+ if buf:
278
+ blocks.append("\n".join(buf))
279
+ buf.clear()
280
+
281
+ for raw in fh:
282
+ line = raw.rstrip("\n")
283
+ if line.startswith("$$$"):
284
+ if cur_ref is not None and blocks:
285
+ verses.append(_make_verse(cur_ref, blocks))
286
+ cur_ref = line[3:].strip()
287
+ blocks = []
288
+ buf = []
289
+ in_block = False
290
+ elif line.startswith("@") and not in_block:
291
+ # start of a block; may also end on same line
292
+ if line.endswith("@") and len(line) > 1:
293
+ blocks.append(line)
294
+ else:
295
+ in_block = True
296
+ buf = [line]
297
+ elif in_block:
298
+ buf.append(line)
299
+ if line.endswith("@"):
300
+ flush_block()
301
+ in_block = False
302
+ elif line == "":
303
+ continue
304
+ if cur_ref is not None and blocks:
305
+ verses.append(_make_verse(cur_ref, blocks))
306
+ _attach_extras(verses)
307
+ return cls(verses)
308
+
309
+ def lookup(self, book: str, chapter: int, verse: int) -> Verse | None:
310
+ return self._index.get((book, chapter, verse))
311
+
312
+ def chapter(self, book: str, chapter: int) -> list[Verse]:
313
+ return list(self._chapters.get((book, chapter), []))
314
+
315
+ def book_chapters(self, book: str) -> list[int]:
316
+ return sorted({ch for (b, ch) in self._chapters if b == book})
317
+
318
+ def __len__(self) -> int:
319
+ return len(self.verses)
320
+
321
+
322
+ def _make_extra(book: str, chapter: int, kind: str, raw: str) -> Verse:
323
+ """Build a searchable superscription/colophon entry from OSIS inner text."""
324
+ v = Verse(book=book, chapter=chapter, verse=0, kind=kind,
325
+ text=display_text(raw), plain=plain_text(raw))
326
+ v.__post_init__()
327
+ return v
328
+
329
+
330
+ def _attach_extras(verses: list[Verse]) -> None:
331
+ """Insert psalm superscriptions (before ch.1) and Pauline colophons
332
+ (after the book's last verse) so they search exactly like the C++ engine.
333
+ Lookup/chapter views only contain kind == "verse" and are unaffected."""
334
+ from .superscriptions import COLOPHONS, SUPERSCRIPTIONS
335
+ # superscriptions: insert before the chapter's first verse
336
+ pending = dict(SUPERSCRIPTIONS)
337
+ out: list[Verse] = []
338
+ for v in verses:
339
+ key = (v.book, v.chapter)
340
+ if v.kind == "verse" and key in pending:
341
+ out.append(_make_extra(v.book, v.chapter, "superscription", pending.pop(key)))
342
+ out.append(v)
343
+ verses[:] = out
344
+ # colophons: append after the book's last verse
345
+ last_idx: dict[str, int] = {}
346
+ for i, v in enumerate(verses):
347
+ last_idx[v.book] = i
348
+ offset = 0
349
+ for book in BOOK_ORDER:
350
+ if book in COLOPHONS and book in last_idx:
351
+ pos = last_idx[book] + 1 + offset
352
+ verses.insert(pos, _make_extra(book, 0, "colophon", COLOPHONS[book]))
353
+ offset += 1
354
+
355
+
356
+ def _make_verse(ref: str, blocks: list[str]) -> Verse:
357
+ # ref like "Gen.1.1"
358
+ parts = ref.split(".")
359
+ book = parts[0]
360
+ chapter = int(parts[1]) if len(parts) > 1 else 1
361
+ verse = int(parts[2]) if len(parts) > 2 else 1
362
+ plain = plain_text(blocks[0]) if len(blocks) >= 1 else ""
363
+ rich = blocks[1] if len(blocks) >= 2 else blocks[0]
364
+ disp = display_text(rich)
365
+ v = Verse(book=book, chapter=chapter, verse=verse, text=disp, plain=plain)
366
+ v.__post_init__()
367
+ return v