diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py index d6f11e3f2..868b23e43 100644 --- a/confluence-mdx/bin/reverse_sync/reconstructors.py +++ b/confluence-mdx/bin/reverse_sync/reconstructors.py @@ -379,6 +379,40 @@ 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 _find_adf_fallback_body(root: Tag) -> Optional[Tag]: + """ac:adf-extension 내부 fallback content slot을 찾는다.""" + fallback = root.find('ac:adf-fallback') + if fallback is None: + return None + panel_content = fallback.find( + 'div', + class_=lambda value: isinstance(value, str) and 'panelContent' in value.split(), + ) + if panel_content is not None: + return panel_content + return fallback.find('div') or fallback + + +def _fragments_contain_confluence_markup(fragments: list[str]) -> bool: + """fallback에 그대로 복사하기 unsafe한 Confluence 전용 markup이 있으면 True.""" + return any(' bool: @@ -451,7 +485,6 @@ def reconstruct_container_fragment( ) -> str: """Container (callout/ADF panel) fragment에 sidecar child 메타데이터로 재구성한다. - anchor 없는 clean container는 stored XHTML를 template으로 텍스트만 업데이트한다. anchor가 있어 재구성이 트리거된 경우 아래 세 단계를 각 child에 적용한다: 1. inline markup 보존: 원본 fragment를 template으로 bold·italic·link 유지 2. anchor 재삽입: ac:image를 offset 매핑으로 복원 @@ -542,28 +575,35 @@ def _apply_outer_wrapper_template( """ 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') - ) + 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 # body children 추출 if rebuilt_children is None: 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 rebuilt_children = [str(c) for c in emitted_body.children if isinstance(c, Tag)] - for child in list(template_body.contents): - child.extract() - for frag in rebuilt_children: - 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_children) + + if template_root.name == 'ac:adf-extension': + fallback_body = _find_adf_fallback_body(template_root) + if fallback_body is not None: + if _fragments_contain_confluence_markup(rebuilt_children): + fallback = template_root.find('ac:adf-fallback') + if fallback is not None: + fallback.decompose() + else: + _replace_container_body_children(fallback_body, rebuilt_children) 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 075702865..abe04ab5c 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -364,6 +364,52 @@ def test_no_sidecar_block_returns_new_fragment(self): result = reconstruct_container_fragment(new_frag, None) assert result == new_frag + def test_anchor_bearing_adf_container_drops_stale_fallback(self): + """Confluence 전용 anchor가 들어가면 fallback은 stale 상태로 남기지 않는다.""" + new_frag = ( + '' + '

Updated note.

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

Original note.

' + '
' + '
' + '

Original fallback.

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

Original {image_xhtml} note.

', + 'plain_text': 'Original note.', + 'type': 'paragraph', + 'anchors': [{'offset': 9, 'raw_xhtml': image_xhtml}], + } + ], + 'child_xpaths': ['ac:adf-extension[1]/p[1]'], + }, + ) + + result = reconstruct_container_fragment(new_frag, block) + + assert '' not in result + assert 'Original fallback.' not in result + @pytest.mark.parametrize("page_id", ['544112828', '1454342158', '544379140', 'panels']) def test_container_child_fragment_oracle(page_id): @@ -504,6 +550,47 @@ def test_clean_callout_uses_transfer_text_changes(self): ) assert 'Updated text' in patched + def test_adf_panel_with_anchor_preserves_adf_wrapper(self): + """ADF panel 재구성 시 wrapper를 유지하고 stale fallback을 제거한다.""" + xhtml = ( + '' + '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 '' not in patched + assert 'Original note.' not in patched + def test_clean_callout_structure_change_keeps_emitted_list(self): """clean container의 구조 변경은 stored paragraph 템플릿으로 덮어쓰면 안 된다.""" sidecar_block = SidecarBlock(