notionary 0.2.17__py3-none-any.whl → 0.2.19__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.
Files changed (113) hide show
  1. notionary/__init__.py +3 -2
  2. notionary/blocks/__init__.py +54 -25
  3. notionary/blocks/audio/__init__.py +7 -0
  4. notionary/blocks/audio/audio_element.py +152 -0
  5. notionary/blocks/audio/audio_markdown_node.py +29 -0
  6. notionary/blocks/audio/audio_models.py +59 -0
  7. notionary/blocks/bookmark/__init__.py +7 -0
  8. notionary/blocks/{bookmark_element.py → bookmark/bookmark_element.py} +20 -65
  9. notionary/blocks/bookmark/bookmark_markdown_node.py +43 -0
  10. notionary/blocks/bookmark/bookmark_models.py +0 -0
  11. notionary/blocks/bulleted_list/__init__.py +7 -0
  12. notionary/blocks/{bulleted_list_element.py → bulleted_list/bulleted_list_element.py} +7 -3
  13. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +33 -0
  14. notionary/blocks/bulleted_list/bulleted_list_models.py +0 -0
  15. notionary/blocks/callout/__init__.py +7 -0
  16. notionary/blocks/callout/callout_element.py +132 -0
  17. notionary/blocks/callout/callout_markdown_node.py +31 -0
  18. notionary/blocks/callout/callout_models.py +0 -0
  19. notionary/blocks/code/__init__.py +7 -0
  20. notionary/blocks/{code_block_element.py → code/code_element.py} +72 -40
  21. notionary/blocks/code/code_markdown_node.py +43 -0
  22. notionary/blocks/code/code_models.py +0 -0
  23. notionary/blocks/column/__init__.py +5 -0
  24. notionary/blocks/{column_element.py → column/column_element.py} +24 -55
  25. notionary/blocks/column/column_models.py +0 -0
  26. notionary/blocks/divider/__init__.py +7 -0
  27. notionary/blocks/{divider_element.py → divider/divider_element.py} +11 -3
  28. notionary/blocks/divider/divider_markdown_node.py +24 -0
  29. notionary/blocks/divider/divider_models.py +0 -0
  30. notionary/blocks/document/__init__.py +7 -0
  31. notionary/blocks/document/document_element.py +102 -0
  32. notionary/blocks/document/document_markdown_node.py +31 -0
  33. notionary/blocks/document/document_models.py +0 -0
  34. notionary/blocks/embed/__init__.py +7 -0
  35. notionary/blocks/{embed_element.py → embed/embed_element.py} +50 -32
  36. notionary/blocks/embed/embed_markdown_node.py +30 -0
  37. notionary/blocks/embed/embed_models.py +0 -0
  38. notionary/blocks/heading/__init__.py +7 -0
  39. notionary/blocks/{heading_element.py → heading/heading_element.py} +25 -17
  40. notionary/blocks/heading/heading_markdown_node.py +29 -0
  41. notionary/blocks/heading/heading_models.py +0 -0
  42. notionary/blocks/image/__init__.py +7 -0
  43. notionary/blocks/{image_element.py → image/image_element.py} +62 -42
  44. notionary/blocks/image/image_markdown_node.py +33 -0
  45. notionary/blocks/image/image_models.py +0 -0
  46. notionary/blocks/markdown_builder.py +356 -0
  47. notionary/blocks/markdown_node.py +29 -0
  48. notionary/blocks/mention/__init__.py +7 -0
  49. notionary/blocks/{mention_element.py → mention/mention_element.py} +6 -2
  50. notionary/blocks/mention/mention_markdown_node.py +38 -0
  51. notionary/blocks/mention/mention_models.py +0 -0
  52. notionary/blocks/numbered_list/__init__.py +7 -0
  53. notionary/blocks/{numbered_list_element.py → numbered_list/numbered_list_element.py} +10 -6
  54. notionary/blocks/numbered_list/numbered_list_markdown_node.py +29 -0
  55. notionary/blocks/numbered_list/numbered_list_models.py +0 -0
  56. notionary/blocks/paragraph/__init__.py +7 -0
  57. notionary/blocks/{paragraph_element.py → paragraph/paragraph_element.py} +7 -3
  58. notionary/blocks/paragraph/paragraph_markdown_node.py +25 -0
  59. notionary/blocks/paragraph/paragraph_models.py +0 -0
  60. notionary/blocks/quote/__init__.py +7 -0
  61. notionary/blocks/quote/quote_element.py +92 -0
  62. notionary/blocks/quote/quote_markdown_node.py +23 -0
  63. notionary/blocks/quote/quote_models.py +0 -0
  64. notionary/blocks/registry/block_registry.py +17 -3
  65. notionary/blocks/registry/block_registry_builder.py +90 -178
  66. notionary/blocks/shared/__init__.py +0 -0
  67. notionary/blocks/shared/block_client.py +256 -0
  68. notionary/blocks/shared/models.py +713 -0
  69. notionary/blocks/{notion_block_element.py → shared/notion_block_element.py} +8 -5
  70. notionary/blocks/{text_inline_formatter.py → shared/text_inline_formatter.py} +14 -14
  71. notionary/blocks/shared/text_inline_formatter_new.py +139 -0
  72. notionary/blocks/table/__init__.py +7 -0
  73. notionary/blocks/{table_element.py → table/table_element.py} +23 -11
  74. notionary/blocks/table/table_markdown_node.py +40 -0
  75. notionary/blocks/table/table_models.py +0 -0
  76. notionary/blocks/todo/__init__.py +7 -0
  77. notionary/blocks/{todo_element.py → todo/todo_element.py} +8 -4
  78. notionary/blocks/todo/todo_markdown_node.py +31 -0
  79. notionary/blocks/todo/todo_models.py +0 -0
  80. notionary/blocks/toggle/__init__.py +4 -0
  81. notionary/blocks/{toggle_element.py → toggle/toggle_element.py} +7 -3
  82. notionary/blocks/toggle/toggle_markdown_node.py +35 -0
  83. notionary/blocks/toggle/toggle_models.py +0 -0
  84. notionary/blocks/toggleable_heading/__init__.py +9 -0
  85. notionary/blocks/{toggleable_heading_element.py → toggleable_heading/toggleable_heading_element.py} +8 -4
  86. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +43 -0
  87. notionary/blocks/toggleable_heading/toggleable_heading_models.py +0 -0
  88. notionary/blocks/video/__init__.py +7 -0
  89. notionary/blocks/{video_element.py → video/video_element.py} +82 -57
  90. notionary/blocks/video/video_markdown_node.py +30 -0
  91. notionary/file_upload/notion_file_upload.py +1 -1
  92. notionary/page/content/markdown_whitespace_processor.py +80 -0
  93. notionary/page/content/notion_text_length_utils.py +87 -0
  94. notionary/page/content/page_content_retriever.py +18 -10
  95. notionary/page/content/page_content_writer.py +97 -148
  96. notionary/page/formatting/line_processor.py +153 -0
  97. notionary/page/formatting/markdown_to_notion_converter.py +104 -425
  98. notionary/page/notion_page.py +9 -11
  99. notionary/page/notion_to_markdown_converter.py +9 -13
  100. notionary/util/factory_decorator.py +0 -0
  101. notionary/workspace.py +0 -1
  102. {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/METADATA +1 -1
  103. notionary-0.2.19.dist-info/RECORD +150 -0
  104. notionary/blocks/audio_element.py +0 -144
  105. notionary/blocks/callout_element.py +0 -122
  106. notionary/blocks/document_element.py +0 -194
  107. notionary/blocks/notion_block_client.py +0 -26
  108. notionary/blocks/qoute_element.py +0 -169
  109. notionary/page/content/notion_page_content_chunker.py +0 -84
  110. notionary/page/formatting/spacer_rules.py +0 -483
  111. notionary-0.2.17.dist-info/RECORD +0 -85
  112. {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/LICENSE +0 -0
  113. {notionary-0.2.17.dist-info → notionary-0.2.19.dist-info}/WHEEL +0 -0
@@ -1,49 +1,31 @@
1
- from typing import Any, Dict
1
+ from typing import Optional
2
2
 
3
- from notionary.blocks import DividerElement, BlockRegistry
4
-
5
- from notionary.page.client import NotionPageClient
3
+ from notionary.blocks import BlockRegistry
4
+ from notionary.blocks.shared.block_client import NotionBlockClient
5
+ from notionary.models.notion_block_response import Block
6
+ from notionary.page.content.markdown_whitespace_processor import (
7
+ MarkdownWhitespaceProcessor,
8
+ )
9
+ from notionary.page.content.notion_text_length_utils import fix_blocks_content_length
6
10
  from notionary.page.formatting.markdown_to_notion_converter import (
7
11
  MarkdownToNotionConverter,
8
12
  )
9
- from notionary.page.notion_to_markdown_converter import (
10
- NotionToMarkdownConverter,
11
- )
12
- from notionary.page.content.notion_page_content_chunker import (
13
- NotionPageContentChunker,
14
- )
13
+
15
14
  from notionary.util import LoggingMixin
16
15
 
17
16
 
18
17
  class PageContentWriter(LoggingMixin):
19
- def __init__(
20
- self,
21
- page_id: str,
22
- client: NotionPageClient,
23
- block_registry: BlockRegistry,
24
- ):
18
+ def __init__(self, page_id: str, block_registry: BlockRegistry):
25
19
  self.page_id = page_id
26
- self._client = client
27
20
  self.block_registry = block_registry
21
+ self._block_client = NotionBlockClient()
22
+
28
23
  self._markdown_to_notion_converter = MarkdownToNotionConverter(
29
24
  block_registry=block_registry
30
25
  )
31
- self._notion_to_markdown_converter = NotionToMarkdownConverter(
32
- block_registry=block_registry
33
- )
34
- self._chunker = NotionPageContentChunker()
35
-
36
- async def append_markdown(self, markdown_text: str, append_divider=False) -> bool:
37
- """
38
- Append markdown text to a Notion page, automatically handling content length limits.
39
- """
40
- if append_divider and not self.block_registry.contains(DividerElement):
41
- self.logger.warning(
42
- "DividerElement not registered. Appending divider skipped."
43
- )
44
- append_divider = False
45
26
 
46
- # Append divider in markdown format as it will be converted to a Notion divider block
27
+ async def append_markdown(self, markdown_text: str, append_divider=True) -> bool:
28
+ """Append markdown text to a Notion page, automatically handling content length limits."""
47
29
  if append_divider:
48
30
  markdown_text = markdown_text + "---\n"
49
31
 
@@ -51,29 +33,36 @@ class PageContentWriter(LoggingMixin):
51
33
 
52
34
  try:
53
35
  blocks = self._markdown_to_notion_converter.convert(markdown_text)
54
- fixed_blocks = self._chunker.fix_blocks_content_length(blocks)
55
36
 
56
- result = await self._client.patch(
57
- f"blocks/{self.page_id}/children", {"children": fixed_blocks}
37
+ fixed_blocks = fix_blocks_content_length(blocks)
38
+
39
+ result = await self._block_client.append_block_children(
40
+ block_id=self.page_id, children=fixed_blocks
58
41
  )
42
+ self.logger.debug("Append block children result: %r", result)
59
43
  return bool(result)
60
44
  except Exception as e:
61
- self.logger.error("Error appending markdown: %s", str(e))
45
+ import traceback
46
+
47
+ self.logger.error(
48
+ "Error appending markdown: %s\nTraceback:\n%s",
49
+ str(e),
50
+ traceback.format_exc(),
51
+ )
62
52
  return False
63
53
 
64
54
  async def clear_page_content(self) -> bool:
65
- """
66
- Clear all content of the page.
67
- """
55
+ """Clear all content of the page."""
68
56
  try:
69
- blocks_resp = await self._client.get(f"blocks/{self.page_id}/children")
70
- results = blocks_resp.get("results", []) if blocks_resp else []
57
+ children_response = await self._block_client.get_block_children(
58
+ block_id=self.page_id
59
+ )
71
60
 
72
- if not results:
61
+ if not children_response or not children_response.results:
73
62
  return True
74
63
 
75
64
  success = True
76
- for block in results:
65
+ for block in children_response.results:
77
66
  block_success = await self._delete_block_with_children(block)
78
67
  if not block_success:
79
68
  success = False
@@ -83,120 +72,80 @@ class PageContentWriter(LoggingMixin):
83
72
  self.logger.error("Error clearing page content: %s", str(e))
84
73
  return False
85
74
 
86
- async def _delete_block_with_children(self, block: Dict[str, Any]) -> bool:
87
- """
88
- Delete a block and all its children.
89
- """
75
+ async def _delete_block_with_children(self, block: Block) -> bool:
76
+ """Delete a block and all its children recursively."""
77
+ if not block.id:
78
+ self.logger.error("Block has no valid ID")
79
+ return False
80
+
81
+ self.logger.debug("Deleting block: %s (type: %s)", block.id, block.type)
82
+
90
83
  try:
91
- if block.get("has_children", False):
92
- children_resp = await self._client.get(f"blocks/{block['id']}/children")
93
- child_results = children_resp.get("results", [])
84
+ if block.has_children and not await self._delete_block_children(block):
85
+ return False
94
86
 
95
- for child in child_results:
96
- child_success = await self._delete_block_with_children(child)
97
- if not child_success:
98
- return False
87
+ return await self._delete_single_block(block)
99
88
 
100
- return await self._client.delete(f"blocks/{block['id']}")
101
89
  except Exception as e:
102
- self.logger.error("Failed to delete block: %s", str(e))
90
+ self.logger.error("Failed to delete block %s: %s", block.id, str(e))
103
91
  return False
104
92
 
105
- def _process_markdown_whitespace(self, markdown_text: str) -> str:
106
- """
107
- Process markdown text to preserve code structure while removing unnecessary indentation.
108
- Strips all leading whitespace from regular lines, but preserves relative indentation
109
- within code blocks.
93
+ async def _delete_block_children(self, block: Block) -> bool:
94
+ """Delete all children of a block."""
95
+ self.logger.debug("Block %s has children, deleting children first", block.id)
110
96
 
111
- Args:
112
- markdown_text: Original markdown text with potential leading whitespace
97
+ try:
98
+ children_blocks = await self._block_client.get_all_block_children(block.id)
113
99
 
114
- Returns:
115
- Processed markdown text with corrected whitespace
116
- """
117
- lines = markdown_text.split("\n")
118
- if not lines:
119
- return ""
100
+ if not children_blocks:
101
+ self.logger.debug("No children found for block: %s", block.id)
102
+ return True
120
103
 
121
- processed_lines = []
122
- in_code_block = False
123
- current_code_block = []
124
-
125
- for line in lines:
126
- # Handle code block markers
127
- if self._is_code_block_marker(line):
128
- if not in_code_block:
129
- # Starting a new code block
130
- in_code_block = True
131
- processed_lines.append(self._process_code_block_start(line))
132
- current_code_block = []
133
- continue
134
-
135
- # Ending a code block
136
- processed_lines.extend(
137
- self._process_code_block_content(current_code_block)
138
- )
139
- processed_lines.append("```")
140
- in_code_block = False
141
- continue
142
-
143
- # Handle code block content
144
- if in_code_block:
145
- current_code_block.append(line)
146
- continue
147
-
148
- # Handle regular text
149
- processed_lines.append(line.lstrip())
150
-
151
- # Handle unclosed code block
152
- if in_code_block and current_code_block:
153
- processed_lines.extend(self._process_code_block_content(current_code_block))
154
- processed_lines.append("```")
155
-
156
- return "\n".join(processed_lines)
157
-
158
- def _is_code_block_marker(self, line: str) -> bool:
159
- """Check if a line is a code block marker."""
160
- return line.lstrip().startswith("```")
161
-
162
- def _process_code_block_start(self, line: str) -> str:
163
- """Extract and normalize the code block opening marker."""
164
- language = line.lstrip().replace("```", "", 1).strip()
165
- return "```" + language
166
-
167
- def _process_code_block_content(self, code_lines: list) -> list:
168
- """
169
- Normalize code block indentation by removing the minimum common indentation.
170
-
171
- Args:
172
- code_lines: List of code block content lines
173
-
174
- Returns:
175
- List of processed code lines with normalized indentation
176
- """
177
- if not code_lines:
178
- return []
179
-
180
- # Find non-empty lines to determine minimum indentation
181
- non_empty_code_lines = [line for line in code_lines if line.strip()]
182
- if not non_empty_code_lines:
183
- return [""] * len(code_lines) # All empty lines stay empty
184
-
185
- # Calculate minimum indentation
186
- min_indent = min(
187
- len(line) - len(line.lstrip()) for line in non_empty_code_lines
188
- )
189
- if min_indent == 0:
190
- return code_lines # No common indentation to remove
104
+ self.logger.debug(
105
+ "Found %d children to delete for block: %s",
106
+ len(children_blocks),
107
+ block.id,
108
+ )
109
+
110
+ # Delete all children recursively
111
+ for child_block in children_blocks:
112
+ if not await self._delete_block_with_children(child_block):
113
+ self.logger.error(
114
+ "Failed to delete child block: %s", child_block.id
115
+ )
116
+ return False
191
117
 
192
- # Process each line
193
- processed_code_lines = []
194
- for line in code_lines:
195
- if not line.strip():
196
- processed_code_lines.append("") # Keep empty lines empty
197
- continue
118
+ self.logger.debug(
119
+ "Successfully deleted all children of block: %s", block.id
120
+ )
121
+ return True
122
+
123
+ except Exception as e:
124
+ self.logger.error(
125
+ "Failed to delete children of block %s: %s", block.id, str(e)
126
+ )
127
+ return False
198
128
 
199
- # Remove exactly the minimum indentation
200
- processed_code_lines.append(line[min_indent:])
129
+ async def _delete_single_block(self, block: Block) -> bool:
130
+ """Delete a single block."""
131
+ deleted_block: Optional[Block] = await self._block_client.delete_block(block.id)
132
+
133
+ if deleted_block is None:
134
+ self.logger.error("Failed to delete block: %s", block.id)
135
+ return False
136
+
137
+ if deleted_block.archived or deleted_block.in_trash:
138
+ self.logger.debug("Successfully deleted/archived block: %s", block.id)
139
+ return True
140
+ else:
141
+ self.logger.warning("Block %s was not properly archived/deleted", block.id)
142
+ return False
143
+
144
+ def _process_markdown_whitespace(self, markdown_text: str) -> str:
145
+ """Process markdown text to normalize whitespace while preserving code blocks."""
146
+ lines = markdown_text.split("\n")
147
+ if not lines:
148
+ return ""
201
149
 
202
- return processed_code_lines
150
+ processor = MarkdownWhitespaceProcessor()
151
+ return processor.process_lines(lines)
@@ -0,0 +1,153 @@
1
+ import re
2
+ from notionary.blocks.shared.notion_block_element import NotionBlock
3
+ from notionary.blocks.registry.block_registry import BlockRegistry
4
+
5
+
6
+ class LineProcessingState:
7
+ """Tracks state during line-by-line processing"""
8
+
9
+ def __init__(self):
10
+ self.paragraph_lines: list[str] = []
11
+ self.paragraph_start: int = 0
12
+
13
+ def add_to_paragraph(self, line: str, current_pos: int):
14
+ """Add line to current paragraph"""
15
+ if not self.paragraph_lines:
16
+ self.paragraph_start = current_pos
17
+ self.paragraph_lines.append(line)
18
+
19
+ def reset_paragraph(self):
20
+ """Reset paragraph state"""
21
+ self.paragraph_lines = []
22
+ self.paragraph_start = 0
23
+
24
+ def has_paragraph(self) -> bool:
25
+ """Check if there are paragraph lines to process"""
26
+ return len(self.paragraph_lines) > 0
27
+
28
+
29
+ class LineProcessor:
30
+ """Handles line-by-line processing of markdown text"""
31
+
32
+ def __init__(
33
+ self,
34
+ block_registry: BlockRegistry,
35
+ excluded_ranges: set[int],
36
+ pipe_pattern: str,
37
+ ):
38
+ self._block_registry = block_registry
39
+ self._excluded_ranges = excluded_ranges
40
+ self._pipe_pattern = pipe_pattern
41
+
42
+ @staticmethod
43
+ def _normalize_to_list(result) -> list[dict[str, any]]:
44
+ """Normalize Union[list[dict], dict] to list[dict]"""
45
+ if result is None:
46
+ return []
47
+ return result if isinstance(result, list) else [result]
48
+
49
+ def process_lines(self, text: str) -> list[tuple[int, int, dict[str, any]]]:
50
+ """Process all lines and return blocks with positions"""
51
+ lines = text.split("\n")
52
+ line_blocks = []
53
+
54
+ state = LineProcessingState()
55
+ current_pos = 0
56
+
57
+ for line in lines:
58
+ line_length = len(line) + 1 # +1 for newline
59
+ line_end = current_pos + line_length - 1
60
+
61
+ if self._should_skip_line(line, current_pos, line_end):
62
+ current_pos += line_length
63
+ continue
64
+
65
+ self._process_single_line(line, current_pos, line_end, line_blocks, state)
66
+ current_pos += line_length
67
+
68
+ # Process any remaining paragraph
69
+ self._finalize_paragraph(state, current_pos, line_blocks)
70
+
71
+ return line_blocks
72
+
73
+ def _should_skip_line(self, line: str, current_pos: int, line_end: int) -> bool:
74
+ """Check if line should be skipped (excluded or pipe syntax)"""
75
+ return self._overlaps_with_excluded(
76
+ current_pos, line_end
77
+ ) or self._is_pipe_syntax_line(line)
78
+
79
+ def _overlaps_with_excluded(self, start_pos: int, end_pos: int) -> bool:
80
+ """Check if position range overlaps with excluded ranges"""
81
+ return any(
82
+ pos in self._excluded_ranges for pos in range(start_pos, end_pos + 1)
83
+ )
84
+
85
+ def _is_pipe_syntax_line(self, line: str) -> bool:
86
+ """Check if line uses pipe syntax for nested content"""
87
+ return bool(re.match(self._pipe_pattern, line))
88
+
89
+ def _process_single_line(
90
+ self,
91
+ line: str,
92
+ current_pos: int,
93
+ line_end: int,
94
+ line_blocks: list[tuple[int, int, dict[str, any]]],
95
+ state: LineProcessingState,
96
+ ):
97
+ """Process a single line of text"""
98
+ # Handle empty lines
99
+ if not line.strip():
100
+ self._finalize_paragraph(state, current_pos, line_blocks)
101
+ state.reset_paragraph()
102
+ return
103
+
104
+ # Handle special blocks (headings, todos, dividers, etc.)
105
+ special_blocks = self._extract_special_block(line)
106
+ if special_blocks:
107
+ self._finalize_paragraph(state, current_pos, line_blocks)
108
+ # Mehrere Blöcke hinzufügen
109
+ for block in special_blocks:
110
+ line_blocks.append((current_pos, line_end, block))
111
+ state.reset_paragraph()
112
+ return
113
+
114
+ # Add to current paragraph
115
+ state.add_to_paragraph(line, current_pos)
116
+
117
+ def _extract_special_block(self, line: str) -> list[NotionBlock]:
118
+ """Extract special block (non-paragraph) from line"""
119
+ for element in (
120
+ element
121
+ for element in self._block_registry.get_elements()
122
+ if not element.is_multiline()
123
+ ):
124
+ if not element.match_markdown(line):
125
+ continue
126
+
127
+ result = element.markdown_to_notion(line)
128
+ blocks = self._normalize_to_list(result)
129
+ if not blocks:
130
+ continue
131
+
132
+ # Gibt nur zurück, wenn mindestens ein Nicht-Paragraph-Block dabei ist
133
+ if any(block.get("type") != "paragraph" for block in blocks):
134
+ return blocks
135
+
136
+ return []
137
+
138
+ def _finalize_paragraph(
139
+ self,
140
+ state: LineProcessingState,
141
+ end_pos: int,
142
+ line_blocks: list[tuple[int, int, dict[str, any]]],
143
+ ):
144
+ """Convert current paragraph lines to paragraph block"""
145
+ if not state.has_paragraph():
146
+ return
147
+
148
+ paragraph_text = "\n".join(state.paragraph_lines)
149
+ result = self._block_registry.markdown_to_notion(paragraph_text)
150
+ blocks = self._normalize_to_list(result)
151
+
152
+ for block in blocks:
153
+ line_blocks.append((state.paragraph_start, end_pos, block))