notionary 0.2.12__py3-none-any.whl → 0.2.14__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. notionary/__init__.py +3 -20
  2. notionary/{notion_client.py → base_notion_client.py} +92 -98
  3. notionary/blocks/__init__.py +61 -0
  4. notionary/{elements → blocks}/audio_element.py +6 -4
  5. notionary/{elements → blocks}/bookmark_element.py +3 -6
  6. notionary/{elements → blocks}/bulleted_list_element.py +5 -7
  7. notionary/{elements → blocks}/callout_element.py +5 -8
  8. notionary/{elements → blocks}/code_block_element.py +4 -6
  9. notionary/{elements → blocks}/column_element.py +3 -6
  10. notionary/{elements → blocks}/divider_element.py +3 -6
  11. notionary/{elements → blocks}/embed_element.py +4 -6
  12. notionary/{elements → blocks}/heading_element.py +5 -9
  13. notionary/{elements → blocks}/image_element.py +4 -6
  14. notionary/{elements → blocks}/mention_element.py +3 -7
  15. notionary/blocks/notion_block_client.py +26 -0
  16. notionary/blocks/notion_block_element.py +34 -0
  17. notionary/{elements → blocks}/numbered_list_element.py +4 -7
  18. notionary/{elements → blocks}/paragraph_element.py +4 -7
  19. notionary/{prompting/element_prompt_content.py → blocks/prompts/element_prompt_builder.py} +1 -40
  20. notionary/blocks/prompts/element_prompt_content.py +41 -0
  21. notionary/{elements → blocks}/qoute_element.py +4 -6
  22. notionary/{elements → blocks}/registry/block_registry.py +4 -26
  23. notionary/{elements → blocks}/registry/block_registry_builder.py +26 -25
  24. notionary/{elements → blocks}/table_element.py +6 -8
  25. notionary/{elements → blocks}/text_inline_formatter.py +1 -4
  26. notionary/{elements → blocks}/todo_element.py +6 -8
  27. notionary/{elements → blocks}/toggle_element.py +3 -6
  28. notionary/{elements → blocks}/toggleable_heading_element.py +5 -8
  29. notionary/{elements → blocks}/video_element.py +4 -6
  30. notionary/cli/main.py +245 -53
  31. notionary/cli/onboarding.py +117 -0
  32. notionary/database/__init__.py +0 -0
  33. notionary/database/client.py +132 -0
  34. notionary/database/database_exceptions.py +13 -0
  35. notionary/database/factory.py +0 -0
  36. notionary/database/filter_builder.py +175 -0
  37. notionary/database/notion_database.py +339 -128
  38. notionary/database/notion_database_provider.py +230 -0
  39. notionary/elements/__init__.py +0 -0
  40. notionary/models/notion_database_response.py +294 -13
  41. notionary/models/notion_page_response.py +9 -31
  42. notionary/models/search_response.py +0 -0
  43. notionary/page/__init__.py +0 -0
  44. notionary/page/client.py +110 -0
  45. notionary/page/content/page_content_retriever.py +5 -20
  46. notionary/page/content/page_content_writer.py +3 -4
  47. notionary/page/formatting/markdown_to_notion_converter.py +1 -3
  48. notionary/{prompting → page}/markdown_syntax_prompt_generator.py +1 -2
  49. notionary/page/notion_page.py +354 -317
  50. notionary/page/notion_to_markdown_converter.py +1 -4
  51. notionary/page/properites/property_value_extractor.py +0 -64
  52. notionary/page/{properites/property_formatter.py → property_formatter.py} +7 -4
  53. notionary/page/search_filter_builder.py +131 -0
  54. notionary/page/utils.py +60 -0
  55. notionary/util/__init__.py +12 -3
  56. notionary/util/factory_decorator.py +33 -0
  57. notionary/util/fuzzy_matcher.py +82 -0
  58. notionary/util/page_id_utils.py +0 -21
  59. notionary/util/singleton_metaclass.py +22 -0
  60. notionary/workspace.py +69 -0
  61. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/METADATA +4 -1
  62. notionary-0.2.14.dist-info/RECORD +72 -0
  63. notionary/database/database_discovery.py +0 -142
  64. notionary/database/notion_database_factory.py +0 -193
  65. notionary/elements/notion_block_element.py +0 -70
  66. notionary/exceptions/database_exceptions.py +0 -76
  67. notionary/exceptions/page_creation_exception.py +0 -9
  68. notionary/page/metadata/metadata_editor.py +0 -150
  69. notionary/page/metadata/notion_icon_manager.py +0 -77
  70. notionary/page/metadata/notion_page_cover_manager.py +0 -56
  71. notionary/page/notion_page_factory.py +0 -332
  72. notionary/page/properites/database_property_service.py +0 -302
  73. notionary/page/properites/page_property_manager.py +0 -152
  74. notionary/page/relations/notion_page_relation_manager.py +0 -350
  75. notionary/page/relations/notion_page_title_resolver.py +0 -104
  76. notionary/page/relations/page_database_relation.py +0 -68
  77. notionary/telemetry/__init__.py +0 -7
  78. notionary/telemetry/telemetry.py +0 -226
  79. notionary/telemetry/track_usage_decorator.py +0 -76
  80. notionary/util/warn_direct_constructor_usage.py +0 -54
  81. notionary-0.2.12.dist-info/RECORD +0 -70
  82. /notionary/util/{singleton.py → singleton_decorator.py} +0 -0
  83. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/WHEEL +0 -0
  84. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/entry_points.txt +0 -0
  85. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/licenses/LICENSE +0 -0
  86. {notionary-0.2.12.dist-info → notionary-0.2.14.dist-info}/top_level.txt +0 -0
@@ -1,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)