notionary 0.1.1__py3-none-any.whl → 0.1.3__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 (51) hide show
  1. notionary/__init__.py +9 -0
  2. notionary/core/__init__.py +0 -0
  3. notionary/core/converters/__init__.py +50 -0
  4. notionary/core/converters/elements/__init__.py +0 -0
  5. notionary/core/converters/elements/bookmark_element.py +224 -0
  6. notionary/core/converters/elements/callout_element.py +179 -0
  7. notionary/core/converters/elements/code_block_element.py +153 -0
  8. notionary/core/converters/elements/column_element.py +294 -0
  9. notionary/core/converters/elements/divider_element.py +73 -0
  10. notionary/core/converters/elements/heading_element.py +84 -0
  11. notionary/core/converters/elements/image_element.py +130 -0
  12. notionary/core/converters/elements/list_element.py +130 -0
  13. notionary/core/converters/elements/notion_block_element.py +51 -0
  14. notionary/core/converters/elements/paragraph_element.py +73 -0
  15. notionary/core/converters/elements/qoute_element.py +242 -0
  16. notionary/core/converters/elements/table_element.py +306 -0
  17. notionary/core/converters/elements/text_inline_formatter.py +294 -0
  18. notionary/core/converters/elements/todo_lists.py +114 -0
  19. notionary/core/converters/elements/toggle_element.py +205 -0
  20. notionary/core/converters/elements/video_element.py +159 -0
  21. notionary/core/converters/markdown_to_notion_converter.py +482 -0
  22. notionary/core/converters/notion_to_markdown_converter.py +45 -0
  23. notionary/core/converters/registry/__init__.py +0 -0
  24. notionary/core/converters/registry/block_element_registry.py +234 -0
  25. notionary/core/converters/registry/block_element_registry_builder.py +280 -0
  26. notionary/core/database/database_info_service.py +43 -0
  27. notionary/core/database/database_query_service.py +73 -0
  28. notionary/core/database/database_schema_service.py +57 -0
  29. notionary/core/database/models/page_result.py +10 -0
  30. notionary/core/database/notion_database_manager.py +332 -0
  31. notionary/core/database/notion_database_manager_factory.py +233 -0
  32. notionary/core/database/notion_database_schema.py +415 -0
  33. notionary/core/database/notion_database_writer.py +390 -0
  34. notionary/core/database/page_service.py +161 -0
  35. notionary/core/notion_client.py +134 -0
  36. notionary/core/page/meta_data/metadata_editor.py +37 -0
  37. notionary/core/page/notion_page_manager.py +110 -0
  38. notionary/core/page/page_content_manager.py +85 -0
  39. notionary/core/page/property_formatter.py +97 -0
  40. notionary/exceptions/database_exceptions.py +76 -0
  41. notionary/exceptions/page_creation_exception.py +9 -0
  42. notionary/util/logging_mixin.py +47 -0
  43. notionary/util/singleton_decorator.py +20 -0
  44. notionary/util/uuid_utils.py +24 -0
  45. {notionary-0.1.1.dist-info → notionary-0.1.3.dist-info}/METADATA +1 -1
  46. notionary-0.1.3.dist-info/RECORD +49 -0
  47. notionary-0.1.3.dist-info/top_level.txt +1 -0
  48. notionary-0.1.1.dist-info/RECORD +0 -5
  49. notionary-0.1.1.dist-info/top_level.txt +0 -1
  50. {notionary-0.1.1.dist-info → notionary-0.1.3.dist-info}/WHEEL +0 -0
  51. {notionary-0.1.1.dist-info → notionary-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,294 @@
1
+ import re
2
+ from typing import Dict, Any, Optional, List, Tuple, Callable
3
+ from typing_extensions import override
4
+
5
+ from notionary.core.converters.elements.notion_block_element import NotionBlockElement
6
+
7
+
8
+ class ColumnElement(NotionBlockElement):
9
+ """
10
+ Handles conversion between custom Markdown column syntax and Notion column blocks.
11
+
12
+ Markdown column syntax:
13
+ ::: columns
14
+ ::: column
15
+ Content for first column
16
+ :::
17
+ ::: column
18
+ Content for second column
19
+ :::
20
+ :::
21
+
22
+ This creates a column layout in Notion with the specified content in each column.
23
+ """
24
+
25
+ COLUMNS_START = re.compile(r"^:::\s*columns\s*$")
26
+ COLUMN_START = re.compile(r"^:::\s*column\s*$")
27
+ BLOCK_END = re.compile(r"^:::\s*$")
28
+
29
+ _converter_callback = None
30
+
31
+ @classmethod
32
+ def set_converter_callback(
33
+ cls, callback: Callable[[str], List[Dict[str, Any]]]
34
+ ) -> None:
35
+ """
36
+ Setze die Callback-Funktion, die zum Konvertieren von Markdown zu Notion-Blöcken verwendet wird.
37
+
38
+ Args:
39
+ callback: Funktion, die Markdown-Text annimmt und eine Liste von Notion-Blöcken zurückgibt
40
+ """
41
+ cls._converter_callback = callback
42
+
43
+ @override
44
+ @staticmethod
45
+ def match_markdown(text: str) -> bool:
46
+ """Check if text starts a columns block."""
47
+ return bool(ColumnElement.COLUMNS_START.match(text.strip()))
48
+
49
+ @override
50
+ @staticmethod
51
+ def match_notion(block: Dict[str, Any]) -> bool:
52
+ """Check if block is a Notion column_list."""
53
+ return block.get("type") == "column_list"
54
+
55
+ @override
56
+ @staticmethod
57
+ def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
58
+ """
59
+ Convert markdown column syntax to Notion column blocks.
60
+
61
+ Note: This only processes the first line (columns start).
62
+ The full column content needs to be processed separately.
63
+ """
64
+ if not ColumnElement.COLUMNS_START.match(text.strip()):
65
+ return None
66
+
67
+ # Create an empty column_list block
68
+ # Child columns will be added by the column processor
69
+ return {"type": "column_list", "column_list": {"children": []}}
70
+
71
+ @override
72
+ @staticmethod
73
+ def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
74
+ """Convert Notion column_list block to markdown column syntax."""
75
+ if block.get("type") != "column_list":
76
+ return None
77
+
78
+ column_children = block.get("column_list", {}).get("children", [])
79
+
80
+ # Start the columns block
81
+ result = ["::: columns"]
82
+
83
+ # Process each column
84
+ for column_block in column_children:
85
+ if column_block.get("type") == "column":
86
+ result.append("::: column")
87
+
88
+ for _ in column_block.get("column", {}).get("children", []):
89
+ result.append(" [Column content]") # Placeholder
90
+
91
+ result.append(":::")
92
+
93
+ # End the columns block
94
+ result.append(":::")
95
+
96
+ return "\n".join(result)
97
+
98
+ @override
99
+ @staticmethod
100
+ def is_multiline() -> bool:
101
+ """Column blocks span multiple lines."""
102
+ return True
103
+
104
+ @classmethod
105
+ def find_matches(
106
+ cls, text: str, converter_callback: Optional[Callable] = None
107
+ ) -> List[Tuple[int, int, Dict[str, Any]]]:
108
+ """
109
+ Find all column block matches in the text and return their positions and blocks.
110
+
111
+ Args:
112
+ text: The input markdown text
113
+ converter_callback: Optional callback to convert nested content
114
+
115
+ Returns:
116
+ List of tuples (start_pos, end_pos, block)
117
+ """
118
+ # Wenn ein Callback übergeben wurde, nutze diesen, sonst die gespeicherte Referenz
119
+ converter = converter_callback or cls._converter_callback
120
+ if not converter:
121
+ raise ValueError(
122
+ "No converter callback provided for ColumnElement. Call set_converter_callback first or provide converter_callback parameter."
123
+ )
124
+
125
+ matches = []
126
+ lines = text.split("\n")
127
+ i = 0
128
+
129
+ while i < len(lines):
130
+ # Skip non-column lines
131
+ if not ColumnElement.COLUMNS_START.match(lines[i].strip()):
132
+ i += 1
133
+ continue
134
+
135
+ # Process a column block and add to matches
136
+ column_block_info = cls._process_column_block(lines, i, converter)
137
+ matches.append(column_block_info)
138
+
139
+ # Skip to the end of the processed column block
140
+ i = column_block_info[3] # i is returned as the 4th element in the tuple
141
+
142
+ return [(start, end, block) for start, end, block, _ in matches]
143
+
144
+ @classmethod
145
+ def _process_column_block(
146
+ cls, lines: List[str], start_index: int, converter_callback: Callable
147
+ ) -> Tuple[int, int, Dict[str, Any], int]:
148
+ """
149
+ Process a complete column block structure from the given starting line.
150
+
151
+ Args:
152
+ lines: All lines of the text
153
+ start_index: Index of the column block start line
154
+ converter_callback: Callback function to convert markdown to notion blocks
155
+
156
+ Returns:
157
+ Tuple of (start_pos, end_pos, block, next_line_index)
158
+ """
159
+ columns_start = start_index
160
+ columns_block = cls.markdown_to_notion(lines[start_index].strip())
161
+ columns_children = []
162
+
163
+ next_index = cls._collect_columns(
164
+ lines, start_index + 1, columns_children, converter_callback
165
+ )
166
+
167
+ # Add columns to the main block
168
+ if columns_children:
169
+ columns_block["column_list"]["children"] = columns_children
170
+
171
+ # Calculate positions
172
+ start_pos = sum(len(lines[j]) + 1 for j in range(columns_start))
173
+ end_pos = sum(len(lines[j]) + 1 for j in range(next_index))
174
+
175
+ return (start_pos, end_pos, columns_block, next_index)
176
+
177
+ @classmethod
178
+ def _collect_columns(
179
+ cls,
180
+ lines: List[str],
181
+ start_index: int,
182
+ columns_children: List[Dict[str, Any]],
183
+ converter_callback: Callable,
184
+ ) -> int:
185
+ """
186
+ Collect all columns within a column block structure.
187
+
188
+ Args:
189
+ lines: All lines of the text
190
+ start_index: Index to start collecting from
191
+ columns_children: List to append collected columns to
192
+ converter_callback: Callback function to convert column content
193
+
194
+ Returns:
195
+ Next line index after all columns have been processed
196
+ """
197
+ i = start_index
198
+ in_column = False
199
+ column_content = []
200
+
201
+ while i < len(lines):
202
+ current_line = lines[i].strip()
203
+
204
+ if cls.COLUMNS_START.match(current_line):
205
+ break
206
+
207
+ if cls.COLUMN_START.match(current_line):
208
+ cls._finalize_column(
209
+ column_content, columns_children, in_column, converter_callback
210
+ )
211
+ column_content = []
212
+ in_column = True
213
+ i += 1
214
+ continue
215
+
216
+ if cls.BLOCK_END.match(current_line) and in_column:
217
+ cls._finalize_column(
218
+ column_content, columns_children, in_column, converter_callback
219
+ )
220
+ column_content = []
221
+ in_column = False
222
+ i += 1
223
+ continue
224
+
225
+ if cls.BLOCK_END.match(current_line) and not in_column:
226
+ i += 1
227
+ break
228
+
229
+ if in_column:
230
+ column_content.append(lines[i])
231
+
232
+ i += 1
233
+
234
+ cls._finalize_column(
235
+ column_content, columns_children, in_column, converter_callback
236
+ )
237
+
238
+ return i
239
+
240
+ @staticmethod
241
+ def _finalize_column(
242
+ column_content: List[str],
243
+ columns_children: List[Dict[str, Any]],
244
+ in_column: bool,
245
+ converter_callback: Callable,
246
+ ) -> None:
247
+ """
248
+ Finalize a column by processing its content and adding it to the columns_children list.
249
+
250
+ Args:
251
+ column_content: Content lines of the column
252
+ columns_children: List to append the column block to
253
+ in_column: Whether we're currently in a column (if False, does nothing)
254
+ converter_callback: Callback function to convert column content
255
+ """
256
+ if not (in_column and column_content):
257
+ return
258
+
259
+ # Process column content using the provided callback
260
+ column_blocks = converter_callback("\n".join(column_content))
261
+
262
+ # Create column block
263
+ column_block = {"type": "column", "column": {"children": column_blocks}}
264
+ columns_children.append(column_block)
265
+
266
+ @classmethod
267
+ def get_llm_prompt_content(cls) -> dict:
268
+ """
269
+ Returns a dictionary with all information needed for LLM prompts about this element.
270
+ Includes description, usage guidance, syntax options, and examples.
271
+ """
272
+ return {
273
+ "description": "Creates a multi-column layout that displays content side by side.",
274
+ "when_to_use": "Use columns sparingly, only for direct comparisons or when parallel presentation significantly improves readability. Best for pros/cons lists, feature comparisons, or pairing images with descriptions. Avoid overusing as it can complicate document structure.",
275
+ "syntax": [
276
+ "::: columns",
277
+ "::: column",
278
+ "Content for first column",
279
+ ":::",
280
+ "::: column",
281
+ "Content for second column",
282
+ ":::",
283
+ ":::",
284
+ ],
285
+ "notes": [
286
+ "Any Notion block can be placed within columns",
287
+ "Add more columns with additional '::: column' sections",
288
+ "Each column must close with ':::' and the entire columns section with another ':::'",
289
+ ],
290
+ "examples": [
291
+ "::: columns\n::: column\n## Features\n- Fast response time\n- Intuitive interface\n- Regular updates\n:::\n::: column\n## Benefits\n- Increased productivity\n- Better collaboration\n- Simplified workflows\n:::\n:::",
292
+ "::: columns\n::: column\n![Image placeholder](/api/placeholder/400/320)\n:::\n::: column\nThis text appears next to the image, creating a media-with-caption style layout that's perfect for documentation or articles.\n:::\n:::",
293
+ ],
294
+ }
@@ -0,0 +1,73 @@
1
+ # File: elements/dividers.py
2
+
3
+ from typing import Dict, Any, Optional
4
+ from typing_extensions import override
5
+ import re
6
+
7
+ from notionary.core.converters.elements.notion_block_element import NotionBlockElement
8
+
9
+
10
+ class DividerElement(NotionBlockElement):
11
+ """
12
+ Handles conversion between Markdown horizontal dividers and Notion divider blocks.
13
+
14
+ Markdown divider syntax:
15
+ - Three or more hyphens (---) on a line by themselves
16
+ """
17
+
18
+ PATTERN = re.compile(r"^\s*-{3,}\s*$")
19
+
20
+ @override
21
+ @staticmethod
22
+ def match_markdown(text: str) -> bool:
23
+ """Check if text is a markdown divider."""
24
+ return bool(DividerElement.PATTERN.match(text))
25
+
26
+ @override
27
+ @staticmethod
28
+ def match_notion(block: Dict[str, Any]) -> bool:
29
+ """Check if block is a Notion divider."""
30
+ return block.get("type") == "divider"
31
+
32
+ @override
33
+ @staticmethod
34
+ def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
35
+ """Convert markdown divider to Notion divider block."""
36
+ if not DividerElement.match_markdown(text):
37
+ return None
38
+
39
+ return {"type": "divider", "divider": {}}
40
+
41
+ @override
42
+ @staticmethod
43
+ def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
44
+ """Convert Notion divider block to markdown divider."""
45
+ if block.get("type") != "divider":
46
+ return None
47
+
48
+ return "---"
49
+
50
+ @override
51
+ @staticmethod
52
+ def is_multiline() -> bool:
53
+ return False
54
+
55
+ @classmethod
56
+ def get_llm_prompt_content(cls) -> dict:
57
+ """
58
+ Returns a dictionary with all information needed for LLM prompts about this element.
59
+ Includes description, usage guidance, syntax options, and examples.
60
+ """
61
+ return {
62
+ "description": "Creates a horizontal divider line that visually separates sections of content.",
63
+ "when_to_use": "Use dividers when you want to create clear visual breaks between different sections or topics in your document. Dividers help improve readability by organizing content into distinct sections without requiring headings.",
64
+ "syntax": ["---"],
65
+ "notes": [
66
+ "Dividers must be on their own line with no other content",
67
+ "Dividers work well when combined with headings to clearly separate major document sections",
68
+ ],
69
+ "examples": [
70
+ "## Introduction\nThis is the introduction section of the document.\n\n---\n\n## Main Content\nThis is the main content section.",
71
+ "Task List:\n- Complete project proposal\n- Review feedback\n\n---\n\nMeeting Notes:\n- Discussed timeline\n- Assigned responsibilities",
72
+ ],
73
+ }
@@ -0,0 +1,84 @@
1
+ from typing import Dict, Any, Optional
2
+ from typing_extensions import override
3
+ import re
4
+
5
+ from notionary.core.converters.elements.notion_block_element import NotionBlockElement
6
+ from notionary.core.converters.elements.text_inline_formatter import TextInlineFormatter
7
+
8
+
9
+ class HeadingElement(NotionBlockElement):
10
+ """Handles conversion between Markdown headings and Notion heading blocks."""
11
+
12
+ PATTERN = re.compile(r"^(#{1,6})\s(.+)$")
13
+
14
+ @override
15
+ @staticmethod
16
+ def match_markdown(text: str) -> bool:
17
+ """Check if text is a markdown heading."""
18
+ return bool(HeadingElement.PATTERN.match(text))
19
+
20
+ @override
21
+ @staticmethod
22
+ def match_notion(block: Dict[str, Any]) -> bool:
23
+ """Check if block is a Notion heading."""
24
+ block_type: str = block.get("type", "")
25
+ return block_type.startswith("heading_") and block_type[-1] in "123456"
26
+
27
+ @override
28
+ @staticmethod
29
+ def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
30
+ """Convert markdown heading to Notion heading block."""
31
+ header_match = HeadingElement.PATTERN.match(text)
32
+ if not header_match:
33
+ return None
34
+
35
+ level = len(header_match.group(1))
36
+ content = header_match.group(2)
37
+
38
+ # Import here to avoid circular imports
39
+
40
+ return {
41
+ "type": f"heading_{level}",
42
+ f"heading_{level}": {
43
+ "rich_text": TextInlineFormatter.parse_inline_formatting(content)
44
+ },
45
+ }
46
+
47
+ @override
48
+ @staticmethod
49
+ def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
50
+ """Convert Notion heading block to markdown heading."""
51
+ block_type = block.get("type", "")
52
+
53
+ if not block_type.startswith("heading_"):
54
+ return None
55
+
56
+ try:
57
+ level = int(block_type[-1])
58
+ if not 1 <= level <= 6:
59
+ return None
60
+ except ValueError:
61
+ return None
62
+
63
+ heading_data = block.get(block_type, {})
64
+ rich_text = heading_data.get("rich_text", [])
65
+
66
+ text = TextInlineFormatter.extract_text_with_formatting(rich_text)
67
+ prefix = "#" * level
68
+ return f"{prefix} {text or ''}"
69
+
70
+ @override
71
+ @staticmethod
72
+ def is_multiline() -> bool:
73
+ return False
74
+
75
+ @override
76
+ @classmethod
77
+ def get_llm_prompt_content(cls) -> dict:
78
+ """
79
+ Returns a dictionary with all information needed for LLM prompts about this element.
80
+ """
81
+ return {
82
+ "description": "Use Markdown headings (#, ##, ###, etc.) to structure content hierarchically.",
83
+ "when_to_use": "Use to group content into sections and define a visual hierarchy.",
84
+ }
@@ -0,0 +1,130 @@
1
+ import re
2
+ from typing import Dict, Any, Optional, List
3
+ from typing_extensions import override
4
+ from notionary.core.converters.elements.notion_block_element import NotionBlockElement
5
+
6
+
7
+ class ImageElement(NotionBlockElement):
8
+ """
9
+ Handles conversion between Markdown images and Notion image blocks.
10
+
11
+ Markdown image syntax:
12
+ - ![Caption](https://example.com/image.jpg) - Basic image with caption
13
+ - ![](https://example.com/image.jpg) - Image without caption
14
+ - ![Caption](https://example.com/image.jpg "alt text") - Image with caption and alt text
15
+ """
16
+
17
+ # Regex pattern for image syntax with optional alt text
18
+ PATTERN = re.compile(
19
+ r"^\!\[(.*?)\]" # ![Caption] part
20
+ + r'\((https?://[^\s"]+)' # (URL part
21
+ + r'(?:\s+"([^"]+)")?' # Optional alt text in quotes
22
+ + r"\)$" # closing parenthesis
23
+ )
24
+
25
+ @override
26
+ @staticmethod
27
+ def match_markdown(text: str) -> bool:
28
+ """Check if text is a markdown image."""
29
+ return text.strip().startswith("![") and bool(
30
+ ImageElement.PATTERN.match(text.strip())
31
+ )
32
+
33
+ @override
34
+ @staticmethod
35
+ def match_notion(block: Dict[str, Any]) -> bool:
36
+ """Check if block is a Notion image."""
37
+ return block.get("type") == "image"
38
+
39
+ @override
40
+ @staticmethod
41
+ def markdown_to_notion(text: str) -> Optional[Dict[str, Any]]:
42
+ """Convert markdown image to Notion image block."""
43
+ image_match = ImageElement.PATTERN.match(text.strip())
44
+ if not image_match:
45
+ return None
46
+
47
+ caption = image_match.group(1)
48
+ url = image_match.group(2)
49
+
50
+ if not url:
51
+ return None
52
+
53
+ # Prepare the image block
54
+ image_block = {
55
+ "type": "image",
56
+ "image": {"type": "external", "external": {"url": url}},
57
+ }
58
+
59
+ # Add caption if provided
60
+ if caption:
61
+ image_block["image"]["caption"] = [
62
+ {"type": "text", "text": {"content": caption}}
63
+ ]
64
+
65
+ return image_block
66
+
67
+ @override
68
+ @staticmethod
69
+ def notion_to_markdown(block: Dict[str, Any]) -> Optional[str]:
70
+ """Convert Notion image block to markdown image."""
71
+ if block.get("type") != "image":
72
+ return None
73
+
74
+ image_data = block.get("image", {})
75
+
76
+ # Handle both external and file (uploaded) images
77
+ if image_data.get("type") == "external":
78
+ url = image_data.get("external", {}).get("url", "")
79
+ elif image_data.get("type") == "file":
80
+ url = image_data.get("file", {}).get("url", "")
81
+ else:
82
+ return None
83
+
84
+ if not url:
85
+ return None
86
+
87
+ # Extract caption if available
88
+ caption = ""
89
+ caption_rich_text = image_data.get("caption", [])
90
+ if caption_rich_text:
91
+ caption = ImageElement._extract_text_content(caption_rich_text)
92
+
93
+ return f"![{caption}]({url})"
94
+
95
+ @staticmethod
96
+ def _extract_text_content(rich_text: List[Dict[str, Any]]) -> str:
97
+ """Extract plain text content from Notion rich_text elements."""
98
+ result = ""
99
+ for text_obj in rich_text:
100
+ if text_obj.get("type") == "text":
101
+ result += text_obj.get("text", {}).get("content", "")
102
+ elif "plain_text" in text_obj:
103
+ result += text_obj.get("plain_text", "")
104
+ return result
105
+
106
+ @override
107
+ @staticmethod
108
+ def is_multiline() -> bool:
109
+ return False
110
+
111
+ @classmethod
112
+ def get_llm_prompt_content(cls) -> dict:
113
+ """
114
+ Returns a dictionary with all information needed for LLM prompts about this element.
115
+ Includes description, usage guidance, syntax options, and examples.
116
+ """
117
+ return {
118
+ "description": "Embeds an image from an external URL into your document.",
119
+ "when_to_use": "Use images to include visual content such as diagrams, screenshots, charts, photos, or illustrations that enhance your document. Images can make complex information easier to understand, create visual interest, or provide evidence for your points.",
120
+ "syntax": [
121
+ "![](https://example.com/image.jpg) - Image without caption",
122
+ "![Caption text](https://example.com/image.jpg) - Image with caption",
123
+ '![Caption text](https://example.com/image.jpg "Alt text") - Image with caption and alt text',
124
+ ],
125
+ "examples": [
126
+ "![Data visualization showing monthly trends](https://example.com/chart.png)",
127
+ "![](https://example.com/screenshot.jpg)",
128
+ '![Company logo](https://company.com/logo.png "Company Inc. logo")',
129
+ ],
130
+ }