mirror of
https://github.com/onyx-and-iris/bible.git
synced 2026-09-14 23:22:34 +00:00
first commit
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
# SCM syntax highlighting & preventing 3-way merges
|
||||
pixi.lock merge=binary linguist-language=YAML linguist-generated=true -diff
|
||||
+441
@@ -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
|
||||
@@ -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]
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"python-envs.defaultEnvManager": "renan-r-santos.pixi-code:pixi",
|
||||
"python-envs.defaultPackageManager": "renan-r-santos.pixi-code:pixi"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
# Bible CLI/TUI - *Powered by API.Bible*
|
||||
|
||||
[](https://pixi.sh)
|
||||
[](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
|
||||
```
|
||||
|
||||

|
||||
|
||||
### Run as a TUI
|
||||
|
||||
```console
|
||||
bible-tui
|
||||
```
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
### 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
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
Executable
BIN
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Executable
+30
@@ -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].'
|
||||
)
|
||||
@@ -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}')
|
||||
@@ -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()
|
||||
@@ -0,0 +1,4 @@
|
||||
from .book import Book
|
||||
from .list import List
|
||||
|
||||
__all__ = ['Book', 'List']
|
||||
@@ -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
|
||||
@@ -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', ''))
|
||||
@@ -0,0 +1,3 @@
|
||||
from rich.console import Console
|
||||
|
||||
out = Console()
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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."""
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user