mirror of
https://github.com/onyx-and-iris/bible.git
synced 2026-09-15 07:32: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