tracktolib 0.67.0__py3-none-any.whl → 0.69.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 +21 -22
- tracktolib/cf/__init__.py +8 -0
- tracktolib/cf/client.py +149 -0
- tracktolib/cf/types.py +17 -0
- tracktolib/gh/__init__.py +11 -0
- tracktolib/gh/client.py +206 -0
- tracktolib/gh/types.py +203 -0
- tracktolib/http_utils.py +1 -1
- tracktolib/logs.py +1 -1
- 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 +26 -0
- tracktolib/notion/utils.py +567 -0
- tracktolib/pg/__init__.py +10 -10
- tracktolib/pg/query.py +1 -1
- tracktolib/pg/utils.py +5 -5
- tracktolib/pg_sync.py +5 -7
- tracktolib/pg_utils.py +1 -4
- tracktolib/s3/minio.py +1 -1
- tracktolib/s3/niquests.py +235 -32
- tracktolib/s3/s3.py +1 -1
- tracktolib/utils.py +48 -3
- {tracktolib-0.67.0.dist-info → tracktolib-0.69.0.dist-info}/METADATA +115 -2
- tracktolib-0.69.0.dist-info/RECORD +31 -0
- {tracktolib-0.67.0.dist-info → tracktolib-0.69.0.dist-info}/WHEEL +1 -1
- tracktolib-0.67.0.dist-info/RECORD +0 -21
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
"""Notion block creation utilities."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Sequence, TypedDict
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from .models import Block, PartialBlock
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
# Types
|
|
13
|
+
"ParagraphBlock",
|
|
14
|
+
"HeadingBlock",
|
|
15
|
+
"CodeBlock",
|
|
16
|
+
"BulletedListBlock",
|
|
17
|
+
"NumberedListBlock",
|
|
18
|
+
"TodoBlock",
|
|
19
|
+
"DividerBlock",
|
|
20
|
+
"QuoteBlock",
|
|
21
|
+
"NotionBlock",
|
|
22
|
+
"ExportResult",
|
|
23
|
+
# Rich text parsing
|
|
24
|
+
"parse_rich_text",
|
|
25
|
+
# Block creators
|
|
26
|
+
"make_paragraph_block",
|
|
27
|
+
"make_heading_block",
|
|
28
|
+
"make_code_block",
|
|
29
|
+
"make_bulleted_list_block",
|
|
30
|
+
"make_numbered_list_block",
|
|
31
|
+
"make_todo_block",
|
|
32
|
+
"make_divider_block",
|
|
33
|
+
"make_quote_block",
|
|
34
|
+
# Block comparison
|
|
35
|
+
"blocks_content_equal",
|
|
36
|
+
"find_divergence_index",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ExportResult(TypedDict):
|
|
41
|
+
"""Result of exporting markdown to Notion."""
|
|
42
|
+
|
|
43
|
+
count: int
|
|
44
|
+
"""Number of blocks created in the page."""
|
|
45
|
+
url: str | None
|
|
46
|
+
"""URL of the created Notion page, or None if creation failed."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _TextContent(TypedDict):
|
|
50
|
+
content: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _Annotations(TypedDict, total=False):
|
|
54
|
+
bold: bool
|
|
55
|
+
italic: bool
|
|
56
|
+
code: bool
|
|
57
|
+
strikethrough: bool
|
|
58
|
+
underline: bool
|
|
59
|
+
color: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _TextItem(TypedDict, total=False):
|
|
63
|
+
type: str
|
|
64
|
+
text: _TextContent
|
|
65
|
+
annotations: _Annotations
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _RichTextContent(TypedDict):
|
|
69
|
+
rich_text: list[_TextItem]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class _CodeContent(TypedDict):
|
|
73
|
+
rich_text: list[_TextItem]
|
|
74
|
+
language: str
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _TodoContent(TypedDict):
|
|
78
|
+
rich_text: list[_TextItem]
|
|
79
|
+
checked: bool
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class _DividerContent(TypedDict):
|
|
83
|
+
pass
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class ParagraphBlock(TypedDict):
|
|
87
|
+
"""Notion paragraph block structure."""
|
|
88
|
+
|
|
89
|
+
object: str
|
|
90
|
+
type: str
|
|
91
|
+
paragraph: _RichTextContent
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class HeadingBlock(TypedDict):
|
|
95
|
+
"""Notion heading block structure."""
|
|
96
|
+
|
|
97
|
+
object: str
|
|
98
|
+
type: str
|
|
99
|
+
heading_1: _RichTextContent
|
|
100
|
+
heading_2: _RichTextContent
|
|
101
|
+
heading_3: _RichTextContent
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CodeBlock(TypedDict):
|
|
105
|
+
"""Notion code block structure."""
|
|
106
|
+
|
|
107
|
+
object: str
|
|
108
|
+
type: str
|
|
109
|
+
code: _CodeContent
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class BulletedListBlock(TypedDict):
|
|
113
|
+
"""Notion bulleted list item block structure."""
|
|
114
|
+
|
|
115
|
+
object: str
|
|
116
|
+
type: str
|
|
117
|
+
bulleted_list_item: _RichTextContent
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class NumberedListBlock(TypedDict):
|
|
121
|
+
"""Notion numbered list item block structure."""
|
|
122
|
+
|
|
123
|
+
object: str
|
|
124
|
+
type: str
|
|
125
|
+
numbered_list_item: _RichTextContent
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TodoBlock(TypedDict):
|
|
129
|
+
"""Notion to_do block structure."""
|
|
130
|
+
|
|
131
|
+
object: str
|
|
132
|
+
type: str
|
|
133
|
+
to_do: _TodoContent
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class DividerBlock(TypedDict):
|
|
137
|
+
"""Notion divider block structure."""
|
|
138
|
+
|
|
139
|
+
object: str
|
|
140
|
+
type: str
|
|
141
|
+
divider: _DividerContent
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class QuoteBlock(TypedDict):
|
|
145
|
+
"""Notion quote block structure."""
|
|
146
|
+
|
|
147
|
+
object: str
|
|
148
|
+
type: str
|
|
149
|
+
quote: _RichTextContent
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# Union type for all block types
|
|
153
|
+
NotionBlock = (
|
|
154
|
+
ParagraphBlock
|
|
155
|
+
| HeadingBlock
|
|
156
|
+
| CodeBlock
|
|
157
|
+
| BulletedListBlock
|
|
158
|
+
| NumberedListBlock
|
|
159
|
+
| TodoBlock
|
|
160
|
+
| DividerBlock
|
|
161
|
+
| QuoteBlock
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
# Language aliases for code blocks
|
|
165
|
+
LANGUAGE_ALIASES = {
|
|
166
|
+
"js": "javascript",
|
|
167
|
+
"ts": "typescript",
|
|
168
|
+
"py": "python",
|
|
169
|
+
"rb": "ruby",
|
|
170
|
+
"sh": "shell",
|
|
171
|
+
"bash": "shell",
|
|
172
|
+
"zsh": "shell",
|
|
173
|
+
"yml": "yaml",
|
|
174
|
+
"": "plain text",
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
# Pattern to match bold, code, or italic (in order of priority)
|
|
178
|
+
# Note: underscore italics use lookahead/lookbehind to avoid matching underscores
|
|
179
|
+
# inside identifiers like my_var_name
|
|
180
|
+
_INLINE_FORMAT_PATTERN = re.compile(
|
|
181
|
+
r"(\*\*(.+?)\*\*|__(.+?)__|`([^`]+)`|\*([^*]+)\*|(?<![a-zA-Z0-9])_([^_]+)_(?![a-zA-Z0-9]))"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_rich_text(text: str) -> list[_TextItem]:
|
|
186
|
+
"""Parse markdown inline formatting to Notion rich_text array.
|
|
187
|
+
|
|
188
|
+
Handles:
|
|
189
|
+
- **bold** or __bold__
|
|
190
|
+
- `inline code`
|
|
191
|
+
- *italic* or _italic_
|
|
192
|
+
"""
|
|
193
|
+
rich_text: list[_TextItem] = []
|
|
194
|
+
pos = 0
|
|
195
|
+
for match in _INLINE_FORMAT_PATTERN.finditer(text):
|
|
196
|
+
# Add plain text before the match
|
|
197
|
+
if match.start() > pos:
|
|
198
|
+
plain = text[pos : match.start()]
|
|
199
|
+
if plain:
|
|
200
|
+
rich_text.append({"type": "text", "text": {"content": plain}})
|
|
201
|
+
|
|
202
|
+
full_match = match.group(0)
|
|
203
|
+
if full_match.startswith("**") or full_match.startswith("__"):
|
|
204
|
+
# Bold
|
|
205
|
+
content = match.group(2) or match.group(3)
|
|
206
|
+
rich_text.append(
|
|
207
|
+
{
|
|
208
|
+
"type": "text",
|
|
209
|
+
"text": {"content": content},
|
|
210
|
+
"annotations": {"bold": True},
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
elif full_match.startswith("`"):
|
|
214
|
+
# Inline code
|
|
215
|
+
content = match.group(4)
|
|
216
|
+
rich_text.append(
|
|
217
|
+
{
|
|
218
|
+
"type": "text",
|
|
219
|
+
"text": {"content": content},
|
|
220
|
+
"annotations": {"code": True},
|
|
221
|
+
}
|
|
222
|
+
)
|
|
223
|
+
else:
|
|
224
|
+
# Italic
|
|
225
|
+
content = match.group(5) or match.group(6)
|
|
226
|
+
rich_text.append(
|
|
227
|
+
{
|
|
228
|
+
"type": "text",
|
|
229
|
+
"text": {"content": content},
|
|
230
|
+
"annotations": {"italic": True},
|
|
231
|
+
}
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
pos = match.end()
|
|
235
|
+
|
|
236
|
+
# Add remaining plain text
|
|
237
|
+
if pos < len(text):
|
|
238
|
+
remaining = text[pos:]
|
|
239
|
+
if remaining:
|
|
240
|
+
rich_text.append({"type": "text", "text": {"content": remaining}})
|
|
241
|
+
|
|
242
|
+
# If no formatting found, return plain text
|
|
243
|
+
if not rich_text:
|
|
244
|
+
rich_text.append({"type": "text", "text": {"content": text}})
|
|
245
|
+
|
|
246
|
+
return rich_text
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def make_paragraph_block(text: str) -> ParagraphBlock:
|
|
250
|
+
"""Create a Notion paragraph block with rich text formatting.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
text: The paragraph text (max 2000 characters)
|
|
254
|
+
|
|
255
|
+
Raises:
|
|
256
|
+
ValueError: If text exceeds 2000 characters (Notion's limit)
|
|
257
|
+
"""
|
|
258
|
+
if len(text) > 2000:
|
|
259
|
+
raise ValueError(f"Text exceeds Notion limit of 2000 characters ({len(text)} chars). Pre-chunk the text.")
|
|
260
|
+
return {
|
|
261
|
+
"object": "block",
|
|
262
|
+
"type": "paragraph",
|
|
263
|
+
"paragraph": {
|
|
264
|
+
"rich_text": parse_rich_text(text),
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def make_heading_block(text: str, level: int) -> dict[str, Any]:
|
|
270
|
+
"""Create a Notion heading block (h1, h2, h3).
|
|
271
|
+
|
|
272
|
+
Args:
|
|
273
|
+
text: The heading text
|
|
274
|
+
level: Heading level (1-6). Levels 4-6 are mapped to h3.
|
|
275
|
+
"""
|
|
276
|
+
# Notion only supports h1, h2, h3 - map others to h3
|
|
277
|
+
heading_type = f"heading_{min(level, 3)}"
|
|
278
|
+
return {
|
|
279
|
+
"object": "block",
|
|
280
|
+
"type": heading_type,
|
|
281
|
+
heading_type: {
|
|
282
|
+
"rich_text": parse_rich_text(text),
|
|
283
|
+
},
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def make_code_block(code: str, language: str = "plain text", *, chunk_size: int = 2000) -> list[dict[str, Any]]:
|
|
288
|
+
"""Create Notion code block(s).
|
|
289
|
+
|
|
290
|
+
If code exceeds chunk_size characters, it is split into multiple blocks
|
|
291
|
+
to preserve the full content.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
code: The code content
|
|
295
|
+
language: Programming language (supports aliases like 'py', 'js', 'ts')
|
|
296
|
+
chunk_size: Maximum characters per block (default 2000, Notion's limit)
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
List of code block dicts (usually one, multiple if code > chunk_size chars)
|
|
300
|
+
"""
|
|
301
|
+
notion_lang = LANGUAGE_ALIASES.get(language.lower(), language.lower())
|
|
302
|
+
|
|
303
|
+
blocks: list[dict[str, Any]] = []
|
|
304
|
+
for i in range(0, len(code), chunk_size):
|
|
305
|
+
chunk = code[i : i + chunk_size]
|
|
306
|
+
blocks.append(
|
|
307
|
+
{
|
|
308
|
+
"object": "block",
|
|
309
|
+
"type": "code",
|
|
310
|
+
"code": {
|
|
311
|
+
"rich_text": [{"type": "text", "text": {"content": chunk}}],
|
|
312
|
+
"language": notion_lang,
|
|
313
|
+
},
|
|
314
|
+
}
|
|
315
|
+
)
|
|
316
|
+
|
|
317
|
+
return blocks
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def make_bulleted_list_block(text: str) -> BulletedListBlock:
|
|
321
|
+
"""Create a Notion bulleted list item block."""
|
|
322
|
+
return {
|
|
323
|
+
"object": "block",
|
|
324
|
+
"type": "bulleted_list_item",
|
|
325
|
+
"bulleted_list_item": {
|
|
326
|
+
"rich_text": parse_rich_text(text),
|
|
327
|
+
},
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def make_numbered_list_block(text: str) -> NumberedListBlock:
|
|
332
|
+
"""Create a Notion numbered list item block."""
|
|
333
|
+
return {
|
|
334
|
+
"object": "block",
|
|
335
|
+
"type": "numbered_list_item",
|
|
336
|
+
"numbered_list_item": {
|
|
337
|
+
"rich_text": parse_rich_text(text),
|
|
338
|
+
},
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def make_todo_block(text: str, checked: bool = False) -> TodoBlock:
|
|
343
|
+
"""Create a Notion to_do block (checkbox item)."""
|
|
344
|
+
return {
|
|
345
|
+
"object": "block",
|
|
346
|
+
"type": "to_do",
|
|
347
|
+
"to_do": {
|
|
348
|
+
"rich_text": parse_rich_text(text),
|
|
349
|
+
"checked": checked,
|
|
350
|
+
},
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def make_divider_block() -> DividerBlock:
|
|
355
|
+
"""Create a Notion divider block (horizontal rule)."""
|
|
356
|
+
return {
|
|
357
|
+
"object": "block",
|
|
358
|
+
"type": "divider",
|
|
359
|
+
"divider": {},
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def make_quote_block(text: str) -> QuoteBlock:
|
|
364
|
+
"""Create a Notion quote block with rich text formatting."""
|
|
365
|
+
return {
|
|
366
|
+
"object": "block",
|
|
367
|
+
"type": "quote",
|
|
368
|
+
"quote": {
|
|
369
|
+
"rich_text": parse_rich_text(text),
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _extract_block_content(block: Block | PartialBlock | NotionBlock | dict[str, Any]) -> dict[str, Any]:
|
|
375
|
+
"""Extract only the content-relevant parts of a block for comparison.
|
|
376
|
+
|
|
377
|
+
Ignores metadata like id, created_time, last_edited_time, etc.
|
|
378
|
+
"""
|
|
379
|
+
from .markdown import rich_text_to_markdown
|
|
380
|
+
|
|
381
|
+
block_type = block.get("type")
|
|
382
|
+
if not block_type:
|
|
383
|
+
return {}
|
|
384
|
+
|
|
385
|
+
block_data = block.get(block_type, {})
|
|
386
|
+
|
|
387
|
+
if block_type == "divider":
|
|
388
|
+
return {"type": "divider"}
|
|
389
|
+
|
|
390
|
+
if block_type in ("paragraph", "heading_1", "heading_2", "heading_3", "bulleted_list_item", "numbered_list_item"):
|
|
391
|
+
rich_text = block_data.get("rich_text", [])
|
|
392
|
+
return {"type": block_type, "text": rich_text_to_markdown(rich_text)}
|
|
393
|
+
|
|
394
|
+
if block_type == "to_do":
|
|
395
|
+
rich_text = block_data.get("rich_text", [])
|
|
396
|
+
return {
|
|
397
|
+
"type": block_type,
|
|
398
|
+
"text": rich_text_to_markdown(rich_text),
|
|
399
|
+
"checked": block_data.get("checked", False),
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
if block_type == "code":
|
|
403
|
+
rich_text = block_data.get("rich_text", [])
|
|
404
|
+
code = "".join(item.get("text", {}).get("content", "") for item in rich_text)
|
|
405
|
+
return {"type": block_type, "code": code, "language": block_data.get("language", "")}
|
|
406
|
+
|
|
407
|
+
if block_type in ("quote", "callout"):
|
|
408
|
+
rich_text = block_data.get("rich_text", [])
|
|
409
|
+
result: dict[str, Any] = {"type": block_type, "text": rich_text_to_markdown(rich_text)}
|
|
410
|
+
if block_type == "callout":
|
|
411
|
+
icon = block_data.get("icon", {})
|
|
412
|
+
result["emoji"] = icon.get("emoji", "")
|
|
413
|
+
return result
|
|
414
|
+
|
|
415
|
+
return {"type": block_type}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def blocks_content_equal(
|
|
419
|
+
existing: Block | PartialBlock | dict[str, Any],
|
|
420
|
+
new: NotionBlock | dict[str, Any],
|
|
421
|
+
) -> bool:
|
|
422
|
+
"""Check if two blocks have equivalent content.
|
|
423
|
+
|
|
424
|
+
Compares only content-relevant fields, ignoring metadata like IDs and timestamps.
|
|
425
|
+
This allows comparing an existing Notion block (with full metadata) against
|
|
426
|
+
a newly created block (without IDs).
|
|
427
|
+
|
|
428
|
+
Args:
|
|
429
|
+
existing: An existing block from Notion (has id, timestamps, etc.)
|
|
430
|
+
new: A new block to compare (may not have id/timestamps)
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
True if the blocks have equivalent content
|
|
434
|
+
"""
|
|
435
|
+
return _extract_block_content(existing) == _extract_block_content(new)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def find_divergence_index(
|
|
439
|
+
existing_blocks: Sequence[Block | PartialBlock] | Sequence[dict[str, Any]],
|
|
440
|
+
new_blocks: Sequence[NotionBlock | dict[str, Any]],
|
|
441
|
+
) -> int:
|
|
442
|
+
"""Find the index where existing blocks start to differ from new blocks.
|
|
443
|
+
|
|
444
|
+
Compares blocks from the start until a difference is found.
|
|
445
|
+
Blocks that match are preserved (keeping their IDs and comments).
|
|
446
|
+
|
|
447
|
+
Args:
|
|
448
|
+
existing_blocks: Current blocks from Notion
|
|
449
|
+
new_blocks: New blocks to replace the content
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Index of first differing block. Returns min(len(existing), len(new))
|
|
453
|
+
if all compared blocks match.
|
|
454
|
+
"""
|
|
455
|
+
min_len = min(len(existing_blocks), len(new_blocks))
|
|
456
|
+
for i in range(min_len):
|
|
457
|
+
if not blocks_content_equal(existing_blocks[i], new_blocks[i]):
|
|
458
|
+
return i
|
|
459
|
+
return min_len
|
|
@@ -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
|