notionary 0.1.6__py3-none-any.whl → 0.1.8__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 (29) hide show
  1. notionary/core/database/notion_database_manager.py +146 -232
  2. notionary/core/database/notion_database_manager_factory.py +9 -52
  3. notionary/core/database/notion_database_schema.py +1 -314
  4. notionary/core/notion_client.py +2 -10
  5. notionary/core/page/{page_content_manager.py → content/page_content_manager.py} +10 -6
  6. notionary/core/page/metadata/metadata_editor.py +109 -0
  7. notionary/core/page/metadata/notion_icon_manager.py +46 -0
  8. notionary/core/page/{meta_data/metadata_editor.py → metadata/notion_page_cover_manager.py} +20 -30
  9. notionary/core/page/notion_page_manager.py +218 -59
  10. notionary/core/page/properites/database_property_service.py +330 -0
  11. notionary/core/page/properites/page_property_manager.py +146 -0
  12. notionary/core/page/{property_formatter.py → properites/property_formatter.py} +19 -20
  13. notionary/core/page/properites/property_operation_result.py +103 -0
  14. notionary/core/page/properites/property_value_extractor.py +46 -0
  15. notionary/core/page/relations/notion_page_relation_manager.py +364 -0
  16. notionary/core/page/relations/notion_page_title_resolver.py +43 -0
  17. notionary/core/page/relations/page_database_relation.py +70 -0
  18. notionary/core/page/relations/relation_operation_result.py +135 -0
  19. notionary/util/page_id_utils.py +44 -0
  20. {notionary-0.1.6.dist-info → notionary-0.1.8.dist-info}/METADATA +1 -1
  21. {notionary-0.1.6.dist-info → notionary-0.1.8.dist-info}/RECORD +24 -18
  22. notionary/core/database/database_query_service.py +0 -73
  23. notionary/core/database/database_schema_service.py +0 -57
  24. notionary/core/database/notion_database_writer.py +0 -390
  25. notionary/core/database/page_service.py +0 -161
  26. notionary/util/uuid_utils.py +0 -24
  27. {notionary-0.1.6.dist-info → notionary-0.1.8.dist-info}/WHEEL +0 -0
  28. {notionary-0.1.6.dist-info → notionary-0.1.8.dist-info}/licenses/LICENSE +0 -0
  29. {notionary-0.1.6.dist-info → notionary-0.1.8.dist-info}/top_level.txt +0 -0
@@ -1,75 +1,12 @@
1
1
  from typing import (
2
2
  AsyncGenerator,
3
3
  Dict,
4
- List,
5
4
  Optional,
6
5
  Any,
7
- TypedDict,
8
- Union,
9
- cast,
10
- Literal,
11
6
  )
12
7
  from notionary.core.notion_client import NotionClient
13
- from notionary.core.page.notion_page_manager import NotionPageManager
14
8
  from notionary.util.logging_mixin import LoggingMixin
15
9
 
16
-
17
- class NotionTextContent(TypedDict):
18
- plain_text: str
19
-
20
-
21
- class NotionTitleProperty(TypedDict):
22
- type: Literal["title"]
23
- title: List[NotionTextContent]
24
-
25
-
26
- class NotionSelectOption(TypedDict):
27
- name: str
28
- id: Optional[str]
29
- color: Optional[str]
30
-
31
-
32
- class NotionSelectProperty(TypedDict):
33
- type: Literal["select"]
34
- select: Dict[str, List[NotionSelectOption]]
35
-
36
-
37
- class NotionMultiSelectProperty(TypedDict):
38
- type: Literal["multi_select"]
39
- multi_select: Dict[str, List[NotionSelectOption]]
40
-
41
-
42
- class NotionStatusProperty(TypedDict):
43
- type: Literal["status"]
44
- status: Dict[str, List[NotionSelectOption]]
45
-
46
-
47
- class NotionRelationProperty(TypedDict):
48
- type: Literal["relation"]
49
- relation: Dict[str, str]
50
-
51
-
52
- class NotionNumberProperty(TypedDict):
53
- type: Literal["number"]
54
- number: Dict[str, Any]
55
-
56
-
57
- NotionPropertyType = Union[
58
- NotionTitleProperty,
59
- NotionSelectProperty,
60
- NotionMultiSelectProperty,
61
- NotionStatusProperty,
62
- NotionRelationProperty,
63
- NotionNumberProperty,
64
- Dict[str, Any], # Fallback
65
- ]
66
-
67
-
68
- class RelationOption(TypedDict):
69
- id: str
70
- title: str
71
-
72
-
73
10
  class NotionDatabaseAccessor(LoggingMixin):
74
11
  """
75
12
  A utility class that provides methods to access Notion databases.
@@ -164,254 +101,4 @@ class NotionDatabaseAccessor(LoggingMixin):
164
101
  if title_parts:
165
102
  title = "".join(title_parts)
166
103
 
167
- return title
168
-
169
-
170
- class NotionDatabaseSchema:
171
- """
172
- Represents the schema of a specific Notion database.
173
- Manages property information, options, and relations for a single database.
174
- """
175
-
176
- def __init__(self, database_id: str, client: NotionClient) -> None:
177
- """
178
- Initialize a database schema handler for a specific database.
179
-
180
- Args:
181
- database_id: The ID of the database
182
- client: An instance of NotionClient for API requests
183
- """
184
- self.database_id: str = database_id
185
- self._client: NotionClient = client
186
- self._properties: Dict[str, NotionPropertyType] = {}
187
- self._loaded: bool = False
188
-
189
- async def load(self) -> bool:
190
- """
191
- Load the database schema from the Notion API.
192
-
193
- Returns:
194
- True if the schema was loaded successfully, False otherwise
195
- """
196
- if self._loaded:
197
- return True
198
-
199
- db_details = await self._client.get(f"databases/{self.database_id}")
200
- if not db_details or "properties" not in db_details:
201
- return False
202
-
203
- self._properties = db_details["properties"]
204
- self._loaded = True
205
- return True
206
-
207
- async def get_property_types(self) -> Dict[str, str]:
208
- """
209
- Get a mapping of property names to their types.
210
-
211
- Returns:
212
- A dictionary mapping property names to types
213
- """
214
- if not self._loaded:
215
- await self.load()
216
-
217
- return {name: prop.get("type", "") for name, prop in self._properties.items()}
218
-
219
- async def get_select_options(self, property_name: str) -> List[NotionSelectOption]:
220
- """
221
- Get the options for a select, multi_select, or status property.
222
-
223
- Args:
224
- property_name: The name of the property
225
-
226
- Returns:
227
- A list of option objects
228
- """
229
- if not self._loaded:
230
- await self.load()
231
-
232
- if property_name not in self._properties:
233
- return []
234
-
235
- prop = self._properties[property_name]
236
- prop_type = prop.get("type", "")
237
-
238
- if prop_type not in ["select", "multi_select", "status"]:
239
- return []
240
-
241
- if prop_type in prop and "options" in prop[prop_type]:
242
- return cast(List[NotionSelectOption], prop[prop_type]["options"])
243
-
244
- return []
245
-
246
- async def get_relation_options(
247
- self, property_name: str, limit: int = 100
248
- ) -> List[RelationOption]:
249
- """
250
- Get available options for a relation property (pages in the related database).
251
-
252
- Args:
253
- property_name: The name of the relation property
254
- limit: Maximum number of options to retrieve
255
-
256
- Returns:
257
- List of options with id and title
258
- """
259
- related_db_id = await self.get_relation_database_id(property_name)
260
- if not related_db_id:
261
- return []
262
-
263
- pages = await self._query_database_pages(related_db_id, limit)
264
- return self._extract_page_titles_and_ids(pages)
265
-
266
- async def get_relation_database_id(self, property_name: str) -> Optional[str]:
267
- """
268
- Get the ID of the related database for a relation property.
269
-
270
- Args:
271
- property_name: The name of the property
272
-
273
- Returns:
274
- The ID of the related database or None
275
- """
276
- if not self._loaded:
277
- await self.load()
278
-
279
- if property_name not in self._properties:
280
- return None
281
-
282
- prop = self._properties[property_name]
283
- prop_type = prop.get("type", "")
284
-
285
- if prop_type != "relation" or "relation" not in prop:
286
- return None
287
-
288
- relation_prop = cast(NotionRelationProperty, prop)
289
- return relation_prop["relation"].get("database_id")
290
-
291
- def _extract_page_titles_and_ids(
292
- self, pages: List[NotionPageManager]
293
- ) -> List[RelationOption]:
294
- """
295
- Extract titles and IDs from page objects.
296
-
297
- Args:
298
- pages: List of page objects from the Notion API
299
-
300
- Returns:
301
- List of dictionaries with id and title for each page
302
- """
303
- options: List[RelationOption] = []
304
-
305
- for page_manager in pages:
306
- page_title = page_manager.title or "Untitled"
307
- options.append({"id": page_manager.page_id, "title": page_title})
308
-
309
- return options
310
-
311
- async def _query_database_pages(
312
- self, database_id: str, limit: int = 100
313
- ) -> List[Dict[str, Any]]:
314
- """
315
- Returns:
316
- List of page objects from the Notion API
317
- """
318
- pages: List[Dict[str, Any]] = []
319
- count = 0
320
-
321
- async for page in self.iter_database_pages(
322
- database_id=database_id, page_size=min(limit, 100)
323
- ):
324
- pages.append(page)
325
- count += 1
326
-
327
- if count >= limit:
328
- break
329
-
330
- return pages
331
-
332
- async def iter_database_pages(
333
- self,
334
- database_id: Optional[str] = None,
335
- page_size: int = 100,
336
- filter_conditions: Optional[Dict[str, Any]] = None,
337
- sorts: Optional[List[Dict[str, Any]]] = None,
338
- ) -> AsyncGenerator[NotionPageManager, None]:
339
- """
340
- Asynchronous generator that yields pages from a Notion database one by one.
341
-
342
- Uses the Notion API to provide paginated access to all pages in a database
343
- without loading all of them into memory at once.
344
-
345
- Args:
346
- database_id: The ID of the database to query (uses self.database_id if None)
347
- page_size: The number of pages to fetch per request
348
- filter_conditions: Optional filter to apply to the database query
349
- sorts: Optional sort instructions for the database query
350
-
351
- Yields:
352
- Individual page objects from the Notion API
353
- """
354
- db_id = database_id or self.database_id
355
- if not db_id:
356
- raise ValueError("No database ID provided")
357
-
358
- start_cursor: Optional[str] = None
359
- has_more = True
360
-
361
- body: Dict[str, Any] = {"page_size": page_size}
362
-
363
- if filter_conditions:
364
- body["filter"] = filter_conditions
365
-
366
- if sorts:
367
- body["sorts"] = sorts
368
-
369
- while has_more:
370
- current_body = body.copy()
371
- if start_cursor:
372
- current_body["start_cursor"] = start_cursor
373
-
374
- result = await self._client.post(
375
- f"databases/{db_id}/query", data=current_body
376
- )
377
-
378
- if not result or "results" not in result:
379
- return
380
-
381
- for page in result["results"]:
382
- page_id: str = page.get("id", "")
383
- title = self._extract_page_title(page)
384
-
385
- page_url = f"https://notion.so/{page_id.replace('-', '')}"
386
-
387
- notion_page_manager = NotionPageManager(page_id=page_id, title=title, url=page_url)
388
- yield notion_page_manager
389
-
390
- has_more = result.get("has_more", False)
391
- start_cursor = result.get("next_cursor") if has_more else None
392
-
393
- def _extract_page_title(self, page: Dict[str, Any]) -> str:
394
- """
395
- Extracts the title from a Notion page object.
396
-
397
- Args:
398
- page: The Notion page object
399
-
400
- Returns:
401
- The extracted title as a string, or an empty string if no title found
402
- """
403
- properties = page.get("properties", {})
404
- if not properties:
405
- return ""
406
-
407
- for prop_value in properties.values():
408
- if prop_value.get("type") != "title":
409
- continue
410
-
411
- title_array = prop_value.get("title", [])
412
- if not title_array:
413
- continue
414
-
415
- return title_array[0].get("plain_text", "")
416
-
417
- return ""
104
+ return title
@@ -1,12 +1,11 @@
1
1
  import asyncio
2
2
  import os
3
+ import weakref
3
4
  from enum import Enum
4
5
  from typing import Dict, Any, Optional, Union
5
6
  import httpx
6
7
  from dotenv import load_dotenv
7
8
  from notionary.util.logging_mixin import LoggingMixin
8
- import weakref
9
-
10
9
 
11
10
  class HttpMethod(Enum):
12
11
  """Enum für HTTP-Methoden."""
@@ -16,7 +15,6 @@ class HttpMethod(Enum):
16
15
  PATCH = "patch"
17
16
  DELETE = "delete"
18
17
 
19
-
20
18
  class NotionClient(LoggingMixin):
21
19
  """Verbesserter Notion-Client mit automatischer Ressourcenverwaltung."""
22
20
 
@@ -113,12 +111,6 @@ class NotionClient(LoggingMixin):
113
111
  return None
114
112
 
115
113
  def __del__(self):
116
- """
117
- Destruktor, der beim Garbage Collecting aufgerufen wird.
118
-
119
- Hinweis: Dies ist nur ein Fallback, da __del__ nicht garantiert für async Cleanup funktioniert.
120
- Die bessere Praxis ist, close() explizit zu rufen, wenn möglich.
121
- """
122
114
  if not hasattr(self, "client") or not self.client:
123
115
  return
124
116
 
@@ -134,4 +126,4 @@ class NotionClient(LoggingMixin):
134
126
  loop.create_task(self.close())
135
127
  self.logger.debug("Created cleanup task for NotionClient")
136
128
  except RuntimeError:
137
- self.logger.warning("No event loop available for auto-closing NotionClient")
129
+ self.logger.warning("No event loop available for auto-closing NotionClient")
@@ -48,26 +48,30 @@ class PageContentManager(LoggingMixin):
48
48
  return "No content to delete."
49
49
 
50
50
  deleted = 0
51
- for b in results:
52
- if await self._client.delete(f"blocks/{b['id']}"):
51
+ skipped = 0
52
+ for block in results:
53
+ if block.get("type") in ["child_database", "database", "linked_database"]:
54
+ skipped += 1
55
+ continue
56
+
57
+ if await self._client.delete(f"blocks/{block['id']}"):
53
58
  deleted += 1
54
59
 
55
- return f"Deleted {deleted}/{len(results)} blocks."
60
+ return f"Deleted {deleted}/{len(results)} blocks. Skipped {skipped} database blocks."
56
61
 
57
- # Methods from PageContentReader
58
62
  async def get_blocks(self) -> List[Dict[str, Any]]:
59
63
  result = await self._client.get(f"blocks/{self.page_id}/children")
60
64
  if not result:
61
65
  self.logger.error("Error retrieving page content: %s", result.error)
62
66
  return []
63
- return result.data.get("results", [])
67
+ return result.get("results", [])
64
68
 
65
69
  async def get_block_children(self, block_id: str) -> List[Dict[str, Any]]:
66
70
  result = await self._client.get(f"blocks/{block_id}/children")
67
71
  if not result:
68
72
  self.logger.error("Error retrieving block children: %s", result.error)
69
73
  return []
70
- return result.data.get("results", [])
74
+ return result.get("results", [])
71
75
 
72
76
  async def get_page_blocks_with_children(self) -> List[Dict[str, Any]]:
73
77
  blocks = await self.get_blocks()
@@ -0,0 +1,109 @@
1
+ from typing import Any, Dict, Optional
2
+ from notionary.core.notion_client import NotionClient
3
+ from notionary.core.page.properites.property_formatter import NotionPropertyFormatter
4
+ from notionary.util.logging_mixin import LoggingMixin
5
+
6
+
7
+ class MetadataEditor(LoggingMixin):
8
+ def __init__(self, page_id: str, client: NotionClient):
9
+ self.page_id = page_id
10
+ self._client = client
11
+ self._property_formatter = NotionPropertyFormatter()
12
+
13
+ async def set_title(self, title: str) -> Optional[Dict[str, Any]]:
14
+ return await self._client.patch(
15
+ f"pages/{self.page_id}",
16
+ {
17
+ "properties": {
18
+ "title": {"title": [{"type": "text", "text": {"content": title}}]}
19
+ }
20
+ },
21
+ )
22
+
23
+ async def set_property(self, property_name: str, property_value: Any, property_type: str) -> Optional[Dict[str, Any]]:
24
+ """
25
+ Generic method to set any property on a Notion page.
26
+
27
+ Args:
28
+ property_name: The name of the property in Notion
29
+ property_value: The value to set
30
+ property_type: The type of property ('select', 'multi_select', 'status', 'relation', etc.)
31
+
32
+ Returns:
33
+ Optional[Dict[str, Any]]: The API response or None if the operation fails
34
+ """
35
+ property_payload = self._property_formatter.format_value(property_type, property_value)
36
+
37
+ if not property_payload:
38
+ self.logger.warning("Could not create payload for property type: %s", property_type)
39
+ return None
40
+
41
+ return await self._client.patch(
42
+ f"pages/{self.page_id}",
43
+ {
44
+ "properties": {
45
+ property_name: property_payload
46
+ }
47
+ },
48
+ )
49
+
50
+
51
+ async def get_property_schema(self) -> Dict[str, Dict[str, Any]]:
52
+ """
53
+ Retrieves the schema for all properties of the page.
54
+
55
+ Returns:
56
+ Dict[str, Dict[str, Any]]: A dictionary mapping property names to their schema
57
+ """
58
+ page_data = await self._client.get_page(self.page_id)
59
+ property_schema = {}
60
+
61
+ if not page_data or "properties" not in page_data:
62
+ return property_schema
63
+
64
+ for prop_name, prop_data in page_data["properties"].items():
65
+ prop_type = prop_data.get("type")
66
+ property_schema[prop_name] = {
67
+ "id": prop_data.get("id"),
68
+ "type": prop_type,
69
+ "name": prop_name
70
+ }
71
+
72
+ try:
73
+ if prop_type == "select" and "select" in prop_data:
74
+ # Make sure prop_data["select"] is a dictionary before calling .get()
75
+ if isinstance(prop_data["select"], dict):
76
+ property_schema[prop_name]["options"] = prop_data["select"].get("options", [])
77
+ elif prop_type == "multi_select" and "multi_select" in prop_data:
78
+ # Make sure prop_data["multi_select"] is a dictionary before calling .get()
79
+ if isinstance(prop_data["multi_select"], dict):
80
+ property_schema[prop_name]["options"] = prop_data["multi_select"].get("options", [])
81
+ elif prop_type == "status" and "status" in prop_data:
82
+ # Make sure prop_data["status"] is a dictionary before calling .get()
83
+ if isinstance(prop_data["status"], dict):
84
+ property_schema[prop_name]["options"] = prop_data["status"].get("options", [])
85
+ except Exception as e:
86
+ if hasattr(self, 'logger') and self.logger:
87
+ self.logger.warning("Error processing property schema for '%s': %s", prop_name, e)
88
+
89
+ return property_schema
90
+
91
+ async def set_property_by_name(self, property_name: str, value: Any) -> Optional[Dict[str, Any]]:
92
+ """
93
+ Sets a property value based on the property name, automatically detecting the property type.
94
+
95
+ Args:
96
+ property_name: The name of the property in Notion
97
+ value: The value to set
98
+
99
+ Returns:
100
+ Optional[Dict[str, Any]]: The API response or None if the operation fails
101
+ """
102
+ property_schema = await self.get_property_schema()
103
+
104
+ if property_name not in property_schema:
105
+ self.logger.warning("Property '%s' not found in database schema", property_name)
106
+ return None
107
+
108
+ property_type = property_schema[property_name]["type"]
109
+ return await self.set_property(property_name, value, property_type)
@@ -0,0 +1,46 @@
1
+ from typing import Any, Dict, Optional
2
+
3
+ from notionary.core.notion_client import NotionClient
4
+ from notionary.util.logging_mixin import LoggingMixin
5
+
6
+ class NotionPageIconManager(LoggingMixin):
7
+ def __init__(self, page_id: str, client: NotionClient):
8
+ self.page_id = page_id
9
+ self._client = client
10
+
11
+ async def set_icon(
12
+ self, emoji: Optional[str] = None, external_url: Optional[str] = None
13
+ ) -> Optional[Dict[str, Any]]:
14
+ if emoji:
15
+ icon = {"type": "emoji", "emoji": emoji}
16
+ elif external_url:
17
+ icon = {"type": "external", "external": {"url": external_url}}
18
+ else:
19
+ return None
20
+
21
+ return await self._client.patch(f"pages/{self.page_id}", {"icon": icon})
22
+
23
+
24
+ async def get_icon(self) -> Optional[str]:
25
+ """
26
+ Retrieves the page icon - either emoji or external URL.
27
+
28
+ Returns:
29
+ str: Emoji character or URL if set, None if no icon
30
+ """
31
+ page_data = await self._client.get_page(self.page_id)
32
+
33
+ if not page_data or "icon" not in page_data:
34
+ return None
35
+
36
+ icon_data = page_data.get("icon", {})
37
+ icon_type = icon_data.get("type")
38
+
39
+ if icon_type == "emoji":
40
+ return icon_data.get("emoji")
41
+ elif icon_type == "external":
42
+ return icon_data.get("external", {}).get("url")
43
+
44
+ return None
45
+
46
+
@@ -1,48 +1,25 @@
1
+
1
2
  import random
2
3
  from typing import Any, Dict, Optional
3
4
  from notionary.core.notion_client import NotionClient
4
5
  from notionary.util.logging_mixin import LoggingMixin
5
6
 
6
-
7
- class MetadataEditor(LoggingMixin):
7
+ class NotionPageCoverManager(LoggingMixin):
8
8
  def __init__(self, page_id: str, client: NotionClient):
9
9
  self.page_id = page_id
10
10
  self._client = client
11
-
12
- async def set_title(self, title: str) -> Optional[Dict[str, Any]]:
13
- return await self._client.patch(
14
- f"pages/{self.page_id}",
15
- {
16
- "properties": {
17
- "title": {"title": [{"type": "text", "text": {"content": title}}]}
18
- }
19
- },
20
- )
21
-
22
- async def set_icon(
23
- self, emoji: Optional[str] = None, external_url: Optional[str] = None
24
- ) -> Optional[Dict[str, Any]]:
25
- if emoji:
26
- icon = {"type": "emoji", "emoji": emoji}
27
- elif external_url:
28
- icon = {"type": "external", "external": {"url": external_url}}
29
- else:
30
- return None
31
-
32
- return await self._client.patch(f"pages/{self.page_id}", {"icon": icon})
33
-
11
+
34
12
  async def set_cover(self, external_url: str) -> Optional[Dict[str, Any]]:
13
+ """Sets a cover image from an external URL.
14
+ """
15
+
35
16
  return await self._client.patch(
36
17
  f"pages/{self.page_id}",
37
18
  {"cover": {"type": "external", "external": {"url": external_url}}},
38
19
  )
39
20
 
40
21
  async def set_random_gradient_cover(self) -> Optional[Dict[str, Any]]:
41
- """
42
- Sets a random gradient cover from Notion's default gradient covers.
43
-
44
- Returns:
45
- Optional[Dict[str, Any]]: The API response or None if the operation fails
22
+ """ Sets a random gradient cover from Notion's default gradient covers.
46
23
  """
47
24
  default_notion_covers = [
48
25
  "https://www.notion.so/images/page-cover/gradients_8.png",
@@ -56,3 +33,16 @@ class MetadataEditor(LoggingMixin):
56
33
  random_cover_url = random.choice(default_notion_covers)
57
34
 
58
35
  return await self.set_cover(random_cover_url)
36
+
37
+
38
+ async def get_cover_url(self) -> str:
39
+ """Retrieves the current cover image URL of the page.
40
+ """
41
+
42
+ page_data = await self._client.get_page(self.page_id)
43
+
44
+ if not page_data:
45
+ return ""
46
+
47
+ return page_data.get("cover", {}).get("external", {}).get("url", "")
48
+