notionary 0.2.13__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 (82) hide show
  1. notionary/__init__.py +3 -16
  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 -3
  5. notionary/{elements → blocks}/bookmark_element.py +3 -5
  6. notionary/{elements → blocks}/bulleted_list_element.py +5 -6
  7. notionary/{elements → blocks}/callout_element.py +4 -6
  8. notionary/{elements → blocks}/code_block_element.py +4 -5
  9. notionary/{elements → blocks}/column_element.py +3 -5
  10. notionary/{elements → blocks}/divider_element.py +3 -5
  11. notionary/{elements → blocks}/embed_element.py +4 -5
  12. notionary/{elements → blocks}/heading_element.py +4 -7
  13. notionary/{elements → blocks}/image_element.py +4 -5
  14. notionary/{elements → blocks}/mention_element.py +3 -6
  15. notionary/blocks/notion_block_client.py +26 -0
  16. notionary/{elements → blocks}/notion_block_element.py +2 -3
  17. notionary/{elements → blocks}/numbered_list_element.py +4 -6
  18. notionary/{elements → blocks}/paragraph_element.py +4 -6
  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 -5
  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 +5 -6
  25. notionary/{elements → blocks}/text_inline_formatter.py +1 -4
  26. notionary/{elements → blocks}/todo_element.py +5 -6
  27. notionary/{elements → blocks}/toggle_element.py +3 -5
  28. notionary/{elements → blocks}/toggleable_heading_element.py +4 -6
  29. notionary/{elements → blocks}/video_element.py +4 -5
  30. notionary/cli/main.py +157 -128
  31. notionary/cli/onboarding.py +10 -9
  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 -126
  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.13.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 -190
  65. notionary/exceptions/database_exceptions.py +0 -76
  66. notionary/exceptions/page_creation_exception.py +0 -9
  67. notionary/page/metadata/metadata_editor.py +0 -150
  68. notionary/page/metadata/notion_icon_manager.py +0 -77
  69. notionary/page/metadata/notion_page_cover_manager.py +0 -56
  70. notionary/page/notion_page_factory.py +0 -328
  71. notionary/page/properites/database_property_service.py +0 -302
  72. notionary/page/properites/page_property_manager.py +0 -152
  73. notionary/page/relations/notion_page_relation_manager.py +0 -350
  74. notionary/page/relations/notion_page_title_resolver.py +0 -104
  75. notionary/page/relations/page_database_relation.py +0 -68
  76. notionary/util/warn_direct_constructor_usage.py +0 -54
  77. notionary-0.2.13.dist-info/RECORD +0 -67
  78. /notionary/util/{singleton.py → singleton_decorator.py} +0 -0
  79. {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/WHEEL +0 -0
  80. {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/entry_points.txt +0 -0
  81. {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/licenses/LICENSE +0 -0
  82. {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/top_level.txt +0 -0
@@ -1,56 +0,0 @@
1
- import random
2
- from typing import Any, Dict, Optional
3
- from notionary.notion_client import NotionClient
4
- from notionary.util import LoggingMixin
5
-
6
-
7
- class NotionPageCoverManager(LoggingMixin):
8
- def __init__(self, page_id: str, client: NotionClient):
9
- self.page_id = page_id
10
- self._client = client
11
-
12
- async def set_cover(self, external_url: str) -> Optional[str]:
13
- """
14
- Sets a cover image from an external URL and returns the new URL if successful.
15
-
16
- Args:
17
- external_url: The URL to be set as the external cover image.
18
-
19
- Returns:
20
- The URL of the new cover image, or None if the request failed.
21
- """
22
- data = {"cover": {"type": "external", "external": {"url": external_url}}}
23
-
24
- try:
25
- updated_page = await self._client.patch_page(self.page_id, data=data)
26
- return updated_page.cover.external.url
27
- except Exception as e:
28
- self.logger.error("Failed to set cover image: %s", str(e))
29
- return None
30
-
31
- async def set_random_gradient_cover(self) -> Optional[Dict[str, Any]]:
32
- """Sets a random gradient cover from Notion's default gradient covers."""
33
- default_notion_covers = [
34
- "https://www.notion.so/images/page-cover/gradients_8.png",
35
- "https://www.notion.so/images/page-cover/gradients_2.png",
36
- "https://www.notion.so/images/page-cover/gradients_11.jpg",
37
- "https://www.notion.so/images/page-cover/gradients_10.jpg",
38
- "https://www.notion.so/images/page-cover/gradients_5.png",
39
- "https://www.notion.so/images/page-cover/gradients_3.png",
40
- ]
41
-
42
- random_cover_url = random.choice(default_notion_covers)
43
-
44
- return await self.set_cover(random_cover_url)
45
-
46
- async def get_cover_url(self) -> Optional[str]:
47
- """Retrieves the current cover image URL of the page."""
48
- page_data = await self._client.get_page(self.page_id)
49
-
50
- if not page_data or not page_data.cover:
51
- return None
52
-
53
- if page_data.cover.type == "external":
54
- return page_data.cover.external.url
55
-
56
- return None
@@ -1,328 +0,0 @@
1
- from typing import List, Optional, Dict, Any, Tuple
2
- from difflib import SequenceMatcher
3
-
4
- from notionary import NotionPage, NotionClient
5
- from notionary.util import LoggingMixin
6
- from notionary.util import format_uuid, extract_and_validate_page_id
7
- from notionary.util import singleton
8
-
9
- @singleton
10
- class NotionPageFactory(LoggingMixin):
11
- """
12
- Factory class for creating NotionPage instances.
13
- Provides methods for creating page instances by page ID, URL, or name.
14
- """
15
-
16
- MATCH_THRESHOLD = 0.6
17
- MAX_SUGGESTIONS = 5
18
- PAGE_SIZE = 100
19
- EARLY_STOP_THRESHOLD = 0.95
20
-
21
- @classmethod
22
- def from_page_id(cls, page_id: str, token: Optional[str] = None) -> NotionPage:
23
- """Create a NotionPage from a page ID."""
24
-
25
- try:
26
- formatted_id = format_uuid(page_id) or page_id
27
- page = NotionPage(page_id=formatted_id, token=token)
28
- cls.logger.info(
29
- "Successfully created page instance for ID: %s", formatted_id
30
- )
31
- return page
32
- except Exception as e:
33
- cls.logger.error("Error connecting to page %s: %s", page_id, str(e))
34
- raise
35
-
36
- @classmethod
37
- def from_url(cls, url: str, token: Optional[str] = None) -> NotionPage:
38
- """Create a NotionPage from a Notion URL."""
39
-
40
- try:
41
- page_id = extract_and_validate_page_id(url=url)
42
- if not page_id:
43
- cls.logger.error("Could not extract valid page ID from URL: %s", url)
44
- raise ValueError(f"Invalid URL: {url}")
45
-
46
- page = NotionPage(page_id=page_id, url=url, token=token)
47
- cls.logger.info(
48
- "Successfully created page instance from URL for ID: %s", page_id
49
- )
50
- return page
51
- except Exception as e:
52
- cls.logger.error("Error connecting to page with URL %s: %s", url, str(e))
53
- raise
54
-
55
- @classmethod
56
- async def from_page_name(
57
- cls, page_name: str, token: Optional[str] = None
58
- ) -> NotionPage:
59
- """Create a NotionPage by finding a page with a matching name using fuzzy matching."""
60
- cls.logger.debug("Searching for page with name: %s", page_name)
61
-
62
- client = NotionClient(token=token)
63
-
64
- try:
65
- # Search with pagination and early stopping
66
- best_match, best_score, all_suggestions = (
67
- await cls._search_pages_with_matching(client, page_name)
68
- )
69
-
70
- # Check if match is good enough
71
- if best_score < cls.MATCH_THRESHOLD or not best_match:
72
- suggestion_msg = cls._format_suggestions(all_suggestions)
73
- cls.logger.warning(
74
- "No good match found for '%s'. Best score: %.2f",
75
- page_name,
76
- best_score,
77
- )
78
- raise ValueError(
79
- f"No good match found for '{page_name}'. {suggestion_msg}"
80
- )
81
-
82
- # Create page from best match
83
- page_id = best_match.get("id")
84
- if not page_id:
85
- cls.logger.error("Best match page has no ID")
86
- raise ValueError("Best match page has no ID")
87
-
88
- matched_name = cls._extract_title_from_page(best_match)
89
- cls.logger.info(
90
- "Found matching page: '%s' (ID: %s) with score: %.2f",
91
- matched_name,
92
- page_id,
93
- best_score,
94
- )
95
-
96
- page = NotionPage.from_page_id(page_id=page_id, token=token)
97
- cls.logger.info("Successfully created page instance for '%s'", matched_name)
98
-
99
- await client.close()
100
- return page
101
-
102
- except Exception as e:
103
- cls.logger.error("Error finding page by name: %s", str(e))
104
- await client.close()
105
- raise
106
-
107
- @classmethod
108
- async def _search_pages_with_matching(
109
- cls, client: NotionClient, query: str
110
- ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
111
- """
112
- Search for pages with pagination and find the best match.
113
- Includes early stopping for performance optimization.
114
- """
115
- cls.logger.debug("Starting paginated search for query: %s", query)
116
-
117
- best_match = None
118
- best_score = 0
119
- all_suggestions = []
120
- page_count = 0
121
-
122
- # Track suggestions across all pages
123
- all_matches = []
124
-
125
- next_cursor = None
126
-
127
- while True:
128
- # Fetch current page batch
129
- pages_batch = await cls._fetch_pages_batch(client, next_cursor)
130
-
131
- if not pages_batch:
132
- cls.logger.debug("No more pages to fetch")
133
- break
134
-
135
- pages = pages_batch.get("results", [])
136
- page_count += len(pages)
137
- cls.logger.debug(
138
- "Processing batch of %d pages (total processed: %d)",
139
- len(pages),
140
- page_count,
141
- )
142
-
143
- # Process current batch
144
- batch_match, batch_score, batch_suggestions = cls._find_best_match_in_batch(
145
- pages, query, best_score
146
- )
147
-
148
- # Update global best if we found a better match
149
- if batch_score > best_score:
150
- best_score = batch_score
151
- best_match = batch_match
152
- cls.logger.debug("New best match found with score: %.2f", best_score)
153
-
154
- # Collect all matches for suggestions
155
- for page in pages:
156
- title = cls._extract_title_from_page(page)
157
- score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
158
- all_matches.append((title, score))
159
-
160
- # Early stopping: if we found a very good match, stop searching
161
- if best_score >= cls.EARLY_STOP_THRESHOLD:
162
- cls.logger.info(
163
- "Early stopping: found excellent match with score %.2f", best_score
164
- )
165
- break
166
-
167
- # Check for next page
168
- next_cursor = pages_batch.get("next_cursor")
169
- if not next_cursor:
170
- cls.logger.debug("Reached end of pages")
171
- break
172
-
173
- # Generate final suggestions from all matches
174
- all_matches.sort(key=lambda x: x[1], reverse=True)
175
- all_suggestions = [title for title, _ in all_matches[: cls.MAX_SUGGESTIONS]]
176
-
177
- cls.logger.info(
178
- "Search completed. Processed %d pages. Best score: %.2f",
179
- page_count,
180
- best_score,
181
- )
182
-
183
- return best_match, best_score, all_suggestions
184
-
185
- @classmethod
186
- async def _fetch_pages_batch(
187
- cls, client: NotionClient, next_cursor: Optional[str] = None
188
- ) -> Dict[str, Any]:
189
- """Fetch a single batch of pages from the Notion API."""
190
- search_payload = {
191
- "filter": {"property": "object", "value": "page"},
192
- "page_size": cls.PAGE_SIZE,
193
- }
194
-
195
- if next_cursor:
196
- search_payload["start_cursor"] = next_cursor
197
-
198
- try:
199
- response = await client.post("search", search_payload)
200
-
201
- if not response:
202
- cls.logger.error("Empty response from search endpoint")
203
- return {}
204
-
205
- return response
206
-
207
- except Exception as e:
208
- cls.logger.error("Error fetching pages batch: %s", str(e))
209
- raise
210
-
211
- @classmethod
212
- def _find_best_match_in_batch(
213
- cls, pages: List[Dict[str, Any]], query: str, current_best_score: float
214
- ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
215
- """Find the best matching page in a single batch."""
216
- batch_best_match = None
217
- batch_best_score = current_best_score
218
-
219
- for page in pages:
220
- title = cls._extract_title_from_page(page)
221
- score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
222
-
223
- if score > batch_best_score:
224
- batch_best_score = score
225
- batch_best_match = page
226
-
227
- # Get batch suggestions (not used in the main algorithm but kept for compatibility)
228
- batch_suggestions = []
229
-
230
- return batch_best_match, batch_best_score, batch_suggestions
231
-
232
- @classmethod
233
- async def _search_pages(cls, client: NotionClient) -> List[Dict[str, Any]]:
234
- """
235
- Legacy method - kept for backward compatibility.
236
- Now uses the paginated approach internally.
237
- """
238
- cls.logger.warning(
239
- "_search_pages is deprecated. Use _search_pages_with_matching instead."
240
- )
241
-
242
- all_pages = []
243
- next_cursor = None
244
-
245
- while True:
246
- batch = await cls._fetch_pages_batch(client, next_cursor)
247
- if not batch:
248
- break
249
-
250
- pages = batch.get("results", [])
251
- all_pages.extend(pages)
252
-
253
- next_cursor = batch.get("next_cursor")
254
- if not next_cursor:
255
- break
256
-
257
- cls.logger.info("Loaded %d total pages", len(all_pages))
258
- return all_pages
259
-
260
- @classmethod
261
- def _find_best_match(
262
- cls, pages: List[Dict[str, Any]], query: str
263
- ) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
264
- """Find the best matching page for the given query."""
265
- cls.logger.debug("Found %d pages, searching for best match", len(pages))
266
-
267
- matches = []
268
- best_match = None
269
- best_score = 0
270
-
271
- for page in pages:
272
- title = cls._extract_title_from_page(page)
273
- score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
274
- matches.append((page, title, score))
275
-
276
- if score > best_score:
277
- best_score = score
278
- best_match = page
279
-
280
- # Get top suggestions
281
- matches.sort(key=lambda x: x[2], reverse=True)
282
- suggestions = [title for _, title, _ in matches[: cls.MAX_SUGGESTIONS]]
283
-
284
- return best_match, best_score, suggestions
285
-
286
- @classmethod
287
- def _format_suggestions(cls, suggestions: List[str]) -> str:
288
- """Format suggestions as a readable string."""
289
- if not suggestions:
290
- return ""
291
-
292
- msg = "Did you mean one of these?\n"
293
- msg += "\n".join(f"- {suggestion}" for suggestion in suggestions)
294
- return msg
295
-
296
- @classmethod
297
- def _extract_title_from_page(cls, page: Dict[str, Any]) -> str:
298
- """Extract the title from a page object."""
299
- try:
300
- if "properties" in page:
301
- for prop_value in page["properties"].values():
302
- if prop_value.get("type") != "title":
303
- continue
304
- title_array = prop_value.get("title", [])
305
- if title_array:
306
- return cls._extract_text_from_rich_text(title_array)
307
-
308
- # Fall back to child_page
309
- if "child_page" in page:
310
- return page.get("child_page", {}).get("title", "Untitled")
311
-
312
- return "Untitled"
313
-
314
- except Exception as e:
315
- cls.logger.warning("Error extracting page title: %s", str(e))
316
- return "Untitled"
317
-
318
- @classmethod
319
- def _extract_text_from_rich_text(cls, rich_text: List[Dict[str, Any]]) -> str:
320
- """Extract plain text from a rich text array."""
321
- if not rich_text:
322
- return ""
323
-
324
- text_parts = [
325
- text_obj["plain_text"] for text_obj in rich_text if "plain_text" in text_obj
326
- ]
327
-
328
- return "".join(text_parts)
@@ -1,302 +0,0 @@
1
- from typing import Dict, List, Optional, Any, Tuple
2
- from notionary.notion_client import NotionClient
3
- from notionary.util import LoggingMixin
4
-
5
-
6
- class DatabasePropertyService(LoggingMixin):
7
- """
8
- Service for working with Notion database properties and options.
9
- Provides specialized methods for retrieving property information and validating values.
10
- """
11
-
12
- def __init__(self, database_id: str, client: NotionClient):
13
- """
14
- Initialize the database property service.
15
-
16
- Args:
17
- database_id: ID of the Notion database
18
- client: Instance of NotionClient
19
- """
20
- self._database_id = database_id
21
- self._client = client
22
- self._schema = None
23
-
24
- async def load_schema(self, force_refresh: bool = False) -> bool:
25
- """
26
- Loads the database schema.
27
-
28
- Args:
29
- force_refresh: Whether to force a refresh of the schema
30
-
31
- Returns:
32
- True if schema loaded successfully, False otherwise.
33
- """
34
- if self._schema is not None and not force_refresh:
35
- return True
36
-
37
- try:
38
- database = await self._client.get_database(self._database_id)
39
-
40
- self._schema = database.properties
41
- self.logger.debug("Loaded schema for database %s", self._database_id)
42
- return True
43
-
44
- except Exception as e:
45
- self.logger.error(
46
- "Error loading database schema for %s: %s", self._database_id, str(e)
47
- )
48
- return False
49
-
50
- async def _ensure_schema_loaded(self) -> None:
51
- """
52
- Ensures the schema is loaded before accessing it.
53
- """
54
- if self._schema is None:
55
- await self.load_schema()
56
-
57
- async def get_schema(self) -> Dict[str, Any]:
58
- """
59
- Gets the database schema.
60
-
61
- Returns:
62
- Dict[str, Any]: The database schema
63
- """
64
- await self._ensure_schema_loaded()
65
- return self._schema or {}
66
-
67
- async def get_property_types(self) -> Dict[str, str]:
68
- """
69
- Gets all property types for the database.
70
-
71
- Returns:
72
- Dict[str, str]: Dictionary mapping property names to their types
73
- """
74
- await self._ensure_schema_loaded()
75
-
76
- if not self._schema:
77
- return {}
78
-
79
- return {
80
- prop_name: prop_data.get("type", "unknown")
81
- for prop_name, prop_data in self._schema.items()
82
- }
83
-
84
- async def get_property_schema(self, property_name: str) -> Optional[Dict[str, Any]]:
85
- """
86
- Gets the schema for a specific property.
87
-
88
- Args:
89
- property_name: The name of the property
90
-
91
- Returns:
92
- Optional[Dict[str, Any]]: The property schema or None if not found
93
- """
94
- await self._ensure_schema_loaded()
95
-
96
- if not self._schema or property_name not in self._schema:
97
- return None
98
-
99
- return self._schema[property_name]
100
-
101
- async def get_property_type(self, property_name: str) -> Optional[str]:
102
- """
103
- Gets the type of a specific property.
104
-
105
- Args:
106
- property_name: The name of the property
107
-
108
- Returns:
109
- Optional[str]: The property type or None if not found
110
- """
111
- property_schema = await self.get_property_schema(property_name)
112
-
113
- if not property_schema:
114
- return None
115
-
116
- return property_schema.get("type")
117
-
118
- async def property_exists(self, property_name: str) -> bool:
119
- """
120
- Checks if a property exists in the database.
121
-
122
- Args:
123
- property_name: The name of the property
124
-
125
- Returns:
126
- bool: True if the property exists, False otherwise
127
- """
128
- property_schema = await self.get_property_schema(property_name)
129
- return property_schema is not None
130
-
131
- async def get_property_options(self, property_name: str) -> List[Dict[str, Any]]:
132
- """
133
- Gets the available options for a property (select, multi_select, status).
134
-
135
- Args:
136
- property_name: The name of the property
137
-
138
- Returns:
139
- List[Dict[str, Any]]: List of available options with their metadata
140
- """
141
- property_schema = await self.get_property_schema(property_name)
142
-
143
- if not property_schema:
144
- return []
145
-
146
- property_type = property_schema.get("type")
147
-
148
- if property_type in ["select", "multi_select", "status"]:
149
- return property_schema.get(property_type, {}).get("options", [])
150
-
151
- return []
152
-
153
- async def get_option_names(self, property_name: str) -> List[str]:
154
- """
155
- Gets the available option names for a property (select, multi_select, status).
156
-
157
- Args:
158
- property_name: The name of the property
159
-
160
- Returns:
161
- List[str]: List of available option names
162
- """
163
- options = await self.get_property_options(property_name)
164
- return [option.get("name", "") for option in options]
165
-
166
- async def get_relation_details(
167
- self, property_name: str
168
- ) -> Optional[Dict[str, Any]]:
169
- """
170
- Gets details about a relation property, including the related database.
171
-
172
- Args:
173
- property_name: The name of the property
174
-
175
- Returns:
176
- Optional[Dict[str, Any]]: The relation details or None if not a relation
177
- """
178
- property_schema = await self.get_property_schema(property_name)
179
-
180
- if not property_schema or property_schema.get("type") != "relation":
181
- return None
182
-
183
- return property_schema.get("relation", {})
184
-
185
- async def get_relation_options(
186
- self, property_name: str, limit: int = 100
187
- ) -> List[Dict[str, Any]]:
188
- """
189
- Gets available options for a relation property by querying the related database.
190
-
191
- Args:
192
- property_name: The name of the relation property
193
- limit: Maximum number of options to retrieve
194
-
195
- Returns:
196
- List[Dict[str, Any]]: List of pages from the related database
197
- """
198
- relation_details = await self.get_relation_details(property_name)
199
-
200
- if not relation_details or "database_id" not in relation_details:
201
- return []
202
-
203
- related_db_id = relation_details["database_id"]
204
-
205
- try:
206
- # Query the related database to get options
207
- query_result = await self._client.post(
208
- f"databases/{related_db_id}/query",
209
- {
210
- "page_size": limit,
211
- },
212
- )
213
-
214
- if not query_result or "results" not in query_result:
215
- return []
216
-
217
- # Extract relevant information from each page
218
- options = []
219
- for page in query_result["results"]:
220
- page_id = page.get("id")
221
- title = self._extract_title_from_page(page)
222
-
223
- if page_id and title:
224
- options.append({"id": page_id, "name": title})
225
-
226
- return options
227
- except Exception as e:
228
- self.logger.error(f"Error getting relation options: {str(e)}")
229
- return []
230
-
231
- def _extract_title_from_page(self, page: Dict[str, Any]) -> Optional[str]:
232
- """
233
- Extracts the title from a page object.
234
-
235
- Args:
236
- page: The page object from Notion API
237
-
238
- Returns:
239
- Optional[str]: The page title or None if not found
240
- """
241
- if "properties" not in page:
242
- return None
243
-
244
- properties = page["properties"]
245
-
246
- # Look for a title property
247
- for prop_data in properties.values():
248
- if prop_data.get("type") == "title" and "title" in prop_data:
249
- title_parts = prop_data["title"]
250
- return "".join(
251
- [text_obj.get("plain_text", "") for text_obj in title_parts]
252
- )
253
-
254
- return None
255
-
256
- async def validate_property_value(
257
- self, property_name: str, value: Any
258
- ) -> Tuple[bool, Optional[str], Optional[List[str]]]:
259
- """
260
- Validates a value for a property.
261
-
262
- Args:
263
- property_name: The name of the property
264
- value: The value to validate
265
-
266
- Returns:
267
- Tuple[bool, Optional[str], Optional[List[str]]]:
268
- - Boolean indicating if valid
269
- - Error message if invalid
270
- - Available options if applicable
271
- """
272
- property_schema = await self.get_property_schema(property_name)
273
-
274
- if not property_schema:
275
- return False, f"Property '{property_name}' does not exist", None
276
-
277
- property_type = property_schema.get("type")
278
-
279
- # Validate select, multi_select, status properties
280
- if property_type in ["select", "status"]:
281
- options = await self.get_option_names(property_name)
282
-
283
- if isinstance(value, str) and value not in options:
284
- return (
285
- False,
286
- f"Invalid {property_type} option. Value '{value}' is not in the available options.",
287
- options,
288
- )
289
-
290
- elif property_type == "multi_select":
291
- options = await self.get_option_names(property_name)
292
-
293
- if isinstance(value, list):
294
- invalid_values = [val for val in value if val not in options]
295
- if invalid_values:
296
- return (
297
- False,
298
- f"Invalid multi_select options: {', '.join(invalid_values)}",
299
- options,
300
- )
301
-
302
- return True, None, None