notionary 0.2.12__py3-none-any.whl → 0.2.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 (86) hide show
  1. notionary/__init__.py +3 -20
  2. notionary/{notion_client.py → base_notion_client.py} +92 -98
  3. notionary/blocks/__init__.py +61 -0
  4. notionary/{elements → blocks}/audio_element.py +6 -4
  5. notionary/{elements → blocks}/bookmark_element.py +3 -6
  6. notionary/{elements → blocks}/bulleted_list_element.py +5 -7
  7. notionary/{elements → blocks}/callout_element.py +5 -8
  8. notionary/{elements → blocks}/code_block_element.py +4 -6
  9. notionary/{elements → blocks}/column_element.py +3 -6
  10. notionary/{elements → blocks}/divider_element.py +3 -6
  11. notionary/{elements → blocks}/embed_element.py +4 -6
  12. notionary/{elements → blocks}/heading_element.py +5 -9
  13. notionary/{elements → blocks}/image_element.py +4 -6
  14. notionary/{elements → blocks}/mention_element.py +3 -7
  15. notionary/blocks/notion_block_client.py +26 -0
  16. notionary/blocks/notion_block_element.py +34 -0
  17. notionary/{elements → blocks}/numbered_list_element.py +4 -7
  18. notionary/{elements → blocks}/paragraph_element.py +4 -7
  19. notionary/{prompting/element_prompt_content.py → blocks/prompts/element_prompt_builder.py} +1 -40
  20. notionary/blocks/prompts/element_prompt_content.py +41 -0
  21. notionary/{elements → blocks}/qoute_element.py +4 -6
  22. notionary/{elements → blocks}/registry/block_registry.py +4 -26
  23. notionary/{elements → blocks}/registry/block_registry_builder.py +26 -25
  24. notionary/{elements → blocks}/table_element.py +6 -8
  25. notionary/{elements → blocks}/text_inline_formatter.py +1 -4
  26. notionary/{elements → blocks}/todo_element.py +6 -8
  27. notionary/{elements → blocks}/toggle_element.py +3 -6
  28. notionary/{elements → blocks}/toggleable_heading_element.py +5 -8
  29. notionary/{elements → blocks}/video_element.py +4 -6
  30. notionary/cli/main.py +245 -53
  31. notionary/cli/onboarding.py +117 -0
  32. notionary/database/__init__.py +0 -0
  33. notionary/database/client.py +132 -0
  34. notionary/database/database_exceptions.py +13 -0
  35. notionary/database/factory.py +0 -0
  36. notionary/database/filter_builder.py +175 -0
  37. notionary/database/notion_database.py +339 -128
  38. notionary/database/notion_database_provider.py +230 -0
  39. notionary/elements/__init__.py +0 -0
  40. notionary/models/notion_database_response.py +294 -13
  41. notionary/models/notion_page_response.py +9 -31
  42. notionary/models/search_response.py +0 -0
  43. notionary/page/__init__.py +0 -0
  44. notionary/page/client.py +110 -0
  45. notionary/page/content/page_content_retriever.py +5 -20
  46. notionary/page/content/page_content_writer.py +3 -4
  47. notionary/page/formatting/markdown_to_notion_converter.py +1 -3
  48. notionary/{prompting → page}/markdown_syntax_prompt_generator.py +1 -2
  49. notionary/page/notion_page.py +354 -317
  50. notionary/page/notion_to_markdown_converter.py +1 -4
  51. notionary/page/properites/property_value_extractor.py +0 -64
  52. notionary/page/{properites/property_formatter.py → property_formatter.py} +7 -4
  53. notionary/page/search_filter_builder.py +131 -0
  54. notionary/page/utils.py +60 -0
  55. notionary/util/__init__.py +12 -3
  56. notionary/util/factory_decorator.py +33 -0
  57. notionary/util/fuzzy_matcher.py +82 -0
  58. notionary/util/page_id_utils.py +0 -21
  59. notionary/util/singleton_metaclass.py +22 -0
  60. notionary/workspace.py +69 -0
  61. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/METADATA +4 -1
  62. notionary-0.2.14.dist-info/RECORD +72 -0
  63. notionary/database/database_discovery.py +0 -142
  64. notionary/database/notion_database_factory.py +0 -193
  65. notionary/elements/notion_block_element.py +0 -70
  66. notionary/exceptions/database_exceptions.py +0 -76
  67. notionary/exceptions/page_creation_exception.py +0 -9
  68. notionary/page/metadata/metadata_editor.py +0 -150
  69. notionary/page/metadata/notion_icon_manager.py +0 -77
  70. notionary/page/metadata/notion_page_cover_manager.py +0 -56
  71. notionary/page/notion_page_factory.py +0 -332
  72. notionary/page/properites/database_property_service.py +0 -302
  73. notionary/page/properites/page_property_manager.py +0 -152
  74. notionary/page/relations/notion_page_relation_manager.py +0 -350
  75. notionary/page/relations/notion_page_title_resolver.py +0 -104
  76. notionary/page/relations/page_database_relation.py +0 -68
  77. notionary/telemetry/__init__.py +0 -7
  78. notionary/telemetry/telemetry.py +0 -226
  79. notionary/telemetry/track_usage_decorator.py +0 -76
  80. notionary/util/warn_direct_constructor_usage.py +0 -54
  81. notionary-0.2.12.dist-info/RECORD +0 -70
  82. /notionary/util/{singleton.py → singleton_decorator.py} +0 -0
  83. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/WHEEL +0 -0
  84. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/entry_points.txt +0 -0
  85. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/licenses/LICENSE +0 -0
  86. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/top_level.txt +0 -0
@@ -1,12 +1,19 @@
1
1
  from __future__ import annotations
2
+ import random
2
3
  from typing import Any, AsyncGenerator, Dict, List, Optional
3
4
 
4
- from notionary.notion_client import NotionClient
5
+ from notionary.database.client import NotionDatabaseClient
6
+ from notionary.models.notion_database_response import (
7
+ NotionDatabaseResponse,
8
+ NotionPageResponse,
9
+ NotionQueryDatabaseResponse,
10
+ )
5
11
  from notionary.page.notion_page import NotionPage
6
- from notionary.telemetry import NotionaryTelemetry
7
- from notionary.util.warn_direct_constructor_usage import warn_direct_constructor_usage
8
- from notionary.util import LoggingMixin
9
- from notionary.util.page_id_utils import format_uuid
12
+
13
+ from notionary.database.notion_database_provider import NotionDatabaseProvider
14
+
15
+ from notionary.database.filter_builder import FilterBuilder
16
+ from notionary.util import factory_only, LoggingMixin
10
17
 
11
18
 
12
19
  class NotionDatabase(LoggingMixin):
@@ -16,138 +23,312 @@ class NotionDatabase(LoggingMixin):
16
23
  for further page operations.
17
24
  """
18
25
 
19
- @warn_direct_constructor_usage
20
- def __init__(self, database_id: str, token: Optional[str] = None):
26
+ @factory_only("from_database_id", "from_database_name")
27
+ def __init__(
28
+ self,
29
+ database_id: str,
30
+ title: str,
31
+ url: str,
32
+ emoji_icon: Optional[str] = None,
33
+ properties: Optional[Dict[str, Any]] = None,
34
+ token: Optional[str] = None,
35
+ ):
21
36
  """
22
37
  Initialize the minimal database manager.
23
-
24
- Args:
25
- database_id: ID of the Notion database
26
- token: Optional Notion API token
27
38
  """
28
- self.database_id = database_id
29
- self._telemetry = NotionaryTelemetry()
30
- self._client = NotionClient(token=token)
39
+ self._database_id = database_id
40
+ self._title = title
41
+ self._url = url
42
+ self._emoji_icon = emoji_icon
43
+ self._properties = properties
44
+
45
+ self.client = NotionDatabaseClient(token=token)
31
46
 
32
47
  @classmethod
33
- def from_database_id(
48
+ async def from_database_id(
34
49
  cls, database_id: str, token: Optional[str] = None
35
50
  ) -> NotionDatabase:
36
51
  """
37
- Create a NotionDatabase from a database ID.
38
- Delegates to NotionDatabaseFactory.
52
+ Create a NotionDatabase from a database ID using NotionDatabaseProvider.
39
53
  """
40
- from notionary.database.notion_database_factory import NotionDatabaseFactory
41
-
42
- return NotionDatabaseFactory.from_database_id(database_id, token)
54
+ provider = cls.get_database_provider()
55
+ return await provider.get_database_by_id(database_id, token)
43
56
 
44
57
  @classmethod
45
58
  async def from_database_name(
46
- cls, database_name: str, token: Optional[str] = None
59
+ cls,
60
+ database_name: str,
61
+ token: Optional[str] = None,
62
+ min_similarity: float = 0.6,
47
63
  ) -> NotionDatabase:
48
64
  """
49
- Create a NotionDatabase by finding a database with a matching name.
50
- Delegates to NotionDatabaseFactory.
65
+ Create a NotionDatabase by finding a database with fuzzy matching on the title using NotionDatabaseProvider.
51
66
  """
52
- from notionary.database.notion_database_factory import NotionDatabaseFactory
67
+ provider = cls.get_database_provider()
68
+ return await provider.get_database_by_name(database_name, token, min_similarity)
69
+
70
+ @property
71
+ def database_id(self) -> str:
72
+ """Get the database ID (readonly)."""
73
+ return self._database_id
74
+
75
+ @property
76
+ def title(self) -> str:
77
+ """Get the database title (readonly)."""
78
+ return self._title
79
+
80
+ @property
81
+ def url(self) -> str:
82
+ """Get the database URL (readonly)."""
83
+ return self._url
84
+
85
+ @property
86
+ def emoji(self) -> Optional[str]:
87
+ """Get the database emoji (readonly)."""
88
+ return self._emoji_icon
89
+
90
+ @property
91
+ def properties(self) -> Optional[Dict[str, Any]]:
92
+ """Get the database properties (readonly)."""
93
+ return self._properties
94
+
95
+ # Database Provider is a singleton so we can instantiate it here with no worries
96
+ @property
97
+ def database_provider(self):
98
+ """Return a NotionDatabaseProvider instance for this database."""
99
+ return NotionDatabaseProvider.get_instance()
53
100
 
54
- return await NotionDatabaseFactory.from_database_name(database_name, token)
101
+ @classmethod
102
+ def get_database_provider(cls):
103
+ """Return a NotionDatabaseProvider instance for class-level usage."""
104
+ return NotionDatabaseProvider.get_instance()
55
105
 
56
106
  async def create_blank_page(self) -> Optional[NotionPage]:
57
107
  """
58
108
  Create a new blank page in the database with minimal properties.
109
+ """
110
+ try:
111
+ create_page_response: NotionPageResponse = await self.client.create_page(
112
+ parent_database_id=self.database_id
113
+ )
59
114
 
60
- Returns:
61
- NotionPage for the created page, or None if creation failed
115
+ return await NotionPage.from_page_id(page_id=create_page_response.id)
116
+
117
+ except Exception as e:
118
+ self.logger.error("Error creating blank page: %s", str(e))
119
+ return None
120
+
121
+ async def set_title(self, new_title: str) -> bool:
122
+ """
123
+ Update the database title.
62
124
  """
63
125
  try:
64
- response = await self._client.post(
65
- "pages", {"parent": {"database_id": self.database_id}, "properties": {}}
126
+ result = await self.client.update_database_title(
127
+ database_id=self.database_id, title=new_title
66
128
  )
67
129
 
68
- if response and "id" in response:
69
- page_id = response["id"]
70
- self.logger.info(
71
- "Created blank page %s in database %s", page_id, self.database_id
72
- )
130
+ self._title = result.title[0].plain_text
131
+ self.logger.info(f"Successfully updated database title to: {new_title}")
132
+ self.database_provider.invalidate_database_cache(
133
+ database_id=self.database_id
134
+ )
135
+ return True
136
+
137
+ except Exception as e:
138
+ self.logger.error(f"Error updating database title: {str(e)}")
139
+ return False
140
+
141
+ async def set_emoji(self, new_emoji: str) -> bool:
142
+ """
143
+ Update the database emoji.
144
+ """
145
+ try:
146
+ result = await self.client.update_database_emoji(
147
+ database_id=self.database_id, emoji=new_emoji
148
+ )
73
149
 
74
- return NotionPage.from_page_id(
75
- page_id=page_id, token=self._client.token
150
+ self._emoji_icon = result.icon.emoji if result.icon else None
151
+ self.logger.info(f"Successfully updated database emoji to: {new_emoji}")
152
+ self.database_provider.invalidate_database_cache(
153
+ database_id=self.database_id
154
+ )
155
+ return True
156
+
157
+ except Exception as e:
158
+ self.logger.error(f"Error updating database emoji: {str(e)}")
159
+ return False
160
+
161
+ async def set_cover_image(self, image_url: str) -> Optional[str]:
162
+ """
163
+ Update the database cover image.
164
+ """
165
+ try:
166
+ result = await self.client.update_database_cover_image(
167
+ database_id=self.database_id, image_url=image_url
168
+ )
169
+
170
+ if result.cover and result.cover.external:
171
+ self.database_provider.invalidate_database_cache(
172
+ database_id=self.database_id
76
173
  )
174
+ return result.cover.external.url
175
+ return None
176
+
177
+ except Exception as e:
178
+ self.logger.error(f"Error updating database cover image: {str(e)}")
179
+ return None
180
+
181
+ async def set_random_gradient_cover(self) -> Optional[str]:
182
+ """Sets a random gradient cover from Notion's default gradient covers (always jpg)."""
183
+ default_notion_covers = [
184
+ f"https://www.notion.so/images/page-cover/gradients_{i}.png"
185
+ for i in range(1, 10)
186
+ ]
187
+ random_cover_url = random.choice(default_notion_covers)
188
+ return await self.set_cover_image(random_cover_url)
189
+
190
+ async def set_external_icon(self, external_icon_url: str) -> Optional[str]:
191
+ """
192
+ Update the database icon with an external image URL.
193
+ """
194
+ try:
195
+ result = await self.client.update_database_external_icon(
196
+ database_id=self.database_id, icon_url=external_icon_url
197
+ )
77
198
 
78
- self.logger.warning("Page creation failed: invalid response")
199
+ if result.icon and result.icon.external:
200
+ self.database_provider.invalidate_database_cache(
201
+ database_id=self.database_id
202
+ )
203
+ return result.icon.external.url
79
204
  return None
80
205
 
81
206
  except Exception as e:
82
- self.logger.error("Error creating blank page: %s", str(e))
207
+ self.logger.error(f"Error updating database external icon: {str(e)}")
83
208
  return None
84
209
 
85
- async def get_pages(
86
- self,
87
- limit: int = 100,
88
- filter_conditions: Optional[Dict[str, Any]] = None,
89
- sorts: Optional[List[Dict[str, Any]]] = None,
90
- ) -> List[NotionPage]:
210
+ async def get_options_by_property_name(self, property_name: str) -> List[str]:
91
211
  """
92
- Get all pages from the database.
212
+ Retrieve all option names for a select, multi_select, status, or relation property.
93
213
 
94
214
  Args:
95
- limit: Maximum number of pages to retrieve
96
- filter_conditions: Optional filter to apply to the database query
97
- sorts: Optional sort instructions for the database query
215
+ property_name: The name of the property in the database schema.
98
216
 
99
217
  Returns:
100
- List of NotionPage instances for each page
218
+ A list of option names for the given property. For select, multi_select, or status,
219
+ returns the option names directly. For relation properties, returns the titles of related pages.
101
220
  """
102
- self.logger.debug(
103
- "Getting up to %d pages with filter: %s, sorts: %s",
104
- limit,
105
- filter_conditions,
106
- sorts,
221
+ property_schema = self.properties.get(property_name)
222
+
223
+ property_type = property_schema.get("type")
224
+
225
+ if property_type in ["select", "multi_select", "status"]:
226
+ options = property_schema.get(property_type, {}).get("options", [])
227
+ return [option.get("name", "") for option in options]
228
+
229
+ if property_type == "relation":
230
+ return await self._get_relation_options(property_name)
231
+
232
+ return []
233
+
234
+ def get_property_type(self, property_name: str) -> Optional[str]:
235
+ """
236
+ Get the type of a property by its name.
237
+ """
238
+ property_schema = self.properties.get(property_name)
239
+ return property_schema.get("type") if property_schema else None
240
+
241
+ async def query_database_by_title(self, page_title: str) -> List[NotionPage]:
242
+ """
243
+ Query the database for pages with a specific title.
244
+ """
245
+ search_results: NotionQueryDatabaseResponse = (
246
+ await self.client.query_database_by_title(
247
+ database_id=self.database_id, page_title=page_title
248
+ )
107
249
  )
108
250
 
109
- pages: List[NotionPage] = []
110
- count = 0
251
+ page_results: List[NotionPage] = []
252
+
253
+ for page in search_results.results:
254
+ page = NotionPage.from_page_id(page_id=page.id, token=self.client.token)
255
+ page_results.append(page)
256
+
257
+ return page_results
258
+
259
+ async def iter_pages_updated_within(
260
+ self, hours: int = 24, page_size: int = 100
261
+ ) -> AsyncGenerator[NotionPage, None]:
262
+ """
263
+ Iterate through pages edited in the last N hours using FilterBuilder.
264
+ """
111
265
 
112
- async for page in self.iter_pages(
113
- page_size=min(limit, 100),
114
- filter_conditions=filter_conditions,
115
- sorts=sorts,
266
+ filter_builder = FilterBuilder()
267
+ filter_builder.with_updated_last_n_hours(hours).with_page_size(page_size)
268
+ filter_conditions = filter_builder.build()
269
+
270
+ async for page in self._iter_pages(
271
+ page_size=page_size, filter_conditions=filter_conditions
116
272
  ):
273
+ yield page
274
+
275
+ async def get_all_pages(self) -> List[NotionPage]:
276
+ """
277
+ Get all pages in the database (use with caution for large databases).
278
+ """
279
+ pages = []
280
+ async for page in self._iter_pages():
117
281
  pages.append(page)
118
- count += 1
282
+ return pages
119
283
 
120
- if count >= limit:
121
- break
284
+ async def get_last_edited_time(self) -> Optional[str]:
285
+ """
286
+ Retrieve the last edited time of the database.
122
287
 
123
- self.logger.debug(
124
- "Retrieved %d pages from database %s", len(pages), self.database_id
125
- )
126
- return pages
288
+ Returns:
289
+ ISO 8601 timestamp string of the last database edit, or None if request fails.
290
+ """
291
+ try:
292
+ db = await self.client.get_database(self.database_id)
293
+
294
+ return db.last_edited_time
295
+
296
+ except Exception as e:
297
+ self.logger.error(
298
+ "Error fetching last_edited_time for database %s: %s",
299
+ self.database_id,
300
+ str(e),
301
+ )
302
+ return None
303
+
304
+ def create_filter(self) -> FilterBuilder:
305
+ """Create a new filter builder for this database."""
306
+ return FilterBuilder()
307
+
308
+ async def iter_pages_with_filter(
309
+ self, filter_builder: FilterBuilder, page_size: int = 100
310
+ ):
311
+ """Iterate pages using a filter builder."""
312
+ filter_config = filter_builder.build()
313
+ self.logger.debug("Using filter: %s", filter_config)
314
+ async for page in self._iter_pages(
315
+ page_size=page_size, filter_conditions=filter_config
316
+ ):
317
+ yield page
127
318
 
128
- async def iter_pages(
319
+ async def _iter_pages(
129
320
  self,
130
321
  page_size: int = 100,
131
322
  filter_conditions: Optional[Dict[str, Any]] = None,
132
- sorts: Optional[List[Dict[str, Any]]] = None,
133
323
  ) -> AsyncGenerator[NotionPage, None]:
134
324
  """
135
325
  Asynchronous generator that yields pages from the database.
136
326
  Directly queries the Notion API without using the schema.
137
-
138
- Args:
139
- page_size: Number of pages to fetch per request
140
- filter_conditions: Optional filter to apply to the database query
141
- sorts: Optional sort instructions for the database query
142
-
143
- Yields:
144
- NotionPage instances for each page
145
327
  """
146
328
  self.logger.debug(
147
- "Iterating pages with page_size: %d, filter: %s, sorts: %s",
329
+ "Iterating pages with page_size: %d, filter: %s",
148
330
  page_size,
149
331
  filter_conditions,
150
- sorts,
151
332
  )
152
333
 
153
334
  start_cursor: Optional[str] = None
@@ -158,79 +339,109 @@ class NotionDatabase(LoggingMixin):
158
339
  if filter_conditions:
159
340
  body["filter"] = filter_conditions
160
341
 
161
- if sorts:
162
- body["sorts"] = sorts
163
-
164
342
  while has_more:
165
343
  current_body = body.copy()
166
344
  if start_cursor:
167
345
  current_body["start_cursor"] = start_cursor
168
346
 
169
- result = await self._client.post(
170
- f"databases/{self.database_id}/query", data=current_body
347
+ result = await self.client.query_database(
348
+ database_id=self.database_id, query_data=current_body
171
349
  )
172
350
 
173
- if not result or "results" not in result:
351
+ if not result or not result.results:
174
352
  return
175
353
 
176
- for page in result["results"]:
177
- page_id: str = page.get("id", "")
178
-
179
- yield NotionPage.from_page_id(page_id=page_id, token=self._client.token)
354
+ for page in result.results:
355
+ yield await NotionPage.from_page_id(page_id=page.id, token=self.client.token)
180
356
 
181
- has_more = result.get("has_more", False)
182
- start_cursor = result.get("next_cursor") if has_more else None
357
+ has_more = result.has_more
358
+ start_cursor = result.next_cursor if has_more else None
183
359
 
184
- async def archive_page(self, page_id: str) -> bool:
360
+ @classmethod
361
+ def _create_from_response(
362
+ cls, db_response: NotionDatabaseResponse, token: Optional[str]
363
+ ) -> NotionDatabase:
185
364
  """
186
- Delete (archive) a page.
365
+ Create NotionDatabase instance from API response.
366
+ """
367
+ title = cls._extract_title(db_response)
368
+ emoji_icon = cls._extract_emoji_icon(db_response)
369
+
370
+ instance = cls(
371
+ database_id=db_response.id,
372
+ title=title,
373
+ url=db_response.url,
374
+ emoji_icon=emoji_icon,
375
+ properties=db_response.properties,
376
+ token=token,
377
+ )
187
378
 
188
- Args:
189
- page_id: The ID of the page to delete
379
+ cls.logger.info(
380
+ "Created database manager: '%s' (ID: %s)", title, db_response.id
381
+ )
190
382
 
191
- Returns:
192
- bool: True if successful, False otherwise
193
- """
194
- try:
195
- formatted_page_id = format_uuid(page_id)
383
+ return instance
196
384
 
197
- data = {"archived": True}
385
+ @staticmethod
386
+ def _extract_title(db_response: NotionDatabaseResponse) -> str:
387
+ """Extract title from database response."""
388
+ if db_response.title and len(db_response.title) > 0:
389
+ return db_response.title[0].plain_text
390
+ return "Untitled Database"
198
391
 
199
- result = await self._client.patch_page(formatted_page_id, data)
392
+ @staticmethod
393
+ def _extract_emoji_icon(db_response: NotionDatabaseResponse) -> Optional[str]:
394
+ """Extract emoji from database response."""
395
+ if not db_response.icon:
396
+ return None
200
397
 
201
- if not result:
202
- self.logger.error("Error deleting page %s", formatted_page_id)
203
- return False
398
+ if db_response.icon.type == "emoji":
399
+ return db_response.icon.emoji
204
400
 
205
- self.logger.info(
206
- "Page %s successfully deleted (archived)", formatted_page_id
207
- )
208
- return True
401
+ return None
209
402
 
210
- except Exception as e:
211
- self.logger.error("Error in archive_page: %s", str(e))
212
- return False
403
+ def _extract_title_from_page(self, page: NotionPageResponse) -> Optional[str]:
404
+ """
405
+ Extracts the title from a NotionPageResponse object.
406
+ """
407
+ if not page.properties:
408
+ return None
213
409
 
214
- async def get_last_edited_time(self) -> Optional[str]:
410
+ title_property = next(
411
+ (
412
+ prop
413
+ for prop in page.properties.values()
414
+ if isinstance(prop, dict) and prop.get("type") == "title"
415
+ ),
416
+ None,
417
+ )
418
+
419
+ if not title_property or "title" not in title_property:
420
+ return None
421
+
422
+ try:
423
+ title_parts = title_property["title"]
424
+ return "".join(part.get("plain_text", "") for part in title_parts)
425
+
426
+ except (KeyError, TypeError, AttributeError):
427
+ return None
428
+
429
+ async def _get_relation_options(self, property_name: str) -> List[Dict[str, Any]]:
215
430
  """
216
- Retrieve the last edited time of the database.
431
+ Retrieve the titles of all pages related to a relation property.
432
+
433
+ Args:
434
+ property_name: The name of the relation property in the database schema.
217
435
 
218
436
  Returns:
219
- ISO 8601 timestamp string of the last database edit, or None if request fails.
437
+ A list of titles for all related pages. Returns an empty list if no related pages are found.
220
438
  """
221
- try:
222
- db = await self._client.get_database(self.database_id)
439
+ property_schema = self.properties.get(property_name)
223
440
 
224
- return db.last_edited_time
441
+ relation_database_id = property_schema.get("relation", {}).get("database_id")
225
442
 
226
- except Exception as e:
227
- self.logger.error(
228
- "Error fetching last_edited_time for database %s: %s",
229
- self.database_id,
230
- str(e),
231
- )
232
- return None
443
+ search_results = await self.client.query_database(
444
+ database_id=relation_database_id
445
+ )
233
446
 
234
- async def close(self) -> None:
235
- """Close the client connection."""
236
- await self._client.close()
447
+ return [self._extract_title_from_page(page) for page in search_results.results]