conjira-cli 0.2.4__tar.gz → 0.2.5__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.
- {conjira_cli-0.2.4/src/conjira_cli.egg-info → conjira_cli-0.2.5}/PKG-INFO +1 -1
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/pyproject.toml +1 -1
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/markdown_import.py +42 -2
- {conjira_cli-0.2.4 → conjira_cli-0.2.5/src/conjira_cli.egg-info}/PKG-INFO +1 -1
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_markdown_import.py +103 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/LICENSE +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/README.md +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/setup.cfg +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/setup.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/__init__.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/__main__.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/cli.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/client.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/config.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/inline_comments.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/markdown_export.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/section_edit.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/setup_macos.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli/tree_export.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/entry_points.txt +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/src/conjira_cli.egg-info/top_level.txt +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_cli.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_client.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_config.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_inline_comments.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_markdown_export.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_section_edit.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_setup_macos.py +0 -0
- {conjira_cli-0.2.4 → conjira_cli-0.2.5}/tests/test_tree_export.py +0 -0
|
@@ -3,6 +3,7 @@ from __future__ import annotations
|
|
|
3
3
|
import html
|
|
4
4
|
import os
|
|
5
5
|
import re
|
|
6
|
+
import xml.etree.ElementTree as ET
|
|
6
7
|
from typing import Optional
|
|
7
8
|
|
|
8
9
|
|
|
@@ -371,19 +372,58 @@ def _parse_table(lines: list[str], start: int) -> tuple[str, int]:
|
|
|
371
372
|
if has_header:
|
|
372
373
|
parts.append(
|
|
373
374
|
"<tr>{0}</tr>".format(
|
|
374
|
-
"".join("<th>{0}</th>".format(
|
|
375
|
+
"".join("<th>{0}</th>".format(_render_table_cell(cell)) for cell in rows[0])
|
|
375
376
|
)
|
|
376
377
|
)
|
|
377
378
|
for row in body_rows:
|
|
378
379
|
parts.append(
|
|
379
380
|
"<tr>{0}</tr>".format(
|
|
380
|
-
"".join("<td>{0}</td>".format(
|
|
381
|
+
"".join("<td>{0}</td>".format(_render_table_cell(cell)) for cell in row)
|
|
381
382
|
)
|
|
382
383
|
)
|
|
383
384
|
parts.append("</tbody></table>")
|
|
384
385
|
return "".join(parts), i
|
|
385
386
|
|
|
386
387
|
|
|
388
|
+
_CELL_HTML_BLOCK_PREFIX_RE = re.compile(
|
|
389
|
+
r"^<(ul|ol|p|div|table|blockquote|h[1-6]|ac:[\w-]+|ri:[\w-]+|atlassian:[\w-]+)\b",
|
|
390
|
+
re.IGNORECASE,
|
|
391
|
+
)
|
|
392
|
+
_CELL_XML_WRAPPER_PREFIX = (
|
|
393
|
+
'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri" xmlns:atlassian="urn:atlassian">'
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _render_table_cell(cell: str) -> str:
|
|
398
|
+
"""Render a markdown table cell.
|
|
399
|
+
|
|
400
|
+
Standard markdown tables only support inline content per cell, but
|
|
401
|
+
Confluence cells frequently need nested structures (``<ul><li>``,
|
|
402
|
+
``<p>``, etc.). When the cell content is a well-formed HTML block,
|
|
403
|
+
we pass it through unchanged so users can hand-author rich cells
|
|
404
|
+
without leaving the markdown pipeline.
|
|
405
|
+
|
|
406
|
+
Pass-through covers the entire cell: any trailing content after the
|
|
407
|
+
HTML block is emitted verbatim and not re-rendered as markdown.
|
|
408
|
+
"""
|
|
409
|
+
stripped = cell.strip()
|
|
410
|
+
if (
|
|
411
|
+
stripped.startswith("<")
|
|
412
|
+
and _CELL_HTML_BLOCK_PREFIX_RE.match(stripped)
|
|
413
|
+
and _is_well_formed_xhtml(stripped)
|
|
414
|
+
):
|
|
415
|
+
return stripped
|
|
416
|
+
return _render_inline(cell)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _is_well_formed_xhtml(fragment: str) -> bool:
|
|
420
|
+
try:
|
|
421
|
+
ET.fromstring(_CELL_XML_WRAPPER_PREFIX + fragment + "</root>")
|
|
422
|
+
except ET.ParseError:
|
|
423
|
+
return False
|
|
424
|
+
return True
|
|
425
|
+
|
|
426
|
+
|
|
387
427
|
def _split_table_row(line: str) -> list[str]:
|
|
388
428
|
stripped = line.strip()
|
|
389
429
|
if stripped.startswith("|"):
|
|
@@ -195,3 +195,106 @@ class MarkdownImportTests(unittest.TestCase):
|
|
|
195
195
|
wrapped = f'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">{result}</root>'
|
|
196
196
|
# Should not raise
|
|
197
197
|
ET.fromstring(wrapped)
|
|
198
|
+
|
|
199
|
+
def test_table_cell_passes_through_raw_html_list(self) -> None:
|
|
200
|
+
"""Cells containing well-formed HTML blocks (e.g. nested <ul>) survive."""
|
|
201
|
+
result = markdown_to_storage_html(
|
|
202
|
+
"\n".join(
|
|
203
|
+
[
|
|
204
|
+
"| 구분 | 내용 |",
|
|
205
|
+
"| --- | --- |",
|
|
206
|
+
"| 포인트 지급 | <ul><li>휴대폰 포인트 지급<ul><li>MDN 기준</li></ul></li></ul> |",
|
|
207
|
+
]
|
|
208
|
+
)
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
self.assertIn(
|
|
212
|
+
"<td><ul><li>휴대폰 포인트 지급<ul><li>MDN 기준</li></ul></li></ul></td>",
|
|
213
|
+
result,
|
|
214
|
+
)
|
|
215
|
+
self.assertNotIn("<ul>", result)
|
|
216
|
+
|
|
217
|
+
def test_table_cell_passes_through_storage_macro(self) -> None:
|
|
218
|
+
"""Cells containing ac:* / ri:* storage-format macros pass through."""
|
|
219
|
+
cell_html = (
|
|
220
|
+
'<ac:structured-macro ac:name="status" ac:schema-version="1">'
|
|
221
|
+
'<ac:parameter ac:name="colour">Green</ac:parameter>'
|
|
222
|
+
'<ac:parameter ac:name="title">Done</ac:parameter>'
|
|
223
|
+
"</ac:structured-macro>"
|
|
224
|
+
)
|
|
225
|
+
result = markdown_to_storage_html(
|
|
226
|
+
"\n".join(
|
|
227
|
+
[
|
|
228
|
+
"| Item | Status |",
|
|
229
|
+
"| --- | --- |",
|
|
230
|
+
"| API | {0} |".format(cell_html),
|
|
231
|
+
]
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
self.assertIn(cell_html, result)
|
|
236
|
+
self.assertNotIn("<ac:", result)
|
|
237
|
+
|
|
238
|
+
def test_table_cell_with_inline_text_still_escapes(self) -> None:
|
|
239
|
+
"""Cells without HTML block content still get inline escaping."""
|
|
240
|
+
result = markdown_to_storage_html(
|
|
241
|
+
"\n".join(
|
|
242
|
+
[
|
|
243
|
+
"| Title | Note |",
|
|
244
|
+
"| --- | --- |",
|
|
245
|
+
"| 5 < 10 | not html |",
|
|
246
|
+
]
|
|
247
|
+
)
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
self.assertIn("<td>5 < 10</td>", result)
|
|
251
|
+
self.assertIn("<td>not html</td>", result)
|
|
252
|
+
|
|
253
|
+
def test_table_cell_with_malformed_html_falls_back_to_inline(self) -> None:
|
|
254
|
+
"""If a cell looks like HTML but isn't well-formed, escape it instead of breaking the page."""
|
|
255
|
+
result = markdown_to_storage_html(
|
|
256
|
+
"\n".join(
|
|
257
|
+
[
|
|
258
|
+
"| Item | Note |",
|
|
259
|
+
"| --- | --- |",
|
|
260
|
+
"| <ul><li>unclosed | broken |",
|
|
261
|
+
]
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# Malformed HTML must not pass through; it should be escaped.
|
|
266
|
+
self.assertNotIn("<td><ul><li>unclosed", result)
|
|
267
|
+
self.assertIn("<ul>", result)
|
|
268
|
+
|
|
269
|
+
def test_table_with_html_cells_is_valid_xhtml(self) -> None:
|
|
270
|
+
"""Tables with HTML pass-through cells must produce valid XHTML."""
|
|
271
|
+
import xml.etree.ElementTree as ET
|
|
272
|
+
|
|
273
|
+
result = markdown_to_storage_html(
|
|
274
|
+
"\n".join(
|
|
275
|
+
[
|
|
276
|
+
"| 구분 | 내용 |",
|
|
277
|
+
"| --- | --- |",
|
|
278
|
+
"| A | <ul><li>x<ul><li>y</li></ul></li></ul> |",
|
|
279
|
+
"| B | plain text |",
|
|
280
|
+
]
|
|
281
|
+
)
|
|
282
|
+
)
|
|
283
|
+
wrapped = f'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">{result}</root>'
|
|
284
|
+
ET.fromstring(wrapped)
|
|
285
|
+
|
|
286
|
+
def test_table_cell_html_block_emits_trailing_content_verbatim(self) -> None:
|
|
287
|
+
"""Trailing content after an HTML block in a cell is emitted verbatim, not re-rendered."""
|
|
288
|
+
result = markdown_to_storage_html(
|
|
289
|
+
"\n".join(
|
|
290
|
+
[
|
|
291
|
+
"| A | B |",
|
|
292
|
+
"| --- | --- |",
|
|
293
|
+
"| 1 | <ul><li>x</li></ul> trailing [link](http://example.com) |",
|
|
294
|
+
]
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
# Whole cell goes through as raw HTML; trailing markdown is NOT rendered.
|
|
299
|
+
self.assertIn("<ul><li>x</li></ul> trailing [link](http://example.com)", result)
|
|
300
|
+
self.assertNotIn('<a href="http://example.com">', result)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|