first commit

This commit is contained in:
2026-09-15 00:16:23 +01:00
commit 6e5fd427f7
33 changed files with 4026 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# SCM syntax highlighting & preventing 3-way merges
pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff
+441
View File
@@ -0,0 +1,441 @@
# pixi environments
.pixi/*
!.pixi/config.toml
# Generated by ignr: github.com/onyx-and-iris/ignr
## Python ##
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
#poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
#pdm.lock
#pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
#pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
.pixi/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Cursor
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
# refer to https://docs.cursor.com/context/ignore-files
.cursorignore
.cursorindexingignore
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# End of ignr
# Generated by ignr: github.com/onyx-and-iris/ignr
## Python ##
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[codz]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py.cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
# Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
# poetry.lock
# poetry.toml
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
# pdm.lock
# pdm.toml
.pdm-python
.pdm-build/
# pixi
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
# pixi.lock
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
# in the .venv directory. It is recommended not to include this directory in version control.
.pixi
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# Redis
*.rdb
*.aof
*.pid
# RabbitMQ
mnesia/
rabbitmq/
rabbitmq-data/
# ActiveMQ
activemq-data/
# SageMath parsed files
*.sage.py
# Environments
.env
.envrc
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
# .idea/
# Abstra
# Abstra is an AI-powered process automation framework.
# Ignore directories containing user credentials, local state, and settings.
# Learn more at https://abstra.io/docs
.abstra/
# Visual Studio Code
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
# and can be added to the global gitignore or merged into this file. However, if you prefer,
# you could uncomment the following to ignore the entire vscode folder
# .vscode/
# Temporary file for partial code execution
tempCodeRunnerFile.py
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
# Marimo
marimo/_static/
marimo/_lsp/
__marimo__/
# Streamlit
.streamlit/secrets.toml
# End of ignr
bible_cache.db
+8
View File
@@ -0,0 +1,8 @@
repos:
- repo: local
hooks:
- id: sync-pixi-pyproject
name: Sync pixi.toml and pyproject.toml
entry: pixi run sync
language: system
stages: [pre-commit]
+4
View File
@@ -0,0 +1,4 @@
{
"python-envs.defaultEnvManager": "renan-r-santos.pixi-code:pixi",
"python-envs.defaultPackageManager": "renan-r-santos.pixi-code:pixi"
}
+65
View File
@@ -0,0 +1,65 @@
# Bible CLI/TUI - *Powered by API.Bible*
[![Pixi](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/prefix-dev/pixi/main/assets/badge/v0.json&style=flat-square)](https://pixi.sh)
[![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff)
---
### Install
#### Clone the repository, and then
*with uv*
```console
uv tool install .
```
*with pipx*
```console
pipx install .
```
---
### Run as a CLI
```console
bible-cli book chapter Genesis 1
```
![cli](./img/cli.png)
### Run as a TUI
```console
bible-tui
```
![tui](./img/tui.png)
---
### Requirements
- An API key from [API Bible][api-bible]
### Configure
Example .env:
```env
BIBLE_API_KEY="<api-key>"
BIBLE_BIBLE_NAME="New King James Version"
BIBLE_BOOK_NAME='Genesis'
BIBLE_ENDPOINT='https://rest.api.bible'
BIBLE_THEME="onyx-light"
```
> Note, although cache expiry days is configurable it should NOT be set above 30 days, as per the terms and conditions of the API. See [Acceptable Use][acceptable-use]
---
[api-bible]: https://api.bible
[acceptable-use]: https://api.bible/terms-and-conditions#acceptable_use
Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Executable
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Generated
+1604
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
[workspace]
channels = ["https://prefix.dev/conda-forge"]
platforms = ["win-64", "linux-64", "osx-arm64", "osx-64"]
preview = ["pixi-build"]
[tasks]
cli = "bible-cli"
tui = "bible-tui"
sync = "python scripts/sync_manifests.py"
fmt = "ruff format"
[dependencies]
tomlkit = ">=0.15.1,<0.16"
ruff = ">=0.16.7,<0.17"
[dependencies.bible]
path = "."
[package]
name = "bible"
version = "0.1.0"
[package.build.backend]
name = "pixi-build-python"
version = "0.4.*"
[package.host-dependencies]
hatchling = "==1.26.3"
[package.run-dependencies]
textual = ">=8.0.0"
niquests = ">=3.21.1,<4"
clypi = ">=1.9.1,<2"
pydantic-settings = ">=2.15.0,<3"
rich = ">=15.0.0,<16"
loguru = ">=0.7.3,<0.8"
beautifulsoup4 = ">=4.15.0,<5"
+21
View File
@@ -0,0 +1,21 @@
[project]
name = "bible"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"textual>=8.0.0",
"niquests>=3.21.1,<4",
"clypi>=1.9.1,<2",
"pydantic-settings>=2.15.0,<3",
"rich>=15.0.0,<16",
"loguru>=0.7.3,<0.8",
"beautifulsoup4>=4.15.0,<5",
]
[project.scripts]
bible-tui = "bible.tui.tui:main"
bible-cli = "bible.cli.cli:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
+77
View File
@@ -0,0 +1,77 @@
# Exclude a variety of commonly ignored directories.
exclude = [
".bzr",
".direnv",
".eggs",
".git",
".git-rewrite",
".hg",
".ipynb_checkpoints",
".mypy_cache",
".nox",
".pants.d",
".pyenv",
".pytest_cache",
".pytype",
".ruff_cache",
".svn",
".tox",
".venv",
".vscode",
"__pypackages__",
"_build",
"buck-out",
"build",
"dist",
"node_modules",
"site-packages",
"venv",
]
# Same as Black.
line-length = 88
indent-width = 4
# Assume Python 3.12
target-version = "py312"
[lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.
# Unlike Flake8, Ruff doesn't enable pycodestyle warnings (`W`) or
# McCabe complexity (`C901`) by default.
select = ["E4", "E7", "E9", "F"]
ignore = []
# Allow fix for all enabled rules (when `--fix`) is provided.
fixable = ["ALL"]
unfixable = []
# Allow unused variables when underscore-prefixed.
dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
[format]
# Unlike Black, use single quotes for strings.
quote-style = "single"
# Like Black, indent with spaces, rather than tabs.
indent-style = "space"
# Like Black, respect magic trailing commas.
skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
# Enable auto-formatting of code examples in docstrings. Markdown,
# reStructuredText code/literal blocks and doctests are all supported.
#
# This is currently disabled by default, but it is planned for this
# to be opt-out in the future.
docstring-code-format = false
# Set the line length limit used when formatting code snippets in
# docstrings.
#
# This only has an effect when the `docstring-code-format` setting is
# enabled.
docstring-code-line-length = "dynamic"
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env python3
import pathlib
import tomllib
import tomlkit
root = pathlib.Path(__file__).resolve().parents[1]
pixi_path = root / 'pixi.toml'
pyproject_path = root / 'pyproject.toml'
pixi = tomllib.loads(pixi_path.read_text())
pyproject = tomllib.loads(pyproject_path.read_text())
# --- Sync version ---
pixi_version = pixi['package']['version']
pyproject['project']['version'] = pixi_version
# --- Sync only [package.run-dependencies] ---
run_deps = pixi.get('package', {}).get('run-dependencies', {})
runtime_deps = [f'{k}{v}' if isinstance(v, str) else k for k, v in run_deps.items()]
pyproject['project']['dependencies'] = runtime_deps
# --- Write back ---
pixi_path.write_text(tomlkit.dumps(pixi))
pyproject_path.write_text(tomlkit.dumps(pyproject))
print(
f'Synced version {pixi_version} and {len(runtime_deps)} runtime dependencies from [package.run-dependencies].'
)
View File
+43
View File
@@ -0,0 +1,43 @@
from niquests import AsyncSession
from .settings import settings
class BibleAPI:
async def __aenter__(self):
self.session = AsyncSession(
base_url=settings.ENDPOINT, headers={'api-key': settings.API_KEY}
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
await self.session.close()
async def get(self, path: str, params: dict | None = None):
response = await self.session.get(path, params=params)
response.raise_for_status()
return response.json()
async def get_bibles(self):
return await self.get('/v1/bibles')
async def get_books(self, bible_id: str):
return await self.get(f'/v1/bibles/{bible_id}/books')
async def get_chapters(self, bible_id: str, book_id: str):
return await self.get(f'/v1/bibles/{bible_id}/books/{book_id}/chapters')
async def get_verses(self, bible_id: str, chapter_id: str):
return await self.get(f'/v1/bibles/{bible_id}/chapters/{chapter_id}/verses')
async def get_verse(self, bible_id: str, verse_id: str):
return await self.get(f'/v1/bibles/{bible_id}/verses/{verse_id}')
async def get_chapter(self, bible_id: str, chapter_id: str):
return await self.get(f'/v1/bibles/{bible_id}/chapters/{chapter_id}')
async def get_passage(self, bible_id: str, passage_id: str):
return await self.get(f'/v1/bibles/{bible_id}/passages/{passage_id}')
async def get_section(self, bible_id: str, section_id: str):
return await self.get(f'/v1/bibles/{bible_id}/sections/{section_id}')
+59
View File
@@ -0,0 +1,59 @@
from typing import final
from clypi import Command, arg
from loguru import logger
from typing_extensions import override
from bible.api import BibleAPI
from bible.settings import settings
from bible.sqlite import cache_json, get_cached, init_db
from .commands import Book, List
class BibleCli(Command):
"""Bible CLI — interact with Bible.API"""
subcommand: Book | List | None
api_key: str = arg(
settings.API_KEY,
help='The API key for the Bible CLI',
group='Connection',
)
bible_name: str = arg(
settings.BIBLE_NAME,
help='The name of the Bible to retrieve books from',
group='Connection',
)
@final
@classmethod
def epilog(cls):
return 'Attribution:\n Data provided by Bible.API — https://api.bible.com'
@override
async def pre_run_hook(self):
"""Hook to run before the main command execution."""
init_db()
key = 'bibles:list'
if get_cached(key):
logger.debug('Using cached list of Bibles.')
return
logger.debug('Fetching list of Bibles from the API.')
async with BibleAPI() as api:
response = await api.get_bibles()
data = response.get('data', [])
if not data:
raise ValueError('Unable to fetch list of Bibles from the API.')
cache_json(key, data)
@override
async def run(self):
"""Run the appropriate subcommand based on user input."""
def main():
cli = BibleCli()
cli.parse().start()
+4
View File
@@ -0,0 +1,4 @@
from .book import Book
from .list import List
__all__ = ['Book', 'List']
+49
View File
@@ -0,0 +1,49 @@
from typing import final
from clypi import Command, arg
from loguru import logger
from typing_extensions import override
from bible.api import BibleAPI
from bible.sqlite import cache_json, load_json
from .book_commands import Chapter, Chapters, List, Verse, Verses
class Book(Command):
"""Manage Bible books, including listing and retrieving chapters."""
subcommand: List | Chapter | Chapters | Verse | Verses
bible_name: str = arg(inherited=True)
@final
@classmethod
def epilog(cls):
return 'Attribution:\n Data provided by Bible.API — https://api.bible.com'
@override
async def pre_run_hook(self):
"""Hook to run before the main command execution."""
if not self.bible_name:
raise ValueError("The 'bible_name' flag is required.")
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
key = f'books:list:{self.bible_name}'
books = load_json(key)
if books:
logger.debug(f"Using cached list of Bible books for '{self.bible_name}'.")
return
logger.debug(f"Fetching list of Bible books for '{self.bible_name}'.")
async with BibleAPI() as api:
response = await api.get_books(bible.get('id', ''))
data = response.get('data', [])
if not data:
raise ValueError('Unable to fetch list of Bible books from the API.')
cache_json(key, data)
@@ -0,0 +1,5 @@
from .chapter import Chapter, Chapters
from .list import List
from .verse import Verse, Verses
__all__ = ['Chapter', 'Chapters', 'List', 'Verse', 'Verses']
@@ -0,0 +1,268 @@
import asyncio
from typing import final
from clypi import Command, Positional, arg
from loguru import logger
from typing_extensions import override
from bible import console
from bible.api import BibleAPI
from bible.render import generate_summary, render_chapter
from bible.sqlite import cache_json, load_json
class Chapter(Command):
"""Retrieve a Bible chapter."""
bible_name: str = arg(inherited=True)
book_name: Positional[str] = arg(
help='The name of the Bible book to retrieve',
)
chapter_number: Positional[int | str] = arg(
help='The number of the chapter to retrieve',
)
@final
@classmethod
def epilog(cls):
return 'Attribution:\n Data provided by Bible.API — https://api.bible.com'
@override
async def pre_run_hook(self):
"""Hook to run before the main command execution."""
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
books = load_json(f'books:list:{self.bible_name}')
for book in books:
if book.get('name', '') == self.book_name:
break
else:
raise ValueError(f"Book with name '{self.book_name}' not found.")
key = f'chapters:list:{self.bible_name}:{self.book_name}'
chapters = load_json(key)
if chapters:
logger.debug(
f"Using cached list of chapters for '{self.bible_name}:{self.book_name}'."
)
else:
async with BibleAPI() as api:
response = await api.get_chapters(bible['id'], book['id'])
chapters = response.get('data', [])
if not chapters:
raise ValueError(f"No chapters found for '{self.book_name}'.")
cache_json(key, chapters)
@override
async def run(self):
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
chapters = load_json(f'chapters:list:{self.bible_name}:{self.book_name}')
for chapter in chapters:
number = chapter.get('number', None)
if number in ('intro',):
if self.chapter_number == 'intro':
break
continue
try:
if self.chapter_number == int(number):
break
except ValueError:
logger.warning(
f"Invalid chapter number '{number}' for '{self.bible_name}:{self.book_name}'. Skipping."
)
continue
else:
raise ValueError(
f"Chapter number '{self.chapter_number}' not found in book '{self.book_name}'."
)
key = (
f'chapters:content:{self.bible_name}:{self.book_name}:{self.chapter_number}'
)
chapter_cache = load_json(key)
if chapter_cache:
logger.debug(
f"Using cached content for chapter '{self.bible_name}:{self.book_name}:{self.chapter_number}'."
)
else:
async with BibleAPI() as api:
response = await api.get_chapter(bible['id'], chapter['id'])
chapter_cache = response.get('data', [])
if not chapter_cache:
raise ValueError(
f"No content found for chapter '{self.bible_name}:{self.book_name}:{self.chapter_number}'."
)
cache_json(key, chapter_cache)
text = render_chapter(
html=chapter_cache.get('content', ''),
reference=chapter_cache.get('reference', ''),
summary=generate_summary(chapter_cache.get('content', '')),
metadata={
'Book': chapter_cache.get('reference', '').split()[0],
'Chapter': chapter_cache.get('number'),
'Verses': chapter_cache.get('verseCount'),
'Copyright': chapter_cache.get('copyright', '').replace('©', '(c)'),
},
)
console.out.print(text)
class Chapters(Command):
"""Retrieve multiple chapters of a Bible book."""
bible_name: str = arg(inherited=True)
book_name: Positional[str] = arg(
help='The name of the Bible book to retrieve',
)
chapter_numbers: Positional[list[int | str]] = arg(
None,
help="The list of chapter numbers or ranges. For example, '1 2 3', '2-4'.",
)
@override
async def pre_run_hook(self):
"""Hook to run before the main command execution."""
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
books = load_json(f'books:list:{self.bible_name}')
for book in books:
if book.get('name', '') == self.book_name:
break
else:
raise ValueError(f"Book with name '{self.book_name}' not found.")
key = f'chapters:list:{self.bible_name}:{self.book_name}'
chapters = load_json(key)
if chapters:
logger.debug(
f"Using cached list of chapters for '{self.bible_name}:{self.book_name}'."
)
else:
async with BibleAPI() as api:
response = await api.get_chapters(bible['id'], book['id'])
chapters = response.get('data', [])
if not chapters:
raise ValueError(f"No chapters found for '{self.book_name}'.")
cache_json(key, chapters)
@override
async def run(self):
bibles = load_json('bibles:list')
bible = next((b for b in bibles if b.get('name') == self.bible_name), None)
if not bible:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
chapters = load_json(f'chapters:list:{self.bible_name}:{self.book_name}')
allowed = expand_chapter_numbers(self.chapter_numbers)
async def fetch_chapter(chapter):
number = chapter.get('number')
if number is None:
logger.warning(
f"Missing chapter number for '{self.book_name}'. Skipping."
)
return None
if number == 'intro' and allowed and 'intro' not in allowed:
return None
if number != 'intro' and allowed and int(number) not in allowed:
return None
key = f'chapters:content:{self.bible_name}:{self.book_name}:{number}'
cached = load_json(key)
if cached:
logger.debug(
f"Using cached content for chapter '{self.book_name} {number}'."
)
return cached
async with BibleAPI() as api:
response = await api.get_chapter(bible['id'], chapter['id'])
data = response.get('data', {})
if not data:
logger.warning(
f"No content found for chapter '{self.book_name} {number}'."
)
return None
cache_json(key, data)
return data
# ✅ Run all chapter fetches concurrently
tasks = [fetch_chapter(ch) for ch in chapters]
results = await asyncio.gather(*tasks, return_exceptions=True)
results = [r for r in results if isinstance(r, dict)]
if not results:
raise ValueError('No chapters retrieved.')
for chapter in results:
text = render_chapter(
html=chapter.get('content', ''),
reference=chapter.get('reference', ''),
summary=generate_summary(chapter.get('content', '')),
metadata={
'Book': chapter.get('reference', '').split()[0],
'Chapter': chapter.get('number'),
'Verses': chapter.get('verseCount'),
'Copyright': chapter.get('copyright', '').replace('©', '(c)'),
},
)
console.out.print(text)
def expand_chapter_numbers(chapter_numbers):
if not chapter_numbers:
return None
allowed = set()
for item in chapter_numbers:
# intro
if item == 'intro':
allowed.add('intro')
continue
# numeric int
if isinstance(item, int):
allowed.add(item)
continue
# numeric string
if isinstance(item, str) and item.isdigit():
allowed.add(int(item))
continue
# range string "2-4"
if isinstance(item, str) and '-' in item:
try:
start, end = item.split('-', maxsplit=1)
allowed.update(range(int(start), int(end) + 1))
except ValueError:
logger.warning(f"Invalid chapter range '{item}'. Skipping.")
continue
# anything else is invalid
logger.warning(f"Invalid chapter number '{item}'. Skipping.")
return allowed
@@ -0,0 +1,50 @@
from typing import final
from clypi import Command, arg
from loguru import logger
from typing_extensions import override
from bible import console
from bible.sqlite import load_json
class List(Command):
"""List all available Bible books."""
contains: str = arg(
None,
help='Filter Bible books by name containing this string',
group='Filter',
)
bible_name: str = arg(inherited=True)
sort: bool = arg(
False,
help='Sort the Bible books alphabetically',
group='Filter',
)
@final
@classmethod
def epilog(cls):
return 'Attribution:\n Data provided by Bible.API — https://api.bible.com'
@override
async def run(self):
"""List all Bible books, optionally filtered by the 'contains' argument."""
books = load_json(f'books:list:{self.bible_name}')
if self.sort:
books = sorted(books, key=lambda x: x.get('name', '').lower())
seen = set()
for book in books:
if (
self.contains
and self.contains.lower() not in book.get('name', '').lower()
):
continue
if book.get('name', '') in seen:
logger.debug(f'Skipping duplicate Bible book: {book.get("name", "")}')
continue
console.out.print(f'{book.get("name", "")}')
seen.add(book.get('name', ''))
@@ -0,0 +1,246 @@
import asyncio
from typing import final
from clypi import Command, Positional, arg
from loguru import logger
from typing_extensions import override
from bible import console
from bible.api import BibleAPI
from bible.render import render_verse
from bible.sqlite import cache_json, load_json
class Verse(Command):
"""Retrieve a single Bible verse."""
bible_name: str = arg(inherited=True)
book_name: Positional[str] = arg(help='The name of the Bible book to retrieve')
chapter_number: Positional[int] = arg(help='The number of the chapter to retrieve')
verse_number: Positional[int] = arg(help='The number of the verse to retrieve')
@final
@classmethod
def epilog(cls):
return 'Attribution:\n Data provided by Bible.API — https://api.bible.com'
@override
async def pre_run_hook(self):
"""Ensure chapter list is cached."""
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
books = load_json(f'books:list:{self.bible_name}')
for book in books:
if book.get('name', '') == self.book_name:
break
else:
raise ValueError(f"Book with name '{self.book_name}' not found.")
key = f'chapters:list:{self.bible_name}:{self.book_name}'
chapters = load_json(key)
if chapters:
logger.debug(
f"Using cached list of chapters for '{self.bible_name}:{self.book_name}'."
)
else:
async with BibleAPI() as api:
response = await api.get_chapters(bible['id'], book['id'])
chapters = response.get('data', [])
if not chapters:
raise ValueError(f"No chapters found for '{self.book_name}'.")
cache_json(key, chapters)
@override
async def run(self):
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
chapters = load_json(f'chapters:list:{self.bible_name}:{self.book_name}')
for chapter in chapters:
if self.chapter_number == int(chapter.get('number', None)):
break
else:
raise ValueError(
f"Chapter number '{self.chapter_number}' not found in book '{self.book_name}'."
)
key = f'verses:list:{self.bible_name}:{self.book_name}:{self.chapter_number}'
verses = load_json(key)
if verses:
logger.debug(
f"Using cached list of verses for '{self.bible_name}:{self.book_name}:{self.chapter_number}'."
)
else:
async with BibleAPI() as api:
response = await api.get_verses(bible['id'], chapter['id'])
verses = response.get('data', [])
if not verses:
raise ValueError(
f'No verses found for chapter {self.chapter_number}.'
)
cache_json(key, verses)
# Match verse by reference string (e.g., "Ruth 1:1")
target_ref = f'{self.book_name} {self.chapter_number}:{self.verse_number}'
verse_meta = next((v for v in verses if v.get('reference') == target_ref), None)
if not verse_meta:
raise ValueError(f'Verse {target_ref} not found.')
verse_id = verse_meta['id']
cache_key = f'verses:content:{self.bible_name}:{self.book_name}:{self.chapter_number}:{self.verse_number}'
verse_data = load_json(cache_key)
if verse_data:
logger.debug(f'Using cached verse {target_ref}.')
else:
async with BibleAPI() as api:
response = await api.get_verse(bible['id'], verse_id)
verse_data = response.get('data', {})
if not verse_data:
raise ValueError(f'No content found for verse {target_ref}.')
cache_json(cache_key, verse_data)
text = render_verse(verse_data.get('content', ''))
console.out.print(text)
class Verses(Command):
"""Retrieve multiple Bible verses."""
bible_name: str = arg(inherited=True)
book_name: Positional[str] = arg(help='The name of the Bible book to retrieve')
chapter_number: Positional[int] = arg(help='The number of the chapter to retrieve')
verse_numbers: Positional[list[int | str]] = arg(
None, help="List of verse numbers or ranges. For example, '1 2 3', '5-9'."
)
@override
async def pre_run_hook(self):
"""Ensure chapter list is cached."""
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name', '') == self.bible_name:
break
else:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
books = load_json(f'books:list:{self.bible_name}')
for book in books:
if book.get('name', '') == self.book_name:
break
else:
raise ValueError(f"Book with name '{self.book_name}' not found.")
key = f'chapters:list:{self.bible_name}:{self.book_name}'
chapters = load_json(key)
if not chapters:
async with BibleAPI() as api:
response = await api.get_chapters(bible['id'], book['id'])
chapters = response.get('data', [])
if not chapters:
raise ValueError(f"No chapters found for '{self.book_name}'.")
cache_json(key, chapters)
@override
async def run(self):
bibles = load_json('bibles:list')
bible = next((b for b in bibles if b.get('name') == self.bible_name), None)
if not bible:
raise ValueError(f"Bible with name '{self.bible_name}' not found.")
chapters = load_json(f'chapters:list:{self.bible_name}:{self.book_name}')
chapter = next(
(ch for ch in chapters if int(ch.get('number', 0)) == self.chapter_number),
None,
)
if not chapter:
raise ValueError(
f"Chapter {self.chapter_number} not found in '{self.book_name}'."
)
key = f'verses:list:{self.bible_name}:{self.book_name}:{self.chapter_number}'
verses = load_json(key)
if not verses:
async with BibleAPI() as api:
response = await api.get_verses(bible['id'], chapter['id'])
verses = response.get('data', [])
if not verses:
raise ValueError(
f'No verses found for chapter {self.chapter_number}.'
)
cache_json(key, verses)
allowed = expand_verse_numbers(self.verse_numbers)
async def fetch_verse(index, verse_meta):
target_ref = f'{self.book_name} {self.chapter_number}:{index}'
if allowed and index not in allowed:
return None
verse_id = verse_meta['id']
cache_key = f'verses:content:{self.bible_name}:{self.book_name}:{self.chapter_number}:{index}'
cached = load_json(cache_key)
if cached:
logger.debug(f'Using cached verse {target_ref}.')
return cached
async with BibleAPI() as api:
response = await api.get_verse(bible['id'], verse_id)
data = response.get('data', {})
if not data:
logger.warning(f'No content found for verse {target_ref}.')
return None
cache_json(cache_key, data)
return data
# ✅ Run all verse fetches concurrently
tasks = [fetch_verse(i, v) for i, v in enumerate(verses, start=1)]
results = await asyncio.gather(*tasks, return_exceptions=True)
results = [r for r in results if isinstance(r, dict)]
if not results:
raise ValueError('No verses retrieved.')
for verse in results:
text = render_verse(verse.get('content', ''))
console.out.print(text)
def expand_verse_numbers(verse_numbers):
if not verse_numbers:
return None
allowed = set()
for item in verse_numbers:
# numeric int
if isinstance(item, int):
allowed.add(item)
continue
# numeric string
if isinstance(item, str) and item.isdigit():
allowed.add(int(item))
continue
# range string "5-9"
if isinstance(item, str) and '-' in item:
try:
start, end = item.split('-', maxsplit=1)
allowed.update(range(int(start), int(end) + 1))
except ValueError:
logger.warning(f"Invalid verse range '{item}'. Skipping.")
continue
logger.warning(f"Invalid verse number '{item}'. Skipping.")
return allowed
+48
View File
@@ -0,0 +1,48 @@
from typing import final
from clypi import Command, arg
from loguru import logger
from typing_extensions import override
from bible import console
from bible.sqlite import load_json
class List(Command):
"""List all available Bibles."""
contains: str = arg(
None,
help='Filter Bibles by name containing this string',
group='Filter',
)
sort: bool = arg(
False,
help='Sort the Bibles alphabetically',
group='Filter',
)
@final
@classmethod
def epilog(cls):
return 'Attribution:\n Data provided by Bible.API — https://api.bible.com'
@override
async def run(self):
bibles = load_json('bibles:list')
if self.sort:
bibles = sorted(bibles, key=lambda x: x.get('name', '').lower())
seen = set()
for bible in bibles:
if (
self.contains
and self.contains.lower() not in bible.get('name', '').lower()
):
continue
if bible.get('name', '') in seen:
logger.debug(f'Skipping duplicate Bible: {bible.get("name", "")}')
continue
console.out.print(f'{bible.get("name", "")}')
seen.add(bible.get('name', ''))
+3
View File
@@ -0,0 +1,3 @@
from rich.console import Console
out = Console()
+127
View File
@@ -0,0 +1,127 @@
from bs4 import BeautifulSoup
from rich.markup import escape
from rich.text import Text
from . import util
def generate_summary(html: str) -> str:
soup = BeautifulSoup(html, 'html.parser')
soup = util.normalize_paragraph_classes(soup)
# Prefer section title if available
section = soup.find('p', class_='s')
if section:
title = section.get_text(strip=True)
return f'{title} — a summary of key events and teachings.'
# Otherwise, use the first sentence of the first paragraph
first_para = soup.find('p', class_='p')
if first_para:
text = first_para.get_text(' ', strip=True)
first_sentence = text.split('.')[0]
return f'{first_sentence.strip()}.'
return 'Summary unavailable.'
def render_chapter(
html: str,
reference: str,
summary: str | None = None,
metadata: dict | None = None,
) -> Text:
"""
Render a full Bible chapter with rich formatting, summaries, and metadata.
- summary: short overview of the chapter's theme
- metadata: dictionary with contextual info (e.g., {"book": "Genesis", "chapter": 1, "verses": 31})
"""
soup = BeautifulSoup(html, 'html.parser')
soup = util.normalize_paragraph_classes(soup)
text = Text()
# Header block
text.append(f'\n{reference}\n', style='bold gold3')
text.append('' * len(reference) + '\n', style='grey50')
# Metadata block
if metadata:
meta_line = ''.join(f'{k.capitalize()}: {v}' for k, v in metadata.items())
text.append(f'{meta_line}\n', style='dim white')
# Chapter summary
if summary:
text.append(f'\n{summary}\n', style='italic grey70')
text.append('' * len(summary) + '\n\n', style='grey50')
# Section titles
for s in soup.find_all('p', class_='s'):
title = s.get_text(strip=True)
text.append(f'{title}\n\n', style='bold gold3')
# Paragraphs with verses
for p in soup.find_all('p', class_='p'):
paragraph_text = Text()
for span in p.find_all('span', class_='v'):
verse_num = span.get_text(strip=True)
verse_content_parts = []
# Collect siblings until next verse span
for sibling in span.next_siblings:
if getattr(sibling, 'name', None) == 'span' and 'v' in sibling.get(
'class', []
):
break
if getattr(sibling, 'name', None) == 'span' and 'it' in sibling.get(
'class', []
):
verse_content_parts.append(
(' ' + sibling.get_text(strip=True) + ' ', 'italic white')
)
elif isinstance(sibling, str):
verse_content_parts.append((sibling, 'white'))
elif getattr(sibling, 'name', None):
verse_content_parts.append(
(sibling.get_text(' ', strip=True), 'white')
)
# Build verse line
paragraph_text.append(f' {verse_num} ', style='bold bright_cyan')
for part, style in verse_content_parts:
if paragraph_text and not paragraph_text.plain.endswith(' '):
paragraph_text.append(' ')
paragraph_text.append(part.strip(), style=style)
paragraph_text.append('\n')
text.append(paragraph_text)
text.append('\n')
return text
def render_verse(html: str) -> Text:
"""Render a single verse with rich formatting."""
soup = BeautifulSoup(html, 'html.parser')
soup = util.normalize_paragraph_classes(soup)
text = Text()
# Section titles
for s in soup.find_all('p', class_='s'):
title = s.get_text(strip=True)
text.append(f'\n{title}\n', style='bold gold3')
# Verse paragraphs
for p in soup.find_all('p', class_='p'):
verse_num = p.find('span', class_='v')
verse_text = p.get_text(' ', strip=True)
if verse_num:
num = verse_num.get_text(strip=True)
# Remove the verse number from the paragraph text
verse_text = verse_text.replace(num, '', 1).strip()
text.append(f'{num} ', style='bold cyan')
text.append(escape(verse_text) + '\n', style='white')
return text
+20
View File
@@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
BIBLE_NAME: str
BOOK_NAME: str = 'Genesis'
ENDPOINT: str = 'https://rest.api.bible'
API_KEY: str
CACHE_EXPIRY_DAYS: int = 30
THEME: str = 'dark'
model_config = SettingsConfigDict(
env_file='.env',
env_file_encoding='utf-8',
env_prefix='BIBLE_',
validate_assignment=True,
)
settings = Settings()
+68
View File
@@ -0,0 +1,68 @@
import json
import sqlite3
from datetime import datetime, timedelta
from loguru import logger
from .settings import settings
DB_PATH = 'bible_cache.db'
def init_db():
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.execute("""
SELECT name FROM sqlite_master
WHERE type='table' AND name='cache'
""")
existed = cursor.fetchone() is not None
conn.execute("""
CREATE TABLE IF NOT EXISTS cache (
key TEXT PRIMARY KEY,
value TEXT,
last_updated TEXT
)
""")
conn.commit()
if existed:
logger.debug('Database already existed.')
else:
logger.debug('Database initialised.')
def get_cached(key: str):
with sqlite3.connect(DB_PATH) as conn:
cursor = conn.cursor()
cursor.execute('SELECT value, last_updated FROM cache WHERE key = ?', (key,))
row = cursor.fetchone()
if not row:
return None
value, last_updated = row
if datetime.fromisoformat(last_updated) < datetime.now() - timedelta( # noqa: DTZ005
days=settings.CACHE_EXPIRY_DAYS
):
logger.debug(f'Cache expired for key: {key}')
return None
return value
def set_cached(key: str, value: str):
with sqlite3.connect(DB_PATH) as conn:
conn.execute(
'REPLACE INTO cache (key, value, last_updated) VALUES (?, ?, ?)',
(key, value, datetime.now().isoformat()), # noqa: DTZ005
)
conn.commit()
def cache_json(key: str, data: dict | list):
set_cached(key, json.dumps(data))
def load_json(key: str):
cached = get_cached(key)
return json.loads(cached) if cached else None
+6
View File
@@ -0,0 +1,6 @@
class BibleTUIError(Exception):
"""Base exception class for Bible TUI errors."""
class BibleTUIFetchError(BibleTUIError):
"""Exception raised for errors during fetching chapters or verses."""
+134
View File
@@ -0,0 +1,134 @@
import asyncio
from bible.api import BibleAPI
from bible.settings import settings
from bible.sqlite import cache_json, load_json
from .exceptions import BibleTUIFetchError
async def fetch_multiple(app, book: str, chapters: list[int], verses: list[int] | None):
"""Fetch multiple chapters or verses concurrently."""
tasks = []
for chapter in chapters:
if verses:
for verse in verses:
# Each task resolves IDs and fetches independently
tasks.append(fetch_chapter_or_verse(app, str(chapter), str(verse)))
else:
tasks.append(fetch_chapter_or_verse(app, str(chapter)))
# Run all tasks concurrently
results = await asyncio.gather(*tasks, return_exceptions=True)
# Pair results with their reference
output = []
idx = 0
for chapter in chapters:
if verses:
for verse in verses:
output.append(('verse', chapter, verse, results[idx]))
idx += 1
else:
output.append(('chapter', chapter, None, results[idx]))
idx += 1
return output
async def fetch_chapter_or_verse(
app,
chapter_number: str,
verse_number: str | None = None,
):
"""Fetch chapter or verse from cache or API."""
if verse_number:
key = f'verses:content:{settings.BIBLE_NAME}:{settings.BOOK_NAME}:{chapter_number}:{verse_number}'
else:
key = f'chapters:content:{settings.BIBLE_NAME}:{settings.BOOK_NAME}:{chapter_number}'
cached = load_json(key)
if cached:
return cached
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name') == settings.BIBLE_NAME:
bible_id = bible.get('id')
break
else:
raise BibleTUIFetchError(f"Bible '{settings.BIBLE_NAME}' not found in cache.")
chapter_list = load_json(
f'chapters:list:{settings.BIBLE_NAME}:{settings.BOOK_NAME}'
)
if not chapter_list:
chapter_list = await app.initialise_chapter_list()
for chapter_item in chapter_list:
if chapter_item.get('number') == chapter_number:
chapter_id = chapter_item.get('id')
break
else:
raise BibleTUIFetchError(f"Chapter '{chapter_number}' not found in cache.")
async with BibleAPI() as api:
if verse_number:
verse_meta = await fetch_verse_meta(
bible_id,
chapter_number,
verse_number,
chapter_id,
)
response = await api.get_verse(bible_id, verse_meta.get('id'))
else:
response = await api.get_chapter(bible_id, chapter_id)
data = response.get('data', {})
if data:
cache_json(key, data)
return data
async def fetch_verse_meta(
bible_id: str,
chapter_number: str,
verse_number: str,
chapter_id: str,
):
"""Fetch metadata for a specific verse from cache or API."""
key = f'verses:content:{settings.BIBLE_NAME}:{settings.BOOK_NAME}:{chapter_number}:{verse_number}'
cached = load_json(key)
if cached:
return cached
verses_key = (
f'verses:list:{settings.BIBLE_NAME}:{settings.BOOK_NAME}:{chapter_number}'
)
verses = load_json(verses_key)
if not verses:
async with BibleAPI() as api:
response = await api.get_verses(bible_id, chapter_id)
verses = response.get('data', [])
if not verses:
raise BibleTUIFetchError(
f"Unable to fetch list of verses for chapter '{chapter_number}' from the API."
)
cache_json(verses_key, verses)
target_ref = f'{settings.BOOK_NAME} {chapter_number}:{verse_number}'
for verse in verses:
if isinstance(verse, dict) and verse.get('reference') == target_ref:
verse_meta = verse
break
else:
verse_meta = None
if not verse_meta:
raise BibleTUIFetchError(f'Verse {target_ref} not found.')
async with BibleAPI() as api:
response = await api.get_verse(bible_id, verse_meta.get('id'))
data = response.get('data', {})
if data:
cache_json(key, data)
return data
+85
View File
@@ -0,0 +1,85 @@
from textual.app import ComposeResult
from textual.containers import Vertical
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Label
from bible.settings import settings
from bible.sqlite import get_cached
from . import util
class SettingsModal(ModalScreen):
"""Modal window for editing persistent Bible settings."""
def compose(self) -> ComposeResult:
yield Vertical(
Label('Set Bible', id='bible_label'),
Input(value=settings.BIBLE_NAME, placeholder='Default Bible', id='bible'),
Label('Set Theme', id='theme_label'),
Input(value=settings.THEME, placeholder='Default Theme', id='theme'),
Button('Save', id='save', variant='success'),
Button('Cancel', id='cancel', variant='error'),
)
async def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handler for button press events in the settings modal.
This method handles the logic for the "Save" and "Cancel" buttons.
If "Cancel" is pressed, the modal is dismissed without saving changes.
If "Save" is pressed, the input is validated and, if valid, the settings are updated and persisted.
Args:
event (Button.Pressed): The button press event that triggered this handler.
"""
if event.button.id == 'cancel':
self.dismiss()
return
if event.button.id == 'save':
bible = self.query_one('#bible', Input).value.strip()
bible_input = self.query_one('#bible', Input)
if not await self.__validate_bible_name(bible):
bible_input.value = f'Invalid Bible name: {bible}'
bible_input.cursor_position = 0
bible_input.selection = (0, 0)
return
theme_input = self.query_one('#theme', Input)
theme = theme_input.value.strip()
if not await self.__validate_theme(theme):
theme_input.value = f'Invalid theme: {theme}'
theme_input.cursor_position = 0
theme_input.selection = (0, 0)
return
util.save_env_value('BIBLE_BIBLE_NAME', bible)
util.save_env_value('BIBLE_THEME', theme)
settings.BIBLE_NAME = bible
settings.THEME = theme
await self.apply_theme(theme)
self.dismiss()
async def __validate_bible_name(self, bible: str) -> bool:
"""Validate the Bible name input."""
bibles = get_cached('bibles:list')
if bibles is None:
return False
return bible in bibles
async def __validate_theme(self, theme: str) -> bool:
return theme in self.app.available_themes
async def apply_theme(self, theme_name: str):
self.app.theme = theme_name
await self.__validate_theme(theme_name)
self.app.refresh_css(self.app.css_path)
self.refresh()
+314
View File
@@ -0,0 +1,314 @@
import argparse
import typing
from typing import Any
from rich.text import Text
from textual.app import App, ComposeResult
from textual.containers import VerticalScroll
from textual.theme import Theme
from textual.widgets import Footer, Header, Input, Link, Static
from bible.api import BibleAPI
from bible.render import generate_summary, render_chapter, render_verse
from bible.settings import settings
from bible.sqlite import cache_json, get_cached, init_db, load_json
from . import util
from .exceptions import BibleTUIFetchError
from .fetch import fetch_chapter_or_verse, fetch_multiple
from .settings_modal import SettingsModal
class BibleTUI(App):
"""A Textual TUI for browsing and reading Bible chapters."""
CSS_PATH = 'tui.tcss'
TITLE = 'Bible TUI'
BINDINGS: typing.ClassVar[list[tuple[str, str, str]]] = [
('q', 'quit', 'Quit'),
('s', 'open_settings', 'Settings'),
('ctrl+s', 'open_settings', 'Settings'),
('b', 'list_books', 'List Books'),
('ctrl+b', 'list_books', 'List Books'),
('l', 'list_bibles', 'List Bibles'),
('ctrl+l', 'list_bibles', 'List Bibles'),
]
def __init__(self, theme_override: str | None = None, **kwargs):
super().__init__(**kwargs)
self.theme_override = theme_override
init_db()
self.call_later(self.initialise_bible_list)
async def initialise_bible_list(self):
bibles_key = 'bibles:list'
if get_cached(bibles_key):
return
async with BibleAPI() as api:
response = await api.get_bibles()
data = response.get('data', [])
if not data:
raise BibleTUIFetchError('Unable to fetch list of Bibles from the API.')
cache_json(bibles_key, data)
async def initialise_book_list(self) -> Any:
books_key = f'books:list:{settings.BIBLE_NAME}'
if cached_books := load_json(books_key):
return cached_books
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name') == settings.BIBLE_NAME:
bible_id = bible.get('id')
break
else:
raise BibleTUIFetchError(
f"Bible '{settings.BIBLE_NAME}' not found in cache."
)
async with BibleAPI() as api:
response = await api.get_books(bible_id)
data = response.get('data', [])
if not data:
raise BibleTUIFetchError(
f"Unable to fetch list of books for Bible '{settings.BIBLE_NAME}' from the API."
)
cache_json(books_key, data)
return data
async def initialise_chapter_list(self) -> Any:
chapters_key = f'chapters:list:{settings.BIBLE_NAME}:{settings.BOOK_NAME}'
if cached_chapters := load_json(chapters_key):
return cached_chapters
bibles = load_json('bibles:list')
for bible in bibles:
if bible.get('name') == settings.BIBLE_NAME:
bible_id = bible.get('id')
break
else:
raise BibleTUIFetchError(
f"Bible '{settings.BIBLE_NAME}' not found in cache."
)
books_key = f'books:list:{settings.BIBLE_NAME}'
books = load_json(books_key)
if not books:
books = await self.initialise_book_list()
for book in books:
if book.get('name') == settings.BOOK_NAME:
book_id = book.get('id')
break
else:
raise BibleTUIFetchError(f"Book '{settings.BOOK_NAME}' not found in cache.")
async with BibleAPI() as api:
response = await api.get_chapters(bible_id, book_id)
data = response.get('data', [])
if not data:
raise BibleTUIFetchError(
f'Unable to fetch list of chapters for {settings.BOOK_NAME} from the API.'
)
cache_json(chapters_key, data)
return data
def compose(self) -> ComposeResult:
"""Create child widgets for the app."""
self.input = Input(
placeholder='Enter book chapter or book chapter:verse (e.g., Genesis 1, Genesis 1:1)',
id='search',
)
yield Header()
yield self.input
yield VerticalScroll(Static('Welcome to the Bible TUI!', id='content'))
yield Link(
'Powered by Bible.API',
url='https://api.bible',
tooltip='Click me',
)
yield Footer()
async def on_mount(self) -> None:
"""Initial setup when the app starts."""
self.query_one('#content', Static).update(
f'📖 Current Bible: {settings.BIBLE_NAME}\n'
'Type a reference or press B to list books.'
)
theme_name = settings.THEME.lower()
"""
# If it's a built-in theme, just apply it
if theme_name in self.available_themes:
self.theme = theme_name
return
"""
light_theme = Theme(
name='onyx-light',
surface='#f5f5f5',
foreground='#1e1e1e',
accent='#4a90e2',
primary='#4a90e2',
secondary='#6bb3ff',
)
self.register_theme(light_theme)
dark_theme = Theme(
name='onyx-dark',
surface='#1e1e1e',
foreground='#e6e6e6',
accent='#4a90e2',
primary='#4a90e2',
secondary='#6bb3ff',
)
self.register_theme(dark_theme)
self.theme = theme_name
async def action_open_settings(self) -> None:
await self.push_screen(SettingsModal())
async def action_quit(self) -> None:
self.exit()
async def action_list_bibles(self) -> None:
"""List all available Bibles."""
key = 'bibles:list'
bibles = load_json(key)
if not bibles:
bibles = await self.initialise_bible_list()
bible_names = [bible.get('name', 'Unknown') for bible in bibles]
formatted = '\n'.join(bible_names)
self.query_one('#content', Static).update(
f'📚 Available Bibles:\n\n{formatted}'
)
async def action_list_books(self) -> None:
"""List all books for the current Bible."""
key = f'books:list:{settings.BIBLE_NAME}'
books = load_json(key)
if not books:
books = await self.initialise_book_list()
book_names = [book.get('name', 'Unknown') for book in books]
formatted = '\n'.join(book_names)
self.query_one('#content', Static).update(
f'📚 Books in {settings.BIBLE_NAME}:\n\n{formatted}'
)
async def action_show_verse(self) -> None:
verse_input = self.input.value.strip()
try:
book, chapter_verse = verse_input.split(maxsplit=1)
settings.BOOK_NAME = book
chapter, verse = chapter_verse.split(':')
try:
verse = await fetch_chapter_or_verse(self, chapter, verse)
if not verse:
self.query_one('#content', Static).update('⚠️ Verse not found.')
return
except BibleTUIFetchError as e:
self.query_one('#content', Static).update(f'⚠️ {e!s}')
return
except ValueError:
self.query_one('#content', Static).update(
'❌ Invalid format. Use: Genesis 1:1'
)
return
html = verse.get('content', '')
text = render_verse(html)
self.query_one('#content', Static).update(text)
async def on_input_submitted(self, event: Input.Submitted) -> None:
"""Handle user search input."""
await self.action_show_reference()
async def action_show_reference(self) -> None:
query = self.input.value.strip()
if query.lower() == 'list books':
await self.action_list_books()
self.input.value = ''
return
try:
book, chapters, verses = util.parse_reference(query)
if not await self.__validate_book(book):
await self.action_list_books()
raise ValueError(
f"Book '{book}' not found. Enter 'list books' to see available books."
)
settings.BOOK_NAME = book
except ValueError as e:
self.query_one('#content', Static).update(f'{e}')
return
try:
items = await fetch_multiple(self, book, chapters, verses)
except BibleTUIFetchError as e:
self.query_one('#content', Static).update(f'⚠️ {e}')
return
# Render everything
output = Text()
for kind, chapter, verse, data in items:
if isinstance(data, Exception):
ref = (
f'{book} {chapter}'
if verse is None
else f'{book} {chapter}:{verse}'
)
output.append(f'⚠️ Error fetching {ref}: {data}\n')
continue
html = data.get('content', '')
if kind == 'verse':
rendered = render_verse(html)
output.append(rendered)
else:
summary = generate_summary(html)
metadata = {
'Book': book,
'Chapter': chapter,
'Verses': data.get('verseCount'),
}
rendered = render_chapter(
html,
f'{book} {chapter}',
summary,
metadata,
)
output.append(rendered)
output.append('\n')
self.query_one('#content', Static).update(output)
async def __validate_book(self, book: str) -> bool:
books_key = f'books:list:{settings.BIBLE_NAME}'
cached_books = load_json(books_key)
if not cached_books:
cached_books = await self.initialise_book_list()
return book in [b.get('name') for b in cached_books]
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Bible TUI — interact with Bible.API')
parser.add_argument('--theme', type=str, help='Specify the theme (light or dark)')
return parser.parse_args()
def main():
args = parse_args()
app = BibleTUI(theme_override=args.theme)
app.run()
+107
View File
@@ -0,0 +1,107 @@
/* ===== Global Layout ===== */
Screen {
background: $surface;
color: $text;
}
/* ===== Header / Footer ===== */
Header {
background: $accent;
color: $text;
padding: 1 2;
text-style: bold;
}
Footer {
background: $surface-darken-3;
color: $text-muted;
border-top: tall $accent;
}
/* ===== Input Bar ===== */
#input {
background: $surface-lighten-2;
border: tall $accent;
}
/* ===== Main Content Area ===== */
#content {
background: $surface;
color: $text;
padding: 2 4;
overflow-y: auto;
}
/* Make scripture text more readable */
#content Text {
padding-top: 1;
padding-bottom: 1;
}
/* Verse numbers */
.vnum {
color: $accent;
text-style: bold;
}
/* ===== Modal Window ===== */
/* Modal overlay */
ModalScreen {
background: $surface-darken-3 40%;
align: center middle;
}
/* Modal window */
ModalScreen > Vertical {
background: $surface-lighten-2 85%;
border: thick $accent;
padding: 2 4;
width: 40%;
max-width: 60;
height: auto;
}
ModalScreen Button {
margin-top: 1;
width: 100%;
}
ModalScreen Label {
padding: 1 1;
}
/* ===== Buttons ===== */
Button {
background: $accent;
color: $text;
text-style: bold;
}
Button:hover {
background: $accent-lighten-3;
}
/* Footer */
Footer {
background: $surface-darken-3;
color: $text-muted;
layout: horizontal;
min-height: 3;
padding: 0 1;
}
/* ===== Attribution ===== */
#api-attribution {
dock: bottom;
padding: 1 2;
background: $surface;
color: $text-muted;
}
+79
View File
@@ -0,0 +1,79 @@
from pathlib import Path
def parse_reference(text: str):
"""
Parse references like:
Genesis 1
Genesis 1:1
Genesis 1-3
Genesis 1:1-5
Genesis 1-3:1-4
Returns (book, chapter_numbers, verse_numbers)
"""
text = text.strip()
try:
book, rest = text.split(maxsplit=1)
except ValueError:
raise ValueError('Invalid format. Use: Genesis 1 or Genesis 1:1')
# Chapter and verse part
if ':' in rest:
chapter_part, verse_part = rest.split(':', 1)
chapter_items = chapter_part.split()
verse_items = verse_part.split()
chapters = expand_numbers(chapter_items)
verses = expand_numbers(verse_items)
return book, chapters, verses
# Only chapters
chapter_items = rest.split()
chapters = expand_numbers(chapter_items)
return book, chapters, None
def expand_numbers(items: list[str | int]) -> list[int]:
"""Expand numbers and ranges like ['1', '3-5'] into [1,3,4,5]."""
result = []
for item in items:
if isinstance(item, int):
result.append(item)
continue
item = item.strip()
if item.isdigit():
result.append(int(item))
continue
if '-' in item:
start, end = item.split('-', 1)
if start.isdigit() and end.isdigit():
result.extend(range(int(start), int(end) + 1))
continue
# Ignore invalid items silently
continue
return sorted(set(result))
def save_env_value(key: str, value: str):
env_path = Path('.env')
lines = env_path.read_text().splitlines()
new_lines = []
found = False
for line in lines:
if line.startswith(f'{key}='):
new_lines.append(f'{key}="{value}"')
found = True
else:
new_lines.append(line)
if not found:
new_lines.append(f'{key}="{value}"')
env_path.write_text('\n'.join(new_lines))
+22
View File
@@ -0,0 +1,22 @@
from bs4 import BeautifulSoup
def normalize_paragraph_classes(soup: BeautifulSoup) -> BeautifulSoup:
"""
Normalise paragraph class names across different Bible translations.
Ensures consistent 's' for section titles and 'p' for verse paragraphs.
"""
for p in soup.find_all('p'):
classes = p.get('class', [])
if not classes:
continue
# Section titles (e.g. s1, s2 → s)
if any(cls.startswith('s') for cls in classes):
p['class'] = ['s']
# Verse paragraphs (e.g. m, b, li1, li2 → p)
elif any(cls.startswith(('m', 'b', 'li', 'p')) for cls in classes):
p['class'] = ['p']
return soup