biblia-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,5 @@
1
+ # Copia este archivo como .env y rellena los valores
2
+ # Genera ANN_ENCRYPT_KEY con: python admin_annotations.py genkey
3
+
4
+ SUPABASE_SERVICE_ROLE_KEY=eyJ...TU_SERVICE_ROLE_KEY...
5
+ ANN_ENCRYPT_KEY=TU_CLAVE_FERNET_BASE64=
@@ -0,0 +1,32 @@
1
+ # Secretos — NUNCA al repo
2
+ .env
3
+ admin_annotations.py
4
+ cargar_anotaciones.py
5
+
6
+ # Python
7
+ __pycache__/
8
+ *.pyc
9
+ *.pyo
10
+ *.pyd
11
+ *.egg-info/
12
+ dist/
13
+ build/
14
+ .eggs/
15
+
16
+ # Datos locales del usuario
17
+ .biblia-cli/
18
+
19
+ # Entornos virtuales
20
+ .venv/
21
+ venv/
22
+ env/
23
+
24
+ # IDEs
25
+ .vscode/
26
+ .idea/
27
+ *.suo
28
+ *.user
29
+
30
+ # Windows
31
+ Thumbs.db
32
+ desktop.ini
@@ -0,0 +1,69 @@
1
+ Metadata-Version: 2.4
2
+ Name: biblia-cli
3
+ Version: 0.1.0
4
+ Summary: 📖 Lee la Biblia en tu terminal — Castellano & Português
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: httpx>=0.27.0
7
+ Requires-Dist: supabase>=2.0.0
8
+ Requires-Dist: textual>=0.70.0
9
+ Description-Content-Type: text/markdown
10
+
11
+ # ⚡ BIBLIA CLI: CRISTO Edition ⚡
12
+
13
+ > **La Palabra de Dios no solo se lee, se vive. Y ahora, se domina desde la Terminal.**
14
+
15
+ Bienvenido a **Biblia CLI**, la herramienta definitiva para el guerrero espiritual moderno y el desarrollador que no sacrifica su fe por su productividad. Olvida las interfaces lentas, las distracciones de la web y el ruido visual. Esto es velocidad pura. Esto es **CRISTO** en tu consola.
16
+
17
+ ---
18
+
19
+ ## 🚀 ¿Por qué Biblia CLI?
20
+
21
+ Biblia CLI no es un simple lector; es un motor de búsqueda y estudio bíblico optimizado para el rendimiento. Diseñado con una estética **Premium**, ofrece una experiencia de usuario que te hará volar la cabeza.
22
+
23
+ ### ✨ Características que rompen moldes:
24
+
25
+ * **⚡ Velocidad de Vértigo**: Acceso instantáneo a cualquier libro y capítulo. Navegación fluida diseñada para los que no tienen tiempo que perder.
26
+ * **📖 Sincronización Litúrgica Real**: Pulsa `D` y conecta directamente con el **calendario litúrgico oficial del Vaticano**. No es una lectura aleatoria, es la Palabra que la Iglesia proclama en todo el mundo, hoy mismo.
27
+ * **🎨 Estética de Vanguardia**: Temas visuales impecables. Desde el modo **Sepia** para lecturas profundas hasta el **Matrix** para sesiones de código nocturnas, y un **Tema Claro** refinado con alto contraste que se ve simplemente increíble.
28
+ * **🛰️ Conexión Bolls.life**: Acceso a la API más robusta del mercado para garantizar que los textos siempre estén ahí cuando los necesites con la máxima fidelidad.
29
+ * **⭐ Gestión de Favoritos y Notas**: Marca tus versículos clave y guarda tus reflexiones directamente en la terminal. Tu estudio bíblico, centralizado y potente.
30
+ * **🕊️ CRISTO Splash Screen**: Una pantalla de inicio en arte ASCII que impone respeto y te recuerda el propósito de tu estudio desde el segundo uno.
31
+
32
+ ---
33
+
34
+ ## 🛠️ Instalación para Elegidos
35
+
36
+ Si tienes Python 3.10+, estás a un comando de la gloria:
37
+
38
+ ```powershell
39
+ pip install -e .
40
+ ```
41
+
42
+ Una vez instalado, simplemente escribe:
43
+ ```powershell
44
+ biblia
45
+ ```
46
+
47
+ ---
48
+
49
+ ## ⌨️ Domina el Arca (Atajos de Teclado)
50
+
51
+ | Tecla | Acción |
52
+ | :--- | :--- |
53
+ | `D` | **Sincronización Divina**: Abre la lectura litúrgica de HOY. |
54
+ | `/` | **Buscador Ninja**: Encuentra cualquier pasaje en milisegundos. |
55
+ | `T` | **Camaleón**: Cambia entre temas visuales épicos. |
56
+ | `F` | **Favoritos**: Tus versículos para la eternidad. |
57
+ | `N` | **Apostolado**: Añade tus notas personales. |
58
+ | `Q` | **Salir**: Hasta la próxima sesión de gloria. |
59
+
60
+ ---
61
+
62
+ ## 🔥 El Futuro es Ahora
63
+
64
+ Biblia CLI no es solo software, es una declaración de intenciones. Es la unión de la tecnología más puntera con la sabiduría más antigua. **Rápido. Elegante. Infalible.**
65
+
66
+ > "Lámpara es a mis pies tu palabra, Y lumbrera a mi camino." — Salmos 119:105
67
+
68
+ ---
69
+ © 2026 - Biblia CLI Project. Built with love for the Glory of Christ, made in Katowice, Poland, born in Basque Country.
@@ -0,0 +1,59 @@
1
+ # ⚡ BIBLIA CLI: CRISTO Edition ⚡
2
+
3
+ > **La Palabra de Dios no solo se lee, se vive. Y ahora, se domina desde la Terminal.**
4
+
5
+ Bienvenido a **Biblia CLI**, la herramienta definitiva para el guerrero espiritual moderno y el desarrollador que no sacrifica su fe por su productividad. Olvida las interfaces lentas, las distracciones de la web y el ruido visual. Esto es velocidad pura. Esto es **CRISTO** en tu consola.
6
+
7
+ ---
8
+
9
+ ## 🚀 ¿Por qué Biblia CLI?
10
+
11
+ Biblia CLI no es un simple lector; es un motor de búsqueda y estudio bíblico optimizado para el rendimiento. Diseñado con una estética **Premium**, ofrece una experiencia de usuario que te hará volar la cabeza.
12
+
13
+ ### ✨ Características que rompen moldes:
14
+
15
+ * **⚡ Velocidad de Vértigo**: Acceso instantáneo a cualquier libro y capítulo. Navegación fluida diseñada para los que no tienen tiempo que perder.
16
+ * **📖 Sincronización Litúrgica Real**: Pulsa `D` y conecta directamente con el **calendario litúrgico oficial del Vaticano**. No es una lectura aleatoria, es la Palabra que la Iglesia proclama en todo el mundo, hoy mismo.
17
+ * **🎨 Estética de Vanguardia**: Temas visuales impecables. Desde el modo **Sepia** para lecturas profundas hasta el **Matrix** para sesiones de código nocturnas, y un **Tema Claro** refinado con alto contraste que se ve simplemente increíble.
18
+ * **🛰️ Conexión Bolls.life**: Acceso a la API más robusta del mercado para garantizar que los textos siempre estén ahí cuando los necesites con la máxima fidelidad.
19
+ * **⭐ Gestión de Favoritos y Notas**: Marca tus versículos clave y guarda tus reflexiones directamente en la terminal. Tu estudio bíblico, centralizado y potente.
20
+ * **🕊️ CRISTO Splash Screen**: Una pantalla de inicio en arte ASCII que impone respeto y te recuerda el propósito de tu estudio desde el segundo uno.
21
+
22
+ ---
23
+
24
+ ## 🛠️ Instalación para Elegidos
25
+
26
+ Si tienes Python 3.10+, estás a un comando de la gloria:
27
+
28
+ ```powershell
29
+ pip install -e .
30
+ ```
31
+
32
+ Una vez instalado, simplemente escribe:
33
+ ```powershell
34
+ biblia
35
+ ```
36
+
37
+ ---
38
+
39
+ ## ⌨️ Domina el Arca (Atajos de Teclado)
40
+
41
+ | Tecla | Acción |
42
+ | :--- | :--- |
43
+ | `D` | **Sincronización Divina**: Abre la lectura litúrgica de HOY. |
44
+ | `/` | **Buscador Ninja**: Encuentra cualquier pasaje en milisegundos. |
45
+ | `T` | **Camaleón**: Cambia entre temas visuales épicos. |
46
+ | `F` | **Favoritos**: Tus versículos para la eternidad. |
47
+ | `N` | **Apostolado**: Añade tus notas personales. |
48
+ | `Q` | **Salir**: Hasta la próxima sesión de gloria. |
49
+
50
+ ---
51
+
52
+ ## 🔥 El Futuro es Ahora
53
+
54
+ Biblia CLI no es solo software, es una declaración de intenciones. Es la unión de la tecnología más puntera con la sabiduría más antigua. **Rápido. Elegante. Infalible.**
55
+
56
+ > "Lámpara es a mis pies tu palabra, Y lumbrera a mi camino." — Salmos 119:105
57
+
58
+ ---
59
+ © 2026 - Biblia CLI Project. Built with love for the Glory of Christ, made in Katowice, Poland, born in Basque Country.
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,70 @@
1
+ """
2
+ Anotaciones del autor — cifradas en Supabase, descifradas en memoria.
3
+
4
+ Flujo:
5
+ · En Supabase: texto cifrado con Fernet (ilegible sin la clave)
6
+ · En la app: se descifra al vuelo con ANN_ENCRYPT_KEY de settings.py
7
+ · En disco: nada se guarda
8
+ """
9
+ from __future__ import annotations
10
+ import json
11
+ from pathlib import Path
12
+
13
+ _chapter_cache: dict[str, dict[int, str]] = {}
14
+ _LOCAL_DRAFT = Path.home() / ".biblia-cli" / "ann_drafts.json"
15
+
16
+ def _prefix(t, b, c): return f"{t}/{b}/{c}"
17
+ def _key(t, b, c, v): return f"{t}/{b}/{c}/{v}"
18
+
19
+ def _client():
20
+ from .settings import SUPABASE_URL, SUPABASE_ANON_KEY
21
+ from supabase import create_client
22
+ return create_client(SUPABASE_URL, SUPABASE_ANON_KEY)
23
+
24
+ def _decrypt(text: str) -> str:
25
+ try:
26
+ from cryptography.fernet import Fernet
27
+ from .settings import ANN_ENCRYPT_KEY
28
+ return Fernet(ANN_ENCRYPT_KEY.encode()).decrypt(text.encode()).decode()
29
+ except Exception:
30
+ return text # si falla el descifrado devuelve el texto tal cual
31
+
32
+ # ── Supabase (lectura + descifrado) ──────────────────────────────────────────
33
+ async def get_chapter(translation: str, book_id: int, chapter: int) -> dict[int, str]:
34
+ key = _prefix(translation, book_id, chapter)
35
+ if key in _chapter_cache: return _chapter_cache[key]
36
+ try:
37
+ res = _client().table("annotations") \
38
+ .select("verse, text") \
39
+ .eq("translation", translation) \
40
+ .eq("book_id", book_id) \
41
+ .eq("chapter", chapter) \
42
+ .execute()
43
+ result = {r["verse"]: _decrypt(r["text"]) for r in (res.data or [])}
44
+ except Exception:
45
+ result = {}
46
+ _chapter_cache[key] = result
47
+ return result
48
+
49
+ def invalidate(translation, book_id, chapter):
50
+ _chapter_cache.pop(_prefix(translation, book_id, chapter), None)
51
+
52
+ # ── Borrador local (modal de escritura in-app) ────────────────────────────────
53
+ def _load_drafts():
54
+ if _LOCAL_DRAFT.exists():
55
+ try: return json.loads(_LOCAL_DRAFT.read_text(encoding="utf-8"))
56
+ except Exception: pass
57
+ return {}
58
+
59
+ def _save_drafts(data):
60
+ _LOCAL_DRAFT.parent.mkdir(parents=True, exist_ok=True)
61
+ _LOCAL_DRAFT.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
62
+
63
+ def get_note_local(translation, book_id, chapter, verse) -> str | None:
64
+ return _load_drafts().get(_key(translation, book_id, chapter, verse))
65
+
66
+ def save_local(translation, book_id, chapter, verse, text):
67
+ data = _load_drafts(); data[_key(translation, book_id, chapter, verse)] = text; _save_drafts(data)
68
+
69
+ def delete_local(translation, book_id, chapter, verse):
70
+ data = _load_drafts(); data.pop(_key(translation, book_id, chapter, verse), None); _save_drafts(data)
File without changes
@@ -0,0 +1,43 @@
1
+ import re, httpx
2
+ from .. import cache as local_cache
3
+
4
+ BASE = "https://bolls.life"
5
+ TIMEOUT = 15.0
6
+
7
+ TRANSLATIONS = {
8
+ "Español 🇪🇸": [("RV1960", "Reina-Valera 1960")],
9
+ "Português 🇧🇷": [("ARA", "Almeida Revista e Atualizada")],
10
+ }
11
+ PT_CODES = {"ARA","ARC09","NVIPT","NTLH","NVT","NAA","OL","KJA","CNBB","TB10","ACF11","NTJud","VFL","NBV07","ALM21"}
12
+
13
+ def _clean(text):
14
+ text = re.sub(r"<br\s*/?>", "\n", text, flags=re.IGNORECASE)
15
+ return re.sub(r"<[^>]+>", "", text).strip()
16
+
17
+ class BollsClient:
18
+ async def get_books(self, translation):
19
+ async with httpx.AsyncClient(timeout=TIMEOUT) as c:
20
+ r = await c.get(f"{BASE}/get-books/{translation}/"); r.raise_for_status()
21
+ return r.json()
22
+
23
+ async def get_chapter(self, translation, book_id, chapter):
24
+ cached = local_cache.load(translation, book_id, chapter)
25
+ if cached: return cached
26
+ async with httpx.AsyncClient(timeout=TIMEOUT) as c:
27
+ r = await c.get(f"{BASE}/get-text/{translation}/{book_id}/{chapter}/"); r.raise_for_status()
28
+ verses = r.json()
29
+ for v in verses: v["text"] = _clean(v["text"])
30
+ local_cache.save(translation, book_id, chapter, verses)
31
+ return verses
32
+
33
+ async def search(self, translation, query, page=1, limit=50):
34
+ async with httpx.AsyncClient(timeout=TIMEOUT) as c:
35
+ r = await c.get(f"{BASE}/v2/find/{translation}", params={"search":query,"page":page,"limit":limit})
36
+ r.raise_for_status(); data = r.json()
37
+ for res in data.get("results",[]): res["text"] = _clean(res["text"])
38
+ return data
39
+
40
+ async def get_random_verse(self, translation):
41
+ async with httpx.AsyncClient(timeout=TIMEOUT) as c:
42
+ r = await c.get(f"{BASE}/get-random-verse/{translation}/"); r.raise_for_status()
43
+ v = r.json(); v["text"] = _clean(v["text"]); return v
@@ -0,0 +1,254 @@
1
+ from __future__ import annotations
2
+ from textual import on, work
3
+ import httpx
4
+ from textual.app import App, ComposeResult
5
+ from textual.binding import Binding
6
+ from textual.containers import Horizontal, Vertical, VerticalScroll
7
+ from textual.reactive import reactive
8
+ from textual.screen import ModalScreen
9
+ from textual.widgets import Button, Footer, Header, Input, Label, ListItem, ListView, Static
10
+ from .api.bolls_client import BollsClient
11
+ from .book_names import get_books_for_lang, lang_for_translation
12
+ from .config import Config
13
+ from .favorites import toggle as fav_toggle, is_favorite
14
+ from .notes import get_chapter_notes
15
+ from .annotations import get_chapter as ann_get_chapter
16
+ from .themes import next_theme, LABELS
17
+ from .widgets.annotation_modal import AnnotationReadModal, AnnotationWriteModal
18
+ from .widgets.favorites_modal import FavoritesModal
19
+ from .widgets.note_modal import NoteModal
20
+ from .widgets.search_modal import SearchModal
21
+ from .widgets.splash_screen import SplashScreen
22
+ from .widgets.translation_modal import TranslationModal
23
+
24
+ class BibliaApp(App):
25
+ CSS_PATH = "css/app.tcss"
26
+ ENABLE_COMMAND_PALETTE = False
27
+ BINDINGS = [
28
+ Binding("q", "quit", "Salir"),
29
+ Binding("t", "switch_lang", "[T] Idioma"),
30
+ Binding("ctrl+t", "cycle_theme", "[^T] Tema"),
31
+ Binding("/", "open_search", "[/] Buscar"),
32
+ Binding("ctrl+f", "toggle_fav", "[^F] ★"),
33
+ Binding("f", "open_favs", "[F] Marcadores"),
34
+ Binding("n", "add_note", "[N] Nota"),
35
+ Binding("h", "read_ann", "[H] ✦ Leer"),
36
+ Binding("ctrl+h", "write_ann", "[^H] ✦ Escribir"),
37
+ Binding("d", "daily", "[D] Lectura hoy"),
38
+ Binding("escape", "clear_filter", "Limpiar"),
39
+ ]
40
+ translation: reactive[str] = reactive("RV1960")
41
+ biblia_theme: reactive[str] = reactive("dark")
42
+
43
+ def __init__(self):
44
+ super().__init__()
45
+ self.cfg = Config(); self.client = BollsClient()
46
+ self.books: list[dict] = []; self.book: dict | None = None
47
+ self.chapter: int = 1; self._verses_cache: list[dict] = []
48
+
49
+ def compose(self) -> ComposeResult:
50
+ yield Header(show_clock=True)
51
+ with Horizontal(id="main"):
52
+ with Vertical(id="books-panel"):
53
+ yield Label("📚 LIBROS", classes="panel-title")
54
+ yield Input(placeholder="🔍 Filtrar...", id="books-filter")
55
+ yield ListView(id="books-list")
56
+ with Vertical(id="chapters-panel"):
57
+ yield Label("📖 CAP.", classes="panel-title")
58
+ yield ListView(id="chapters-list")
59
+ with VerticalScroll(id="scripture-panel"):
60
+ yield Label("", id="scripture-title")
61
+ yield Label("", id="fav-indicator")
62
+ yield Static("", id="scripture-content", markup=True)
63
+ yield Footer()
64
+
65
+ def on_mount(self):
66
+ self.push_screen(SplashScreen())
67
+ self._start()
68
+
69
+ def _start(self, _=None):
70
+ s = self.cfg.load(); self.translation = s["translation"]; self.biblia_theme = s.get("theme","dark")
71
+ self._set_title()
72
+ self.load_books(s.get("last_book_id"), s.get("last_chapter",1))
73
+
74
+ def _set_title(self):
75
+ self.title = f"📖 Biblia CLI · {self.translation} · {LABELS[self.biblia_theme]}"
76
+
77
+ def watch_biblia_theme(self, t: str):
78
+ self._apply_theme(t)
79
+
80
+ def on_screen_resume(self) -> None:
81
+ self._apply_theme(self.biblia_theme)
82
+
83
+ def _apply_theme(self, t):
84
+ for o in [self, self.screen]:
85
+ for c in list(o.classes):
86
+ if c.startswith("theme-"): o.remove_class(c)
87
+ o.add_class(f"theme-{t}")
88
+ @work(exclusive=True)
89
+ async def load_books(self, restore_id=None, restore_ch=1):
90
+ bl = self.query_one("#books-list", ListView); bl.clear()
91
+ try: self.books = await self.client.get_books(self.translation)
92
+ except Exception:
93
+ self.books = get_books_for_lang(lang_for_translation(self.translation))
94
+ self.notify("Sin conexión — lista offline", timeout=3)
95
+ seen = set()
96
+ for b in self.books:
97
+ bid = b["bookid"]
98
+ if bid in seen: continue
99
+ seen.add(bid)
100
+ li = ListItem(Label(b["name"]))
101
+ li.book_id = bid
102
+ bl.append(li)
103
+ if restore_id:
104
+ tgt = next((b for b in self.books if b["bookid"]==restore_id), None)
105
+ if tgt: self.book=tgt; self._fill_chapters(tgt["chapters"],restore_ch); self.load_scripture()
106
+
107
+ def _fill_chapters(self, total, highlight=1):
108
+ cl = self.query_one("#chapters-list", ListView); cl.clear()
109
+ for i in range(1, total+1): cl.append(ListItem(Label(str(i))))
110
+ if highlight <= total: self.chapter=highlight; cl.index=highlight-1
111
+
112
+ @work(exclusive=True)
113
+ async def load_scripture(self):
114
+ if not self.book: return
115
+ tw=self.query_one("#scripture-title",Label); cw=self.query_one("#scripture-content",Static); fw=self.query_one("#fav-indicator",Label)
116
+ tw.update("⏳ Cargando..."); cw.update(""); fw.update("")
117
+ try: raw = await self.client.get_chapter(self.translation, self.book["bookid"], self.chapter)
118
+ except Exception as e: tw.update(f"[red]❌ {e}[/red]"); return
119
+ self._verses_cache = raw
120
+ verses = raw
121
+ notes = get_chapter_notes(self.translation, self.book["bookid"], self.chapter)
122
+ ann = await ann_get_chapter(self.translation, self.book["bookid"], self.chapter)
123
+ tw.update(f"[bold #d4a017]{self.book['name']}[/bold #d4a017] [bold cyan]Cap. {self.chapter}[/bold cyan] [dim]{self.translation}[/dim]")
124
+ fw.update("[bold #d4a017]⭐ Marcado[/bold #d4a017]" if is_favorite(self.translation,self.book["bookid"],self.chapter) else "")
125
+ lines=[]
126
+ for v in verses:
127
+ vn=v["verse"]
128
+ note_icon = " [bold #d4a017]✏[/bold #d4a017]" if vn in notes else ""
129
+ ann_marker = " [bold #aaaaaa]***[/bold #aaaaaa]" if vn in ann else ""
130
+ lines.append(f"[bold cyan]{vn:>3}[/bold cyan]{note_icon} {v['text']}{ann_marker}")
131
+ if vn in notes:
132
+ for nl in notes[vn].splitlines(): lines.append(f" [italic #d4a017]│ {nl}[/italic #d4a017]")
133
+ lines.append("")
134
+ cw.update("\n".join(lines))
135
+ self.cfg.save({"translation":self.translation,"last_book_id":self.book["bookid"],"last_chapter":self.chapter,"theme":self.biblia_theme})
136
+
137
+ @on(ListView.Selected,"#books-list")
138
+ def on_book(self,event):
139
+ if event.item and hasattr(event.item, "book_id"):
140
+ bid = event.item.book_id
141
+ self.book=next((b for b in self.books if b["bookid"]==bid),None)
142
+ if self.book: self._fill_chapters(self.book["chapters"]); self.chapter=1; self.load_scripture(); self.query_one("#chapters-list",ListView).focus()
143
+
144
+ @on(ListView.Selected,"#chapters-list")
145
+ def on_chapter(self,_):
146
+ idx=self.query_one("#chapters-list",ListView).index
147
+ if idx is not None: self.chapter=idx+1; self.load_scripture()
148
+
149
+ @on(Input.Changed,"#books-filter")
150
+ def filter_books(self,event):
151
+ q=event.value.strip().lower(); bl=self.query_one("#books-list",ListView); bl.clear()
152
+ seen = set()
153
+ for b in self.books:
154
+ bid = b["bookid"]
155
+ if q in b["name"].lower() and bid not in seen:
156
+ seen.add(bid)
157
+ li = ListItem(Label(b["name"]))
158
+ li.book_id = bid
159
+ bl.append(li)
160
+
161
+ def action_switch_lang(self): self.push_screen(TranslationModal(),self._on_trans)
162
+ def _on_trans(self,t):
163
+ if t: self.translation=t; self.cfg.save({"translation":t}); self._set_title(); self.load_books()
164
+ def action_cycle_theme(self):
165
+ self.biblia_theme=next_theme(self.biblia_theme); self._set_title(); self.cfg.save({"theme":self.biblia_theme}); self.notify(f"Tema: {LABELS[self.biblia_theme]}",timeout=1.5)
166
+ def action_open_search(self):
167
+ if self.books: self.push_screen(SearchModal(self.translation,self.books),self._on_search)
168
+ def _on_search(self,r):
169
+ if r:
170
+ bid,ch=r; b=next((x for x in self.books if x["bookid"]==bid),None)
171
+ if b: self.book=b; self.chapter=ch; self._fill_chapters(b["chapters"],ch); self.load_scripture()
172
+ def action_toggle_fav(self):
173
+ if not self.book: self.notify("Abre un capítulo primero.",timeout=2); return
174
+ added=fav_toggle(self.translation,self.book["bookid"],self.book["name"],self.chapter)
175
+ self.notify("⭐ Marcado" if added else "★ Eliminado",timeout=2)
176
+ self.query_one("#fav-indicator",Label).update("[bold #d4a017]⭐ Marcado[/bold #d4a017]" if added else "")
177
+ def action_open_favs(self): self.push_screen(FavoritesModal(),self._on_fav)
178
+ def _on_fav(self,fav):
179
+ if fav:
180
+ b=next((x for x in self.books if x["bookid"]==fav["book_id"]),None)
181
+ if b: self.book=b; self.chapter=fav["chapter"]; self._fill_chapters(b["chapters"],fav["chapter"]); self.load_scripture()
182
+ def action_add_note(self):
183
+ if not self.book: self.notify("Abre un capítulo primero.",timeout=2); return
184
+ self.push_screen(_VP(self.book["name"],self.chapter,"✏️ Nota en"),self._open_note)
185
+ def _open_note(self,v):
186
+ if v: self.push_screen(NoteModal(self.translation,self.book["bookid"],self.book["name"],self.chapter,v),lambda c: self.load_scripture() if c else None)
187
+ def action_read_ann(self):
188
+ if not self.book: self.notify("Abre un capítulo primero.",timeout=2); return
189
+ self.push_screen(_VP(self.book["name"],self.chapter,"✦ Ver nota de"),self._open_ann_read)
190
+ def _open_ann_read(self,verse):
191
+ if verse: self._do_read_ann(verse)
192
+ @work
193
+ async def _do_read_ann(self,verse:int):
194
+ ann=await ann_get_chapter(self.translation,self.book["bookid"],self.chapter)
195
+ if verse in ann: self.push_screen(AnnotationReadModal(self.book["name"],self.chapter,verse,ann[verse]))
196
+ else: self.notify(f"No hay nota ✦ en el versículo {verse}.",timeout=2)
197
+ def action_write_ann(self):
198
+ if not self.book: self.notify("Abre un capítulo primero.",timeout=2); return
199
+ self.push_screen(_VP(self.book["name"],self.chapter,"✦ Nota de autor en"),self._open_ann_write)
200
+ def _open_ann_write(self,verse):
201
+ if verse: self.push_screen(AnnotationWriteModal(self.translation,self.book["bookid"],self.book["name"],self.chapter,verse),lambda c: self.load_scripture() if c else None)
202
+ def action_daily(self): self._do_daily()
203
+ @work(exclusive=True)
204
+ async def _do_daily(self):
205
+ if not self.books: return
206
+ self.notify("⏳ Obteniendo lectura litúrgica...", timeout=2)
207
+ try:
208
+ import json, re
209
+ async with httpx.AsyncClient(timeout=10, follow_redirects=True) as c:
210
+ r = await c.get("https://universalis.com/Europe.Spain/jsonpmass.js")
211
+ r.raise_for_status()
212
+ txt = r.text
213
+ mj = re.search(r"universalisCallback\((.*)\);", txt, re.DOTALL)
214
+ if not mj: raise Exception("Error de formato")
215
+ data = json.loads(mj.group(1))
216
+ src = data.get("Mass_R1",{}).get("source","")
217
+ if not src: src = data.get("Mass_G",{}).get("source","")
218
+ m = re.search(r"^([1-3]?\s?[a-zA-Z\s]+)\s+(\d+)", src)
219
+ if not m: raise Exception(f"No parseable: {src}")
220
+ bname, ch = m.group(1).strip(), int(m.group(2))
221
+ from .book_names import resolve_book
222
+ bid = resolve_book(bname)
223
+ if not bid: raise Exception(f"No reconozco {bname}")
224
+ tgt = next((b for b in self.books if b["bookid"]==bid),None)
225
+ if not tgt: raise Exception(f"{bname} no disponible")
226
+ self.book=tgt; self.chapter=ch; self._fill_chapters(tgt["chapters"],ch); self.load_scripture()
227
+ self.notify(f"📖 {src}", timeout=3)
228
+ except Exception as e: self.notify(f"Error lectura: {e}", severity="error")
229
+ def action_clear_filter(self):
230
+ f=self.query_one("#books-filter",Input)
231
+ if f.value: f.value=""; self.filter_books(Input.Changed(f,""))
232
+ else: self.query_one("#books-list",ListView).focus()
233
+
234
+ class _VP(ModalScreen):
235
+ DEFAULT_CSS="""
236
+ _VP { align: center middle; }
237
+ #vp-box { width: 46; height: auto; background: $surface; border: thick $primary; padding: 1 2; }
238
+ #vp-title { text-style: bold; color: $accent; text-align: center; width: 100%; margin-bottom: 1; }
239
+ #vp-foot { height: 3; margin-top: 1; align: right middle; }
240
+ """
241
+ def __init__(self,book_name,chapter,label): super().__init__(); self._label=f"{label} {book_name} {chapter}"
242
+ def compose(self) -> ComposeResult:
243
+ with Vertical(id="vp-box"):
244
+ yield Label(self._label,id="vp-title"); yield Input(placeholder="Número de versículo...",id="vp-input")
245
+ with Horizontal(id="vp-foot"): yield Button("Aceptar",id="vp-ok",variant="primary"); yield Button("Cancelar",id="vp-no")
246
+ def on_mount(self): self.query_one("#vp-input",Input).focus()
247
+ @on(Button.Pressed,"#vp-ok")
248
+ @on(Input.Submitted,"#vp-input")
249
+ def accept(self,_=None):
250
+ val=self.query_one("#vp-input",Input).value.strip(); self.dismiss(int(val) if val.isdigit() else None)
251
+ @on(Button.Pressed,"#vp-no")
252
+ def cancel(self): self.dismiss(None)
253
+
254
+ def main(): BibliaApp().run()
@@ -0,0 +1,86 @@
1
+ import unicodedata
2
+ from .api.bolls_client import PT_CODES
3
+
4
+ def _n(s):
5
+ return "".join(c for c in unicodedata.normalize("NFD",s.lower()) if unicodedata.category(c)!="Mn")
6
+
7
+ BOOKS = [
8
+ (1,"Génesis","Gênesis",50),(2,"Éxodo","Êxodo",40),(3,"Levítico","Levítico",27),
9
+ (4,"Números","Números",36),(5,"Deuteronomio","Deuteronômio",34),(6,"Josué","Josué",24),
10
+ (7,"Jueces","Juízes",21),(8,"Rut","Rute",4),(9,"1 Samuel","1 Samuel",31),
11
+ (10,"2 Samuel","2 Samuel",24),(11,"1 Reyes","1 Reis",22),(12,"2 Reyes","2 Reis",25),
12
+ (13,"1 Crónicas","1 Crônicas",29),(14,"2 Crónicas","2 Crônicas",36),
13
+ (15,"Esdras","Esdras",10),(16,"Nehemías","Neemias",13),(17,"Ester","Ester",10),
14
+ (18,"Job","Jó",42),(19,"Salmos","Salmos",150),(20,"Proverbios","Provérbios",31),
15
+ (21,"Eclesiastés","Eclesiastes",12),(22,"Cantares","Cantares",8),
16
+ (23,"Isaías","Isaías",66),(24,"Jeremías","Jeremias",52),
17
+ (25,"Lamentaciones","Lamentações",5),(26,"Ezequiel","Ezequiel",48),
18
+ (27,"Daniel","Daniel",12),(28,"Oseas","Oséias",14),(29,"Joel","Joel",3),
19
+ (30,"Amós","Amós",9),(31,"Abdías","Obadias",1),(32,"Jonás","Jonas",4),
20
+ (33,"Miqueas","Miquéias",7),(34,"Nahúm","Naum",3),(35,"Habacuc","Habacuque",3),
21
+ (36,"Sofonías","Sofonias",3),(37,"Hageo","Ageu",2),(38,"Zacarías","Zacarias",14),
22
+ (39,"Malaquías","Malaquias",4),(40,"Mateo","Mateus",28),(41,"Marcos","Marcos",16),
23
+ (42,"Lucas","Lucas",24),(43,"Juan","João",21),(44,"Hechos","Atos",28),
24
+ (45,"Romanos","Romanos",16),(46,"1 Corintios","1 Coríntios",16),
25
+ (47,"2 Corintios","2 Coríntios",13),(48,"Gálatas","Gálatas",6),
26
+ (49,"Efesios","Efésios",6),(50,"Filipenses","Filipenses",4),
27
+ (51,"Colosenses","Colossenses",4),(52,"1 Tesalonicenses","1 Tessalonicenses",5),
28
+ (53,"2 Tesalonicenses","2 Tessalonicenses",3),(54,"1 Timoteo","1 Timóteo",6),
29
+ (55,"2 Timoteo","2 Timóteo",4),(56,"Tito","Tito",3),(57,"Filemón","Filemom",1),
30
+ (58,"Hebreos","Hebreus",13),(59,"Santiago","Tiago",5),(60,"1 Pedro","1 Pedro",5),
31
+ (61,"2 Pedro","2 Pedro",3),(62,"1 Juan","1 João",5),(63,"2 Juan","2 João",1),
32
+ (64,"3 Juan","3 João",1),(65,"Judas","Judas",1),(66,"Apocalipsis","Apocalipse",22),
33
+ ]
34
+
35
+ def get_books_for_lang(lang):
36
+ return [{"bookid":b,"name":es if lang=="es" else pt,"chapters":ch} for b,es,pt,ch in BOOKS]
37
+
38
+ def lang_for_translation(code):
39
+ return "pt" if code in PT_CODES else "es"
40
+
41
+ _ES, _PT = {}, {}
42
+ for _bid,_nes,_npt,_ in BOOKS:
43
+ _ES[_n(_nes)]=_bid; _PT[_n(_npt)]=_bid
44
+
45
+ _ES.update({"gn":1,"gen":1,"ex":2,"lv":3,"lev":3,"nm":4,"num":4,"dt":5,"jos":6,"jue":7,"rt":8,
46
+ "1sm":9,"1sa":9,"2sm":10,"2sa":10,"1re":11,"2re":12,"1cr":13,"2cr":14,"esd":15,"ne":16,
47
+ "neh":16,"est":17,"jb":18,"sal":19,"sl":19,"ps":19,"pr":20,"prov":20,"ec":21,"ecl":21,
48
+ "ct":22,"is":23,"isa":23,"jr":24,"jer":24,"lm":25,"ez":26,"dn":27,"dan":27,"os":28,
49
+ "jl":29,"am":30,"ab":31,"jon":32,"mi":33,"na":34,"hab":35,"sof":36,"sf":36,"hag":37,
50
+ "zac":38,"mal":39,"mt":40,"mc":41,"mr":41,"lc":42,"jn":43,"hch":44,"act":44,"ro":45,
51
+ "rom":45,"1co":46,"2co":47,"gl":48,"ga":48,"ef":49,"fp":50,"col":51,"1ts":52,"2ts":53,
52
+ "1tm":54,"1ti":54,"2tm":55,"2ti":55,"tt":56,"flm":57,"he":58,"heb":58,"stg":59,"sg":59,
53
+ "1pe":60,"2pe":61,"1jn":62,"1j":62,"2jn":63,"3jn":64,"jud":65,"ap":66,"apoc":66})
54
+ _PT.update({"gn":1,"ex":2,"lv":3,"nm":4,"dt":5,"js":6,"jz":7,"rt":8,"1sm":9,"2sm":10,
55
+ "1rs":11,"2rs":12,"1cr":13,"2cr":14,"ed":15,"ne":16,"et":17,"sl":19,"pv":20,"ec":21,
56
+ "ct":22,"is":23,"jr":24,"lm":25,"ez":26,"dn":27,"os":28,"jl":29,"am":30,"ob":31,
57
+ "mq":33,"na":34,"hc":35,"sf":36,"ag":37,"zc":38,"ml":39,"mt":40,"mc":41,"lc":42,
58
+ "jo":43,"at":44,"rm":45,"1co":46,"2co":47,"gl":48,"ef":49,"fp":50,"cl":51,"1ts":52,
59
+ "2ts":53,"1tm":54,"2tm":55,"tt":56,"fm":57,"hb":58,"tg":59,"1pe":60,"2pe":61,
60
+ "1jo":62,"2jo":63,"3jo":64,"jd":65,"ap":66})
61
+
62
+ _EN = {"gn":1,"gen":1,"ex":2,"lv":3,"lev":3,"nm":4,"num":4,"dt":5,"jos":6,"jue":7,"rt":8,
63
+ "1sm":9,"1sa":9,"2sm":10,"2sa":10,"1re":11,"2re":12,"1cr":13,"2cr":14,"esd":15,"ne":16,
64
+ "est":17,"jb":18,"sal":19,"sl":19,"ps":19,"pr":20,"prov":20,"ec":21,"ecl":21,
65
+ "ct":22,"is":23,"isa":23,"jr":24,"jer":24,"lm":25,"ez":26,"dn":27,"dan":27,"os":28,
66
+ "jl":29,"am":30,"ab":31,"jon":32,"mi":33,"na":34,"hab":35,"sof":36,"sf":36,"hag":37,
67
+ "zac":38,"mal":39,"mt":40,"mc":41,"mr":41,"lc":42,"jn":43,"hch":44,"act":44,"ro":45,
68
+ "rom":45,"1co":46,"2co":47,"gl":48,"ga":48,"ef":49,"fp":50,"col":51,"1ts":52,"2ts":53,
69
+ "1tm":54,"1ti":54,"2tm":55,"2ti":55,"tt":56,"flm":57,"he":58,"heb":58,"stg":59,"sg":59,
70
+ "1pe":60,"2pe":61,"1jn":62,"1j":62,"2jn":63,"3jn":64,"jud":65,"ap":66,"apoc":66,
71
+ "genesis":1,"exodus":2,"leviticus":3,"numbers":4,"deuteronomy":5,"joshua":6,"judges":7,"ruth":8,
72
+ "1 samuel":9,"2 samuel":10,"1 kings":11,"2 kings":12,"1 chronicles":13,"2 chronicles":14,
73
+ "ezra":15,"nehemiah":16,"esther":17,"job":18,"psalms":19,"proverbs":20,"ecclesiastes":21,
74
+ "song of songs":22,"isaiah":23,"jeremiah":24,"lamentations":25,"ezekiel":26,"daniel":27,
75
+ "hosea":28,"joel":29,"amos":30,"obadiah":31,"jonah":32,"micah":33,"nahum":34,"habakkuk":35,
76
+ "zephaniah":36,"haggai":37,"zechariah":38,"malachi":39,"matthew":40,"mark":41,"luke":42,
77
+ "john":43,"acts":44,"romans":45,"1 corinthians":46,"2 corinthians":47,"galatians":48,
78
+ "ephesians":49,"philippians":50,"colossians":51,"1 thessalonians":52,"2 thessalonians":53,
79
+ "1 timothy":54,"2 timothy":55,"titus":56,"philemon":57,"hebrews":58,"james":59,"1 peter":60,
80
+ "2 peter":61,"1 john":62,"2 john":63,"3 john":64,"jude":65,"revelation":66}
81
+
82
+ def resolve_book(name, lang="es"):
83
+ n = _n(name.strip())
84
+ if lang == "es": return _ES.get(n) or _EN.get(n)
85
+ if lang == "pt": return _PT.get(n) or _EN.get(n)
86
+ return _EN.get(n)
@@ -0,0 +1,22 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ CACHE_DIR = Path.home() / ".biblia-cli" / "cache"
5
+
6
+ def _p(t, b, c): return CACHE_DIR / t / f"{b}_{c}.json"
7
+
8
+ def load(translation, book_id, chapter):
9
+ p = _p(translation, book_id, chapter)
10
+ if p.exists():
11
+ try: return json.loads(p.read_text(encoding="utf-8"))
12
+ except Exception: pass
13
+ return None
14
+
15
+ def save(translation, book_id, chapter, verses):
16
+ p = _p(translation, book_id, chapter)
17
+ p.parent.mkdir(parents=True, exist_ok=True)
18
+ p.write_text(json.dumps(verses, ensure_ascii=False), encoding="utf-8")
19
+
20
+ def stats():
21
+ if not CACHE_DIR.exists(): return {}
22
+ return {d.name: len(list(d.glob("*.json"))) for d in CACHE_DIR.iterdir() if d.is_dir()}