notionary 0.2.22__py3-none-any.whl → 0.2.24__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 (105) hide show
  1. notionary/__init__.py +1 -1
  2. notionary/blocks/__init__.py +3 -1
  3. notionary/blocks/audio/__init__.py +0 -2
  4. notionary/blocks/audio/audio_element.py +92 -49
  5. notionary/blocks/audio/audio_markdown_node.py +4 -17
  6. notionary/blocks/bookmark/__init__.py +0 -2
  7. notionary/blocks/bookmark/bookmark_markdown_node.py +5 -21
  8. notionary/blocks/breadcrumbs/__init__.py +0 -2
  9. notionary/blocks/breadcrumbs/breadcrumb_markdown_node.py +2 -21
  10. notionary/blocks/bulleted_list/__init__.py +0 -2
  11. notionary/blocks/bulleted_list/bulleted_list_markdown_node.py +3 -17
  12. notionary/blocks/bulleted_list/bulleted_list_models.py +0 -1
  13. notionary/blocks/callout/__init__.py +0 -2
  14. notionary/blocks/callout/callout_markdown_node.py +4 -18
  15. notionary/blocks/callout/callout_models.py +3 -4
  16. notionary/blocks/child_database/child_database_element.py +2 -4
  17. notionary/blocks/code/code_markdown_node.py +5 -19
  18. notionary/blocks/column/__init__.py +0 -4
  19. notionary/blocks/column/column_list_markdown_node.py +3 -19
  20. notionary/blocks/column/column_markdown_node.py +4 -21
  21. notionary/blocks/divider/__init__.py +0 -2
  22. notionary/blocks/divider/divider_markdown_node.py +2 -16
  23. notionary/blocks/embed/__init__.py +0 -2
  24. notionary/blocks/embed/embed_markdown_node.py +4 -17
  25. notionary/blocks/equation/__init__.py +0 -1
  26. notionary/blocks/equation/equation_element_markdown_node.py +3 -15
  27. notionary/blocks/file/__init__.py +0 -2
  28. notionary/blocks/file/file_element.py +67 -46
  29. notionary/blocks/file/file_element_markdown_node.py +4 -17
  30. notionary/blocks/heading/__init__.py +0 -2
  31. notionary/blocks/heading/heading_markdown_node.py +5 -19
  32. notionary/blocks/heading/heading_models.py +3 -3
  33. notionary/blocks/image_block/__init__.py +0 -2
  34. notionary/blocks/image_block/image_element.py +66 -25
  35. notionary/blocks/image_block/image_markdown_node.py +5 -20
  36. notionary/{markdown → blocks/markdown}/markdown_builder.py +29 -233
  37. notionary/blocks/markdown/markdown_node.py +25 -0
  38. notionary/blocks/mixins/file_upload/__init__.py +3 -0
  39. notionary/blocks/mixins/file_upload/file_upload_mixin.py +320 -0
  40. notionary/blocks/numbered_list/__init__.py +0 -1
  41. notionary/blocks/numbered_list/numbered_list_markdown_node.py +3 -17
  42. notionary/blocks/numbered_list/numbered_list_models.py +3 -3
  43. notionary/blocks/paragraph/__init__.py +0 -2
  44. notionary/blocks/paragraph/paragraph_markdown_node.py +3 -13
  45. notionary/blocks/pdf/__init__.py +0 -2
  46. notionary/blocks/pdf/pdf_element.py +81 -32
  47. notionary/blocks/pdf/pdf_markdown_node.py +5 -18
  48. notionary/blocks/quote/__init__.py +0 -2
  49. notionary/blocks/quote/quote_markdown_node.py +3 -13
  50. notionary/blocks/registry/__init__.py +1 -2
  51. notionary/blocks/registry/block_registry.py +116 -61
  52. notionary/blocks/rich_text/text_inline_formatter.py +1 -1
  53. notionary/blocks/table/__init__.py +0 -2
  54. notionary/blocks/table/table_markdown_node.py +17 -16
  55. notionary/blocks/table_of_contents/__init__.py +0 -2
  56. notionary/blocks/table_of_contents/table_of_contents_element.py +27 -15
  57. notionary/blocks/table_of_contents/table_of_contents_markdown_node.py +3 -17
  58. notionary/blocks/table_of_contents/table_of_contents_models.py +2 -2
  59. notionary/blocks/todo/__init__.py +0 -2
  60. notionary/blocks/todo/todo_markdown_node.py +9 -20
  61. notionary/blocks/todo/todo_models.py +2 -3
  62. notionary/blocks/toggle/__init__.py +0 -2
  63. notionary/blocks/toggle/toggle_markdown_node.py +5 -19
  64. notionary/blocks/toggleable_heading/__init__.py +0 -2
  65. notionary/blocks/toggleable_heading/toggleable_heading_markdown_node.py +6 -23
  66. notionary/blocks/video/__init__.py +0 -2
  67. notionary/blocks/video/video_element.py +110 -34
  68. notionary/blocks/video/video_markdown_node.py +4 -15
  69. notionary/comments/__init__.py +26 -0
  70. notionary/comments/client.py +211 -0
  71. notionary/comments/models.py +129 -0
  72. notionary/file_upload/client.py +3 -2
  73. notionary/file_upload/models.py +10 -1
  74. notionary/file_upload/notion_file_upload.py +5 -5
  75. notionary/page/client.py +1 -6
  76. notionary/page/markdown_whitespace_processor.py +129 -0
  77. notionary/page/notion_page.py +87 -48
  78. notionary/page/page_content_deleting_service.py +1 -1
  79. notionary/page/page_content_writer.py +32 -129
  80. notionary/page/page_context.py +0 -6
  81. notionary/page/reader/handler/column_list_renderer.py +2 -2
  82. notionary/page/reader/handler/column_renderer.py +2 -2
  83. notionary/page/reader/handler/line_renderer.py +2 -2
  84. notionary/page/reader/handler/toggle_renderer.py +2 -2
  85. notionary/page/reader/handler/toggleable_heading_renderer.py +2 -2
  86. notionary/page/writer/handler/toggle_handler.py +8 -4
  87. notionary/page/writer/handler/toggleable_heading_handler.py +3 -2
  88. notionary/page/writer/markdown_to_notion_converter.py +74 -30
  89. notionary/schemas/__init__.py +3 -0
  90. notionary/schemas/base.py +73 -0
  91. notionary/shared/__init__.py +3 -0
  92. notionary/{blocks/rich_text → shared}/name_to_id_resolver.py +0 -2
  93. {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/METADATA +15 -2
  94. {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/RECORD +97 -95
  95. notionary/blocks/guards.py +0 -22
  96. notionary/blocks/registry/block_registry_builder.py +0 -264
  97. notionary/markdown/makdown_document_model.py +0 -0
  98. notionary/markdown/markdown_document_model.py +0 -228
  99. notionary/markdown/markdown_node.py +0 -30
  100. notionary/models/notion_database_response.py +0 -0
  101. notionary/page/writer/markdown_to_notion_formatting_post_processor.py +0 -73
  102. notionary/page/writer/markdown_to_notion_post_processor.py +0 -0
  103. /notionary/{markdown/___init__.py → blocks/markdown/markdown_document_model.py} +0 -0
  104. {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/LICENSE +0 -0
  105. {notionary-0.2.22.dist-info → notionary-0.2.24.dist-info}/WHEEL +0 -0
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, AsyncGenerator, Optional
4
+
5
+ from notionary.base_notion_client import BaseNotionClient
6
+ from notionary.blocks.rich_text.rich_text_models import RichTextObject
7
+ from notionary.comments.models import Comment, CommentListResponse
8
+
9
+
10
+ class CommentClient(BaseNotionClient):
11
+ """
12
+ Client for Notion comment operations.
13
+ Uses Pydantic models for typed responses.
14
+
15
+ Notes / API constraints:
16
+ - Listing returns only *unresolved* comments. Resolved comments are not returned.
17
+ - You can create:
18
+ 1) a top-level comment on a page
19
+ 2) a reply in an existing discussion (requires discussion_id)
20
+ You cannot start a brand-new inline thread via API.
21
+ - Read/Insert comment capabilities must be enabled for the integration.
22
+ """
23
+
24
+ async def retrieve_comment(self, comment_id: str) -> Comment:
25
+ """
26
+ Retrieve a single Comment object by its ID.
27
+
28
+ Requires the integration to have "Read comment" capability enabled.
29
+ Raises 403 (restricted_resource) without it.
30
+ """
31
+ resp = await self.get(f"comments/{comment_id}")
32
+ if resp is None:
33
+ raise RuntimeError("Failed to retrieve comment.")
34
+ return Comment.model_validate(resp)
35
+
36
+ async def list_all_comments_for_page(
37
+ self, *, page_id: str, page_size: int = 100
38
+ ) -> list[Comment]:
39
+ """Returns all unresolved comments for a page (handles pagination)."""
40
+ results: list[Comment] = []
41
+ cursor: str | None = None
42
+ while True:
43
+ page = await self.list_comments(
44
+ block_id=page_id, start_cursor=cursor, page_size=page_size
45
+ )
46
+ results.extend(page.results)
47
+ if not page.has_more:
48
+ break
49
+ cursor = page.next_cursor
50
+ return results
51
+
52
+ async def list_comments(
53
+ self,
54
+ *,
55
+ block_id: str,
56
+ start_cursor: Optional[str] = None,
57
+ page_size: Optional[int] = None,
58
+ ) -> CommentListResponse:
59
+ """
60
+ List unresolved comments for a page or block.
61
+
62
+ Args:
63
+ block_id: Page ID or block ID to list comments for.
64
+ start_cursor: Pagination cursor.
65
+ page_size: Max items per page (<= 100).
66
+
67
+ Returns:
68
+ CommentListResponse with results, next_cursor, has_more, etc.
69
+ """
70
+ params: dict[str, str | int] = {"block_id": block_id}
71
+ if start_cursor:
72
+ params["start_cursor"] = start_cursor
73
+ if page_size:
74
+ params["page_size"] = page_size
75
+
76
+ resp = await self.get("comments", params=params)
77
+ if resp is None:
78
+ raise RuntimeError("Failed to list comments.")
79
+ return CommentListResponse.model_validate(resp)
80
+
81
+ async def iter_comments(
82
+ self,
83
+ *,
84
+ block_id: str,
85
+ page_size: int = 100,
86
+ ) -> AsyncGenerator[Comment, None]:
87
+ """
88
+ Async generator over all unresolved comments for a given page/block.
89
+ Handles pagination under the hood.
90
+ """
91
+ cursor: Optional[str] = None
92
+ while True:
93
+ page = await self.list_comments(
94
+ block_id=block_id, start_cursor=cursor, page_size=page_size
95
+ )
96
+ for item in page.results:
97
+ yield item
98
+ if not page.has_more:
99
+ break
100
+ cursor = page.next_cursor
101
+
102
+ async def create_comment_on_page(
103
+ self,
104
+ *,
105
+ page_id: str,
106
+ text: str,
107
+ display_name: Optional[dict] = None,
108
+ attachments: Optional[list[dict]] = None,
109
+ ) -> Comment:
110
+ """
111
+ Create a top-level comment on a page.
112
+
113
+ Args:
114
+ page_id: Target page ID.
115
+ text: Plain text content for the comment (rich_text will be constructed).
116
+ display_name: Optional "Comment Display Name" object to override author label.
117
+ attachments: Optional list of "Comment Attachment" objects (max 3).
118
+
119
+ Returns:
120
+ The created Comment object.
121
+ """
122
+ body: dict = {
123
+ "parent": {"page_id": page_id},
124
+ "rich_text": [{"type": "text", "text": {"content": text}}],
125
+ }
126
+ if display_name:
127
+ body["display_name"] = display_name
128
+ if attachments:
129
+ body["attachments"] = attachments
130
+
131
+ resp = await self.post("comments", data=body)
132
+ if resp is None:
133
+ raise RuntimeError("Failed to create page comment.")
134
+ return Comment.model_validate(resp)
135
+
136
+ async def create_comment(
137
+ self,
138
+ *,
139
+ page_id: Optional[str] = None,
140
+ discussion_id: Optional[str] = None,
141
+ content: Optional[str] = None,
142
+ rich_text: Optional[list[RichTextObject]] = None,
143
+ display_name: Optional[dict[str, Any]] = None,
144
+ attachments: Optional[list[dict[str, Any]]] = None,
145
+ ) -> Comment:
146
+ """
147
+ Create a comment on a page OR reply to an existing discussion.
148
+
149
+ Rules:
150
+ - Exactly one of page_id or discussion_id must be provided.
151
+ - Provide either rich_text OR content (plain text). If both given, rich_text wins.
152
+ - Up to 3 attachments allowed by Notion.
153
+ """
154
+ # validate parent
155
+ if (page_id is None) == (discussion_id is None):
156
+ raise ValueError("Specify exactly one parent: page_id OR discussion_id")
157
+
158
+ # build rich_text if only content is provided
159
+ rt = rich_text if rich_text else None
160
+ if rt is None:
161
+ if not content:
162
+ raise ValueError("Provide either 'rich_text' or 'content'.")
163
+ rt = [{"type": "text", "text": {"content": content}}]
164
+
165
+ body: dict[str, Any] = {"rich_text": rt}
166
+ if page_id:
167
+ body["parent"] = {"page_id": page_id}
168
+ else:
169
+ body["discussion_id"] = discussion_id
170
+
171
+ if display_name:
172
+ body["display_name"] = display_name
173
+ if attachments:
174
+ body["attachments"] = attachments
175
+
176
+ resp = await self.post("comments", data=body)
177
+ if resp is None:
178
+ raise RuntimeError("Failed to create comment.")
179
+ return Comment.model_validate(resp)
180
+
181
+ # ---------- Convenience wrappers ----------
182
+
183
+ async def create_comment_on_page(
184
+ self,
185
+ *,
186
+ page_id: str,
187
+ text: str,
188
+ display_name: Optional[dict] = None,
189
+ attachments: Optional[list[dict]] = None,
190
+ ) -> Comment:
191
+ return await self.create_comment(
192
+ page_id=page_id,
193
+ content=text,
194
+ display_name=display_name,
195
+ attachments=attachments,
196
+ )
197
+
198
+ async def reply_to_discussion(
199
+ self,
200
+ *,
201
+ discussion_id: str,
202
+ text: str,
203
+ display_name: Optional[dict] = None,
204
+ attachments: Optional[list[dict]] = None,
205
+ ) -> Comment:
206
+ return await self.create_comment(
207
+ discussion_id=discussion_id,
208
+ content=text,
209
+ display_name=display_name,
210
+ attachments=attachments,
211
+ )
@@ -0,0 +1,129 @@
1
+ # notionary/comments/models.py
2
+ from __future__ import annotations
3
+
4
+ from datetime import datetime
5
+ from typing import Literal, Optional, Union
6
+
7
+ from pydantic import BaseModel, Field, ConfigDict
8
+
9
+ from notionary.blocks.rich_text import RichTextObject
10
+
11
+
12
+ class UserRef(BaseModel):
13
+ """Minimal Notion user reference."""
14
+
15
+ model_config = ConfigDict(extra="ignore")
16
+ object: Literal["user"] = "user"
17
+ id: str
18
+
19
+
20
+ class CommentParent(BaseModel):
21
+ """
22
+ Parent of a comment. Can be page_id or block_id.
23
+ Notion responds with the active one; the other remains None.
24
+ """
25
+
26
+ model_config = ConfigDict(extra="ignore")
27
+ type: Literal["page_id", "block_id"]
28
+ page_id: Optional[str] = None
29
+ block_id: Optional[str] = None
30
+
31
+
32
+ class FileWithExpiry(BaseModel):
33
+ """File object with temporary URL (common Notion pattern)."""
34
+
35
+ model_config = ConfigDict(extra="ignore")
36
+ url: str
37
+ expiry_time: Optional[datetime] = None
38
+
39
+
40
+ class CommentAttachmentFile(BaseModel):
41
+ """Attachment stored by Notion with expiring download URL."""
42
+
43
+ model_config = ConfigDict(extra="ignore")
44
+ type: Literal["file"] = "file"
45
+ name: Optional[str] = None
46
+ file: FileWithExpiry
47
+
48
+
49
+ class CommentAttachmentExternal(BaseModel):
50
+ """External attachment referenced by URL."""
51
+
52
+ model_config = ConfigDict(extra="ignore")
53
+ type: Literal["external"] = "external"
54
+ name: Optional[str] = None
55
+ external: dict # {"url": "..."} – kept generic
56
+
57
+
58
+ CommentAttachment = Union[CommentAttachmentFile, CommentAttachmentExternal]
59
+
60
+
61
+ # ---------------------------
62
+ # Display name override (optional)
63
+ # ---------------------------
64
+
65
+
66
+ class CommentDisplayName(BaseModel):
67
+ """
68
+ Optional display name override for comments created by an integration.
69
+ Example: {"type": "integration", "resolved_name": "int"}.
70
+ """
71
+
72
+ model_config = ConfigDict(extra="ignore")
73
+ type: str
74
+ resolved_name: Optional[str] = None
75
+
76
+
77
+ # ---------------------------
78
+ # Core Comment object
79
+ # ---------------------------
80
+
81
+
82
+ class Comment(BaseModel):
83
+ """
84
+ Notion Comment object as returned by:
85
+ - GET /v1/comments/{comment_id} (retrieve)
86
+ - GET /v1/comments?block_id=... (list -> in results[])
87
+ - POST /v1/comments (create)
88
+ """
89
+
90
+ model_config = ConfigDict(extra="ignore")
91
+
92
+ object: Literal["comment"] = "comment"
93
+ id: str
94
+
95
+ parent: CommentParent
96
+ discussion_id: str
97
+
98
+ created_time: datetime
99
+ last_edited_time: datetime
100
+
101
+ created_by: UserRef
102
+
103
+ rich_text: list[RichTextObject] = Field(default_factory=list)
104
+
105
+ # Optional fields that may appear depending on capabilities/payload
106
+ display_name: Optional[CommentDisplayName] = None
107
+ attachments: Optional[list[CommentAttachment]] = None
108
+
109
+
110
+ # ---------------------------
111
+ # List envelope (for list-comments)
112
+ # ---------------------------
113
+
114
+
115
+ class CommentListResponse(BaseModel):
116
+ """
117
+ Envelope for GET /v1/comments?block_id=...
118
+ """
119
+
120
+ model_config = ConfigDict(extra="ignore")
121
+
122
+ object: Literal["list"] = "list"
123
+ results: list[Comment] = Field(default_factory=list)
124
+ next_cursor: Optional[str] = None
125
+ has_more: bool = False
126
+
127
+ # Notion includes these two fields on the list envelope.
128
+ type: Optional[Literal["comment"]] = None
129
+ comment: Optional[dict] = None
@@ -11,6 +11,7 @@ from notionary.file_upload.models import (
11
11
  FileUploadCreateRequest,
12
12
  FileUploadListResponse,
13
13
  FileUploadResponse,
14
+ UploadMode,
14
15
  )
15
16
 
16
17
 
@@ -25,7 +26,7 @@ class NotionFileUploadClient(BaseNotionClient):
25
26
  filename: str,
26
27
  content_type: Optional[str] = None,
27
28
  content_length: Optional[int] = None,
28
- mode: str = "single_part",
29
+ mode: UploadMode = UploadMode.SINGLE_PART,
29
30
  ) -> Optional[FileUploadResponse]:
30
31
  """
31
32
  Create a new file upload.
@@ -34,7 +35,7 @@ class NotionFileUploadClient(BaseNotionClient):
34
35
  filename: Name of the file (max 900 bytes)
35
36
  content_type: MIME type of the file
36
37
  content_length: Size of the file in bytes
37
- mode: Upload mode ("single_part" or "multi_part")
38
+ mode: Upload mode (UploadMode.SINGLE_PART or UploadMode.MULTI_PART)
38
39
 
39
40
  Returns:
40
41
  FileUploadResponse or None if failed
@@ -1,8 +1,17 @@
1
+ from enum import Enum
2
+
1
3
  from typing import Literal, Optional
2
4
 
3
5
  from pydantic import BaseModel
4
6
 
5
7
 
8
+ class UploadMode(str, Enum):
9
+ """Enum for file upload modes."""
10
+
11
+ SINGLE_PART = "single_part"
12
+ MULTI_PART = "multi_part"
13
+
14
+
6
15
  class FileUploadResponse(BaseModel):
7
16
  """
8
17
  Represents a Notion file upload object as returned by the File Upload API.
@@ -44,7 +53,7 @@ class FileUploadCreateRequest(BaseModel):
44
53
  filename: str
45
54
  content_type: Optional[str] = None
46
55
  content_length: Optional[int] = None
47
- mode: Literal["single_part", "multi_part"] = "single_part"
56
+ mode: UploadMode = UploadMode.SINGLE_PART
48
57
 
49
58
  def model_dump(self, **kwargs):
50
59
  """Override to exclude None values"""
@@ -5,7 +5,7 @@ from io import BytesIO
5
5
  from pathlib import Path
6
6
  from typing import Optional
7
7
 
8
- from notionary.file_upload.models import FileUploadResponse
8
+ from notionary.file_upload.models import FileUploadResponse, UploadMode
9
9
  from notionary.util import LoggingMixin
10
10
 
11
11
 
@@ -196,7 +196,7 @@ class NotionFileUpload(LoggingMixin):
196
196
  filename=filename,
197
197
  content_type=content_type,
198
198
  content_length=file_size,
199
- mode="single_part",
199
+ mode=UploadMode.SINGLE_PART,
200
200
  )
201
201
 
202
202
  if not file_upload:
@@ -228,7 +228,7 @@ class NotionFileUpload(LoggingMixin):
228
228
  filename=filename,
229
229
  content_type=content_type,
230
230
  content_length=file_size,
231
- mode="multi_part",
231
+ mode=UploadMode.MULTI_PART,
232
232
  )
233
233
 
234
234
  if not file_upload:
@@ -269,7 +269,7 @@ class NotionFileUpload(LoggingMixin):
269
269
  filename=filename,
270
270
  content_type=content_type,
271
271
  content_length=file_size,
272
- mode="single_part",
272
+ mode=UploadMode.SINGLE_PART,
273
273
  )
274
274
 
275
275
  if not file_upload:
@@ -299,7 +299,7 @@ class NotionFileUpload(LoggingMixin):
299
299
  filename=filename,
300
300
  content_type=content_type,
301
301
  content_length=file_size,
302
- mode="multi_part",
302
+ mode=UploadMode.MULTI_PART,
303
303
  )
304
304
 
305
305
  if not file_upload:
notionary/page/client.py CHANGED
@@ -42,18 +42,13 @@ class NotionPageClient(BaseNotionClient):
42
42
  )
43
43
 
44
44
  properties: dict[str, Any] = {
45
- "title": {
46
- "title": [
47
- {"type": "text", "text": {"content": title}}
48
- ]
49
- }
45
+ "title": {"title": [{"type": "text", "text": {"content": title}}]}
50
46
  }
51
47
 
52
48
  payload = {"parent": parent, "properties": properties}
53
49
  response = await self.post("pages", payload)
54
50
  return NotionPageResponse.model_validate(response)
55
51
 
56
-
57
52
  async def patch_page(
58
53
  self, page_id: str, data: Optional[dict[str, Any]] = None
59
54
  ) -> NotionPageResponse:
@@ -0,0 +1,129 @@
1
+ """
2
+ Markdown whitespace processing utilities.
3
+
4
+ Handles normalization of markdown text while preserving code blocks and their indentation.
5
+ """
6
+
7
+ from typing import Tuple
8
+
9
+
10
+ class MarkdownWhitespaceProcessor:
11
+ """
12
+ Processes markdown text to normalize whitespace while preserving code block formatting.
13
+
14
+ This processor handles:
15
+ - Removing leading whitespace from regular lines
16
+ - Preserving code block structure and indentation
17
+ - Normalizing code block markers
18
+ """
19
+
20
+ @staticmethod
21
+ def process_markdown_whitespace(markdown_text: str) -> str:
22
+ """Process markdown text to normalize whitespace while preserving code blocks."""
23
+ lines = markdown_text.split("\n")
24
+ if not lines:
25
+ return ""
26
+
27
+ return MarkdownWhitespaceProcessor._process_whitespace_lines(lines)
28
+
29
+ @staticmethod
30
+ def _process_whitespace_lines(lines: list[str]) -> str:
31
+ """Process all lines and return the processed markdown."""
32
+ processed_lines = []
33
+ in_code_block = False
34
+ current_code_block = []
35
+
36
+ for line in lines:
37
+ processed_lines, in_code_block, current_code_block = (
38
+ MarkdownWhitespaceProcessor._process_single_line(
39
+ line, processed_lines, in_code_block, current_code_block
40
+ )
41
+ )
42
+
43
+ return "\n".join(processed_lines)
44
+
45
+ @staticmethod
46
+ def _process_single_line(
47
+ line: str,
48
+ processed_lines: list[str],
49
+ in_code_block: bool,
50
+ current_code_block: list[str],
51
+ ) -> Tuple[list[str], bool, list[str]]:
52
+ """Process a single line and return updated state."""
53
+ if MarkdownWhitespaceProcessor._is_code_block_marker(line):
54
+ return MarkdownWhitespaceProcessor._handle_code_block_marker(
55
+ line, processed_lines, in_code_block, current_code_block
56
+ )
57
+ if in_code_block:
58
+ current_code_block.append(line)
59
+ return processed_lines, in_code_block, current_code_block
60
+ else:
61
+ processed_lines.append(line.lstrip())
62
+ return processed_lines, in_code_block, current_code_block
63
+
64
+ @staticmethod
65
+ def _handle_code_block_marker(
66
+ line: str,
67
+ processed_lines: list[str],
68
+ in_code_block: bool,
69
+ current_code_block: list[str],
70
+ ) -> Tuple[list[str], bool, list[str]]:
71
+ """Handle code block start/end markers."""
72
+ if not in_code_block:
73
+ return MarkdownWhitespaceProcessor._start_code_block(line, processed_lines)
74
+ else:
75
+ return MarkdownWhitespaceProcessor._end_code_block(
76
+ processed_lines, current_code_block
77
+ )
78
+
79
+ @staticmethod
80
+ def _start_code_block(
81
+ line: str, processed_lines: list[str]
82
+ ) -> Tuple[list[str], bool, list[str]]:
83
+ """Start a new code block."""
84
+ processed_lines.append(
85
+ MarkdownWhitespaceProcessor._normalize_code_block_start(line)
86
+ )
87
+ return processed_lines, True, []
88
+
89
+ @staticmethod
90
+ def _end_code_block(
91
+ processed_lines: list[str], current_code_block: list[str]
92
+ ) -> Tuple[list[str], bool, list[str]]:
93
+ """End the current code block."""
94
+ processed_lines.extend(
95
+ MarkdownWhitespaceProcessor._normalize_code_block_content(
96
+ current_code_block
97
+ )
98
+ )
99
+ processed_lines.append("```")
100
+ return processed_lines, False, []
101
+
102
+ @staticmethod
103
+ def _is_code_block_marker(line: str) -> bool:
104
+ """Check if line is a code block marker."""
105
+ return line.lstrip().startswith("```")
106
+
107
+ @staticmethod
108
+ def _normalize_code_block_start(line: str) -> str:
109
+ """Normalize code block opening marker."""
110
+ language = line.lstrip().replace("```", "", 1).strip()
111
+ return "```" + language
112
+
113
+ @staticmethod
114
+ def _normalize_code_block_content(code_lines: list[str]) -> list[str]:
115
+ """Normalize code block indentation."""
116
+ if not code_lines:
117
+ return []
118
+
119
+ # Find minimum indentation from non-empty lines
120
+ non_empty_lines = [line for line in code_lines if line.strip()]
121
+ if not non_empty_lines:
122
+ return [""] * len(code_lines)
123
+
124
+ min_indent = min(len(line) - len(line.lstrip()) for line in non_empty_lines)
125
+ if min_indent == 0:
126
+ return code_lines
127
+
128
+ # Remove common indentation
129
+ return ["" if not line.strip() else line[min_indent:] for line in code_lines]