notionary 0.1.11__py3-none-any.whl → 0.1.13__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.
- notionary/__init__.py +21 -6
- notionary/{core/converters → converters}/elements/audio_element.py +7 -5
- notionary/{core/converters → converters}/elements/bookmark_element.py +1 -1
- notionary/{core/converters → converters}/elements/callout_element.py +2 -2
- notionary/{core/converters → converters}/elements/code_block_element.py +1 -1
- notionary/{core/converters → converters}/elements/column_element.py +1 -1
- notionary/{core/converters → converters}/elements/divider_element.py +1 -1
- notionary/{core/converters → converters}/elements/embed_element.py +3 -5
- notionary/{core/converters → converters}/elements/heading_element.py +2 -2
- notionary/{core/converters → converters}/elements/image_element.py +1 -1
- notionary/{core/converters → converters}/elements/list_element.py +2 -2
- notionary/{core/converters → converters}/elements/paragraph_element.py +2 -2
- notionary/{core/converters → converters}/elements/qoute_element.py +1 -1
- notionary/{core/converters → converters}/elements/table_element.py +2 -2
- notionary/{core/converters → converters}/elements/todo_lists.py +2 -2
- notionary/{core/converters → converters}/elements/toggle_element.py +24 -21
- notionary/{core/converters → converters}/elements/video_element.py +1 -1
- notionary/{core/converters → converters}/markdown_to_notion_converter.py +72 -111
- notionary/{core/converters → converters}/notion_to_markdown_converter.py +2 -2
- notionary/{core/converters → converters}/registry/block_element_registry.py +5 -5
- notionary/{core/converters → converters}/registry/block_element_registry_builder.py +18 -18
- notionary/database/database_discovery.py +142 -0
- notionary/{core/database → database}/database_info_service.py +1 -1
- notionary/{core/database/notion_database_manager.py → database/notion_database.py} +33 -57
- notionary/{core/database/notion_database_manager_factory.py → database/notion_database_factory.py} +18 -16
- notionary/{core/notion_client.py → notion_client.py} +4 -2
- notionary/page/content/notion_page_content_chunker.py +84 -0
- notionary/{core/page → page}/content/page_content_manager.py +29 -13
- notionary/{core/page → page}/metadata/metadata_editor.py +59 -46
- notionary/{core/page → page}/metadata/notion_icon_manager.py +10 -12
- notionary/{core/page → page}/metadata/notion_page_cover_manager.py +16 -21
- notionary/page/notion_page.py +504 -0
- notionary/page/notion_page_factory.py +256 -0
- notionary/{core/page → page}/properites/database_property_service.py +115 -99
- notionary/{core/page → page}/properites/page_property_manager.py +81 -52
- notionary/{core/page → page}/properites/property_formatter.py +1 -1
- notionary/{core/page → page}/properites/property_operation_result.py +43 -30
- notionary/{core/page → page}/properites/property_value_extractor.py +26 -8
- notionary/{core/page → page}/relations/notion_page_relation_manager.py +72 -53
- notionary/{core/page → page}/relations/notion_page_title_resolver.py +12 -12
- notionary/{core/page → page}/relations/page_database_relation.py +15 -15
- notionary/{core/page → page}/relations/relation_operation_result.py +50 -41
- notionary/util/page_id_utils.py +14 -8
- {notionary-0.1.11.dist-info → notionary-0.1.13.dist-info}/METADATA +1 -1
- notionary-0.1.13.dist-info/RECORD +56 -0
- notionary/core/database/notion_database_schema.py +0 -104
- notionary/core/page/notion_page_manager.py +0 -322
- notionary-0.1.11.dist-info/RECORD +0 -54
- /notionary/{core/converters → converters}/__init__.py +0 -0
- /notionary/{core/converters → converters}/elements/notion_block_element.py +0 -0
- /notionary/{core/converters → converters}/elements/text_inline_formatter.py +0 -0
- /notionary/{core/database → database}/models/page_result.py +0 -0
- {notionary-0.1.11.dist-info → notionary-0.1.13.dist-info}/WHEEL +0 -0
- {notionary-0.1.11.dist-info → notionary-0.1.13.dist-info}/licenses/LICENSE +0 -0
- {notionary-0.1.11.dist-info → notionary-0.1.13.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,256 @@
|
|
1
|
+
import logging
|
2
|
+
from typing import List, Optional, Dict, Any
|
3
|
+
from difflib import SequenceMatcher
|
4
|
+
|
5
|
+
from notionary import NotionPage, NotionClient
|
6
|
+
from notionary.util.logging_mixin import LoggingMixin
|
7
|
+
from notionary.util.page_id_utils import format_uuid, extract_and_validate_page_id
|
8
|
+
|
9
|
+
|
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
|
+
@classmethod
|
17
|
+
def class_logger(cls):
|
18
|
+
"""Class logger - for class methods"""
|
19
|
+
return logging.getLogger(cls.__name__)
|
20
|
+
|
21
|
+
@classmethod
|
22
|
+
async def from_page_id(
|
23
|
+
cls, page_id: str, token: Optional[str] = None
|
24
|
+
) -> NotionPage:
|
25
|
+
"""
|
26
|
+
Create a NotionPage from a page ID.
|
27
|
+
|
28
|
+
Args:
|
29
|
+
page_id: The ID of the Notion page
|
30
|
+
token: Optional Notion API token (uses environment variable if not provided)
|
31
|
+
|
32
|
+
Returns:
|
33
|
+
An initialized NotionPage instance
|
34
|
+
|
35
|
+
Raises:
|
36
|
+
NotionError: If there is any error during page creation or connection
|
37
|
+
"""
|
38
|
+
logger = cls.class_logger()
|
39
|
+
|
40
|
+
try:
|
41
|
+
formatted_id = format_uuid(page_id) or page_id
|
42
|
+
|
43
|
+
page = NotionPage(page_id=formatted_id, token=token)
|
44
|
+
|
45
|
+
logger.info("Successfully created page instance for ID: %s", formatted_id)
|
46
|
+
return page
|
47
|
+
|
48
|
+
except Exception as e:
|
49
|
+
error_msg = f"Error connecting to page {page_id}: {str(e)}"
|
50
|
+
logger.error(error_msg)
|
51
|
+
|
52
|
+
@classmethod
|
53
|
+
async def from_url(cls, url: str, token: Optional[str] = None) -> NotionPage:
|
54
|
+
"""
|
55
|
+
Create a NotionPage from a Notion URL.
|
56
|
+
|
57
|
+
Args:
|
58
|
+
url: The URL of the Notion page
|
59
|
+
token: Optional Notion API token (uses environment variable if not provided)
|
60
|
+
|
61
|
+
Returns:
|
62
|
+
An initialized NotionPage instance
|
63
|
+
|
64
|
+
Raises:
|
65
|
+
NotionError: If there is any error during page creation or connection
|
66
|
+
"""
|
67
|
+
logger = cls.class_logger()
|
68
|
+
|
69
|
+
try:
|
70
|
+
page_id = extract_and_validate_page_id(url=url)
|
71
|
+
if not page_id:
|
72
|
+
error_msg = f"Could not extract valid page ID from URL: {url}"
|
73
|
+
logger.error(error_msg)
|
74
|
+
|
75
|
+
page = NotionPage(page_id=page_id, url=url, token=token)
|
76
|
+
|
77
|
+
logger.info(
|
78
|
+
"Successfully created page instance from URL for ID: %s", page_id
|
79
|
+
)
|
80
|
+
return page
|
81
|
+
|
82
|
+
except Exception as e:
|
83
|
+
error_msg = f"Error connecting to page with URL {url}: {str(e)}"
|
84
|
+
logger.error(error_msg)
|
85
|
+
|
86
|
+
@classmethod
|
87
|
+
async def from_page_name(
|
88
|
+
cls, page_name: str, token: Optional[str] = None
|
89
|
+
) -> NotionPage:
|
90
|
+
"""
|
91
|
+
Create a NotionPage by finding a page with a matching name.
|
92
|
+
Uses fuzzy matching to find the closest match to the given name.
|
93
|
+
If no good match is found, suggests closest alternatives ("Did you mean?").
|
94
|
+
|
95
|
+
Args:
|
96
|
+
page_name: The name of the Notion page to search for
|
97
|
+
token: Optional Notion API token (uses environment variable if not provided)
|
98
|
+
|
99
|
+
Returns:
|
100
|
+
An initialized NotionPage instance
|
101
|
+
|
102
|
+
Raises:
|
103
|
+
NotionError: If there is any error during page search or connection
|
104
|
+
NotionPageNotFoundError: If no matching page found, includes suggestions
|
105
|
+
"""
|
106
|
+
logger = cls.class_logger()
|
107
|
+
logger.debug("Searching for page with name: %s", page_name)
|
108
|
+
|
109
|
+
client = NotionClient(token=token)
|
110
|
+
|
111
|
+
try:
|
112
|
+
logger.debug("Using search endpoint to find pages")
|
113
|
+
|
114
|
+
search_payload = {
|
115
|
+
"filter": {"property": "object", "value": "page"},
|
116
|
+
"page_size": 100,
|
117
|
+
}
|
118
|
+
|
119
|
+
response = await client.post("search", search_payload)
|
120
|
+
|
121
|
+
if not response or "results" not in response:
|
122
|
+
error_msg = "Failed to fetch pages using search endpoint"
|
123
|
+
logger.error(error_msg)
|
124
|
+
|
125
|
+
pages = response.get("results", [])
|
126
|
+
|
127
|
+
if not pages:
|
128
|
+
error_msg = f"No pages found matching '{page_name}'"
|
129
|
+
logger.warning(error_msg)
|
130
|
+
|
131
|
+
logger.debug("Found %d pages, searching for best match", len(pages))
|
132
|
+
|
133
|
+
# Store all matches with their scores for potential suggestions
|
134
|
+
matches = []
|
135
|
+
best_match = None
|
136
|
+
best_score = 0
|
137
|
+
|
138
|
+
for page in pages:
|
139
|
+
title = cls._extract_title_from_page(page)
|
140
|
+
score = SequenceMatcher(None, page_name.lower(), title.lower()).ratio()
|
141
|
+
|
142
|
+
matches.append((page, title, score))
|
143
|
+
|
144
|
+
if score > best_score:
|
145
|
+
best_score = score
|
146
|
+
best_match = page
|
147
|
+
|
148
|
+
if best_score < 0.6 or not best_match:
|
149
|
+
# Sort matches by score in descending order
|
150
|
+
matches.sort(key=lambda x: x[2], reverse=True)
|
151
|
+
|
152
|
+
# Take top N suggestions (adjust as needed)
|
153
|
+
suggestions = [title for _, title, _ in matches[:5]]
|
154
|
+
|
155
|
+
error_msg = f"No good match found for '{page_name}'. Did you mean one of these?\n"
|
156
|
+
error_msg += "\n".join(f"- {suggestion}" for suggestion in suggestions)
|
157
|
+
|
158
|
+
logger.warning(
|
159
|
+
"No good match found for '%s'. Best score: %.2f",
|
160
|
+
page_name,
|
161
|
+
best_score,
|
162
|
+
)
|
163
|
+
|
164
|
+
page_id = best_match.get("id")
|
165
|
+
|
166
|
+
if not page_id:
|
167
|
+
error_msg = "Best match page has no ID"
|
168
|
+
logger.error(error_msg)
|
169
|
+
|
170
|
+
matched_name = cls._extract_title_from_page(best_match)
|
171
|
+
|
172
|
+
logger.info(
|
173
|
+
"Found matching page: '%s' (ID: %s) with score: %.2f",
|
174
|
+
matched_name,
|
175
|
+
page_id,
|
176
|
+
best_score,
|
177
|
+
)
|
178
|
+
|
179
|
+
page = NotionPage(
|
180
|
+
page_id=page_id, title=matched_name, token=token
|
181
|
+
)
|
182
|
+
|
183
|
+
logger.info("Successfully created page instance for '%s'", matched_name)
|
184
|
+
await client.close()
|
185
|
+
return page
|
186
|
+
|
187
|
+
except Exception as e:
|
188
|
+
error_msg = f"Error finding page by name: {str(e)}"
|
189
|
+
logger.error(error_msg)
|
190
|
+
|
191
|
+
@classmethod
|
192
|
+
def _extract_title_from_page(cls, page: Dict[str, Any]) -> str:
|
193
|
+
"""
|
194
|
+
Extract the title from a page object.
|
195
|
+
|
196
|
+
Args:
|
197
|
+
page: The page object returned from the Notion API
|
198
|
+
|
199
|
+
Returns:
|
200
|
+
The title of the page
|
201
|
+
|
202
|
+
Raises:
|
203
|
+
NotionError: If the title cannot be extracted
|
204
|
+
"""
|
205
|
+
try:
|
206
|
+
if "properties" in page:
|
207
|
+
for prop_value in page["properties"].values():
|
208
|
+
if prop_value.get("type") != "title":
|
209
|
+
continue
|
210
|
+
title_array = prop_value.get("title", [])
|
211
|
+
if not title_array:
|
212
|
+
continue
|
213
|
+
return cls._extract_text_from_rich_text(title_array)
|
214
|
+
|
215
|
+
if "child_page" in page:
|
216
|
+
return page.get("child_page", {}).get("title", "Untitled")
|
217
|
+
|
218
|
+
return "Untitled"
|
219
|
+
|
220
|
+
except Exception as e:
|
221
|
+
error_msg = f"Error extracting page title: {str(e)}"
|
222
|
+
cls.class_logger().warning(error_msg)
|
223
|
+
return "Untitled"
|
224
|
+
|
225
|
+
@classmethod
|
226
|
+
def _extract_text_from_rich_text(cls, rich_text: List[Dict[str, Any]]) -> str:
|
227
|
+
"""
|
228
|
+
Extract plain text from a rich text array.
|
229
|
+
|
230
|
+
Args:
|
231
|
+
rich_text: A list of rich text objects from the Notion API
|
232
|
+
|
233
|
+
Returns:
|
234
|
+
The combined plain text content
|
235
|
+
"""
|
236
|
+
if not rich_text:
|
237
|
+
return ""
|
238
|
+
|
239
|
+
text_parts = []
|
240
|
+
for text_obj in rich_text:
|
241
|
+
if "plain_text" in text_obj:
|
242
|
+
text_parts.append(text_obj["plain_text"])
|
243
|
+
|
244
|
+
return "".join(text_parts)
|
245
|
+
|
246
|
+
|
247
|
+
async def demo():
|
248
|
+
clipboard = await NotionPageFactory.from_page_name("Jarvis Clipboard")
|
249
|
+
icon = await clipboard.get_icon()
|
250
|
+
print(f"Icon: {icon}")
|
251
|
+
|
252
|
+
|
253
|
+
if __name__ == "__main__":
|
254
|
+
import asyncio
|
255
|
+
|
256
|
+
asyncio.run(demo())
|
@@ -1,5 +1,5 @@
|
|
1
1
|
from typing import Dict, List, Optional, Any, Tuple
|
2
|
-
from notionary.
|
2
|
+
from notionary.notion_client import NotionClient
|
3
3
|
from notionary.util.logging_mixin import LoggingMixin
|
4
4
|
|
5
5
|
|
@@ -12,7 +12,7 @@ class DatabasePropertyService(LoggingMixin):
|
|
12
12
|
def __init__(self, database_id: str, client: NotionClient):
|
13
13
|
"""
|
14
14
|
Initialize the database property service.
|
15
|
-
|
15
|
+
|
16
16
|
Args:
|
17
17
|
database_id: ID of the Notion database
|
18
18
|
client: Instance of NotionClient
|
@@ -20,20 +20,20 @@ class DatabasePropertyService(LoggingMixin):
|
|
20
20
|
self._database_id = database_id
|
21
21
|
self._client = client
|
22
22
|
self._schema = None
|
23
|
-
|
23
|
+
|
24
24
|
async def load_schema(self, force_refresh=False) -> bool:
|
25
25
|
"""
|
26
26
|
Loads the database schema.
|
27
|
-
|
27
|
+
|
28
28
|
Args:
|
29
29
|
force_refresh: Whether to force a refresh of the schema
|
30
|
-
|
30
|
+
|
31
31
|
Returns:
|
32
32
|
bool: True if schema loaded successfully, False otherwise
|
33
33
|
"""
|
34
34
|
if self._schema is not None and not force_refresh:
|
35
35
|
return True
|
36
|
-
|
36
|
+
|
37
37
|
try:
|
38
38
|
database = await self._client.get(f"databases/{self._database_id}")
|
39
39
|
if database and "properties" in database:
|
@@ -41,290 +41,306 @@ class DatabasePropertyService(LoggingMixin):
|
|
41
41
|
self.logger.debug("Loaded schema for database %s", self._database_id)
|
42
42
|
return True
|
43
43
|
else:
|
44
|
-
self.logger.error(
|
44
|
+
self.logger.error(
|
45
|
+
"Failed to load schema: missing 'properties' in response"
|
46
|
+
)
|
45
47
|
return False
|
46
48
|
except Exception as e:
|
47
49
|
self.logger.error("Error loading database schema: %s", str(e))
|
48
50
|
return False
|
49
|
-
|
51
|
+
|
50
52
|
async def _ensure_schema_loaded(self) -> None:
|
51
53
|
"""
|
52
54
|
Ensures the schema is loaded before accessing it.
|
53
55
|
"""
|
54
56
|
if self._schema is None:
|
55
57
|
await self.load_schema()
|
56
|
-
|
58
|
+
|
57
59
|
async def get_schema(self) -> Dict[str, Any]:
|
58
60
|
"""
|
59
61
|
Gets the database schema.
|
60
|
-
|
62
|
+
|
61
63
|
Returns:
|
62
64
|
Dict[str, Any]: The database schema
|
63
65
|
"""
|
64
66
|
await self._ensure_schema_loaded()
|
65
67
|
return self._schema or {}
|
66
|
-
|
68
|
+
|
67
69
|
async def get_property_types(self) -> Dict[str, str]:
|
68
70
|
"""
|
69
71
|
Gets all property types for the database.
|
70
|
-
|
72
|
+
|
71
73
|
Returns:
|
72
74
|
Dict[str, str]: Dictionary mapping property names to their types
|
73
75
|
"""
|
74
76
|
await self._ensure_schema_loaded()
|
75
|
-
|
77
|
+
|
76
78
|
if not self._schema:
|
77
79
|
return {}
|
78
|
-
|
80
|
+
|
79
81
|
return {
|
80
82
|
prop_name: prop_data.get("type", "unknown")
|
81
83
|
for prop_name, prop_data in self._schema.items()
|
82
84
|
}
|
83
|
-
|
85
|
+
|
84
86
|
async def get_property_schema(self, property_name: str) -> Optional[Dict[str, Any]]:
|
85
87
|
"""
|
86
88
|
Gets the schema for a specific property.
|
87
|
-
|
89
|
+
|
88
90
|
Args:
|
89
91
|
property_name: The name of the property
|
90
|
-
|
92
|
+
|
91
93
|
Returns:
|
92
94
|
Optional[Dict[str, Any]]: The property schema or None if not found
|
93
95
|
"""
|
94
96
|
await self._ensure_schema_loaded()
|
95
|
-
|
97
|
+
|
96
98
|
if not self._schema or property_name not in self._schema:
|
97
99
|
return None
|
98
|
-
|
100
|
+
|
99
101
|
return self._schema[property_name]
|
100
|
-
|
102
|
+
|
101
103
|
async def get_property_type(self, property_name: str) -> Optional[str]:
|
102
104
|
"""
|
103
105
|
Gets the type of a specific property.
|
104
|
-
|
106
|
+
|
105
107
|
Args:
|
106
108
|
property_name: The name of the property
|
107
|
-
|
109
|
+
|
108
110
|
Returns:
|
109
111
|
Optional[str]: The property type or None if not found
|
110
112
|
"""
|
111
113
|
property_schema = await self.get_property_schema(property_name)
|
112
|
-
|
114
|
+
|
113
115
|
if not property_schema:
|
114
116
|
return None
|
115
|
-
|
117
|
+
|
116
118
|
return property_schema.get("type")
|
117
|
-
|
119
|
+
|
118
120
|
async def property_exists(self, property_name: str) -> bool:
|
119
121
|
"""
|
120
122
|
Checks if a property exists in the database.
|
121
|
-
|
123
|
+
|
122
124
|
Args:
|
123
125
|
property_name: The name of the property
|
124
|
-
|
126
|
+
|
125
127
|
Returns:
|
126
128
|
bool: True if the property exists, False otherwise
|
127
129
|
"""
|
128
130
|
property_schema = await self.get_property_schema(property_name)
|
129
131
|
return property_schema is not None
|
130
|
-
|
132
|
+
|
131
133
|
async def get_property_options(self, property_name: str) -> List[Dict[str, Any]]:
|
132
134
|
"""
|
133
135
|
Gets the available options for a property (select, multi_select, status).
|
134
|
-
|
136
|
+
|
135
137
|
Args:
|
136
138
|
property_name: The name of the property
|
137
|
-
|
139
|
+
|
138
140
|
Returns:
|
139
141
|
List[Dict[str, Any]]: List of available options with their metadata
|
140
142
|
"""
|
141
143
|
property_schema = await self.get_property_schema(property_name)
|
142
|
-
|
144
|
+
|
143
145
|
if not property_schema:
|
144
146
|
return []
|
145
|
-
|
147
|
+
|
146
148
|
property_type = property_schema.get("type")
|
147
|
-
|
149
|
+
|
148
150
|
if property_type in ["select", "multi_select", "status"]:
|
149
151
|
return property_schema.get(property_type, {}).get("options", [])
|
150
|
-
|
152
|
+
|
151
153
|
return []
|
152
|
-
|
154
|
+
|
153
155
|
async def get_option_names(self, property_name: str) -> List[str]:
|
154
156
|
"""
|
155
157
|
Gets the available option names for a property (select, multi_select, status).
|
156
|
-
|
158
|
+
|
157
159
|
Args:
|
158
160
|
property_name: The name of the property
|
159
|
-
|
161
|
+
|
160
162
|
Returns:
|
161
163
|
List[str]: List of available option names
|
162
164
|
"""
|
163
165
|
options = await self.get_property_options(property_name)
|
164
166
|
return [option.get("name", "") for option in options]
|
165
|
-
|
166
|
-
async def get_relation_details(
|
167
|
+
|
168
|
+
async def get_relation_details(
|
169
|
+
self, property_name: str
|
170
|
+
) -> Optional[Dict[str, Any]]:
|
167
171
|
"""
|
168
172
|
Gets details about a relation property, including the related database.
|
169
|
-
|
173
|
+
|
170
174
|
Args:
|
171
175
|
property_name: The name of the property
|
172
|
-
|
176
|
+
|
173
177
|
Returns:
|
174
178
|
Optional[Dict[str, Any]]: The relation details or None if not a relation
|
175
179
|
"""
|
176
180
|
property_schema = await self.get_property_schema(property_name)
|
177
|
-
|
181
|
+
|
178
182
|
if not property_schema or property_schema.get("type") != "relation":
|
179
183
|
return None
|
180
|
-
|
184
|
+
|
181
185
|
return property_schema.get("relation", {})
|
182
|
-
|
183
|
-
async def get_relation_options(
|
186
|
+
|
187
|
+
async def get_relation_options(
|
188
|
+
self, property_name: str, limit: int = 100
|
189
|
+
) -> List[Dict[str, Any]]:
|
184
190
|
"""
|
185
191
|
Gets available options for a relation property by querying the related database.
|
186
|
-
|
192
|
+
|
187
193
|
Args:
|
188
194
|
property_name: The name of the relation property
|
189
195
|
limit: Maximum number of options to retrieve
|
190
|
-
|
196
|
+
|
191
197
|
Returns:
|
192
198
|
List[Dict[str, Any]]: List of pages from the related database
|
193
199
|
"""
|
194
200
|
relation_details = await self.get_relation_details(property_name)
|
195
|
-
|
201
|
+
|
196
202
|
if not relation_details or "database_id" not in relation_details:
|
197
203
|
return []
|
198
|
-
|
204
|
+
|
199
205
|
related_db_id = relation_details["database_id"]
|
200
|
-
|
206
|
+
|
201
207
|
try:
|
202
208
|
# Query the related database to get options
|
203
209
|
query_result = await self._client.post(
|
204
210
|
f"databases/{related_db_id}/query",
|
205
211
|
{
|
206
212
|
"page_size": limit,
|
207
|
-
}
|
213
|
+
},
|
208
214
|
)
|
209
|
-
|
215
|
+
|
210
216
|
if not query_result or "results" not in query_result:
|
211
217
|
return []
|
212
|
-
|
218
|
+
|
213
219
|
# Extract relevant information from each page
|
214
220
|
options = []
|
215
221
|
for page in query_result["results"]:
|
216
222
|
page_id = page.get("id")
|
217
223
|
title = self._extract_title_from_page(page)
|
218
|
-
|
224
|
+
|
219
225
|
if page_id and title:
|
220
|
-
options.append({
|
221
|
-
|
222
|
-
"name": title
|
223
|
-
})
|
224
|
-
|
226
|
+
options.append({"id": page_id, "name": title})
|
227
|
+
|
225
228
|
return options
|
226
229
|
except Exception as e:
|
227
230
|
self.logger.error(f"Error getting relation options: {str(e)}")
|
228
231
|
return []
|
229
|
-
|
232
|
+
|
230
233
|
def _extract_title_from_page(self, page: Dict[str, Any]) -> Optional[str]:
|
231
234
|
"""
|
232
235
|
Extracts the title from a page object.
|
233
|
-
|
236
|
+
|
234
237
|
Args:
|
235
238
|
page: The page object from Notion API
|
236
|
-
|
239
|
+
|
237
240
|
Returns:
|
238
241
|
Optional[str]: The page title or None if not found
|
239
242
|
"""
|
240
243
|
if "properties" not in page:
|
241
244
|
return None
|
242
|
-
|
245
|
+
|
243
246
|
properties = page["properties"]
|
244
|
-
|
247
|
+
|
245
248
|
# Look for a title property
|
246
249
|
for prop_data in properties.values():
|
247
250
|
if prop_data.get("type") == "title" and "title" in prop_data:
|
248
251
|
title_parts = prop_data["title"]
|
249
|
-
return "".join(
|
250
|
-
|
252
|
+
return "".join(
|
253
|
+
[text_obj.get("plain_text", "") for text_obj in title_parts]
|
254
|
+
)
|
255
|
+
|
251
256
|
return None
|
252
|
-
|
253
|
-
async def validate_property_value(
|
257
|
+
|
258
|
+
async def validate_property_value(
|
259
|
+
self, property_name: str, value: Any
|
260
|
+
) -> Tuple[bool, Optional[str], Optional[List[str]]]:
|
254
261
|
"""
|
255
262
|
Validates a value for a property.
|
256
|
-
|
263
|
+
|
257
264
|
Args:
|
258
265
|
property_name: The name of the property
|
259
266
|
value: The value to validate
|
260
|
-
|
267
|
+
|
261
268
|
Returns:
|
262
|
-
Tuple[bool, Optional[str], Optional[List[str]]]:
|
269
|
+
Tuple[bool, Optional[str], Optional[List[str]]]:
|
263
270
|
- Boolean indicating if valid
|
264
271
|
- Error message if invalid
|
265
272
|
- Available options if applicable
|
266
273
|
"""
|
267
274
|
property_schema = await self.get_property_schema(property_name)
|
268
|
-
|
275
|
+
|
269
276
|
if not property_schema:
|
270
277
|
return False, f"Property '{property_name}' does not exist", None
|
271
|
-
|
278
|
+
|
272
279
|
property_type = property_schema.get("type")
|
273
|
-
|
280
|
+
|
274
281
|
# Validate select, multi_select, status properties
|
275
282
|
if property_type in ["select", "status"]:
|
276
283
|
options = await self.get_option_names(property_name)
|
277
|
-
|
284
|
+
|
278
285
|
if isinstance(value, str) and value not in options:
|
279
|
-
return
|
280
|
-
|
286
|
+
return (
|
287
|
+
False,
|
288
|
+
f"Invalid {property_type} option. Value '{value}' is not in the available options.",
|
289
|
+
options,
|
290
|
+
)
|
291
|
+
|
281
292
|
elif property_type == "multi_select":
|
282
293
|
options = await self.get_option_names(property_name)
|
283
|
-
|
294
|
+
|
284
295
|
if isinstance(value, list):
|
285
296
|
invalid_values = [val for val in value if val not in options]
|
286
297
|
if invalid_values:
|
287
|
-
return
|
288
|
-
|
298
|
+
return (
|
299
|
+
False,
|
300
|
+
f"Invalid multi_select options: {', '.join(invalid_values)}",
|
301
|
+
options,
|
302
|
+
)
|
303
|
+
|
289
304
|
return True, None, None
|
290
|
-
|
291
|
-
async def get_database_metadata(
|
305
|
+
|
306
|
+
async def get_database_metadata(
|
307
|
+
self, include_types: Optional[List[str]] = None
|
308
|
+
) -> Dict[str, Any]:
|
292
309
|
"""
|
293
310
|
Gets the complete metadata of the database, including property options.
|
294
|
-
|
311
|
+
|
295
312
|
Args:
|
296
313
|
include_types: List of property types to include (if None, include all)
|
297
|
-
|
314
|
+
|
298
315
|
Returns:
|
299
316
|
Dict[str, Any]: The database metadata
|
300
317
|
"""
|
301
318
|
await self._ensure_schema_loaded()
|
302
|
-
|
319
|
+
|
303
320
|
if not self._schema:
|
304
321
|
return {"properties": {}}
|
305
|
-
|
322
|
+
|
306
323
|
metadata = {"properties": {}}
|
307
|
-
|
324
|
+
|
308
325
|
for prop_name, prop_data in self._schema.items():
|
309
326
|
prop_type = prop_data.get("type")
|
310
|
-
|
327
|
+
|
311
328
|
# Skip if we're filtering and this type isn't included
|
312
329
|
if include_types and prop_type not in include_types:
|
313
330
|
continue
|
314
|
-
|
315
|
-
prop_metadata = {
|
316
|
-
|
317
|
-
"options": []
|
318
|
-
}
|
319
|
-
|
331
|
+
|
332
|
+
prop_metadata = {"type": prop_type, "options": []}
|
333
|
+
|
320
334
|
# Include options for select, multi_select, status
|
321
335
|
if prop_type in ["select", "multi_select", "status"]:
|
322
|
-
prop_metadata["options"] = prop_data.get(prop_type, {}).get(
|
323
|
-
|
336
|
+
prop_metadata["options"] = prop_data.get(prop_type, {}).get(
|
337
|
+
"options", []
|
338
|
+
)
|
339
|
+
|
324
340
|
# For relation properties, we might want to include related database info
|
325
341
|
elif prop_type == "relation":
|
326
342
|
prop_metadata["relation_details"] = prop_data.get("relation", {})
|
327
|
-
|
343
|
+
|
328
344
|
metadata["properties"][prop_name] = prop_metadata
|
329
|
-
|
330
|
-
return metadata
|
345
|
+
|
346
|
+
return metadata
|