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.
- purebible-0.1.0/PKG-INFO +119 -0
- purebible-0.1.0/README.md +109 -0
- purebible-0.1.0/purebible/__init__.py +11 -0
- purebible-0.1.0/purebible/__main__.py +4 -0
- purebible-0.1.0/purebible/bible.py +367 -0
- purebible-0.1.0/purebible/cli.py +348 -0
- purebible-0.1.0/purebible/refs.py +168 -0
- purebible-0.1.0/purebible/search.py +349 -0
- purebible-0.1.0/purebible/superscriptions.py +148 -0
- purebible-0.1.0/purebible/tui.py +916 -0
- purebible-0.1.0/purebible.egg-info/PKG-INFO +119 -0
- purebible-0.1.0/purebible.egg-info/SOURCES.txt +16 -0
- purebible-0.1.0/purebible.egg-info/dependency_links.txt +1 -0
- purebible-0.1.0/purebible.egg-info/entry_points.txt +3 -0
- purebible-0.1.0/purebible.egg-info/top_level.txt +1 -0
- purebible-0.1.0/pyproject.toml +24 -0
- purebible-0.1.0/setup.cfg +4 -0
- purebible-0.1.0/tests/test_basic.py +60 -0
purebible-0.1.0/PKG-INFO
ADDED
|
@@ -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,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
|