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 CHANGED
@@ -1,22 +1,22 @@
1
1
  import json
2
2
  import warnings
3
- from collections.abc import Mapping
4
- from dataclasses import field, dataclass
3
+ from dataclasses import dataclass, field
5
4
  from inspect import getdoc
6
5
  from typing import (
7
- Callable,
8
6
  Any,
9
- Literal,
10
- Sequence,
11
7
  AsyncIterator,
8
+ Callable,
9
+ ClassVar,
12
10
  Coroutine,
13
- get_type_hints,
14
- get_args,
15
- TypedDict,
16
- TypeAlias,
11
+ Literal,
12
+ Mapping,
13
+ Sequence,
17
14
  Type,
18
- ClassVar,
15
+ TypeAlias,
16
+ TypedDict,
17
+ get_args,
19
18
  get_origin,
19
+ get_type_hints,
20
20
  )
21
21
 
22
22
  from .utils import json_serial, get_first_line
@@ -0,0 +1,44 @@
1
+ from .blocks import (
2
+ ExportResult,
3
+ make_bulleted_list_block,
4
+ make_code_block,
5
+ make_divider_block,
6
+ make_heading_block,
7
+ make_numbered_list_block,
8
+ make_paragraph_block,
9
+ make_todo_block,
10
+ parse_rich_text,
11
+ )
12
+ from .cache import CachedDatabase, NotionCache
13
+ from .utils import (
14
+ PageComment,
15
+ ProgressCallback,
16
+ clear_page_blocks,
17
+ download_page_to_markdown,
18
+ export_markdown_to_page,
19
+ fetch_all_page_comments,
20
+ update_page_content,
21
+ )
22
+
23
+ __all__ = [
24
+ "CachedDatabase",
25
+ "NotionCache",
26
+ # Block utilities
27
+ "parse_rich_text",
28
+ "make_paragraph_block",
29
+ "make_heading_block",
30
+ "make_code_block",
31
+ "make_bulleted_list_block",
32
+ "make_numbered_list_block",
33
+ "make_todo_block",
34
+ "make_divider_block",
35
+ # Export/Import
36
+ "export_markdown_to_page",
37
+ "download_page_to_markdown",
38
+ "clear_page_blocks",
39
+ "update_page_content",
40
+ "fetch_all_page_comments",
41
+ "ProgressCallback",
42
+ "ExportResult",
43
+ "PageComment",
44
+ ]
@@ -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