notionary 0.2.21__py3-none-any.whl → 0.2.22__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.
- notionary/blocks/_bootstrap.py +9 -1
- notionary/blocks/audio/audio_element.py +53 -28
- notionary/blocks/audio/audio_markdown_node.py +10 -4
- notionary/blocks/base_block_element.py +15 -3
- notionary/blocks/bookmark/bookmark_element.py +39 -36
- notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
- notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
- notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
- notionary/blocks/callout/callout_element.py +20 -4
- notionary/blocks/child_database/__init__.py +11 -4
- notionary/blocks/child_database/child_database_element.py +61 -0
- notionary/blocks/child_database/child_database_models.py +7 -14
- notionary/blocks/child_page/child_page_element.py +94 -0
- notionary/blocks/client.py +0 -1
- notionary/blocks/code/code_element.py +51 -2
- notionary/blocks/code/code_markdown_node.py +52 -1
- notionary/blocks/column/column_element.py +9 -3
- notionary/blocks/column/column_list_element.py +18 -3
- notionary/blocks/divider/divider_element.py +3 -11
- notionary/blocks/embed/embed_element.py +27 -6
- notionary/blocks/equation/equation_element.py +94 -41
- notionary/blocks/equation/equation_element_markdown_node.py +8 -9
- notionary/blocks/file/file_element.py +56 -37
- notionary/blocks/file/file_element_markdown_node.py +9 -7
- notionary/blocks/guards.py +22 -0
- notionary/blocks/heading/heading_element.py +23 -4
- notionary/blocks/image_block/image_element.py +43 -38
- notionary/blocks/image_block/image_markdown_node.py +10 -5
- notionary/blocks/mixins/captions/__init__.py +4 -0
- notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
- notionary/blocks/mixins/captions/caption_mixin.py +92 -0
- notionary/blocks/models.py +3 -1
- notionary/blocks/numbered_list/numbered_list_element.py +21 -4
- notionary/blocks/paragraph/paragraph_element.py +21 -5
- notionary/blocks/pdf/pdf_element.py +47 -41
- notionary/blocks/pdf/pdf_markdown_node.py +9 -7
- notionary/blocks/quote/quote_element.py +26 -9
- notionary/blocks/quote/quote_markdown_node.py +2 -2
- notionary/blocks/registry/block_registry.py +1 -46
- notionary/blocks/registry/block_registry_builder.py +8 -0
- notionary/blocks/rich_text/name_to_id_resolver.py +205 -0
- notionary/blocks/rich_text/rich_text_models.py +62 -29
- notionary/blocks/rich_text/text_inline_formatter.py +432 -101
- notionary/blocks/syntax_prompt_builder.py +137 -0
- notionary/blocks/table/table_element.py +110 -9
- notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
- notionary/blocks/todo/todo_element.py +21 -4
- notionary/blocks/toggle/toggle_element.py +19 -3
- notionary/blocks/toggle/toggle_markdown_node.py +1 -1
- notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
- notionary/blocks/types.py +69 -0
- notionary/blocks/video/video_element.py +44 -39
- notionary/blocks/video/video_markdown_node.py +10 -5
- notionary/database/client.py +23 -0
- notionary/file_upload/models.py +2 -2
- notionary/markdown/markdown_builder.py +34 -27
- notionary/page/client.py +26 -6
- notionary/page/notion_page.py +37 -6
- notionary/page/page_content_deleting_service.py +117 -0
- notionary/page/page_content_writer.py +89 -113
- notionary/page/page_context.py +65 -0
- notionary/page/reader/handler/__init__.py +2 -0
- notionary/page/reader/handler/base_block_renderer.py +4 -4
- notionary/page/reader/handler/block_rendering_context.py +5 -0
- notionary/page/reader/handler/line_renderer.py +16 -3
- notionary/page/reader/handler/numbered_list_renderer.py +85 -0
- notionary/page/reader/page_content_retriever.py +17 -5
- notionary/page/writer/handler/__init__.py +2 -0
- notionary/page/writer/handler/code_handler.py +12 -40
- notionary/page/writer/handler/column_handler.py +12 -12
- notionary/page/writer/handler/column_list_handler.py +13 -13
- notionary/page/writer/handler/equation_handler.py +74 -0
- notionary/page/writer/handler/line_handler.py +4 -4
- notionary/page/writer/handler/regular_line_handler.py +31 -37
- notionary/page/writer/handler/table_handler.py +8 -72
- notionary/page/writer/handler/toggle_handler.py +14 -12
- notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
- notionary/page/writer/markdown_to_notion_converter.py +28 -9
- notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
- notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
- notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
- notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
- notionary/page/writer/notion_text_length_processor.py +150 -0
- notionary/telemetry/service.py +0 -1
- notionary/user/notion_user_manager.py +22 -95
- notionary/util/concurrency_limiter.py +0 -0
- notionary/workspace.py +4 -4
- notionary-0.2.22.dist-info/METADATA +237 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/RECORD +92 -77
- notionary/page/markdown_whitespace_processor.py +0 -80
- notionary/page/notion_text_length_utils.py +0 -119
- notionary/user/notion_user_provider.py +0 -1
- notionary-0.2.21.dist-info/METADATA +0 -229
- /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/LICENSE +0 -0
- {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/WHEEL +0 -0
@@ -32,21 +32,21 @@ class ToggleableHeadingHandler(LineHandler):
|
|
32
32
|
or self._is_toggleable_heading_content(context)
|
33
33
|
)
|
34
34
|
|
35
|
-
def _process(self, context: LineProcessingContext) -> None:
|
35
|
+
async def _process(self, context: LineProcessingContext) -> None:
|
36
36
|
"""Process toggleable heading start, end, or content with unified handling."""
|
37
37
|
|
38
|
-
def _handle(action):
|
39
|
-
action(context)
|
38
|
+
async def _handle(action):
|
39
|
+
await action(context)
|
40
40
|
context.was_processed = True
|
41
41
|
context.should_continue = True
|
42
42
|
return True
|
43
43
|
|
44
44
|
if self._is_toggleable_heading_start(context):
|
45
|
-
return _handle(self._start_toggleable_heading)
|
45
|
+
return await _handle(self._start_toggleable_heading)
|
46
46
|
if self._is_toggleable_heading_end(context):
|
47
|
-
return _handle(self._finalize_toggleable_heading)
|
47
|
+
return await _handle(self._finalize_toggleable_heading)
|
48
48
|
if self._is_toggleable_heading_content(context):
|
49
|
-
return _handle(self._add_toggleable_heading_content)
|
49
|
+
return await _handle(self._add_toggleable_heading_content)
|
50
50
|
|
51
51
|
def _is_toggleable_heading_start(self, context: LineProcessingContext) -> bool:
|
52
52
|
"""Check if line starts a toggleable heading (+++# "Title")."""
|
@@ -64,16 +64,16 @@ class ToggleableHeadingHandler(LineHandler):
|
|
64
64
|
current_parent = context.parent_stack[-1]
|
65
65
|
return issubclass(current_parent.element_type, ToggleableHeadingElement)
|
66
66
|
|
67
|
-
def _start_toggleable_heading(self, context: LineProcessingContext) -> None:
|
67
|
+
async def _start_toggleable_heading(self, context: LineProcessingContext) -> None:
|
68
68
|
"""Start a new toggleable heading block."""
|
69
69
|
toggleable_heading_element = ToggleableHeadingElement()
|
70
70
|
|
71
71
|
# Create the block
|
72
|
-
result = toggleable_heading_element.markdown_to_notion(context.line)
|
72
|
+
result = await toggleable_heading_element.markdown_to_notion(context.line)
|
73
73
|
if not result:
|
74
74
|
return
|
75
75
|
|
76
|
-
block = result
|
76
|
+
block = result
|
77
77
|
|
78
78
|
# Push to parent stack
|
79
79
|
parent_context = ParentBlockContext(
|
@@ -96,16 +96,20 @@ class ToggleableHeadingHandler(LineHandler):
|
|
96
96
|
line = context.line.strip()
|
97
97
|
return not (self._start_pattern.match(line) or self._end_pattern.match(line))
|
98
98
|
|
99
|
-
def _add_toggleable_heading_content(
|
99
|
+
async def _add_toggleable_heading_content(
|
100
|
+
self, context: LineProcessingContext
|
101
|
+
) -> None:
|
100
102
|
"""Add content to the current toggleable heading context."""
|
101
103
|
context.parent_stack[-1].add_child_line(context.line)
|
102
104
|
|
103
|
-
def _finalize_toggleable_heading(
|
105
|
+
async def _finalize_toggleable_heading(
|
106
|
+
self, context: LineProcessingContext
|
107
|
+
) -> None:
|
104
108
|
"""Finalize a toggleable heading block and add it to result_blocks."""
|
105
109
|
heading_context = context.parent_stack.pop()
|
106
110
|
|
107
111
|
if heading_context.has_children():
|
108
|
-
all_children = self._get_all_children(
|
112
|
+
all_children = await self._get_all_children(
|
109
113
|
heading_context, context.block_registry
|
110
114
|
)
|
111
115
|
self._assign_heading_children(heading_context.block, all_children)
|
@@ -123,7 +127,7 @@ class ToggleableHeadingHandler(LineHandler):
|
|
123
127
|
# No parent, add to top level
|
124
128
|
context.result_blocks.append(heading_context.block)
|
125
129
|
|
126
|
-
def _get_all_children(
|
130
|
+
async def _get_all_children(
|
127
131
|
self, parent_context: ParentBlockContext, block_registry
|
128
132
|
) -> list:
|
129
133
|
"""Helper method to combine text-based and direct block children."""
|
@@ -132,7 +136,9 @@ class ToggleableHeadingHandler(LineHandler):
|
|
132
136
|
# Process text lines
|
133
137
|
if parent_context.child_lines:
|
134
138
|
children_text = "\n".join(parent_context.child_lines)
|
135
|
-
text_blocks = self._convert_children_text(
|
139
|
+
text_blocks = await self._convert_children_text(
|
140
|
+
children_text, block_registry
|
141
|
+
)
|
136
142
|
children_blocks.extend(text_blocks)
|
137
143
|
|
138
144
|
# Add direct blocks
|
@@ -154,7 +160,7 @@ class ToggleableHeadingHandler(LineHandler):
|
|
154
160
|
elif block_type == BlockType.HEADING_3:
|
155
161
|
parent_block.heading_3.children = children
|
156
162
|
|
157
|
-
def _convert_children_text(self, text: str, block_registry) -> list:
|
163
|
+
async def _convert_children_text(self, text: str, block_registry) -> list:
|
158
164
|
"""Convert children text to blocks."""
|
159
165
|
from notionary.page.writer.markdown_to_notion_converter import (
|
160
166
|
MarkdownToNotionConverter,
|
@@ -164,4 +170,4 @@ class ToggleableHeadingHandler(LineHandler):
|
|
164
170
|
return []
|
165
171
|
|
166
172
|
child_converter = MarkdownToNotionConverter(block_registry)
|
167
|
-
return child_converter.
|
173
|
+
return await child_converter.process_lines(text)
|
@@ -1,10 +1,10 @@
|
|
1
1
|
from notionary.blocks.models import BlockCreateRequest
|
2
2
|
from notionary.blocks.registry.block_registry import BlockRegistry
|
3
|
-
from notionary.page.notion_text_length_utils import fix_blocks_content_length
|
4
3
|
from notionary.page.writer.handler import (
|
5
4
|
CodeHandler,
|
6
5
|
ColumnHandler,
|
7
6
|
ColumnListHandler,
|
7
|
+
EquationHandler,
|
8
8
|
LineProcessingContext,
|
9
9
|
ParentBlockContext,
|
10
10
|
RegularLineHandler,
|
@@ -12,6 +12,12 @@ from notionary.page.writer.handler import (
|
|
12
12
|
ToggleableHeadingHandler,
|
13
13
|
ToggleHandler,
|
14
14
|
)
|
15
|
+
from notionary.page.writer.markdown_to_notion_formatting_post_processor import (
|
16
|
+
MarkdownToNotionFormattingPostProcessor,
|
17
|
+
)
|
18
|
+
from notionary.page.writer.notion_text_length_processor import (
|
19
|
+
NotionTextLengthProcessor,
|
20
|
+
)
|
15
21
|
|
16
22
|
|
17
23
|
class MarkdownToNotionConverter:
|
@@ -19,10 +25,14 @@ class MarkdownToNotionConverter:
|
|
19
25
|
|
20
26
|
def __init__(self, block_registry: BlockRegistry) -> None:
|
21
27
|
self._block_registry = block_registry
|
28
|
+
self._formatting_post_processor = MarkdownToNotionFormattingPostProcessor()
|
29
|
+
self._text_length_post_processor = NotionTextLengthProcessor()
|
30
|
+
|
22
31
|
self._setup_handler_chain()
|
23
32
|
|
24
33
|
def _setup_handler_chain(self) -> None:
|
25
34
|
code_handler = CodeHandler()
|
35
|
+
equation_handler = EquationHandler()
|
26
36
|
table_handler = TableHandler()
|
27
37
|
column_list_handler = ColumnListHandler()
|
28
38
|
column_handler = ColumnHandler()
|
@@ -31,22 +41,31 @@ class MarkdownToNotionConverter:
|
|
31
41
|
regular_handler = RegularLineHandler()
|
32
42
|
|
33
43
|
# register more specific elements first
|
34
|
-
code_handler.set_next(
|
35
|
-
|
36
|
-
).set_next(
|
44
|
+
code_handler.set_next(equation_handler).set_next(table_handler).set_next(
|
45
|
+
column_list_handler
|
46
|
+
).set_next(column_handler).set_next(toggleable_heading_handler).set_next(
|
47
|
+
toggle_handler
|
48
|
+
).set_next(
|
37
49
|
regular_handler
|
38
50
|
)
|
39
51
|
|
40
52
|
self._handler_chain = code_handler
|
41
53
|
|
42
|
-
def convert(self, markdown_text: str) -> list[BlockCreateRequest]:
|
54
|
+
async def convert(self, markdown_text: str) -> list[BlockCreateRequest]:
|
43
55
|
if not markdown_text.strip():
|
44
56
|
return []
|
45
57
|
|
46
|
-
all_blocks = self.
|
47
|
-
|
58
|
+
all_blocks = await self.process_lines(markdown_text)
|
59
|
+
|
60
|
+
# Apply formatting post-processing (empty paragraphs)
|
61
|
+
all_blocks = self._formatting_post_processor.process(all_blocks)
|
62
|
+
|
63
|
+
# Apply text length post-processing (truncation)
|
64
|
+
all_blocks = self._text_length_post_processor.process(all_blocks)
|
65
|
+
|
66
|
+
return all_blocks
|
48
67
|
|
49
|
-
def
|
68
|
+
async def process_lines(self, text: str) -> list[BlockCreateRequest]:
|
50
69
|
lines = text.split("\n")
|
51
70
|
result_blocks: list[BlockCreateRequest] = []
|
52
71
|
parent_stack: list[ParentBlockContext] = []
|
@@ -65,7 +84,7 @@ class MarkdownToNotionConverter:
|
|
65
84
|
lines_consumed=0,
|
66
85
|
)
|
67
86
|
|
68
|
-
self._handler_chain.handle(context)
|
87
|
+
await self._handler_chain.handle(context)
|
69
88
|
|
70
89
|
# Skip consumed lines
|
71
90
|
i += 1 + context.lines_consumed
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# notionary/blocks/context/conversion_context.py
|
2
|
+
from __future__ import annotations
|
3
|
+
|
4
|
+
from typing import Optional, TYPE_CHECKING
|
5
|
+
from dataclasses import dataclass
|
6
|
+
|
7
|
+
if TYPE_CHECKING:
|
8
|
+
from notionary.database.client import NotionDatabaseClient
|
9
|
+
|
10
|
+
|
11
|
+
@dataclass
|
12
|
+
class ConverterContext:
|
13
|
+
"""
|
14
|
+
Context object that provides dependencies for block conversion operations.
|
15
|
+
"""
|
16
|
+
|
17
|
+
page_id: Optional[str] = None
|
18
|
+
database_client: Optional["NotionDatabaseClient"] = None
|
19
|
+
|
20
|
+
def require_database_client(self) -> NotionDatabaseClient:
|
21
|
+
"""Get database client or raise if not available."""
|
22
|
+
if self.database_client is None:
|
23
|
+
raise ValueError("Database client required but not provided in context")
|
24
|
+
return self.database_client
|
25
|
+
|
26
|
+
def require_page_id(self) -> str:
|
27
|
+
"""Get parent page ID or raise if not available."""
|
28
|
+
if self.page_id is None:
|
29
|
+
raise ValueError("Parent page ID required but not provided in context")
|
30
|
+
return self.page_id
|
@@ -0,0 +1,73 @@
|
|
1
|
+
"""
|
2
|
+
Post-processor for handling block formatting in Markdown to Notion conversion.
|
3
|
+
|
4
|
+
Handles block formatting tasks like adding empty paragraphs before media blocks
|
5
|
+
and other formatting-related post-processing.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import cast
|
9
|
+
|
10
|
+
from notionary.blocks.models import BlockCreateRequest
|
11
|
+
from notionary.blocks.types import BlockType
|
12
|
+
from notionary.blocks.paragraph.paragraph_models import (
|
13
|
+
CreateParagraphBlock,
|
14
|
+
ParagraphBlock,
|
15
|
+
)
|
16
|
+
|
17
|
+
|
18
|
+
class MarkdownToNotionFormattingPostProcessor:
|
19
|
+
"""Handles block formatting post-processing for Notion blocks."""
|
20
|
+
|
21
|
+
BLOCKS_NEEDING_EMPTY_PARAGRAPH: set[BlockType] = {
|
22
|
+
BlockType.DIVIDER,
|
23
|
+
BlockType.FILE,
|
24
|
+
BlockType.IMAGE,
|
25
|
+
BlockType.PDF,
|
26
|
+
BlockType.VIDEO,
|
27
|
+
}
|
28
|
+
|
29
|
+
def process(self, blocks: list[BlockCreateRequest]) -> list[BlockCreateRequest]:
|
30
|
+
"""Process blocks with all formatting steps."""
|
31
|
+
if not blocks:
|
32
|
+
return blocks
|
33
|
+
|
34
|
+
return self._add_empty_paragraphs_for_media_blocks(blocks)
|
35
|
+
|
36
|
+
def _add_empty_paragraphs_for_media_blocks(
|
37
|
+
self, blocks: list[BlockCreateRequest]
|
38
|
+
) -> list[BlockCreateRequest]:
|
39
|
+
"""Add empty paragraphs before configured block types."""
|
40
|
+
if not blocks:
|
41
|
+
return blocks
|
42
|
+
|
43
|
+
result: list[BlockCreateRequest] = []
|
44
|
+
|
45
|
+
for i, block in enumerate(blocks):
|
46
|
+
block_type = block.type
|
47
|
+
|
48
|
+
if (
|
49
|
+
block_type in self.BLOCKS_NEEDING_EMPTY_PARAGRAPH
|
50
|
+
and i > 0
|
51
|
+
and not self._is_empty_paragraph(result[-1] if result else None)
|
52
|
+
):
|
53
|
+
|
54
|
+
# Create empty paragraph block inline
|
55
|
+
empty_paragraph = CreateParagraphBlock(
|
56
|
+
paragraph=ParagraphBlock(rich_text=[])
|
57
|
+
)
|
58
|
+
result.append(empty_paragraph)
|
59
|
+
|
60
|
+
result.append(block)
|
61
|
+
|
62
|
+
return result
|
63
|
+
|
64
|
+
def _is_empty_paragraph(self, block: BlockCreateRequest | None) -> bool:
|
65
|
+
if not block or block.type != BlockType.PARAGRAPH:
|
66
|
+
return False
|
67
|
+
if not isinstance(block, CreateParagraphBlock):
|
68
|
+
return False
|
69
|
+
|
70
|
+
para_block = cast(CreateParagraphBlock, block)
|
71
|
+
paragraph: ParagraphBlock | None = para_block.paragraph
|
72
|
+
if not paragraph:
|
73
|
+
return False
|
File without changes
|
File without changes
|
@@ -0,0 +1,150 @@
|
|
1
|
+
"""
|
2
|
+
Post-processor for handling Notion API text length limitations.
|
3
|
+
|
4
|
+
Handles text length validation and truncation for blocks that exceed
|
5
|
+
Notion's rich_text character limit of 2000 characters per element.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from typing import TypeGuard, Union
|
9
|
+
|
10
|
+
from notionary.blocks.models import BlockCreateRequest
|
11
|
+
from notionary.blocks.rich_text.rich_text_models import RichTextObject
|
12
|
+
from notionary.blocks.types import HasRichText, HasChildren
|
13
|
+
from notionary.util import LoggingMixin
|
14
|
+
|
15
|
+
|
16
|
+
class NotionTextLengthProcessor(LoggingMixin):
|
17
|
+
"""
|
18
|
+
Processes Notion blocks to ensure text content doesn't exceed API limits.
|
19
|
+
|
20
|
+
The Notion API has a limit of 2000 characters per rich_text element.
|
21
|
+
This processor truncates content that exceeds the specified limit.
|
22
|
+
"""
|
23
|
+
|
24
|
+
DEFAULT_MAX_LENGTH = 1900 # Leave some buffer under the 2000 limit
|
25
|
+
|
26
|
+
def __init__(self, max_text_length: int = DEFAULT_MAX_LENGTH) -> None:
|
27
|
+
"""
|
28
|
+
Initialize the processor.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
max_text_length: Maximum allowed text length (default: 1900)
|
32
|
+
"""
|
33
|
+
if max_text_length <= 0:
|
34
|
+
raise ValueError("max_text_length must be positive")
|
35
|
+
if max_text_length > 2000:
|
36
|
+
self.logger.warning(
|
37
|
+
"max_text_length (%d) exceeds Notion's limit of 2000 characters",
|
38
|
+
max_text_length,
|
39
|
+
)
|
40
|
+
|
41
|
+
self.max_text_length = max_text_length
|
42
|
+
|
43
|
+
def process(self, blocks: list[BlockCreateRequest]) -> list[BlockCreateRequest]:
|
44
|
+
"""
|
45
|
+
Process blocks to fix text length limits.
|
46
|
+
"""
|
47
|
+
if not blocks:
|
48
|
+
return blocks
|
49
|
+
|
50
|
+
flattened_blocks = self._flatten_block_list(blocks)
|
51
|
+
return [self._process_single_block(block) for block in flattened_blocks]
|
52
|
+
|
53
|
+
def _process_single_block(self, block: BlockCreateRequest) -> BlockCreateRequest:
|
54
|
+
"""
|
55
|
+
Process a single block to fix text length issues.
|
56
|
+
"""
|
57
|
+
block_copy = block.model_copy(deep=True)
|
58
|
+
|
59
|
+
block_content = self._extract_block_content(block_copy)
|
60
|
+
|
61
|
+
if block_content is not None:
|
62
|
+
self._fix_content_text_lengths(block_content)
|
63
|
+
|
64
|
+
return block_copy
|
65
|
+
|
66
|
+
def _extract_block_content(self, block: BlockCreateRequest) -> object | None:
|
67
|
+
"""
|
68
|
+
Extract the content object from a block using type-safe attribute access.
|
69
|
+
"""
|
70
|
+
# Get the block's content using the block type as attribute name
|
71
|
+
# We assume block.type always exists as per the BlockCreateRequest structure
|
72
|
+
content = getattr(block, block.type, None)
|
73
|
+
|
74
|
+
# Verify it's a valid content object (has rich_text or children)
|
75
|
+
if content and (
|
76
|
+
self._is_rich_text_container(content)
|
77
|
+
or self._is_children_container(content)
|
78
|
+
):
|
79
|
+
return content
|
80
|
+
|
81
|
+
return None
|
82
|
+
|
83
|
+
def _fix_content_text_lengths(self, content: object) -> None:
|
84
|
+
"""
|
85
|
+
Fix text lengths in a content object and its children recursively.
|
86
|
+
"""
|
87
|
+
# Process rich_text if present
|
88
|
+
if self._is_rich_text_container(content):
|
89
|
+
self._truncate_rich_text_content(content.rich_text)
|
90
|
+
|
91
|
+
# Process children recursively if present
|
92
|
+
if self._is_children_container(content):
|
93
|
+
for child in content.children:
|
94
|
+
child_content = self._extract_block_content(child)
|
95
|
+
if child_content:
|
96
|
+
self._fix_content_text_lengths(child_content)
|
97
|
+
|
98
|
+
def _truncate_rich_text_content(self, rich_text_list: list[RichTextObject]) -> None:
|
99
|
+
"""
|
100
|
+
Truncate text content in rich text objects that exceed the limit.
|
101
|
+
"""
|
102
|
+
for rich_text_obj in rich_text_list:
|
103
|
+
if not self._is_text_rich_text_object(rich_text_obj):
|
104
|
+
continue
|
105
|
+
|
106
|
+
content = rich_text_obj.text.content
|
107
|
+
if len(content) > self.max_text_length:
|
108
|
+
self.logger.warning(
|
109
|
+
"Truncating text content from %d to %d characters",
|
110
|
+
len(content),
|
111
|
+
self.max_text_length,
|
112
|
+
)
|
113
|
+
# Truncate the content
|
114
|
+
rich_text_obj.text.content = content[: self.max_text_length]
|
115
|
+
|
116
|
+
def _flatten_block_list(
|
117
|
+
self, blocks: list[Union[BlockCreateRequest, list]]
|
118
|
+
) -> list[BlockCreateRequest]:
|
119
|
+
"""
|
120
|
+
Flatten a potentially nested list of blocks.
|
121
|
+
"""
|
122
|
+
flattened: list[BlockCreateRequest] = []
|
123
|
+
|
124
|
+
for item in blocks:
|
125
|
+
if isinstance(item, list):
|
126
|
+
# Recursively flatten nested lists
|
127
|
+
flattened.extend(self._flatten_block_list(item))
|
128
|
+
else:
|
129
|
+
# Add individual block
|
130
|
+
flattened.append(item)
|
131
|
+
|
132
|
+
return flattened
|
133
|
+
|
134
|
+
def _is_rich_text_container(self, obj: object) -> TypeGuard[HasRichText]:
|
135
|
+
"""Type guard to check if an object has rich_text attribute."""
|
136
|
+
return hasattr(obj, "rich_text") and isinstance(getattr(obj, "rich_text"), list)
|
137
|
+
|
138
|
+
def _is_children_container(self, obj: object) -> TypeGuard[HasChildren]:
|
139
|
+
"""Type guard to check if an object has children attribute."""
|
140
|
+
return hasattr(obj, "children") and isinstance(getattr(obj, "children"), list)
|
141
|
+
|
142
|
+
def _is_text_rich_text_object(
|
143
|
+
self, rich_text_obj: RichTextObject
|
144
|
+
) -> TypeGuard[RichTextObject]:
|
145
|
+
"""Type guard to check if a RichTextObject is of type 'text' with content."""
|
146
|
+
return (
|
147
|
+
rich_text_obj.type == "text"
|
148
|
+
and rich_text_obj.text is not None
|
149
|
+
and rich_text_obj.text.content is not None
|
150
|
+
)
|
notionary/telemetry/service.py
CHANGED
@@ -1,7 +1,6 @@
|
|
1
|
-
from typing import
|
1
|
+
from typing import Optional
|
2
2
|
|
3
3
|
from notionary.user.client import NotionUserClient
|
4
|
-
from notionary.user.models import NotionUsersListResponse
|
5
4
|
from notionary.user.notion_user import NotionUser
|
6
5
|
from notionary.util import LoggingMixin
|
7
6
|
|
@@ -18,72 +17,52 @@ class NotionUserManager(LoggingMixin):
|
|
18
17
|
"""Initialize the user manager."""
|
19
18
|
self.client = NotionUserClient(token=token)
|
20
19
|
|
21
|
-
async def get_current_bot_user(self) -> Optional[NotionUser]:
|
22
|
-
"""
|
23
|
-
Get the current bot user from the API token.
|
24
|
-
"""
|
25
|
-
return await NotionUser.current_bot_user(token=self.client.token)
|
26
|
-
|
27
20
|
async def get_user_by_id(self, user_id: str) -> Optional[NotionUser]:
|
28
21
|
"""
|
29
22
|
Get a specific user by their ID.
|
30
23
|
"""
|
31
24
|
return await NotionUser.from_user_id(user_id, token=self.client.token)
|
32
25
|
|
33
|
-
async def
|
34
|
-
self, page_size: int = 100, start_cursor: Optional[str] = None
|
35
|
-
) -> Optional[NotionUsersListResponse]:
|
36
|
-
"""
|
37
|
-
List users in the workspace (paginated).
|
38
|
-
|
39
|
-
Note: Guests are not included in the response.
|
40
|
-
"""
|
41
|
-
try:
|
42
|
-
response = await self.client.list_users(page_size, start_cursor)
|
43
|
-
if response is None:
|
44
|
-
self.logger.error("Failed to list users")
|
45
|
-
return None
|
46
|
-
|
47
|
-
self.logger.info(
|
48
|
-
"Retrieved %d users (has_more: %s)",
|
49
|
-
len(response.results),
|
50
|
-
response.has_more,
|
51
|
-
)
|
52
|
-
return response
|
53
|
-
|
54
|
-
except Exception as e:
|
55
|
-
self.logger.error("Error listing users: %s", str(e))
|
56
|
-
return None
|
57
|
-
|
58
|
-
async def get_all_users(self) -> List[NotionUser]:
|
26
|
+
async def get_all_users(self) -> list[NotionUser]:
|
59
27
|
"""
|
60
28
|
Get all users in the workspace as NotionUser objects.
|
61
29
|
Automatically handles pagination and converts responses to NotionUser instances.
|
30
|
+
Only returns person users, excludes bots and integrations.
|
62
31
|
"""
|
63
32
|
try:
|
64
33
|
# Get raw user responses
|
65
34
|
user_responses = await self.client.get_all_users()
|
66
35
|
|
67
|
-
#
|
36
|
+
# Filter for person users only and convert to NotionUser objects
|
68
37
|
notion_users = []
|
69
38
|
for user_response in user_responses:
|
39
|
+
# Skip bot users and integrations
|
40
|
+
if user_response.type != "person":
|
41
|
+
self.logger.debug(
|
42
|
+
"Skipping non-person user %s (type: %s)",
|
43
|
+
user_response.id,
|
44
|
+
user_response.type,
|
45
|
+
)
|
46
|
+
continue
|
47
|
+
|
70
48
|
try:
|
71
49
|
# Use the internal creation method to convert response to NotionUser
|
72
|
-
notion_user = NotionUser.
|
50
|
+
notion_user = NotionUser.from_user_response(
|
73
51
|
user_response, self.client.token
|
74
52
|
)
|
75
53
|
notion_users.append(notion_user)
|
76
54
|
except Exception as e:
|
77
55
|
self.logger.warning(
|
78
|
-
"Failed to convert user %s to NotionUser: %s",
|
56
|
+
"Failed to convert person user %s to NotionUser: %s",
|
79
57
|
user_response.id,
|
80
58
|
str(e),
|
81
59
|
)
|
82
60
|
continue
|
83
61
|
|
84
62
|
self.logger.info(
|
85
|
-
"Successfully converted %d users to NotionUser objects",
|
63
|
+
"Successfully converted %d person users to NotionUser objects (skipped %d non-person users)",
|
86
64
|
len(notion_users),
|
65
|
+
len(user_responses) - len(notion_users),
|
87
66
|
)
|
88
67
|
return notion_users
|
89
68
|
|
@@ -91,68 +70,16 @@ class NotionUserManager(LoggingMixin):
|
|
91
70
|
self.logger.error("Error getting all users: %s", str(e))
|
92
71
|
return []
|
93
72
|
|
94
|
-
async def
|
95
|
-
"""
|
96
|
-
Get all users of a specific type (person or bot).
|
97
|
-
"""
|
98
|
-
try:
|
99
|
-
all_users = await self.get_all_users()
|
100
|
-
filtered_users = [user for user in all_users if user.user_type == user_type]
|
101
|
-
|
102
|
-
self.logger.info(
|
103
|
-
"Found %d users of type '%s' out of %d total users",
|
104
|
-
len(filtered_users),
|
105
|
-
user_type,
|
106
|
-
len(all_users),
|
107
|
-
)
|
108
|
-
return filtered_users
|
109
|
-
|
110
|
-
except Exception as e:
|
111
|
-
self.logger.error("Error filtering users by type: %s", str(e))
|
112
|
-
return []
|
113
|
-
|
114
|
-
# TODO: Type this
|
115
|
-
async def get_workspace_info(self) -> Optional[Dict[str, Any]]:
|
116
|
-
"""
|
117
|
-
Get available workspace information from the bot user.
|
118
|
-
"""
|
119
|
-
bot_user = await self.get_current_bot_user()
|
120
|
-
if bot_user is None:
|
121
|
-
self.logger.error("Failed to get bot user for workspace info")
|
122
|
-
return None
|
123
|
-
|
124
|
-
workspace_info = {
|
125
|
-
"workspace_name": bot_user.workspace_name,
|
126
|
-
"bot_user_id": bot_user.id,
|
127
|
-
"bot_user_name": bot_user.name,
|
128
|
-
"bot_user_type": bot_user.user_type,
|
129
|
-
}
|
130
|
-
|
131
|
-
# Add workspace limits if available
|
132
|
-
if bot_user.is_bot:
|
133
|
-
limits = await bot_user.get_workspace_limits()
|
134
|
-
if limits:
|
135
|
-
workspace_info["workspace_limits"] = limits
|
136
|
-
|
137
|
-
# Add user count statistics
|
138
|
-
try:
|
139
|
-
all_users = await self.get_all_users()
|
140
|
-
workspace_info["total_users"] = len(all_users)
|
141
|
-
workspace_info["person_users"] = len([u for u in all_users if u.is_person])
|
142
|
-
workspace_info["bot_users"] = len([u for u in all_users if u.is_bot])
|
143
|
-
except Exception as e:
|
144
|
-
self.logger.warning("Could not get user statistics: %s", str(e))
|
145
|
-
|
146
|
-
return workspace_info
|
147
|
-
|
148
|
-
async def find_users_by_name(self, name_pattern: str) -> List[NotionUser]:
|
73
|
+
async def find_users_by_name(self, name_pattern: str) -> list[NotionUser]:
|
149
74
|
"""
|
150
|
-
Find users by name pattern (case-insensitive partial match).
|
75
|
+
Find person users by name pattern (case-insensitive partial match).
|
76
|
+
Only returns person users, excludes bots and integrations.
|
151
77
|
|
152
78
|
Note: The API doesn't support server-side filtering, so this fetches all users
|
153
79
|
and filters client-side.
|
154
80
|
"""
|
155
81
|
try:
|
82
|
+
# get_all_users() already filters for person users only
|
156
83
|
all_users = await self.get_all_users()
|
157
84
|
pattern_lower = name_pattern.lower()
|
158
85
|
|
@@ -163,7 +90,7 @@ class NotionUserManager(LoggingMixin):
|
|
163
90
|
]
|
164
91
|
|
165
92
|
self.logger.info(
|
166
|
-
"Found %d users matching pattern '%s'",
|
93
|
+
"Found %d person users matching pattern '%s'",
|
167
94
|
len(matching_users),
|
168
95
|
name_pattern,
|
169
96
|
)
|
File without changes
|
notionary/workspace.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
1
|
import asyncio
|
2
|
-
from typing import
|
2
|
+
from typing import Optional
|
3
3
|
|
4
4
|
from notionary import NotionDatabase, NotionPage
|
5
5
|
from notionary.database.client import NotionDatabaseClient
|
@@ -24,7 +24,7 @@ class NotionWorkspace(LoggingMixin):
|
|
24
24
|
self.page_client = NotionPageClient(token=token)
|
25
25
|
self.user_manager = NotionUserManager(token=token)
|
26
26
|
|
27
|
-
async def search_pages(self, query: str, limit=100) ->
|
27
|
+
async def search_pages(self, query: str, limit=100) -> list[NotionPage]:
|
28
28
|
"""
|
29
29
|
Search for pages globally across Notion workspace.
|
30
30
|
"""
|
@@ -35,7 +35,7 @@ class NotionWorkspace(LoggingMixin):
|
|
35
35
|
|
36
36
|
async def search_databases(
|
37
37
|
self, query: str, limit: int = 100
|
38
|
-
) ->
|
38
|
+
) -> list[NotionDatabase]:
|
39
39
|
"""
|
40
40
|
Search for databases globally across the Notion workspace.
|
41
41
|
"""
|
@@ -58,7 +58,7 @@ class NotionWorkspace(LoggingMixin):
|
|
58
58
|
|
59
59
|
return databases[0] if databases else None
|
60
60
|
|
61
|
-
async def list_all_databases(self, limit: int = 100) ->
|
61
|
+
async def list_all_databases(self, limit: int = 100) -> list[NotionDatabase]:
|
62
62
|
"""
|
63
63
|
List all databases in the workspace.
|
64
64
|
Returns a list of NotionDatabase instances.
|