conjira-cli 0.2.6__tar.gz → 0.2.7__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.6/src/conjira_cli.egg-info → conjira_cli-0.2.7}/PKG-INFO +3 -3
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/README.md +2 -2
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/pyproject.toml +1 -1
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/__init__.py +1 -1
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/markdown_export.py +28 -4
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/markdown_import.py +44 -6
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/section_edit.py +36 -9
- {conjira_cli-0.2.6 → conjira_cli-0.2.7/src/conjira_cli.egg-info}/PKG-INFO +3 -3
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_markdown_export.py +140 -2
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_markdown_import.py +28 -0
- conjira_cli-0.2.7/tests/test_section_edit.py +180 -0
- conjira_cli-0.2.6/tests/test_section_edit.py +0 -93
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/LICENSE +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/setup.cfg +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/setup.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/__main__.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/cli.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/client.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/config.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/footer_comments.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/inline_comments.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/setup_macos.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli/tree_export.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/entry_points.txt +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/src/conjira_cli.egg-info/top_level.txt +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_cli.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_client.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_config.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_inline_comments.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_setup_macos.py +0 -0
- {conjira_cli-0.2.6 → conjira_cli-0.2.7}/tests/test_tree_export.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conjira-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
|
|
5
5
|
Author: quanttraderkim
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,7 +47,7 @@ If your team uses self-hosted Confluence and Jira, official cloud-native connect
|
|
|
47
47
|
|
|
48
48
|
`conjira-cli` is built for that gap. It helps when you want to read Confluence pages, export them to Markdown, refresh stale exports from the live wiki, summarize inline comment threads, search Jira with JQL, or create and update content without hardcoding PATs into source files or chat transcripts.
|
|
49
49
|
|
|
50
|
-
For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
|
|
50
|
+
For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers fenced code blocks (Confluence `code` macro), LaTeX formulas with ```` ```math ```` fences (Confluence `mathblock` macro), Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
|
|
51
51
|
|
|
52
52
|
## What you can do with it
|
|
53
53
|
|
|
@@ -484,7 +484,7 @@ The recommended pattern is to set `CONFLUENCE_EXPORT_DEFAULT_DIR` to an inbox or
|
|
|
484
484
|
|
|
485
485
|
## Markdown import notes
|
|
486
486
|
|
|
487
|
-
Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
|
|
487
|
+
Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`code`, ```` ```math ```` for `mathblock`, `mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). Export keeps code, math, and other plain-text macro bodies as fenced blocks, so export → edit → upload does not drop formulas. It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
|
|
488
488
|
|
|
489
489
|
Use `--body-file` and `--append-file` only for storage HTML files. If your source file is Markdown, use `--body-markdown-file` or `--append-markdown-file` so the CLI converts it before upload.
|
|
490
490
|
|
|
@@ -18,7 +18,7 @@ If your team uses self-hosted Confluence and Jira, official cloud-native connect
|
|
|
18
18
|
|
|
19
19
|
`conjira-cli` is built for that gap. It helps when you want to read Confluence pages, export them to Markdown, refresh stale exports from the live wiki, summarize inline comment threads, search Jira with JQL, or create and update content without hardcoding PATs into source files or chat transcripts.
|
|
20
20
|
|
|
21
|
-
For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
|
|
21
|
+
For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers fenced code blocks (Confluence `code` macro), LaTeX formulas with ```` ```math ```` fences (Confluence `mathblock` macro), Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
|
|
22
22
|
|
|
23
23
|
## What you can do with it
|
|
24
24
|
|
|
@@ -455,7 +455,7 @@ The recommended pattern is to set `CONFLUENCE_EXPORT_DEFAULT_DIR` to an inbox or
|
|
|
455
455
|
|
|
456
456
|
## Markdown import notes
|
|
457
457
|
|
|
458
|
-
Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
|
|
458
|
+
Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`code`, ```` ```math ```` for `mathblock`, `mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). Export keeps code, math, and other plain-text macro bodies as fenced blocks, so export → edit → upload does not drop formulas. It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
|
|
459
459
|
|
|
460
460
|
Use `--body-file` and `--append-file` only for storage HTML files. If your source file is Markdown, use `--body-markdown-file` or `--append-markdown-file` so the CLI converts it before upload.
|
|
461
461
|
|
|
@@ -212,11 +212,17 @@ class MarkdownExporter:
|
|
|
212
212
|
if macro_name == "toc":
|
|
213
213
|
return ""
|
|
214
214
|
if self.mermaid_macro_name and macro_name == self.mermaid_macro_name:
|
|
215
|
-
return self.
|
|
215
|
+
return self._render_fenced_macro(elem, "mermaid")
|
|
216
216
|
if macro_name == "status":
|
|
217
217
|
return self._render_status_token(elem) + "\n\n"
|
|
218
218
|
if macro_name in _CALLOUT_MACRO_NAMES:
|
|
219
219
|
return self._render_callout_macro(elem, macro_name)
|
|
220
|
+
if macro_name == "code":
|
|
221
|
+
return self._render_fenced_macro(elem, self._extract_macro_parameter(elem, "language"))
|
|
222
|
+
if macro_name == "mathblock":
|
|
223
|
+
return self._render_fenced_macro(elem, "math")
|
|
224
|
+
if self._has_only_plain_text_body(elem):
|
|
225
|
+
return self._render_fenced_macro(elem, "")
|
|
220
226
|
return self._render_children(elem, indent=indent)
|
|
221
227
|
if name in {"h1", "h2", "h3", "h4", "h5", "h6"}:
|
|
222
228
|
level = int(name[1])
|
|
@@ -295,6 +301,8 @@ class MarkdownExporter:
|
|
|
295
301
|
macro_name = elem.attrib.get("{urn:ac}name")
|
|
296
302
|
if macro_name == "status":
|
|
297
303
|
return self._render_status_token(elem)
|
|
304
|
+
if self._is_fenced_macro(elem):
|
|
305
|
+
return "\n" + self._render_block(elem, indent=0).strip() + "\n"
|
|
298
306
|
return ""
|
|
299
307
|
if name in {"ul", "ol", "table"}:
|
|
300
308
|
return "\n" + self._render_block(elem, indent=1).strip() + "\n"
|
|
@@ -317,7 +325,7 @@ class MarkdownExporter:
|
|
|
317
325
|
|
|
318
326
|
for grandchild in list(child):
|
|
319
327
|
grandchild_name = _local_name(grandchild.tag)
|
|
320
|
-
if grandchild_name in {"ul", "ol", "table"}:
|
|
328
|
+
if grandchild_name in {"ul", "ol", "table"} or self._is_fenced_macro(grandchild):
|
|
321
329
|
nested_text = self._render_block(grandchild, indent=indent + 1).rstrip()
|
|
322
330
|
if not nested_text:
|
|
323
331
|
continue
|
|
@@ -512,9 +520,25 @@ class MarkdownExporter:
|
|
|
512
520
|
)
|
|
513
521
|
return f"\n\n"
|
|
514
522
|
|
|
515
|
-
def
|
|
523
|
+
def _is_fenced_macro(self, elem: ET.Element) -> bool:
|
|
524
|
+
if _local_name(elem.tag) != "structured-macro":
|
|
525
|
+
return False
|
|
526
|
+
macro_name = elem.attrib.get("{urn:ac}name")
|
|
527
|
+
if macro_name in {"code", "mathblock"}:
|
|
528
|
+
return True
|
|
529
|
+
if self.mermaid_macro_name and macro_name == self.mermaid_macro_name:
|
|
530
|
+
return True
|
|
531
|
+
return self._has_only_plain_text_body(elem)
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
def _has_only_plain_text_body(elem: ET.Element) -> bool:
|
|
535
|
+
child_names = {_local_name(child.tag) for child in list(elem)}
|
|
536
|
+
return "plain-text-body" in child_names and "rich-text-body" not in child_names
|
|
537
|
+
|
|
538
|
+
def _render_fenced_macro(self, elem: ET.Element, language: str) -> str:
|
|
516
539
|
text = self._extract_macro_plain_text_body(elem).strip("\n")
|
|
517
|
-
|
|
540
|
+
fence_language = "" if language.strip().lower() in {"", "none"} else language.strip()
|
|
541
|
+
return f"```{fence_language}\n{text}\n```\n\n"
|
|
518
542
|
|
|
519
543
|
def _render_callout_macro(self, elem: ET.Element, macro_name: str) -> str:
|
|
520
544
|
title = self._extract_macro_parameter(elem, "title")
|
|
@@ -126,7 +126,7 @@ def _parse_blocks(
|
|
|
126
126
|
continue
|
|
127
127
|
|
|
128
128
|
if _LIST_ITEM_RE.match(line):
|
|
129
|
-
block, i = _parse_list(lines, i, current_indent)
|
|
129
|
+
block, i = _parse_list(lines, i, current_indent, mermaid_macro_name=mermaid_macro_name)
|
|
130
130
|
parts.append(block)
|
|
131
131
|
continue
|
|
132
132
|
|
|
@@ -175,7 +175,9 @@ def _render_fenced_block(
|
|
|
175
175
|
mermaid_macro_name: Optional[str],
|
|
176
176
|
) -> str:
|
|
177
177
|
if mermaid_macro_name and language.lower() == "mermaid":
|
|
178
|
-
return
|
|
178
|
+
return _render_plain_text_macro(code, mermaid_macro_name)
|
|
179
|
+
if language.lower() == "math":
|
|
180
|
+
return _render_plain_text_macro(code, "mathblock")
|
|
179
181
|
return _render_code_macro(code, language)
|
|
180
182
|
|
|
181
183
|
|
|
@@ -191,7 +193,7 @@ def _render_code_macro(code: str, language: str) -> str:
|
|
|
191
193
|
).format(safe_language, cdata_code)
|
|
192
194
|
|
|
193
195
|
|
|
194
|
-
def
|
|
196
|
+
def _render_plain_text_macro(code: str, macro_name: str) -> str:
|
|
195
197
|
safe_macro_name = html.escape(macro_name, quote=True)
|
|
196
198
|
cdata_code = code.replace("]]>", "]]]]><![CDATA[>")
|
|
197
199
|
return (
|
|
@@ -282,7 +284,13 @@ def _render_callout_macro(*, macro_name: str, title: str, body_html: str) -> str
|
|
|
282
284
|
)
|
|
283
285
|
|
|
284
286
|
|
|
285
|
-
def _parse_list(
|
|
287
|
+
def _parse_list(
|
|
288
|
+
lines: list[str],
|
|
289
|
+
start: int,
|
|
290
|
+
indent: int,
|
|
291
|
+
*,
|
|
292
|
+
mermaid_macro_name: Optional[str] = None,
|
|
293
|
+
) -> tuple[str, int]:
|
|
286
294
|
first_match = _LIST_ITEM_RE.match(lines[start])
|
|
287
295
|
if first_match is None:
|
|
288
296
|
raise ValueError("List parsing requires a list item")
|
|
@@ -302,7 +310,7 @@ def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
|
|
|
302
310
|
if current_indent < indent or current_ordered != ordered:
|
|
303
311
|
break
|
|
304
312
|
if current_indent > indent:
|
|
305
|
-
nested, i = _parse_list(lines, i, current_indent)
|
|
313
|
+
nested, i = _parse_list(lines, i, current_indent, mermaid_macro_name=mermaid_macro_name)
|
|
306
314
|
if items:
|
|
307
315
|
items[-1] = items[-1].replace("</li>", nested + "</li>", 1)
|
|
308
316
|
continue
|
|
@@ -326,13 +334,22 @@ def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
|
|
|
326
334
|
if nested_indent == indent and nested_ordered == ordered:
|
|
327
335
|
break
|
|
328
336
|
if nested_indent > indent:
|
|
329
|
-
nested_html, i = _parse_list(lines, i, nested_indent)
|
|
337
|
+
nested_html, i = _parse_list(lines, i, nested_indent, mermaid_macro_name=mermaid_macro_name)
|
|
330
338
|
content_parts.append(nested_html)
|
|
331
339
|
continue
|
|
332
340
|
if nested_indent < indent:
|
|
333
341
|
break
|
|
334
342
|
|
|
335
343
|
continuation_indent = _leading_spaces(next_line)
|
|
344
|
+
if continuation_indent > indent and next_line.strip().startswith("```"):
|
|
345
|
+
block_html, i = _parse_nested_fenced_code(
|
|
346
|
+
lines,
|
|
347
|
+
i,
|
|
348
|
+
fence_indent=continuation_indent,
|
|
349
|
+
mermaid_macro_name=mermaid_macro_name,
|
|
350
|
+
)
|
|
351
|
+
content_parts.append(block_html)
|
|
352
|
+
continue
|
|
336
353
|
if continuation_indent > indent:
|
|
337
354
|
content_parts.append(" " + _render_inline(next_line.strip()))
|
|
338
355
|
i += 1
|
|
@@ -344,6 +361,27 @@ def _parse_list(lines: list[str], start: int, indent: int) -> tuple[str, int]:
|
|
|
344
361
|
return "<{0}>{1}</{0}>".format(tag, "".join(items)), i
|
|
345
362
|
|
|
346
363
|
|
|
364
|
+
def _parse_nested_fenced_code(
|
|
365
|
+
lines: list[str],
|
|
366
|
+
start: int,
|
|
367
|
+
*,
|
|
368
|
+
fence_indent: int,
|
|
369
|
+
mermaid_macro_name: Optional[str],
|
|
370
|
+
) -> tuple[str, int]:
|
|
371
|
+
"""Parse a fenced block indented under a list item, dedenting it by the fence indent."""
|
|
372
|
+
fence = lines[start].strip()[:3]
|
|
373
|
+
dedented: list[str] = []
|
|
374
|
+
i = start
|
|
375
|
+
while i < len(lines):
|
|
376
|
+
line = lines[i]
|
|
377
|
+
dedented.append(line[fence_indent:] if line[:fence_indent].strip() == "" else line.lstrip())
|
|
378
|
+
i += 1
|
|
379
|
+
if i - 1 > start and line.strip().startswith(fence):
|
|
380
|
+
break
|
|
381
|
+
block_html, _ = _parse_fenced_code(dedented, 0, mermaid_macro_name=mermaid_macro_name)
|
|
382
|
+
return block_html, i
|
|
383
|
+
|
|
384
|
+
|
|
347
385
|
def _parse_table(lines: list[str], start: int) -> tuple[str, int]:
|
|
348
386
|
rows: list[list[str]] = []
|
|
349
387
|
i = start
|
|
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|
|
2
2
|
|
|
3
3
|
import copy
|
|
4
4
|
import re
|
|
5
|
+
import uuid
|
|
5
6
|
import xml.etree.ElementTree as ET
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
|
|
@@ -11,6 +12,7 @@ ET.register_namespace("ri", "urn:ri")
|
|
|
11
12
|
|
|
12
13
|
|
|
13
14
|
_WRAPPED_ROOT_PREFIX = '<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">'
|
|
15
|
+
_CDATA_RE = re.compile(r"<!\[CDATA\[.*?\]\]>", re.DOTALL)
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
class SectionEditError(ValueError):
|
|
@@ -36,13 +38,37 @@ class HeadingInsertionResult:
|
|
|
36
38
|
updated_body_html: str
|
|
37
39
|
|
|
38
40
|
|
|
41
|
+
class _CdataVault:
|
|
42
|
+
"""Hide CDATA sections from the XML parser and restore them after serialization.
|
|
43
|
+
|
|
44
|
+
ElementTree folds ``<![CDATA[...]]>`` into ordinary text and writes it back
|
|
45
|
+
escaped, which Confluence no longer treats as a plain-text macro body.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._token = "conjira-cdata-" + uuid.uuid4().hex
|
|
50
|
+
self._sections: list[str] = []
|
|
51
|
+
self._placeholder_re = re.compile(re.escape(self._token) + r"-(\d+)-")
|
|
52
|
+
|
|
53
|
+
def protect(self, html: str) -> str:
|
|
54
|
+
def _stash(match: re.Match[str]) -> str:
|
|
55
|
+
self._sections.append(match.group(0))
|
|
56
|
+
return "{0}-{1}-".format(self._token, len(self._sections) - 1)
|
|
57
|
+
|
|
58
|
+
return _CDATA_RE.sub(_stash, html)
|
|
59
|
+
|
|
60
|
+
def restore(self, html: str) -> str:
|
|
61
|
+
return self._placeholder_re.sub(lambda match: self._sections[int(match.group(1))], html)
|
|
62
|
+
|
|
63
|
+
|
|
39
64
|
def replace_section_html(
|
|
40
65
|
body_html: str,
|
|
41
66
|
*,
|
|
42
67
|
heading: str,
|
|
43
68
|
replacement_html: str,
|
|
44
69
|
) -> SectionReplacementResult:
|
|
45
|
-
|
|
70
|
+
vault = _CdataVault()
|
|
71
|
+
root = _parse_fragment(vault.protect(body_html))
|
|
46
72
|
children = list(root)
|
|
47
73
|
match_index, match_elem, match_level = _find_unique_heading(
|
|
48
74
|
children,
|
|
@@ -59,7 +85,7 @@ def replace_section_html(
|
|
|
59
85
|
old_section_children = children[match_index + 1 : end_index]
|
|
60
86
|
old_section_html = _serialize_elements(old_section_children)
|
|
61
87
|
|
|
62
|
-
new_root = _parse_fragment(replacement_html)
|
|
88
|
+
new_root = _parse_fragment(vault.protect(replacement_html))
|
|
63
89
|
new_children = [copy.deepcopy(child) for child in list(new_root)]
|
|
64
90
|
|
|
65
91
|
for child in old_section_children:
|
|
@@ -72,9 +98,9 @@ def replace_section_html(
|
|
|
72
98
|
heading=heading,
|
|
73
99
|
matched_heading=_element_text(match_elem),
|
|
74
100
|
heading_level=match_level,
|
|
75
|
-
old_section_html=old_section_html,
|
|
76
|
-
new_section_html=_serialize_elements(new_children),
|
|
77
|
-
updated_body_html=_serialize_root(root),
|
|
101
|
+
old_section_html=vault.restore(old_section_html),
|
|
102
|
+
new_section_html=vault.restore(_serialize_elements(new_children)),
|
|
103
|
+
updated_body_html=vault.restore(_serialize_root(root)),
|
|
78
104
|
)
|
|
79
105
|
|
|
80
106
|
|
|
@@ -84,7 +110,8 @@ def insert_after_heading_html(
|
|
|
84
110
|
heading: str,
|
|
85
111
|
inserted_html: str,
|
|
86
112
|
) -> HeadingInsertionResult:
|
|
87
|
-
|
|
113
|
+
vault = _CdataVault()
|
|
114
|
+
root = _parse_fragment(vault.protect(body_html))
|
|
88
115
|
children = list(root)
|
|
89
116
|
match_index, match_elem, match_level = _find_unique_heading(
|
|
90
117
|
children,
|
|
@@ -92,7 +119,7 @@ def insert_after_heading_html(
|
|
|
92
119
|
action_name="insert-after-heading",
|
|
93
120
|
)
|
|
94
121
|
|
|
95
|
-
new_root = _parse_fragment(inserted_html)
|
|
122
|
+
new_root = _parse_fragment(vault.protect(inserted_html))
|
|
96
123
|
new_children = [copy.deepcopy(child) for child in list(new_root)]
|
|
97
124
|
|
|
98
125
|
insert_at = match_index + 1
|
|
@@ -103,8 +130,8 @@ def insert_after_heading_html(
|
|
|
103
130
|
heading=heading,
|
|
104
131
|
matched_heading=_element_text(match_elem),
|
|
105
132
|
heading_level=match_level,
|
|
106
|
-
inserted_html=_serialize_elements(new_children),
|
|
107
|
-
updated_body_html=_serialize_root(root),
|
|
133
|
+
inserted_html=vault.restore(_serialize_elements(new_children)),
|
|
134
|
+
updated_body_html=vault.restore(_serialize_root(root)),
|
|
108
135
|
)
|
|
109
136
|
|
|
110
137
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conjira-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.7
|
|
4
4
|
Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
|
|
5
5
|
Author: quanttraderkim
|
|
6
6
|
License-Expression: MIT
|
|
@@ -47,7 +47,7 @@ If your team uses self-hosted Confluence and Jira, official cloud-native connect
|
|
|
47
47
|
|
|
48
48
|
`conjira-cli` is built for that gap. It helps when you want to read Confluence pages, export them to Markdown, refresh stale exports from the live wiki, summarize inline comment threads, search Jira with JQL, or create and update content without hardcoding PATs into source files or chat transcripts.
|
|
49
49
|
|
|
50
|
-
For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
|
|
50
|
+
For report-style pages, it can also preserve a small set of Confluence-native presentation macros while still keeping Markdown as the source of truth. The current bridge covers fenced code blocks (Confluence `code` macro), LaTeX formulas with ```` ```math ```` fences (Confluence `mathblock` macro), Mermaid, Markdown callouts such as `> [!INFO]`, `> [!NOTE]`, `> [!TIP]`, and `> [!WARNING]`, expandable blocks with `> [!EXPAND]`, and inline status badges with `:status[In Progress]{color=yellow}`.
|
|
51
51
|
|
|
52
52
|
## What you can do with it
|
|
53
53
|
|
|
@@ -484,7 +484,7 @@ The recommended pattern is to set `CONFLUENCE_EXPORT_DEFAULT_DIR` to an inbox or
|
|
|
484
484
|
|
|
485
485
|
## Markdown import notes
|
|
486
486
|
|
|
487
|
-
Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
|
|
487
|
+
Markdown upload is a best-effort conversion to Confluence storage HTML. It works well for common headings, paragraphs, lists, blockquotes, fenced code blocks, tables, links, images, simple wiki-style links, and the currently supported report macros (`code`, ```` ```math ```` for `mathblock`, `mermaid`, callouts, `expand`, and `:status[Title]{color=blue}`). Export keeps code, math, and other plain-text macro bodies as fenced blocks, so export → edit → upload does not drop formulas. It is not a perfect round-trip for complex Confluence macros, merged tables, or deeply nested layouts, so treat Markdown import as a practical authoring path rather than a lossless document converter.
|
|
488
488
|
|
|
489
489
|
Use `--body-file` and `--append-file` only for storage HTML files. If your source file is Markdown, use `--body-markdown-file` or `--append-markdown-file` so the CLI converts it before upload.
|
|
490
490
|
|
|
@@ -161,7 +161,7 @@ class MarkdownExportTests(unittest.TestCase):
|
|
|
161
161
|
|
|
162
162
|
self.assertIn("```mermaid\ngraph TD\nA-->B\n```", result)
|
|
163
163
|
|
|
164
|
-
def
|
|
164
|
+
def test_mermaid_macro_keeps_source_as_plain_fence_without_config(self) -> None:
|
|
165
165
|
exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
|
|
166
166
|
html = (
|
|
167
167
|
'<ac:structured-macro ac:name="mermaid-macro" ac:schema-version="1">'
|
|
@@ -171,7 +171,7 @@ class MarkdownExportTests(unittest.TestCase):
|
|
|
171
171
|
|
|
172
172
|
result = exporter.convert_fragment(html)
|
|
173
173
|
|
|
174
|
-
self.assertEqual(result.strip(), "")
|
|
174
|
+
self.assertEqual(result.strip(), "```\ngraph TD\nA-->B\n```")
|
|
175
175
|
|
|
176
176
|
def test_callout_macro_renders_as_markdown_callout(self) -> None:
|
|
177
177
|
exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
|
|
@@ -295,3 +295,141 @@ class MarkdownExportTests(unittest.TestCase):
|
|
|
295
295
|
for line in result.splitlines():
|
|
296
296
|
if "Review" in line and line.strip().startswith("|"):
|
|
297
297
|
self.assertIn("\\|", line, "Pipe in list item should be escaped")
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
class PlainTextMacroExportTests(unittest.TestCase):
|
|
301
|
+
def setUp(self) -> None:
|
|
302
|
+
self.exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="12345")
|
|
303
|
+
|
|
304
|
+
def test_code_macro_renders_as_fenced_block_with_language(self) -> None:
|
|
305
|
+
html = (
|
|
306
|
+
"<h2>Formula</h2>"
|
|
307
|
+
'<ac:structured-macro ac:name="code" ac:schema-version="1">'
|
|
308
|
+
'<ac:parameter ac:name="language">text</ac:parameter>'
|
|
309
|
+
'<ac:parameter ac:name="theme">Default</ac:parameter>'
|
|
310
|
+
"<ac:plain-text-body><![CDATA[Credit = Cost × E\n + InfraCredit]]></ac:plain-text-body>"
|
|
311
|
+
"</ac:structured-macro>"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
result = self.exporter.convert_fragment(html)
|
|
315
|
+
|
|
316
|
+
self.assertIn("## Formula", result)
|
|
317
|
+
self.assertIn("```text\nCredit = Cost × E\n + InfraCredit\n```", result)
|
|
318
|
+
self.assertNotIn("Default", result)
|
|
319
|
+
|
|
320
|
+
def test_code_macro_without_language_renders_as_bare_fence(self) -> None:
|
|
321
|
+
for language_xml in ("", '<ac:parameter ac:name="language">none</ac:parameter>'):
|
|
322
|
+
html = (
|
|
323
|
+
'<ac:structured-macro ac:name="code" ac:schema-version="1">'
|
|
324
|
+
+ language_xml
|
|
325
|
+
+ "<ac:plain-text-body><![CDATA[plain body]]></ac:plain-text-body>"
|
|
326
|
+
"</ac:structured-macro>"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
result = self.exporter.convert_fragment(html)
|
|
330
|
+
|
|
331
|
+
self.assertIn("```\nplain body\n```", result)
|
|
332
|
+
self.assertNotIn("```none", result)
|
|
333
|
+
|
|
334
|
+
def test_mathblock_macro_renders_as_math_fence_with_latex_verbatim(self) -> None:
|
|
335
|
+
html = (
|
|
336
|
+
'<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
|
|
337
|
+
'<ac:parameter ac:name="alignment">left</ac:parameter>'
|
|
338
|
+
"<ac:plain-text-body><![CDATA[\\text{Credit} = \\sum_{i=1}^{N} a_i \\times E]]></ac:plain-text-body>"
|
|
339
|
+
"</ac:structured-macro>"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
result = self.exporter.convert_fragment(html)
|
|
343
|
+
|
|
344
|
+
self.assertIn("```math\n\\text{Credit} = \\sum_{i=1}^{N} a_i \\times E\n```", result)
|
|
345
|
+
self.assertNotIn("left", result)
|
|
346
|
+
|
|
347
|
+
def test_unknown_plain_text_macro_renders_as_plain_fence_but_rich_macros_keep_children(self) -> None:
|
|
348
|
+
html = (
|
|
349
|
+
'<ac:structured-macro ac:name="noformat" ac:schema-version="1">'
|
|
350
|
+
"<ac:plain-text-body><![CDATA[raw text]]></ac:plain-text-body>"
|
|
351
|
+
"</ac:structured-macro>"
|
|
352
|
+
'<ac:structured-macro ac:name="panel" ac:schema-version="1">'
|
|
353
|
+
"<ac:rich-text-body><p>Panel text</p></ac:rich-text-body>"
|
|
354
|
+
"</ac:structured-macro>"
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
result = self.exporter.convert_fragment(html)
|
|
358
|
+
|
|
359
|
+
self.assertIn("```\nraw text\n```", result)
|
|
360
|
+
self.assertIn("Panel text", result)
|
|
361
|
+
self.assertNotIn("```\nPanel text", result)
|
|
362
|
+
|
|
363
|
+
def test_code_and_math_macros_survive_export_import_round_trip(self) -> None:
|
|
364
|
+
from conjira_cli.markdown_import import markdown_to_storage_html
|
|
365
|
+
|
|
366
|
+
html = (
|
|
367
|
+
"<h2>Formula</h2>"
|
|
368
|
+
'<ac:structured-macro ac:name="code" ac:schema-version="1">'
|
|
369
|
+
'<ac:parameter ac:name="language">text</ac:parameter>'
|
|
370
|
+
"<ac:plain-text-body><![CDATA[Credit = Cost × E]]></ac:plain-text-body>"
|
|
371
|
+
"</ac:structured-macro>"
|
|
372
|
+
'<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
|
|
373
|
+
"<ac:plain-text-body><![CDATA[\\text{Credit} = \\text{Cost} \\times E]]></ac:plain-text-body>"
|
|
374
|
+
"</ac:structured-macro>"
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
round_tripped = markdown_to_storage_html(self.exporter.convert_fragment(html))
|
|
378
|
+
|
|
379
|
+
self.assertIn('<ac:structured-macro ac:name="code" ac:schema-version="1">', round_tripped)
|
|
380
|
+
self.assertIn('<ac:parameter ac:name="language">text</ac:parameter>', round_tripped)
|
|
381
|
+
self.assertIn("<![CDATA[Credit = Cost × E]]>", round_tripped)
|
|
382
|
+
self.assertIn('<ac:structured-macro ac:name="mathblock" ac:schema-version="1">', round_tripped)
|
|
383
|
+
self.assertIn("<![CDATA[\\text{Credit} = \\text{Cost} \\times E]]>", round_tripped)
|
|
384
|
+
|
|
385
|
+
def test_plain_text_macro_inside_list_item_renders_as_nested_fence(self) -> None:
|
|
386
|
+
html = (
|
|
387
|
+
"<ul><li>Ledger"
|
|
388
|
+
'<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
|
|
389
|
+
"<ac:plain-text-body><![CDATA[\\text{Ledger} = \\text{Credit}]]></ac:plain-text-body>"
|
|
390
|
+
"</ac:structured-macro>"
|
|
391
|
+
"</li><li>Next item</li></ul>"
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
result = self.exporter.convert_fragment(html)
|
|
395
|
+
|
|
396
|
+
self.assertIn("- Ledger\n", result)
|
|
397
|
+
self.assertIn("```math\n \\text{Ledger} = \\text{Credit}\n ```", result)
|
|
398
|
+
self.assertIn("- Next item", result)
|
|
399
|
+
|
|
400
|
+
def test_math_macro_inside_list_item_survives_round_trip(self) -> None:
|
|
401
|
+
from conjira_cli.markdown_import import markdown_to_storage_html
|
|
402
|
+
|
|
403
|
+
html = (
|
|
404
|
+
"<ul><li>Ledger"
|
|
405
|
+
'<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
|
|
406
|
+
"<ac:plain-text-body><![CDATA[\\text{Ledger} = \\text{Credit}]]></ac:plain-text-body>"
|
|
407
|
+
"</ac:structured-macro>"
|
|
408
|
+
"</li><li>Next item</li></ul>"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
round_tripped = markdown_to_storage_html(self.exporter.convert_fragment(html))
|
|
412
|
+
|
|
413
|
+
self.assertIn(
|
|
414
|
+
'<li>Ledger<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
|
|
415
|
+
"<ac:plain-text-body><![CDATA[\\text{Ledger} = \\text{Credit}]]></ac:plain-text-body>"
|
|
416
|
+
"</ac:structured-macro></li>",
|
|
417
|
+
round_tripped,
|
|
418
|
+
)
|
|
419
|
+
self.assertIn("<li>Next item</li>", round_tripped)
|
|
420
|
+
|
|
421
|
+
def test_plain_text_macro_wrapped_in_paragraph_renders_as_fence(self) -> None:
|
|
422
|
+
html = (
|
|
423
|
+
"<p>Before"
|
|
424
|
+
'<ac:structured-macro ac:name="code" ac:schema-version="1">'
|
|
425
|
+
'<ac:parameter ac:name="language">text</ac:parameter>'
|
|
426
|
+
"<ac:plain-text-body><![CDATA[x = 1]]></ac:plain-text-body>"
|
|
427
|
+
"</ac:structured-macro>"
|
|
428
|
+
"After</p>"
|
|
429
|
+
)
|
|
430
|
+
|
|
431
|
+
result = self.exporter.convert_fragment(html)
|
|
432
|
+
|
|
433
|
+
self.assertIn("Before", result)
|
|
434
|
+
self.assertIn("```text\nx = 1\n```", result)
|
|
435
|
+
self.assertIn("After", result)
|
|
@@ -298,3 +298,31 @@ class MarkdownImportTests(unittest.TestCase):
|
|
|
298
298
|
# Whole cell goes through as raw HTML; trailing markdown is NOT rendered.
|
|
299
299
|
self.assertIn("<ul><li>x</li></ul> trailing [link](http://example.com)", result)
|
|
300
300
|
self.assertNotIn('<a href="http://example.com">', result)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
class MathFenceImportTests(unittest.TestCase):
|
|
304
|
+
def test_math_fence_becomes_mathblock_macro(self) -> None:
|
|
305
|
+
result = markdown_to_storage_html("```math\n\\text{Credit} = a \\times E\n```\n")
|
|
306
|
+
|
|
307
|
+
self.assertIn(
|
|
308
|
+
'<ac:structured-macro ac:name="mathblock" ac:schema-version="1">',
|
|
309
|
+
result,
|
|
310
|
+
)
|
|
311
|
+
self.assertIn(
|
|
312
|
+
"<ac:plain-text-body><![CDATA[\\text{Credit} = a \\times E]]></ac:plain-text-body>",
|
|
313
|
+
result,
|
|
314
|
+
)
|
|
315
|
+
self.assertNotIn('ac:name="code"', result)
|
|
316
|
+
|
|
317
|
+
def test_indented_math_fence_inside_list_item_becomes_nested_mathblock(self) -> None:
|
|
318
|
+
markdown = "- Ledger\n ```math\n x = 1\n ```\n- Next item\n"
|
|
319
|
+
|
|
320
|
+
result = markdown_to_storage_html(markdown)
|
|
321
|
+
|
|
322
|
+
self.assertIn(
|
|
323
|
+
"<li>Ledger<ac:structured-macro ac:name=\"mathblock\" ac:schema-version=\"1\">"
|
|
324
|
+
"<ac:plain-text-body><![CDATA[x = 1]]></ac:plain-text-body></ac:structured-macro></li>",
|
|
325
|
+
result,
|
|
326
|
+
)
|
|
327
|
+
self.assertIn("<li>Next item</li>", result)
|
|
328
|
+
self.assertNotIn("```", result)
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import unittest
|
|
2
|
+
|
|
3
|
+
from conjira_cli.section_edit import (
|
|
4
|
+
SectionEditError,
|
|
5
|
+
insert_after_heading_html,
|
|
6
|
+
replace_section_html,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SectionEditTests(unittest.TestCase):
|
|
11
|
+
def test_replace_section_html_replaces_heading_block_until_next_peer_heading(self) -> None:
|
|
12
|
+
body_html = (
|
|
13
|
+
"<h1>Guide</h1>"
|
|
14
|
+
"<p>Intro</p>"
|
|
15
|
+
"<h2>Install</h2>"
|
|
16
|
+
"<p>Old install step</p>"
|
|
17
|
+
"<h3>Nested detail</h3>"
|
|
18
|
+
"<p>Nested note</p>"
|
|
19
|
+
"<h2>Usage</h2>"
|
|
20
|
+
"<p>Run command</p>"
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
result = replace_section_html(
|
|
24
|
+
body_html,
|
|
25
|
+
heading="Install",
|
|
26
|
+
replacement_html="<p>New install step</p><ul><li>Do this first</li></ul>",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
self.assertEqual(result.matched_heading, "Install")
|
|
30
|
+
self.assertEqual(result.heading_level, 2)
|
|
31
|
+
self.assertIn("<p>Old install step</p>", result.old_section_html)
|
|
32
|
+
self.assertIn("<h3>Nested detail</h3>", result.old_section_html)
|
|
33
|
+
self.assertIn("<p>New install step</p>", result.new_section_html)
|
|
34
|
+
self.assertIn("<ul><li>Do this first</li></ul>", result.updated_body_html)
|
|
35
|
+
self.assertIn("<h2>Usage</h2>", result.updated_body_html)
|
|
36
|
+
self.assertNotIn("Old install step", result.updated_body_html)
|
|
37
|
+
|
|
38
|
+
def test_replace_section_html_fails_when_heading_missing(self) -> None:
|
|
39
|
+
with self.assertRaises(SectionEditError):
|
|
40
|
+
replace_section_html(
|
|
41
|
+
"<h1>Guide</h1><p>Body</p>",
|
|
42
|
+
heading="Install",
|
|
43
|
+
replacement_html="<p>Replacement</p>",
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def test_replace_section_html_fails_when_heading_ambiguous(self) -> None:
|
|
47
|
+
with self.assertRaises(SectionEditError):
|
|
48
|
+
replace_section_html(
|
|
49
|
+
"<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
|
|
50
|
+
heading="Install",
|
|
51
|
+
replacement_html="<p>Replacement</p>",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def test_insert_after_heading_html_inserts_immediately_after_heading(self) -> None:
|
|
55
|
+
body_html = (
|
|
56
|
+
"<h1>Guide</h1>"
|
|
57
|
+
"<p>Intro</p>"
|
|
58
|
+
"<h2>Install</h2>"
|
|
59
|
+
"<p>Old install step</p>"
|
|
60
|
+
"<h2>Usage</h2>"
|
|
61
|
+
"<p>Run command</p>"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
result = insert_after_heading_html(
|
|
65
|
+
body_html,
|
|
66
|
+
heading="Install",
|
|
67
|
+
inserted_html="<p>New note</p><ul><li>Check this first</li></ul>",
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
self.assertEqual(result.matched_heading, "Install")
|
|
71
|
+
self.assertEqual(result.heading_level, 2)
|
|
72
|
+
self.assertIn("<p>New note</p>", result.inserted_html)
|
|
73
|
+
self.assertIn("<ul><li>Check this first</li></ul>", result.updated_body_html)
|
|
74
|
+
self.assertIn(
|
|
75
|
+
"<h2>Install</h2><p>New note</p><ul><li>Check this first</li></ul><p>Old install step</p>",
|
|
76
|
+
result.updated_body_html,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def test_insert_after_heading_html_fails_when_heading_missing(self) -> None:
|
|
80
|
+
with self.assertRaises(SectionEditError):
|
|
81
|
+
insert_after_heading_html(
|
|
82
|
+
"<h1>Guide</h1><p>Body</p>",
|
|
83
|
+
heading="Install",
|
|
84
|
+
inserted_html="<p>Inserted</p>",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
def test_insert_after_heading_html_fails_when_heading_ambiguous(self) -> None:
|
|
88
|
+
with self.assertRaises(SectionEditError):
|
|
89
|
+
insert_after_heading_html(
|
|
90
|
+
"<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
|
|
91
|
+
heading="Install",
|
|
92
|
+
inserted_html="<p>Inserted</p>",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
_CODE_MACRO = (
|
|
97
|
+
'<ac:structured-macro ac:name="code" ac:schema-version="1">'
|
|
98
|
+
'<ac:parameter ac:name="language">text</ac:parameter>'
|
|
99
|
+
"<ac:plain-text-body><![CDATA[Credit = Cost × E]]></ac:plain-text-body>"
|
|
100
|
+
"</ac:structured-macro>"
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class SectionEditCdataTests(unittest.TestCase):
|
|
105
|
+
def test_replace_section_keeps_cdata_macro_body_outside_edited_section(self) -> None:
|
|
106
|
+
body_html = (
|
|
107
|
+
"<h2>Formula</h2>"
|
|
108
|
+
+ _CODE_MACRO
|
|
109
|
+
+ "<h2>Other</h2><p>old</p>"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
result = replace_section_html(
|
|
113
|
+
body_html,
|
|
114
|
+
heading="Other",
|
|
115
|
+
replacement_html="<p>new</p>",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
self.assertIn("<![CDATA[Credit = Cost × E]]>", result.updated_body_html)
|
|
119
|
+
self.assertIn("<p>new</p>", result.updated_body_html)
|
|
120
|
+
self.assertNotIn("<p>old</p>", result.updated_body_html)
|
|
121
|
+
|
|
122
|
+
def test_replace_section_keeps_cdata_in_replacement_and_in_replaced_section(self) -> None:
|
|
123
|
+
old_macro = _CODE_MACRO.replace("Credit = Cost × E", "old formula")
|
|
124
|
+
body_html = "<h2>Formula</h2>" + old_macro + "<h2>Other</h2><p>text</p>"
|
|
125
|
+
|
|
126
|
+
result = replace_section_html(
|
|
127
|
+
body_html,
|
|
128
|
+
heading="Formula",
|
|
129
|
+
replacement_html=_CODE_MACRO + "<p>note</p>",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
self.assertIn("<![CDATA[old formula]]>", result.old_section_html)
|
|
133
|
+
self.assertIn("<![CDATA[Credit = Cost × E]]>", result.new_section_html)
|
|
134
|
+
self.assertIn("<![CDATA[Credit = Cost × E]]>", result.updated_body_html)
|
|
135
|
+
self.assertNotIn("old formula", result.updated_body_html)
|
|
136
|
+
|
|
137
|
+
def test_insert_after_heading_keeps_cdata_in_body_and_inserted_fragment(self) -> None:
|
|
138
|
+
existing_macro = _CODE_MACRO.replace("Credit = Cost × E", "existing formula")
|
|
139
|
+
body_html = "<h2>Formula</h2>" + existing_macro + "<h2>Other</h2><p>text</p>"
|
|
140
|
+
|
|
141
|
+
result = insert_after_heading_html(
|
|
142
|
+
body_html,
|
|
143
|
+
heading="Other",
|
|
144
|
+
inserted_html=_CODE_MACRO,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
self.assertIn("<![CDATA[Credit = Cost × E]]>", result.inserted_html)
|
|
148
|
+
self.assertIn("<![CDATA[existing formula]]>", result.updated_body_html)
|
|
149
|
+
self.assertIn("<![CDATA[Credit = Cost × E]]>", result.updated_body_html)
|
|
150
|
+
|
|
151
|
+
def test_multiple_cdata_sections_with_special_characters_are_restored_in_place(self) -> None:
|
|
152
|
+
math_macro = (
|
|
153
|
+
'<ac:structured-macro ac:name="mathblock" ac:schema-version="1">'
|
|
154
|
+
"<ac:plain-text-body><![CDATA[\\text{Credit} = \\sum_{i} a_i < b & c]]></ac:plain-text-body>"
|
|
155
|
+
"</ac:structured-macro>"
|
|
156
|
+
)
|
|
157
|
+
second_code = _CODE_MACRO.replace("Credit = Cost × E", "if (a < b && c) { run(); }")
|
|
158
|
+
body_html = (
|
|
159
|
+
"<h2>Formula</h2>" + _CODE_MACRO + math_macro
|
|
160
|
+
+ "<h2>Code</h2>" + second_code
|
|
161
|
+
+ "<h2>Other</h2><p>old</p>"
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
result = replace_section_html(
|
|
165
|
+
body_html,
|
|
166
|
+
heading="Other",
|
|
167
|
+
replacement_html="<p>new</p>",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
updated = result.updated_body_html
|
|
171
|
+
expected_in_order = [
|
|
172
|
+
"<![CDATA[Credit = Cost × E]]>",
|
|
173
|
+
"<![CDATA[\\text{Credit} = \\sum_{i} a_i < b & c]]>",
|
|
174
|
+
"<![CDATA[if (a < b && c) { run(); }]]>",
|
|
175
|
+
"<h2>Other</h2><p>new</p>",
|
|
176
|
+
]
|
|
177
|
+
positions = [updated.find(piece) for piece in expected_in_order]
|
|
178
|
+
self.assertTrue(all(position >= 0 for position in positions), updated)
|
|
179
|
+
self.assertEqual(positions, sorted(positions), updated)
|
|
180
|
+
self.assertNotIn("conjira-cdata", updated)
|
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
import unittest
|
|
2
|
-
|
|
3
|
-
from conjira_cli.section_edit import (
|
|
4
|
-
SectionEditError,
|
|
5
|
-
insert_after_heading_html,
|
|
6
|
-
replace_section_html,
|
|
7
|
-
)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
class SectionEditTests(unittest.TestCase):
|
|
11
|
-
def test_replace_section_html_replaces_heading_block_until_next_peer_heading(self) -> None:
|
|
12
|
-
body_html = (
|
|
13
|
-
"<h1>Guide</h1>"
|
|
14
|
-
"<p>Intro</p>"
|
|
15
|
-
"<h2>Install</h2>"
|
|
16
|
-
"<p>Old install step</p>"
|
|
17
|
-
"<h3>Nested detail</h3>"
|
|
18
|
-
"<p>Nested note</p>"
|
|
19
|
-
"<h2>Usage</h2>"
|
|
20
|
-
"<p>Run command</p>"
|
|
21
|
-
)
|
|
22
|
-
|
|
23
|
-
result = replace_section_html(
|
|
24
|
-
body_html,
|
|
25
|
-
heading="Install",
|
|
26
|
-
replacement_html="<p>New install step</p><ul><li>Do this first</li></ul>",
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
self.assertEqual(result.matched_heading, "Install")
|
|
30
|
-
self.assertEqual(result.heading_level, 2)
|
|
31
|
-
self.assertIn("<p>Old install step</p>", result.old_section_html)
|
|
32
|
-
self.assertIn("<h3>Nested detail</h3>", result.old_section_html)
|
|
33
|
-
self.assertIn("<p>New install step</p>", result.new_section_html)
|
|
34
|
-
self.assertIn("<ul><li>Do this first</li></ul>", result.updated_body_html)
|
|
35
|
-
self.assertIn("<h2>Usage</h2>", result.updated_body_html)
|
|
36
|
-
self.assertNotIn("Old install step", result.updated_body_html)
|
|
37
|
-
|
|
38
|
-
def test_replace_section_html_fails_when_heading_missing(self) -> None:
|
|
39
|
-
with self.assertRaises(SectionEditError):
|
|
40
|
-
replace_section_html(
|
|
41
|
-
"<h1>Guide</h1><p>Body</p>",
|
|
42
|
-
heading="Install",
|
|
43
|
-
replacement_html="<p>Replacement</p>",
|
|
44
|
-
)
|
|
45
|
-
|
|
46
|
-
def test_replace_section_html_fails_when_heading_ambiguous(self) -> None:
|
|
47
|
-
with self.assertRaises(SectionEditError):
|
|
48
|
-
replace_section_html(
|
|
49
|
-
"<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
|
|
50
|
-
heading="Install",
|
|
51
|
-
replacement_html="<p>Replacement</p>",
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
def test_insert_after_heading_html_inserts_immediately_after_heading(self) -> None:
|
|
55
|
-
body_html = (
|
|
56
|
-
"<h1>Guide</h1>"
|
|
57
|
-
"<p>Intro</p>"
|
|
58
|
-
"<h2>Install</h2>"
|
|
59
|
-
"<p>Old install step</p>"
|
|
60
|
-
"<h2>Usage</h2>"
|
|
61
|
-
"<p>Run command</p>"
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
result = insert_after_heading_html(
|
|
65
|
-
body_html,
|
|
66
|
-
heading="Install",
|
|
67
|
-
inserted_html="<p>New note</p><ul><li>Check this first</li></ul>",
|
|
68
|
-
)
|
|
69
|
-
|
|
70
|
-
self.assertEqual(result.matched_heading, "Install")
|
|
71
|
-
self.assertEqual(result.heading_level, 2)
|
|
72
|
-
self.assertIn("<p>New note</p>", result.inserted_html)
|
|
73
|
-
self.assertIn("<ul><li>Check this first</li></ul>", result.updated_body_html)
|
|
74
|
-
self.assertIn(
|
|
75
|
-
"<h2>Install</h2><p>New note</p><ul><li>Check this first</li></ul><p>Old install step</p>",
|
|
76
|
-
result.updated_body_html,
|
|
77
|
-
)
|
|
78
|
-
|
|
79
|
-
def test_insert_after_heading_html_fails_when_heading_missing(self) -> None:
|
|
80
|
-
with self.assertRaises(SectionEditError):
|
|
81
|
-
insert_after_heading_html(
|
|
82
|
-
"<h1>Guide</h1><p>Body</p>",
|
|
83
|
-
heading="Install",
|
|
84
|
-
inserted_html="<p>Inserted</p>",
|
|
85
|
-
)
|
|
86
|
-
|
|
87
|
-
def test_insert_after_heading_html_fails_when_heading_ambiguous(self) -> None:
|
|
88
|
-
with self.assertRaises(SectionEditError):
|
|
89
|
-
insert_after_heading_html(
|
|
90
|
-
"<h2>Install</h2><p>A</p><h2>Install</h2><p>B</p>",
|
|
91
|
-
heading="Install",
|
|
92
|
-
inserted_html="<p>Inserted</p>",
|
|
93
|
-
)
|
|
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
|