notionary 0.1.1__py3-none-any.whl → 0.1.3__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 (51) hide show
  1. notionary/__init__.py +9 -0
  2. notionary/core/__init__.py +0 -0
  3. notionary/core/converters/__init__.py +50 -0
  4. notionary/core/converters/elements/__init__.py +0 -0
  5. notionary/core/converters/elements/bookmark_element.py +224 -0
  6. notionary/core/converters/elements/callout_element.py +179 -0
  7. notionary/core/converters/elements/code_block_element.py +153 -0
  8. notionary/core/converters/elements/column_element.py +294 -0
  9. notionary/core/converters/elements/divider_element.py +73 -0
  10. notionary/core/converters/elements/heading_element.py +84 -0
  11. notionary/core/converters/elements/image_element.py +130 -0
  12. notionary/core/converters/elements/list_element.py +130 -0
  13. notionary/core/converters/elements/notion_block_element.py +51 -0
  14. notionary/core/converters/elements/paragraph_element.py +73 -0
  15. notionary/core/converters/elements/qoute_element.py +242 -0
  16. notionary/core/converters/elements/table_element.py +306 -0
  17. notionary/core/converters/elements/text_inline_formatter.py +294 -0
  18. notionary/core/converters/elements/todo_lists.py +114 -0
  19. notionary/core/converters/elements/toggle_element.py +205 -0
  20. notionary/core/converters/elements/video_element.py +159 -0
  21. notionary/core/converters/markdown_to_notion_converter.py +482 -0
  22. notionary/core/converters/notion_to_markdown_converter.py +45 -0
  23. notionary/core/converters/registry/__init__.py +0 -0
  24. notionary/core/converters/registry/block_element_registry.py +234 -0
  25. notionary/core/converters/registry/block_element_registry_builder.py +280 -0
  26. notionary/core/database/database_info_service.py +43 -0
  27. notionary/core/database/database_query_service.py +73 -0
  28. notionary/core/database/database_schema_service.py +57 -0
  29. notionary/core/database/models/page_result.py +10 -0
  30. notionary/core/database/notion_database_manager.py +332 -0
  31. notionary/core/database/notion_database_manager_factory.py +233 -0
  32. notionary/core/database/notion_database_schema.py +415 -0
  33. notionary/core/database/notion_database_writer.py +390 -0
  34. notionary/core/database/page_service.py +161 -0
  35. notionary/core/notion_client.py +134 -0
  36. notionary/core/page/meta_data/metadata_editor.py +37 -0
  37. notionary/core/page/notion_page_manager.py +110 -0
  38. notionary/core/page/page_content_manager.py +85 -0
  39. notionary/core/page/property_formatter.py +97 -0
  40. notionary/exceptions/database_exceptions.py +76 -0
  41. notionary/exceptions/page_creation_exception.py +9 -0
  42. notionary/util/logging_mixin.py +47 -0
  43. notionary/util/singleton_decorator.py +20 -0
  44. notionary/util/uuid_utils.py +24 -0
  45. {notionary-0.1.1.dist-info → notionary-0.1.3.dist-info}/METADATA +1 -1
  46. notionary-0.1.3.dist-info/RECORD +49 -0
  47. notionary-0.1.3.dist-info/top_level.txt +1 -0
  48. notionary-0.1.1.dist-info/RECORD +0 -5
  49. notionary-0.1.1.dist-info/top_level.txt +0 -1
  50. {notionary-0.1.1.dist-info → notionary-0.1.3.dist-info}/WHEEL +0 -0
  51. {notionary-0.1.1.dist-info → notionary-0.1.3.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,332 @@
1
+ from typing import Any, AsyncGenerator, Dict, List, Optional, Union
2
+
3
+ from notionary.core.database.database_info_service import DatabaseInfoService
4
+ from notionary.core.database.database_query_service import DatabaseQueryService
5
+ from notionary.core.database.database_schema_service import DatabaseSchemaService
6
+ from notionary.core.database.models.page_result import PageResult
7
+ from notionary.core.database.page_service import DatabasePageService
8
+ from notionary.core.notion_client import NotionClient
9
+ from notionary.core.database.notion_database_schema import NotionDatabaseSchema
10
+ from notionary.core.database.notion_database_writer import DatabaseWritter
11
+ from notionary.core.page.notion_page_manager import NotionPageManager
12
+ from notionary.exceptions.database_exceptions import (
13
+ DatabaseInitializationError,
14
+ PropertyError,
15
+ )
16
+ from notionary.util.logging_mixin import LoggingMixin
17
+ from notionary.util.uuid_utils import format_uuid
18
+
19
+
20
+ class NotionDatabaseManager(LoggingMixin):
21
+ """
22
+ High-level facade for working with Notion databases.
23
+ Provides simplified operations for creating, reading, updating and deleting pages.
24
+
25
+ Note:
26
+ It is recommended to create instances of this class using the NotionDatabaseFactory
27
+ instead of directly calling the constructor.
28
+ """
29
+
30
+ def __init__(self, database_id: str, token: Optional[str] = None):
31
+ """
32
+ Initialize the database facade with a database ID.
33
+
34
+ Note:
35
+ It's recommended to use NotionDatabaseFactory to create instances of this class
36
+ rather than using this constructor directly.
37
+
38
+ Args:
39
+ database_id: The ID of the Notion database
40
+ token: Optional Notion API token (uses environment variable if not provided)
41
+ """
42
+ self.database_id = format_uuid(database_id) or database_id
43
+ self._client = NotionClient(token=token)
44
+ self._schema = NotionDatabaseSchema(self.database_id, self._client)
45
+ self._writer = DatabaseWritter(self._client, self._schema)
46
+ self._initialized = False
47
+
48
+ self._info_service = DatabaseInfoService(self._client, self.database_id)
49
+ self._page_service = DatabasePageService(
50
+ self._client, self._schema, self._writer
51
+ )
52
+ self._query_service = DatabaseQueryService(self._schema)
53
+ self._schema_service = DatabaseSchemaService(self._schema)
54
+
55
+ @property
56
+ def title(self) -> Optional[str]:
57
+ """Get the database title."""
58
+ return self._info_service.title
59
+
60
+ async def initialize(self) -> bool:
61
+ """
62
+ Initialize the database facade by loading the schema.
63
+
64
+ This method needs to be called after creating a new instance via the constructor.
65
+ When using NotionDatabaseFactory, this is called automatically.
66
+ """
67
+ try:
68
+ success = await self._schema.load()
69
+ if not success:
70
+ self.logger.error(
71
+ "Failed to load schema for database %s", self.database_id
72
+ )
73
+ return False
74
+
75
+ await self._info_service.load_title()
76
+ self.logger.debug("Loaded database title: %s", self.title)
77
+
78
+ self._initialized = True
79
+ return True
80
+ except Exception as e:
81
+ self.logger.error("Error initializing database: %s", str(e))
82
+ return False
83
+
84
+ async def _ensure_initialized(self) -> None:
85
+ """
86
+ Ensure the database manager is initialized before use.
87
+
88
+ Raises:
89
+ DatabaseInitializationError: If the database isn't initialized
90
+ """
91
+ if not self._initialized:
92
+ raise DatabaseInitializationError(
93
+ self.database_id,
94
+ "Database manager not initialized. Call initialize() first.",
95
+ )
96
+
97
+ async def get_database_name(self) -> Optional[str]:
98
+ """
99
+ Get the name of the current database.
100
+
101
+ Returns:
102
+ The database name or None if it couldn't be retrieved
103
+ """
104
+ await self._ensure_initialized()
105
+
106
+ if self.title:
107
+ return self.title
108
+
109
+ try:
110
+ return await self._info_service.load_title()
111
+ except PropertyError as e:
112
+ self.logger.error("Error getting database name: %s", str(e))
113
+ return None
114
+
115
+ async def get_property_types(self) -> Dict[str, str]:
116
+ """
117
+ Get all property types for the database.
118
+
119
+ Returns:
120
+ Dictionary mapping property names to their types
121
+ """
122
+ await self._ensure_initialized()
123
+ return await self._schema_service.get_property_types()
124
+
125
+ async def get_select_options(self, property_name: str) -> List[Dict[str, str]]:
126
+ """
127
+ Get options for a select, multi-select, or status property.
128
+
129
+ Args:
130
+ property_name: Name of the property
131
+
132
+ Returns:
133
+ List of select options with name, id, and color (if available)
134
+ """
135
+ await self._ensure_initialized()
136
+ return await self._schema_service.get_select_options(property_name)
137
+
138
+ async def get_relation_options(
139
+ self, property_name: str, limit: int = 100
140
+ ) -> List[Dict[str, str]]:
141
+ """
142
+ Get available options for a relation property.
143
+
144
+ Args:
145
+ property_name: Name of the relation property
146
+ limit: Maximum number of options to retrieve
147
+
148
+ Returns:
149
+ List of relation options with id and title
150
+ """
151
+ await self._ensure_initialized()
152
+ return await self._schema_service.get_relation_options(property_name, limit)
153
+
154
+ async def create_page(
155
+ self,
156
+ properties: Dict[str, Any],
157
+ relations: Optional[Dict[str, Union[str, List[str]]]] = None,
158
+ ) -> PageResult:
159
+ """
160
+ Create a new page in the database.
161
+
162
+ Args:
163
+ properties: Dictionary of property names and values
164
+ relations: Optional dictionary of relation property names and titles
165
+
166
+ Returns:
167
+ Result object with success status and page information
168
+ """
169
+ await self._ensure_initialized()
170
+
171
+ result = await self._page_service.create_page(
172
+ self.database_id, properties, relations
173
+ )
174
+
175
+ if result["success"]:
176
+ self.logger.info(
177
+ "Created page %s in database %s",
178
+ result.get("page_id", ""),
179
+ self.database_id,
180
+ )
181
+ else:
182
+ self.logger.warning("Page creation failed: %s", result.get("message", ""))
183
+
184
+ return result
185
+
186
+ async def update_page(
187
+ self,
188
+ page_id: str,
189
+ properties: Optional[Dict[str, Any]] = None,
190
+ relations: Optional[Dict[str, Union[str, List[str]]]] = None,
191
+ ) -> PageResult:
192
+ """
193
+ Update an existing page.
194
+
195
+ Args:
196
+ page_id: The ID of the page to update
197
+ properties: Dictionary of property names and values to update
198
+ relations: Optional dictionary of relation property names and titles
199
+
200
+ Returns:
201
+ Result object with success status and message
202
+ """
203
+ await self._ensure_initialized()
204
+
205
+ self.logger.debug("Updating page %s", page_id)
206
+
207
+ result = await self._page_service.update_page(page_id, properties, relations)
208
+
209
+ if result["success"]:
210
+ self.logger.info("Successfully updated page %s", result.get("page_id", ""))
211
+ else:
212
+ self.logger.error(
213
+ "Error updating page %s: %s", page_id, result.get("message", "")
214
+ )
215
+
216
+ return result
217
+
218
+ async def delete_page(self, page_id: str) -> PageResult:
219
+ """
220
+ Delete (archive) a page.
221
+
222
+ Args:
223
+ page_id: The ID of the page to delete
224
+
225
+ Returns:
226
+ Result object with success status and message
227
+ """
228
+ await self._ensure_initialized()
229
+
230
+ self.logger.debug("Deleting page %s", page_id)
231
+
232
+ result = await self._page_service.delete_page(page_id)
233
+
234
+ if result["success"]:
235
+ self.logger.info("Successfully deleted page %s", result.get("page_id", ""))
236
+ else:
237
+ self.logger.error(
238
+ "Error deleting page %s: %s", page_id, result.get("message", "")
239
+ )
240
+
241
+ return result
242
+
243
+ async def get_pages(
244
+ self,
245
+ limit: int = 100,
246
+ filter_conditions: Optional[Dict[str, Any]] = None,
247
+ sorts: Optional[List[Dict[str, Any]]] = None,
248
+ ) -> List[NotionPageManager]:
249
+ """
250
+ Get all pages from the database.
251
+
252
+ Args:
253
+ limit: Maximum number of pages to retrieve
254
+ filter_conditions: Optional filter to apply to the database query
255
+ sorts: Optional sort instructions for the database query
256
+
257
+ Returns:
258
+ List of NotionPageManager instances for each page
259
+ """
260
+ await self._ensure_initialized()
261
+
262
+ self.logger.debug(
263
+ "Getting up to %d pages with filter: %s, sorts: %s",
264
+ limit,
265
+ filter_conditions,
266
+ sorts,
267
+ )
268
+
269
+ pages = await self._query_service.get_pages(
270
+ self.database_id, limit, filter_conditions, sorts
271
+ )
272
+
273
+ self.logger.debug(
274
+ "Retrieved %d pages from database %s", len(pages), self.database_id
275
+ )
276
+ return pages
277
+
278
+ async def iter_pages(
279
+ self,
280
+ page_size: int = 100,
281
+ filter_conditions: Optional[Dict[str, Any]] = None,
282
+ sorts: Optional[List[Dict[str, Any]]] = None,
283
+ ) -> AsyncGenerator[NotionPageManager, None]:
284
+ """
285
+ Asynchronous generator that yields pages from the database.
286
+
287
+ Args:
288
+ page_size: Number of pages to fetch per request
289
+ filter_conditions: Optional filter to apply to the database query
290
+ sorts: Optional sort instructions for the database query
291
+
292
+ Yields:
293
+ NotionPageManager instances for each page
294
+ """
295
+ await self._ensure_initialized()
296
+
297
+ self.logger.debug(
298
+ "Iterating pages with page_size: %d, filter: %s, sorts: %s",
299
+ page_size,
300
+ filter_conditions,
301
+ sorts,
302
+ )
303
+
304
+ async for page_manager in self._query_service.iter_pages(
305
+ self.database_id, page_size, filter_conditions, sorts
306
+ ):
307
+ yield page_manager
308
+
309
+ async def get_page_manager(self, page_id: str) -> Optional[NotionPageManager]:
310
+ """
311
+ Get a NotionPageManager for a specific page.
312
+
313
+ Args:
314
+ page_id: The ID of the page
315
+
316
+ Returns:
317
+ NotionPageManager instance or None if the page wasn't found
318
+ """
319
+ await self._ensure_initialized()
320
+
321
+ self.logger.debug("Getting page manager for page %s", page_id)
322
+
323
+ page_manager = await self._page_service.get_page_manager(page_id)
324
+
325
+ if not page_manager:
326
+ self.logger.error("Page %s not found", page_id)
327
+
328
+ return page_manager
329
+
330
+ async def close(self) -> None:
331
+ """Close the client connection."""
332
+ await self._client.close()
@@ -0,0 +1,233 @@
1
+ import logging
2
+ from typing import List, Optional, Dict, Any
3
+ from difflib import SequenceMatcher
4
+
5
+ from notionary.core.notion_client import NotionClient
6
+ from notionary.core.database.notion_database_manager import NotionDatabaseManager
7
+ from notionary.exceptions.database_exceptions import (
8
+ DatabaseConnectionError,
9
+ DatabaseInitializationError,
10
+ DatabaseNotFoundException,
11
+ DatabaseParsingError,
12
+ NotionDatabaseException,
13
+ )
14
+ from notionary.util.logging_mixin import LoggingMixin
15
+ from notionary.util.uuid_utils import format_uuid
16
+
17
+
18
+ class NotionDatabaseFactory(LoggingMixin):
19
+ """
20
+ Factory class for creating NotionDatabaseManager instances.
21
+ Provides methods for creating managers by database ID or name.
22
+ """
23
+
24
+ @classmethod
25
+ def class_logger(cls):
26
+ """Class logger - for class methods"""
27
+ return logging.getLogger(cls.__name__)
28
+
29
+ @classmethod
30
+ async def from_database_id(
31
+ cls, database_id: str, token: Optional[str] = None
32
+ ) -> NotionDatabaseManager:
33
+ """
34
+ Create a NotionDatabaseManager from a database ID.
35
+
36
+ Args:
37
+ database_id: The ID of the Notion database
38
+ token: Optional Notion API token (uses environment variable if not provided)
39
+
40
+ Returns:
41
+ An initialized NotionDatabaseManager instance
42
+ """
43
+ logger = cls.class_logger()
44
+
45
+ try:
46
+ formatted_id = format_uuid(database_id) or database_id
47
+
48
+ manager = NotionDatabaseManager(formatted_id, token)
49
+
50
+ success = await manager.initialize()
51
+
52
+ if not success:
53
+ error_msg = (
54
+ f"Failed to initialize database manager for ID: {formatted_id}"
55
+ )
56
+ logger.error(error_msg)
57
+ raise DatabaseInitializationError(formatted_id, error_msg)
58
+
59
+ logger.info(
60
+ lambda: f"Successfully created database manager for ID: {formatted_id}"
61
+ )
62
+ return manager
63
+
64
+ except DatabaseInitializationError:
65
+ # Re-raise the already typed exception
66
+ raise
67
+ except NotionDatabaseException:
68
+ # Re-raise other custom exceptions
69
+ raise
70
+ except Exception as e:
71
+ error_msg = f"Error connecting to database {database_id}: {str(e)}"
72
+ logger.error(error_msg)
73
+ raise DatabaseConnectionError(error_msg) from e
74
+
75
+ @classmethod
76
+ async def from_database_name(
77
+ cls, database_name: str, token: Optional[str] = None
78
+ ) -> NotionDatabaseManager:
79
+ """
80
+ Create a NotionDatabaseManager by finding a database with a matching name.
81
+ Uses fuzzy matching to find the closest match to the given name.
82
+
83
+ Args:
84
+ database_name: The name of the Notion database to search for
85
+ token: Optional Notion API token (uses environment variable if not provided)
86
+
87
+ Returns:
88
+ An initialized NotionDatabaseManager instance
89
+ """
90
+ logger = cls.class_logger()
91
+ logger.debug(lambda: f"Searching for database with name: {database_name}")
92
+
93
+ client = NotionClient(token=token)
94
+
95
+ try:
96
+ logger.debug("Using search endpoint to find databases")
97
+
98
+ # Create search query for databases
99
+ search_payload = {
100
+ "filter": {"property": "object", "value": "database"},
101
+ "page_size": 100,
102
+ }
103
+
104
+ # Perform search
105
+ response = await client.post("search", search_payload)
106
+
107
+ if not response or "results" not in response:
108
+ error_msg = "Failed to fetch databases using search endpoint"
109
+ logger.error(error_msg)
110
+ raise DatabaseConnectionError(error_msg)
111
+
112
+ databases = response.get("results", [])
113
+
114
+ if not databases:
115
+ error_msg = "No databases found"
116
+ logger.warning(error_msg)
117
+ raise DatabaseNotFoundException(database_name, error_msg)
118
+
119
+ logger.debug(
120
+ lambda: f"Found {len(databases)} databases, searching for best match"
121
+ )
122
+
123
+ # Find best match using fuzzy matching
124
+ best_match = None
125
+ best_score = 0
126
+
127
+ for db in databases:
128
+ title = cls._extract_title_from_database(db)
129
+
130
+ score = SequenceMatcher(
131
+ None, database_name.lower(), title.lower()
132
+ ).ratio()
133
+
134
+ if score > best_score:
135
+ best_score = score
136
+ best_match = db
137
+
138
+ # Use a minimum threshold for match quality (0.6 = 60% similarity)
139
+ if best_score < 0.6 or not best_match:
140
+ error_msg = f"No good database name match found for '{database_name}'. Best match had score {best_score:.2f}"
141
+ logger.warning(error_msg)
142
+ raise DatabaseNotFoundException(database_name, error_msg)
143
+
144
+ database_id = best_match.get("id")
145
+
146
+ if not database_id:
147
+ error_msg = "Best match database has no ID"
148
+ logger.error(error_msg)
149
+ raise DatabaseParsingError(error_msg)
150
+
151
+ matched_name = cls._extract_title_from_database(best_match)
152
+
153
+ logger.info(
154
+ lambda: f"Found matching database: '{matched_name}' (ID: {database_id}) with score: {best_score:.2f}"
155
+ )
156
+
157
+ manager = NotionDatabaseManager(database_id, token)
158
+ success = await manager.initialize()
159
+
160
+ if not success:
161
+ error_msg = (
162
+ f"Failed to initialize database manager for database {database_id}"
163
+ )
164
+ logger.error(error_msg)
165
+ raise DatabaseInitializationError(database_id, error_msg)
166
+
167
+ logger.info(
168
+ lambda: f"Successfully created database manager for '{matched_name}'"
169
+ )
170
+ await client.close()
171
+ return manager
172
+
173
+ except NotionDatabaseException:
174
+ await client.close()
175
+ raise
176
+ except Exception as e:
177
+ error_msg = f"Error finding database by name: {str(e)}"
178
+ logger.error(error_msg)
179
+ await client.close()
180
+ raise DatabaseConnectionError(error_msg) from e
181
+
182
+ @classmethod
183
+ def _extract_title_from_database(cls, database: Dict[str, Any]) -> str:
184
+ """
185
+ Extract the title from a database object.
186
+
187
+ Args:
188
+ database: A database object from the Notion API
189
+
190
+ Returns:
191
+ The extracted title or "Untitled" if no title is found
192
+
193
+ Raises:
194
+ DatabaseParsingError: If there's an error parsing the database title
195
+ """
196
+ try:
197
+ # Check for title in the root object
198
+ if "title" in database:
199
+ return cls._extract_text_from_rich_text(database["title"])
200
+
201
+ # Check for title in properties
202
+ if "properties" in database and "title" in database["properties"]:
203
+ title_prop = database["properties"]["title"]
204
+ if "title" in title_prop:
205
+ return cls._extract_text_from_rich_text(title_prop["title"])
206
+
207
+ return "Untitled"
208
+
209
+ except Exception as e:
210
+ error_msg = f"Error extracting database title: {str(e)}"
211
+ cls.class_logger().warning(error_msg)
212
+ raise DatabaseParsingError(error_msg) from e
213
+
214
+ @classmethod
215
+ def _extract_text_from_rich_text(cls, rich_text: List[Dict[str, Any]]) -> str:
216
+ """
217
+ Extract plain text from a rich text array.
218
+
219
+ Args:
220
+ rich_text: A list of rich text objects from Notion API
221
+
222
+ Returns:
223
+ The concatenated plain text content
224
+ """
225
+ if not rich_text:
226
+ return ""
227
+
228
+ text_parts = []
229
+ for text_obj in rich_text:
230
+ if "plain_text" in text_obj:
231
+ text_parts.append(text_obj["plain_text"])
232
+
233
+ return "".join(text_parts)