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
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
|
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""Markdown conversion utilities for Notion blocks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Sequence
|
|
7
|
+
|
|
8
|
+
from ..utils import get_chunks
|
|
9
|
+
from .blocks import (
|
|
10
|
+
BulletedListBlock,
|
|
11
|
+
DividerBlock,
|
|
12
|
+
NumberedListBlock,
|
|
13
|
+
ParagraphBlock,
|
|
14
|
+
QuoteBlock,
|
|
15
|
+
TodoBlock,
|
|
16
|
+
make_bulleted_list_block,
|
|
17
|
+
make_code_block,
|
|
18
|
+
make_divider_block,
|
|
19
|
+
make_heading_block,
|
|
20
|
+
make_numbered_list_block,
|
|
21
|
+
make_paragraph_block,
|
|
22
|
+
make_quote_block,
|
|
23
|
+
make_todo_block,
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Union type for all Notion blocks used in markdown conversion
|
|
27
|
+
NotionBlock = ParagraphBlock | DividerBlock | BulletedListBlock | NumberedListBlock | TodoBlock | QuoteBlock
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from .models import Block, Comment, PartialBlock, RichTextItemResponse
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"NOTION_CHAR_LIMIT",
|
|
34
|
+
"NotionBlock",
|
|
35
|
+
"blocks_to_markdown",
|
|
36
|
+
"blocks_to_markdown_with_comments",
|
|
37
|
+
"comments_to_markdown",
|
|
38
|
+
"markdown_to_blocks",
|
|
39
|
+
"rich_text_to_markdown",
|
|
40
|
+
"strip_comments_from_markdown",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Notion's character limit per rich_text element
|
|
44
|
+
NOTION_CHAR_LIMIT = 2000
|
|
45
|
+
|
|
46
|
+
# Markdown block patterns (pre-compiled for performance)
|
|
47
|
+
_CODE_FENCE_PATTERN = re.compile(r"^```(\w*)$")
|
|
48
|
+
_HORIZONTAL_RULE_PATTERN = re.compile(r"^[-*_]{3,}\s*$")
|
|
49
|
+
_HEADING_PATTERN = re.compile(r"^(#{1,6})\s+(.+)$")
|
|
50
|
+
_TODO_PATTERN = re.compile(r"^\s*[-*]\s*\[([xX ])\]\s*(.*)$")
|
|
51
|
+
_BULLET_PATTERN = re.compile(r"^\s*[-*]\s+(.+)$")
|
|
52
|
+
_NUMBERED_PATTERN = re.compile(r"^\s*\d+\.\s+(.+)$")
|
|
53
|
+
_QUOTE_PATTERN = re.compile(r"^>\s*(.*)$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def rich_text_to_markdown(rich_text: Sequence[RichTextItemResponse] | Sequence[dict[str, Any]]) -> str:
|
|
57
|
+
"""Convert Notion rich_text array to markdown string.
|
|
58
|
+
|
|
59
|
+
Handles:
|
|
60
|
+
- Bold (annotations.bold)
|
|
61
|
+
- Italic (annotations.italic)
|
|
62
|
+
- Inline code (annotations.code)
|
|
63
|
+
- Links (text.link.url)
|
|
64
|
+
"""
|
|
65
|
+
result = []
|
|
66
|
+
for item in rich_text:
|
|
67
|
+
text_obj = item.get("text", {})
|
|
68
|
+
content = text_obj.get("content", "")
|
|
69
|
+
|
|
70
|
+
if not content:
|
|
71
|
+
continue
|
|
72
|
+
|
|
73
|
+
annotations = item.get("annotations", {})
|
|
74
|
+
link = text_obj.get("link")
|
|
75
|
+
|
|
76
|
+
# Apply formatting in order: code, bold, italic
|
|
77
|
+
if annotations.get("code"):
|
|
78
|
+
content = f"`{content}`"
|
|
79
|
+
if annotations.get("bold"):
|
|
80
|
+
content = f"**{content}**"
|
|
81
|
+
if annotations.get("italic"):
|
|
82
|
+
content = f"*{content}*"
|
|
83
|
+
if link:
|
|
84
|
+
content = f"[{content}]({link['url']})"
|
|
85
|
+
|
|
86
|
+
result.append(content)
|
|
87
|
+
|
|
88
|
+
return "".join(result)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def markdown_to_blocks(content: str) -> list[NotionBlock | dict[str, Any]]:
|
|
92
|
+
"""Convert markdown content to Notion blocks with proper formatting.
|
|
93
|
+
|
|
94
|
+
Handles:
|
|
95
|
+
- Code blocks (```)
|
|
96
|
+
- Headings (# ## ### etc)
|
|
97
|
+
- Bold (**text**)
|
|
98
|
+
- Inline code (`code`)
|
|
99
|
+
- Italic (*text*)
|
|
100
|
+
- Todo items (- [ ] or - [x])
|
|
101
|
+
- Bulleted lists (- or *)
|
|
102
|
+
- Numbered lists (1. 2. etc)
|
|
103
|
+
- Horizontal rules (---)
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
content: Markdown content to convert
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
List of Notion block objects (caller handles chunking for API limits)
|
|
110
|
+
"""
|
|
111
|
+
blocks: list[NotionBlock | dict[str, Any]] = []
|
|
112
|
+
lines = content.split("\n")
|
|
113
|
+
i = 0
|
|
114
|
+
|
|
115
|
+
while i < len(lines):
|
|
116
|
+
line = lines[i]
|
|
117
|
+
|
|
118
|
+
# Check for fenced code block
|
|
119
|
+
code_match = _CODE_FENCE_PATTERN.match(line)
|
|
120
|
+
if code_match:
|
|
121
|
+
language = code_match.group(1) or "plain text"
|
|
122
|
+
code_lines = []
|
|
123
|
+
i += 1
|
|
124
|
+
while i < len(lines) and not lines[i].startswith("```"):
|
|
125
|
+
code_lines.append(lines[i])
|
|
126
|
+
i += 1
|
|
127
|
+
code_content = "\n".join(code_lines)
|
|
128
|
+
if code_content:
|
|
129
|
+
blocks.extend(make_code_block(code_content, language))
|
|
130
|
+
i += 1 # Skip closing ```
|
|
131
|
+
continue
|
|
132
|
+
|
|
133
|
+
# Check for horizontal rule (---, ***, ___)
|
|
134
|
+
if _HORIZONTAL_RULE_PATTERN.match(line):
|
|
135
|
+
blocks.append(make_divider_block())
|
|
136
|
+
i += 1
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Check for heading
|
|
140
|
+
heading_match = _HEADING_PATTERN.match(line)
|
|
141
|
+
if heading_match:
|
|
142
|
+
level = len(heading_match.group(1))
|
|
143
|
+
text = heading_match.group(2).strip()
|
|
144
|
+
blocks.append(make_heading_block(text, level))
|
|
145
|
+
i += 1
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
# Check for todo item (- [ ] or - [x]) - must be before bullet list
|
|
149
|
+
todo_match = _TODO_PATTERN.match(line)
|
|
150
|
+
if todo_match:
|
|
151
|
+
checked = todo_match.group(1).lower() == "x"
|
|
152
|
+
text = todo_match.group(2).strip()
|
|
153
|
+
blocks.append(make_todo_block(text, checked))
|
|
154
|
+
i += 1
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
# Check for bulleted list
|
|
158
|
+
bullet_match = _BULLET_PATTERN.match(line)
|
|
159
|
+
if bullet_match:
|
|
160
|
+
text = bullet_match.group(1).strip()
|
|
161
|
+
blocks.append(make_bulleted_list_block(text))
|
|
162
|
+
i += 1
|
|
163
|
+
continue
|
|
164
|
+
|
|
165
|
+
# Check for numbered list
|
|
166
|
+
numbered_match = _NUMBERED_PATTERN.match(line)
|
|
167
|
+
if numbered_match:
|
|
168
|
+
text = numbered_match.group(1).strip()
|
|
169
|
+
blocks.append(make_numbered_list_block(text))
|
|
170
|
+
i += 1
|
|
171
|
+
continue
|
|
172
|
+
|
|
173
|
+
# Check for blockquote
|
|
174
|
+
quote_match = _QUOTE_PATTERN.match(line)
|
|
175
|
+
if quote_match:
|
|
176
|
+
text = quote_match.group(1)
|
|
177
|
+
blocks.append(make_quote_block(text))
|
|
178
|
+
i += 1
|
|
179
|
+
continue
|
|
180
|
+
|
|
181
|
+
# Empty line - check if it separates quote blocks
|
|
182
|
+
if not line.strip():
|
|
183
|
+
# Look ahead to see if next non-empty line is a quote
|
|
184
|
+
# and previous block was also a quote
|
|
185
|
+
if blocks and blocks[-1].get("type") == "quote":
|
|
186
|
+
j = i + 1
|
|
187
|
+
while j < len(lines) and not lines[j].strip():
|
|
188
|
+
j += 1
|
|
189
|
+
if j < len(lines) and lines[j].startswith(">"):
|
|
190
|
+
# Insert empty paragraph to preserve blank line between quotes
|
|
191
|
+
blocks.append(make_paragraph_block(""))
|
|
192
|
+
i += 1
|
|
193
|
+
continue
|
|
194
|
+
|
|
195
|
+
# Regular paragraph - collect consecutive non-empty lines
|
|
196
|
+
para_lines = [line]
|
|
197
|
+
i += 1
|
|
198
|
+
while i < len(lines):
|
|
199
|
+
next_line = lines[i]
|
|
200
|
+
# Stop at special lines
|
|
201
|
+
if (
|
|
202
|
+
not next_line.strip()
|
|
203
|
+
or next_line.startswith("#")
|
|
204
|
+
or next_line.startswith("```")
|
|
205
|
+
or next_line.startswith(">")
|
|
206
|
+
or _HORIZONTAL_RULE_PATTERN.match(next_line)
|
|
207
|
+
or _TODO_PATTERN.match(next_line)
|
|
208
|
+
or _BULLET_PATTERN.match(next_line)
|
|
209
|
+
or _NUMBERED_PATTERN.match(next_line)
|
|
210
|
+
):
|
|
211
|
+
break
|
|
212
|
+
para_lines.append(next_line)
|
|
213
|
+
i += 1
|
|
214
|
+
|
|
215
|
+
para_text = " ".join(ln.strip() for ln in para_lines)
|
|
216
|
+
if para_text:
|
|
217
|
+
# Split long paragraphs into chunks
|
|
218
|
+
if len(para_text) > NOTION_CHAR_LIMIT:
|
|
219
|
+
for chunk in get_chunks(para_text, NOTION_CHAR_LIMIT):
|
|
220
|
+
blocks.append(make_paragraph_block("".join(chunk)))
|
|
221
|
+
else:
|
|
222
|
+
blocks.append(make_paragraph_block(para_text))
|
|
223
|
+
|
|
224
|
+
return blocks
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _block_to_markdown(block: Block | PartialBlock | dict[str, Any]) -> str | None:
|
|
228
|
+
"""Convert a single Notion block to markdown.
|
|
229
|
+
|
|
230
|
+
Returns None for unsupported block types.
|
|
231
|
+
"""
|
|
232
|
+
block_type = block.get("type")
|
|
233
|
+
if not block_type:
|
|
234
|
+
return None
|
|
235
|
+
|
|
236
|
+
block_data = block.get(block_type, {})
|
|
237
|
+
|
|
238
|
+
if block_type == "paragraph":
|
|
239
|
+
rich_text = block_data.get("rich_text", [])
|
|
240
|
+
text = rich_text_to_markdown(rich_text)
|
|
241
|
+
return text if text else ""
|
|
242
|
+
|
|
243
|
+
if block_type in ("heading_1", "heading_2", "heading_3"):
|
|
244
|
+
level = int(block_type[-1])
|
|
245
|
+
rich_text = block_data.get("rich_text", [])
|
|
246
|
+
text = rich_text_to_markdown(rich_text)
|
|
247
|
+
return f"{'#' * level} {text}"
|
|
248
|
+
|
|
249
|
+
if block_type == "code":
|
|
250
|
+
rich_text = block_data.get("rich_text", [])
|
|
251
|
+
code = "".join(item.get("text", {}).get("content", "") for item in rich_text)
|
|
252
|
+
language = block_data.get("language", "")
|
|
253
|
+
# Map Notion language back to common alias
|
|
254
|
+
if language == "plain text":
|
|
255
|
+
language = ""
|
|
256
|
+
return f"```{language}\n{code}\n```"
|
|
257
|
+
|
|
258
|
+
if block_type == "bulleted_list_item":
|
|
259
|
+
rich_text = block_data.get("rich_text", [])
|
|
260
|
+
text = rich_text_to_markdown(rich_text)
|
|
261
|
+
return f"- {text}"
|
|
262
|
+
|
|
263
|
+
if block_type == "numbered_list_item":
|
|
264
|
+
rich_text = block_data.get("rich_text", [])
|
|
265
|
+
text = rich_text_to_markdown(rich_text)
|
|
266
|
+
return f"1. {text}"
|
|
267
|
+
|
|
268
|
+
if block_type == "to_do":
|
|
269
|
+
rich_text = block_data.get("rich_text", [])
|
|
270
|
+
text = rich_text_to_markdown(rich_text)
|
|
271
|
+
checked = block_data.get("checked", False)
|
|
272
|
+
checkbox = "[x]" if checked else "[ ]"
|
|
273
|
+
return f"- {checkbox} {text}"
|
|
274
|
+
|
|
275
|
+
if block_type == "divider":
|
|
276
|
+
return "---"
|
|
277
|
+
|
|
278
|
+
if block_type == "quote":
|
|
279
|
+
rich_text = block_data.get("rich_text", [])
|
|
280
|
+
text = rich_text_to_markdown(rich_text)
|
|
281
|
+
return f"> {text}"
|
|
282
|
+
|
|
283
|
+
if block_type == "callout":
|
|
284
|
+
rich_text = block_data.get("rich_text", [])
|
|
285
|
+
text = rich_text_to_markdown(rich_text)
|
|
286
|
+
icon = block_data.get("icon", {})
|
|
287
|
+
emoji = icon.get("emoji", "")
|
|
288
|
+
prefix = f"{emoji} " if emoji else ""
|
|
289
|
+
return f"> {prefix}{text}"
|
|
290
|
+
|
|
291
|
+
# Unsupported block type
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def blocks_to_markdown(blocks: list[Block | PartialBlock] | list[dict[str, Any]]) -> str:
|
|
296
|
+
"""Convert a list of Notion blocks to markdown content.
|
|
297
|
+
|
|
298
|
+
Handles:
|
|
299
|
+
- Paragraphs
|
|
300
|
+
- Headings (h1, h2, h3)
|
|
301
|
+
- Code blocks
|
|
302
|
+
- Bulleted lists
|
|
303
|
+
- Numbered lists
|
|
304
|
+
- Todo items
|
|
305
|
+
- Dividers
|
|
306
|
+
- Quotes
|
|
307
|
+
- Callouts
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
blocks: List of Notion block objects
|
|
311
|
+
|
|
312
|
+
Returns:
|
|
313
|
+
Markdown string
|
|
314
|
+
"""
|
|
315
|
+
result: list[str] = []
|
|
316
|
+
prev_type: str | None = None
|
|
317
|
+
|
|
318
|
+
for block in blocks:
|
|
319
|
+
block_type = block.get("type")
|
|
320
|
+
md_line = _block_to_markdown(block)
|
|
321
|
+
if md_line is not None:
|
|
322
|
+
# Empty paragraph acts as separator (resets consecutive quote joining)
|
|
323
|
+
if block_type == "paragraph" and md_line == "":
|
|
324
|
+
prev_type = None
|
|
325
|
+
continue
|
|
326
|
+
# Join consecutive quotes with single newline
|
|
327
|
+
if prev_type == "quote" and block_type == "quote":
|
|
328
|
+
result.append(f"\n{md_line}")
|
|
329
|
+
elif result:
|
|
330
|
+
result.append(f"\n\n{md_line}")
|
|
331
|
+
else:
|
|
332
|
+
result.append(md_line)
|
|
333
|
+
prev_type = block_type
|
|
334
|
+
|
|
335
|
+
return "".join(result)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _inline_comment_to_markdown(comment: Comment | dict[str, Any]) -> str:
|
|
339
|
+
"""Convert a single inline comment to markdown format."""
|
|
340
|
+
rich_text = comment.get("rich_text", [])
|
|
341
|
+
text = rich_text_to_markdown(rich_text)
|
|
342
|
+
|
|
343
|
+
created_by = comment.get("created_by", {})
|
|
344
|
+
author = created_by.get("name") or created_by.get("id", "Unknown")
|
|
345
|
+
|
|
346
|
+
created_time = comment.get("created_time", "")
|
|
347
|
+
if created_time:
|
|
348
|
+
timestamp = created_time[:16].replace("T", " ")
|
|
349
|
+
else:
|
|
350
|
+
timestamp = ""
|
|
351
|
+
|
|
352
|
+
header = f"**{author}**"
|
|
353
|
+
if timestamp:
|
|
354
|
+
header += f" - {timestamp}"
|
|
355
|
+
|
|
356
|
+
return f"> 💬 {header}: {text}"
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def blocks_to_markdown_with_comments(
|
|
360
|
+
blocks: list[Block | PartialBlock] | list[dict[str, Any]],
|
|
361
|
+
block_comments: dict[str, list[Comment]] | dict[str, list[dict[str, Any]]] | None = None,
|
|
362
|
+
) -> str:
|
|
363
|
+
"""Convert a list of Notion blocks to markdown content with inline comments.
|
|
364
|
+
|
|
365
|
+
Args:
|
|
366
|
+
blocks: List of Notion block objects
|
|
367
|
+
block_comments: Dictionary mapping block IDs to their comments
|
|
368
|
+
|
|
369
|
+
Returns:
|
|
370
|
+
Markdown string with inline comments after their respective blocks
|
|
371
|
+
"""
|
|
372
|
+
if block_comments is None:
|
|
373
|
+
block_comments = {}
|
|
374
|
+
|
|
375
|
+
result: list[str] = []
|
|
376
|
+
prev_type: str | None = None
|
|
377
|
+
|
|
378
|
+
for block in blocks:
|
|
379
|
+
block_type = block.get("type")
|
|
380
|
+
md_line = _block_to_markdown(block)
|
|
381
|
+
if md_line is not None:
|
|
382
|
+
# Empty paragraph acts as separator (resets consecutive quote joining)
|
|
383
|
+
if block_type == "paragraph" and md_line == "":
|
|
384
|
+
prev_type = None
|
|
385
|
+
continue
|
|
386
|
+
# Join consecutive quotes with single newline
|
|
387
|
+
if prev_type == "quote" and block_type == "quote":
|
|
388
|
+
result.append(f"\n{md_line}")
|
|
389
|
+
elif result:
|
|
390
|
+
result.append(f"\n\n{md_line}")
|
|
391
|
+
else:
|
|
392
|
+
result.append(md_line)
|
|
393
|
+
prev_type = block_type
|
|
394
|
+
|
|
395
|
+
# Add inline comments for this block
|
|
396
|
+
block_id = block.get("id")
|
|
397
|
+
if block_id and block_id in block_comments:
|
|
398
|
+
for comment in block_comments[block_id]:
|
|
399
|
+
comment_md = _inline_comment_to_markdown(comment)
|
|
400
|
+
result.append(f"\n\n{comment_md}")
|
|
401
|
+
prev_type = None # Reset after comment
|
|
402
|
+
|
|
403
|
+
return "".join(result)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def comments_to_markdown(comments: list[Comment] | list[dict[str, Any]]) -> str:
|
|
407
|
+
"""Convert a list of Notion comments to markdown.
|
|
408
|
+
|
|
409
|
+
Each comment is formatted as a blockquote with author and timestamp.
|
|
410
|
+
|
|
411
|
+
Args:
|
|
412
|
+
comments: List of Notion comment objects
|
|
413
|
+
|
|
414
|
+
Returns:
|
|
415
|
+
Markdown string with comments section
|
|
416
|
+
"""
|
|
417
|
+
if not comments:
|
|
418
|
+
return ""
|
|
419
|
+
|
|
420
|
+
lines: list[str] = ["## Comments", ""]
|
|
421
|
+
|
|
422
|
+
for comment in comments:
|
|
423
|
+
rich_text = comment.get("rich_text", [])
|
|
424
|
+
text = rich_text_to_markdown(rich_text)
|
|
425
|
+
|
|
426
|
+
# Get author info
|
|
427
|
+
created_by = comment.get("created_by", {})
|
|
428
|
+
author = created_by.get("name") or created_by.get("id", "Unknown")
|
|
429
|
+
|
|
430
|
+
# Get timestamp
|
|
431
|
+
created_time = comment.get("created_time", "")
|
|
432
|
+
if created_time:
|
|
433
|
+
# Format: 2024-01-15T10:30:00.000Z -> 2024-01-15 10:30
|
|
434
|
+
timestamp = created_time[:16].replace("T", " ")
|
|
435
|
+
else:
|
|
436
|
+
timestamp = ""
|
|
437
|
+
|
|
438
|
+
# Format as blockquote with metadata
|
|
439
|
+
header = f"**{author}**"
|
|
440
|
+
if timestamp:
|
|
441
|
+
header += f" - {timestamp}"
|
|
442
|
+
|
|
443
|
+
lines.append(f"> {header}")
|
|
444
|
+
lines.append(f"> {text}")
|
|
445
|
+
lines.append("")
|
|
446
|
+
|
|
447
|
+
return "\n".join(lines)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def strip_comments_from_markdown(content: str) -> str:
|
|
451
|
+
"""Remove comment blockquotes (> 💬) from markdown content.
|
|
452
|
+
|
|
453
|
+
This is useful when re-uploading markdown that was downloaded with comments,
|
|
454
|
+
to avoid converting comments into regular quote blocks.
|
|
455
|
+
|
|
456
|
+
Args:
|
|
457
|
+
content: Markdown content potentially containing comment blockquotes
|
|
458
|
+
|
|
459
|
+
Returns:
|
|
460
|
+
Markdown content with comment lines removed
|
|
461
|
+
"""
|
|
462
|
+
lines = content.splitlines()
|
|
463
|
+
result = []
|
|
464
|
+
for line in lines:
|
|
465
|
+
if line.startswith("> 💬"):
|
|
466
|
+
continue
|
|
467
|
+
result.append(line)
|
|
468
|
+
return "\n".join(result)
|
tracktolib/notion/models.py
CHANGED
|
@@ -286,3 +286,29 @@ class SearchResponse(TypedDict):
|
|
|
286
286
|
results: list[Page | PartialPage | Database | PartialDatabase]
|
|
287
287
|
next_cursor: str | None
|
|
288
288
|
has_more: bool
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# Comment types
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
class Comment(TypedDict):
|
|
295
|
+
"""Comment object response."""
|
|
296
|
+
|
|
297
|
+
object: Literal["comment"]
|
|
298
|
+
id: str
|
|
299
|
+
parent: Parent
|
|
300
|
+
discussion_id: str
|
|
301
|
+
created_time: str
|
|
302
|
+
last_edited_time: str
|
|
303
|
+
created_by: PartialUser
|
|
304
|
+
rich_text: list[RichTextItemResponse]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class CommentListResponse(TypedDict):
|
|
308
|
+
"""Response from listing comments."""
|
|
309
|
+
|
|
310
|
+
object: Literal["list"]
|
|
311
|
+
type: Literal["comment"]
|
|
312
|
+
results: list[Comment]
|
|
313
|
+
next_cursor: str | None
|
|
314
|
+
has_more: bool
|