notionary 0.2.16__py3-none-any.whl → 0.2.17__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 (35) hide show
  1. notionary/__init__.py +9 -5
  2. notionary/base_notion_client.py +18 -7
  3. notionary/blocks/__init__.py +2 -0
  4. notionary/blocks/document_element.py +194 -0
  5. notionary/database/__init__.py +4 -0
  6. notionary/database/database.py +481 -0
  7. notionary/database/{filter_builder.py → database_filter_builder.py} +27 -29
  8. notionary/database/{notion_database_provider.py → database_provider.py} +4 -4
  9. notionary/database/notion_database.py +45 -18
  10. notionary/file_upload/__init__.py +7 -0
  11. notionary/file_upload/client.py +254 -0
  12. notionary/file_upload/models.py +60 -0
  13. notionary/file_upload/notion_file_upload.py +387 -0
  14. notionary/page/notion_page.py +4 -3
  15. notionary/telemetry/views.py +15 -6
  16. notionary/user/__init__.py +11 -0
  17. notionary/user/base_notion_user.py +52 -0
  18. notionary/user/client.py +129 -0
  19. notionary/user/models.py +83 -0
  20. notionary/user/notion_bot_user.py +227 -0
  21. notionary/user/notion_user.py +256 -0
  22. notionary/user/notion_user_manager.py +173 -0
  23. notionary/user/notion_user_provider.py +1 -0
  24. notionary/util/__init__.py +3 -5
  25. notionary/util/{factory_decorator.py → factory_only.py} +9 -5
  26. notionary/util/fuzzy.py +74 -0
  27. notionary/util/logging_mixin.py +12 -12
  28. notionary/workspace.py +38 -2
  29. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/METADATA +2 -1
  30. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/RECORD +34 -20
  31. notionary/util/fuzzy_matcher.py +0 -82
  32. /notionary/database/{database_exceptions.py → exceptions.py} +0 -0
  33. /notionary/util/{singleton_decorator.py → singleton.py} +0 -0
  34. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/LICENSE +0 -0
  35. {notionary-0.2.16.dist-info → notionary-0.2.17.dist-info}/WHEEL +0 -0
@@ -0,0 +1,481 @@
1
+ from __future__ import annotations
2
+ import asyncio
3
+ import random
4
+ from typing import Any, AsyncGenerator, Dict, Optional
5
+
6
+ from notionary.database.client import NotionDatabaseClient
7
+ from notionary.models.notion_database_response import (
8
+ NotionDatabaseResponse,
9
+ NotionPageResponse,
10
+ NotionQueryDatabaseResponse,
11
+ )
12
+ from notionary.page.notion_page import NotionPage
13
+
14
+ from notionary.database.database_provider import NotionDatabaseProvider
15
+
16
+ from notionary.database.database_filter_builder import DatabaseFilterBuilder
17
+ from notionary.telemetry import (
18
+ ProductTelemetry,
19
+ DatabaseFactoryUsedEvent,
20
+ QueryOperationEvent,
21
+ )
22
+ from notionary.util import factory_only, LoggingMixin
23
+
24
+
25
+ class NotionDatabase(LoggingMixin):
26
+ """
27
+ Minimal manager for Notion databases.
28
+ Focused exclusively on creating basic pages and retrieving page managers
29
+ for further page operations.
30
+ """
31
+
32
+ telemetry = ProductTelemetry()
33
+
34
+ @factory_only("from_database_id", "from_database_name")
35
+ def __init__(
36
+ self,
37
+ id: str,
38
+ title: str,
39
+ url: str,
40
+ emoji_icon: Optional[str] = None,
41
+ properties: Optional[Dict[str, Any]] = None,
42
+ token: Optional[str] = None,
43
+ ):
44
+ """
45
+ Initialize the minimal database manager.
46
+ """
47
+ self._id = id
48
+ self._title = title
49
+ self._url = url
50
+ self._emoji_icon = emoji_icon
51
+ self._properties = properties
52
+
53
+ self.client = NotionDatabaseClient(token=token)
54
+
55
+ @classmethod
56
+ async def from_database_id(
57
+ cls, id: str, token: Optional[str] = None
58
+ ) -> NotionDatabase:
59
+ """
60
+ Create a NotionDatabase from a database ID using NotionDatabaseProvider.
61
+ """
62
+ provider = cls.get_database_provider()
63
+ cls.telemetry.capture(
64
+ DatabaseFactoryUsedEvent(factory_method="from_database_id")
65
+ )
66
+
67
+ return await provider.get_database_by_id(id, token)
68
+
69
+ @classmethod
70
+ async def from_database_name(
71
+ cls,
72
+ database_name: str,
73
+ token: Optional[str] = None,
74
+ min_similarity: float = 0.6,
75
+ ) -> NotionDatabase:
76
+ """
77
+ Create a NotionDatabase by finding a database with fuzzy matching on the title using NotionDatabaseProvider.
78
+ """
79
+ provider = cls.get_database_provider()
80
+ cls.telemetry.capture(
81
+ DatabaseFactoryUsedEvent(factory_method="from_database_name")
82
+ )
83
+ return await provider.get_database_by_name(database_name, token, min_similarity)
84
+
85
+ @property
86
+ def id(self) -> str:
87
+ """Get the database ID (readonly)."""
88
+ return self._id
89
+
90
+ @property
91
+ def title(self) -> str:
92
+ """Get the database title (readonly)."""
93
+ return self._title
94
+
95
+ @property
96
+ def url(self) -> str:
97
+ """Get the database URL (readonly)."""
98
+ return self._url
99
+
100
+ @property
101
+ def emoji(self) -> Optional[str]:
102
+ """Get the database emoji (readonly)."""
103
+ return self._emoji_icon
104
+
105
+ @property
106
+ def properties(self) -> Optional[Dict[str, Any]]:
107
+ """Get the database properties (readonly)."""
108
+ return self._properties
109
+
110
+ # Database Provider is a singleton so we can instantiate it here with no worries
111
+ @property
112
+ def database_provider(self):
113
+ """Return a NotionDatabaseProvider instance for this database."""
114
+ return NotionDatabaseProvider.get_instance()
115
+
116
+ @classmethod
117
+ def get_database_provider(cls):
118
+ """Return a NotionDatabaseProvider instance for class-level usage."""
119
+ return NotionDatabaseProvider.get_instance()
120
+
121
+ async def create_blank_page(self) -> Optional[NotionPage]:
122
+ """
123
+ Create a new blank page in the database with minimal properties.
124
+ """
125
+ try:
126
+ create_page_response: NotionPageResponse = await self.client.create_page(
127
+ parent_database_id=self.id
128
+ )
129
+
130
+ return await NotionPage.from_page_id(page_id=create_page_response.id)
131
+
132
+ except Exception as e:
133
+ self.logger.error("Error creating blank page: %s", str(e))
134
+ return None
135
+
136
+ async def set_title(self, new_title: str) -> bool:
137
+ """
138
+ Update the database title.
139
+ """
140
+ try:
141
+ result = await self.client.update_database_title(
142
+ database_id=self.id, title=new_title
143
+ )
144
+
145
+ self._title = result.title[0].plain_text
146
+ self.logger.info(f"Successfully updated database title to: {new_title}")
147
+ self.database_provider.invalidate_database_cache(database_id=self.id)
148
+ return True
149
+
150
+ except Exception as e:
151
+ self.logger.error(f"Error updating database title: {str(e)}")
152
+ return False
153
+
154
+ async def set_emoji(self, new_emoji: str) -> bool:
155
+ """
156
+ Update the database emoji.
157
+ """
158
+ try:
159
+ result = await self.client.update_database_emoji(
160
+ database_id=self.id, emoji=new_emoji
161
+ )
162
+
163
+ self._emoji_icon = result.icon.emoji if result.icon else None
164
+ self.logger.info(f"Successfully updated database emoji to: {new_emoji}")
165
+ self.database_provider.invalidate_database_cache(database_id=self.id)
166
+ return True
167
+
168
+ except Exception as e:
169
+ self.logger.error(f"Error updating database emoji: {str(e)}")
170
+ return False
171
+
172
+ async def set_cover_image(self, image_url: str) -> Optional[str]:
173
+ """
174
+ Update the database cover image.
175
+ """
176
+ try:
177
+ result = await self.client.update_database_cover_image(
178
+ database_id=self.id, image_url=image_url
179
+ )
180
+
181
+ if result.cover and result.cover.external:
182
+ self.database_provider.invalidate_database_cache(database_id=self.id)
183
+ return result.cover.external.url
184
+ return None
185
+
186
+ except Exception as e:
187
+ self.logger.error(f"Error updating database cover image: {str(e)}")
188
+ return None
189
+
190
+ async def set_random_gradient_cover(self) -> Optional[str]:
191
+ """Sets a random gradient cover from Notion's default gradient covers (always jpg)."""
192
+ default_notion_covers = [
193
+ f"https://www.notion.so/images/page-cover/gradients_{i}.png"
194
+ for i in range(1, 10)
195
+ ]
196
+ random_cover_url = random.choice(default_notion_covers)
197
+ return await self.set_cover_image(random_cover_url)
198
+
199
+ async def set_external_icon(self, external_icon_url: str) -> Optional[str]:
200
+ """
201
+ Update the database icon with an external image URL.
202
+ """
203
+ try:
204
+ result = await self.client.update_database_external_icon(
205
+ database_id=self.id, icon_url=external_icon_url
206
+ )
207
+
208
+ if result.icon and result.icon.external:
209
+ self.database_provider.invalidate_database_cache(database_id=self.id)
210
+ return result.icon.external.url
211
+ return None
212
+
213
+ except Exception as e:
214
+ self.logger.error(f"Error updating database external icon: {str(e)}")
215
+ return None
216
+
217
+ async def get_options_by_property_name(self, property_name: str) -> list[str]:
218
+ """
219
+ Retrieve all option names for a select, multi_select, status, or relation property.
220
+
221
+ Args:
222
+ property_name: The name of the property in the database schema.
223
+
224
+ Returns:
225
+ A list of option names for the given property. For select, multi_select, or status,
226
+ returns the option names directly. For relation properties, returns the titles of related pages.
227
+ """
228
+ property_schema = self.properties.get(property_name)
229
+
230
+ property_type = property_schema.get("type")
231
+
232
+ if property_type in ["select", "multi_select", "status"]:
233
+ options = property_schema.get(property_type, {}).get("options", [])
234
+ return [option.get("name", "") for option in options]
235
+
236
+ if property_type == "relation":
237
+ return await self._get_relation_options(property_name)
238
+
239
+ return []
240
+
241
+ def get_property_type(self, property_name: str) -> Optional[str]:
242
+ """
243
+ Get the type of a property by its name.
244
+ """
245
+ property_schema = self.properties.get(property_name)
246
+ return property_schema.get("type") if property_schema else None
247
+
248
+ async def query_database_by_title(self, page_title: str) -> list[NotionPage]:
249
+ """
250
+ Query the database for pages with a specific title.
251
+ """
252
+ search_results: NotionQueryDatabaseResponse = (
253
+ await self.client.query_database_by_title(
254
+ database_id=self.id, page_title=page_title
255
+ )
256
+ )
257
+
258
+ page_results: list[NotionPage] = []
259
+
260
+ if search_results.results:
261
+ page_tasks = [
262
+ NotionPage.from_page_id(
263
+ page_id=page_response.id, token=self.client.token
264
+ )
265
+ for page_response in search_results.results
266
+ ]
267
+ page_results = await asyncio.gather(*page_tasks)
268
+
269
+ self.telemetry.capture(
270
+ QueryOperationEvent(query_type="query_database_by_title")
271
+ )
272
+
273
+ return page_results
274
+
275
+ async def iter_pages_updated_within(
276
+ self, hours: int = 24, page_size: int = 100
277
+ ) -> AsyncGenerator[NotionPage, None]:
278
+ """
279
+ Iterate through pages edited in the last N hours using DatabaseFilterBuilder.
280
+ """
281
+ filter_builder = DatabaseFilterBuilder()
282
+ filter_builder.with_updated_last_n_hours(hours)
283
+ filter_conditions = filter_builder.build()
284
+
285
+ async for page in self._iter_pages(page_size, filter_conditions):
286
+ yield page
287
+
288
+ async def get_all_pages(self) -> list[NotionPage]:
289
+ """
290
+ Get all pages in the database (use with caution for large databases).
291
+ Uses asyncio.gather to parallelize NotionPage creation per API batch.
292
+ """
293
+ pages: list[NotionPage] = []
294
+
295
+ async for batch in self._paginate_database(page_size=100):
296
+ # Parallelize NotionPage creation for this batch
297
+ page_tasks = [
298
+ NotionPage.from_page_id(
299
+ page_id=page_response.id, token=self.client.token
300
+ )
301
+ for page_response in batch
302
+ ]
303
+ batch_pages = await asyncio.gather(*page_tasks)
304
+ pages.extend(batch_pages)
305
+
306
+ return pages
307
+
308
+ async def get_last_edited_time(self) -> Optional[str]:
309
+ """
310
+ Retrieve the last edited time of the database.
311
+
312
+ Returns:
313
+ ISO 8601 timestamp string of the last database edit, or None if request fails.
314
+ """
315
+ try:
316
+ db = await self.client.get_database(self.id)
317
+
318
+ return db.last_edited_time
319
+
320
+ except Exception as e:
321
+ self.logger.error(
322
+ "Error fetching last_edited_time for database %s: %s",
323
+ self.id,
324
+ str(e),
325
+ )
326
+ return None
327
+
328
+ async def _iter_pages(
329
+ self,
330
+ page_size: int = 100,
331
+ filter_conditions: Optional[Dict[str, Any]] = None,
332
+ ) -> AsyncGenerator[NotionPage, None]:
333
+ """
334
+ Asynchronous generator that yields NotionPage objects from the database.
335
+ Directly queries the Notion API without using the schema.
336
+
337
+ Args:
338
+ page_size: Number of pages to fetch per request
339
+ filter_conditions: Optional filter conditions
340
+
341
+ Yields:
342
+ NotionPage objects
343
+ """
344
+ self.logger.debug(
345
+ "Iterating pages with page_size: %d, filter: %s",
346
+ page_size,
347
+ filter_conditions,
348
+ )
349
+
350
+ async for batch in self._paginate_database(page_size, filter_conditions):
351
+ for page_response in batch:
352
+ yield await NotionPage.from_page_id(
353
+ page_id=page_response.id, token=self.client.token
354
+ )
355
+
356
+ @classmethod
357
+ def _create_from_response(
358
+ cls, db_response: NotionDatabaseResponse, token: Optional[str]
359
+ ) -> NotionDatabase:
360
+ """
361
+ Create NotionDatabase instance from API response.
362
+ """
363
+ title = cls._extract_title(db_response)
364
+ emoji_icon = cls._extract_emoji_icon(db_response)
365
+
366
+ instance = cls(
367
+ id=db_response.id,
368
+ title=title,
369
+ url=db_response.url,
370
+ emoji_icon=emoji_icon,
371
+ properties=db_response.properties,
372
+ token=token,
373
+ )
374
+
375
+ cls.logger.info(
376
+ "Created database manager: '%s' (ID: %s)", title, db_response.id
377
+ )
378
+
379
+ return instance
380
+
381
+ @staticmethod
382
+ def _extract_title(db_response: NotionDatabaseResponse) -> str:
383
+ """Extract title from database response."""
384
+ if db_response.title and len(db_response.title) > 0:
385
+ return db_response.title[0].plain_text
386
+ return "Untitled Database"
387
+
388
+ @staticmethod
389
+ def _extract_emoji_icon(db_response: NotionDatabaseResponse) -> Optional[str]:
390
+ """Extract emoji from database response."""
391
+ if not db_response.icon:
392
+ return None
393
+
394
+ if db_response.icon.type == "emoji":
395
+ return db_response.icon.emoji
396
+
397
+ return None
398
+
399
+ def _extract_title_from_page(self, page: NotionPageResponse) -> Optional[str]:
400
+ """
401
+ Extracts the title from a NotionPageResponse object.
402
+ """
403
+ if not page.properties:
404
+ return None
405
+
406
+ title_property = next(
407
+ (
408
+ prop
409
+ for prop in page.properties.values()
410
+ if isinstance(prop, dict) and prop.get("type") == "title"
411
+ ),
412
+ None,
413
+ )
414
+
415
+ if not title_property or "title" not in title_property:
416
+ return None
417
+
418
+ try:
419
+ title_parts = title_property["title"]
420
+ return "".join(part.get("plain_text", "") for part in title_parts)
421
+
422
+ except (KeyError, TypeError, AttributeError):
423
+ return None
424
+
425
+ async def _get_relation_options(self, property_name: str) -> list[Dict[str, Any]]:
426
+ """
427
+ Retrieve the titles of all pages related to a relation property.
428
+
429
+ Args:
430
+ property_name: The name of the relation property in the database schema.
431
+
432
+ Returns:
433
+ A list of titles for all related pages. Returns an empty list if no related pages are found.
434
+ """
435
+ property_schema = self.properties.get(property_name)
436
+
437
+ relation_database_id = property_schema.get("relation", {}).get("database_id")
438
+
439
+ search_results = await self.client.query_database(
440
+ database_id=relation_database_id
441
+ )
442
+
443
+ return [self._extract_title_from_page(page) for page in search_results.results]
444
+
445
+ async def _paginate_database(
446
+ self,
447
+ page_size: int = 100,
448
+ filter_conditions: Optional[Dict[str, Any]] = None,
449
+ ) -> AsyncGenerator[list[NotionPageResponse], None]:
450
+ """
451
+ Central pagination logic for Notion Database queries.
452
+
453
+ Args:
454
+ page_size: Number of pages per request (max 100)
455
+ filter_conditions: Optional filter conditions for the query
456
+
457
+ Yields:
458
+ Batches of NotionPageResponse objects
459
+ """
460
+ start_cursor: Optional[str] = None
461
+ has_more = True
462
+
463
+ while has_more:
464
+ query_data: Dict[str, Any] = {"page_size": page_size}
465
+
466
+ if start_cursor:
467
+ query_data["start_cursor"] = start_cursor
468
+ if filter_conditions:
469
+ query_data["filter"] = filter_conditions
470
+
471
+ result: NotionQueryDatabaseResponse = await self.client.query_database(
472
+ database_id=self.id, query_data=query_data
473
+ )
474
+
475
+ if not result or not result.results:
476
+ return
477
+
478
+ yield result.results
479
+
480
+ has_more = result.has_more
481
+ start_cursor = result.next_cursor if has_more else None
@@ -7,13 +7,13 @@ from dataclasses import dataclass, field
7
7
 
8
8
  @dataclass
9
9
  class FilterConfig:
10
- """Einfache Konfiguration für Notion Database Filter."""
10
+ """Simple configuration for Notion Database filters."""
11
11
 
12
12
  conditions: List[Dict[str, Any]] = field(default_factory=list)
13
13
  page_size: int = 100
14
14
 
15
15
  def to_filter_dict(self) -> Dict[str, Any]:
16
- """Konvertiert zu einem Notion-Filter-Dictionary."""
16
+ """Convert to a Notion filter dictionary."""
17
17
  if len(self.conditions) == 0:
18
18
  return {}
19
19
  if len(self.conditions) == 1:
@@ -22,7 +22,7 @@ class FilterConfig:
22
22
  return {"and": self.conditions}
23
23
 
24
24
 
25
- class FilterBuilder:
25
+ class DatabaseFilterBuilder:
26
26
  """
27
27
  Builder class for creating complex Notion filters with comprehensive property type support.
28
28
  """
@@ -30,32 +30,32 @@ class FilterBuilder:
30
30
  def __init__(self, config: FilterConfig = None):
31
31
  self.config = config or FilterConfig()
32
32
 
33
- def with_page_object_filter(self) -> FilterBuilder:
34
- """Filter: Nur Datenbank-Objekte (Notion API search)."""
33
+ def with_page_object_filter(self):
34
+ """Filter: Only page objects (Notion API search)."""
35
35
  self.config.conditions.append({"value": "page", "property": "object"})
36
36
  return self
37
37
 
38
- def with_database_object_filter(self) -> FilterBuilder:
39
- """Filter: Nur Datenbank-Objekte (Notion API search)."""
38
+ def with_database_object_filter(self):
39
+ """Filter: Only database objects (Notion API search)."""
40
40
  self.config.conditions.append({"value": "database", "property": "object"})
41
41
  return self
42
42
 
43
43
  # TIMESTAMP FILTERS (Created/Updated)
44
- def with_created_after(self, date: datetime) -> FilterBuilder:
44
+ def with_created_after(self, date: datetime):
45
45
  """Add condition: created after specific date."""
46
46
  self.config.conditions.append(
47
47
  {"timestamp": "created_time", "created_time": {"after": date.isoformat()}}
48
48
  )
49
49
  return self
50
50
 
51
- def with_created_before(self, date: datetime) -> FilterBuilder:
51
+ def with_created_before(self, date: datetime):
52
52
  """Add condition: created before specific date."""
53
53
  self.config.conditions.append(
54
54
  {"timestamp": "created_time", "created_time": {"before": date.isoformat()}}
55
55
  )
56
56
  return self
57
57
 
58
- def with_updated_after(self, date: datetime) -> FilterBuilder:
58
+ def with_updated_after(self, date: datetime):
59
59
  """Add condition: updated after specific date."""
60
60
  self.config.conditions.append(
61
61
  {
@@ -65,25 +65,25 @@ class FilterBuilder:
65
65
  )
66
66
  return self
67
67
 
68
- def with_created_last_n_days(self, days: int) -> FilterBuilder:
69
- """In den letzten N Tagen erstellt."""
68
+ def with_created_last_n_days(self, days: int):
69
+ """Created in the last N days."""
70
70
  cutoff = datetime.now() - timedelta(days=days)
71
71
  return self.with_created_after(cutoff)
72
72
 
73
- def with_updated_last_n_hours(self, hours: int) -> FilterBuilder:
74
- """In den letzten N Stunden bearbeitet."""
73
+ def with_updated_last_n_hours(self, hours: int):
74
+ """Updated in the last N hours."""
75
75
  cutoff = datetime.now() - timedelta(hours=hours)
76
76
  return self.with_updated_after(cutoff)
77
77
 
78
78
  # RICH TEXT FILTERS
79
- def with_text_contains(self, property_name: str, value: str) -> FilterBuilder:
79
+ def with_text_contains(self, property_name: str, value: str):
80
80
  """Rich text contains value."""
81
81
  self.config.conditions.append(
82
82
  {"property": property_name, "rich_text": {"contains": value}}
83
83
  )
84
84
  return self
85
85
 
86
- def with_text_equals(self, property_name: str, value: str) -> FilterBuilder:
86
+ def with_text_equals(self, property_name: str, value: str):
87
87
  """Rich text equals value."""
88
88
  self.config.conditions.append(
89
89
  {"property": property_name, "rich_text": {"equals": value}}
@@ -91,55 +91,53 @@ class FilterBuilder:
91
91
  return self
92
92
 
93
93
  # TITLE FILTERS
94
- def with_title_contains(self, value: str) -> FilterBuilder:
94
+ def with_title_contains(self, value: str):
95
95
  """Title contains value."""
96
96
  self.config.conditions.append(
97
97
  {"property": "title", "title": {"contains": value}}
98
98
  )
99
99
  return self
100
100
 
101
- def with_title_equals(self, value: str) -> FilterBuilder:
101
+ def with_title_equals(self, value: str):
102
102
  """Title equals value."""
103
103
  self.config.conditions.append({"property": "title", "title": {"equals": value}})
104
104
  return self
105
105
 
106
106
  # SELECT FILTERS (Single Select)
107
- def with_select_equals(self, property_name: str, value: str) -> FilterBuilder:
107
+ def with_select_equals(self, property_name: str, value: str):
108
108
  """Select equals value."""
109
109
  self.config.conditions.append(
110
110
  {"property": property_name, "select": {"equals": value}}
111
111
  )
112
112
  return self
113
113
 
114
- def with_select_is_empty(self, property_name: str) -> FilterBuilder:
114
+ def with_select_is_empty(self, property_name: str):
115
115
  """Select is empty."""
116
116
  self.config.conditions.append(
117
117
  {"property": property_name, "select": {"is_empty": True}}
118
118
  )
119
119
  return self
120
120
 
121
- def with_multi_select_contains(
122
- self, property_name: str, value: str
123
- ) -> FilterBuilder:
121
+ def with_multi_select_contains(self, property_name: str, value: str):
124
122
  """Multi-select contains value."""
125
123
  self.config.conditions.append(
126
124
  {"property": property_name, "multi_select": {"contains": value}}
127
125
  )
128
126
  return self
129
127
 
130
- def with_status_equals(self, property_name: str, value: str) -> FilterBuilder:
128
+ def with_status_equals(self, property_name: str, value: str):
131
129
  """Status equals value."""
132
130
  self.config.conditions.append(
133
131
  {"property": property_name, "status": {"equals": value}}
134
132
  )
135
133
  return self
136
134
 
137
- def with_page_size(self, size: int) -> FilterBuilder:
135
+ def with_page_size(self, size: int):
138
136
  """Set page size for pagination."""
139
137
  self.config.page_size = size
140
138
  return self
141
139
 
142
- def with_or_condition(self, *builders: FilterBuilder) -> FilterBuilder:
140
+ def with_or_condition(self, *builders):
143
141
  """Add OR condition with multiple sub-conditions."""
144
142
  or_conditions = []
145
143
  for builder in builders:
@@ -162,14 +160,14 @@ class FilterBuilder:
162
160
  """Get the underlying FilterConfig."""
163
161
  return self.config
164
162
 
165
- def copy(self) -> FilterBuilder:
163
+ def copy(self):
166
164
  """Create a copy of the builder."""
167
165
  new_config = FilterConfig(
168
166
  conditions=self.config.conditions.copy(), page_size=self.config.page_size
169
167
  )
170
- return FilterBuilder(new_config)
168
+ return DatabaseFilterBuilder(new_config)
171
169
 
172
- def reset(self) -> FilterBuilder:
170
+ def reset(self):
173
171
  """Reset all conditions."""
174
172
  self.config = FilterConfig()
175
173
  return self
@@ -2,10 +2,10 @@ from __future__ import annotations
2
2
 
3
3
  from typing import Dict, Optional, TYPE_CHECKING
4
4
  from notionary.database.client import NotionDatabaseClient
5
- from notionary.database.database_exceptions import DatabaseNotFoundException
5
+ from notionary.database.exceptions import DatabaseNotFoundException
6
6
  from notionary.models.notion_database_response import NotionDatabaseResponse
7
- from notionary.util import LoggingMixin, FuzzyMatcher, format_uuid
8
- from notionary.util.singleton_metaclass import SingletonMetaClass
7
+ from notionary.util import LoggingMixin, format_uuid, SingletonMetaClass
8
+ from notionary.util.fuzzy import find_best_match
9
9
 
10
10
  if TYPE_CHECKING:
11
11
  from notionary import NotionDatabase
@@ -125,7 +125,7 @@ class NotionDatabaseProvider(LoggingMixin, metaclass=SingletonMetaClass):
125
125
  self.logger.warning("No databases found for name: %s", database_name)
126
126
  raise DatabaseNotFoundException(database_name)
127
127
 
128
- best_match = FuzzyMatcher.find_best_match(
128
+ best_match = find_best_match(
129
129
  query=database_name,
130
130
  items=search_result.results,
131
131
  text_extractor=lambda db: self._extract_title(db),