conjira-cli 0.2.3__tar.gz → 0.2.4__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.3/src/conjira_cli.egg-info → conjira_cli-0.2.4}/PKG-INFO +7 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/README.md +6 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/pyproject.toml +1 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/__init__.py +1 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/cli.py +96 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/client.py +34 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/markdown_export.py +12 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/markdown_import.py +27 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/section_edit.py +74 -23
- {conjira_cli-0.2.3 → conjira_cli-0.2.4/src/conjira_cli.egg-info}/PKG-INFO +7 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_cli.py +143 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_client.py +81 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_markdown_export.py +46 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_markdown_import.py +42 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_section_edit.py +46 -1
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/LICENSE +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/setup.cfg +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/setup.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/__main__.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/config.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/inline_comments.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/setup_macos.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli/tree_export.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli.egg-info/SOURCES.txt +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli.egg-info/dependency_links.txt +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli.egg-info/entry_points.txt +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/src/conjira_cli.egg-info/top_level.txt +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_config.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_inline_comments.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/tests/test_setup_macos.py +0 -0
- {conjira_cli-0.2.3 → conjira_cli-0.2.4}/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.4
|
|
4
4
|
Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
|
|
5
5
|
Author: quanttraderkim
|
|
6
6
|
License-Expression: MIT
|
|
@@ -338,6 +338,12 @@ Replace one named section on an existing Confluence page:
|
|
|
338
338
|
conjira --env-file ./local/agent.env replace-section --allow-write --page-id 100002 --heading "Rollout plan" --section-markdown-file ./notes/rollout.md
|
|
339
339
|
```
|
|
340
340
|
|
|
341
|
+
Insert new content immediately after a named heading:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
conjira --env-file ./local/agent.env insert-after-heading --dry-run --page-id 100002 --heading "Rollout plan" --insert-markdown-file ./notes/rollout-intro.md
|
|
345
|
+
```
|
|
346
|
+
|
|
341
347
|
Move an existing Confluence page under a different parent page:
|
|
342
348
|
|
|
343
349
|
```bash
|
|
@@ -309,6 +309,12 @@ Replace one named section on an existing Confluence page:
|
|
|
309
309
|
conjira --env-file ./local/agent.env replace-section --allow-write --page-id 100002 --heading "Rollout plan" --section-markdown-file ./notes/rollout.md
|
|
310
310
|
```
|
|
311
311
|
|
|
312
|
+
Insert new content immediately after a named heading:
|
|
313
|
+
|
|
314
|
+
```bash
|
|
315
|
+
conjira --env-file ./local/agent.env insert-after-heading --dry-run --page-id 100002 --heading "Rollout plan" --insert-markdown-file ./notes/rollout-intro.md
|
|
316
|
+
```
|
|
317
|
+
|
|
312
318
|
Move an existing Confluence page under a different parent page:
|
|
313
319
|
|
|
314
320
|
```bash
|
|
@@ -23,7 +23,11 @@ from conjira_cli.config import (
|
|
|
23
23
|
from conjira_cli.inline_comments import render_inline_comment_summary_markdown
|
|
24
24
|
from conjira_cli.markdown_export import MarkdownExporter
|
|
25
25
|
from conjira_cli.markdown_import import markdown_to_storage_html
|
|
26
|
-
from conjira_cli.section_edit import
|
|
26
|
+
from conjira_cli.section_edit import (
|
|
27
|
+
SectionEditError,
|
|
28
|
+
insert_after_heading_html,
|
|
29
|
+
replace_section_html,
|
|
30
|
+
)
|
|
27
31
|
from conjira_cli.tree_export import export_page_tree, sanitize_path_component
|
|
28
32
|
|
|
29
33
|
_JIRA_SUMMARY_FIELDS = [
|
|
@@ -402,6 +406,22 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
402
406
|
replace_section_body_group.add_argument("--section-markdown")
|
|
403
407
|
replace_section_body_group.add_argument("--section-markdown-file")
|
|
404
408
|
|
|
409
|
+
insert_after_heading = subparsers.add_parser(
|
|
410
|
+
"insert-after-heading",
|
|
411
|
+
help="Insert content immediately after a specific Confluence heading",
|
|
412
|
+
)
|
|
413
|
+
insert_after_heading.add_argument("--page-id", required=True)
|
|
414
|
+
insert_after_heading.add_argument("--heading", required=True)
|
|
415
|
+
insert_after_heading.add_argument("--allow-write", action="store_true")
|
|
416
|
+
insert_after_heading.add_argument("--dry-run", action="store_true")
|
|
417
|
+
insert_after_heading_body_group = insert_after_heading.add_mutually_exclusive_group(
|
|
418
|
+
required=True
|
|
419
|
+
)
|
|
420
|
+
insert_after_heading_body_group.add_argument("--insert-html")
|
|
421
|
+
insert_after_heading_body_group.add_argument("--insert-file")
|
|
422
|
+
insert_after_heading_body_group.add_argument("--insert-markdown")
|
|
423
|
+
insert_after_heading_body_group.add_argument("--insert-markdown-file")
|
|
424
|
+
|
|
405
425
|
move_page = subparsers.add_parser(
|
|
406
426
|
"move-page",
|
|
407
427
|
help="Move an existing Confluence page under a different parent page",
|
|
@@ -634,6 +654,31 @@ def _confluence_replace_section_preview(
|
|
|
634
654
|
}
|
|
635
655
|
|
|
636
656
|
|
|
657
|
+
def _confluence_insert_after_heading_preview(
|
|
658
|
+
*,
|
|
659
|
+
page: Dict[str, Any],
|
|
660
|
+
heading: str,
|
|
661
|
+
result: Any,
|
|
662
|
+
body_source: str,
|
|
663
|
+
) -> Dict[str, Any]:
|
|
664
|
+
current_summary = ConfluenceClient.summarize_page(page)
|
|
665
|
+
return {
|
|
666
|
+
"dry_run": True,
|
|
667
|
+
"product": "confluence",
|
|
668
|
+
"action": "insert-after-heading",
|
|
669
|
+
"page_id": current_summary.get("id"),
|
|
670
|
+
"space_key": current_summary.get("space_key"),
|
|
671
|
+
"source_url": current_summary.get("webui_url"),
|
|
672
|
+
"heading": heading,
|
|
673
|
+
"matched_heading": result.matched_heading,
|
|
674
|
+
"heading_level": result.heading_level,
|
|
675
|
+
"body_source": body_source,
|
|
676
|
+
"inserted_after_heading": True,
|
|
677
|
+
"inserted_block_preview": _preview_html(result.inserted_html),
|
|
678
|
+
"resulting_body_preview": _preview_html(result.updated_body_html),
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
|
|
637
682
|
def _confluence_move_page_preview(
|
|
638
683
|
*,
|
|
639
684
|
page: Dict[str, Any],
|
|
@@ -819,6 +864,11 @@ def _guidance_for_config_error(message: str) -> list[str]:
|
|
|
819
864
|
"Check that the heading text exists exactly once on the live Confluence page before retrying replace-section.",
|
|
820
865
|
"For the first iteration, replace-section is safest on text-first pages with clear heading structure.",
|
|
821
866
|
]
|
|
867
|
+
if "insert-after-heading target heading" in lowered:
|
|
868
|
+
return [
|
|
869
|
+
"Check that the heading text exists exactly once on the live Confluence page before retrying insert-after-heading.",
|
|
870
|
+
"insert-after-heading is safest on text-first pages with clear heading structure and a stable target heading.",
|
|
871
|
+
]
|
|
822
872
|
if "move-page requires different" in lowered:
|
|
823
873
|
return [
|
|
824
874
|
"Choose a different parent page before retrying move-page.",
|
|
@@ -1190,6 +1240,51 @@ def _handle_confluence(args: argparse.Namespace) -> Dict[str, Any]:
|
|
|
1190
1240
|
payload["heading"] = args.heading
|
|
1191
1241
|
payload["matched_heading"] = replacement.matched_heading
|
|
1192
1242
|
return payload
|
|
1243
|
+
if args.command == "insert-after-heading":
|
|
1244
|
+
_require_write_intent(args.allow_write, args.dry_run)
|
|
1245
|
+
_assert_confluence_update_allowed(
|
|
1246
|
+
page_id=args.page_id,
|
|
1247
|
+
allowed_page_ids=settings.allowed_page_ids,
|
|
1248
|
+
)
|
|
1249
|
+
inserted_html = _read_confluence_body_arg(
|
|
1250
|
+
args.insert_html,
|
|
1251
|
+
args.insert_file,
|
|
1252
|
+
args.insert_markdown,
|
|
1253
|
+
args.insert_markdown_file,
|
|
1254
|
+
mermaid_macro_name=settings.mermaid_macro_name,
|
|
1255
|
+
)
|
|
1256
|
+
page = client.get_page(args.page_id, expand="body.storage,version,space")
|
|
1257
|
+
current_body = (((page.get("body") or {}).get("storage") or {}).get("value")) or ""
|
|
1258
|
+
try:
|
|
1259
|
+
insertion = insert_after_heading_html(
|
|
1260
|
+
current_body,
|
|
1261
|
+
heading=args.heading,
|
|
1262
|
+
inserted_html=inserted_html,
|
|
1263
|
+
)
|
|
1264
|
+
except SectionEditError as exc:
|
|
1265
|
+
raise ConfigError(str(exc)) from exc
|
|
1266
|
+
body_source = _confluence_body_source(
|
|
1267
|
+
raw_html=args.insert_html,
|
|
1268
|
+
html_file=args.insert_file,
|
|
1269
|
+
raw_markdown=args.insert_markdown,
|
|
1270
|
+
markdown_file=args.insert_markdown_file,
|
|
1271
|
+
)
|
|
1272
|
+
if args.dry_run:
|
|
1273
|
+
return _confluence_insert_after_heading_preview(
|
|
1274
|
+
page=page,
|
|
1275
|
+
heading=args.heading,
|
|
1276
|
+
result=insertion,
|
|
1277
|
+
body_source=body_source or "unknown",
|
|
1278
|
+
)
|
|
1279
|
+
updated = client.update_page_from_snapshot(
|
|
1280
|
+
page,
|
|
1281
|
+
new_body_html=insertion.updated_body_html,
|
|
1282
|
+
)
|
|
1283
|
+
payload = client.summarize_page(updated)
|
|
1284
|
+
payload["action"] = "insert-after-heading"
|
|
1285
|
+
payload["heading"] = args.heading
|
|
1286
|
+
payload["matched_heading"] = insertion.matched_heading
|
|
1287
|
+
return payload
|
|
1193
1288
|
if args.command == "move-page":
|
|
1194
1289
|
_require_write_intent(args.allow_write, args.dry_run)
|
|
1195
1290
|
_assert_confluence_update_allowed(
|
|
@@ -5,6 +5,7 @@ import time
|
|
|
5
5
|
import urllib.error
|
|
6
6
|
import urllib.parse
|
|
7
7
|
import urllib.request
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
8
9
|
from typing import Any, Dict, Iterable, Optional
|
|
9
10
|
|
|
10
11
|
from conjira_cli.inline_comments import build_inline_comment_summary
|
|
@@ -31,6 +32,28 @@ class JiraError(AtlassianError):
|
|
|
31
32
|
pass
|
|
32
33
|
|
|
33
34
|
|
|
35
|
+
def validate_storage_html(body_html: str) -> None:
|
|
36
|
+
"""Check that *body_html* is well-formed XHTML before sending to Confluence.
|
|
37
|
+
|
|
38
|
+
Raises :class:`ConfluenceError` with a descriptive message if parsing
|
|
39
|
+
fails, giving the caller a chance to fix the content instead of getting
|
|
40
|
+
a cryptic 400 from the server.
|
|
41
|
+
"""
|
|
42
|
+
wrapped = (
|
|
43
|
+
'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri"'
|
|
44
|
+
' xmlns:atlassian="urn:atlassian">'
|
|
45
|
+
+ body_html
|
|
46
|
+
+ "</root>"
|
|
47
|
+
)
|
|
48
|
+
try:
|
|
49
|
+
ET.fromstring(wrapped)
|
|
50
|
+
except ET.ParseError as exc:
|
|
51
|
+
raise ConfluenceError(
|
|
52
|
+
f"Body HTML is not well-formed XHTML — Confluence will reject it. "
|
|
53
|
+
f"Detail: {exc}",
|
|
54
|
+
) from exc
|
|
55
|
+
|
|
56
|
+
|
|
34
57
|
class BaseAtlassianClient:
|
|
35
58
|
product_name = "Atlassian"
|
|
36
59
|
error_cls = AtlassianError
|
|
@@ -83,8 +106,15 @@ class BaseAtlassianClient:
|
|
|
83
106
|
raw = response.read().decode("utf-8")
|
|
84
107
|
if not raw:
|
|
85
108
|
return None
|
|
86
|
-
|
|
109
|
+
content_type = response.headers.get("Content-Type", "")
|
|
110
|
+
if "application/json" in content_type:
|
|
87
111
|
return json.loads(raw)
|
|
112
|
+
stripped = raw.lstrip()
|
|
113
|
+
if stripped.startswith("{") or stripped.startswith("["):
|
|
114
|
+
try:
|
|
115
|
+
return json.loads(raw)
|
|
116
|
+
except json.JSONDecodeError:
|
|
117
|
+
pass
|
|
88
118
|
return raw
|
|
89
119
|
except urllib.error.HTTPError as exc:
|
|
90
120
|
raw = exc.read().decode("utf-8", errors="replace")
|
|
@@ -168,6 +198,7 @@ class ConfluenceClient(BaseAtlassianClient):
|
|
|
168
198
|
body_html: str,
|
|
169
199
|
parent_id: Optional[str] = None,
|
|
170
200
|
) -> Dict[str, Any]:
|
|
201
|
+
validate_storage_html(body_html)
|
|
171
202
|
payload: Dict[str, Any] = {
|
|
172
203
|
"type": "page",
|
|
173
204
|
"title": title,
|
|
@@ -215,6 +246,8 @@ class ConfluenceClient(BaseAtlassianClient):
|
|
|
215
246
|
if append_html:
|
|
216
247
|
updated_body += append_html
|
|
217
248
|
|
|
249
|
+
validate_storage_html(updated_body)
|
|
250
|
+
|
|
218
251
|
payload = {
|
|
219
252
|
"id": current["id"],
|
|
220
253
|
"type": current["type"],
|
|
@@ -470,16 +470,27 @@ class MarkdownExporter:
|
|
|
470
470
|
child_name = _local_name(child.tag)
|
|
471
471
|
if child_name in {"ul", "ol"}:
|
|
472
472
|
list_text = self._render_block(child, indent=0).strip().replace("\n", "<br>")
|
|
473
|
+
# Escape unescaped pipes BEFORE joining so nested content
|
|
474
|
+
# doesn't break the outer table column boundaries.
|
|
475
|
+
# Use negative lookbehind to avoid double-escaping pipes
|
|
476
|
+
# that were already escaped by deeper recursion.
|
|
477
|
+
list_text = re.sub(r"(?<!\\)\|", r"\\|", list_text)
|
|
473
478
|
pieces.append(list_text)
|
|
474
479
|
elif child_name == "table":
|
|
475
480
|
table_text = self._render_table(child).replace("\n", "<br>")
|
|
481
|
+
# Escape unescaped pipes from the nested table so they
|
|
482
|
+
# don't create spurious columns in the parent table row.
|
|
483
|
+
table_text = re.sub(r"(?<!\\)\|", r"\\|", table_text)
|
|
476
484
|
pieces.append(table_text)
|
|
477
485
|
else:
|
|
478
486
|
pieces.append(self._render_inline(child))
|
|
479
487
|
if child.tail and _collapse_inline(child.tail):
|
|
480
488
|
pieces.append(_collapse_inline(child.tail))
|
|
481
489
|
joined = _join_inline_pieces(pieces)
|
|
482
|
-
|
|
490
|
+
# Escape any remaining unescaped pipes from inline content.
|
|
491
|
+
# Use a negative lookbehind to avoid double-escaping pipes that
|
|
492
|
+
# were already escaped above.
|
|
493
|
+
joined = re.sub(r"(?<!\\)\|", r"\\|", joined)
|
|
483
494
|
joined = joined.replace("\n", "<br>")
|
|
484
495
|
joined = re.sub(r"\s{2,}", " ", joined)
|
|
485
496
|
joined = re.sub(r"^(<br>\s*)+$", "", joined)
|
|
@@ -39,7 +39,33 @@ def markdown_to_storage_html(
|
|
|
39
39
|
markdown = _strip_frontmatter(markdown).replace("\r\n", "\n").replace("\r", "\n")
|
|
40
40
|
lines = markdown.split("\n")
|
|
41
41
|
html_parts, _ = _parse_blocks(lines, 0, mermaid_macro_name=mermaid_macro_name)
|
|
42
|
-
|
|
42
|
+
result = "".join(html_parts).strip()
|
|
43
|
+
return _ensure_xhtml_self_closing(result)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# Tags that must be self-closing in Confluence XHTML Storage Format
|
|
47
|
+
_VOID_TAGS = {"br", "hr", "img"}
|
|
48
|
+
_VOID_TAG_RE = re.compile(
|
|
49
|
+
r"<(" + "|".join(_VOID_TAGS) + r")((?:\s[^>]*?)?)(\s*/?)>",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _ensure_xhtml_self_closing(html_str: str) -> str:
|
|
55
|
+
"""Normalise void HTML tags to XHTML self-closing form.
|
|
56
|
+
|
|
57
|
+
Confluence Storage Format requires strict XHTML: ``<br />`` not ``<br>``.
|
|
58
|
+
Handles ``<br>``, ``<br/>``, ``<br />``, and variants with attributes.
|
|
59
|
+
"""
|
|
60
|
+
def _fix(m: re.Match[str]) -> str:
|
|
61
|
+
tag = m.group(1)
|
|
62
|
+
attrs = (m.group(2) or "").rstrip()
|
|
63
|
+
# Strip any trailing slash already present in attrs
|
|
64
|
+
if attrs.endswith("/"):
|
|
65
|
+
attrs = attrs[:-1].rstrip()
|
|
66
|
+
return f"<{tag}{attrs} />"
|
|
67
|
+
|
|
68
|
+
return _VOID_TAG_RE.sub(_fix, html_str)
|
|
43
69
|
|
|
44
70
|
|
|
45
71
|
def _strip_frontmatter(markdown: str) -> str:
|
|
@@ -27,6 +27,15 @@ class SectionReplacementResult:
|
|
|
27
27
|
updated_body_html: str
|
|
28
28
|
|
|
29
29
|
|
|
30
|
+
@dataclass
|
|
31
|
+
class HeadingInsertionResult:
|
|
32
|
+
heading: str
|
|
33
|
+
matched_heading: str
|
|
34
|
+
heading_level: int
|
|
35
|
+
inserted_html: str
|
|
36
|
+
updated_body_html: str
|
|
37
|
+
|
|
38
|
+
|
|
30
39
|
def replace_section_html(
|
|
31
40
|
body_html: str,
|
|
32
41
|
*,
|
|
@@ -35,29 +44,11 @@ def replace_section_html(
|
|
|
35
44
|
) -> SectionReplacementResult:
|
|
36
45
|
root = _parse_fragment(body_html)
|
|
37
46
|
children = list(root)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
if heading_level is None:
|
|
44
|
-
continue
|
|
45
|
-
rendered_heading = _element_text(child)
|
|
46
|
-
if _normalize_heading(rendered_heading) == normalized_target:
|
|
47
|
-
matches.append((index, child, heading_level))
|
|
48
|
-
|
|
49
|
-
if not matches:
|
|
50
|
-
raise SectionEditError(
|
|
51
|
-
'replace-section target heading "{0}" was not found.'.format(heading)
|
|
52
|
-
)
|
|
53
|
-
if len(matches) > 1:
|
|
54
|
-
raise SectionEditError(
|
|
55
|
-
'replace-section target heading "{0}" is ambiguous because it appears multiple times.'.format(
|
|
56
|
-
heading
|
|
57
|
-
)
|
|
58
|
-
)
|
|
59
|
-
|
|
60
|
-
match_index, match_elem, match_level = matches[0]
|
|
47
|
+
match_index, match_elem, match_level = _find_unique_heading(
|
|
48
|
+
children,
|
|
49
|
+
heading=heading,
|
|
50
|
+
action_name="replace-section",
|
|
51
|
+
)
|
|
61
52
|
end_index = len(children)
|
|
62
53
|
for index in range(match_index + 1, len(children)):
|
|
63
54
|
next_level = _heading_level(children[index])
|
|
@@ -87,6 +78,36 @@ def replace_section_html(
|
|
|
87
78
|
)
|
|
88
79
|
|
|
89
80
|
|
|
81
|
+
def insert_after_heading_html(
|
|
82
|
+
body_html: str,
|
|
83
|
+
*,
|
|
84
|
+
heading: str,
|
|
85
|
+
inserted_html: str,
|
|
86
|
+
) -> HeadingInsertionResult:
|
|
87
|
+
root = _parse_fragment(body_html)
|
|
88
|
+
children = list(root)
|
|
89
|
+
match_index, match_elem, match_level = _find_unique_heading(
|
|
90
|
+
children,
|
|
91
|
+
heading=heading,
|
|
92
|
+
action_name="insert-after-heading",
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
new_root = _parse_fragment(inserted_html)
|
|
96
|
+
new_children = [copy.deepcopy(child) for child in list(new_root)]
|
|
97
|
+
|
|
98
|
+
insert_at = match_index + 1
|
|
99
|
+
for offset, child in enumerate(new_children):
|
|
100
|
+
root.insert(insert_at + offset, child)
|
|
101
|
+
|
|
102
|
+
return HeadingInsertionResult(
|
|
103
|
+
heading=heading,
|
|
104
|
+
matched_heading=_element_text(match_elem),
|
|
105
|
+
heading_level=match_level,
|
|
106
|
+
inserted_html=_serialize_elements(new_children),
|
|
107
|
+
updated_body_html=_serialize_root(root),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
90
111
|
def _parse_fragment(fragment_html: str) -> ET.Element:
|
|
91
112
|
wrapped = _WRAPPED_ROOT_PREFIX + fragment_html + "</root>"
|
|
92
113
|
try:
|
|
@@ -103,6 +124,36 @@ def _serialize_elements(elements: list[ET.Element]) -> str:
|
|
|
103
124
|
return "".join(ET.tostring(element, encoding="unicode") for element in elements).strip()
|
|
104
125
|
|
|
105
126
|
|
|
127
|
+
def _find_unique_heading(
|
|
128
|
+
children: list[ET.Element],
|
|
129
|
+
*,
|
|
130
|
+
heading: str,
|
|
131
|
+
action_name: str,
|
|
132
|
+
) -> tuple[int, ET.Element, int]:
|
|
133
|
+
normalized_target = _normalize_heading(heading)
|
|
134
|
+
matches: list[tuple[int, ET.Element, int]] = []
|
|
135
|
+
for index, child in enumerate(children):
|
|
136
|
+
heading_level = _heading_level(child)
|
|
137
|
+
if heading_level is None:
|
|
138
|
+
continue
|
|
139
|
+
rendered_heading = _element_text(child)
|
|
140
|
+
if _normalize_heading(rendered_heading) == normalized_target:
|
|
141
|
+
matches.append((index, child, heading_level))
|
|
142
|
+
|
|
143
|
+
if not matches:
|
|
144
|
+
raise SectionEditError(
|
|
145
|
+
'{0} target heading "{1}" was not found.'.format(action_name, heading)
|
|
146
|
+
)
|
|
147
|
+
if len(matches) > 1:
|
|
148
|
+
raise SectionEditError(
|
|
149
|
+
'{0} target heading "{1}" is ambiguous because it appears multiple times.'.format(
|
|
150
|
+
action_name,
|
|
151
|
+
heading,
|
|
152
|
+
)
|
|
153
|
+
)
|
|
154
|
+
return matches[0]
|
|
155
|
+
|
|
156
|
+
|
|
106
157
|
def _local_name(tag: str) -> str:
|
|
107
158
|
if "}" in tag:
|
|
108
159
|
return tag.split("}", 1)[1]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: conjira-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
4
4
|
Summary: Unofficial agent-friendly CLI for self-hosted Confluence and Jira
|
|
5
5
|
Author: quanttraderkim
|
|
6
6
|
License-Expression: MIT
|
|
@@ -338,6 +338,12 @@ Replace one named section on an existing Confluence page:
|
|
|
338
338
|
conjira --env-file ./local/agent.env replace-section --allow-write --page-id 100002 --heading "Rollout plan" --section-markdown-file ./notes/rollout.md
|
|
339
339
|
```
|
|
340
340
|
|
|
341
|
+
Insert new content immediately after a named heading:
|
|
342
|
+
|
|
343
|
+
```bash
|
|
344
|
+
conjira --env-file ./local/agent.env insert-after-heading --dry-run --page-id 100002 --heading "Rollout plan" --insert-markdown-file ./notes/rollout-intro.md
|
|
345
|
+
```
|
|
346
|
+
|
|
341
347
|
Move an existing Confluence page under a different parent page:
|
|
342
348
|
|
|
343
349
|
```bash
|
|
@@ -570,6 +570,141 @@ class CliTests(unittest.TestCase):
|
|
|
570
570
|
self.assertEqual(mock_update_page.call_args.args[0]["id"], "12345")
|
|
571
571
|
self.assertIn("<p>Replacement</p>", mock_update_page.call_args.kwargs["new_body_html"])
|
|
572
572
|
|
|
573
|
+
def test_handle_confluence_insert_after_heading_dry_run_returns_preview(self) -> None:
|
|
574
|
+
args = SimpleNamespace(
|
|
575
|
+
command="insert-after-heading",
|
|
576
|
+
base_url=None,
|
|
577
|
+
token=None,
|
|
578
|
+
token_file=None,
|
|
579
|
+
token_keychain_service=None,
|
|
580
|
+
token_keychain_account=None,
|
|
581
|
+
timeout=None,
|
|
582
|
+
env_file=None,
|
|
583
|
+
page_id="12345",
|
|
584
|
+
heading="Install",
|
|
585
|
+
allow_write=False,
|
|
586
|
+
dry_run=True,
|
|
587
|
+
insert_html=None,
|
|
588
|
+
insert_file=None,
|
|
589
|
+
insert_markdown="Prepended install note",
|
|
590
|
+
insert_markdown_file=None,
|
|
591
|
+
)
|
|
592
|
+
settings = ConfluenceSettings(
|
|
593
|
+
base_url="https://confluence.example.com",
|
|
594
|
+
token="token",
|
|
595
|
+
timeout_seconds=30,
|
|
596
|
+
)
|
|
597
|
+
page = {
|
|
598
|
+
"id": "12345",
|
|
599
|
+
"type": "page",
|
|
600
|
+
"title": "Guide",
|
|
601
|
+
"space": {"key": "DOCS"},
|
|
602
|
+
"version": {"number": 7},
|
|
603
|
+
"body": {
|
|
604
|
+
"storage": {
|
|
605
|
+
"value": (
|
|
606
|
+
"<h1>Guide</h1>"
|
|
607
|
+
"<h2>Install</h2>"
|
|
608
|
+
"<p>Old step</p>"
|
|
609
|
+
"<h2>Usage</h2>"
|
|
610
|
+
"<p>Run command</p>"
|
|
611
|
+
)
|
|
612
|
+
}
|
|
613
|
+
},
|
|
614
|
+
"_links": {
|
|
615
|
+
"base": "https://confluence.example.com",
|
|
616
|
+
"webui": "/pages/viewpage.action?pageId=12345",
|
|
617
|
+
},
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
with mock.patch("conjira_cli.cli.build_confluence_settings", return_value=settings), mock.patch(
|
|
621
|
+
"conjira_cli.cli.ConfluenceClient.get_page",
|
|
622
|
+
return_value=page,
|
|
623
|
+
) as mock_get_page, mock.patch(
|
|
624
|
+
"conjira_cli.cli.ConfluenceClient.update_page_from_snapshot"
|
|
625
|
+
) as mock_update_page:
|
|
626
|
+
payload = _handle_confluence(args)
|
|
627
|
+
|
|
628
|
+
self.assertTrue(payload["dry_run"])
|
|
629
|
+
self.assertEqual(payload["action"], "insert-after-heading")
|
|
630
|
+
self.assertEqual(payload["page_id"], "12345")
|
|
631
|
+
self.assertEqual(payload["heading"], "Install")
|
|
632
|
+
self.assertEqual(payload["matched_heading"], "Install")
|
|
633
|
+
self.assertEqual(payload["body_source"], "markdown")
|
|
634
|
+
self.assertIn("Prepended install note", payload["inserted_block_preview"])
|
|
635
|
+
mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space")
|
|
636
|
+
mock_update_page.assert_not_called()
|
|
637
|
+
|
|
638
|
+
def test_handle_confluence_insert_after_heading_write_updates_page(self) -> None:
|
|
639
|
+
args = SimpleNamespace(
|
|
640
|
+
command="insert-after-heading",
|
|
641
|
+
base_url=None,
|
|
642
|
+
token=None,
|
|
643
|
+
token_file=None,
|
|
644
|
+
token_keychain_service=None,
|
|
645
|
+
token_keychain_account=None,
|
|
646
|
+
timeout=None,
|
|
647
|
+
env_file=None,
|
|
648
|
+
page_id="12345",
|
|
649
|
+
heading="Install",
|
|
650
|
+
allow_write=True,
|
|
651
|
+
dry_run=False,
|
|
652
|
+
insert_html="<p>Inserted</p>",
|
|
653
|
+
insert_file=None,
|
|
654
|
+
insert_markdown=None,
|
|
655
|
+
insert_markdown_file=None,
|
|
656
|
+
)
|
|
657
|
+
settings = ConfluenceSettings(
|
|
658
|
+
base_url="https://confluence.example.com",
|
|
659
|
+
token="token",
|
|
660
|
+
timeout_seconds=30,
|
|
661
|
+
)
|
|
662
|
+
page = {
|
|
663
|
+
"id": "12345",
|
|
664
|
+
"type": "page",
|
|
665
|
+
"title": "Guide",
|
|
666
|
+
"space": {"key": "DOCS"},
|
|
667
|
+
"version": {"number": 7},
|
|
668
|
+
"body": {
|
|
669
|
+
"storage": {
|
|
670
|
+
"value": "<h2>Install</h2><p>Old step</p><h2>Usage</h2><p>Run command</p>"
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
"_links": {
|
|
674
|
+
"base": "https://confluence.example.com",
|
|
675
|
+
"webui": "/pages/viewpage.action?pageId=12345",
|
|
676
|
+
},
|
|
677
|
+
}
|
|
678
|
+
updated_summary = {
|
|
679
|
+
"id": "12345",
|
|
680
|
+
"type": "page",
|
|
681
|
+
"status": "current",
|
|
682
|
+
"title": "Guide",
|
|
683
|
+
"space": {"key": "DOCS"},
|
|
684
|
+
"version": {"number": 8},
|
|
685
|
+
"_links": {
|
|
686
|
+
"base": "https://confluence.example.com",
|
|
687
|
+
"webui": "/pages/viewpage.action?pageId=12345",
|
|
688
|
+
},
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
with mock.patch("conjira_cli.cli.build_confluence_settings", return_value=settings), mock.patch(
|
|
692
|
+
"conjira_cli.cli.ConfluenceClient.get_page",
|
|
693
|
+
return_value=page,
|
|
694
|
+
) as mock_get_page, mock.patch(
|
|
695
|
+
"conjira_cli.cli.ConfluenceClient.update_page_from_snapshot",
|
|
696
|
+
return_value=updated_summary,
|
|
697
|
+
) as mock_update_page:
|
|
698
|
+
payload = _handle_confluence(args)
|
|
699
|
+
|
|
700
|
+
self.assertEqual(payload["action"], "insert-after-heading")
|
|
701
|
+
self.assertEqual(payload["heading"], "Install")
|
|
702
|
+
self.assertEqual(payload["matched_heading"], "Install")
|
|
703
|
+
mock_get_page.assert_called_once_with("12345", expand="body.storage,version,space")
|
|
704
|
+
mock_update_page.assert_called_once()
|
|
705
|
+
self.assertEqual(mock_update_page.call_args.args[0]["id"], "12345")
|
|
706
|
+
self.assertIn("<h2>Install</h2><p>Inserted</p><p>Old step</p>", mock_update_page.call_args.kwargs["new_body_html"])
|
|
707
|
+
|
|
573
708
|
def test_handle_confluence_move_page_dry_run_returns_preview(self) -> None:
|
|
574
709
|
args = SimpleNamespace(
|
|
575
710
|
command="move-page",
|
|
@@ -691,6 +826,14 @@ class CliTests(unittest.TestCase):
|
|
|
691
826
|
self.assertEqual(payload["error_type"], "ConfigError")
|
|
692
827
|
self.assertTrue(any("heading" in item.lower() for item in payload["guidance"]))
|
|
693
828
|
|
|
829
|
+
def test_build_error_payload_adds_insert_after_heading_guidance(self) -> None:
|
|
830
|
+
payload = _build_error_payload(
|
|
831
|
+
ConfigError('insert-after-heading target heading "Install" was not found.')
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
self.assertEqual(payload["error_type"], "ConfigError")
|
|
835
|
+
self.assertTrue(any("insert-after-heading" in item.lower() for item in payload["guidance"]))
|
|
836
|
+
|
|
694
837
|
def test_build_error_payload_adds_move_page_guidance(self) -> None:
|
|
695
838
|
payload = _build_error_payload(
|
|
696
839
|
ConfigError("move-page requires different current and new parent IDs.")
|
|
@@ -4,6 +4,21 @@ from unittest import mock
|
|
|
4
4
|
from conjira_cli.client import ConfluenceClient, JiraClient
|
|
5
5
|
|
|
6
6
|
|
|
7
|
+
class _FakeHTTPResponse:
|
|
8
|
+
def __init__(self, body: str, *, content_type: str) -> None:
|
|
9
|
+
self._body = body.encode("utf-8")
|
|
10
|
+
self.headers = {"Content-Type": content_type}
|
|
11
|
+
|
|
12
|
+
def read(self) -> bytes:
|
|
13
|
+
return self._body
|
|
14
|
+
|
|
15
|
+
def __enter__(self) -> "_FakeHTTPResponse":
|
|
16
|
+
return self
|
|
17
|
+
|
|
18
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
19
|
+
return False
|
|
20
|
+
|
|
21
|
+
|
|
7
22
|
class ClientTests(unittest.TestCase):
|
|
8
23
|
def test_update_page_from_snapshot_uses_snapshot_version_and_id(self) -> None:
|
|
9
24
|
client = ConfluenceClient(base_url="https://confluence.example.com", token="token")
|
|
@@ -192,3 +207,69 @@ class ClientTests(unittest.TestCase):
|
|
|
192
207
|
|
|
193
208
|
self.assertEqual([page["id"] for page in pages], ["1", "2", "3"])
|
|
194
209
|
self.assertEqual(mock_get_child_pages.call_count, 2)
|
|
210
|
+
|
|
211
|
+
class ValidateStorageHtmlTests(unittest.TestCase):
|
|
212
|
+
def test_valid_xhtml_passes(self) -> None:
|
|
213
|
+
from conjira_cli.client import validate_storage_html
|
|
214
|
+
|
|
215
|
+
# Should not raise
|
|
216
|
+
validate_storage_html("<p>Hello <strong>world</strong></p>")
|
|
217
|
+
|
|
218
|
+
def test_invalid_xhtml_raises(self) -> None:
|
|
219
|
+
from conjira_cli.client import validate_storage_html, ConfluenceError
|
|
220
|
+
|
|
221
|
+
with self.assertRaises(ConfluenceError) as ctx:
|
|
222
|
+
validate_storage_html("<p>Unclosed paragraph")
|
|
223
|
+
self.assertIn("well-formed XHTML", str(ctx.exception))
|
|
224
|
+
|
|
225
|
+
def test_bare_br_is_invalid(self) -> None:
|
|
226
|
+
from conjira_cli.client import validate_storage_html, ConfluenceError
|
|
227
|
+
|
|
228
|
+
with self.assertRaises(ConfluenceError):
|
|
229
|
+
validate_storage_html("<p>line<br>break</p>")
|
|
230
|
+
|
|
231
|
+
def test_valid_xhtml_with_confluence_macros_passes(self) -> None:
|
|
232
|
+
from conjira_cli.client import validate_storage_html
|
|
233
|
+
|
|
234
|
+
validate_storage_html(
|
|
235
|
+
'<ac:structured-macro ac:name="code" ac:schema-version="1">'
|
|
236
|
+
'<ac:parameter ac:name="language">python</ac:parameter>'
|
|
237
|
+
'<ac:plain-text-body><![CDATA[print("hi")]]></ac:plain-text-body>'
|
|
238
|
+
"</ac:structured-macro>"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def test_valid_xhtml_with_atlassian_namespace_passes(self) -> None:
|
|
242
|
+
from conjira_cli.client import validate_storage_html
|
|
243
|
+
|
|
244
|
+
validate_storage_html(
|
|
245
|
+
'<td atlassian:data-highlight-colour="blue">text</td>'
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def test_get_page_parses_json_even_when_content_type_is_not_json(self) -> None:
|
|
249
|
+
client = ConfluenceClient(base_url="https://confluence.example.com", token="token")
|
|
250
|
+
body = (
|
|
251
|
+
'{"id":"123","type":"page","status":"current","title":"Demo",'
|
|
252
|
+
'"space":{"key":"TEST"},"version":{"number":7}}'
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
with mock.patch(
|
|
256
|
+
"conjira_cli.client.urllib.request.urlopen",
|
|
257
|
+
return_value=_FakeHTTPResponse(body, content_type="text/plain; charset=utf-8"),
|
|
258
|
+
):
|
|
259
|
+
page = client.get_page("123")
|
|
260
|
+
|
|
261
|
+
self.assertEqual(page["id"], "123")
|
|
262
|
+
self.assertEqual(ConfluenceClient.summarize_page(page)["title"], "Demo")
|
|
263
|
+
|
|
264
|
+
def test_jira_auth_check_parses_json_even_when_content_type_is_not_json(self) -> None:
|
|
265
|
+
client = JiraClient(base_url="https://jira.example.com", token="token")
|
|
266
|
+
body = '{"version":"10.3.16","buildNumber":10030016,"deploymentType":"Server"}'
|
|
267
|
+
|
|
268
|
+
with mock.patch(
|
|
269
|
+
"conjira_cli.client.urllib.request.urlopen",
|
|
270
|
+
return_value=_FakeHTTPResponse(body, content_type="text/plain; charset=utf-8"),
|
|
271
|
+
):
|
|
272
|
+
payload = client.auth_check()
|
|
273
|
+
|
|
274
|
+
self.assertEqual(payload["version"], "10.3.16")
|
|
275
|
+
self.assertEqual(payload["build_number"], 10030016)
|
|
@@ -249,3 +249,49 @@ class MarkdownExportTests(unittest.TestCase):
|
|
|
249
249
|
result = exporter.convert_fragment(html)
|
|
250
250
|
|
|
251
251
|
self.assertIn("- Description emphasis", result)
|
|
252
|
+
|
|
253
|
+
def test_nested_table_pipes_escaped_in_cell(self) -> None:
|
|
254
|
+
"""A nested table inside a cell must not break the outer table columns."""
|
|
255
|
+
exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
|
|
256
|
+
# Outer 2-column table with headers that do NOT trigger structured
|
|
257
|
+
# detection so we get a regular pipe table.
|
|
258
|
+
html = (
|
|
259
|
+
"<table><tbody>"
|
|
260
|
+
"<tr><th>Host</th><th>Config</th></tr>"
|
|
261
|
+
"<tr>"
|
|
262
|
+
"<td>SSL cert</td>"
|
|
263
|
+
"<td><table><tbody>"
|
|
264
|
+
"<tr><td>Type</td><td>Wildcard</td></tr>"
|
|
265
|
+
"<tr><td>Issuer</td><td>DigiCert</td></tr>"
|
|
266
|
+
"</tbody></table></td>"
|
|
267
|
+
"</tr>"
|
|
268
|
+
"</tbody></table>"
|
|
269
|
+
)
|
|
270
|
+
result = exporter.convert_fragment(html)
|
|
271
|
+
|
|
272
|
+
# The outer table row for "SSL cert" should have nested table pipes
|
|
273
|
+
# escaped so it stays as a 2-column row.
|
|
274
|
+
for line in result.splitlines():
|
|
275
|
+
if "SSL cert" in line and line.strip().startswith("|"):
|
|
276
|
+
self.assertIn("\\|", line, "Nested table pipes should be escaped")
|
|
277
|
+
break
|
|
278
|
+
else:
|
|
279
|
+
self.fail("Could not find 'SSL cert' row in rendered table")
|
|
280
|
+
|
|
281
|
+
def test_nested_list_pipes_escaped_in_cell(self) -> None:
|
|
282
|
+
"""A nested list with pipe chars inside a cell must escape them."""
|
|
283
|
+
exporter = MarkdownExporter(base_url="https://confluence.example.com", page_id="123")
|
|
284
|
+
html = (
|
|
285
|
+
"<table><tbody>"
|
|
286
|
+
"<tr><th>Task</th><th>Notes</th></tr>"
|
|
287
|
+
"<tr>"
|
|
288
|
+
"<td>Review</td>"
|
|
289
|
+
"<td><ul><li>Option A | Option B</li></ul></td>"
|
|
290
|
+
"</tr>"
|
|
291
|
+
"</tbody></table>"
|
|
292
|
+
)
|
|
293
|
+
result = exporter.convert_fragment(html)
|
|
294
|
+
|
|
295
|
+
for line in result.splitlines():
|
|
296
|
+
if "Review" in line and line.strip().startswith("|"):
|
|
297
|
+
self.assertIn("\\|", line, "Pipe in list item should be escaped")
|
|
@@ -153,3 +153,45 @@ class MarkdownImportTests(unittest.TestCase):
|
|
|
153
153
|
self.assertIn('<ac:structured-macro ac:name="status"', result)
|
|
154
154
|
self.assertIn('<ac:parameter ac:name="colour">Blue</ac:parameter>', result)
|
|
155
155
|
self.assertIn('<ac:parameter ac:name="title">Planned</ac:parameter>', result)
|
|
156
|
+
|
|
157
|
+
def test_xhtml_self_closing_br_tags(self) -> None:
|
|
158
|
+
"""<br> must be emitted as <br /> for Confluence XHTML strict mode."""
|
|
159
|
+
# The current renderer doesn't emit bare <br> from markdown, but
|
|
160
|
+
# if inline HTML sneaks through we need the post-processor to fix it.
|
|
161
|
+
from conjira_cli.markdown_import import _ensure_xhtml_self_closing
|
|
162
|
+
|
|
163
|
+
self.assertEqual(_ensure_xhtml_self_closing("<br>"), "<br />")
|
|
164
|
+
self.assertEqual(_ensure_xhtml_self_closing("<br/>"), "<br />")
|
|
165
|
+
self.assertEqual(_ensure_xhtml_self_closing("<br />"), "<br />")
|
|
166
|
+
self.assertEqual(_ensure_xhtml_self_closing("<hr>"), "<hr />")
|
|
167
|
+
self.assertEqual(
|
|
168
|
+
_ensure_xhtml_self_closing("<p>text<br>more</p>"),
|
|
169
|
+
"<p>text<br />more</p>",
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def test_xhtml_self_closing_preserves_attributes(self) -> None:
|
|
173
|
+
from conjira_cli.markdown_import import _ensure_xhtml_self_closing
|
|
174
|
+
|
|
175
|
+
self.assertEqual(
|
|
176
|
+
_ensure_xhtml_self_closing('<hr class="divider">'),
|
|
177
|
+
'<hr class="divider" />',
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
def test_xhtml_self_closing_img_tag(self) -> None:
|
|
181
|
+
from conjira_cli.markdown_import import _ensure_xhtml_self_closing
|
|
182
|
+
|
|
183
|
+
self.assertEqual(
|
|
184
|
+
_ensure_xhtml_self_closing('<img src="x.png">'),
|
|
185
|
+
'<img src="x.png" />',
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
def test_output_is_valid_xhtml(self) -> None:
|
|
189
|
+
"""Full markdown-to-HTML output must be well-formed XHTML."""
|
|
190
|
+
import xml.etree.ElementTree as ET
|
|
191
|
+
|
|
192
|
+
result = markdown_to_storage_html(
|
|
193
|
+
"# Hello\n\nParagraph with **bold**.\n\n---\n"
|
|
194
|
+
)
|
|
195
|
+
wrapped = f'<root xmlns:ac="urn:ac" xmlns:ri="urn:ri">{result}</root>'
|
|
196
|
+
# Should not raise
|
|
197
|
+
ET.fromstring(wrapped)
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import unittest
|
|
2
2
|
|
|
3
|
-
from conjira_cli.section_edit import
|
|
3
|
+
from conjira_cli.section_edit import (
|
|
4
|
+
SectionEditError,
|
|
5
|
+
insert_after_heading_html,
|
|
6
|
+
replace_section_html,
|
|
7
|
+
)
|
|
4
8
|
|
|
5
9
|
|
|
6
10
|
class SectionEditTests(unittest.TestCase):
|
|
@@ -46,3 +50,44 @@ class SectionEditTests(unittest.TestCase):
|
|
|
46
50
|
heading="Install",
|
|
47
51
|
replacement_html="<p>Replacement</p>",
|
|
48
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
|