pyolivetree 2.0.0__py3-none-any.whl
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.
- pyolivetree/__init__.py +37 -0
- pyolivetree/__main__.py +3 -0
- pyolivetree/_http.py +116 -0
- pyolivetree/async_client.py +131 -0
- pyolivetree/cli.py +42 -0
- pyolivetree/client.py +273 -0
- pyolivetree/constants.py +101 -0
- pyolivetree/exceptions.py +35 -0
- pyolivetree/notes.py +174 -0
- pyolivetree/py.typed +0 -0
- pyolivetree/resources/__init__.py +27 -0
- pyolivetree/resources/_base.py +20 -0
- pyolivetree/resources/account.py +117 -0
- pyolivetree/resources/ai.py +41 -0
- pyolivetree/resources/firebase.py +24 -0
- pyolivetree/resources/media.py +44 -0
- pyolivetree/resources/messages.py +46 -0
- pyolivetree/resources/products.py +55 -0
- pyolivetree/resources/reading.py +53 -0
- pyolivetree/resources/store.py +185 -0
- pyolivetree/resources/sync.py +156 -0
- pyolivetree/resources/telemetry.py +63 -0
- pyolivetree/resources/web.py +61 -0
- pyolivetree-2.0.0.dist-info/METADATA +168 -0
- pyolivetree-2.0.0.dist-info/RECORD +28 -0
- pyolivetree-2.0.0.dist-info/WHEEL +4 -0
- pyolivetree-2.0.0.dist-info/entry_points.txt +2 -0
- pyolivetree-2.0.0.dist-info/licenses/LICENSE +21 -0
pyolivetree/constants.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Hosts, protocol values and signing keys for the Olive Tree APIs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
#: Main site: store, account, legacy APIs, in-app message center.
|
|
8
|
+
WWW = "https://www.olivetree.com"
|
|
9
|
+
#: Store JSON API (catalog, library, purchases).
|
|
10
|
+
STORE = f"{WWW}/store/br5/app/json/store"
|
|
11
|
+
#: Account JSON API (login, tokens, subscriptions).
|
|
12
|
+
ACCOUNT = f"{WWW}/store/br5/app/json/account"
|
|
13
|
+
#: In-app XML API (product registration and download links).
|
|
14
|
+
XML = f"{WWW}/store/br5/app/in-app/xml"
|
|
15
|
+
#: Sync server for notes, highlights, library and reading-plan data.
|
|
16
|
+
SYNC = "https://sync.olivetree.com"
|
|
17
|
+
#: Smart Search AI.
|
|
18
|
+
AI_API = "https://biblesearch-ai.olivetree.com/api/v1"
|
|
19
|
+
#: Smart Search AI staging host (serves the model list).
|
|
20
|
+
AI_STAGING_API = "https://staging-biblesearch-ai.olivetree.com/api/v1"
|
|
21
|
+
#: Event analytics and A/B experiments.
|
|
22
|
+
ANALYTICS = "https://analytics.olivetree.com/api/v1"
|
|
23
|
+
#: Consent, privacy and session telemetry.
|
|
24
|
+
PRIVACY = "https://privacy.olivetree.com/api/v1"
|
|
25
|
+
#: Text-to-speech for audio reading plans.
|
|
26
|
+
SPEECH = "https://speech.olivetree.com/api"
|
|
27
|
+
#: Static images (covers, reading-plan art).
|
|
28
|
+
CDN = "https://cdn.olivetree.com"
|
|
29
|
+
#: Legacy product images.
|
|
30
|
+
MEDIA = "https://media.olivetree.com"
|
|
31
|
+
#: Verse-of-the-day images.
|
|
32
|
+
VOTD = "https://votd.olivetree.com"
|
|
33
|
+
#: Help center.
|
|
34
|
+
HELP = "https://help.olivetree.com"
|
|
35
|
+
#: Firebase Realtime Database.
|
|
36
|
+
FIREBASE = "https://biblereader-android-df00f.firebaseio.com"
|
|
37
|
+
|
|
38
|
+
#: Sync protocol version for token-based accounts (4 otherwise).
|
|
39
|
+
SYNC_PROTOCOL = 6
|
|
40
|
+
#: Client platform id.
|
|
41
|
+
PLATFORM_ID = 26
|
|
42
|
+
#: Server ``errorCode`` values that mean the token is missing or expired.
|
|
43
|
+
TOKEN_ERRORS = frozenset({1018, 1020})
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class DataSet(str, Enum):
|
|
47
|
+
"""Sync data sets. Every method that takes a data set accepts a member or its string value.
|
|
48
|
+
|
|
49
|
+
Members are strings, so ``DataSet.ANNOTATIONS == "annotations"``.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
#: Notes, highlights, clips, categories and highlighters.
|
|
53
|
+
ANNOTATIONS = "annotations"
|
|
54
|
+
#: Library categories, document details and last-read locations.
|
|
55
|
+
LIBRARY = "library"
|
|
56
|
+
#: Reading plans and their progress.
|
|
57
|
+
DAILY_READING = "daily_reading"
|
|
58
|
+
#: Recently opened text and audio locations.
|
|
59
|
+
BOOKSHELF = "bookshelf"
|
|
60
|
+
|
|
61
|
+
def __str__(self) -> str:
|
|
62
|
+
return self.value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
#: All data sets, in backup order.
|
|
66
|
+
DATA_SETS = tuple(DataSet)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def data_set_name(data_set: DataSet | str) -> str:
|
|
70
|
+
"""Return the plain name of a data set.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
data_set: A :class:`DataSet`, its value (``"annotations"``), or the server form
|
|
74
|
+
(``"managed_annotations"``). Other strings pass through unchanged.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
e.g. ``"annotations"``.
|
|
78
|
+
"""
|
|
79
|
+
name = data_set.value if isinstance(data_set, DataSet) else str(data_set)
|
|
80
|
+
return name.removeprefix("managed_")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
#: HMAC-SHA512 client keys for signed endpoints, by service.
|
|
84
|
+
HMAC_KEYS = {
|
|
85
|
+
"telemetry": bytes.fromhex(
|
|
86
|
+
"483136e789e9781c4bf4b94a405da7b4c3109a559d1429883e71eb032cfcdc9"
|
|
87
|
+
"ceb3f76381639321b4890449ac0093c3c103e9b4b19e213d3c34f02dc4106a87e"
|
|
88
|
+
),
|
|
89
|
+
"ai": bytes.fromhex(
|
|
90
|
+
"c69b54518884da105554f4dda9ac30b16d14f1d084318bbc389f00debaa0f37c"
|
|
91
|
+
"3f29b7494afdbe41abb7eab9c7eb5bb234cb02b2f9355cc8997c105756d580a6"
|
|
92
|
+
),
|
|
93
|
+
"consent": bytes.fromhex(
|
|
94
|
+
"f91c94eb56d2e0ae31b902828b931243b5a0956c8f99a4ace778655d39f65511"
|
|
95
|
+
"f4f6123046516609630241a879d705c6f02b3d197bbbe78e16265eeaac87f875"
|
|
96
|
+
),
|
|
97
|
+
"features": bytes.fromhex(
|
|
98
|
+
"d11bde62682b4b41253c4f6eec12d2a0095aa1802f92343e703af16a29168617"
|
|
99
|
+
"081c6859384f960d7680989c0600ba596e2a5d17d0444424fdd4b367ce75402b"
|
|
100
|
+
),
|
|
101
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Exceptions raised by pyolivetree.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`OliveTreeError`, so ``except OliveTreeError`` catches everything.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class OliveTreeError(Exception):
|
|
12
|
+
"""Base error for pyolivetree.
|
|
13
|
+
|
|
14
|
+
Attributes:
|
|
15
|
+
status: HTTP status code (0 if no request was made).
|
|
16
|
+
code: Olive Tree ``errorCode`` from the body, if any (e.g. 1018 = invalid token).
|
|
17
|
+
body: Parsed response body, for inspection.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, status: int = 0, code: int | None = None, body: Any = None) -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.status, self.code, self.body = status, code, body
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def status_code(self) -> int:
|
|
26
|
+
"""Same as :attr:`status`."""
|
|
27
|
+
return self.status
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class OliveTreeAPIError(OliveTreeError):
|
|
31
|
+
"""The server returned an HTTP error (status >= 400 other than 401)."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class OliveTreeAuthError(OliveTreeError):
|
|
35
|
+
"""Login failed, credentials are missing, the token was rejected, or signing failed."""
|
pyolivetree/notes.py
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""Back up your sync data to SQLite and export notes and highlights as Markdown.
|
|
2
|
+
|
|
3
|
+
The backup is a plain SQLite file with one table, ``syncables``, holding every synced row
|
|
4
|
+
(``data_set``, ``table_name``, ``id``, ``sequence_number``, ``deleted``, ``datum`` as JSON),
|
|
5
|
+
plus a ``cursor`` table so later runs only fetch changes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as dt
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import sqlite3
|
|
14
|
+
from collections.abc import Iterable
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
from pyolivetree.constants import DATA_SETS, DataSet, data_set_name
|
|
18
|
+
from pyolivetree.exceptions import OliveTreeError
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from pyolivetree.client import OliveTree
|
|
22
|
+
|
|
23
|
+
# annotation_type_id is a bit flag on the server. NOTE: mapped from a real account's data
|
|
24
|
+
# (1 = typed note text, 16 = has highlighter_id, 256 = copied verse text); other bits may exist.
|
|
25
|
+
#: ``annotation_type_id`` -> kind name.
|
|
26
|
+
ANNOTATION_TYPES = {1: "note", 16: "highlight", 256: "clip"}
|
|
27
|
+
#: Book names in canonical order; ``book_begin`` is a 1-based index into this.
|
|
28
|
+
BOOKS = (
|
|
29
|
+
"Genesis Exodus Leviticus Numbers Deuteronomy Joshua Judges Ruth 1Samuel 2Samuel 1Kings "
|
|
30
|
+
"2Kings 1Chronicles 2Chronicles Ezra Nehemiah Esther Job Psalms Proverbs Ecclesiastes "
|
|
31
|
+
"SongOfSolomon Isaiah Jeremiah Lamentations Ezekiel Daniel Hosea Joel Amos Obadiah Jonah "
|
|
32
|
+
"Micah Nahum Habakkuk Zephaniah Haggai Zechariah Malachi Matthew Mark Luke John Acts Romans "
|
|
33
|
+
"1Corinthians 2Corinthians Galatians Ephesians Philippians Colossians 1Thessalonians "
|
|
34
|
+
"2Thessalonians 1Timothy 2Timothy Titus Philemon Hebrews James 1Peter 2Peter 1John 2John "
|
|
35
|
+
"3John Jude Revelation"
|
|
36
|
+
).split()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def verse_ref(d: dict[str, Any]) -> str:
|
|
40
|
+
"""Format a row's verse range.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
d: A ``datum`` with ``book_begin``/``chapter_begin``/``verse_begin`` (and optional ``_end``).
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
e.g. ``"John 3:16-18"`` or ``"Psalms 1:1 - 2:3"``; ``""`` if there is no book.
|
|
47
|
+
"""
|
|
48
|
+
b = d.get("book_begin")
|
|
49
|
+
if not b:
|
|
50
|
+
return ""
|
|
51
|
+
name = BOOKS[b - 1] if 0 < b <= len(BOOKS) else f"Book{b}"
|
|
52
|
+
ref = f"{name} {d.get('chapter_begin', '?')}:{d.get('verse_begin', '?')}"
|
|
53
|
+
end = (d.get("book_end"), d.get("chapter_end"), d.get("verse_end"))
|
|
54
|
+
if end[2] and end != (b, d.get("chapter_begin"), d.get("verse_begin")):
|
|
55
|
+
ref += f"-{end[2]}" if end[:2] == (b, d.get("chapter_begin")) else f" - {end[1]}:{end[2]}"
|
|
56
|
+
return ref
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def backup(
|
|
60
|
+
ot: OliveTree, db_path: str = "olivetree.db", data_sets: Iterable[DataSet | str] = DATA_SETS
|
|
61
|
+
) -> dict[str, int]:
|
|
62
|
+
"""Mirror your sync data into a SQLite file, fetching only what changed since the last run.
|
|
63
|
+
|
|
64
|
+
Args:
|
|
65
|
+
ot: A client.
|
|
66
|
+
db_path: SQLite file to create or update.
|
|
67
|
+
data_sets: Data sets to back up (:class:`~pyolivetree.DataSet` members or names).
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
Rows fetched per data set, e.g. ``{"annotations": 12, "library": 0, ...}``.
|
|
71
|
+
"""
|
|
72
|
+
db = sqlite3.connect(db_path)
|
|
73
|
+
db.executescript("""CREATE TABLE IF NOT EXISTS syncables(
|
|
74
|
+
data_set TEXT, table_name TEXT, id INTEGER, sequence_number INTEGER,
|
|
75
|
+
table_version INTEGER, deleted INTEGER, datum TEXT,
|
|
76
|
+
PRIMARY KEY(table_name, id));
|
|
77
|
+
CREATE TABLE IF NOT EXISTS cursor(data_set TEXT PRIMARY KEY, above INTEGER);""")
|
|
78
|
+
counts = {}
|
|
79
|
+
try:
|
|
80
|
+
for ds in map(data_set_name, data_sets):
|
|
81
|
+
row = db.execute("SELECT above FROM cursor WHERE data_set=?", (ds,)).fetchone()
|
|
82
|
+
above, n = (row[0] if row else 0), 0
|
|
83
|
+
for item in ot.sync.pull(ds, above):
|
|
84
|
+
seq = int(item["sequence_number"])
|
|
85
|
+
db.execute(
|
|
86
|
+
"INSERT OR REPLACE INTO syncables VALUES(?,?,?,?,?,?,?)",
|
|
87
|
+
(
|
|
88
|
+
ds,
|
|
89
|
+
item["table_name"],
|
|
90
|
+
int(item["id"]),
|
|
91
|
+
seq,
|
|
92
|
+
item.get("table_version"),
|
|
93
|
+
int(item.get("deleted") or 0),
|
|
94
|
+
json.dumps(item.get("datum") or {}),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
above, n = max(above, seq), n + 1
|
|
98
|
+
# Commit per data set so an interrupted run keeps what it already downloaded.
|
|
99
|
+
db.execute("INSERT OR REPLACE INTO cursor VALUES(?,?)", (ds, above))
|
|
100
|
+
db.commit()
|
|
101
|
+
counts[ds] = n
|
|
102
|
+
finally:
|
|
103
|
+
db.close()
|
|
104
|
+
return counts
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def annotations(db_path: str = "olivetree.db", kind: str | None = "note") -> list[dict[str, Any]]:
|
|
108
|
+
"""Read annotations from a backup, in Bible order. Deleted rows are skipped.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
db_path: SQLite file made by :func:`backup`.
|
|
112
|
+
kind: ``"note"``, ``"highlight"``, ``"clip"``, or ``None`` for all.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Each row's ``datum`` plus ``type``, ``ref`` (see :func:`verse_ref`) and ``color``
|
|
116
|
+
(highlighter name). Text is in ``content``, the verse label in ``title``.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
OliveTreeError: If ``db_path`` is not a backup yet (run :func:`backup` first).
|
|
120
|
+
"""
|
|
121
|
+
if not os.path.isfile(db_path):
|
|
122
|
+
raise OliveTreeError(f"No backup at {db_path!r}; run backup() first.")
|
|
123
|
+
db = sqlite3.connect(db_path)
|
|
124
|
+
if not db.execute("SELECT 1 FROM sqlite_master WHERE name='syncables'").fetchone():
|
|
125
|
+
db.close()
|
|
126
|
+
raise OliveTreeError(f"{db_path!r} is not a pyolivetree backup; run backup() first.")
|
|
127
|
+
try:
|
|
128
|
+
rows = db.execute(
|
|
129
|
+
"SELECT datum FROM syncables WHERE table_name='annotations' AND deleted=0"
|
|
130
|
+
" ORDER BY json_extract(datum,'$.book_begin'), json_extract(datum,'$.chapter_begin'),"
|
|
131
|
+
" json_extract(datum,'$.verse_begin'), json_extract(datum,'$.created_date')"
|
|
132
|
+
).fetchall()
|
|
133
|
+
colors = dict(
|
|
134
|
+
db.execute(
|
|
135
|
+
"SELECT json_extract(datum,'$.client_id'), json_extract(datum,'$.name')"
|
|
136
|
+
" FROM syncables WHERE table_name='highlighters'"
|
|
137
|
+
).fetchall()
|
|
138
|
+
)
|
|
139
|
+
finally:
|
|
140
|
+
db.close()
|
|
141
|
+
out = []
|
|
142
|
+
for (datum,) in rows:
|
|
143
|
+
d = json.loads(datum)
|
|
144
|
+
kind_of = ANNOTATION_TYPES.get(d.get("annotation_type_id"), str(d.get("annotation_type_id")))
|
|
145
|
+
if kind is None or kind_of == kind:
|
|
146
|
+
out.append(
|
|
147
|
+
{
|
|
148
|
+
**d,
|
|
149
|
+
"type": kind_of,
|
|
150
|
+
"ref": verse_ref(d),
|
|
151
|
+
"color": colors.get(d.get("highlighter_id")),
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
return out
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def to_markdown(items: list[dict[str, Any]]) -> str:
|
|
158
|
+
"""Render annotations as Markdown, one ``#`` section per Bible book.
|
|
159
|
+
|
|
160
|
+
Args:
|
|
161
|
+
items: Output of :func:`annotations`.
|
|
162
|
+
"""
|
|
163
|
+
parts, book = [], None
|
|
164
|
+
for it in items:
|
|
165
|
+
b = it["ref"].split(" ")[0] or "Other"
|
|
166
|
+
if b != book:
|
|
167
|
+
book = b
|
|
168
|
+
parts.append(f"# {book}\n")
|
|
169
|
+
when = dt.datetime.fromtimestamp(it["created_date"]).date() if it.get("created_date") else ""
|
|
170
|
+
meta = " · ".join(str(x) for x in (it["type"], it.get("color"), when) if x)
|
|
171
|
+
title = f"*{it['title']}* — " if it.get("title") else ""
|
|
172
|
+
parts.append(f"## {it['ref'] or it.get('title') or 'Untitled'}\n{title}{meta}\n")
|
|
173
|
+
parts.append((it.get("content") or "").strip() + "\n")
|
|
174
|
+
return "\n".join(parts)
|
pyolivetree/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Endpoint groups. Each is attached to :class:`~pyolivetree.OliveTree` as an attribute."""
|
|
2
|
+
|
|
3
|
+
from pyolivetree.resources.account import Account
|
|
4
|
+
from pyolivetree.resources.ai import AI
|
|
5
|
+
from pyolivetree.resources.firebase import Firebase
|
|
6
|
+
from pyolivetree.resources.media import Media
|
|
7
|
+
from pyolivetree.resources.messages import Messages
|
|
8
|
+
from pyolivetree.resources.products import Products
|
|
9
|
+
from pyolivetree.resources.reading import Reading
|
|
10
|
+
from pyolivetree.resources.store import Store
|
|
11
|
+
from pyolivetree.resources.sync import Sync
|
|
12
|
+
from pyolivetree.resources.telemetry import Telemetry
|
|
13
|
+
from pyolivetree.resources.web import Web
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"AI",
|
|
17
|
+
"Account",
|
|
18
|
+
"Firebase",
|
|
19
|
+
"Media",
|
|
20
|
+
"Messages",
|
|
21
|
+
"Products",
|
|
22
|
+
"Reading",
|
|
23
|
+
"Store",
|
|
24
|
+
"Sync",
|
|
25
|
+
"Telemetry",
|
|
26
|
+
"Web",
|
|
27
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Base class shared by every resource group."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from pyolivetree.client import OliveTree
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Resource:
|
|
12
|
+
"""A group of related endpoints bound to one :class:`~pyolivetree.OliveTree` client."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, client: OliveTree) -> None:
|
|
15
|
+
self._c = client
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def ids(product_ids: tuple[int, ...]) -> str:
|
|
19
|
+
"""Join ids the way the API expects in paths: ``"1,2,3"``."""
|
|
20
|
+
return ",".join(map(str, product_ids))
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Account endpoints: login, tokens, signup and subscriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyolivetree.constants import ACCOUNT
|
|
8
|
+
from pyolivetree.resources._base import Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Account(Resource):
|
|
12
|
+
"""``www.olivetree.com/store/br5/app/json/account``. Available as ``ot.account``."""
|
|
13
|
+
|
|
14
|
+
def _creds(self, **kw: Any) -> dict[str, Any]:
|
|
15
|
+
c = self._c
|
|
16
|
+
return {
|
|
17
|
+
"user": c.username,
|
|
18
|
+
"pass": c.password,
|
|
19
|
+
"install_id": c.device_id,
|
|
20
|
+
"supportsAccessToken": 1,
|
|
21
|
+
**kw,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
def login(self, **kw: Any) -> dict:
|
|
25
|
+
"""Log in with the client's username and password and store the tokens.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
**kw: Extra form fields (e.g. ``subscribe_to_newsletter``).
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
``{"authenticated", "user_id", "authentication_token", "expires", "refresh_token"}``.
|
|
32
|
+
"""
|
|
33
|
+
data = self._c.request("POST", f"{ACCOUNT}/account_login", data=self._creds(**kw), auth=False)
|
|
34
|
+
self._c._take_tokens(data)
|
|
35
|
+
return data
|
|
36
|
+
|
|
37
|
+
def access_token(self, **kw: Any) -> dict:
|
|
38
|
+
"""Exchange credentials for an access token (older login flow). Stores the tokens.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
Same shape as :meth:`login`.
|
|
42
|
+
"""
|
|
43
|
+
data = self._c.request("POST", f"{ACCOUNT}/access_token", data=self._creds(**kw), auth=False)
|
|
44
|
+
self._c._take_tokens(data)
|
|
45
|
+
return data
|
|
46
|
+
|
|
47
|
+
def refresh_token(self) -> dict:
|
|
48
|
+
"""Swap the stored refresh token for a new access token. Stores the new tokens.
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Same shape as :meth:`login`.
|
|
52
|
+
"""
|
|
53
|
+
data = self._c.request(
|
|
54
|
+
"POST",
|
|
55
|
+
f"{ACCOUNT}/refresh_token",
|
|
56
|
+
auth=False,
|
|
57
|
+
headers={"OTAuthorization": f"Bearer {self._c.refresh_token}"},
|
|
58
|
+
)
|
|
59
|
+
self._c._take_tokens(data)
|
|
60
|
+
return data
|
|
61
|
+
|
|
62
|
+
def signup(self, email: str, password: str, first_name: str = "", **kw: Any) -> dict:
|
|
63
|
+
"""Create a new Olive Tree account.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
email: Account email.
|
|
67
|
+
password: Account password.
|
|
68
|
+
first_name: Optional first name.
|
|
69
|
+
**kw: Extra fields such as ``subscribe_to_newsletter``.
|
|
70
|
+
"""
|
|
71
|
+
return self._c.request(
|
|
72
|
+
"POST",
|
|
73
|
+
f"{ACCOUNT}/account_signup",
|
|
74
|
+
auth=False,
|
|
75
|
+
data={**self._creds(**kw), "user": email, "pass": password, "first_name": first_name},
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def forgot_password(self, email: str) -> dict:
|
|
79
|
+
"""Send a password-reset email.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
email: Account email.
|
|
83
|
+
"""
|
|
84
|
+
return self._c.request("POST", f"{ACCOUNT}/forgot_password", data={"email": email}, auth=False)
|
|
85
|
+
|
|
86
|
+
def web_handoff_token(self, storefront_code: str | None = None) -> dict:
|
|
87
|
+
"""Get a one-time token for opening olivetree.com already logged in.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
storefront_code: Optional storefront code.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
``{"token": ...}``; pass it to :meth:`~pyolivetree.resources.web.Web.app_to_web_bridge`
|
|
94
|
+
as ``login_handoff_token``.
|
|
95
|
+
"""
|
|
96
|
+
return self._c.request(
|
|
97
|
+
"POST", f"{ACCOUNT}/generate_web_handoff_token", data={"storefront_code": storefront_code}
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def subscription(self, subscription_id: int) -> dict:
|
|
101
|
+
"""Details of one of your subscriptions.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
subscription_id: Subscription id.
|
|
105
|
+
"""
|
|
106
|
+
return self._c.request("POST", f"{ACCOUNT}/subscription/{subscription_id}")
|
|
107
|
+
|
|
108
|
+
def add_variable_subscription_selections(self, selection_id: int, **data: Any) -> dict:
|
|
109
|
+
"""Save product choices for a pick-your-books subscription.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
selection_id: Selection id.
|
|
113
|
+
**data: Form fields the store expects.
|
|
114
|
+
"""
|
|
115
|
+
return self._c.request(
|
|
116
|
+
"POST", f"{ACCOUNT}/add_variable_subscription_selections/{selection_id}", data=data
|
|
117
|
+
)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Smart Search AI endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyolivetree.constants import AI_API, AI_STAGING_API
|
|
8
|
+
from pyolivetree.resources._base import Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class AI(Resource):
|
|
12
|
+
"""``biblesearch-ai.olivetree.com``. Available as ``ot.ai``. Requests are HMAC-signed."""
|
|
13
|
+
|
|
14
|
+
def query(self, query: str, books: list[str] | None = None) -> Any:
|
|
15
|
+
"""Ask Smart Search a question. Uses your Smart Search quota.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
query: Question or topic, e.g. ``"verses about patience"``.
|
|
19
|
+
books: Limit results to these books, e.g. ``["Romans", "James"]``.
|
|
20
|
+
"""
|
|
21
|
+
body: dict[str, Any] = {"query": query}
|
|
22
|
+
if books:
|
|
23
|
+
body["filters"] = {"books": books}
|
|
24
|
+
return self._c.request("POST", f"{AI_API}/query", json=body, sign="ai")
|
|
25
|
+
|
|
26
|
+
def status(self) -> dict:
|
|
27
|
+
"""Your Smart Search usage and limits (``user_limits``, ``status``, ...)."""
|
|
28
|
+
return self._c.request("GET", f"{AI_API}/status")
|
|
29
|
+
|
|
30
|
+
def set_usage(self, input_tokens: int, output_tokens: int, requests: int) -> Any:
|
|
31
|
+
"""Report Smart Search usage. Changes your usage record."""
|
|
32
|
+
return self._c.request(
|
|
33
|
+
"PUT",
|
|
34
|
+
f"{AI_API}/set-my-usage",
|
|
35
|
+
sign="ai",
|
|
36
|
+
json={"input_tokens": input_tokens, "output_tokens": output_tokens, "requests": requests},
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
def models(self) -> list:
|
|
40
|
+
"""Available AI models."""
|
|
41
|
+
return self._c.request("GET", f"{AI_STAGING_API}/models", auth=False)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Firebase Realtime Database."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyolivetree.constants import FIREBASE
|
|
8
|
+
from pyolivetree.resources._base import Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Firebase(Resource):
|
|
12
|
+
"""Available as ``ot.firebase``. Requests are unauthenticated."""
|
|
13
|
+
|
|
14
|
+
def get(self, path: str = "") -> Any:
|
|
15
|
+
"""Read a node, e.g. ``get("config")``. The server may refuse access (HTTP 423 or 401).
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
path: Node path; empty for the root.
|
|
19
|
+
"""
|
|
20
|
+
return self._c.request("GET", f"{FIREBASE}/{path.strip('/')}.json", auth=False)
|
|
21
|
+
|
|
22
|
+
def root(self) -> Any:
|
|
23
|
+
"""Read the database root (``/.json``)."""
|
|
24
|
+
return self.get()
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Images and media streams."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyolivetree.constants import CDN, MEDIA, WWW
|
|
8
|
+
from pyolivetree.resources._base import Resource
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Media(Resource):
|
|
12
|
+
"""Available as ``ot.media``. Image methods return ``bytes``."""
|
|
13
|
+
|
|
14
|
+
def _get(self, url: str) -> Any:
|
|
15
|
+
return self._c.request("GET", url, auth=False)
|
|
16
|
+
|
|
17
|
+
def cover(self, product_id: int) -> bytes:
|
|
18
|
+
"""Product cover (JPEG)."""
|
|
19
|
+
return self._get(f"{CDN}/images/covers/{product_id}.jpg")
|
|
20
|
+
|
|
21
|
+
def cover_stack(self, product_id: int) -> bytes:
|
|
22
|
+
"""Stacked cover for book sets (PNG). 404 for single products."""
|
|
23
|
+
return self._get(f"{CDN}/images/covers/{product_id}_stack.png")
|
|
24
|
+
|
|
25
|
+
def product_image(self, product_id: int) -> bytes:
|
|
26
|
+
"""Legacy product image (JPEG)."""
|
|
27
|
+
return self._get(f"{MEDIA}/store/images40/{product_id}.jpg")
|
|
28
|
+
|
|
29
|
+
def reading_plan_image(self, template_id: int, size: str = "hero") -> bytes:
|
|
30
|
+
"""Reading-plan artwork.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
template_id: Reading-plan template id.
|
|
34
|
+
size: ``hero``, ``carousel``, ``grid``, ``list`` or ``portrait``.
|
|
35
|
+
"""
|
|
36
|
+
return self._get(f"{CDN}/images/reading_plans/{template_id}_{size}.jpg")
|
|
37
|
+
|
|
38
|
+
def reading_plan_list_image(self, template_id: int) -> bytes:
|
|
39
|
+
"""Reading-plan list thumbnail (JPEG)."""
|
|
40
|
+
return self._get(f"{WWW}/store/images/reading_plans/{template_id}_list.jpg")
|
|
41
|
+
|
|
42
|
+
def stream(self, content_id: int, name: str) -> Any:
|
|
43
|
+
"""MPEG-DASH manifest for audio/video content you own."""
|
|
44
|
+
return self._c.request("GET", f"{WWW}/media/content/{content_id}/{name}/stream.mpd")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""In-app message center."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pyolivetree.constants import WWW
|
|
8
|
+
from pyolivetree.resources._base import Resource
|
|
9
|
+
|
|
10
|
+
_BASE = f"{WWW}/api/messages/v1"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Messages(Resource):
|
|
14
|
+
"""Available as ``ot.messages``. Requests are HMAC-signed.
|
|
15
|
+
|
|
16
|
+
The server answers 401 until the device is registered with :meth:`register_device`.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def list(self) -> Any:
|
|
20
|
+
"""Messages for this device."""
|
|
21
|
+
return self._c.request(
|
|
22
|
+
"POST",
|
|
23
|
+
f"{_BASE}/messages.php",
|
|
24
|
+
sign="telemetry",
|
|
25
|
+
auth=False,
|
|
26
|
+
json={"install_id": self._c.device_id},
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def register_device(self, **body: Any) -> Any:
|
|
30
|
+
"""Register this device for messages. Body fields are not documented."""
|
|
31
|
+
return self._c.request("POST", f"{_BASE}/devices.php", json=body, sign="telemetry", auth=False)
|
|
32
|
+
|
|
33
|
+
def metrics(self, message_id: int, metrics: dict) -> Any:
|
|
34
|
+
"""Report that a message was seen/clicked.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
message_id: Message id from :meth:`list`.
|
|
38
|
+
metrics: Metric values to report.
|
|
39
|
+
"""
|
|
40
|
+
return self._c.request(
|
|
41
|
+
"POST",
|
|
42
|
+
f"{_BASE}/metrics.php",
|
|
43
|
+
sign="telemetry",
|
|
44
|
+
auth=False,
|
|
45
|
+
json={"install_id": self._c.device_id, "message_id": message_id, "metrics": metrics},
|
|
46
|
+
)
|