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.
- notionary/__init__.py +3 -16
- notionary/{notion_client.py → base_notion_client.py} +92 -98
- notionary/blocks/__init__.py +61 -0
- notionary/{elements → blocks}/audio_element.py +6 -3
- notionary/{elements → blocks}/bookmark_element.py +3 -5
- notionary/{elements → blocks}/bulleted_list_element.py +5 -6
- notionary/{elements → blocks}/callout_element.py +4 -6
- notionary/{elements → blocks}/code_block_element.py +4 -5
- notionary/{elements → blocks}/column_element.py +3 -5
- notionary/{elements → blocks}/divider_element.py +3 -5
- notionary/{elements → blocks}/embed_element.py +4 -5
- notionary/{elements → blocks}/heading_element.py +4 -7
- notionary/{elements → blocks}/image_element.py +4 -5
- notionary/{elements → blocks}/mention_element.py +3 -6
- notionary/blocks/notion_block_client.py +26 -0
- notionary/{elements → blocks}/notion_block_element.py +2 -3
- notionary/{elements → blocks}/numbered_list_element.py +4 -6
- notionary/{elements → blocks}/paragraph_element.py +4 -6
- notionary/{prompting/element_prompt_content.py → blocks/prompts/element_prompt_builder.py} +1 -40
- notionary/blocks/prompts/element_prompt_content.py +41 -0
- notionary/{elements → blocks}/qoute_element.py +4 -5
- notionary/{elements → blocks}/registry/block_registry.py +4 -26
- notionary/{elements → blocks}/registry/block_registry_builder.py +26 -25
- notionary/{elements → blocks}/table_element.py +5 -6
- notionary/{elements → blocks}/text_inline_formatter.py +1 -4
- notionary/{elements → blocks}/todo_element.py +5 -6
- notionary/{elements → blocks}/toggle_element.py +3 -5
- notionary/{elements → blocks}/toggleable_heading_element.py +4 -6
- notionary/{elements → blocks}/video_element.py +4 -5
- notionary/cli/main.py +157 -128
- notionary/cli/onboarding.py +10 -9
- notionary/database/__init__.py +0 -0
- notionary/database/client.py +132 -0
- notionary/database/database_exceptions.py +13 -0
- notionary/database/factory.py +0 -0
- notionary/database/filter_builder.py +175 -0
- notionary/database/notion_database.py +339 -126
- notionary/database/notion_database_provider.py +230 -0
- notionary/elements/__init__.py +0 -0
- notionary/models/notion_database_response.py +294 -13
- notionary/models/notion_page_response.py +9 -31
- notionary/models/search_response.py +0 -0
- notionary/page/__init__.py +0 -0
- notionary/page/client.py +110 -0
- notionary/page/content/page_content_retriever.py +5 -20
- notionary/page/content/page_content_writer.py +3 -4
- notionary/page/formatting/markdown_to_notion_converter.py +1 -3
- notionary/{prompting → page}/markdown_syntax_prompt_generator.py +1 -2
- notionary/page/notion_page.py +354 -317
- notionary/page/notion_to_markdown_converter.py +1 -4
- notionary/page/properites/property_value_extractor.py +0 -64
- notionary/page/{properites/property_formatter.py → property_formatter.py} +7 -4
- notionary/page/search_filter_builder.py +131 -0
- notionary/page/utils.py +60 -0
- notionary/util/__init__.py +12 -3
- notionary/util/factory_decorator.py +33 -0
- notionary/util/fuzzy_matcher.py +82 -0
- notionary/util/page_id_utils.py +0 -21
- notionary/util/singleton_metaclass.py +22 -0
- notionary/workspace.py +69 -0
- {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/METADATA +4 -1
- notionary-0.2.14.dist-info/RECORD +72 -0
- notionary/database/database_discovery.py +0 -142
- notionary/database/notion_database_factory.py +0 -190
- notionary/exceptions/database_exceptions.py +0 -76
- notionary/exceptions/page_creation_exception.py +0 -9
- notionary/page/metadata/metadata_editor.py +0 -150
- notionary/page/metadata/notion_icon_manager.py +0 -77
- notionary/page/metadata/notion_page_cover_manager.py +0 -56
- notionary/page/notion_page_factory.py +0 -328
- notionary/page/properites/database_property_service.py +0 -302
- notionary/page/properites/page_property_manager.py +0 -152
- notionary/page/relations/notion_page_relation_manager.py +0 -350
- notionary/page/relations/notion_page_title_resolver.py +0 -104
- notionary/page/relations/page_database_relation.py +0 -68
- notionary/util/warn_direct_constructor_usage.py +0 -54
- notionary-0.2.13.dist-info/RECORD +0 -67
- /notionary/util/{singleton.py → singleton_decorator.py} +0 -0
- {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/WHEEL +0 -0
- {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/entry_points.txt +0 -0
- {notionary-0.2.13.dist-info → notionary-0.2.14.dist-info}/licenses/LICENSE +0 -0
- {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
|