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.
- notionary/__init__.py +3 -20
- notionary/{notion_client.py → base_notion_client.py} +92 -98
- notionary/blocks/__init__.py +61 -0
- notionary/{elements → blocks}/audio_element.py +6 -4
- notionary/{elements → blocks}/bookmark_element.py +3 -6
- notionary/{elements → blocks}/bulleted_list_element.py +5 -7
- notionary/{elements → blocks}/callout_element.py +5 -8
- notionary/{elements → blocks}/code_block_element.py +4 -6
- notionary/{elements → blocks}/column_element.py +3 -6
- notionary/{elements → blocks}/divider_element.py +3 -6
- notionary/{elements → blocks}/embed_element.py +4 -6
- notionary/{elements → blocks}/heading_element.py +5 -9
- notionary/{elements → blocks}/image_element.py +4 -6
- notionary/{elements → blocks}/mention_element.py +3 -7
- notionary/blocks/notion_block_client.py +26 -0
- notionary/blocks/notion_block_element.py +34 -0
- notionary/{elements → blocks}/numbered_list_element.py +4 -7
- notionary/{elements → blocks}/paragraph_element.py +4 -7
- 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 -6
- 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 +6 -8
- notionary/{elements → blocks}/text_inline_formatter.py +1 -4
- notionary/{elements → blocks}/todo_element.py +6 -8
- notionary/{elements → blocks}/toggle_element.py +3 -6
- notionary/{elements → blocks}/toggleable_heading_element.py +5 -8
- notionary/{elements → blocks}/video_element.py +4 -6
- notionary/cli/main.py +245 -53
- notionary/cli/onboarding.py +117 -0
- 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 -128
- 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.12.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 -193
- notionary/elements/notion_block_element.py +0 -70
- 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 -332
- 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/telemetry/__init__.py +0 -7
- notionary/telemetry/telemetry.py +0 -226
- notionary/telemetry/track_usage_decorator.py +0 -76
- notionary/util/warn_direct_constructor_usage.py +0 -54
- notionary-0.2.12.dist-info/RECORD +0 -70
- /notionary/util/{singleton.py → singleton_decorator.py} +0 -0
- {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/WHEEL +0 -0
- {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/entry_points.txt +0 -0
- {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/licenses/LICENSE +0 -0
- {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/top_level.txt +0 -0
@@ -1,77 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
from typing import Optional
|
3
|
-
|
4
|
-
from notionary.models.notion_page_response import EmojiIcon, ExternalIcon, FileIcon
|
5
|
-
from notionary.notion_client import NotionClient
|
6
|
-
from notionary.util import LoggingMixin
|
7
|
-
|
8
|
-
|
9
|
-
class NotionPageIconManager(LoggingMixin):
|
10
|
-
def __init__(self, page_id: str, client: NotionClient):
|
11
|
-
self.page_id = page_id
|
12
|
-
self._client = client
|
13
|
-
|
14
|
-
async def set_emoji_icon(self, emoji: str) -> Optional[str]:
|
15
|
-
"""
|
16
|
-
Sets the page icon to an emoji.
|
17
|
-
|
18
|
-
Args:
|
19
|
-
emoji (str): The emoji character to set as the icon.
|
20
|
-
|
21
|
-
Returns:
|
22
|
-
Optional[str]: The emoji that was set as the icon, or None if the operation failed.
|
23
|
-
"""
|
24
|
-
icon = {"type": "emoji", "emoji": emoji}
|
25
|
-
page_response = await self._client.patch_page(
|
26
|
-
page_id=self.page_id, data={"icon": icon}
|
27
|
-
)
|
28
|
-
|
29
|
-
if page_response and page_response.icon and page_response.icon.type == "emoji":
|
30
|
-
return page_response.icon.emoji
|
31
|
-
return None
|
32
|
-
|
33
|
-
async def set_external_icon(self, external_icon_url: str) -> Optional[str]:
|
34
|
-
"""
|
35
|
-
Sets the page icon to an external image.
|
36
|
-
|
37
|
-
Args:
|
38
|
-
url (str): The URL of the external image to set as the icon.
|
39
|
-
|
40
|
-
Returns:
|
41
|
-
Optional[str]: The URL of the external image that was set as the icon,
|
42
|
-
or None if the operation failed.
|
43
|
-
"""
|
44
|
-
icon = {"type": "external", "external": {"url": external_icon_url}}
|
45
|
-
page_response = await self._client.patch_page(
|
46
|
-
page_id=self.page_id, data={"icon": icon}
|
47
|
-
)
|
48
|
-
|
49
|
-
if (
|
50
|
-
page_response
|
51
|
-
and page_response.icon
|
52
|
-
and page_response.icon.type == "external"
|
53
|
-
):
|
54
|
-
return page_response.icon.external.url
|
55
|
-
return None
|
56
|
-
|
57
|
-
async def get_icon(self) -> Optional[str]:
|
58
|
-
"""
|
59
|
-
Retrieves the page icon - either emoji or external URL.
|
60
|
-
|
61
|
-
Returns:
|
62
|
-
Optional[str]: Emoji character or URL if set, None if no icon.
|
63
|
-
"""
|
64
|
-
page_response = await self._client.get_page(self.page_id)
|
65
|
-
if not page_response or not page_response.icon:
|
66
|
-
return None
|
67
|
-
|
68
|
-
icon = page_response.icon
|
69
|
-
|
70
|
-
if isinstance(icon, EmojiIcon):
|
71
|
-
return icon.emoji
|
72
|
-
if isinstance(icon, ExternalIcon):
|
73
|
-
return icon.external.url
|
74
|
-
if isinstance(icon, FileIcon):
|
75
|
-
return icon.file.url
|
76
|
-
|
77
|
-
return None
|
@@ -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,332 +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.telemetry import track_usage
|
6
|
-
from notionary.util import LoggingMixin
|
7
|
-
from notionary.util import format_uuid, extract_and_validate_page_id
|
8
|
-
from notionary.util import singleton
|
9
|
-
|
10
|
-
@singleton
|
11
|
-
class NotionPageFactory(LoggingMixin):
|
12
|
-
"""
|
13
|
-
Factory class for creating NotionPage instances.
|
14
|
-
Provides methods for creating page instances by page ID, URL, or name.
|
15
|
-
"""
|
16
|
-
|
17
|
-
MATCH_THRESHOLD = 0.6
|
18
|
-
MAX_SUGGESTIONS = 5
|
19
|
-
PAGE_SIZE = 100
|
20
|
-
EARLY_STOP_THRESHOLD = 0.95
|
21
|
-
|
22
|
-
@classmethod
|
23
|
-
@track_usage('page_factory_method_used', {'method': 'from_page_id'})
|
24
|
-
def from_page_id(cls, page_id: str, token: Optional[str] = None) -> NotionPage:
|
25
|
-
"""Create a NotionPage from a page ID."""
|
26
|
-
|
27
|
-
try:
|
28
|
-
formatted_id = format_uuid(page_id) or page_id
|
29
|
-
page = NotionPage(page_id=formatted_id, token=token)
|
30
|
-
cls.logger.info(
|
31
|
-
"Successfully created page instance for ID: %s", formatted_id
|
32
|
-
)
|
33
|
-
return page
|
34
|
-
except Exception as e:
|
35
|
-
cls.logger.error("Error connecting to page %s: %s", page_id, str(e))
|
36
|
-
raise
|
37
|
-
|
38
|
-
@classmethod
|
39
|
-
@track_usage('page_factory_method_used', {'method': 'from_url'})
|
40
|
-
def from_url(cls, url: str, token: Optional[str] = None) -> NotionPage:
|
41
|
-
"""Create a NotionPage from a Notion URL."""
|
42
|
-
|
43
|
-
try:
|
44
|
-
page_id = extract_and_validate_page_id(url=url)
|
45
|
-
if not page_id:
|
46
|
-
cls.logger.error("Could not extract valid page ID from URL: %s", url)
|
47
|
-
raise ValueError(f"Invalid URL: {url}")
|
48
|
-
|
49
|
-
page = NotionPage(page_id=page_id, url=url, token=token)
|
50
|
-
cls.logger.info(
|
51
|
-
"Successfully created page instance from URL for ID: %s", page_id
|
52
|
-
)
|
53
|
-
return page
|
54
|
-
except Exception as e:
|
55
|
-
cls.logger.error("Error connecting to page with URL %s: %s", url, str(e))
|
56
|
-
raise
|
57
|
-
|
58
|
-
@classmethod
|
59
|
-
@track_usage('page_factory_method_used', {'method': 'from_page_name'})
|
60
|
-
async def from_page_name(
|
61
|
-
cls, page_name: str, token: Optional[str] = None
|
62
|
-
) -> NotionPage:
|
63
|
-
"""Create a NotionPage by finding a page with a matching name using fuzzy matching."""
|
64
|
-
cls.logger.debug("Searching for page with name: %s", page_name)
|
65
|
-
|
66
|
-
client = NotionClient(token=token)
|
67
|
-
|
68
|
-
try:
|
69
|
-
# Search with pagination and early stopping
|
70
|
-
best_match, best_score, all_suggestions = (
|
71
|
-
await cls._search_pages_with_matching(client, page_name)
|
72
|
-
)
|
73
|
-
|
74
|
-
# Check if match is good enough
|
75
|
-
if best_score < cls.MATCH_THRESHOLD or not best_match:
|
76
|
-
suggestion_msg = cls._format_suggestions(all_suggestions)
|
77
|
-
cls.logger.warning(
|
78
|
-
"No good match found for '%s'. Best score: %.2f",
|
79
|
-
page_name,
|
80
|
-
best_score,
|
81
|
-
)
|
82
|
-
raise ValueError(
|
83
|
-
f"No good match found for '{page_name}'. {suggestion_msg}"
|
84
|
-
)
|
85
|
-
|
86
|
-
# Create page from best match
|
87
|
-
page_id = best_match.get("id")
|
88
|
-
if not page_id:
|
89
|
-
cls.logger.error("Best match page has no ID")
|
90
|
-
raise ValueError("Best match page has no ID")
|
91
|
-
|
92
|
-
matched_name = cls._extract_title_from_page(best_match)
|
93
|
-
cls.logger.info(
|
94
|
-
"Found matching page: '%s' (ID: %s) with score: %.2f",
|
95
|
-
matched_name,
|
96
|
-
page_id,
|
97
|
-
best_score,
|
98
|
-
)
|
99
|
-
|
100
|
-
page = NotionPage.from_page_id(page_id=page_id, token=token)
|
101
|
-
cls.logger.info("Successfully created page instance for '%s'", matched_name)
|
102
|
-
|
103
|
-
await client.close()
|
104
|
-
return page
|
105
|
-
|
106
|
-
except Exception as e:
|
107
|
-
cls.logger.error("Error finding page by name: %s", str(e))
|
108
|
-
await client.close()
|
109
|
-
raise
|
110
|
-
|
111
|
-
@classmethod
|
112
|
-
async def _search_pages_with_matching(
|
113
|
-
cls, client: NotionClient, query: str
|
114
|
-
) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
|
115
|
-
"""
|
116
|
-
Search for pages with pagination and find the best match.
|
117
|
-
Includes early stopping for performance optimization.
|
118
|
-
"""
|
119
|
-
cls.logger.debug("Starting paginated search for query: %s", query)
|
120
|
-
|
121
|
-
best_match = None
|
122
|
-
best_score = 0
|
123
|
-
all_suggestions = []
|
124
|
-
page_count = 0
|
125
|
-
|
126
|
-
# Track suggestions across all pages
|
127
|
-
all_matches = []
|
128
|
-
|
129
|
-
next_cursor = None
|
130
|
-
|
131
|
-
while True:
|
132
|
-
# Fetch current page batch
|
133
|
-
pages_batch = await cls._fetch_pages_batch(client, next_cursor)
|
134
|
-
|
135
|
-
if not pages_batch:
|
136
|
-
cls.logger.debug("No more pages to fetch")
|
137
|
-
break
|
138
|
-
|
139
|
-
pages = pages_batch.get("results", [])
|
140
|
-
page_count += len(pages)
|
141
|
-
cls.logger.debug(
|
142
|
-
"Processing batch of %d pages (total processed: %d)",
|
143
|
-
len(pages),
|
144
|
-
page_count,
|
145
|
-
)
|
146
|
-
|
147
|
-
# Process current batch
|
148
|
-
batch_match, batch_score, batch_suggestions = cls._find_best_match_in_batch(
|
149
|
-
pages, query, best_score
|
150
|
-
)
|
151
|
-
|
152
|
-
# Update global best if we found a better match
|
153
|
-
if batch_score > best_score:
|
154
|
-
best_score = batch_score
|
155
|
-
best_match = batch_match
|
156
|
-
cls.logger.debug("New best match found with score: %.2f", best_score)
|
157
|
-
|
158
|
-
# Collect all matches for suggestions
|
159
|
-
for page in pages:
|
160
|
-
title = cls._extract_title_from_page(page)
|
161
|
-
score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
|
162
|
-
all_matches.append((title, score))
|
163
|
-
|
164
|
-
# Early stopping: if we found a very good match, stop searching
|
165
|
-
if best_score >= cls.EARLY_STOP_THRESHOLD:
|
166
|
-
cls.logger.info(
|
167
|
-
"Early stopping: found excellent match with score %.2f", best_score
|
168
|
-
)
|
169
|
-
break
|
170
|
-
|
171
|
-
# Check for next page
|
172
|
-
next_cursor = pages_batch.get("next_cursor")
|
173
|
-
if not next_cursor:
|
174
|
-
cls.logger.debug("Reached end of pages")
|
175
|
-
break
|
176
|
-
|
177
|
-
# Generate final suggestions from all matches
|
178
|
-
all_matches.sort(key=lambda x: x[1], reverse=True)
|
179
|
-
all_suggestions = [title for title, _ in all_matches[: cls.MAX_SUGGESTIONS]]
|
180
|
-
|
181
|
-
cls.logger.info(
|
182
|
-
"Search completed. Processed %d pages. Best score: %.2f",
|
183
|
-
page_count,
|
184
|
-
best_score,
|
185
|
-
)
|
186
|
-
|
187
|
-
return best_match, best_score, all_suggestions
|
188
|
-
|
189
|
-
@classmethod
|
190
|
-
async def _fetch_pages_batch(
|
191
|
-
cls, client: NotionClient, next_cursor: Optional[str] = None
|
192
|
-
) -> Dict[str, Any]:
|
193
|
-
"""Fetch a single batch of pages from the Notion API."""
|
194
|
-
search_payload = {
|
195
|
-
"filter": {"property": "object", "value": "page"},
|
196
|
-
"page_size": cls.PAGE_SIZE,
|
197
|
-
}
|
198
|
-
|
199
|
-
if next_cursor:
|
200
|
-
search_payload["start_cursor"] = next_cursor
|
201
|
-
|
202
|
-
try:
|
203
|
-
response = await client.post("search", search_payload)
|
204
|
-
|
205
|
-
if not response:
|
206
|
-
cls.logger.error("Empty response from search endpoint")
|
207
|
-
return {}
|
208
|
-
|
209
|
-
return response
|
210
|
-
|
211
|
-
except Exception as e:
|
212
|
-
cls.logger.error("Error fetching pages batch: %s", str(e))
|
213
|
-
raise
|
214
|
-
|
215
|
-
@classmethod
|
216
|
-
def _find_best_match_in_batch(
|
217
|
-
cls, pages: List[Dict[str, Any]], query: str, current_best_score: float
|
218
|
-
) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
|
219
|
-
"""Find the best matching page in a single batch."""
|
220
|
-
batch_best_match = None
|
221
|
-
batch_best_score = current_best_score
|
222
|
-
|
223
|
-
for page in pages:
|
224
|
-
title = cls._extract_title_from_page(page)
|
225
|
-
score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
|
226
|
-
|
227
|
-
if score > batch_best_score:
|
228
|
-
batch_best_score = score
|
229
|
-
batch_best_match = page
|
230
|
-
|
231
|
-
# Get batch suggestions (not used in the main algorithm but kept for compatibility)
|
232
|
-
batch_suggestions = []
|
233
|
-
|
234
|
-
return batch_best_match, batch_best_score, batch_suggestions
|
235
|
-
|
236
|
-
@classmethod
|
237
|
-
async def _search_pages(cls, client: NotionClient) -> List[Dict[str, Any]]:
|
238
|
-
"""
|
239
|
-
Legacy method - kept for backward compatibility.
|
240
|
-
Now uses the paginated approach internally.
|
241
|
-
"""
|
242
|
-
cls.logger.warning(
|
243
|
-
"_search_pages is deprecated. Use _search_pages_with_matching instead."
|
244
|
-
)
|
245
|
-
|
246
|
-
all_pages = []
|
247
|
-
next_cursor = None
|
248
|
-
|
249
|
-
while True:
|
250
|
-
batch = await cls._fetch_pages_batch(client, next_cursor)
|
251
|
-
if not batch:
|
252
|
-
break
|
253
|
-
|
254
|
-
pages = batch.get("results", [])
|
255
|
-
all_pages.extend(pages)
|
256
|
-
|
257
|
-
next_cursor = batch.get("next_cursor")
|
258
|
-
if not next_cursor:
|
259
|
-
break
|
260
|
-
|
261
|
-
cls.logger.info("Loaded %d total pages", len(all_pages))
|
262
|
-
return all_pages
|
263
|
-
|
264
|
-
@classmethod
|
265
|
-
def _find_best_match(
|
266
|
-
cls, pages: List[Dict[str, Any]], query: str
|
267
|
-
) -> Tuple[Optional[Dict[str, Any]], float, List[str]]:
|
268
|
-
"""Find the best matching page for the given query."""
|
269
|
-
cls.logger.debug("Found %d pages, searching for best match", len(pages))
|
270
|
-
|
271
|
-
matches = []
|
272
|
-
best_match = None
|
273
|
-
best_score = 0
|
274
|
-
|
275
|
-
for page in pages:
|
276
|
-
title = cls._extract_title_from_page(page)
|
277
|
-
score = SequenceMatcher(None, query.lower(), title.lower()).ratio()
|
278
|
-
matches.append((page, title, score))
|
279
|
-
|
280
|
-
if score > best_score:
|
281
|
-
best_score = score
|
282
|
-
best_match = page
|
283
|
-
|
284
|
-
# Get top suggestions
|
285
|
-
matches.sort(key=lambda x: x[2], reverse=True)
|
286
|
-
suggestions = [title for _, title, _ in matches[: cls.MAX_SUGGESTIONS]]
|
287
|
-
|
288
|
-
return best_match, best_score, suggestions
|
289
|
-
|
290
|
-
@classmethod
|
291
|
-
def _format_suggestions(cls, suggestions: List[str]) -> str:
|
292
|
-
"""Format suggestions as a readable string."""
|
293
|
-
if not suggestions:
|
294
|
-
return ""
|
295
|
-
|
296
|
-
msg = "Did you mean one of these?\n"
|
297
|
-
msg += "\n".join(f"- {suggestion}" for suggestion in suggestions)
|
298
|
-
return msg
|
299
|
-
|
300
|
-
@classmethod
|
301
|
-
def _extract_title_from_page(cls, page: Dict[str, Any]) -> str:
|
302
|
-
"""Extract the title from a page object."""
|
303
|
-
try:
|
304
|
-
if "properties" in page:
|
305
|
-
for prop_value in page["properties"].values():
|
306
|
-
if prop_value.get("type") != "title":
|
307
|
-
continue
|
308
|
-
title_array = prop_value.get("title", [])
|
309
|
-
if title_array:
|
310
|
-
return cls._extract_text_from_rich_text(title_array)
|
311
|
-
|
312
|
-
# Fall back to child_page
|
313
|
-
if "child_page" in page:
|
314
|
-
return page.get("child_page", {}).get("title", "Untitled")
|
315
|
-
|
316
|
-
return "Untitled"
|
317
|
-
|
318
|
-
except Exception as e:
|
319
|
-
cls.logger.warning("Error extracting page title: %s", str(e))
|
320
|
-
return "Untitled"
|
321
|
-
|
322
|
-
@classmethod
|
323
|
-
def _extract_text_from_rich_text(cls, rich_text: List[Dict[str, Any]]) -> str:
|
324
|
-
"""Extract plain text from a rich text array."""
|
325
|
-
if not rich_text:
|
326
|
-
return ""
|
327
|
-
|
328
|
-
text_parts = [
|
329
|
-
text_obj["plain_text"] for text_obj in rich_text if "plain_text" in text_obj
|
330
|
-
]
|
331
|
-
|
332
|
-
return "".join(text_parts)
|