notionary 0.1.2__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 (49) hide show
  1. notionary/core/__init__.py +0 -0
  2. notionary/core/converters/__init__.py +50 -0
  3. notionary/core/converters/elements/__init__.py +0 -0
  4. notionary/core/converters/elements/bookmark_element.py +224 -0
  5. notionary/core/converters/elements/callout_element.py +179 -0
  6. notionary/core/converters/elements/code_block_element.py +153 -0
  7. notionary/core/converters/elements/column_element.py +294 -0
  8. notionary/core/converters/elements/divider_element.py +73 -0
  9. notionary/core/converters/elements/heading_element.py +84 -0
  10. notionary/core/converters/elements/image_element.py +130 -0
  11. notionary/core/converters/elements/list_element.py +130 -0
  12. notionary/core/converters/elements/notion_block_element.py +51 -0
  13. notionary/core/converters/elements/paragraph_element.py +73 -0
  14. notionary/core/converters/elements/qoute_element.py +242 -0
  15. notionary/core/converters/elements/table_element.py +306 -0
  16. notionary/core/converters/elements/text_inline_formatter.py +294 -0
  17. notionary/core/converters/elements/todo_lists.py +114 -0
  18. notionary/core/converters/elements/toggle_element.py +205 -0
  19. notionary/core/converters/elements/video_element.py +159 -0
  20. notionary/core/converters/markdown_to_notion_converter.py +482 -0
  21. notionary/core/converters/notion_to_markdown_converter.py +45 -0
  22. notionary/core/converters/registry/__init__.py +0 -0
  23. notionary/core/converters/registry/block_element_registry.py +234 -0
  24. notionary/core/converters/registry/block_element_registry_builder.py +280 -0
  25. notionary/core/database/database_info_service.py +43 -0
  26. notionary/core/database/database_query_service.py +73 -0
  27. notionary/core/database/database_schema_service.py +57 -0
  28. notionary/core/database/models/page_result.py +10 -0
  29. notionary/core/database/notion_database_manager.py +332 -0
  30. notionary/core/database/notion_database_manager_factory.py +233 -0
  31. notionary/core/database/notion_database_schema.py +415 -0
  32. notionary/core/database/notion_database_writer.py +390 -0
  33. notionary/core/database/page_service.py +161 -0
  34. notionary/core/notion_client.py +134 -0
  35. notionary/core/page/meta_data/metadata_editor.py +37 -0
  36. notionary/core/page/notion_page_manager.py +110 -0
  37. notionary/core/page/page_content_manager.py +85 -0
  38. notionary/core/page/property_formatter.py +97 -0
  39. notionary/exceptions/database_exceptions.py +76 -0
  40. notionary/exceptions/page_creation_exception.py +9 -0
  41. notionary/util/logging_mixin.py +47 -0
  42. notionary/util/singleton_decorator.py +20 -0
  43. notionary/util/uuid_utils.py +24 -0
  44. {notionary-0.1.2.dist-info → notionary-0.1.3.dist-info}/METADATA +1 -1
  45. notionary-0.1.3.dist-info/RECORD +49 -0
  46. notionary-0.1.2.dist-info/RECORD +0 -6
  47. {notionary-0.1.2.dist-info → notionary-0.1.3.dist-info}/WHEEL +0 -0
  48. {notionary-0.1.2.dist-info → notionary-0.1.3.dist-info}/licenses/LICENSE +0 -0
  49. {notionary-0.1.2.dist-info → notionary-0.1.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,482 @@
1
+ from typing import Dict, Any, List, Optional, Tuple
2
+
3
+ from notionary.core.converters.registry.block_element_registry import (
4
+ BlockElementRegistry,
5
+ )
6
+ from notionary.core.converters.registry.block_element_registry_builder import (
7
+ BlockElementRegistryBuilder,
8
+ )
9
+
10
+
11
+ class MarkdownToNotionConverter:
12
+ SPACER_MARKER = "<!-- spacer -->"
13
+ MULTILINE_CONTENT_MARKER = "<!-- REMOVED_MULTILINE_CONTENT -->"
14
+ TOGGLE_MARKER = "<!-- toggle_content -->"
15
+
16
+ def __init__(self, block_registry: Optional[BlockElementRegistry] = None):
17
+ """
18
+ Initialize the MarkdownToNotionConverter.
19
+
20
+ Args:
21
+ block_registry: Optional registry of Notion block elements
22
+ """
23
+ self._block_registry = (
24
+ block_registry or BlockElementRegistryBuilder().create_standard_registry()
25
+ )
26
+
27
+ self._setup_element_callbacks()
28
+
29
+ def _setup_element_callbacks(self) -> None:
30
+ """Registriert den Converter als Callback für Elemente, die ihn benötigen."""
31
+
32
+ for element in self._block_registry.get_elements():
33
+ if hasattr(element, "set_converter_callback"):
34
+ element.set_converter_callback(self.convert)
35
+
36
+ def convert(self, markdown_text: str) -> List[Dict[str, Any]]:
37
+ """
38
+ Convert markdown text to Notion API block format.
39
+
40
+ Args:
41
+ markdown_text: The markdown text to convert
42
+
43
+ Returns:
44
+ List of Notion blocks
45
+ """
46
+ if not markdown_text:
47
+ return []
48
+
49
+ # Process toggles first
50
+ processed_text, toggle_blocks = self._extract_toggle_elements(markdown_text)
51
+
52
+ # Process other multiline elements
53
+ processed_text, multiline_blocks = self._extract_multiline_elements(
54
+ processed_text
55
+ )
56
+
57
+ # Process remaining text line by line
58
+ line_blocks = self._process_text_lines(processed_text)
59
+
60
+ # Combine and sort all blocks
61
+ all_blocks = toggle_blocks + multiline_blocks + line_blocks
62
+ all_blocks.sort(key=lambda x: x[0])
63
+
64
+ # Extract just the blocks from position tuples
65
+ blocks = [block for _, _, block in all_blocks]
66
+
67
+ # Process spacing between blocks
68
+ return self._process_block_spacing(blocks)
69
+
70
+ def _extract_toggle_elements(
71
+ self, text: str
72
+ ) -> Tuple[str, List[Tuple[int, int, Dict[str, Any]]]]:
73
+ """
74
+ Extract toggle elements and their nested content using the ToggleElement class.
75
+
76
+ Args:
77
+ text: The text to process
78
+
79
+ Returns:
80
+ Tuple of (processed text, list of (start_pos, end_pos, block) tuples)
81
+ """
82
+ # Find toggle element in registry
83
+ toggle_element = None
84
+ for element in self._block_registry.get_elements():
85
+ if (
86
+ element.is_multiline()
87
+ and hasattr(element, "match_markdown")
88
+ and element.__name__ == "ToggleElement"
89
+ ):
90
+ toggle_element = element
91
+ break
92
+
93
+ if not toggle_element:
94
+ # No toggle element found, return text as is
95
+ return text, []
96
+
97
+ # Use the find_matches method of ToggleElement to find and process all toggles
98
+ # Pass the converter's convert method as a callback to process nested content
99
+ toggle_blocks = toggle_element.find_matches(text, self.convert)
100
+
101
+ if not toggle_blocks:
102
+ return text, []
103
+
104
+ # Create a processed text with toggle markers
105
+ lines = text.split("\n")
106
+ processed_lines = lines.copy()
107
+
108
+ # Replace toggle content with markers
109
+ for start_pos, end_pos, _ in reversed(toggle_blocks):
110
+ # Calculate line indices for this toggle
111
+ start_line_index = 0
112
+ current_pos = 0
113
+ for i, line in enumerate(lines):
114
+ line_length = len(line) + 1 # +1 for newline
115
+ if current_pos <= start_pos < current_pos + line_length:
116
+ start_line_index = i
117
+ break
118
+ current_pos += line_length
119
+
120
+ end_line_index = start_line_index
121
+ current_pos = 0
122
+ for i, line in enumerate(lines):
123
+ line_length = len(line) + 1 # +1 for newline
124
+ if current_pos <= end_pos < current_pos + line_length:
125
+ end_line_index = i
126
+ break
127
+ current_pos += line_length
128
+
129
+ # Replace toggle content with markers
130
+ num_lines = end_line_index - start_line_index + 1
131
+ for i in range(start_line_index, start_line_index + num_lines):
132
+ processed_lines[i] = self.TOGGLE_MARKER
133
+
134
+ processed_text = "\n".join(processed_lines)
135
+ return processed_text, toggle_blocks
136
+
137
+ def _extract_multiline_elements(
138
+ self, text: str
139
+ ) -> Tuple[str, List[Tuple[int, int, Dict[str, Any]]]]:
140
+ """
141
+ Extract multiline elements and remove them from the text.
142
+
143
+ Args:
144
+ text: The text to process
145
+
146
+ Returns:
147
+ Tuple of (processed text, list of (start_pos, end_pos, block) tuples)
148
+ """
149
+ if not text:
150
+ return text, []
151
+
152
+ multiline_blocks = []
153
+ processed_text = text
154
+
155
+ # Get all multiline elements except ToggleElement
156
+ multiline_elements = [
157
+ element
158
+ for element in self._block_registry.get_multiline_elements()
159
+ if element.__name__ != "ToggleElement"
160
+ ]
161
+
162
+ if not multiline_elements:
163
+ return text, []
164
+
165
+ for element in multiline_elements:
166
+ if not hasattr(element, "find_matches"):
167
+ continue
168
+
169
+ # Find all matches for this element (pass the convert method as callback if needed)
170
+ if hasattr(element, "set_converter_callback"):
171
+ matches = element.find_matches(processed_text, self.convert)
172
+ else:
173
+ matches = element.find_matches(processed_text)
174
+
175
+ if not matches:
176
+ continue
177
+
178
+ multiline_blocks.extend(matches)
179
+
180
+ # Remove matched content from the text to avoid processing it again
181
+ processed_text = self._replace_matched_content_with_markers(
182
+ processed_text, matches
183
+ )
184
+
185
+ return processed_text, multiline_blocks
186
+
187
+ def _replace_matched_content_with_markers(
188
+ self, text: str, matches: List[Tuple[int, int, Dict[str, Any]]]
189
+ ) -> str:
190
+ """Replace matched content with marker placeholders to preserve line structure."""
191
+ for start, end, _ in reversed(matches):
192
+ num_newlines = text[start:end].count("\n")
193
+ text = (
194
+ text[:start]
195
+ + "\n"
196
+ + self.MULTILINE_CONTENT_MARKER
197
+ + "\n" * num_newlines
198
+ + text[end:]
199
+ )
200
+ return text
201
+
202
+ def _process_text_lines(self, text: str) -> List[Tuple[int, int, Dict[str, Any]]]:
203
+ """
204
+ Process text line by line for single-line elements.
205
+
206
+ Args:
207
+ text: The text to process
208
+
209
+ Returns:
210
+ List of (start_pos, end_pos, block) tuples
211
+ """
212
+ if not text:
213
+ return []
214
+
215
+ line_blocks = []
216
+ lines = text.split("\n")
217
+
218
+ current_pos = 0
219
+ current_paragraph = []
220
+ paragraph_start = 0
221
+ in_todo_sequence = False
222
+
223
+ for line in lines:
224
+ line_length = len(line) + 1 # +1 for newline
225
+
226
+ # Skip marker lines
227
+ if self._is_marker_line(line):
228
+ current_pos += line_length
229
+ continue
230
+
231
+ # Check for spacer marker
232
+ if self._is_spacer_marker(line):
233
+ line_blocks.append(
234
+ (
235
+ current_pos,
236
+ current_pos + line_length,
237
+ self._create_empty_paragraph(),
238
+ )
239
+ )
240
+ current_pos += line_length
241
+ continue
242
+
243
+ # Process todos first to keep them grouped
244
+ todo_block = self._extract_todo_item(line)
245
+ if todo_block:
246
+ self._handle_todo_item(
247
+ todo_block,
248
+ line_length,
249
+ current_pos,
250
+ current_paragraph,
251
+ paragraph_start,
252
+ line_blocks,
253
+ in_todo_sequence,
254
+ )
255
+ in_todo_sequence = True
256
+ current_pos += line_length
257
+ continue
258
+
259
+ if in_todo_sequence:
260
+ in_todo_sequence = False
261
+
262
+ if not line.strip():
263
+ self._process_paragraph_if_present(
264
+ current_paragraph, paragraph_start, current_pos, line_blocks
265
+ )
266
+ current_paragraph = []
267
+ current_pos += line_length
268
+ continue
269
+
270
+ special_block = self._extract_special_block(line)
271
+ if special_block:
272
+ self._process_paragraph_if_present(
273
+ current_paragraph, paragraph_start, current_pos, line_blocks
274
+ )
275
+ line_blocks.append(
276
+ (current_pos, current_pos + line_length, special_block)
277
+ )
278
+ current_paragraph = []
279
+ current_pos += line_length
280
+ continue
281
+
282
+ # Handle as part of paragraph
283
+ if not current_paragraph:
284
+ paragraph_start = current_pos
285
+ current_paragraph.append(line)
286
+ current_pos += line_length
287
+
288
+ # Process any remaining paragraph content
289
+ self._process_paragraph_if_present(
290
+ current_paragraph, paragraph_start, current_pos, line_blocks
291
+ )
292
+
293
+ return line_blocks
294
+
295
+ def _is_marker_line(self, line: str) -> bool:
296
+ """Check if a line is any kind of marker line that should be skipped."""
297
+ return self._is_multiline_marker(line) or self._is_toggle_marker(line)
298
+
299
+ def _is_multiline_marker(self, line: str) -> bool:
300
+ """Check if a line is a multiline content marker."""
301
+ return line.strip() == self.MULTILINE_CONTENT_MARKER
302
+
303
+ def _is_toggle_marker(self, line: str) -> bool:
304
+ """Check if a line is a toggle content marker."""
305
+ return line.strip() == self.TOGGLE_MARKER
306
+
307
+ def _is_spacer_marker(self, line: str) -> bool:
308
+ """Check if a line is a spacer marker."""
309
+ return line.strip() == self.SPACER_MARKER
310
+
311
+ def _extract_todo_item(self, line: str) -> Optional[Dict[str, Any]]:
312
+ """
313
+ Try to extract a todo item from a line.
314
+
315
+ Returns:
316
+ Todo block if line is a todo item, None otherwise
317
+ """
318
+ for element in self._block_registry.get_elements():
319
+ if (
320
+ not element.is_multiline()
321
+ and hasattr(element, "match_markdown")
322
+ and element.__name__ == "TodoElement"
323
+ and element.match_markdown(line)
324
+ ):
325
+ return element.markdown_to_notion(line)
326
+ return None
327
+
328
+ def _handle_todo_item(
329
+ self,
330
+ todo_block: Dict[str, Any],
331
+ line_length: int,
332
+ current_pos: int,
333
+ current_paragraph: List[str],
334
+ paragraph_start: int,
335
+ line_blocks: List[Tuple[int, int, Dict[str, Any]]],
336
+ in_todo_sequence: bool,
337
+ ) -> None:
338
+ """Handle a todo item line."""
339
+ # If we were building a paragraph, finish it before starting todos
340
+ if not in_todo_sequence and current_paragraph:
341
+ self._process_paragraph_if_present(
342
+ current_paragraph, paragraph_start, current_pos, line_blocks
343
+ )
344
+ current_paragraph.clear()
345
+
346
+ line_blocks.append((current_pos, current_pos + line_length, todo_block))
347
+
348
+ def _extract_special_block(self, line: str) -> Optional[Dict[str, Any]]:
349
+ """
350
+ Try to extract a special block (not paragraph) from a line.
351
+
352
+ Returns:
353
+ Block if line is a special block, None otherwise
354
+ """
355
+ for element in self._block_registry.get_elements():
356
+ if (
357
+ not element.is_multiline()
358
+ and hasattr(element, "match_markdown")
359
+ and element.match_markdown(line)
360
+ ):
361
+ block = element.markdown_to_notion(line)
362
+ if block and block.get("type") != "paragraph":
363
+ return block
364
+ return None
365
+
366
+ def _process_paragraph_if_present(
367
+ self,
368
+ paragraph_lines: List[str],
369
+ start_pos: int,
370
+ end_pos: int,
371
+ blocks: List[Tuple[int, int, Dict[str, Any]]],
372
+ ) -> None:
373
+ """
374
+ Process a paragraph and add it to the blocks list if valid.
375
+
376
+ Args:
377
+ paragraph_lines: Lines that make up the paragraph
378
+ start_pos: Starting position of the paragraph
379
+ end_pos: Ending position of the paragraph
380
+ blocks: List to add the processed paragraph block to
381
+ """
382
+ if not paragraph_lines:
383
+ return
384
+
385
+ paragraph_text = "\n".join(paragraph_lines)
386
+ block = self._block_registry.markdown_to_notion(paragraph_text)
387
+
388
+ if not block:
389
+ return
390
+
391
+ blocks.append((start_pos, end_pos, block))
392
+
393
+ def _process_block_spacing(
394
+ self, blocks: List[Dict[str, Any]]
395
+ ) -> List[Dict[str, Any]]:
396
+ """
397
+ Process blocks and add spacing only where no explicit spacer is present.
398
+
399
+ Args:
400
+ blocks: List of Notion blocks
401
+
402
+ Returns:
403
+ List of Notion blocks with processed spacing
404
+ """
405
+ if not blocks:
406
+ return blocks
407
+
408
+ final_blocks = []
409
+ i = 0
410
+
411
+ while i < len(blocks):
412
+ current_block = blocks[i]
413
+ final_blocks.append(current_block)
414
+
415
+ # Check if this is a multiline element that needs spacing
416
+ if not self._is_multiline_block_type(current_block.get("type")):
417
+ i += 1
418
+ continue
419
+
420
+ # Check if the next block is already a spacer
421
+ if i + 1 < len(blocks) and self._is_empty_paragraph(blocks[i + 1]):
422
+ # Next block is already a spacer, don't add another
423
+ pass
424
+ else:
425
+ # No explicit spacer found, add one automatically
426
+ final_blocks.append(self._create_empty_paragraph())
427
+
428
+ i += 1
429
+
430
+ return final_blocks
431
+
432
+ def _is_multiline_block_type(self, block_type: str) -> bool:
433
+ """
434
+ Check if a block type corresponds to a multiline element.
435
+
436
+ Args:
437
+ block_type: The type of block to check
438
+
439
+ Returns:
440
+ True if the block type is a multiline element, False otherwise
441
+ """
442
+ if not block_type:
443
+ return False
444
+
445
+ multiline_elements = self._block_registry.get_multiline_elements()
446
+
447
+ for element in multiline_elements:
448
+ element_name = element.__name__.lower()
449
+ if block_type in element_name:
450
+ return True
451
+
452
+ if hasattr(element, "match_notion"):
453
+ dummy_block = {"type": block_type}
454
+ if element.match_notion(dummy_block):
455
+ return True
456
+
457
+ return False
458
+
459
+ def _is_empty_paragraph(self, block: Dict[str, Any]) -> bool:
460
+ """
461
+ Check if a block is an empty paragraph.
462
+
463
+ Args:
464
+ block: The block to check
465
+
466
+ Returns:
467
+ True if it's an empty paragraph, False otherwise
468
+ """
469
+ if block.get("type") != "paragraph":
470
+ return False
471
+
472
+ rich_text = block.get("paragraph", {}).get("rich_text", [])
473
+ return not rich_text or len(rich_text) == 0
474
+
475
+ def _create_empty_paragraph(self) -> Dict[str, Any]:
476
+ """
477
+ Create an empty paragraph block.
478
+
479
+ Returns:
480
+ Empty paragraph block dictionary
481
+ """
482
+ return {"type": "paragraph", "paragraph": {"rich_text": []}}
@@ -0,0 +1,45 @@
1
+ from typing import Dict, Any, List, Optional
2
+
3
+ from notionary.core.converters.registry.block_element_registry import (
4
+ BlockElementRegistry,
5
+ )
6
+ from notionary.core.converters.registry.block_element_registry_builder import (
7
+ BlockElementRegistryBuilder,
8
+ )
9
+
10
+
11
+ class NotionToMarkdownConverter:
12
+ """Converts Notion blocks to Markdown text."""
13
+
14
+ def __init__(self, block_registry: Optional[BlockElementRegistry] = None):
15
+ """
16
+ Initialize the MarkdownToNotionConverter.
17
+
18
+ Args:
19
+ block_registry: Optional registry of Notion block elements
20
+ """
21
+ self._block_registry = (
22
+ block_registry or BlockElementRegistryBuilder().create_standard_registry()
23
+ )
24
+
25
+ def convert(self, blocks: List[Dict[str, Any]]) -> str:
26
+ """
27
+ Convert Notion blocks to Markdown text.
28
+
29
+ Args:
30
+ blocks: List of Notion blocks
31
+
32
+ Returns:
33
+ Markdown text
34
+ """
35
+ if not blocks:
36
+ return ""
37
+
38
+ markdown_parts = []
39
+
40
+ for block in blocks:
41
+ markdown = self._block_registry.notion_to_markdown(block)
42
+ if markdown:
43
+ markdown_parts.append(markdown)
44
+
45
+ return "\n\n".join(markdown_parts)
File without changes