diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py
index 9e1b8150b..a7bdcf932 100644
--- a/confluence-mdx/bin/reverse_sync/reconstructors.py
+++ b/confluence-mdx/bin/reverse_sync/reconstructors.py
@@ -202,7 +202,6 @@ def _rebuild_list_fragment(new_fragment: str, recon: dict) -> str:
return str(soup)
-
# ── container 재구성 헬퍼 ──────────────────────────────────────────────────────
def _has_inline_markup(fragment: str) -> bool:
@@ -362,6 +361,21 @@ def _reconstruct_child_with_anchors(child_frag: str, child_meta: dict) -> str:
return str(soup)
+def _find_container_body(root: Tag) -> Optional[Tag]:
+ """Container fragment에서 child slot이 들어가는 body wrapper를 찾는다."""
+ return root.find('ac:rich-text-body') or root.find('ac:adf-content')
+
+
+def _replace_container_body_children(body: Tag, children: list[str]) -> None:
+ """Container body의 직계 child를 새 fragment 목록으로 교체한다."""
+ for child in list(body.contents):
+ child.extract()
+ for fragment in children:
+ fragment_soup = BeautifulSoup(fragment, 'html.parser')
+ for child in list(fragment_soup.contents):
+ body.append(child.extract())
+
+
def sidecar_block_requires_reconstruction(
sidecar_block: Optional['SidecarBlock'],
) -> bool:
@@ -434,11 +448,12 @@ def reconstruct_container_fragment(
) -> str:
"""Container (callout/ADF panel) fragment에 sidecar child 메타데이터로 재구성한다.
- anchor 없는 clean container는 new_fragment를 그대로 반환한다.
+ sidecar의 xhtml_fragment를 wrapper template으로 유지하고 body child만 갱신한다.
anchor가 있어 재구성이 트리거된 경우 아래 세 단계를 각 child에 적용한다:
1. inline markup 보존: 원본 fragment를 template으로 bold·italic·link 유지
2. anchor 재삽입: ac:image를 offset 매핑으로 복원
3. outer wrapper 보존: sidecar xhtml_fragment를 template으로 macro 속성 유지
+ anchor가 없는 clean container도 emitted child를 template wrapper 안에 다시 배치한다.
"""
if sidecar_block is None or sidecar_block.reconstruction is None:
return new_fragment
@@ -446,19 +461,25 @@ def reconstruct_container_fragment(
if recon.get('kind') != 'container':
return new_fragment
children_meta = recon.get('children', [])
- if not any(c.get('anchors') or c.get('items') for c in children_meta):
- return new_fragment # clean container — emit_block result 그대로 사용
-
- # emitted new_fragment에서 body children 추출
emitted_soup = BeautifulSoup(new_fragment, 'html.parser')
- emitted_body = emitted_soup.find('ac:rich-text-body') or emitted_soup.find('ac:adf-content')
+ emitted_root = next((child for child in emitted_soup.contents if isinstance(child, Tag)), None)
+ if emitted_root is None:
+ return new_fragment
+ emitted_body = _find_container_body(emitted_root)
if emitted_body is None:
return new_fragment
- emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)]
+ template_fragment = sidecar_block.xhtml_fragment or new_fragment
+ template_soup = BeautifulSoup(template_fragment, 'html.parser')
+ template_root = next((child for child in template_soup.contents if isinstance(child, Tag)), None)
+ if template_root is None:
+ return new_fragment
+ template_body = _find_container_body(template_root)
+ if template_body is None:
+ return new_fragment
- # 각 child 재구성
rebuilt_fragments = []
+ emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)]
for i, child_tag in enumerate(emitted_children):
if i >= len(children_meta):
rebuilt_fragments.append(str(child_tag))
@@ -474,25 +495,21 @@ def reconstruct_container_fragment(
# Step 2: anchor 재삽입
if child_meta.get('anchors'):
child_frag = _reconstruct_child_with_anchors(child_frag, child_meta)
+ elif child_meta.get('items'):
+ child_frag = _rebuild_list_fragment(
+ child_frag,
+ {
+ 'old_plain_text': child_meta.get('plain_text', ''),
+ 'items': child_meta.get('items', []),
+ },
+ )
rebuilt_fragments.append(child_frag)
- # Step 3: outer wrapper template (macro 속성 보존)
- outer_template = sidecar_block.xhtml_fragment
- template_soup = BeautifulSoup(outer_template or new_fragment, 'html.parser')
- template_body = (
- template_soup.find('ac:rich-text-body') or template_soup.find('ac:adf-content')
- )
- if template_body is None:
+ if not rebuilt_fragments:
return new_fragment
- for child in list(template_body.contents):
- child.extract()
- for frag in rebuilt_fragments:
- frag_soup = BeautifulSoup(frag, 'html.parser')
- for node in list(frag_soup.contents):
- template_body.append(node.extract())
-
+ _replace_container_body_children(template_body, rebuilt_fragments)
return str(template_soup)
diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py
index 8164812de..432ff281d 100644
--- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py
+++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py
@@ -364,6 +364,42 @@ def test_no_sidecar_block_returns_new_fragment(self):
result = reconstruct_container_fragment(new_frag, None)
assert result == new_frag
+ def test_clean_container_preserves_template_wrapper(self):
+ """clean container도 원본 wrapper 종류는 유지해야 한다."""
+ new_frag = (
+ ' Updated note panel. This is a note panel.
This is a note panel.
', + 'plain_text': 'This is a note panel.', + 'type': 'paragraph', + } + ], + 'child_xpaths': ['ac:adf-extension[1]/p[1]'], + }, + ) + + result = reconstruct_container_fragment(new_frag, block) + + assert 'Original
Original note.
' + '
note.\n'
+ '
note.\n'
+ '