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.

' + '
' + ) + block = SidecarBlock( + block_index=0, + xhtml_xpath='ac:adf-extension[1]', + xhtml_fragment=( + '' + 'note' + '

This is a note panel.

' + '
' + ), + reconstruction={ + 'kind': 'container', + 'children': [ + { + 'xpath': 'ac:adf-extension[1]/p[1]', + 'fragment': '

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 '' in result + assert 'Updated note panel.' in result + assert '' not in result + @pytest.mark.parametrize("page_id", ['544112828', '1454342158', '544379140', 'panels']) def test_container_child_fragment_oracle(page_id): @@ -478,6 +514,44 @@ def test_callout_with_image_routes_to_replace_fragment(self): # img 태그는 Confluence 마크업으로 교체되어야 한다 assert '' + 'note' + '' + '

Original ' + '' + ' note.

' + '
' + '
' + '
' + '

Original note.

' + '
' + '
' + ) + original_mdx = ( + 'import { Callout } from "nextra/components"\n\n' + '\n' + 'Original sample.png note.\n' + '\n' + ) + improved_mdx = ( + 'import { Callout } from "nextra/components"\n\n' + '\n' + 'Updated sample.png note.\n' + '\n' + ) + + patches, patched = _run_pipeline(xhtml, original_mdx, improved_mdx) + + replace_patches = [p for p in patches if p.get('action') == 'replace_fragment'] + assert len(replace_patches) == 1 + assert replace_patches[0]['xhtml_xpath'] == 'ac:adf-extension[1]' + assert '' in patched + assert '' not in patched + assert '