notionary 0.3.1__py3-none-any.whl → 0.4.0__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 (80) hide show
  1. notionary/__init__.py +6 -1
  2. notionary/blocks/enums.py +0 -6
  3. notionary/blocks/schemas.py +32 -78
  4. notionary/comments/schemas.py +2 -29
  5. notionary/data_source/properties/schemas.py +128 -107
  6. notionary/data_source/schemas.py +2 -2
  7. notionary/data_source/service.py +32 -23
  8. notionary/database/schemas.py +2 -2
  9. notionary/database/service.py +3 -5
  10. notionary/exceptions/__init__.py +6 -2
  11. notionary/exceptions/api.py +2 -2
  12. notionary/exceptions/base.py +1 -1
  13. notionary/exceptions/block_parsing.py +3 -3
  14. notionary/exceptions/data_source/builder.py +2 -2
  15. notionary/exceptions/data_source/properties.py +3 -3
  16. notionary/exceptions/file_upload.py +67 -0
  17. notionary/exceptions/properties.py +4 -4
  18. notionary/exceptions/search.py +4 -4
  19. notionary/file_upload/__init__.py +4 -0
  20. notionary/file_upload/client.py +124 -210
  21. notionary/file_upload/config/__init__.py +17 -0
  22. notionary/file_upload/config/config.py +32 -0
  23. notionary/file_upload/config/constants.py +16 -0
  24. notionary/file_upload/file/reader.py +28 -0
  25. notionary/file_upload/query/__init__.py +7 -0
  26. notionary/file_upload/query/builder.py +54 -0
  27. notionary/file_upload/query/models.py +37 -0
  28. notionary/file_upload/schemas.py +78 -0
  29. notionary/file_upload/service.py +152 -289
  30. notionary/file_upload/validation/factory.py +64 -0
  31. notionary/file_upload/validation/impl/file_name_length.py +23 -0
  32. notionary/file_upload/validation/models.py +124 -0
  33. notionary/file_upload/validation/port.py +7 -0
  34. notionary/file_upload/validation/service.py +17 -0
  35. notionary/file_upload/validation/validators/__init__.py +11 -0
  36. notionary/file_upload/validation/validators/file_exists.py +15 -0
  37. notionary/file_upload/validation/validators/file_extension.py +122 -0
  38. notionary/file_upload/validation/validators/file_name_length.py +21 -0
  39. notionary/file_upload/validation/validators/upload_limit.py +31 -0
  40. notionary/http/client.py +6 -22
  41. notionary/page/content/parser/factory.py +8 -5
  42. notionary/page/content/parser/parsers/audio.py +8 -33
  43. notionary/page/content/parser/parsers/embed.py +0 -2
  44. notionary/page/content/parser/parsers/file.py +8 -35
  45. notionary/page/content/parser/parsers/file_like_block.py +89 -0
  46. notionary/page/content/parser/parsers/image.py +8 -35
  47. notionary/page/content/parser/parsers/pdf.py +8 -35
  48. notionary/page/content/parser/parsers/video.py +8 -35
  49. notionary/page/content/renderer/renderers/audio.py +9 -21
  50. notionary/page/content/renderer/renderers/file.py +9 -21
  51. notionary/page/content/renderer/renderers/file_like_block.py +43 -0
  52. notionary/page/content/renderer/renderers/image.py +9 -21
  53. notionary/page/content/renderer/renderers/pdf.py +9 -21
  54. notionary/page/content/renderer/renderers/video.py +9 -21
  55. notionary/page/content/syntax/__init__.py +2 -1
  56. notionary/page/content/syntax/registry.py +38 -60
  57. notionary/page/properties/client.py +1 -1
  58. notionary/page/properties/{models.py → schemas.py} +93 -107
  59. notionary/page/properties/service.py +1 -1
  60. notionary/page/schemas.py +3 -3
  61. notionary/page/service.py +1 -1
  62. notionary/shared/entity/dto_parsers.py +1 -36
  63. notionary/shared/entity/entity_metadata_update_client.py +18 -4
  64. notionary/shared/entity/schemas.py +6 -6
  65. notionary/shared/entity/service.py +53 -30
  66. notionary/shared/models/file.py +34 -6
  67. notionary/shared/models/icon.py +5 -12
  68. notionary/user/bot.py +12 -12
  69. notionary/utils/decorators.py +8 -8
  70. notionary/workspace/__init__.py +2 -2
  71. notionary/workspace/query/__init__.py +2 -1
  72. notionary/workspace/query/service.py +3 -17
  73. notionary/workspace/service.py +45 -45
  74. {notionary-0.3.1.dist-info → notionary-0.4.0.dist-info}/METADATA +1 -1
  75. {notionary-0.3.1.dist-info → notionary-0.4.0.dist-info}/RECORD +77 -58
  76. notionary/file_upload/models.py +0 -69
  77. notionary/page/page_context.py +0 -50
  78. notionary/shared/models/cover.py +0 -20
  79. {notionary-0.3.1.dist-info → notionary-0.4.0.dist-info}/WHEEL +0 -0
  80. {notionary-0.3.1.dist-info → notionary-0.4.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,124 @@
1
+ from enum import StrEnum
2
+
3
+
4
+ class AudioExtension(StrEnum):
5
+ AAC = ".aac"
6
+ ADTS = ".adts"
7
+ MID = ".mid"
8
+ MIDI = ".midi"
9
+ MP3 = ".mp3"
10
+ MPGA = ".mpga"
11
+ M4A = ".m4a"
12
+ M4B = ".m4b"
13
+ MP4 = ".mp4"
14
+ OGA = ".oga"
15
+ OGG = ".ogg"
16
+ WAV = ".wav"
17
+ WMA = ".wma"
18
+
19
+
20
+ class AudioMimeType(StrEnum):
21
+ AAC = "audio/aac"
22
+ MIDI = "audio/midi"
23
+ MPEG = "audio/mpeg"
24
+ MP4 = "audio/mp4"
25
+ OGG = "audio/ogg"
26
+ WAV = "audio/wav"
27
+ WMA = "audio/x-ms-wma"
28
+
29
+
30
+ class DocumentExtension(StrEnum):
31
+ PDF = ".pdf"
32
+ TXT = ".txt"
33
+ JSON = ".json"
34
+ DOC = ".doc"
35
+ DOT = ".dot"
36
+ DOCX = ".docx"
37
+ DOTX = ".dotx"
38
+ XLS = ".xls"
39
+ XLT = ".xlt"
40
+ XLA = ".xla"
41
+ XLSX = ".xlsx"
42
+ XLTX = ".xltx"
43
+ PPT = ".ppt"
44
+ POT = ".pot"
45
+ PPS = ".pps"
46
+ PPA = ".ppa"
47
+ PPTX = ".pptx"
48
+ POTX = ".potx"
49
+
50
+
51
+ class DocumentMimeType(StrEnum):
52
+ PDF = "application/pdf"
53
+ PLAIN_TEXT = "text/plain"
54
+ JSON = "application/json"
55
+ MSWORD = "application/msword"
56
+ WORD_DOCUMENT = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
57
+ WORD_TEMPLATE = "application/vnd.openxmlformats-officedocument.wordprocessingml.template"
58
+ EXCEL = "application/vnd.ms-excel"
59
+ EXCEL_SHEET = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
60
+ EXCEL_TEMPLATE = "application/vnd.openxmlformats-officedocument.spreadsheetml.template"
61
+ POWERPOINT = "application/vnd.ms-powerpoint"
62
+ POWERPOINT_PRESENTATION = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
63
+ POWERPOINT_TEMPLATE = "application/vnd.openxmlformats-officedocument.presentationml.template"
64
+
65
+
66
+ class ImageExtension(StrEnum):
67
+ GIF = ".gif"
68
+ HEIC = ".heic"
69
+ JPEG = ".jpeg"
70
+ JPG = ".jpg"
71
+ PNG = ".png"
72
+ SVG = ".svg"
73
+ TIF = ".tif"
74
+ TIFF = ".tiff"
75
+ WEBP = ".webp"
76
+ ICO = ".ico"
77
+
78
+
79
+ class ImageMimeType(StrEnum):
80
+ GIF = "image/gif"
81
+ HEIC = "image/heic"
82
+ JPEG = "image/jpeg"
83
+ PNG = "image/png"
84
+ SVG = "image/svg+xml"
85
+ TIFF = "image/tiff"
86
+ WEBP = "image/webp"
87
+ ICON = "image/vnd.microsoft.icon"
88
+
89
+
90
+ class VideoExtension(StrEnum):
91
+ AMV = ".amv"
92
+ ASF = ".asf"
93
+ WMV = ".wmv"
94
+ AVI = ".avi"
95
+ F4V = ".f4v"
96
+ FLV = ".flv"
97
+ GIFV = ".gifv"
98
+ M4V = ".m4v"
99
+ MP4 = ".mp4"
100
+ MKV = ".mkv"
101
+ WEBM = ".webm"
102
+ MOV = ".mov"
103
+ QT = ".qt"
104
+ MPEG = ".mpeg"
105
+
106
+
107
+ class VideoMimeType(StrEnum):
108
+ AMV = "video/x-amv"
109
+ ASF = "video/x-ms-asf"
110
+ AVI = "video/x-msvideo"
111
+ F4V = "video/x-f4v"
112
+ FLV = "video/x-flv"
113
+ MP4 = "video/mp4"
114
+ MKV = "video/x-matroska"
115
+ WEBM = "video/webm"
116
+ QUICKTIME = "video/quicktime"
117
+ MPEG = "video/mpeg"
118
+
119
+
120
+ class FileCategory(StrEnum):
121
+ AUDIO = "audio"
122
+ DOCUMENT = "document"
123
+ IMAGE = "image"
124
+ VIDEO = "video"
@@ -0,0 +1,7 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+
4
+ class FileUploadValidator(ABC):
5
+ @abstractmethod
6
+ async def validate(self) -> None:
7
+ pass
@@ -0,0 +1,17 @@
1
+ from notionary.file_upload.validation.port import FileUploadValidator
2
+ from notionary.utils.decorators import time_execution_async
3
+ from notionary.utils.mixins.logging import LoggingMixin
4
+
5
+
6
+ class FileUploadValidationService(LoggingMixin):
7
+ def __init__(self) -> None:
8
+ self._validators: list[FileUploadValidator] = []
9
+
10
+ def register(self, validator: FileUploadValidator) -> None:
11
+ self._validators.append(validator)
12
+
13
+ @time_execution_async()
14
+ async def validate(self) -> None:
15
+ for validator in self._validators:
16
+ self.logger.info("Validating with %s", validator.__class__.__name__)
17
+ await validator.validate()
@@ -0,0 +1,11 @@
1
+ from .file_exists import FileExistsValidator
2
+ from .file_extension import FileExtensionValidator
3
+ from .file_name_length import FileNameLengthValidator
4
+ from .upload_limit import FileUploadLimitValidator
5
+
6
+ __all__ = [
7
+ "FileExistsValidator",
8
+ "FileExtensionValidator",
9
+ "FileNameLengthValidator",
10
+ "FileUploadLimitValidator",
11
+ ]
@@ -0,0 +1,15 @@
1
+ from pathlib import Path
2
+ from typing import override
3
+
4
+ from notionary.exceptions.file_upload import FileNotFoundError
5
+ from notionary.file_upload.validation.port import FileUploadValidator
6
+
7
+
8
+ class FileExistsValidator(FileUploadValidator):
9
+ def __init__(self, file_path: Path) -> None:
10
+ self.file_path = file_path
11
+
12
+ @override
13
+ async def validate(self) -> None:
14
+ if not self.file_path.exists():
15
+ raise FileNotFoundError(str(self.file_path))
@@ -0,0 +1,122 @@
1
+ from pathlib import Path
2
+ from typing import ClassVar, override
3
+
4
+ from notionary.exceptions.file_upload import NoFileExtensionException, UnsupportedFileTypeException
5
+ from notionary.file_upload.validation.models import (
6
+ AudioExtension,
7
+ AudioMimeType,
8
+ DocumentExtension,
9
+ DocumentMimeType,
10
+ FileCategory,
11
+ ImageExtension,
12
+ ImageMimeType,
13
+ VideoExtension,
14
+ VideoMimeType,
15
+ )
16
+ from notionary.file_upload.validation.port import FileUploadValidator
17
+
18
+
19
+ class FileExtensionValidator(FileUploadValidator):
20
+ EXTENSION_TO_MIME: ClassVar[dict[str, str]] = {
21
+ AudioExtension.AAC: AudioMimeType.AAC,
22
+ AudioExtension.ADTS: AudioMimeType.AAC,
23
+ AudioExtension.MID: AudioMimeType.MIDI,
24
+ AudioExtension.MIDI: AudioMimeType.MIDI,
25
+ AudioExtension.MP3: AudioMimeType.MPEG,
26
+ AudioExtension.MPGA: AudioMimeType.MPEG,
27
+ AudioExtension.M4A: AudioMimeType.MP4,
28
+ AudioExtension.M4B: AudioMimeType.MP4,
29
+ AudioExtension.MP4: AudioMimeType.MP4,
30
+ AudioExtension.OGA: AudioMimeType.OGG,
31
+ AudioExtension.OGG: AudioMimeType.OGG,
32
+ AudioExtension.WAV: AudioMimeType.WAV,
33
+ AudioExtension.WMA: AudioMimeType.WMA,
34
+ DocumentExtension.PDF: DocumentMimeType.PDF,
35
+ DocumentExtension.TXT: DocumentMimeType.PLAIN_TEXT,
36
+ DocumentExtension.JSON: DocumentMimeType.JSON,
37
+ DocumentExtension.DOC: DocumentMimeType.MSWORD,
38
+ DocumentExtension.DOT: DocumentMimeType.MSWORD,
39
+ DocumentExtension.DOCX: DocumentMimeType.WORD_DOCUMENT,
40
+ DocumentExtension.DOTX: DocumentMimeType.WORD_TEMPLATE,
41
+ DocumentExtension.XLS: DocumentMimeType.EXCEL,
42
+ DocumentExtension.XLT: DocumentMimeType.EXCEL,
43
+ DocumentExtension.XLA: DocumentMimeType.EXCEL,
44
+ DocumentExtension.XLSX: DocumentMimeType.EXCEL_SHEET,
45
+ DocumentExtension.XLTX: DocumentMimeType.EXCEL_TEMPLATE,
46
+ DocumentExtension.PPT: DocumentMimeType.POWERPOINT,
47
+ DocumentExtension.POT: DocumentMimeType.POWERPOINT,
48
+ DocumentExtension.PPS: DocumentMimeType.POWERPOINT,
49
+ DocumentExtension.PPA: DocumentMimeType.POWERPOINT,
50
+ DocumentExtension.PPTX: DocumentMimeType.POWERPOINT_PRESENTATION,
51
+ DocumentExtension.POTX: DocumentMimeType.POWERPOINT_TEMPLATE,
52
+ ImageExtension.GIF: ImageMimeType.GIF,
53
+ ImageExtension.HEIC: ImageMimeType.HEIC,
54
+ ImageExtension.JPEG: ImageMimeType.JPEG,
55
+ ImageExtension.JPG: ImageMimeType.JPEG,
56
+ ImageExtension.PNG: ImageMimeType.PNG,
57
+ ImageExtension.SVG: ImageMimeType.SVG,
58
+ ImageExtension.TIF: ImageMimeType.TIFF,
59
+ ImageExtension.TIFF: ImageMimeType.TIFF,
60
+ ImageExtension.WEBP: ImageMimeType.WEBP,
61
+ ImageExtension.ICO: ImageMimeType.ICON,
62
+ VideoExtension.AMV: VideoMimeType.AMV,
63
+ VideoExtension.ASF: VideoMimeType.ASF,
64
+ VideoExtension.WMV: VideoMimeType.ASF,
65
+ VideoExtension.AVI: VideoMimeType.AVI,
66
+ VideoExtension.F4V: VideoMimeType.F4V,
67
+ VideoExtension.FLV: VideoMimeType.FLV,
68
+ VideoExtension.GIFV: VideoMimeType.WEBM,
69
+ VideoExtension.M4V: VideoMimeType.MP4,
70
+ VideoExtension.MP4: VideoMimeType.MP4,
71
+ VideoExtension.MKV: VideoMimeType.MKV,
72
+ VideoExtension.WEBM: VideoMimeType.WEBM,
73
+ VideoExtension.MOV: VideoMimeType.QUICKTIME,
74
+ VideoExtension.QT: VideoMimeType.QUICKTIME,
75
+ VideoExtension.MPEG: VideoMimeType.MPEG,
76
+ }
77
+
78
+ EXTENSION_TO_CATEGORY: ClassVar[dict[str, FileCategory]] = {
79
+ **{ext.value: FileCategory.AUDIO for ext in AudioExtension},
80
+ **{ext.value: FileCategory.DOCUMENT for ext in DocumentExtension},
81
+ **{ext.value: FileCategory.IMAGE for ext in ImageExtension},
82
+ **{ext.value: FileCategory.VIDEO for ext in VideoExtension},
83
+ }
84
+
85
+ def __init__(self, filename: str | Path) -> None:
86
+ self.filename = Path(filename).name if isinstance(filename, Path) else filename
87
+
88
+ @override
89
+ async def validate(self) -> None:
90
+ extension = self._extract_extension(self.filename)
91
+
92
+ if not extension:
93
+ raise NoFileExtensionException(self.filename)
94
+
95
+ if not self._is_supported(extension):
96
+ supported_by_category = self._get_supported_extensions_by_category()
97
+ raise UnsupportedFileTypeException(extension, self.filename, supported_by_category)
98
+
99
+ @staticmethod
100
+ def _extract_extension(filename: str) -> str:
101
+ import os
102
+
103
+ _, ext = os.path.splitext(filename)
104
+ return ext.lower()
105
+
106
+ def _is_supported(self, extension: str) -> bool:
107
+ normalized = self._normalize_extension(extension)
108
+ return normalized in self.EXTENSION_TO_MIME
109
+
110
+ @staticmethod
111
+ def _normalize_extension(extension: str) -> str:
112
+ extension = extension.lower()
113
+ if not extension.startswith("."):
114
+ extension = f".{extension}"
115
+ return extension
116
+
117
+ def _get_supported_extensions_by_category(self) -> dict[str, list[str]]:
118
+ result = {}
119
+ for category in FileCategory:
120
+ extensions = [ext for ext, cat in self.EXTENSION_TO_CATEGORY.items() if cat == category]
121
+ result[category.value] = extensions
122
+ return result
@@ -0,0 +1,21 @@
1
+ from typing import override
2
+
3
+ from notionary.exceptions.file_upload import FilenameTooLongError
4
+ from notionary.file_upload.config.constants import NOTION_MAX_FILENAME_BYTES
5
+ from notionary.file_upload.validation.port import FileUploadValidator
6
+
7
+
8
+ class FileNameLengthValidator(FileUploadValidator):
9
+ def __init__(self, filename: str) -> None:
10
+ self._filename = filename
11
+ self._max_filename_bytes = NOTION_MAX_FILENAME_BYTES
12
+
13
+ @override
14
+ async def validate(self) -> None:
15
+ filename_bytes = len(self._filename.encode("utf-8"))
16
+ if filename_bytes > self._max_filename_bytes:
17
+ raise FilenameTooLongError(
18
+ filename=self._filename,
19
+ filename_bytes=filename_bytes,
20
+ max_filename_bytes=self._max_filename_bytes,
21
+ )
@@ -0,0 +1,31 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import TYPE_CHECKING, override
5
+
6
+ from notionary.exceptions.file_upload import FileSizeException
7
+ from notionary.file_upload.validation.port import FileUploadValidator
8
+
9
+ if TYPE_CHECKING:
10
+ from notionary.user import BotUser
11
+
12
+
13
+ class FileUploadLimitValidator(FileUploadValidator):
14
+ def __init__(self, filename: str | Path, file_size_bytes: int) -> None:
15
+ self.filename = Path(filename).name if isinstance(filename, Path) else filename
16
+ self.file_size_bytes = file_size_bytes
17
+
18
+ @override
19
+ async def validate(self, integration: BotUser | None = None) -> None:
20
+ from notionary.user import BotUser
21
+
22
+ integration = integration or await BotUser.from_current_integration()
23
+
24
+ max_file_size_in_bytes = integration.workspace_file_upload_limit_in_bytes
25
+
26
+ if self.file_size_bytes > max_file_size_in_bytes:
27
+ raise FileSizeException(
28
+ filename=self.filename,
29
+ file_size_bytes=self.file_size_bytes,
30
+ max_size_bytes=max_file_size_in_bytes,
31
+ )
notionary/http/client.py CHANGED
@@ -18,7 +18,7 @@ from notionary.http.models import HttpMethod
18
18
  from notionary.shared.typings import JsonDict
19
19
  from notionary.utils.mixins.logging import LoggingMixin
20
20
 
21
- load_dotenv()
21
+ load_dotenv(override=True)
22
22
 
23
23
 
24
24
  class NotionHttpClient(LoggingMixin):
@@ -44,7 +44,6 @@ class NotionHttpClient(LoggingMixin):
44
44
  self._is_initialized = False
45
45
 
46
46
  def __del__(self):
47
- """Auto-cleanup when client is destroyed."""
48
47
  if not hasattr(self, "client") or not self.client:
49
48
  return
50
49
 
@@ -60,18 +59,13 @@ class NotionHttpClient(LoggingMixin):
60
59
  self.logger.warning("No event loop available for auto-closing NotionHttpClient")
61
60
 
62
61
  async def __aenter__(self):
63
- """Async context manager entry."""
64
62
  await self._ensure_initialized()
65
63
  return self
66
64
 
67
65
  async def __aexit__(self, exc_type, exc_val, exc_tb):
68
- """Async context manager exit."""
69
66
  await self.close()
70
67
 
71
68
  async def close(self) -> None:
72
- """
73
- Closes the HTTP client and releases resources.
74
- """
75
69
  if not hasattr(self, "client") or not self.client:
76
70
  return
77
71
 
@@ -81,33 +75,24 @@ class NotionHttpClient(LoggingMixin):
81
75
  self.logger.debug("NotionHttpClient closed")
82
76
 
83
77
  async def get(self, endpoint: str, params: JsonDict | None = None) -> JsonDict | None:
84
- return await self.make_request(HttpMethod.GET, endpoint, params=params)
78
+ return await self._make_request(HttpMethod.GET, endpoint, params=params)
85
79
 
86
80
  async def post(self, endpoint: str, data: JsonDict | None = None) -> JsonDict | None:
87
- return await self.make_request(HttpMethod.POST, endpoint, data)
81
+ return await self._make_request(HttpMethod.POST, endpoint, data)
88
82
 
89
83
  async def patch(self, endpoint: str, data: JsonDict | None = None) -> JsonDict | None:
90
- return await self.make_request(HttpMethod.PATCH, endpoint, data)
84
+ return await self._make_request(HttpMethod.PATCH, endpoint, data)
91
85
 
92
86
  async def delete(self, endpoint: str) -> JsonDict | None:
93
- return await self.make_request(HttpMethod.DELETE, endpoint)
87
+ return await self._make_request(HttpMethod.DELETE, endpoint)
94
88
 
95
- async def make_request(
89
+ async def _make_request(
96
90
  self,
97
91
  method: HttpMethod,
98
92
  endpoint: str,
99
93
  data: JsonDict | None = None,
100
94
  params: JsonDict | None = None,
101
95
  ) -> JsonDict | None:
102
- """
103
- Executes an HTTP request and returns the data or None on error.
104
-
105
- Args:
106
- method: HTTP method to use
107
- endpoint: API endpoint
108
- data: Request body data (for POST/PATCH)
109
- params: Query parameters (for GET requests)
110
- """
111
96
  await self._ensure_initialized()
112
97
 
113
98
  url = f"{self.BASE_URL}/{endpoint.lstrip('/')}"
@@ -141,7 +126,6 @@ class NotionHttpClient(LoggingMixin):
141
126
  status_code = e.response.status_code
142
127
  response_text = e.response.text
143
128
 
144
- # Map HTTP status codes to specific business exceptions
145
129
  if status_code == 401:
146
130
  raise NotionAuthenticationError(
147
131
  "Invalid or missing API key. Please check your Notion integration token.",
@@ -1,6 +1,7 @@
1
1
  from notionary.blocks.rich_text.markdown_rich_text_converter import (
2
2
  MarkdownRichTextConverter,
3
3
  )
4
+ from notionary.file_upload.service import NotionFileUpload
4
5
  from notionary.page.content.parser.parsers import (
5
6
  AudioParser,
6
7
  BookmarkParser,
@@ -37,9 +38,11 @@ class ConverterChainFactory:
37
38
  self,
38
39
  rich_text_converter: MarkdownRichTextConverter | None = None,
39
40
  syntax_registry: SyntaxRegistry | None = None,
41
+ file_upload_service: NotionFileUpload | None = None,
40
42
  ) -> None:
41
43
  self._rich_text_converter = rich_text_converter or MarkdownRichTextConverter()
42
44
  self._syntax_registry = syntax_registry or SyntaxRegistry()
45
+ self._file_upload_service = file_upload_service
43
46
 
44
47
  def create(self) -> LineParser:
45
48
  # multi-line (structural) blocks
@@ -186,19 +189,19 @@ class ConverterChainFactory:
186
189
  return EmbedParser(syntax_registry=self._syntax_registry)
187
190
 
188
191
  def _create_image_parser(self) -> ImageParser:
189
- return ImageParser(syntax_registry=self._syntax_registry)
192
+ return ImageParser(syntax_registry=self._syntax_registry, file_upload_service=self._file_upload_service)
190
193
 
191
194
  def _create_video_parser(self) -> VideoParser:
192
- return VideoParser(syntax_registry=self._syntax_registry)
195
+ return VideoParser(syntax_registry=self._syntax_registry, file_upload_service=self._file_upload_service)
193
196
 
194
197
  def _create_audio_parser(self) -> AudioParser:
195
- return AudioParser(syntax_registry=self._syntax_registry)
198
+ return AudioParser(syntax_registry=self._syntax_registry, file_upload_service=self._file_upload_service)
196
199
 
197
200
  def _create_file_parser(self) -> FileParser:
198
- return FileParser(syntax_registry=self._syntax_registry)
201
+ return FileParser(syntax_registry=self._syntax_registry, file_upload_service=self._file_upload_service)
199
202
 
200
203
  def _create_pdf_parser(self) -> PdfParser:
201
- return PdfParser(syntax_registry=self._syntax_registry)
204
+ return PdfParser(syntax_registry=self._syntax_registry, file_upload_service=self._file_upload_service)
202
205
 
203
206
  def _create_caption_parser(self) -> CaptionParser:
204
207
  return CaptionParser(
@@ -1,40 +1,15 @@
1
1
  from typing import override
2
2
 
3
- from notionary.blocks.schemas import (
4
- CreateAudioBlock,
5
- ExternalFile,
6
- FileData,
7
- FileType,
8
- )
9
- from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
10
- from notionary.page.content.syntax import SyntaxRegistry
3
+ from notionary.blocks.schemas import CreateAudioBlock, ExternalFileWithCaption
4
+ from notionary.page.content.parser.parsers.file_like_block import FileLikeBlockParser
5
+ from notionary.page.content.syntax import SyntaxDefinition, SyntaxRegistry
11
6
 
12
7
 
13
- class AudioParser(LineParser):
14
- def __init__(self, syntax_registry: SyntaxRegistry) -> None:
15
- super().__init__(syntax_registry)
16
- self._syntax = syntax_registry.get_audio_syntax()
17
-
8
+ class AudioParser(FileLikeBlockParser[CreateAudioBlock]):
18
9
  @override
19
- def _can_handle(self, context: BlockParsingContext) -> bool:
20
- if context.is_inside_parent_context():
21
- return False
22
- return self._syntax.regex_pattern.search(context.line) is not None
10
+ def _get_syntax(self, syntax_registry: SyntaxRegistry) -> SyntaxDefinition:
11
+ return syntax_registry.get_audio_syntax()
23
12
 
24
13
  @override
25
- async def _process(self, context: BlockParsingContext) -> None:
26
- url = self._extract_url(context.line)
27
- if url is None:
28
- return
29
-
30
- audio_data = FileData(
31
- type=FileType.EXTERNAL,
32
- external=ExternalFile(url=url),
33
- caption=[],
34
- )
35
- block = CreateAudioBlock(audio=audio_data)
36
- context.result_blocks.append(block)
37
-
38
- def _extract_url(self, line: str) -> str | None:
39
- match = self._syntax.regex_pattern.search(line)
40
- return match.group(1).strip() if match else None
14
+ def _create_block(self, file_data: ExternalFileWithCaption) -> CreateAudioBlock:
15
+ return CreateAudioBlock(audio=file_data)
@@ -1,5 +1,3 @@
1
- """Parser for embed blocks."""
2
-
3
1
  from typing import override
4
2
 
5
3
  from notionary.blocks.schemas import CreateEmbedBlock, EmbedData
@@ -1,42 +1,15 @@
1
- """Parser for file blocks."""
2
-
3
1
  from typing import override
4
2
 
5
- from notionary.blocks.schemas import (
6
- CreateFileBlock,
7
- ExternalFile,
8
- FileData,
9
- FileType,
10
- )
11
- from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
12
- from notionary.page.content.syntax import SyntaxRegistry
13
-
3
+ from notionary.blocks.schemas import CreateFileBlock, ExternalFileWithCaption
4
+ from notionary.page.content.parser.parsers.file_like_block import FileLikeBlockParser
5
+ from notionary.page.content.syntax import SyntaxDefinition, SyntaxRegistry
14
6
 
15
- class FileParser(LineParser):
16
- def __init__(self, syntax_registry: SyntaxRegistry) -> None:
17
- super().__init__(syntax_registry)
18
- self._syntax = syntax_registry.get_file_syntax()
19
7
 
8
+ class FileParser(FileLikeBlockParser[CreateFileBlock]):
20
9
  @override
21
- def _can_handle(self, context: BlockParsingContext) -> bool:
22
- if context.is_inside_parent_context():
23
- return False
24
- return self._syntax.regex_pattern.search(context.line) is not None
10
+ def _get_syntax(self, syntax_registry: SyntaxRegistry) -> SyntaxDefinition:
11
+ return syntax_registry.get_file_syntax()
25
12
 
26
13
  @override
27
- async def _process(self, context: BlockParsingContext) -> None:
28
- url = self._extract_url(context.line)
29
- if not url:
30
- return
31
-
32
- file_data = FileData(
33
- type=FileType.EXTERNAL,
34
- external=ExternalFile(url=url),
35
- caption=[],
36
- )
37
- block = CreateFileBlock(file=file_data)
38
- context.result_blocks.append(block)
39
-
40
- def _extract_url(self, line: str) -> str | None:
41
- match = self._syntax.regex_pattern.search(line)
42
- return match.group(1).strip() if match else None
14
+ def _create_block(self, file_data: ExternalFileWithCaption) -> CreateFileBlock:
15
+ return CreateFileBlock(file=file_data)
@@ -0,0 +1,89 @@
1
+ from abc import abstractmethod
2
+ from pathlib import Path
3
+ from typing import Generic, TypeVar, override
4
+
5
+ from notionary.blocks.schemas import (
6
+ ExternalFileWithCaption,
7
+ FileUploadFileWithCaption,
8
+ FileWithCaption,
9
+ )
10
+ from notionary.exceptions.file_upload import UploadFailedError, UploadTimeoutError
11
+ from notionary.file_upload.service import NotionFileUpload
12
+ from notionary.page.content.parser.parsers.base import BlockParsingContext, LineParser
13
+ from notionary.page.content.syntax import SyntaxRegistry
14
+ from notionary.page.content.syntax.models import SyntaxDefinition
15
+ from notionary.shared.models.file import ExternalFileData, FileUploadedFileData
16
+ from notionary.utils.mixins.logging import LoggingMixin
17
+
18
+ _TBlock = TypeVar("_TBlock")
19
+
20
+
21
+ class FileLikeBlockParser(LineParser, LoggingMixin, Generic[_TBlock]):
22
+ def __init__(self, syntax_registry: SyntaxRegistry, file_upload_service: NotionFileUpload | None = None) -> None:
23
+ super().__init__(syntax_registry)
24
+ self._syntax = self._get_syntax(syntax_registry)
25
+ self._file_upload_service = file_upload_service or NotionFileUpload()
26
+
27
+ @abstractmethod
28
+ def _get_syntax(self, syntax_registry: SyntaxRegistry) -> SyntaxDefinition:
29
+ pass
30
+
31
+ @abstractmethod
32
+ def _create_block(self, file_data: FileWithCaption) -> _TBlock:
33
+ pass
34
+
35
+ @override
36
+ def _can_handle(self, context: BlockParsingContext) -> bool:
37
+ if context.is_inside_parent_context():
38
+ return False
39
+ return self._syntax.regex_pattern.search(context.line) is not None
40
+
41
+ @override
42
+ async def _process(self, context: BlockParsingContext) -> None:
43
+ path_or_url = self._extract_path_or_url(context.line)
44
+ if not path_or_url:
45
+ return
46
+
47
+ try:
48
+ if self._is_external_url(path_or_url):
49
+ file_data = ExternalFileWithCaption(external=ExternalFileData(url=path_or_url))
50
+ else:
51
+ file_data = await self._upload_local_file(path_or_url)
52
+
53
+ block = self._create_block(file_data)
54
+ context.result_blocks.append(block)
55
+
56
+ except FileNotFoundError:
57
+ self.logger.warning("File not found: '%s' - skipping block", path_or_url)
58
+ except PermissionError:
59
+ self.logger.warning("No permission to read file: '%s' - skipping block", path_or_url)
60
+ except IsADirectoryError:
61
+ self.logger.warning("Path is a directory, not a file: '%s' - skipping block", path_or_url)
62
+ except (UploadFailedError, UploadTimeoutError) as e:
63
+ self.logger.warning("Upload failed for '%s': %s - skipping block", path_or_url, e)
64
+ except OSError as e:
65
+ self.logger.warning("IO error reading file '%s': %s - skipping block", path_or_url, e)
66
+ except Exception as e:
67
+ self.logger.warning("Unexpected error processing file '%s': %s - skipping block", path_or_url, e)
68
+
69
+ def _extract_path_or_url(self, line: str) -> str | None:
70
+ match = self._syntax.regex_pattern.search(line)
71
+ return match.group(1).strip() if match else None
72
+
73
+ def _is_external_url(self, path_or_url: str) -> bool:
74
+ if path_or_url.startswith("http://") or path_or_url.startswith("https://"):
75
+ return True
76
+
77
+ if path_or_url.startswith("data:"):
78
+ return True
79
+
80
+ return path_or_url.startswith("/")
81
+
82
+ async def _upload_local_file(self, file_path: str) -> FileUploadFileWithCaption:
83
+ path = Path(file_path)
84
+ self.logger.debug("Uploading local file: '%s'", path)
85
+ upload_response = await self._file_upload_service.upload_file(path)
86
+
87
+ return FileUploadFileWithCaption(
88
+ file_upload=FileUploadedFileData(id=upload_response.id),
89
+ )