tracktolib 0.66.3__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/s3/niquests.py +11 -2
- tracktolib/utils.py +39 -3
- {tracktolib-0.66.3.dist-info → tracktolib-0.68.0.dist-info}/METADATA +21 -1
- tracktolib-0.68.0.dist-info/RECORD +25 -0
- tracktolib-0.66.3.dist-info/RECORD +0 -21
- {tracktolib-0.66.3.dist-info → tracktolib-0.68.0.dist-info}/WHEEL +0 -0
tracktolib/api.py
CHANGED
|
@@ -1,22 +1,22 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import warnings
|
|
3
|
-
from
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
TypeAlias,
|
|
11
|
+
Literal,
|
|
12
|
+
Mapping,
|
|
13
|
+
Sequence,
|
|
17
14
|
Type,
|
|
18
|
-
|
|
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
|
tracktolib/notion/__init__.py
CHANGED
|
@@ -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
|