notionary 0.1.12__py3-none-any.whl → 0.1.14__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 (57) hide show
  1. notionary/__init__.py +14 -10
  2. notionary/{core/database → database}/database_discovery.py +19 -17
  3. notionary/{core/database → database}/database_info_service.py +1 -1
  4. notionary/{core/database/notion_database_manager.py → database/notion_database.py} +13 -14
  5. notionary/{core/database/notion_database_manager_factory.py → database/notion_database_factory.py} +8 -12
  6. notionary/{core/converters/elements → elements}/audio_element.py +1 -1
  7. notionary/{core/converters/registry → elements}/block_element_registry.py +2 -5
  8. notionary/elements/block_element_registry_builder.py +401 -0
  9. notionary/{core/converters/elements → elements}/bookmark_element.py +1 -1
  10. notionary/{core/converters/elements → elements}/callout_element.py +2 -2
  11. notionary/{core/converters/elements → elements}/code_block_element.py +1 -1
  12. notionary/{core/converters/elements → elements}/column_element.py +1 -1
  13. notionary/{core/converters/elements → elements}/divider_element.py +1 -1
  14. notionary/{core/converters/elements → elements}/embed_element.py +1 -1
  15. notionary/{core/converters/elements → elements}/heading_element.py +2 -2
  16. notionary/{core/converters/elements → elements}/image_element.py +1 -1
  17. notionary/{core/converters/elements → elements}/list_element.py +2 -2
  18. notionary/elements/mention_element.py +135 -0
  19. notionary/{core/converters/elements → elements}/paragraph_element.py +2 -2
  20. notionary/{core/converters/elements → elements}/qoute_element.py +1 -1
  21. notionary/{core/converters/elements → elements}/table_element.py +2 -2
  22. notionary/{core/converters/elements → elements}/todo_lists.py +2 -2
  23. notionary/{core/converters/elements → elements}/toggle_element.py +1 -6
  24. notionary/{core/converters/elements → elements}/video_element.py +1 -1
  25. notionary/{core/notion_client.py → notion_client.py} +0 -1
  26. notionary/{core/page → page}/content/page_content_manager.py +7 -8
  27. notionary/{core/converters → page}/markdown_to_notion_converter.py +2 -4
  28. notionary/{core/page → page}/metadata/metadata_editor.py +2 -2
  29. notionary/{core/page → page}/metadata/notion_icon_manager.py +1 -1
  30. notionary/{core/page → page}/metadata/notion_page_cover_manager.py +1 -1
  31. notionary/page/notion_page.py +522 -0
  32. notionary/page/notion_page_factory.py +242 -0
  33. notionary/page/notion_to_markdown_converter.py +245 -0
  34. notionary/{core/page → page}/properites/database_property_service.py +1 -1
  35. notionary/{core/page → page}/properites/page_property_manager.py +7 -7
  36. notionary/{core/page → page}/relations/notion_page_relation_manager.py +3 -3
  37. notionary/{core/page → page}/relations/notion_page_title_resolver.py +1 -1
  38. notionary/{core/page → page}/relations/page_database_relation.py +1 -1
  39. notionary/util/page_id_utils.py +3 -1
  40. {notionary-0.1.12.dist-info → notionary-0.1.14.dist-info}/METADATA +1 -1
  41. notionary-0.1.14.dist-info/RECORD +56 -0
  42. notionary/core/converters/__init__.py +0 -50
  43. notionary/core/converters/notion_to_markdown_converter.py +0 -45
  44. notionary/core/converters/registry/block_element_registry_builder.py +0 -284
  45. notionary/core/page/notion_page_manager.py +0 -312
  46. notionary-0.1.12.dist-info/RECORD +0 -55
  47. /notionary/{core/database → database}/models/page_result.py +0 -0
  48. /notionary/{core/converters/elements → elements}/notion_block_element.py +0 -0
  49. /notionary/{core/converters/elements → elements}/text_inline_formatter.py +0 -0
  50. /notionary/{core/page → page}/content/notion_page_content_chunker.py +0 -0
  51. /notionary/{core/page → page}/properites/property_formatter.py +0 -0
  52. /notionary/{core/page → page}/properites/property_operation_result.py +0 -0
  53. /notionary/{core/page → page}/properites/property_value_extractor.py +0 -0
  54. /notionary/{core/page → page}/relations/relation_operation_result.py +0 -0
  55. {notionary-0.1.12.dist-info → notionary-0.1.14.dist-info}/WHEEL +0 -0
  56. {notionary-0.1.12.dist-info → notionary-0.1.14.dist-info}/licenses/LICENSE +0 -0
  57. {notionary-0.1.12.dist-info → notionary-0.1.14.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  from typing import Dict, Any, Optional, List, Tuple, Callable
3
3
 
4
- from notionary.core.converters.elements.notion_block_element import NotionBlockElement
4
+ from notionary.elements.notion_block_element import NotionBlockElement
5
5
 
6
6
 
7
7
  class ToggleElement(NotionBlockElement):
@@ -171,17 +171,12 @@ class ToggleElement(NotionBlockElement):
171
171
  i += 1
172
172
  continue
173
173
 
174
- # Wenn context_aware aktiviert ist, prüfen wir für "Transcript"-Toggles
175
- # ob sie direkt nach einem Bullet Point kommen
176
174
  is_transcript_toggle = cls.TRANSCRIPT_TOGGLE_PATTERN.match(line.strip())
177
175
 
178
176
  if context_aware and is_transcript_toggle:
179
- # Prüfen, ob der Toggle in einem gültigen Kontext ist (nach Bullet Point)
180
177
  if i > 0 and lines[i - 1].strip().startswith("- "):
181
- # Gültiger Kontext, fahre fort
182
178
  pass
183
179
  else:
184
- # Ungültiger Kontext für Transcript-Toggle, überspringe ihn
185
180
  i += 1
186
181
  continue
187
182
 
@@ -1,6 +1,6 @@
1
1
  import re
2
2
  from typing import Dict, Any, Optional, List
3
- from notionary.core.converters.elements.notion_block_element import NotionBlockElement
3
+ from notionary.elements.notion_block_element import NotionBlockElement
4
4
 
5
5
 
6
6
  class VideoElement(NotionBlockElement):
@@ -124,7 +124,6 @@ class NotionClient(LoggingMixin):
124
124
  )
125
125
  return
126
126
 
127
- # Versuche, Cleanup Task zu erstellen
128
127
  loop.create_task(self.close())
129
128
  self.logger.debug("Created cleanup task for NotionClient")
130
129
  except RuntimeError:
@@ -1,17 +1,16 @@
1
- import re
1
+ import json
2
2
  from typing import Any, Dict, List, Optional
3
3
 
4
- from notionary.core.converters.markdown_to_notion_converter import (
4
+ from notionary.elements.block_element_registry import BlockElementRegistry
5
+ from notionary.notion_client import NotionClient
6
+
7
+ from notionary.page.markdown_to_notion_converter import (
5
8
  MarkdownToNotionConverter,
6
9
  )
7
- from notionary.core.converters.notion_to_markdown_converter import (
10
+ from notionary.page.notion_to_markdown_converter import (
8
11
  NotionToMarkdownConverter,
9
12
  )
10
- from notionary.core.converters.registry.block_element_registry import (
11
- BlockElementRegistry,
12
- )
13
- from notionary.core.notion_client import NotionClient
14
- from notionary.core.page.content.notion_page_content_chunker import (
13
+ from notionary.page.content.notion_page_content_chunker import (
15
14
  NotionPageContentChunker,
16
15
  )
17
16
  from notionary.util.logging_mixin import LoggingMixin
@@ -1,9 +1,7 @@
1
1
  from typing import Dict, Any, List, Optional, Tuple
2
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 (
3
+ from notionary.elements.block_element_registry import BlockElementRegistry
4
+ from notionary.elements.block_element_registry_builder import (
7
5
  BlockElementRegistryBuilder,
8
6
  )
9
7
 
@@ -1,6 +1,6 @@
1
1
  from typing import Any, Dict, Optional
2
- from notionary.core.notion_client import NotionClient
3
- from notionary.core.page.properites.property_formatter import NotionPropertyFormatter
2
+ from notionary.notion_client import NotionClient
3
+ from notionary.page.properites.property_formatter import NotionPropertyFormatter
4
4
  from notionary.util.logging_mixin import LoggingMixin
5
5
 
6
6
 
@@ -1,6 +1,6 @@
1
1
  from typing import Any, Dict, Optional
2
2
 
3
- from notionary.core.notion_client import NotionClient
3
+ from notionary.notion_client import NotionClient
4
4
  from notionary.util.logging_mixin import LoggingMixin
5
5
 
6
6
 
@@ -1,6 +1,6 @@
1
1
  import random
2
2
  from typing import Any, Dict, Optional
3
- from notionary.core.notion_client import NotionClient
3
+ from notionary.notion_client import NotionClient
4
4
  from notionary.util.logging_mixin import LoggingMixin
5
5
 
6
6
 
@@ -0,0 +1,522 @@
1
+ import re
2
+ from typing import Any, Dict, List, Optional, Union
3
+
4
+ from notionary.elements.block_element_registry import BlockElementRegistry
5
+ from notionary.elements.block_element_registry_builder import (
6
+ BlockElementRegistryBuilder,
7
+ )
8
+ from notionary.notion_client import NotionClient
9
+ from notionary.page.metadata.metadata_editor import MetadataEditor
10
+ from notionary.page.metadata.notion_icon_manager import NotionPageIconManager
11
+ from notionary.page.metadata.notion_page_cover_manager import (
12
+ NotionPageCoverManager,
13
+ )
14
+ from notionary.page.properites.database_property_service import (
15
+ DatabasePropertyService,
16
+ )
17
+ from notionary.page.relations.notion_page_relation_manager import (
18
+ NotionRelationManager,
19
+ )
20
+ from notionary.page.content.page_content_manager import PageContentManager
21
+ from notionary.page.properites.page_property_manager import PagePropertyManager
22
+ from notionary.util.logging_mixin import LoggingMixin
23
+ from notionary.util.page_id_utils import extract_and_validate_page_id
24
+ from notionary.page.relations.page_database_relation import PageDatabaseRelation
25
+
26
+
27
+ class NotionPage(LoggingMixin):
28
+ """
29
+ High-Level Facade for managing content and metadata of a Notion page.
30
+ """
31
+
32
+ def __init__(
33
+ self,
34
+ page_id: Optional[str] = None,
35
+ title: Optional[str] = None,
36
+ url: Optional[str] = None,
37
+ token: Optional[str] = None,
38
+ ):
39
+ self._page_id = extract_and_validate_page_id(page_id=page_id, url=url)
40
+ self._url = url
41
+ self._title = title
42
+ self._client = NotionClient(token=token)
43
+ self._page_data = None
44
+ self._title_loaded = title is not None
45
+ self._url_loaded = url is not None
46
+
47
+ self._block_element_registry = (
48
+ BlockElementRegistryBuilder.create_standard_registry()
49
+ )
50
+
51
+ self._page_content_manager = PageContentManager(
52
+ page_id=self._page_id,
53
+ client=self._client,
54
+ block_registry=self._block_element_registry,
55
+ )
56
+ self._metadata = MetadataEditor(self._page_id, self._client)
57
+ self._page_cover_manager = NotionPageCoverManager(
58
+ page_id=self._page_id, client=self._client
59
+ )
60
+ self._page_icon_manager = NotionPageIconManager(
61
+ page_id=self._page_id, client=self._client
62
+ )
63
+
64
+ self._db_relation = PageDatabaseRelation(
65
+ page_id=self._page_id, client=self._client
66
+ )
67
+ self._db_property_service = None
68
+
69
+ self._relation_manager = NotionRelationManager(
70
+ page_id=self._page_id, client=self._client
71
+ )
72
+
73
+ self._property_manager = PagePropertyManager(
74
+ self._page_id, self._client, self._metadata, self._db_relation
75
+ )
76
+
77
+ @property
78
+ def id(self) -> str:
79
+ """
80
+ Get the ID of the page.
81
+
82
+ Returns:
83
+ str: The page ID.
84
+ """
85
+ return self._page_id
86
+
87
+ @property
88
+ def block_registry(self) -> BlockElementRegistry:
89
+ """
90
+ Get the block element registry associated with this page.
91
+
92
+ Returns:
93
+ BlockElementRegistry: The registry of block elements.
94
+ """
95
+ return self._block_element_registry
96
+
97
+ @block_registry.setter
98
+ def block_registry(self, block_registry: BlockElementRegistry) -> None:
99
+ """
100
+ Set the block element registry for the page content manager.
101
+
102
+ Args:
103
+ block_registry: The registry of block elements to use.
104
+ """
105
+ self._block_element_registry = block_registry
106
+ self._page_content_manager = PageContentManager(
107
+ page_id=self._page_id, client=self._client, block_registry=block_registry
108
+ )
109
+
110
+ async def get_title(self) -> str:
111
+ """
112
+ Get the title of the page, loading it if necessary.
113
+
114
+ Returns:
115
+ str: The page title.
116
+ """
117
+ if not self._title_loaded:
118
+ await self._load_page_title()
119
+ return self._title
120
+
121
+ async def get_url(self) -> str:
122
+ """
123
+ Get the URL of the page, constructing it if necessary.
124
+
125
+ Returns:
126
+ str: The page URL.
127
+ """
128
+ if not self._url_loaded:
129
+ self._url = await self._build_notion_url()
130
+ self._url_loaded = True
131
+ return self._url
132
+
133
+ async def append_markdown(self, markdown: str) -> str:
134
+ """
135
+ Append markdown content to the page.
136
+
137
+ Args:
138
+ markdown: The markdown content to append.
139
+
140
+ Returns:
141
+ str: Status or confirmation message.
142
+ """
143
+ return await self._page_content_manager.append_markdown(markdown)
144
+
145
+ async def clear(self) -> str:
146
+ """
147
+ Clear all content from the page.
148
+
149
+ Returns:
150
+ str: Status or confirmation message.
151
+ """
152
+ return await self._page_content_manager.clear()
153
+
154
+ async def replace_content(self, markdown: str) -> str:
155
+ """
156
+ Replace the entire page content with new markdown content.
157
+
158
+ Args:
159
+ markdown: The new markdown content.
160
+
161
+ Returns:
162
+ str: Status or confirmation message.
163
+ """
164
+ await self._page_content_manager.clear()
165
+ return await self._page_content_manager.append_markdown(markdown)
166
+
167
+ async def get_text(self) -> str:
168
+ """
169
+ Get the text content of the page.
170
+
171
+ Returns:
172
+ str: The text content of the page.
173
+ """
174
+ return await self._page_content_manager.get_text()
175
+
176
+ async def set_title(self, title: str) -> Optional[Dict[str, Any]]:
177
+ """
178
+ Set the title of the page.
179
+
180
+ Args:
181
+ title: The new title.
182
+
183
+ Returns:
184
+ Optional[Dict[str, Any]]: Response data from the API if successful, None otherwise.
185
+ """
186
+ result = await self._metadata.set_title(title)
187
+ if result:
188
+ self._title = title
189
+ self._title_loaded = True
190
+ return result
191
+
192
+ async def set_page_icon(
193
+ self, emoji: Optional[str] = None, external_url: Optional[str] = None
194
+ ) -> Optional[Dict[str, Any]]:
195
+ """
196
+ Set the icon for the page. Provide either emoji or external_url.
197
+
198
+ Args:
199
+ emoji: Optional emoji to use as icon.
200
+ external_url: Optional URL to an external image to use as icon.
201
+
202
+ Returns:
203
+ Optional[Dict[str, Any]]: Response data from the API if successful, None otherwise.
204
+ """
205
+ return await self._page_icon_manager.set_icon(emoji, external_url)
206
+
207
+ async def get_icon(self) -> Optional[str]:
208
+ """
209
+ Retrieve the page icon - either emoji or external URL.
210
+
211
+ Returns:
212
+ Optional[str]: The icon emoji or URL, or None if no icon is set.
213
+ """
214
+ return await self._page_icon_manager.get_icon()
215
+
216
+ async def get_cover_url(self) -> str:
217
+ """
218
+ Get the URL of the page cover image.
219
+
220
+ Returns:
221
+ str: The URL of the cover image or empty string if not available.
222
+ """
223
+ return await self._page_cover_manager.get_cover_url()
224
+
225
+ async def set_page_cover(self, external_url: str) -> Optional[Dict[str, Any]]:
226
+ """
227
+ Set the cover image for the page using an external URL.
228
+
229
+ Args:
230
+ external_url: URL to the external image.
231
+
232
+ Returns:
233
+ Optional[Dict[str, Any]]: Response data from the API if successful, None otherwise.
234
+ """
235
+ return await self._page_cover_manager.set_cover(external_url)
236
+
237
+ async def set_random_gradient_cover(self) -> Optional[Dict[str, Any]]:
238
+ """
239
+ Set a random gradient as the page cover.
240
+
241
+ Returns:
242
+ Optional[Dict[str, Any]]: Response data from the API if successful, None otherwise.
243
+ """
244
+ return await self._page_cover_manager.set_random_gradient_cover()
245
+
246
+ async def get_properties(self) -> Dict[str, Any]:
247
+ """
248
+ Retrieve all properties of the page.
249
+
250
+ Returns:
251
+ Dict[str, Any]: Dictionary of property names and their values.
252
+ """
253
+ return await self._property_manager.get_properties()
254
+
255
+ async def get_property_value(self, property_name: str) -> Any:
256
+ """
257
+ Get the value of a specific property.
258
+
259
+ Args:
260
+ property_name: The name of the property.
261
+
262
+ Returns:
263
+ Any: The value of the property.
264
+ """
265
+ return await self._property_manager.get_property_value(
266
+ property_name, self._relation_manager.get_relation_values
267
+ )
268
+
269
+ async def set_property_by_name(
270
+ self, property_name: str, value: Any
271
+ ) -> Optional[Dict[str, Any]]:
272
+ """
273
+ Set the value of a specific property by its name.
274
+
275
+ Args:
276
+ property_name: The name of the property.
277
+ value: The new value to set.
278
+
279
+ Returns:
280
+ Optional[Dict[str, Any]]: Response data from the API if successful, None otherwise.
281
+ """
282
+ return await self._property_manager.set_property_by_name(
283
+ property_name=property_name,
284
+ value=value,
285
+ )
286
+
287
+ async def is_database_page(self) -> bool:
288
+ """
289
+ Check if this page belongs to a database.
290
+
291
+ Returns:
292
+ bool: True if the page belongs to a database, False otherwise.
293
+ """
294
+ return await self._db_relation.is_database_page()
295
+
296
+ async def get_parent_database_id(self) -> Optional[str]:
297
+ """
298
+ Get the ID of the database this page belongs to, if any.
299
+
300
+ Returns:
301
+ Optional[str]: The database ID or None if the page doesn't belong to a database.
302
+ """
303
+ return await self._db_relation.get_parent_database_id()
304
+
305
+ async def get_available_options_for_property(self, property_name: str) -> List[str]:
306
+ """
307
+ Get the available option names for a property (select, multi_select, status).
308
+
309
+ Args:
310
+ property_name: The name of the property.
311
+
312
+ Returns:
313
+ List[str]: List of available option names.
314
+ """
315
+ db_service = await self._get_db_property_service()
316
+ if db_service:
317
+ return await db_service.get_option_names(property_name)
318
+ return []
319
+
320
+ async def get_property_type(self, property_name: str) -> Optional[str]:
321
+ """
322
+ Get the type of a specific property.
323
+
324
+ Args:
325
+ property_name: The name of the property.
326
+
327
+ Returns:
328
+ Optional[str]: The type of the property or None if not found.
329
+ """
330
+ db_service = await self._get_db_property_service()
331
+ if db_service:
332
+ return await db_service.get_property_type(property_name)
333
+ return None
334
+
335
+ async def get_database_metadata(
336
+ self, include_types: Optional[List[str]] = None
337
+ ) -> Dict[str, Any]:
338
+ """
339
+ Get complete metadata about the database this page belongs to.
340
+
341
+ Args:
342
+ include_types: Optional list of property types to include. If None, all properties are included.
343
+
344
+ Returns:
345
+ Dict[str, Any]: Database metadata or empty dict if not a database page.
346
+ """
347
+ db_service = await self._get_db_property_service()
348
+ if db_service:
349
+ return await db_service.get_database_metadata(include_types)
350
+ return {"properties": {}}
351
+
352
+ async def get_relation_options(
353
+ self, property_name: str, limit: int = 100
354
+ ) -> List[Dict[str, Any]]:
355
+ """
356
+ Return available options for a relation property.
357
+
358
+ Args:
359
+ property_name: The name of the relation property.
360
+ limit: Maximum number of options to return.
361
+
362
+ Returns:
363
+ List[Dict[str, Any]]: List of available relation options.
364
+ """
365
+ return await self._relation_manager.get_relation_options(property_name, limit)
366
+
367
+ async def add_relations_by_name(
368
+ self, relation_property_name: str, page_titles: Union[str, List[str]]
369
+ ) -> Optional[Dict[str, Any]]:
370
+ """
371
+ Add one or more relations to a relation property.
372
+
373
+ Args:
374
+ relation_property_name: The name of the relation property.
375
+ page_titles: One or more page titles to relate to.
376
+
377
+ Returns:
378
+ Optional[Dict[str, Any]]: Response data from the API if successful, None otherwise.
379
+ """
380
+ return await self._relation_manager.add_relation_by_name(
381
+ property_name=relation_property_name, page_titles=page_titles
382
+ )
383
+
384
+ async def get_relation_values(self, property_name: str) -> List[str]:
385
+ """
386
+ Return the current relation values for a property.
387
+
388
+ Args:
389
+ property_name: The name of the relation property.
390
+
391
+ Returns:
392
+ List[str]: List of relation values.
393
+ """
394
+ return await self._relation_manager.get_relation_values(property_name)
395
+
396
+ async def get_relation_property_ids(self) -> List[str]:
397
+ """
398
+ Return a list of all relation property names.
399
+
400
+ Returns:
401
+ List[str]: List of relation property names.
402
+ """
403
+ return await self._relation_manager.get_relation_property_ids()
404
+
405
+ async def get_all_relations(self) -> Dict[str, List[str]]:
406
+ """
407
+ Return all relation properties and their values.
408
+
409
+ Returns:
410
+ Dict[str, List[str]]: Dictionary mapping relation property names to their values.
411
+ """
412
+ return await self._relation_manager.get_all_relations()
413
+
414
+ async def get_last_edited_time(self) -> str:
415
+ """
416
+ Get the timestamp when the page was last edited.
417
+
418
+ Returns:
419
+ str: ISO 8601 formatted timestamp string of when the page was last edited.
420
+ """
421
+ try:
422
+ page_data = await self._client.get_page(self._page_id)
423
+ if "last_edited_time" in page_data:
424
+ return page_data["last_edited_time"]
425
+
426
+ self.logger.warning("last_edited_time not found in page data")
427
+ return ""
428
+
429
+ except Exception as e:
430
+ self.logger.error("Error retrieving last edited time: %s", str(e))
431
+ return ""
432
+
433
+ async def _load_page_title(self) -> str:
434
+ """
435
+ Load the page title from Notion API if not already loaded.
436
+
437
+ Returns:
438
+ str: The page title.
439
+ """
440
+ if self._title is not None:
441
+ return self._title
442
+
443
+ self.logger.debug("Lazy loading page title for page: %s", self._page_id)
444
+ try:
445
+ # Retrieve page data
446
+ page_data = await self._client.get(f"pages/{self._page_id}")
447
+ self._title = self._extract_title_from_page_data(page_data)
448
+ except Exception as e:
449
+ self.logger.error("Error loading page title: %s", str(e))
450
+ self._title = "Untitled"
451
+
452
+ self._title_loaded = True
453
+ self.logger.debug("Loaded page title: %s", self._title)
454
+ return self._title
455
+
456
+ def _extract_title_from_page_data(self, page_data: Dict[str, Any]) -> str:
457
+ """
458
+ Extract title from page data.
459
+
460
+ Args:
461
+ page_data: The page data from Notion API
462
+
463
+ Returns:
464
+ str: The extracted title or "Untitled" if not found
465
+ """
466
+ if "properties" not in page_data:
467
+ return "Untitled"
468
+
469
+ for prop_value in page_data["properties"].values():
470
+ if prop_value.get("type") != "title":
471
+ continue
472
+
473
+ title_array = prop_value.get("title", [])
474
+ if not title_array:
475
+ continue
476
+
477
+ text_parts = []
478
+ for text_obj in title_array:
479
+ if "plain_text" in text_obj:
480
+ text_parts.append(text_obj["plain_text"])
481
+
482
+ return "".join(text_parts) or "Untitled"
483
+
484
+ return "Untitled"
485
+
486
+ async def _build_notion_url(self) -> str:
487
+ """
488
+ Build a Notion URL from the page ID, including the title if available.
489
+
490
+ Returns:
491
+ str: The Notion URL for the page.
492
+ """
493
+ title = await self._load_page_title()
494
+
495
+ url_title = ""
496
+ if title and title != "Untitled":
497
+ url_title = re.sub(r"[^\w\s-]", "", title)
498
+ url_title = re.sub(r"[\s]+", "-", url_title)
499
+ url_title = f"{url_title}-"
500
+
501
+ clean_id = self._page_id.replace("-", "")
502
+
503
+ return f"https://www.notion.so/{url_title}{clean_id}"
504
+
505
+ async def _get_db_property_service(self) -> Optional[DatabasePropertyService]:
506
+ """
507
+ Gets the database property service, initializing it if necessary.
508
+ This is a more intuitive way to work with the instance variable.
509
+
510
+ Returns:
511
+ Optional[DatabasePropertyService]: The database property service or None if not applicable
512
+ """
513
+ if self._db_property_service is not None:
514
+ return self._db_property_service
515
+
516
+ database_id = await self._db_relation.get_parent_database_id()
517
+ if not database_id:
518
+ return None
519
+
520
+ self._db_property_service = DatabasePropertyService(database_id, self._client)
521
+ await self._db_property_service.load_schema()
522
+ return self._db_property_service