notionary 0.2.10__py3-none-any.whl → 0.2.11__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 (56) hide show
  1. notionary/__init__.py +6 -0
  2. notionary/database/database_discovery.py +1 -1
  3. notionary/database/notion_database.py +5 -4
  4. notionary/database/notion_database_factory.py +10 -5
  5. notionary/elements/audio_element.py +2 -2
  6. notionary/elements/bookmark_element.py +2 -2
  7. notionary/elements/bulleted_list_element.py +2 -2
  8. notionary/elements/callout_element.py +2 -2
  9. notionary/elements/code_block_element.py +2 -2
  10. notionary/elements/column_element.py +51 -44
  11. notionary/elements/divider_element.py +2 -2
  12. notionary/elements/embed_element.py +2 -2
  13. notionary/elements/heading_element.py +3 -3
  14. notionary/elements/image_element.py +2 -2
  15. notionary/elements/mention_element.py +2 -2
  16. notionary/elements/notion_block_element.py +36 -0
  17. notionary/elements/numbered_list_element.py +2 -2
  18. notionary/elements/paragraph_element.py +2 -2
  19. notionary/elements/qoute_element.py +2 -2
  20. notionary/elements/table_element.py +2 -2
  21. notionary/elements/text_inline_formatter.py +23 -1
  22. notionary/elements/todo_element.py +2 -2
  23. notionary/elements/toggle_element.py +2 -2
  24. notionary/elements/toggleable_heading_element.py +2 -2
  25. notionary/elements/video_element.py +2 -2
  26. notionary/notion_client.py +1 -1
  27. notionary/page/content/notion_page_content_chunker.py +1 -1
  28. notionary/page/content/page_content_retriever.py +1 -1
  29. notionary/page/content/page_content_writer.py +3 -3
  30. notionary/page/{markdown_to_notion_converter.py → formatting/markdown_to_notion_converter.py} +44 -140
  31. notionary/page/formatting/spacer_rules.py +483 -0
  32. notionary/page/metadata/metadata_editor.py +1 -1
  33. notionary/page/metadata/notion_icon_manager.py +1 -1
  34. notionary/page/metadata/notion_page_cover_manager.py +1 -1
  35. notionary/page/notion_page.py +1 -1
  36. notionary/page/notion_page_factory.py +161 -22
  37. notionary/page/properites/database_property_service.py +1 -1
  38. notionary/page/properites/page_property_manager.py +1 -1
  39. notionary/page/properites/property_formatter.py +1 -1
  40. notionary/page/properites/property_value_extractor.py +1 -1
  41. notionary/page/relations/notion_page_relation_manager.py +1 -1
  42. notionary/page/relations/notion_page_title_resolver.py +1 -1
  43. notionary/page/relations/page_database_relation.py +1 -1
  44. notionary/prompting/element_prompt_content.py +1 -0
  45. notionary/telemetry/__init__.py +7 -0
  46. notionary/telemetry/telemetry.py +226 -0
  47. notionary/telemetry/track_usage_decorator.py +76 -0
  48. notionary/util/__init__.py +5 -0
  49. notionary/util/logging_mixin.py +3 -0
  50. notionary/util/singleton.py +18 -0
  51. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/METADATA +2 -1
  52. notionary-0.2.11.dist-info/RECORD +67 -0
  53. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/WHEEL +1 -1
  54. notionary-0.2.10.dist-info/RECORD +0 -61
  55. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/licenses/LICENSE +0 -0
  56. {notionary-0.2.10.dist-info → notionary-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,483 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, Any, List, Optional, Tuple
4
+ from abc import ABC, abstractmethod
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ import re
8
+
9
+ SPACER_MARKER = "---spacer---"
10
+
11
+
12
+ class LineType(Enum):
13
+ """Enum for different line types"""
14
+
15
+ EMPTY = "empty"
16
+ HEADING = "heading"
17
+ DIVIDER = "divider"
18
+ CODE_BLOCK_MARKER = "code_block_marker"
19
+ SPACER_MARKER = SPACER_MARKER
20
+ PIPE_SYNTAX = "pipe_syntax"
21
+ TODO_ITEM = "todo_item"
22
+ REGULAR_CONTENT = "regular_content"
23
+
24
+
25
+ @dataclass
26
+ class LineContext:
27
+ """Context of a line for spacer rule application"""
28
+
29
+ line: str
30
+ line_number: int
31
+ line_type: LineType
32
+ is_empty: bool
33
+ content: str
34
+ in_code_block: bool
35
+ last_line_was_spacer: bool
36
+ last_non_empty_was_heading: bool
37
+ has_content_before: bool
38
+ processed_lines: List[str]
39
+
40
+
41
+ @dataclass
42
+ class SpacerDecision:
43
+ """Decision about inserting a spacer"""
44
+
45
+ should_add_spacer: bool
46
+ reason: str
47
+ rule_name: str
48
+
49
+
50
+ @dataclass
51
+ class ProcessingResult:
52
+ """Result of processing a single line"""
53
+
54
+ output_lines: List[str]
55
+ new_state: Dict[str, Any]
56
+ spacer_added: bool = False
57
+
58
+
59
+ class SpacerRule(ABC):
60
+ """Abstract base class for spacer rules"""
61
+
62
+ @property
63
+ @abstractmethod
64
+ def name(self) -> str:
65
+ """Name of the rule for debugging"""
66
+ pass
67
+
68
+ @property
69
+ @abstractmethod
70
+ def description(self) -> str:
71
+ """Description of what the rule does"""
72
+ pass
73
+
74
+ @abstractmethod
75
+ def applies_to(self, context: LineContext) -> bool:
76
+ """Checks if this rule is relevant for the context"""
77
+ pass
78
+
79
+ @abstractmethod
80
+ def should_add_spacer(self, context: LineContext) -> SpacerDecision:
81
+ """Decides whether a spacer should be added"""
82
+ pass
83
+
84
+
85
+ class HeadingSpacerRule(SpacerRule):
86
+ """Rule: Add spacer before headings (except after other headings)"""
87
+
88
+ @property
89
+ def name(self) -> str:
90
+ return "HeadingSpacerRule"
91
+
92
+ @property
93
+ def description(self) -> str:
94
+ return "Adds spacer before headings, except when the previous line was already a heading"
95
+
96
+ def applies_to(self, context: LineContext) -> bool:
97
+ return context.line_type == LineType.HEADING
98
+
99
+ def should_add_spacer(self, context: LineContext) -> SpacerDecision:
100
+ # Rule: Insert spacer before heading when:
101
+ # 1. There is content before this heading
102
+ # 2. The last line was not a spacer
103
+ # 3. The last non-empty line was not a heading
104
+
105
+ if not context.has_content_before:
106
+ return SpacerDecision(False, "No content before this heading", self.name)
107
+
108
+ if context.last_line_was_spacer:
109
+ return SpacerDecision(
110
+ False, "Previous line was already a spacer", self.name
111
+ )
112
+
113
+ if context.last_non_empty_was_heading:
114
+ return SpacerDecision(
115
+ False,
116
+ "Previous non-empty line was a heading (consecutive headings)",
117
+ self.name,
118
+ )
119
+
120
+ return SpacerDecision(
121
+ True,
122
+ "Adding spacer before heading to separate from previous content",
123
+ self.name,
124
+ )
125
+
126
+
127
+ class DividerSpacerRule(SpacerRule):
128
+ """Rule: Add spacer before dividers"""
129
+
130
+ @property
131
+ def name(self) -> str:
132
+ return "DividerSpacerRule"
133
+
134
+ @property
135
+ def description(self) -> str:
136
+ return "Adds spacer before dividers (---) to create visual distance"
137
+
138
+ def applies_to(self, context: LineContext) -> bool:
139
+ return context.line_type == LineType.DIVIDER
140
+
141
+ def should_add_spacer(self, context: LineContext) -> SpacerDecision:
142
+ # Rule: Insert spacer before divider except when last line was already a spacer
143
+
144
+ if context.last_line_was_spacer:
145
+ return SpacerDecision(
146
+ False, "Previous line was already a spacer", self.name
147
+ )
148
+
149
+ return SpacerDecision(
150
+ True, "Adding spacer before divider for visual separation", self.name
151
+ )
152
+
153
+
154
+ class ConsecutiveSpacerRule(SpacerRule):
155
+ """Rule: Prevent consecutive spacers"""
156
+
157
+ @property
158
+ def name(self) -> str:
159
+ return "ConsecutiveSpacerRule"
160
+
161
+ @property
162
+ def description(self) -> str:
163
+ return "Prevents consecutive spacer markers"
164
+
165
+ def applies_to(self, context: LineContext) -> bool:
166
+ return context.line_type == LineType.SPACER_MARKER
167
+
168
+ def should_add_spacer(self, context: LineContext) -> SpacerDecision:
169
+ # Rule: Never allow consecutive spacers
170
+
171
+ if context.last_line_was_spacer:
172
+ return SpacerDecision(False, "Preventing consecutive spacers", self.name)
173
+
174
+ return SpacerDecision(True, "Adding spacer marker", self.name)
175
+
176
+
177
+ class CodeBlockSpacerRule(SpacerRule):
178
+ """Rule: No spacers inside code blocks"""
179
+
180
+ @property
181
+ def name(self) -> str:
182
+ return "CodeBlockSpacerRule"
183
+
184
+ @property
185
+ def description(self) -> str:
186
+ return "Prevents spacer processing inside code blocks"
187
+
188
+ def applies_to(self, context: LineContext) -> bool:
189
+ return context.in_code_block and context.line_type != LineType.CODE_BLOCK_MARKER
190
+
191
+ def should_add_spacer(self, context: LineContext) -> SpacerDecision:
192
+ return SpacerDecision(
193
+ False, "Inside code block - no spacer processing", self.name
194
+ )
195
+
196
+
197
+ class StateBuilder:
198
+ """Builder for creating and updating state"""
199
+
200
+ def __init__(self, initial_state: Dict[str, Any]):
201
+ self._state = initial_state.copy()
202
+
203
+ def toggle_code_block(self) -> StateBuilder:
204
+ """Toggle the code block state"""
205
+ self._state["in_code_block"] = not self._state.get("in_code_block", False)
206
+ return self
207
+
208
+ def set_last_line_was_spacer(self, value: bool) -> StateBuilder:
209
+ """Set whether the last line was a spacer"""
210
+ self._state["last_line_was_spacer"] = value
211
+ return self
212
+
213
+ def update_content_tracking(
214
+ self, line_type: LineType, has_content: bool
215
+ ) -> StateBuilder:
216
+ """Update content tracking state"""
217
+ if has_content:
218
+ self._state["last_non_empty_was_heading"] = line_type == LineType.HEADING
219
+ self._state["has_content_before"] = True
220
+ return self
221
+
222
+ def build(self) -> Dict[str, Any]:
223
+ """Build the final state"""
224
+ return self._state
225
+
226
+
227
+ class LineProcessor(ABC):
228
+ """Abstract processor for different line types"""
229
+
230
+ @abstractmethod
231
+ def can_process(self, line_type: LineType) -> bool:
232
+ """Check if this processor can handle the line type"""
233
+ pass
234
+
235
+ @abstractmethod
236
+ def process(self, context: LineContext, state: Dict[str, Any]) -> ProcessingResult:
237
+ """Process the line and return the result"""
238
+ pass
239
+
240
+
241
+ class EmptyLineProcessor(LineProcessor):
242
+ """Processor for empty lines"""
243
+
244
+ def can_process(self, line_type: LineType) -> bool:
245
+ return line_type == LineType.EMPTY
246
+
247
+ def process(self, context: LineContext, state: Dict[str, Any]) -> ProcessingResult:
248
+ new_state = StateBuilder(state).set_last_line_was_spacer(False).build()
249
+
250
+ return ProcessingResult(output_lines=[context.line], new_state=new_state)
251
+
252
+
253
+ class CodeBlockMarkerProcessor(LineProcessor):
254
+ """Processor for code block markers"""
255
+
256
+ def can_process(self, line_type: LineType) -> bool:
257
+ return line_type == LineType.CODE_BLOCK_MARKER
258
+
259
+ def process(self, context: LineContext, state: Dict[str, Any]) -> ProcessingResult:
260
+ new_state = (
261
+ StateBuilder(state)
262
+ .toggle_code_block()
263
+ .set_last_line_was_spacer(False)
264
+ .update_content_tracking(context.line_type, bool(context.content))
265
+ .build()
266
+ )
267
+
268
+ return ProcessingResult(output_lines=[context.line], new_state=new_state)
269
+
270
+
271
+ class SpacerMarkerProcessor(LineProcessor):
272
+ """Processor for spacer marker lines"""
273
+
274
+ def __init__(self, spacer_marker: str, rules: List[SpacerRule]):
275
+ self.spacer_marker = spacer_marker
276
+ self.rules = rules
277
+
278
+ def can_process(self, line_type: LineType) -> bool:
279
+ return line_type == LineType.SPACER_MARKER
280
+
281
+ def process(self, context: LineContext, state: Dict[str, Any]) -> ProcessingResult:
282
+ # Apply spacer rules
283
+ spacer_decision = self._get_spacer_decision(context)
284
+
285
+ output_lines = []
286
+ spacer_added = False
287
+
288
+ if spacer_decision.should_add_spacer:
289
+ output_lines.append(context.line)
290
+ spacer_added = True
291
+
292
+ new_state = StateBuilder(state).set_last_line_was_spacer(spacer_added).build()
293
+
294
+ return ProcessingResult(
295
+ output_lines=output_lines, new_state=new_state, spacer_added=spacer_added
296
+ )
297
+
298
+ def _get_spacer_decision(self, context: LineContext) -> SpacerDecision:
299
+ """Get spacer decision from rules"""
300
+ for rule in self.rules:
301
+ if rule.applies_to(context):
302
+ return rule.should_add_spacer(context)
303
+
304
+ # Default: don't add spacer
305
+ return SpacerDecision(False, "No applicable rule found", "DefaultRule")
306
+
307
+
308
+ class RegularContentProcessor(LineProcessor):
309
+ """Processor for regular content lines"""
310
+
311
+ def __init__(self, spacer_marker: str, rules: List[SpacerRule]):
312
+ self.spacer_marker = spacer_marker
313
+ self.rules = rules
314
+
315
+ def can_process(self, line_type: LineType) -> bool:
316
+ return line_type in [
317
+ LineType.HEADING,
318
+ LineType.DIVIDER,
319
+ LineType.TODO_ITEM,
320
+ LineType.REGULAR_CONTENT,
321
+ LineType.PIPE_SYNTAX,
322
+ ]
323
+
324
+ def process(self, context: LineContext, state: Dict[str, Any]) -> ProcessingResult:
325
+ output_lines = []
326
+ spacer_added = False
327
+
328
+ # Check if we should add a spacer before this line
329
+ spacer_decision = self._get_spacer_decision(context)
330
+
331
+ if spacer_decision.should_add_spacer:
332
+ output_lines.append(self.spacer_marker)
333
+ spacer_added = True
334
+
335
+ # Add the original line
336
+ output_lines.append(context.line)
337
+
338
+ # Build new state
339
+ new_state = (
340
+ StateBuilder(state)
341
+ .set_last_line_was_spacer(spacer_added)
342
+ .update_content_tracking(context.line_type, bool(context.content))
343
+ .build()
344
+ )
345
+
346
+ return ProcessingResult(
347
+ output_lines=output_lines, new_state=new_state, spacer_added=spacer_added
348
+ )
349
+
350
+ def _get_spacer_decision(self, context: LineContext) -> SpacerDecision:
351
+ """Get spacer decision from rules"""
352
+ for rule in self.rules:
353
+ if rule.applies_to(context):
354
+ return rule.should_add_spacer(context)
355
+
356
+ # Default: don't add spacer
357
+ return SpacerDecision(False, "No applicable rule found", "DefaultRule")
358
+
359
+
360
+ class LineProcessorFactory:
361
+ """Factory for creating line processors"""
362
+
363
+ def __init__(self, spacer_marker: str, rules: List[SpacerRule]):
364
+ self.processors = [
365
+ EmptyLineProcessor(),
366
+ CodeBlockMarkerProcessor(),
367
+ SpacerMarkerProcessor(spacer_marker, rules),
368
+ RegularContentProcessor(spacer_marker, rules),
369
+ ]
370
+
371
+ def get_processor(self, line_type: LineType) -> Optional[LineProcessor]:
372
+ """Get appropriate processor for the line type"""
373
+ for processor in self.processors:
374
+ if processor.can_process(line_type):
375
+ return processor
376
+ return None
377
+
378
+
379
+ class ContextFactory:
380
+ """Factory for creating line contexts"""
381
+
382
+ @staticmethod
383
+ def create_context(
384
+ line: str, line_number: int, line_type: LineType, state: Dict[str, Any]
385
+ ) -> LineContext:
386
+ """Create a LineContext from line and state"""
387
+ return LineContext(
388
+ line=line,
389
+ line_number=line_number,
390
+ line_type=line_type,
391
+ is_empty=not line.strip(),
392
+ content=line.strip(),
393
+ in_code_block=state.get("in_code_block", False),
394
+ last_line_was_spacer=state.get("last_line_was_spacer", False),
395
+ last_non_empty_was_heading=state.get("last_non_empty_was_heading", False),
396
+ has_content_before=state.get("has_content_before", False),
397
+ processed_lines=state.get("processed_lines", []),
398
+ )
399
+
400
+
401
+ class SpacerRuleEngine:
402
+ """Refactored engine with reduced complexity"""
403
+
404
+ def __init__(self, rules: Optional[List[SpacerRule]] = None):
405
+ self.rules = rules or self._get_default_rules()
406
+ self.SPACER_MARKER = SPACER_MARKER
407
+
408
+ # Initialize factories
409
+ self.processor_factory = LineProcessorFactory(
410
+ self.SPACER_MARKER,
411
+ self.rules,
412
+ )
413
+ self.context_factory = ContextFactory()
414
+
415
+ def process_line(
416
+ self, line: str, line_number: int, context_state: Dict[str, Any]
417
+ ) -> Tuple[List[str], Dict[str, Any]]:
418
+ """
419
+ Processes a line and returns the resulting lines + new state
420
+
421
+ Returns:
422
+ Tuple[List[str], Dict[str, Any]]: (processed_lines, new_state)
423
+ """
424
+ # Step 1: Determine line type (single responsibility)
425
+ line_type = self._determine_line_type(
426
+ line, context_state.get("in_code_block", False)
427
+ )
428
+
429
+ # Step 2: Create context (factory pattern)
430
+ context = self.context_factory.create_context(
431
+ line, line_number, line_type, context_state
432
+ )
433
+
434
+ # Step 3: Get appropriate processor (strategy pattern)
435
+ processor = self.processor_factory.get_processor(line_type)
436
+ if not processor:
437
+ # Fallback to original line
438
+ return [line], context_state.copy()
439
+
440
+ # Step 4: Process line (delegation)
441
+ result = processor.process(context, context_state)
442
+
443
+ return result.output_lines, result.new_state
444
+
445
+ def _get_default_rules(self) -> List[SpacerRule]:
446
+ """Default rule set"""
447
+ return [
448
+ CodeBlockSpacerRule(), # Highest priority - code blocks
449
+ ConsecutiveSpacerRule(), # Prevent duplicate spacers
450
+ HeadingSpacerRule(), # Spacer before headings
451
+ DividerSpacerRule(), # Spacer before dividers
452
+ ]
453
+
454
+ def _determine_line_type(self, line: str, in_code_block: bool) -> LineType:
455
+ """Determines the type of a line"""
456
+ content = line.strip()
457
+
458
+ # Guard clauses for early returns
459
+ if not content:
460
+ return LineType.EMPTY
461
+
462
+ if content.startswith("```"):
463
+ return LineType.CODE_BLOCK_MARKER
464
+
465
+ if in_code_block:
466
+ return LineType.REGULAR_CONTENT
467
+
468
+ if content == self.SPACER_MARKER:
469
+ return LineType.SPACER_MARKER
470
+
471
+ # Pattern matching with early returns
472
+ patterns = [
473
+ (r"^\|\s?(.*)$", LineType.PIPE_SYNTAX),
474
+ (r"^(#{1,6})\s+(.+)$", LineType.HEADING),
475
+ (r"^-{3,}$", LineType.DIVIDER),
476
+ (r"^\s*[-*+]\s+\[[ x]\]", LineType.TODO_ITEM),
477
+ ]
478
+
479
+ for pattern, line_type in patterns:
480
+ if re.match(pattern, content if pattern.startswith("^#{") else line):
481
+ return line_type
482
+
483
+ return LineType.REGULAR_CONTENT
@@ -1,7 +1,7 @@
1
1
  from typing import Any, Dict, Optional
2
2
  from notionary.notion_client import NotionClient
3
3
  from notionary.page.properites.property_formatter import NotionPropertyFormatter
4
- from notionary.util.logging_mixin import LoggingMixin
4
+ from notionary.util import LoggingMixin
5
5
 
6
6
 
7
7
  class MetadataEditor(LoggingMixin):
@@ -3,7 +3,7 @@ from typing import Optional
3
3
 
4
4
  from notionary.models.notion_page_response import EmojiIcon, ExternalIcon, FileIcon
5
5
  from notionary.notion_client import NotionClient
6
- from notionary.util.logging_mixin import LoggingMixin
6
+ from notionary.util import LoggingMixin
7
7
 
8
8
 
9
9
  class NotionPageIconManager(LoggingMixin):
@@ -1,7 +1,7 @@
1
1
  import random
2
2
  from typing import Any, Dict, Optional
3
3
  from notionary.notion_client import NotionClient
4
- from notionary.util.logging_mixin import LoggingMixin
4
+ from notionary.util import LoggingMixin
5
5
 
6
6
 
7
7
  class NotionPageCoverManager(LoggingMixin):
@@ -21,7 +21,7 @@ from notionary.page.content.page_content_writer import PageContentWriter
21
21
  from notionary.page.properites.page_property_manager import PagePropertyManager
22
22
  from notionary.page.relations.notion_page_title_resolver import NotionPageTitleResolver
23
23
  from notionary.util.warn_direct_constructor_usage import warn_direct_constructor_usage
24
- from notionary.util.logging_mixin import LoggingMixin
24
+ from notionary.util import LoggingMixin
25
25
  from notionary.util.page_id_utils import extract_and_validate_page_id
26
26
  from notionary.page.relations.page_database_relation import PageDatabaseRelation
27
27