tracktolib 0.67.0__py3-none-any.whl → 0.68.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.
- tracktolib/api.py +10 -10
- tracktolib/notion/__init__.py +44 -0
- tracktolib/notion/blocks.py +459 -0
- tracktolib/notion/cache.py +202 -0
- tracktolib/notion/fetch.py +121 -5
- tracktolib/notion/markdown.py +468 -0
- tracktolib/notion/models.py +27 -0
- tracktolib/notion/utils.py +567 -0
- tracktolib/pg_sync.py +2 -2
- tracktolib/utils.py +39 -3
- {tracktolib-0.67.0.dist-info → tracktolib-0.68.0.dist-info}/METADATA +21 -1
- tracktolib-0.68.0.dist-info/RECORD +25 -0
- tracktolib-0.67.0.dist-info/RECORD +0 -21
- {tracktolib-0.67.0.dist-info → tracktolib-0.68.0.dist-info}/WHEEL +0 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Mapping, Self, Sequence, TypedDict, cast
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .utils import PageComment
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CachedDatabase(TypedDict):
|
|
16
|
+
id: str
|
|
17
|
+
title: str
|
|
18
|
+
properties: dict[str, Any]
|
|
19
|
+
cached_at: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class CachedPageBlocks(TypedDict):
|
|
23
|
+
"""Cached blocks for a page."""
|
|
24
|
+
|
|
25
|
+
page_id: str
|
|
26
|
+
blocks: list[dict[str, Any]]
|
|
27
|
+
cached_at: str
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class CachedPageComments(TypedDict):
|
|
31
|
+
"""Cached comments for a page."""
|
|
32
|
+
|
|
33
|
+
page_id: str
|
|
34
|
+
comments: list[dict[str, Any]]
|
|
35
|
+
cached_at: str
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class CacheData(TypedDict, total=False):
|
|
39
|
+
databases: dict[str, CachedDatabase]
|
|
40
|
+
page_blocks: dict[str, CachedPageBlocks]
|
|
41
|
+
page_comments: dict[str, CachedPageComments]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _default_cache_dir() -> Path:
|
|
45
|
+
"""Default cache directory: $XDG_CACHE_HOME/tracktolib/notion or ~/.cache/tracktolib/notion."""
|
|
46
|
+
xdg_cache = os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))
|
|
47
|
+
return Path(xdg_cache) / "tracktolib" / "notion"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class NotionCache:
|
|
52
|
+
"""Persistent cache for Notion data.
|
|
53
|
+
|
|
54
|
+
Use as a context manager to load on entry and save on exit:
|
|
55
|
+
|
|
56
|
+
with NotionCache() as cache:
|
|
57
|
+
db = cache.get_database("db-id")
|
|
58
|
+
cache.set_database({"id": "new-db", ...})
|
|
59
|
+
# Automatically saved on exit
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
# Directory for cache file.
|
|
63
|
+
cache_dir: Path = field(default_factory=_default_cache_dir)
|
|
64
|
+
_file_path: Path = field(init=False)
|
|
65
|
+
_data: CacheData = field(init=False)
|
|
66
|
+
_dirty: bool = field(init=False, default=False)
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
self._file_path = self.cache_dir / "cache.json"
|
|
70
|
+
self._data = {}
|
|
71
|
+
|
|
72
|
+
def load(self) -> None:
|
|
73
|
+
"""Load cache from disk into memory."""
|
|
74
|
+
if self._file_path.exists():
|
|
75
|
+
self._data = json.loads(self._file_path.read_text())
|
|
76
|
+
else:
|
|
77
|
+
self._data = {}
|
|
78
|
+
self._dirty = False
|
|
79
|
+
|
|
80
|
+
def save(self) -> None:
|
|
81
|
+
"""Save in-memory cache to disk (only if modified)."""
|
|
82
|
+
if not self._dirty:
|
|
83
|
+
return
|
|
84
|
+
self._file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
tmp = self._file_path.with_suffix(".tmp")
|
|
86
|
+
tmp.write_text(json.dumps(self._data, indent=2, default=str))
|
|
87
|
+
tmp.rename(self._file_path)
|
|
88
|
+
self._dirty = False
|
|
89
|
+
|
|
90
|
+
def __enter__(self) -> Self:
|
|
91
|
+
self.load()
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(
|
|
95
|
+
self,
|
|
96
|
+
exc_type: type[BaseException] | None,
|
|
97
|
+
exc_val: BaseException | None,
|
|
98
|
+
exc_tb: TracebackType | None,
|
|
99
|
+
) -> None:
|
|
100
|
+
self.save()
|
|
101
|
+
|
|
102
|
+
def get_database(self, database_id: str) -> CachedDatabase | None:
|
|
103
|
+
"""Get a cached database by ID."""
|
|
104
|
+
return self._data.get("databases", {}).get(database_id)
|
|
105
|
+
|
|
106
|
+
def get_databases(self) -> dict[str, CachedDatabase]:
|
|
107
|
+
"""Get all cached databases."""
|
|
108
|
+
return self._data.get("databases", {})
|
|
109
|
+
|
|
110
|
+
def set_database(self, database: Mapping[str, Any]) -> CachedDatabase:
|
|
111
|
+
"""Cache a database from Notion API response."""
|
|
112
|
+
title_prop = database.get("title", [])
|
|
113
|
+
title = next(
|
|
114
|
+
(el["plain_text"] for el in title_prop if isinstance(el, Mapping) and "plain_text" in el),
|
|
115
|
+
"",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
entry: CachedDatabase = {
|
|
119
|
+
"id": database["id"],
|
|
120
|
+
"title": title,
|
|
121
|
+
"properties": database["properties"],
|
|
122
|
+
"cached_at": datetime.now().isoformat(),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if "databases" not in self._data:
|
|
126
|
+
self._data["databases"] = {}
|
|
127
|
+
self._data["databases"][database["id"]] = entry
|
|
128
|
+
self._dirty = True
|
|
129
|
+
return entry
|
|
130
|
+
|
|
131
|
+
def delete_database(self, database_id: str) -> None:
|
|
132
|
+
"""Remove a database from cache."""
|
|
133
|
+
if "databases" in self._data:
|
|
134
|
+
self._data["databases"].pop(database_id, None)
|
|
135
|
+
self._dirty = True
|
|
136
|
+
|
|
137
|
+
def clear(self) -> None:
|
|
138
|
+
"""Clear all cached data."""
|
|
139
|
+
self._data = {}
|
|
140
|
+
self._dirty = True
|
|
141
|
+
|
|
142
|
+
def get_page_blocks(self, page_id: str) -> list[dict[str, Any]] | None:
|
|
143
|
+
"""Get cached blocks for the given page_id, or None if not cached."""
|
|
144
|
+
cached = self._data.get("page_blocks", {}).get(page_id)
|
|
145
|
+
if cached:
|
|
146
|
+
return cached["blocks"]
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
def set_page_blocks(
|
|
150
|
+
self,
|
|
151
|
+
page_id: str,
|
|
152
|
+
blocks: Sequence[dict[str, Any]],
|
|
153
|
+
) -> CachedPageBlocks:
|
|
154
|
+
"""Cache the given blocks for page_id and return the cached entry."""
|
|
155
|
+
entry: CachedPageBlocks = {
|
|
156
|
+
"page_id": page_id,
|
|
157
|
+
"blocks": list(blocks),
|
|
158
|
+
"cached_at": datetime.now().isoformat(),
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if "page_blocks" not in self._data:
|
|
162
|
+
self._data["page_blocks"] = {}
|
|
163
|
+
self._data["page_blocks"][page_id] = entry
|
|
164
|
+
self._dirty = True
|
|
165
|
+
return entry
|
|
166
|
+
|
|
167
|
+
def delete_page_blocks(self, page_id: str) -> None:
|
|
168
|
+
"""Remove cached blocks for the given page_id."""
|
|
169
|
+
if "page_blocks" in self._data:
|
|
170
|
+
self._data["page_blocks"].pop(page_id, None)
|
|
171
|
+
self._dirty = True
|
|
172
|
+
|
|
173
|
+
def get_page_comments(self, page_id: str) -> list[PageComment] | None:
|
|
174
|
+
"""Get cached comments for the given page_id, or None if not cached."""
|
|
175
|
+
cached = self._data.get("page_comments", {}).get(page_id)
|
|
176
|
+
if cached:
|
|
177
|
+
return cached["comments"] # type: ignore[return-value]
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
def set_page_comments(
|
|
181
|
+
self,
|
|
182
|
+
page_id: str,
|
|
183
|
+
comments: Sequence[PageComment],
|
|
184
|
+
) -> CachedPageComments:
|
|
185
|
+
"""Cache the given comments for page_id and return the cached entry."""
|
|
186
|
+
entry: CachedPageComments = {
|
|
187
|
+
"page_id": page_id,
|
|
188
|
+
"comments": cast(list[dict[str, Any]], list(comments)),
|
|
189
|
+
"cached_at": datetime.now().isoformat(),
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if "page_comments" not in self._data:
|
|
193
|
+
self._data["page_comments"] = {}
|
|
194
|
+
self._data["page_comments"][page_id] = entry
|
|
195
|
+
self._dirty = True
|
|
196
|
+
return entry
|
|
197
|
+
|
|
198
|
+
def delete_page_comments(self, page_id: str) -> None:
|
|
199
|
+
"""Remove cached comments for the given page_id."""
|
|
200
|
+
if "page_comments" in self._data:
|
|
201
|
+
self._data["page_comments"].pop(page_id, None)
|
|
202
|
+
self._dirty = True
|
tracktolib/notion/fetch.py
CHANGED
|
@@ -1,14 +1,22 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import os
|
|
2
|
-
from typing import Any, Literal
|
|
4
|
+
from typing import TYPE_CHECKING, Any, Literal, Sequence, overload
|
|
3
5
|
|
|
4
6
|
try:
|
|
5
7
|
import niquests
|
|
6
8
|
except ImportError:
|
|
7
9
|
raise ImportError('Please install niquests or tracktolib with "notion" to use this module')
|
|
8
10
|
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from .blocks import NotionBlock
|
|
13
|
+
from .cache import CachedDatabase, NotionCache
|
|
14
|
+
|
|
9
15
|
from .models import (
|
|
10
16
|
Block,
|
|
11
17
|
BlockListResponse,
|
|
18
|
+
Comment,
|
|
19
|
+
CommentListResponse,
|
|
12
20
|
Database,
|
|
13
21
|
IntrospectTokenResponse,
|
|
14
22
|
Page,
|
|
@@ -61,6 +69,10 @@ __all__ = (
|
|
|
61
69
|
"fetch_block",
|
|
62
70
|
"fetch_block_children",
|
|
63
71
|
"fetch_append_block_children",
|
|
72
|
+
"delete_block",
|
|
73
|
+
# Comments
|
|
74
|
+
"fetch_comments",
|
|
75
|
+
"create_comment",
|
|
64
76
|
# Search
|
|
65
77
|
"fetch_search",
|
|
66
78
|
)
|
|
@@ -217,7 +229,7 @@ async def create_page(
|
|
|
217
229
|
*,
|
|
218
230
|
parent: dict[str, Any],
|
|
219
231
|
properties: dict[str, Any],
|
|
220
|
-
children:
|
|
232
|
+
children: Sequence[NotionBlock | dict[str, Any]] | None = None,
|
|
221
233
|
icon: dict[str, Any] | None = None,
|
|
222
234
|
cover: dict[str, Any] | None = None,
|
|
223
235
|
api_version: ApiVersion | None = None,
|
|
@@ -274,17 +286,46 @@ async def update_page(
|
|
|
274
286
|
# Databases endpoints
|
|
275
287
|
|
|
276
288
|
|
|
289
|
+
@overload
|
|
290
|
+
async def fetch_database(
|
|
291
|
+
session: niquests.AsyncSession,
|
|
292
|
+
database_id: str,
|
|
293
|
+
*,
|
|
294
|
+
api_version: ApiVersion | None = None,
|
|
295
|
+
cache: None = None,
|
|
296
|
+
) -> Database: ...
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
@overload
|
|
277
300
|
async def fetch_database(
|
|
278
301
|
session: niquests.AsyncSession,
|
|
279
302
|
database_id: str,
|
|
280
303
|
*,
|
|
281
304
|
api_version: ApiVersion | None = None,
|
|
282
|
-
|
|
305
|
+
cache: NotionCache,
|
|
306
|
+
) -> Database | CachedDatabase: ...
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def fetch_database(
|
|
310
|
+
session: niquests.AsyncSession,
|
|
311
|
+
database_id: str,
|
|
312
|
+
*,
|
|
313
|
+
api_version: ApiVersion | None = None,
|
|
314
|
+
cache: NotionCache | None = None,
|
|
315
|
+
) -> Database | CachedDatabase:
|
|
283
316
|
"""Retrieve a database/data source by ID.
|
|
284
317
|
|
|
285
318
|
For API version 2025-09-03+, uses /v1/data_sources/{id} endpoint.
|
|
286
319
|
For older API versions, uses /v1/databases/{id} endpoint.
|
|
320
|
+
|
|
321
|
+
If cache is provided, the database will be looked up in the cache first.
|
|
322
|
+
On cache miss, the database is fetched from the API and stored in the cache.
|
|
323
|
+
When returning from cache, returns a CachedDatabase (partial) instead of full Database.
|
|
287
324
|
"""
|
|
325
|
+
if cache:
|
|
326
|
+
if cached := cache.get_database(database_id):
|
|
327
|
+
return cached
|
|
328
|
+
|
|
288
329
|
_api_version = _get_api_version(session, api_version)
|
|
289
330
|
if _use_data_source_api(_api_version):
|
|
290
331
|
endpoint = f"{NOTION_API_URL}/v1/data_sources/{database_id}"
|
|
@@ -293,7 +334,12 @@ async def fetch_database(
|
|
|
293
334
|
|
|
294
335
|
response = await session.get(endpoint)
|
|
295
336
|
response.raise_for_status()
|
|
296
|
-
|
|
337
|
+
result: Database = response.json()
|
|
338
|
+
|
|
339
|
+
if cache:
|
|
340
|
+
cache.set_database(result)
|
|
341
|
+
|
|
342
|
+
return result
|
|
297
343
|
|
|
298
344
|
|
|
299
345
|
async def query_database(
|
|
@@ -364,7 +410,7 @@ async def fetch_block_children(
|
|
|
364
410
|
async def fetch_append_block_children(
|
|
365
411
|
session: niquests.AsyncSession,
|
|
366
412
|
block_id: str,
|
|
367
|
-
children:
|
|
413
|
+
children: Sequence[NotionBlock | dict[str, Any]],
|
|
368
414
|
) -> BlockListResponse:
|
|
369
415
|
"""Append children blocks to a parent block."""
|
|
370
416
|
payload = {"children": children}
|
|
@@ -374,6 +420,76 @@ async def fetch_append_block_children(
|
|
|
374
420
|
return response.json() # type: ignore[return-value]
|
|
375
421
|
|
|
376
422
|
|
|
423
|
+
async def delete_block(session: niquests.AsyncSession, block_id: str) -> Block:
|
|
424
|
+
"""Delete (archive) a block.
|
|
425
|
+
|
|
426
|
+
Args:
|
|
427
|
+
session: Authenticated niquests session
|
|
428
|
+
block_id: The ID of the block to delete
|
|
429
|
+
|
|
430
|
+
Returns:
|
|
431
|
+
The deleted block object with archived=True
|
|
432
|
+
"""
|
|
433
|
+
response = await session.delete(f"{NOTION_API_URL}/v1/blocks/{block_id}")
|
|
434
|
+
response.raise_for_status()
|
|
435
|
+
return response.json() # type: ignore[return-value]
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# Comments endpoints
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
async def fetch_comments(
|
|
442
|
+
session: niquests.AsyncSession,
|
|
443
|
+
block_id: str,
|
|
444
|
+
*,
|
|
445
|
+
start_cursor: str | None = None,
|
|
446
|
+
page_size: int | None = None,
|
|
447
|
+
) -> CommentListResponse:
|
|
448
|
+
"""Retrieve comments for a block or page.
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
session: Authenticated niquests session
|
|
452
|
+
block_id: The ID of the block or page to get comments for
|
|
453
|
+
start_cursor: Pagination cursor
|
|
454
|
+
page_size: Number of results per page
|
|
455
|
+
"""
|
|
456
|
+
params: dict[str, str] = {"block_id": block_id}
|
|
457
|
+
if start_cursor:
|
|
458
|
+
params["start_cursor"] = start_cursor
|
|
459
|
+
if page_size:
|
|
460
|
+
params["page_size"] = str(page_size)
|
|
461
|
+
|
|
462
|
+
response = (await session.get(f"{NOTION_API_URL}/v1/comments", params=params)).raise_for_status()
|
|
463
|
+
return response.json() # type: ignore[return-value]
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
async def create_comment(
|
|
467
|
+
session: niquests.AsyncSession,
|
|
468
|
+
*,
|
|
469
|
+
parent: dict[str, str],
|
|
470
|
+
rich_text: list[dict[str, Any]],
|
|
471
|
+
discussion_id: str | None = None,
|
|
472
|
+
) -> Comment:
|
|
473
|
+
"""Create a comment on a page or in an existing discussion.
|
|
474
|
+
|
|
475
|
+
Args:
|
|
476
|
+
session: Authenticated niquests session
|
|
477
|
+
parent: Parent object, e.g. {"page_id": "..."} for page-level comments
|
|
478
|
+
rich_text: The comment content as rich text array
|
|
479
|
+
discussion_id: Optional discussion ID to reply to an existing thread
|
|
480
|
+
"""
|
|
481
|
+
payload: dict[str, Any] = {
|
|
482
|
+
"parent": parent,
|
|
483
|
+
"rich_text": rich_text,
|
|
484
|
+
}
|
|
485
|
+
if discussion_id:
|
|
486
|
+
payload["discussion_id"] = discussion_id
|
|
487
|
+
|
|
488
|
+
response = await session.post(f"{NOTION_API_URL}/v1/comments", json=payload)
|
|
489
|
+
response.raise_for_status()
|
|
490
|
+
return response.json() # type: ignore[return-value]
|
|
491
|
+
|
|
492
|
+
|
|
377
493
|
# Search endpoint
|
|
378
494
|
|
|
379
495
|
|