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.
Files changed (96) hide show
  1. notionary/blocks/_bootstrap.py +9 -1
  2. notionary/blocks/audio/audio_element.py +53 -28
  3. notionary/blocks/audio/audio_markdown_node.py +10 -4
  4. notionary/blocks/base_block_element.py +15 -3
  5. notionary/blocks/bookmark/bookmark_element.py +39 -36
  6. notionary/blocks/bookmark/bookmark_markdown_node.py +16 -17
  7. notionary/blocks/breadcrumbs/breadcrumb_element.py +2 -2
  8. notionary/blocks/bulleted_list/bulleted_list_element.py +21 -4
  9. notionary/blocks/callout/callout_element.py +20 -4
  10. notionary/blocks/child_database/__init__.py +11 -4
  11. notionary/blocks/child_database/child_database_element.py +61 -0
  12. notionary/blocks/child_database/child_database_models.py +7 -14
  13. notionary/blocks/child_page/child_page_element.py +94 -0
  14. notionary/blocks/client.py +0 -1
  15. notionary/blocks/code/code_element.py +51 -2
  16. notionary/blocks/code/code_markdown_node.py +52 -1
  17. notionary/blocks/column/column_element.py +9 -3
  18. notionary/blocks/column/column_list_element.py +18 -3
  19. notionary/blocks/divider/divider_element.py +3 -11
  20. notionary/blocks/embed/embed_element.py +27 -6
  21. notionary/blocks/equation/equation_element.py +94 -41
  22. notionary/blocks/equation/equation_element_markdown_node.py +8 -9
  23. notionary/blocks/file/file_element.py +56 -37
  24. notionary/blocks/file/file_element_markdown_node.py +9 -7
  25. notionary/blocks/guards.py +22 -0
  26. notionary/blocks/heading/heading_element.py +23 -4
  27. notionary/blocks/image_block/image_element.py +43 -38
  28. notionary/blocks/image_block/image_markdown_node.py +10 -5
  29. notionary/blocks/mixins/captions/__init__.py +4 -0
  30. notionary/blocks/mixins/captions/caption_markdown_node_mixin.py +31 -0
  31. notionary/blocks/mixins/captions/caption_mixin.py +92 -0
  32. notionary/blocks/models.py +3 -1
  33. notionary/blocks/numbered_list/numbered_list_element.py +21 -4
  34. notionary/blocks/paragraph/paragraph_element.py +21 -5
  35. notionary/blocks/pdf/pdf_element.py +47 -41
  36. notionary/blocks/pdf/pdf_markdown_node.py +9 -7
  37. notionary/blocks/quote/quote_element.py +26 -9
  38. notionary/blocks/quote/quote_markdown_node.py +2 -2
  39. notionary/blocks/registry/block_registry.py +1 -46
  40. notionary/blocks/registry/block_registry_builder.py +8 -0
  41. notionary/blocks/rich_text/name_to_id_resolver.py +205 -0
  42. notionary/blocks/rich_text/rich_text_models.py +62 -29
  43. notionary/blocks/rich_text/text_inline_formatter.py +432 -101
  44. notionary/blocks/syntax_prompt_builder.py +137 -0
  45. notionary/blocks/table/table_element.py +110 -9
  46. notionary/blocks/table_of_contents/table_of_contents_element.py +19 -2
  47. notionary/blocks/todo/todo_element.py +21 -4
  48. notionary/blocks/toggle/toggle_element.py +19 -3
  49. notionary/blocks/toggle/toggle_markdown_node.py +1 -1
  50. notionary/blocks/toggleable_heading/toggleable_heading_element.py +19 -4
  51. notionary/blocks/types.py +69 -0
  52. notionary/blocks/video/video_element.py +44 -39
  53. notionary/blocks/video/video_markdown_node.py +10 -5
  54. notionary/database/client.py +23 -0
  55. notionary/file_upload/models.py +2 -2
  56. notionary/markdown/markdown_builder.py +34 -27
  57. notionary/page/client.py +26 -6
  58. notionary/page/notion_page.py +37 -6
  59. notionary/page/page_content_deleting_service.py +117 -0
  60. notionary/page/page_content_writer.py +89 -113
  61. notionary/page/page_context.py +65 -0
  62. notionary/page/reader/handler/__init__.py +2 -0
  63. notionary/page/reader/handler/base_block_renderer.py +4 -4
  64. notionary/page/reader/handler/block_rendering_context.py +5 -0
  65. notionary/page/reader/handler/line_renderer.py +16 -3
  66. notionary/page/reader/handler/numbered_list_renderer.py +85 -0
  67. notionary/page/reader/page_content_retriever.py +17 -5
  68. notionary/page/writer/handler/__init__.py +2 -0
  69. notionary/page/writer/handler/code_handler.py +12 -40
  70. notionary/page/writer/handler/column_handler.py +12 -12
  71. notionary/page/writer/handler/column_list_handler.py +13 -13
  72. notionary/page/writer/handler/equation_handler.py +74 -0
  73. notionary/page/writer/handler/line_handler.py +4 -4
  74. notionary/page/writer/handler/regular_line_handler.py +31 -37
  75. notionary/page/writer/handler/table_handler.py +8 -72
  76. notionary/page/writer/handler/toggle_handler.py +14 -12
  77. notionary/page/writer/handler/toggleable_heading_handler.py +22 -16
  78. notionary/page/writer/markdown_to_notion_converter.py +28 -9
  79. notionary/page/writer/markdown_to_notion_converter_context.py +30 -0
  80. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +73 -0
  81. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  82. notionary/page/writer/markdown_to_notion_text_length_post_processor.py +0 -0
  83. notionary/page/writer/notion_text_length_processor.py +150 -0
  84. notionary/telemetry/service.py +0 -1
  85. notionary/user/notion_user_manager.py +22 -95
  86. notionary/util/concurrency_limiter.py +0 -0
  87. notionary/workspace.py +4 -4
  88. notionary-0.2.22.dist-info/METADATA +237 -0
  89. {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/RECORD +92 -77
  90. notionary/page/markdown_whitespace_processor.py +0 -80
  91. notionary/page/notion_text_length_utils.py +0 -119
  92. notionary/user/notion_user_provider.py +0 -1
  93. notionary-0.2.21.dist-info/METADATA +0 -229
  94. /notionary/page/reader/handler/{context.py → equation_renderer.py} +0 -0
  95. {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/LICENSE +0 -0
  96. {notionary-0.2.21.dist-info → notionary-0.2.22.dist-info}/WHEEL +0 -0
@@ -1,125 +1,456 @@
1
1
  import re
2
- from typing import Any
2
+ from typing import Optional, Match, List
3
3
 
4
- from notionary.blocks.rich_text.rich_text_models import RichTextObject
4
+ from notionary.blocks.rich_text.rich_text_models import (
5
+ RichTextObject,
6
+ RichTextType,
7
+ MentionType,
8
+ TemplateMentionType,
9
+ MentionDate,
10
+ MentionTemplateMention,
11
+ )
12
+ from notionary.blocks.types import BlockColor
13
+ from notionary.blocks.rich_text.name_to_id_resolver import NameIdResolver
5
14
 
6
15
 
7
16
  class TextInlineFormatter:
8
- FORMAT_PATTERNS: list[tuple[str, dict[str, Any]]] = [
9
- (r"\*\*(.+?)\*\*", {"bold": True}),
10
- (r"\*(.+?)\*", {"italic": True}),
11
- (r"_(.+?)_", {"italic": True}),
12
- (r"__(.+?)__", {"underline": True}),
13
- (r"~~(.+?)~~", {"strikethrough": True}),
14
- (r"`(.+?)`", {"code": True}),
15
- (r"\[(.+?)\]\((.+?)\)", {"link": True}),
16
- (r"@\[([0-9a-f-]+)\]", {"mention_page": True}), # weiterhin deine Kurzsyntax
17
- ]
18
-
19
- @classmethod
20
- def parse_inline_formatting(cls, text: str) -> list[RichTextObject]:
17
+ """
18
+ Supported syntax patterns:
19
+
20
+ Bold
21
+ **bold text**
22
+ → RichTextObject(plain_text="bold text", bold=True)
23
+
24
+ Italic
25
+ *italic text* or _italic text_
26
+ → RichTextObject(plain_text="italic text", italic=True)
27
+
28
+ • Underline
29
+ __underlined text__
30
+ → RichTextObject(plain_text="underlined text", underline=True)
31
+
32
+ • Strikethrough
33
+ ~~strikethrough~~
34
+ → RichTextObject(plain_text="strikethrough", strikethrough=True)
35
+
36
+ • Inline code
37
+ `code snippet`
38
+ → RichTextObject(plain_text="code snippet", code=True)
39
+
40
+ • Link
41
+ [link text](https://example.com)
42
+ → RichTextObject.for_link("link text", "https://example.com")
43
+
44
+ • Inline equation
45
+ $E = mc^2$
46
+ → RichTextObject.equation_inline("E = mc^2")
47
+
48
+ • Colored text / highlight (supports nested formatting)
49
+ (red:important) — sets text color to "red"
50
+ (blue_background:note) — sets background to "blue_background"
51
+ (red_background:**bold text**) — red background with bold formatting
52
+ → RichTextObject(plain_text="important", color="red", bold=True)
53
+ Valid colors are any value in the BlockColor enum, e.g.:
54
+ default, gray, brown, orange, yellow, green, blue, purple, pink, red
55
+ or their `_background` variants.
56
+
57
+ • Page mention
58
+ @page[123e4567-e89b-12d3-a456-426614174000] — by ID
59
+ @page[Page Name] — by name
60
+ → RichTextObject.mention_page("resolved-id")
61
+
62
+ • Database mention
63
+ @database[123e4567-e89b-12d3-a456-426614174000] — by ID
64
+ @database[Database Name] — by name
65
+ → RichTextObject.mention_database("resolved-id")
66
+ """
67
+
68
+ class Patterns:
69
+ BOLD = r"\*\*(.+?)\*\*"
70
+ ITALIC = r"\*(.+?)\*"
71
+ ITALIC_UNDERSCORE = r"_([^_]+?)_"
72
+ UNDERLINE = r"__(.+?)__"
73
+ STRIKETHROUGH = r"~~(.+?)~~"
74
+ CODE = r"`(.+?)`"
75
+ LINK = r"\[(.+?)\]\((.+?)\)"
76
+ INLINE_EQUATION = r"\$(.+?)\$"
77
+ COLOR = r"\((\w+):(.+?)\)" # (blue:colored text) or (blue_background:text)
78
+ PAGE_MENTION = r"@page\[([^\]]+)\]" # Matches both IDs and names
79
+ DATABASE_MENTION = r"@database\[([^\]]+)\]" # Matches both IDs and names
80
+ USER_MENTION = r"@user\[([^\]]+)\]" # Matches both IDs and names
81
+
82
+ # Pattern to handler mapping - cleaner approach
83
+ @classmethod
84
+ def _get_format_handlers(cls):
85
+ """Get pattern to handler mapping - defined as method to access class methods."""
86
+ return [
87
+ (cls.Patterns.BOLD, cls._handle_bold_pattern),
88
+ (cls.Patterns.ITALIC, cls._handle_italic_pattern),
89
+ (cls.Patterns.ITALIC_UNDERSCORE, cls._handle_italic_pattern),
90
+ (cls.Patterns.UNDERLINE, cls._handle_underline_pattern),
91
+ (cls.Patterns.STRIKETHROUGH, cls._handle_strikethrough_pattern),
92
+ (cls.Patterns.CODE, cls._handle_code_pattern),
93
+ (cls.Patterns.LINK, cls._handle_link_pattern),
94
+ (cls.Patterns.INLINE_EQUATION, cls._handle_equation_pattern),
95
+ (cls.Patterns.COLOR, cls._handle_color_pattern),
96
+ (cls.Patterns.PAGE_MENTION, cls._handle_page_mention_pattern),
97
+ (cls.Patterns.DATABASE_MENTION, cls._handle_database_mention_pattern),
98
+ (cls.Patterns.USER_MENTION, cls._handle_user_mention_pattern),
99
+ ]
100
+
101
+ VALID_COLORS = {color.value for color in BlockColor}
102
+
103
+ _resolver: Optional[NameIdResolver] = None
104
+
105
+ @classmethod
106
+ def set_resolver(cls, resolver: Optional[NameIdResolver]) -> None:
107
+ """Set the name-to-ID resolver instance."""
108
+ cls._resolver = resolver
109
+
110
+ @classmethod
111
+ def get_resolver(cls) -> NameIdResolver:
112
+ """Get or create the name-to-ID resolver instance."""
113
+ if cls._resolver is None:
114
+ cls._resolver = NameIdResolver()
115
+ return cls._resolver
116
+
117
+ @classmethod
118
+ async def parse_inline_formatting(cls, text: str) -> list[RichTextObject]:
119
+ """Main entry point: Parse markdown text into RichTextObjects."""
21
120
  if not text:
22
121
  return []
23
- return cls._split_text_into_segments(text, cls.FORMAT_PATTERNS)
122
+ return await cls._split_text_into_segments(text)
24
123
 
25
124
  @classmethod
26
- def _split_text_into_segments(
27
- cls, text: str, patterns: list[tuple[str, dict[str, Any]]]
28
- ) -> list[RichTextObject]:
29
- segs: list[RichTextObject] = []
125
+ async def _split_text_into_segments(cls, text: str) -> list[RichTextObject]:
126
+ """Core parsing logic - split text based on formatting patterns."""
127
+ segments: list[RichTextObject] = []
30
128
  remaining = text
31
129
 
32
130
  while remaining:
33
- match, fmt, pos = None, None, len(remaining)
34
- for pattern, f in patterns:
35
- m = re.search(pattern, remaining)
36
- if m and m.start() < pos:
37
- match, fmt, pos = m, f, m.start()
38
-
39
- if not match:
40
- segs.append(RichTextObject.from_plain_text(remaining))
131
+ earliest_match = cls._find_earliest_pattern_match(remaining)
132
+
133
+ if not earliest_match:
134
+ # No more patterns - add remaining as plain text
135
+ segments.append(RichTextObject.from_plain_text(remaining))
41
136
  break
42
137
 
43
- if pos > 0:
44
- segs.append(RichTextObject.from_plain_text(remaining[:pos]))
138
+ match, handler_name, position = earliest_match
139
+
140
+ # Add any plain text before the pattern
141
+ if position > 0:
142
+ plain_text = remaining[:position]
143
+ segments.append(RichTextObject.from_plain_text(plain_text))
144
+
145
+ # Convert pattern to RichTextObject(s) - handlers can now return single objects or lists
146
+ if handler_name in [
147
+ cls._handle_page_mention_pattern,
148
+ cls._handle_database_mention_pattern,
149
+ cls._handle_user_mention_pattern,
150
+ cls._handle_color_pattern, # Color pattern also needs async for recursive parsing
151
+ ]:
152
+ result = await handler_name(match)
153
+ else:
154
+ result = handler_name(match)
155
+
156
+ # Handle both single RichTextObject and list of RichTextObjects
157
+ if isinstance(result, list):
158
+ segments.extend(result)
159
+ elif result:
160
+ segments.append(result)
161
+
162
+ # Continue with text after the pattern
163
+ remaining = remaining[position + len(match.group(0)) :]
164
+
165
+ return segments
166
+
167
+ @classmethod
168
+ def _find_earliest_pattern_match(
169
+ cls, text: str
170
+ ) -> Optional[tuple[Match, callable, int]]:
171
+ """Find the pattern that appears earliest in the text."""
172
+ earliest_match = None
173
+ earliest_position = len(text)
174
+ earliest_handler = None
175
+
176
+ for pattern, handler_func in cls._get_format_handlers():
177
+ match = re.search(pattern, text)
178
+ if match and match.start() < earliest_position:
179
+ earliest_match = match
180
+ earliest_position = match.start()
181
+ earliest_handler = handler_func
182
+
183
+ if earliest_match:
184
+ return earliest_match, earliest_handler, earliest_position
185
+ return None
45
186
 
46
- if "link" in fmt:
47
- segs.append(RichTextObject.for_link(match.group(1), match.group(2)))
48
- elif "mention_page" in fmt:
49
- segs.append(RichTextObject.mention_page(match.group(1)))
50
- elif "code" in fmt:
51
- segs.append(RichTextObject.from_plain_text(match.group(1), code=True))
187
+ @classmethod
188
+ async def _handle_color_pattern(cls, match: Match) -> List[RichTextObject]:
189
+ """Handle colored text with support for nested formatting: (blue:**bold text**)"""
190
+ color, content = match.group(1).lower(), match.group(2)
191
+
192
+ if color not in cls.VALID_COLORS:
193
+ return [RichTextObject.from_plain_text(f"({match.group(1)}:{content})")]
194
+
195
+ # Recursively parse the content inside the color pattern for nested formatting
196
+ parsed_segments = await cls._split_text_into_segments(content)
197
+
198
+ # Apply the color to all resulting segments
199
+ colored_segments = []
200
+ for segment in parsed_segments:
201
+ # Create a new RichTextObject with the same formatting but with the color applied
202
+ if segment.type == RichTextType.TEXT:
203
+ # For text segments, we can combine the color with existing formatting
204
+ colored_segment = cls._apply_color_to_text_segment(segment, color)
205
+ colored_segments.append(colored_segment)
52
206
  else:
53
- segs.append(RichTextObject.from_plain_text(match.group(1), **fmt))
207
+ # For non-text segments (equations, mentions, etc.), keep as-is
208
+ colored_segments.append(segment)
209
+
210
+ return colored_segments
54
211
 
55
- remaining = remaining[pos + len(match.group(0)) :]
212
+ @classmethod
213
+ def _apply_color_to_text_segment(
214
+ cls, segment: RichTextObject, color: str
215
+ ) -> RichTextObject:
216
+ """Apply color to a text segment while preserving existing formatting."""
217
+ if segment.type != RichTextType.TEXT:
218
+ return segment
56
219
 
57
- return segs
220
+ # Extract existing formatting
221
+ annotations = segment.annotations
222
+ text_content = segment.text
223
+ plain_text = segment.plain_text
224
+
225
+ # Create new RichTextObject with color and existing formatting
226
+ if text_content and text_content.link:
227
+ # For links, preserve the link while adding color and formatting
228
+ return RichTextObject.for_link(
229
+ plain_text,
230
+ text_content.link.url,
231
+ bold=annotations.bold if annotations else False,
232
+ italic=annotations.italic if annotations else False,
233
+ strikethrough=annotations.strikethrough if annotations else False,
234
+ underline=annotations.underline if annotations else False,
235
+ code=annotations.code if annotations else False,
236
+ color=color,
237
+ )
238
+ else:
239
+ # For regular text, combine all formatting
240
+ return RichTextObject.from_plain_text(
241
+ plain_text,
242
+ bold=annotations.bold if annotations else False,
243
+ italic=annotations.italic if annotations else False,
244
+ strikethrough=annotations.strikethrough if annotations else False,
245
+ underline=annotations.underline if annotations else False,
246
+ code=annotations.code if annotations else False,
247
+ color=color,
248
+ )
249
+
250
+ @classmethod
251
+ async def _handle_page_mention_pattern(cls, match: Match) -> RichTextObject:
252
+ """Handle page mentions: @page[page-id-or-name]"""
253
+ page_identifier = match.group(1)
254
+
255
+ resolver = cls.get_resolver()
256
+ page_id = await resolver.resolve_page_id(page_identifier)
257
+
258
+ if page_id:
259
+ return RichTextObject.mention_page(page_id)
260
+ else:
261
+ # If resolution fails, treat as plain text
262
+ return RichTextObject.for_caption(f"@page[{page_identifier}]")
263
+
264
+ @classmethod
265
+ async def _handle_database_mention_pattern(cls, match: Match) -> RichTextObject:
266
+ """Handle database mentions: @database[database-id-or-name]"""
267
+ database_identifier = match.group(1)
268
+
269
+ resolver = cls.get_resolver()
270
+ database_id = await resolver.resolve_database_id(database_identifier)
271
+
272
+ if database_id:
273
+ return RichTextObject.mention_database(database_id)
274
+ else:
275
+ # If resolution fails, treat as plain text
276
+ return RichTextObject.for_caption(f"@database[{database_identifier}]")
58
277
 
59
278
  @classmethod
60
- def extract_text_with_formatting(cls, rich_text: list[RichTextObject]) -> str:
61
- """
62
- Convert a list of RichTextObjects back into Markdown inline syntax.
63
- """
279
+ async def _handle_user_mention_pattern(cls, match: Match) -> RichTextObject:
280
+ """Handle user mentions: @user[user-id-or-name]"""
281
+ user_identifier = match.group(1)
282
+
283
+ resolver = cls.get_resolver()
284
+ user_id = await resolver.resolve_user_id(user_identifier)
285
+
286
+ if user_id:
287
+ return RichTextObject.mention_user(user_id)
288
+ else:
289
+ # If resolution fails, treat as plain text
290
+ return RichTextObject.for_caption(f"@user[{user_identifier}]")
291
+
292
+ @classmethod
293
+ async def extract_text_with_formatting(cls, rich_text: list[RichTextObject]) -> str:
294
+ """Convert RichTextObjects back into markdown with inline formatting."""
295
+ if not rich_text:
296
+ return ""
297
+
64
298
  parts: list[str] = []
65
299
 
66
- for obj in rich_text:
67
- # Basisinhalt
68
- content = obj.plain_text or (obj.text.content if obj.text else "")
69
-
70
- # Equations
71
- if obj.type == "equation" and obj.equation:
72
- parts.append(f"${obj.equation.expression}$")
73
- continue
74
-
75
- # Mentions
76
- if obj.type == "mention" and obj.mention:
77
- m = obj.mention
78
- if m.type == "page" and m.page:
79
- parts.append(f"@[{m.page.id}]")
80
- continue
81
- elif m.type == "user" and m.user:
82
- parts.append(f"@user({m.user.id})")
83
- continue
84
- elif m.type == "database" and m.database:
85
- parts.append(f"@db({m.database.id})")
86
- continue
87
- elif m.type == "date" and m.date:
88
- if m.date.end:
89
- parts.append(f"{m.date.start}–{m.date.end}")
90
- else:
91
- parts.append(m.date.start)
92
- continue
93
- elif m.type == "link_preview" and m.link_preview:
94
- # Als Link rendern
95
- content = f"[{content}]({m.link_preview.url})"
96
- elif m.type == "template_mention" and m.template_mention:
97
- tm = m.template_mention.type
98
- parts.append(
99
- "@template_user"
100
- if tm == "template_mention_user"
101
- else "@template_date"
102
- )
103
- continue
104
-
105
- # Normale Links (text.link)
106
- if obj.text and obj.text.link:
107
- url = obj.text.link.url
108
- content = f"[{content}]({url})"
109
-
110
- # Inline-Formatierungen
111
- ann = obj.annotations.model_dump() if obj.annotations else {}
112
- if ann.get("code"):
113
- content = f"`{content}`"
114
- if ann.get("strikethrough"):
115
- content = f"~~{content}~~"
116
- if ann.get("underline"):
117
- content = f"__{content}__"
118
- if ann.get("italic"):
119
- content = f"*{content}*"
120
- if ann.get("bold"):
121
- content = f"**{content}**"
122
-
123
- parts.append(content)
300
+ for rich_obj in rich_text:
301
+ formatted_text = await cls._convert_rich_text_to_markdown(rich_obj)
302
+ parts.append(formatted_text)
124
303
 
125
304
  return "".join(parts)
305
+
306
+ @classmethod
307
+ async def _convert_rich_text_to_markdown(cls, obj: RichTextObject) -> str:
308
+ """Convert single RichTextObject back to markdown format."""
309
+
310
+ # Handle special types first
311
+ if obj.type == RichTextType.EQUATION and obj.equation:
312
+ return f"${obj.equation.expression}$"
313
+
314
+ if obj.type == RichTextType.MENTION:
315
+ mention_markdown = await cls._extract_mention_markdown(obj)
316
+ if mention_markdown:
317
+ return mention_markdown
318
+
319
+ # Handle regular text with formatting
320
+ content = obj.plain_text or (obj.text.content if obj.text else "")
321
+ return cls._apply_text_formatting_to_content(obj, content)
322
+
323
+ @classmethod
324
+ async def _extract_mention_markdown(cls, obj: RichTextObject) -> Optional[str]:
325
+ """Extract mention objects back to markdown format with human-readable names."""
326
+ if not obj.mention:
327
+ return None
328
+
329
+ mention = obj.mention
330
+
331
+ # Handle different mention types
332
+ if mention.type == MentionType.PAGE and mention.page:
333
+ return await cls._extract_page_mention_markdown(mention.page.id)
334
+
335
+ if mention.type == MentionType.DATABASE and mention.database:
336
+ return await cls._extract_database_mention_markdown(mention.database.id)
337
+
338
+ if mention.type == MentionType.USER and mention.user:
339
+ return await cls._extract_user_mention_markdown(mention.user.id)
340
+
341
+ if mention.type == MentionType.DATE and mention.date:
342
+ return cls._extract_date_mention_markdown(mention.date)
343
+
344
+ if mention.type == MentionType.TEMPLATE_MENTION and mention.template_mention:
345
+ return cls._extract_template_mention_markdown(mention.template_mention)
346
+
347
+ if mention.type == MentionType.LINK_PREVIEW and mention.link_preview:
348
+ return f"[{obj.plain_text}]({mention.link_preview.url})"
349
+
350
+ return None
351
+
352
+ @classmethod
353
+ async def _extract_page_mention_markdown(cls, page_id: str) -> str:
354
+ """Extract page mention to markdown format."""
355
+ resolver = cls.get_resolver()
356
+ page_name = await resolver.resolve_page_name(page_id)
357
+ return f"@page[{page_name or page_id}]"
358
+
359
+ @classmethod
360
+ async def _extract_database_mention_markdown(cls, database_id: str) -> str:
361
+ """Extract database mention to markdown format."""
362
+ resolver = cls.get_resolver()
363
+ database_name = await resolver.resolve_database_name(database_id)
364
+ return f"@database[{database_name or database_id}]"
365
+
366
+ @classmethod
367
+ async def _extract_user_mention_markdown(cls, user_id: str) -> str:
368
+ """Extract user mention to markdown format."""
369
+ resolver = cls.get_resolver()
370
+ user_name = await resolver.resolve_user_name(user_id)
371
+ return f"@user[{user_name or user_id}]"
372
+
373
+ @classmethod
374
+ def _extract_date_mention_markdown(cls, date_mention: MentionDate) -> str:
375
+ """Extract date mention to markdown format."""
376
+ date_range = date_mention.start
377
+ if date_mention.end:
378
+ date_range += f"–{date_mention.end}"
379
+ return date_range
380
+
381
+ @classmethod
382
+ def _extract_template_mention_markdown(
383
+ cls, template_mention: MentionTemplateMention
384
+ ) -> str:
385
+ """Extract template mention to markdown format."""
386
+ template_type = template_mention.type
387
+ return (
388
+ "@template_user"
389
+ if template_type == TemplateMentionType.USER
390
+ else "@template_date"
391
+ )
392
+
393
+ @classmethod
394
+ def _apply_text_formatting_to_content(
395
+ cls, obj: RichTextObject, content: str
396
+ ) -> str:
397
+ """Apply text formatting annotations to content in correct order."""
398
+
399
+ # Handle links first (they wrap the content)
400
+ if obj.text and obj.text.link:
401
+ content = f"[{content}]({obj.text.link.url})"
402
+
403
+ # Apply formatting annotations if they exist
404
+ if not obj.annotations:
405
+ return content
406
+
407
+ annotations = obj.annotations
408
+
409
+ # Apply formatting in inside-out order
410
+ if annotations.code:
411
+ content = f"`{content}`"
412
+ if annotations.strikethrough:
413
+ content = f"~~{content}~~"
414
+ if annotations.underline:
415
+ content = f"__{content}__"
416
+ if annotations.italic:
417
+ content = f"*{content}*"
418
+ if annotations.bold:
419
+ content = f"**{content}**"
420
+
421
+ # Handle colors (wrap everything)
422
+ if annotations.color != "default" and annotations.color in cls.VALID_COLORS:
423
+ content = f"({annotations.color}:{content})"
424
+
425
+ return content
426
+
427
+ @classmethod
428
+ def _handle_bold_pattern(cls, match: Match) -> RichTextObject:
429
+ return RichTextObject.from_plain_text(match.group(1), bold=True)
430
+
431
+ @classmethod
432
+ def _handle_italic_pattern(cls, match: Match) -> RichTextObject:
433
+ return RichTextObject.from_plain_text(match.group(1), italic=True)
434
+
435
+ @classmethod
436
+ def _handle_underline_pattern(cls, match: Match) -> RichTextObject:
437
+ return RichTextObject.from_plain_text(match.group(1), underline=True)
438
+
439
+ @classmethod
440
+ def _handle_strikethrough_pattern(cls, match: Match) -> RichTextObject:
441
+ return RichTextObject.from_plain_text(match.group(1), strikethrough=True)
442
+
443
+ @classmethod
444
+ def _handle_code_pattern(cls, match: Match) -> RichTextObject:
445
+ return RichTextObject.from_plain_text(match.group(1), code=True)
446
+
447
+ @classmethod
448
+ def _handle_link_pattern(cls, match: Match) -> RichTextObject:
449
+ link_text, url = match.group(1), match.group(2)
450
+ return RichTextObject.for_link(link_text, url)
451
+
452
+ @classmethod
453
+ def _handle_equation_pattern(cls, match: Match) -> RichTextObject:
454
+ """Handle inline equations: $E = mc^2$"""
455
+ expression = match.group(1)
456
+ return RichTextObject.equation_inline(expression)