slack-markdown-parser 2.3.1__tar.gz → 2.3.2__tar.gz
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.
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/CHANGELOG.md +7 -0
- {slack_markdown_parser-2.3.1/slack_markdown_parser.egg-info → slack_markdown_parser-2.3.2}/PKG-INFO +4 -4
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/README-ja.md +1 -1
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/README.md +3 -2
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/docs/spec-ja.md +1 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/docs/spec.md +1 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/pyproject.toml +5 -3
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/__init__.py +1 -1
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/converter.py +214 -71
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2/slack_markdown_parser.egg-info}/PKG-INFO +4 -4
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/requires.txt +0 -1
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/LICENSE +0 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/MANIFEST.in +0 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/setup.cfg +0 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/py.typed +0 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/SOURCES.txt +0 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/dependency_links.txt +0 -0
- {slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser.egg-info/top_level.txt +0 -0
|
@@ -6,6 +6,13 @@ The format is based on Keep a Changelog, and the project follows Semantic Versio
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [2.3.2] - 2026-04-17
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Stopped `preserve_visual_blank_lines` from keeping ordered-list context open after list items, including continued items, nested lists, and ordered-list paragraph interruption edge cases.
|
|
14
|
+
- Avoided false-positive list-context detection for indented non-list lines and thematic breaks, so visible blank-line preservation no longer regresses on inputs like ` 1. not-a-list` or `- - -`.
|
|
15
|
+
|
|
9
16
|
## [2.3.1] - 2026-04-10
|
|
10
17
|
|
|
11
18
|
### Fixed
|
{slack_markdown_parser-2.3.1/slack_markdown_parser.egg-info → slack_markdown_parser-2.3.2}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slack-markdown-parser
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
|
|
5
5
|
Author: darkgaldragon
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,7 +26,6 @@ Requires-Dist: pip-audit>=2.7.0; extra == "dev"
|
|
|
26
26
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
27
27
|
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
28
28
|
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
29
|
-
Requires-Dist: twine>=5.1.0; extra == "dev"
|
|
30
29
|
Dynamic: license-file
|
|
31
30
|
|
|
32
31
|
# slack-markdown-parser
|
|
@@ -210,8 +209,9 @@ Markdown segments with lines that contain only a non-breaking space. Those
|
|
|
210
209
|
placeholder lines are removed again when generating preview plain text, so Slack
|
|
211
210
|
notifications and logs stay close to the original Markdown source.
|
|
212
211
|
The current implementation deliberately skips blank runs that sit immediately
|
|
213
|
-
before setext-heading underlines or
|
|
214
|
-
boundaries can change Markdown meaning in newer
|
|
212
|
+
after list-item content, before setext-heading underlines, or before
|
|
213
|
+
reference-link definitions, because those boundaries can change Markdown meaning in newer
|
|
214
|
+
Slack Markdown rendering or keep list formatting open in some clients.
|
|
215
215
|
|
|
216
216
|
### Utility functions
|
|
217
217
|
|
|
@@ -161,7 +161,7 @@ QA | ~~保留~~ | Team C
|
|
|
161
161
|
|
|
162
162
|
`preserve_visual_blank_lines=True` は、非テーブル領域の内部空行を「改行を見えやすくするための補助行」に置き換えるオプションです。
|
|
163
163
|
この補助行はプレビュー文字列を作るときに取り除かれるので、通知文やログは元の Markdown に近い形を保てます。
|
|
164
|
-
|
|
164
|
+
また、リスト項目の内容直後、setext 見出しの下線直前、参照リンク定義直前には補助行を入れず、Markdown の意味が変わったり一部クライアントでリスト解釈が継続したりしないようにしています。
|
|
165
165
|
|
|
166
166
|
### ユーティリティ関数(公開関数)
|
|
167
167
|
|
|
@@ -179,8 +179,9 @@ Markdown segments with lines that contain only a non-breaking space. Those
|
|
|
179
179
|
placeholder lines are removed again when generating preview plain text, so Slack
|
|
180
180
|
notifications and logs stay close to the original Markdown source.
|
|
181
181
|
The current implementation deliberately skips blank runs that sit immediately
|
|
182
|
-
before setext-heading underlines or
|
|
183
|
-
boundaries can change Markdown meaning in newer
|
|
182
|
+
after list-item content, before setext-heading underlines, or before
|
|
183
|
+
reference-link definitions, because those boundaries can change Markdown meaning in newer
|
|
184
|
+
Slack Markdown rendering or keep list formatting open in some clients.
|
|
184
185
|
|
|
185
186
|
### Utility functions
|
|
186
187
|
|
|
@@ -205,6 +205,7 @@ LLM は外枠パイプの省略、区切り行の欠落、列数の不一致な
|
|
|
205
205
|
- 見える行に挟まれた内部空行だけを書き換える
|
|
206
206
|
- 先頭・末尾の空行のまとまりはそのまま残す
|
|
207
207
|
- table 領域には適用しない
|
|
208
|
+
- リスト項目の内容直後の空行はそのまま残す
|
|
208
209
|
- setext 見出しの下線直前の空行はそのまま残す
|
|
209
210
|
- 参照リンク定義直前の空行はそのまま残す
|
|
210
211
|
- プレビュー文字列と `blocks_to_plain_text` では補助用のマーカーを除去し、元の空行構造に戻す
|
|
@@ -207,6 +207,7 @@ This workaround is intentionally narrow:
|
|
|
207
207
|
- Only blank lines between visible lines are rewritten
|
|
208
208
|
- Leading and trailing blank runs are left untouched
|
|
209
209
|
- Table segments are not modified by this option
|
|
210
|
+
- Blank runs immediately after list-item content are left untouched
|
|
210
211
|
- Blank runs immediately before setext-heading underlines are left untouched
|
|
211
212
|
- Blank runs immediately before reference-link definitions are left untouched
|
|
212
213
|
- Preview text and `blocks_to_plain_text` remove those placeholder markers again, preserving the original blank lines in plain-text output
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "slack-markdown-parser"
|
|
7
|
-
version = "2.3.
|
|
7
|
+
version = "2.3.2"
|
|
8
8
|
description = "Convert LLM Markdown into Slack Block Kit markdown/table messages"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -33,7 +33,6 @@ dev = [
|
|
|
33
33
|
"pytest>=8.0.0",
|
|
34
34
|
"pytest-cov>=5.0.0",
|
|
35
35
|
"ruff>=0.6.0",
|
|
36
|
-
"twine>=5.1.0",
|
|
37
36
|
]
|
|
38
37
|
|
|
39
38
|
[project.urls]
|
|
@@ -57,4 +56,7 @@ target-version = "py310"
|
|
|
57
56
|
|
|
58
57
|
[tool.ruff.lint]
|
|
59
58
|
select = ["E", "F", "I", "UP", "B"]
|
|
60
|
-
ignore = ["E501"
|
|
59
|
+
ignore = ["E501"] # line length is enforced by black
|
|
60
|
+
|
|
61
|
+
[tool.pytest.ini_options]
|
|
62
|
+
testpaths = ["tests"]
|
{slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2}/slack_markdown_parser/converter.py
RENAMED
|
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|
|
8
8
|
|
|
9
9
|
import html
|
|
10
10
|
import re
|
|
11
|
-
from typing import Any
|
|
11
|
+
from typing import Any
|
|
12
12
|
|
|
13
13
|
ZWSP = "\u200b"
|
|
14
14
|
NBSP = "\u00a0"
|
|
@@ -42,6 +42,12 @@ PROTECTED_UNDERSCORE_SPAN_PATTERN = re.compile(
|
|
|
42
42
|
)
|
|
43
43
|
REFERENCE_DEFINITION_PATTERN = re.compile(r"^[ \t]{0,3}\[[^\]\n]+\]:")
|
|
44
44
|
SETEXT_HEADING_UNDERLINE_PATTERN = re.compile(r"^[ \t]{0,3}(?:=+|-+)\s*$")
|
|
45
|
+
THEMATIC_BREAK_PATTERN = re.compile(
|
|
46
|
+
r"^[ \t]{0,3}(?P<char>[-_*])(?:[ \t]*\1){2,}[ \t]*$"
|
|
47
|
+
)
|
|
48
|
+
LIST_ITEM_PATTERN = re.compile(
|
|
49
|
+
r"^(?P<indent>[ \t]*)(?P<marker>\d+[.)]|[-+*])(?P<spacing>[ \t]+|$)"
|
|
50
|
+
)
|
|
45
51
|
DOUBLE_UNDERSCORE_EMPHASIS_PATTERN = re.compile(
|
|
46
52
|
r"(?<![\\0-9A-Za-z_])__(?=\S)(.+?\S)__(?![0-9A-Za-z_])"
|
|
47
53
|
)
|
|
@@ -118,7 +124,7 @@ def _nested_code_space_strategy(
|
|
|
118
124
|
source: str,
|
|
119
125
|
start: int,
|
|
120
126
|
end: int,
|
|
121
|
-
boundary_chars:
|
|
127
|
+
boundary_chars: set[str] | None = None,
|
|
122
128
|
) -> str | None:
|
|
123
129
|
boundary_chars = boundary_chars or {*VISIBLE_BOUNDARY_CHARS, ZWSP}
|
|
124
130
|
neighbors = []
|
|
@@ -154,7 +160,7 @@ def _normalize_markdown_block_plain_text(text: str) -> str:
|
|
|
154
160
|
|
|
155
161
|
|
|
156
162
|
def _build_markdown_block_plain_text(
|
|
157
|
-
text: str, synthetic_space_indices:
|
|
163
|
+
text: str, synthetic_space_indices: list[int] | None = None
|
|
158
164
|
) -> str:
|
|
159
165
|
"""Build fallback/plain text for a markdown block before visual-only rewrites."""
|
|
160
166
|
return _normalize_markdown_block_plain_text(
|
|
@@ -166,7 +172,7 @@ def _build_markdown_block_plain_text(
|
|
|
166
172
|
|
|
167
173
|
|
|
168
174
|
def _strip_synthetic_spaces_from_plain_text(
|
|
169
|
-
text: str, synthetic_space_indices:
|
|
175
|
+
text: str, synthetic_space_indices: list[int] | None = None
|
|
170
176
|
) -> str:
|
|
171
177
|
if not text or not synthetic_space_indices:
|
|
172
178
|
return text
|
|
@@ -177,15 +183,148 @@ def _strip_synthetic_spaces_from_plain_text(
|
|
|
177
183
|
)
|
|
178
184
|
|
|
179
185
|
|
|
186
|
+
def _indent_width(text: str) -> int:
|
|
187
|
+
width = 0
|
|
188
|
+
for char in text:
|
|
189
|
+
if char == " ":
|
|
190
|
+
width += 1
|
|
191
|
+
elif char == "\t":
|
|
192
|
+
# Treat tabs conservatively for list-continuation heuristics.
|
|
193
|
+
width += 4
|
|
194
|
+
else:
|
|
195
|
+
break
|
|
196
|
+
return width
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _list_item_content_indent(match: re.Match[str]) -> int:
|
|
200
|
+
spacing = match.group("spacing") or ""
|
|
201
|
+
spacing_width = _indent_width(spacing) if spacing else 1
|
|
202
|
+
return (
|
|
203
|
+
_indent_width(match.group("indent") or "")
|
|
204
|
+
+ len(match.group("marker") or "")
|
|
205
|
+
+ spacing_width
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _is_ordered_list_marker(marker: str) -> bool:
|
|
210
|
+
return bool(marker) and marker[0].isdigit()
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _is_thematic_break_line(line: str) -> bool:
|
|
214
|
+
return bool(THEMATIC_BREAK_PATTERN.match(line))
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _ordered_list_marker_starts_at_one(marker: str) -> bool:
|
|
218
|
+
if not _is_ordered_list_marker(marker):
|
|
219
|
+
return False
|
|
220
|
+
return marker[:-1] == "1"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _starts_root_list_item(lines: list[str], marker_index: int) -> bool:
|
|
224
|
+
line = lines[marker_index]
|
|
225
|
+
if _is_thematic_break_line(line):
|
|
226
|
+
return False
|
|
227
|
+
match = LIST_ITEM_PATTERN.match(line)
|
|
228
|
+
if not match or _indent_width(match.group("indent") or "") > 3:
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
marker = match.group("marker") or ""
|
|
232
|
+
if marker_index == 0 or not lines[marker_index - 1].strip(" \t\r"):
|
|
233
|
+
return True
|
|
234
|
+
|
|
235
|
+
if not _is_ordered_list_marker(marker):
|
|
236
|
+
return True
|
|
237
|
+
|
|
238
|
+
return _ordered_list_marker_starts_at_one(marker)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _line_belongs_to_list_context(
|
|
242
|
+
lines: list[str], *, marker_index: int, target_index: int
|
|
243
|
+
) -> bool:
|
|
244
|
+
marker_match = LIST_ITEM_PATTERN.match(lines[marker_index])
|
|
245
|
+
if not marker_match:
|
|
246
|
+
return False
|
|
247
|
+
|
|
248
|
+
list_indent_stack = [
|
|
249
|
+
(
|
|
250
|
+
_indent_width(marker_match.group("indent") or ""),
|
|
251
|
+
_list_item_content_indent(marker_match),
|
|
252
|
+
)
|
|
253
|
+
]
|
|
254
|
+
|
|
255
|
+
for idx in range(marker_index + 1, target_index + 1):
|
|
256
|
+
line = lines[idx]
|
|
257
|
+
if not line.strip(" \t\r"):
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
line_indent = _indent_width(line)
|
|
261
|
+
nested_match = LIST_ITEM_PATTERN.match(line)
|
|
262
|
+
if nested_match and not _is_thematic_break_line(line):
|
|
263
|
+
marker_indent = _indent_width(nested_match.group("indent") or "")
|
|
264
|
+
while list_indent_stack and marker_indent < list_indent_stack[-1][0]:
|
|
265
|
+
list_indent_stack.pop()
|
|
266
|
+
|
|
267
|
+
if not list_indent_stack:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
if marker_indent == list_indent_stack[-1][0]:
|
|
271
|
+
list_indent_stack[-1] = (
|
|
272
|
+
marker_indent,
|
|
273
|
+
_list_item_content_indent(nested_match),
|
|
274
|
+
)
|
|
275
|
+
continue
|
|
276
|
+
|
|
277
|
+
if marker_indent >= list_indent_stack[-1][1]:
|
|
278
|
+
list_indent_stack.append(
|
|
279
|
+
(
|
|
280
|
+
marker_indent,
|
|
281
|
+
_list_item_content_indent(nested_match),
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
continue
|
|
285
|
+
|
|
286
|
+
return False
|
|
287
|
+
|
|
288
|
+
while len(list_indent_stack) > 1 and line_indent < list_indent_stack[-1][0]:
|
|
289
|
+
list_indent_stack.pop()
|
|
290
|
+
|
|
291
|
+
if line_indent >= list_indent_stack[-1][1]:
|
|
292
|
+
continue
|
|
293
|
+
|
|
294
|
+
return False
|
|
295
|
+
|
|
296
|
+
return True
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _blank_run_follows_list_context(lines: list[str], blank_start: int) -> bool:
|
|
300
|
+
previous_visible_index = blank_start - 1
|
|
301
|
+
if previous_visible_index < 0 or not lines[previous_visible_index].strip(" \t\r"):
|
|
302
|
+
return False
|
|
303
|
+
|
|
304
|
+
block_start = previous_visible_index
|
|
305
|
+
while block_start > 0 and lines[block_start - 1].strip(" \t\r"):
|
|
306
|
+
block_start -= 1
|
|
307
|
+
|
|
308
|
+
for marker_index in range(previous_visible_index, block_start - 1, -1):
|
|
309
|
+
if not _starts_root_list_item(lines, marker_index):
|
|
310
|
+
continue
|
|
311
|
+
if _line_belongs_to_list_context(
|
|
312
|
+
lines, marker_index=marker_index, target_index=previous_visible_index
|
|
313
|
+
):
|
|
314
|
+
return True
|
|
315
|
+
|
|
316
|
+
return False
|
|
317
|
+
|
|
318
|
+
|
|
180
319
|
def _inject_visual_blank_line_placeholders_in_chunk(
|
|
181
320
|
text: str,
|
|
182
|
-
) -> tuple[str,
|
|
321
|
+
) -> tuple[str, list[int]]:
|
|
183
322
|
"""Replace internal blank lines with NBSP-only lines for Slack rendering."""
|
|
184
323
|
if not text or "\n" not in text:
|
|
185
324
|
return text, []
|
|
186
325
|
|
|
187
326
|
lines = text.split("\n")
|
|
188
|
-
rewritten:
|
|
327
|
+
rewritten: list[tuple[str, bool]] = []
|
|
189
328
|
i = 0
|
|
190
329
|
|
|
191
330
|
while i < len(lines):
|
|
@@ -202,6 +341,9 @@ def _inject_visual_blank_line_placeholders_in_chunk(
|
|
|
202
341
|
lines[blank_start - 1].strip(" \t\r")
|
|
203
342
|
)
|
|
204
343
|
has_visible_line_after = i < len(lines) and bool(lines[i].strip(" \t\r"))
|
|
344
|
+
blank_run_follows_list_context = has_visible_line_before and (
|
|
345
|
+
_blank_run_follows_list_context(lines, blank_start)
|
|
346
|
+
)
|
|
205
347
|
next_visible_starts_reference_definition = has_visible_line_after and bool(
|
|
206
348
|
REFERENCE_DEFINITION_PATTERN.match(lines[i])
|
|
207
349
|
)
|
|
@@ -217,6 +359,7 @@ def _inject_visual_blank_line_placeholders_in_chunk(
|
|
|
217
359
|
if (
|
|
218
360
|
has_visible_line_before
|
|
219
361
|
and has_visible_line_after
|
|
362
|
+
and not blank_run_follows_list_context
|
|
220
363
|
and not next_visible_starts_reference_definition
|
|
221
364
|
and not next_visible_starts_setext_heading
|
|
222
365
|
):
|
|
@@ -224,8 +367,8 @@ def _inject_visual_blank_line_placeholders_in_chunk(
|
|
|
224
367
|
else:
|
|
225
368
|
rewritten.extend((line, False) for line in lines[blank_start:i])
|
|
226
369
|
|
|
227
|
-
rebuilt_parts:
|
|
228
|
-
synthetic_indices:
|
|
370
|
+
rebuilt_parts: list[str] = []
|
|
371
|
+
synthetic_indices: list[int] = []
|
|
229
372
|
offset = 0
|
|
230
373
|
|
|
231
374
|
for idx, (line, is_synthetic) in enumerate(rewritten):
|
|
@@ -240,13 +383,13 @@ def _inject_visual_blank_line_placeholders_in_chunk(
|
|
|
240
383
|
return "".join(rebuilt_parts), synthetic_indices
|
|
241
384
|
|
|
242
385
|
|
|
243
|
-
def _inject_visual_blank_line_placeholders(text: str) -> tuple[str,
|
|
386
|
+
def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, list[int]]:
|
|
244
387
|
"""Replace internal blank lines outside fenced code blocks."""
|
|
245
388
|
if not text or "\n" not in text:
|
|
246
389
|
return text, []
|
|
247
390
|
|
|
248
|
-
rebuilt_parts:
|
|
249
|
-
synthetic_indices:
|
|
391
|
+
rebuilt_parts: list[str] = []
|
|
392
|
+
synthetic_indices: list[int] = []
|
|
250
393
|
offset = 0
|
|
251
394
|
|
|
252
395
|
for is_fenced, chunk in _split_fenced_code_chunks(text):
|
|
@@ -266,7 +409,7 @@ def _inject_visual_blank_line_placeholders(text: str) -> tuple[str, List[int]]:
|
|
|
266
409
|
|
|
267
410
|
|
|
268
411
|
def _strip_synthetic_blank_line_placeholders(
|
|
269
|
-
text: str, synthetic_blank_line_indices:
|
|
412
|
+
text: str, synthetic_blank_line_indices: list[int] | None = None
|
|
270
413
|
) -> str:
|
|
271
414
|
if not text or not synthetic_blank_line_indices:
|
|
272
415
|
return text
|
|
@@ -277,12 +420,12 @@ def _strip_synthetic_blank_line_placeholders(
|
|
|
277
420
|
)
|
|
278
421
|
|
|
279
422
|
|
|
280
|
-
def _remove_synthetic_space_markers(text: str) -> tuple[str,
|
|
423
|
+
def _remove_synthetic_space_markers(text: str) -> tuple[str, list[int]]:
|
|
281
424
|
if not text or SYNTH_SPACE_MARKER not in text:
|
|
282
425
|
return text, []
|
|
283
426
|
|
|
284
|
-
cleaned:
|
|
285
|
-
synthetic_indices:
|
|
427
|
+
cleaned: list[str] = []
|
|
428
|
+
synthetic_indices: list[int] = []
|
|
286
429
|
mark_next_space = False
|
|
287
430
|
|
|
288
431
|
for char in text:
|
|
@@ -366,7 +509,7 @@ def normalize_bare_urls_for_slack_markdown(text: str) -> str:
|
|
|
366
509
|
return text
|
|
367
510
|
|
|
368
511
|
def wrap_chunk(chunk: str) -> str:
|
|
369
|
-
parts:
|
|
512
|
+
parts: list[str] = []
|
|
370
513
|
cursor = 0
|
|
371
514
|
length = len(chunk)
|
|
372
515
|
|
|
@@ -430,7 +573,7 @@ def sanitize_slack_text(text: str) -> str:
|
|
|
430
573
|
return SLACK_ANGLE_TOKEN_PATTERN.sub(replace_invalid_token, cleaned)
|
|
431
574
|
|
|
432
575
|
|
|
433
|
-
def _match_fence_open(line: str) ->
|
|
576
|
+
def _match_fence_open(line: str) -> tuple[str, int] | None:
|
|
434
577
|
match = FENCE_OPEN_PATTERN.match(line)
|
|
435
578
|
if not match:
|
|
436
579
|
return None
|
|
@@ -447,13 +590,13 @@ def _is_fence_close(line: str, fence: tuple[str, int]) -> bool:
|
|
|
447
590
|
)
|
|
448
591
|
|
|
449
592
|
|
|
450
|
-
def _split_fenced_code_chunks(text: str) ->
|
|
451
|
-
chunks:
|
|
593
|
+
def _split_fenced_code_chunks(text: str) -> list[tuple[bool, str]]:
|
|
594
|
+
chunks: list[tuple[bool, str]] = []
|
|
452
595
|
if not text:
|
|
453
596
|
return chunks
|
|
454
597
|
|
|
455
|
-
current:
|
|
456
|
-
active_fence:
|
|
598
|
+
current: list[str] = []
|
|
599
|
+
active_fence: tuple[str, int] | None = None
|
|
457
600
|
|
|
458
601
|
for line in text.splitlines(keepends=True):
|
|
459
602
|
opening_fence = _match_fence_open(line) if active_fence is None else None
|
|
@@ -479,7 +622,7 @@ def _split_fenced_code_chunks(text: str) -> List[tuple[bool, str]]:
|
|
|
479
622
|
|
|
480
623
|
|
|
481
624
|
def _normalize_underscore_emphasis_chunk(text: str) -> str:
|
|
482
|
-
protected_spans:
|
|
625
|
+
protected_spans: list[str] = []
|
|
483
626
|
|
|
484
627
|
def protect(match: re.Match[str]) -> str:
|
|
485
628
|
token = f"\ufff0{len(protected_spans)}\ufff1"
|
|
@@ -517,7 +660,7 @@ def add_zero_width_spaces_to_markdown(text: str) -> str:
|
|
|
517
660
|
return formatted
|
|
518
661
|
|
|
519
662
|
|
|
520
|
-
def _format_markdown_with_spacing_metadata(text: str) -> tuple[str,
|
|
663
|
+
def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, list[int]]:
|
|
521
664
|
"""Return formatted markdown text plus synthetic visible-space positions."""
|
|
522
665
|
if not text:
|
|
523
666
|
return text, []
|
|
@@ -615,12 +758,12 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
|
|
|
615
758
|
suffix = STRIP_RIGHT_ZWSP_MARKER if after_char == ZWSP else ""
|
|
616
759
|
return f"{prefix}{adjusted_text}{suffix}"
|
|
617
760
|
|
|
618
|
-
def wrap_segment(segment: str) -> tuple[str,
|
|
761
|
+
def wrap_segment(segment: str) -> tuple[str, list[int]]:
|
|
619
762
|
if not segment:
|
|
620
763
|
return segment, []
|
|
621
764
|
|
|
622
765
|
placeholder_map: dict[str, dict[str, str]] = {}
|
|
623
|
-
protected_parts:
|
|
766
|
+
protected_parts: list[str] = []
|
|
624
767
|
last_end = 0
|
|
625
768
|
|
|
626
769
|
for idx, match in enumerate(INLINE_CODE_SPAN_PATTERN.finditer(segment)):
|
|
@@ -690,8 +833,8 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
|
|
|
690
833
|
return _remove_synthetic_space_markers(protected_segment)
|
|
691
834
|
|
|
692
835
|
chunks = _split_fenced_code_chunks(text)
|
|
693
|
-
combined_parts:
|
|
694
|
-
combined_indices:
|
|
836
|
+
combined_parts: list[str] = []
|
|
837
|
+
combined_indices: list[int] = []
|
|
695
838
|
offset = 0
|
|
696
839
|
for is_fenced, chunk in chunks:
|
|
697
840
|
if is_fenced:
|
|
@@ -710,7 +853,7 @@ def _format_markdown_with_spacing_metadata(text: str) -> tuple[str, List[int]]:
|
|
|
710
853
|
add_zero_width_spaces = add_zero_width_spaces_to_markdown
|
|
711
854
|
|
|
712
855
|
|
|
713
|
-
def _split_markdown_table_cells(line: str) ->
|
|
856
|
+
def _split_markdown_table_cells(line: str) -> list[str]:
|
|
714
857
|
"""Split markdown table cells while preserving pipes inside <...|...> links."""
|
|
715
858
|
working = line.strip()
|
|
716
859
|
if not working:
|
|
@@ -721,8 +864,8 @@ def _split_markdown_table_cells(line: str) -> List[str]:
|
|
|
721
864
|
if working.endswith("|"):
|
|
722
865
|
working = working[:-1]
|
|
723
866
|
|
|
724
|
-
cells:
|
|
725
|
-
current:
|
|
867
|
+
cells: list[str] = []
|
|
868
|
+
current: list[str] = []
|
|
726
869
|
in_angle = False
|
|
727
870
|
escaped = False
|
|
728
871
|
cursor = 0
|
|
@@ -772,8 +915,8 @@ def _count_cell_words(cell_text: str) -> int:
|
|
|
772
915
|
|
|
773
916
|
|
|
774
917
|
def _split_heading_prefix_and_first_cell(
|
|
775
|
-
heading_prefix: str, reference_cell:
|
|
776
|
-
) ->
|
|
918
|
+
heading_prefix: str, reference_cell: str | None
|
|
919
|
+
) -> tuple[str, str] | None:
|
|
777
920
|
tokens = [token for token in heading_prefix.strip().split() if token]
|
|
778
921
|
if len(tokens) < 2:
|
|
779
922
|
return None
|
|
@@ -792,8 +935,8 @@ def _split_heading_prefix_and_first_cell(
|
|
|
792
935
|
|
|
793
936
|
|
|
794
937
|
def _split_heading_and_table_row(
|
|
795
|
-
line: str, next_line:
|
|
796
|
-
) ->
|
|
938
|
+
line: str, next_line: str | None = None
|
|
939
|
+
) -> tuple[str, str] | None:
|
|
797
940
|
"""Split lines like '# Heading |a|b|' into heading and table row."""
|
|
798
941
|
if "|" not in line:
|
|
799
942
|
return None
|
|
@@ -851,7 +994,7 @@ def _split_heading_and_table_row(
|
|
|
851
994
|
if not explicit_cells:
|
|
852
995
|
return None
|
|
853
996
|
|
|
854
|
-
reference_cell:
|
|
997
|
+
reference_cell: str | None = None
|
|
855
998
|
if (
|
|
856
999
|
next_line
|
|
857
1000
|
and "|" in next_line
|
|
@@ -887,10 +1030,10 @@ def normalize_markdown_tables(markdown_text: str) -> str:
|
|
|
887
1030
|
return markdown_text
|
|
888
1031
|
|
|
889
1032
|
lines = markdown_text.splitlines()
|
|
890
|
-
normalized:
|
|
891
|
-
buffer:
|
|
1033
|
+
normalized: list[str] = []
|
|
1034
|
+
buffer: list[str] = []
|
|
892
1035
|
|
|
893
|
-
def is_table_block(candidates:
|
|
1036
|
+
def is_table_block(candidates: list[str]) -> bool:
|
|
894
1037
|
if len(candidates) < 2:
|
|
895
1038
|
return False
|
|
896
1039
|
if any(
|
|
@@ -898,7 +1041,7 @@ def normalize_markdown_tables(markdown_text: str) -> str:
|
|
|
898
1041
|
):
|
|
899
1042
|
return True
|
|
900
1043
|
|
|
901
|
-
column_counts:
|
|
1044
|
+
column_counts: list[int] = []
|
|
902
1045
|
for line in candidates:
|
|
903
1046
|
working = line.strip()
|
|
904
1047
|
if "|" not in working:
|
|
@@ -955,7 +1098,7 @@ def normalize_markdown_tables(markdown_text: str) -> str:
|
|
|
955
1098
|
normalized.extend(buffer)
|
|
956
1099
|
buffer = []
|
|
957
1100
|
|
|
958
|
-
active_fence:
|
|
1101
|
+
active_fence: tuple[str, int] | None = None
|
|
959
1102
|
|
|
960
1103
|
for idx, line in enumerate(lines):
|
|
961
1104
|
opening_fence = _match_fence_open(line) if active_fence is None else None
|
|
@@ -1003,13 +1146,13 @@ def looks_like_markdown_table(text: str) -> bool:
|
|
|
1003
1146
|
return table_like_lines >= 2
|
|
1004
1147
|
|
|
1005
1148
|
|
|
1006
|
-
def _create_table_cell(text: str) ->
|
|
1149
|
+
def _create_table_cell(text: str) -> dict[str, Any]:
|
|
1007
1150
|
"""Build Slack rich_text cell from markdown fragment."""
|
|
1008
1151
|
clean_text = strip_zero_width_spaces(text or "")
|
|
1009
1152
|
clean_text = clean_text.replace("\\|", "|")
|
|
1010
1153
|
if not clean_text.strip():
|
|
1011
1154
|
clean_text = "-"
|
|
1012
|
-
elements:
|
|
1155
|
+
elements: list[dict[str, Any]] = []
|
|
1013
1156
|
last_index = 0
|
|
1014
1157
|
|
|
1015
1158
|
for match in TABLE_TOKEN_PATTERN.finditer(clean_text):
|
|
@@ -1018,7 +1161,7 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
|
|
|
1018
1161
|
if prefix:
|
|
1019
1162
|
elements.append({"type": "text", "text": prefix})
|
|
1020
1163
|
|
|
1021
|
-
element:
|
|
1164
|
+
element: dict[str, Any]
|
|
1022
1165
|
markdown_label = match.group("markdown_label")
|
|
1023
1166
|
markdown_url = match.group("markdown_url")
|
|
1024
1167
|
angle_url = match.group("angle_url")
|
|
@@ -1034,7 +1177,7 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
|
|
|
1034
1177
|
"text": angle_label or angle_url,
|
|
1035
1178
|
}
|
|
1036
1179
|
else:
|
|
1037
|
-
style:
|
|
1180
|
+
style: dict[str, bool] = {}
|
|
1038
1181
|
content = token
|
|
1039
1182
|
|
|
1040
1183
|
if content.startswith("`") and content.endswith("`"):
|
|
@@ -1070,13 +1213,13 @@ def _create_table_cell(text: str) -> Dict[str, Any]:
|
|
|
1070
1213
|
}
|
|
1071
1214
|
|
|
1072
1215
|
|
|
1073
|
-
def extract_plain_text_from_table_cell(cell:
|
|
1216
|
+
def extract_plain_text_from_table_cell(cell: dict[str, Any]) -> str:
|
|
1074
1217
|
"""Extract plain text from a Slack table cell object."""
|
|
1075
1218
|
if not isinstance(cell, dict):
|
|
1076
1219
|
return ""
|
|
1077
1220
|
|
|
1078
1221
|
if cell.get("type") == "rich_text":
|
|
1079
|
-
texts:
|
|
1222
|
+
texts: list[str] = []
|
|
1080
1223
|
for element in cell.get("elements", []):
|
|
1081
1224
|
if not isinstance(element, dict):
|
|
1082
1225
|
continue
|
|
@@ -1094,13 +1237,13 @@ def extract_plain_text_from_table_cell(cell: Dict[str, Any]) -> str:
|
|
|
1094
1237
|
return str(cell.get("text", ""))
|
|
1095
1238
|
|
|
1096
1239
|
|
|
1097
|
-
def markdown_table_to_slack_table(table_markdown: str) ->
|
|
1240
|
+
def markdown_table_to_slack_table(table_markdown: str) -> dict[str, Any] | None:
|
|
1098
1241
|
"""Convert markdown table text to Slack table block."""
|
|
1099
1242
|
lines = [
|
|
1100
1243
|
line.rstrip() for line in table_markdown.strip().splitlines() if line.strip()
|
|
1101
1244
|
]
|
|
1102
|
-
rows:
|
|
1103
|
-
expected_columns:
|
|
1245
|
+
rows: list[list[dict[str, Any]]] = []
|
|
1246
|
+
expected_columns: int | None = None
|
|
1104
1247
|
|
|
1105
1248
|
for line in lines:
|
|
1106
1249
|
if TABLE_SEPARATOR_PATTERN.match(line):
|
|
@@ -1131,15 +1274,15 @@ def markdown_table_to_slack_table(table_markdown: str) -> Optional[Dict[str, Any
|
|
|
1131
1274
|
markdown_table_to_table_block = markdown_table_to_slack_table
|
|
1132
1275
|
|
|
1133
1276
|
|
|
1134
|
-
def split_markdown_into_segments(markdown_text: str) ->
|
|
1277
|
+
def split_markdown_into_segments(markdown_text: str) -> list[dict[str, str]]:
|
|
1135
1278
|
"""Split markdown into alternating text/table segments."""
|
|
1136
|
-
segments:
|
|
1279
|
+
segments: list[dict[str, str]] = []
|
|
1137
1280
|
if not markdown_text:
|
|
1138
1281
|
return segments
|
|
1139
1282
|
|
|
1140
1283
|
lines = markdown_text.splitlines()
|
|
1141
|
-
current:
|
|
1142
|
-
current_is_table:
|
|
1284
|
+
current: list[str] = []
|
|
1285
|
+
current_is_table: bool | None = None
|
|
1143
1286
|
|
|
1144
1287
|
def flush() -> None:
|
|
1145
1288
|
nonlocal current, current_is_table
|
|
@@ -1153,7 +1296,7 @@ def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
|
|
|
1153
1296
|
current = []
|
|
1154
1297
|
current_is_table = None
|
|
1155
1298
|
|
|
1156
|
-
active_fence:
|
|
1299
|
+
active_fence: tuple[str, int] | None = None
|
|
1157
1300
|
|
|
1158
1301
|
for line in lines:
|
|
1159
1302
|
stripped = line.strip()
|
|
@@ -1186,7 +1329,7 @@ def split_markdown_into_segments(markdown_text: str) -> List[Dict[str, str]]:
|
|
|
1186
1329
|
|
|
1187
1330
|
def convert_markdown_to_slack_blocks(
|
|
1188
1331
|
markdown_text: str, *, preserve_visual_blank_lines: bool = False
|
|
1189
|
-
) ->
|
|
1332
|
+
) -> list[dict[str, Any]]:
|
|
1190
1333
|
"""Convert markdown text into Slack markdown/table blocks."""
|
|
1191
1334
|
if not markdown_text:
|
|
1192
1335
|
return []
|
|
@@ -1196,7 +1339,7 @@ def convert_markdown_to_slack_blocks(
|
|
|
1196
1339
|
markdown_text = normalize_underscore_emphasis(markdown_text)
|
|
1197
1340
|
markdown_text = normalize_bare_urls_for_slack_markdown(markdown_text)
|
|
1198
1341
|
markdown_text = normalize_markdown_tables(markdown_text)
|
|
1199
|
-
blocks:
|
|
1342
|
+
blocks: list[dict[str, Any]] = []
|
|
1200
1343
|
|
|
1201
1344
|
for segment in split_markdown_into_segments(markdown_text):
|
|
1202
1345
|
content = segment.get("content", "")
|
|
@@ -1211,7 +1354,7 @@ def convert_markdown_to_slack_blocks(
|
|
|
1211
1354
|
|
|
1212
1355
|
formatted, synthetic_indices = _format_markdown_with_spacing_metadata(content)
|
|
1213
1356
|
plain_text = _build_markdown_block_plain_text(formatted, synthetic_indices)
|
|
1214
|
-
synthetic_blank_line_indices:
|
|
1357
|
+
synthetic_blank_line_indices: list[int] = []
|
|
1215
1358
|
if preserve_visual_blank_lines:
|
|
1216
1359
|
formatted, synthetic_blank_line_indices = (
|
|
1217
1360
|
_inject_visual_blank_line_placeholders(formatted)
|
|
@@ -1230,10 +1373,10 @@ def convert_markdown_to_slack_blocks(
|
|
|
1230
1373
|
convert_markdown_text_to_blocks = convert_markdown_to_slack_blocks
|
|
1231
1374
|
|
|
1232
1375
|
|
|
1233
|
-
def split_blocks_by_table(blocks:
|
|
1376
|
+
def split_blocks_by_table(blocks: list[dict[str, Any]]) -> list[list[dict[str, Any]]]:
|
|
1234
1377
|
"""Split blocks into multiple messages to satisfy one-table-per-message constraint."""
|
|
1235
|
-
messages:
|
|
1236
|
-
current_message:
|
|
1378
|
+
messages: list[list[dict[str, Any]]] = []
|
|
1379
|
+
current_message: list[dict[str, Any]] = []
|
|
1237
1380
|
|
|
1238
1381
|
for block in blocks or []:
|
|
1239
1382
|
if isinstance(block, dict) and block.get("type") == "table":
|
|
@@ -1254,7 +1397,7 @@ def convert_markdown_to_slack_messages(
|
|
|
1254
1397
|
markdown_text: str,
|
|
1255
1398
|
*,
|
|
1256
1399
|
preserve_visual_blank_lines: bool = False,
|
|
1257
|
-
) ->
|
|
1400
|
+
) -> list[list[dict[str, Any]]]:
|
|
1258
1401
|
"""Convert markdown text into a list of Slack message block groups."""
|
|
1259
1402
|
blocks = convert_markdown_to_slack_blocks(
|
|
1260
1403
|
markdown_text, preserve_visual_blank_lines=preserve_visual_blank_lines
|
|
@@ -1268,9 +1411,9 @@ def convert_markdown_to_slack_payloads(
|
|
|
1268
1411
|
markdown_text: str,
|
|
1269
1412
|
*,
|
|
1270
1413
|
preserve_visual_blank_lines: bool = False,
|
|
1271
|
-
) ->
|
|
1414
|
+
) -> list[dict[str, Any]]:
|
|
1272
1415
|
"""Convert markdown text into Slack-ready payload dicts with fallback text."""
|
|
1273
|
-
payloads:
|
|
1416
|
+
payloads: list[dict[str, Any]] = []
|
|
1274
1417
|
for blocks in convert_markdown_to_slack_messages(
|
|
1275
1418
|
markdown_text, preserve_visual_blank_lines=preserve_visual_blank_lines
|
|
1276
1419
|
):
|
|
@@ -1279,9 +1422,9 @@ def convert_markdown_to_slack_payloads(
|
|
|
1279
1422
|
return payloads
|
|
1280
1423
|
|
|
1281
1424
|
|
|
1282
|
-
def blocks_to_plain_text(blocks:
|
|
1425
|
+
def blocks_to_plain_text(blocks: list[dict[str, Any]]) -> str:
|
|
1283
1426
|
"""Build plain text representation from Slack blocks."""
|
|
1284
|
-
parts:
|
|
1427
|
+
parts: list[str] = []
|
|
1285
1428
|
|
|
1286
1429
|
for block in blocks or []:
|
|
1287
1430
|
block_type = block.get("type") if isinstance(block, dict) else None
|
|
@@ -1305,7 +1448,7 @@ def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
|
|
|
1305
1448
|
elif block_type == "table":
|
|
1306
1449
|
rows = block.get("rows") or []
|
|
1307
1450
|
for row in rows:
|
|
1308
|
-
cell_texts:
|
|
1451
|
+
cell_texts: list[str] = []
|
|
1309
1452
|
if not isinstance(row, list):
|
|
1310
1453
|
continue
|
|
1311
1454
|
for cell in row:
|
|
@@ -1322,9 +1465,9 @@ def blocks_to_plain_text(blocks: List[Dict[str, Any]]) -> str:
|
|
|
1322
1465
|
return "\n".join([p for p in parts if p]).strip()
|
|
1323
1466
|
|
|
1324
1467
|
|
|
1325
|
-
def build_fallback_text_from_blocks(blocks:
|
|
1468
|
+
def build_fallback_text_from_blocks(blocks: list[dict[str, Any]]) -> str:
|
|
1326
1469
|
"""Build Slack fallback text from block structure."""
|
|
1327
|
-
plain_parts:
|
|
1470
|
+
plain_parts: list[str] = []
|
|
1328
1471
|
|
|
1329
1472
|
for block in blocks or []:
|
|
1330
1473
|
if not isinstance(block, dict):
|
|
@@ -1345,7 +1488,7 @@ def build_fallback_text_from_blocks(blocks: List[Dict[str, Any]]) -> str:
|
|
|
1345
1488
|
if text.strip():
|
|
1346
1489
|
plain_parts.append(text)
|
|
1347
1490
|
elif block.get("type") == "table":
|
|
1348
|
-
table_lines:
|
|
1491
|
+
table_lines: list[str] = []
|
|
1349
1492
|
for row in block.get("rows", []):
|
|
1350
1493
|
if not isinstance(row, list):
|
|
1351
1494
|
continue
|
|
@@ -1359,9 +1502,9 @@ def build_fallback_text_from_blocks(blocks: List[Dict[str, Any]]) -> str:
|
|
|
1359
1502
|
|
|
1360
1503
|
|
|
1361
1504
|
# Backward-compatible helper retained for existing imports.
|
|
1362
|
-
def parse_markdown_table(table_text: str) ->
|
|
1505
|
+
def parse_markdown_table(table_text: str) -> list[list[str]]:
|
|
1363
1506
|
"""Parse markdown table into row/cell text matrix."""
|
|
1364
|
-
rows:
|
|
1507
|
+
rows: list[list[str]] = []
|
|
1365
1508
|
for line in [line for line in table_text.strip().splitlines() if line.strip()]:
|
|
1366
1509
|
if TABLE_SEPARATOR_PATTERN.match(line.strip()):
|
|
1367
1510
|
continue
|
{slack_markdown_parser-2.3.1 → slack_markdown_parser-2.3.2/slack_markdown_parser.egg-info}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: slack-markdown-parser
|
|
3
|
-
Version: 2.3.
|
|
3
|
+
Version: 2.3.2
|
|
4
4
|
Summary: Convert LLM Markdown into Slack Block Kit markdown/table messages
|
|
5
5
|
Author: darkgaldragon
|
|
6
6
|
License-Expression: MIT
|
|
@@ -26,7 +26,6 @@ Requires-Dist: pip-audit>=2.7.0; extra == "dev"
|
|
|
26
26
|
Requires-Dist: pytest>=8.0.0; extra == "dev"
|
|
27
27
|
Requires-Dist: pytest-cov>=5.0.0; extra == "dev"
|
|
28
28
|
Requires-Dist: ruff>=0.6.0; extra == "dev"
|
|
29
|
-
Requires-Dist: twine>=5.1.0; extra == "dev"
|
|
30
29
|
Dynamic: license-file
|
|
31
30
|
|
|
32
31
|
# slack-markdown-parser
|
|
@@ -210,8 +209,9 @@ Markdown segments with lines that contain only a non-breaking space. Those
|
|
|
210
209
|
placeholder lines are removed again when generating preview plain text, so Slack
|
|
211
210
|
notifications and logs stay close to the original Markdown source.
|
|
212
211
|
The current implementation deliberately skips blank runs that sit immediately
|
|
213
|
-
before setext-heading underlines or
|
|
214
|
-
boundaries can change Markdown meaning in newer
|
|
212
|
+
after list-item content, before setext-heading underlines, or before
|
|
213
|
+
reference-link definitions, because those boundaries can change Markdown meaning in newer
|
|
214
|
+
Slack Markdown rendering or keep list formatting open in some clients.
|
|
215
215
|
|
|
216
216
|
### Utility functions
|
|
217
217
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|