Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 66 additions & 14 deletions confluence-mdx/bin/reverse_sync/reconstructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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('<ac:' in fragment or '<ri:' in fragment for fragment in fragments)


def sidecar_block_requires_reconstruction(
sidecar_block: Optional['SidecarBlock'],
) -> bool:
Expand Down Expand Up @@ -456,6 +490,7 @@ def reconstruct_container_fragment(
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
Expand All @@ -467,10 +502,12 @@ def reconstruct_container_fragment(

# 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)]

# clean container 처리:
Expand Down Expand Up @@ -524,6 +561,14 @@ def reconstruct_container_fragment(
# Step 2: anchor 재삽입
if has_anchors and 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)

Expand All @@ -542,28 +587,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)

Expand Down
163 changes: 163 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_reconstruct_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,128 @@ 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 = (
'<ac:structured-macro ac:name="note">'
'<ac:rich-text-body><p>Updated note panel.</p></ac:rich-text-body>'
'</ac:structured-macro>'
)
block = SidecarBlock(
block_index=0,
xhtml_xpath='ac:adf-extension[1]',
xhtml_fragment=(
'<ac:adf-extension><ac:adf-node type="panel">'
'<ac:adf-attribute key="panel-type">note</ac:adf-attribute>'
'<ac:adf-content><p>This is a note panel.</p></ac:adf-content>'
'</ac:adf-node></ac:adf-extension>'
),
reconstruction={
'kind': 'container',
'children': [
{
'xpath': 'ac:adf-extension[1]/p[1]',
'fragment': '<p>This is a note panel.</p>',
'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 '<ac:adf-extension>' in result
assert 'Updated note panel.' in result
assert '<ac:structured-macro ac:name="note">' not in result

def test_clean_adf_container_updates_fallback_body(self):
"""clean ADF panel은 fallback도 stale 상태로 남기면 안 된다."""
new_frag = (
'<ac:structured-macro ac:name="note">'
'<ac:rich-text-body><p>Updated note panel.</p></ac:rich-text-body>'
'</ac:structured-macro>'
)
block = SidecarBlock(
block_index=0,
xhtml_xpath='ac:adf-extension[1]',
xhtml_fragment=(
'<ac:adf-extension><ac:adf-node type="panel">'
'<ac:adf-attribute key="panel-type">note</ac:adf-attribute>'
'<ac:adf-content><p>Original content.</p></ac:adf-content>'
'</ac:adf-node>'
'<ac:adf-fallback><div class="panel"><div class="panelContent">'
'<p>Original fallback.</p>'
'</div></div></ac:adf-fallback>'
'</ac:adf-extension>'
),
reconstruction={
'kind': 'container',
'children': [
{
'xpath': 'ac:adf-extension[1]/p[1]',
'fragment': '<p>Original content.</p>',
'plain_text': 'Original content.',
'type': 'paragraph',
}
],
'child_xpaths': ['ac:adf-extension[1]/p[1]'],
},
)

result = reconstruct_container_fragment(new_frag, block)

assert 'Updated note panel.' in result
assert 'Original fallback.' not in result
assert '<ac:adf-fallback>' in result

def test_anchor_bearing_adf_container_drops_stale_fallback(self):
"""Confluence 전용 anchor가 들어가면 fallback은 stale 상태로 남기지 않는다."""
new_frag = (
'<ac:structured-macro ac:name="note">'
'<ac:rich-text-body><p>Updated note.</p></ac:rich-text-body>'
'</ac:structured-macro>'
)
image_xhtml = (
'<ac:image ac:inline="true">'
'<ri:attachment ri:filename="sample.png"/>'
'</ac:image>'
)
block = SidecarBlock(
block_index=0,
xhtml_xpath='ac:adf-extension[1]',
xhtml_fragment=(
'<ac:adf-extension><ac:adf-node type="panel">'
'<ac:adf-attribute key="panel-type">note</ac:adf-attribute>'
'<ac:adf-content><p>Original note.</p></ac:adf-content>'
'</ac:adf-node>'
'<ac:adf-fallback><div class="panel"><div class="panelContent">'
'<p>Original fallback.</p>'
'</div></div></ac:adf-fallback>'
'</ac:adf-extension>'
),
reconstruction={
'kind': 'container',
'children': [
{
'xpath': 'ac:adf-extension[1]/p[1]',
'fragment': f'<p>Original {image_xhtml} note.</p>',
'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 '<ac:image' in result
assert '<ac:adf-fallback>' 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):
Expand Down Expand Up @@ -504,6 +626,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 = (
'<ac:adf-extension><ac:adf-node type="panel">'
'<ac:adf-attribute key="panel-type">note</ac:adf-attribute>'
'<ac:adf-content>'
'<p>Original <ac:image ac:inline="true">'
'<ri:attachment ri:filename="sample.png"/>'
'</ac:image> note.</p>'
'</ac:adf-content>'
'</ac:adf-node>'
'<ac:adf-fallback><div class="panel"><div class="panelContent">'
'<p>Original note.</p>'
'</div></div></ac:adf-fallback>'
'</ac:adf-extension>'
)
original_mdx = (
'import { Callout } from "nextra/components"\n\n'
'<Callout type="important">\n'
'Original <img src="/sample.png" alt="sample.png"/> note.\n'
'</Callout>\n'
)
improved_mdx = (
'import { Callout } from "nextra/components"\n\n'
'<Callout type="important">\n'
'Updated <img src="/sample.png" alt="sample.png"/> note.\n'
'</Callout>\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 '<ac:adf-extension>' in patched
assert '<ac:structured-macro ac:name="note">' not in patched
assert '<ac:image' in patched
# ac: markup 포함 시 stale fallback은 제거되어야 한다
assert '<ac:adf-fallback>' 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(
Expand Down
Loading