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
63 changes: 40 additions & 23 deletions confluence-mdx/bin/reverse_sync/reconstructors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -434,31 +448,38 @@ 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
recon = sidecar_block.reconstruction
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))
Expand All @@ -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)


Expand Down
74 changes: 74 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,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 = (
'<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


@pytest.mark.parametrize("page_id", ['544112828', '1454342158', '544379140', 'panels'])
def test_container_child_fragment_oracle(page_id):
Expand Down Expand Up @@ -478,6 +514,44 @@ def test_callout_with_image_routes_to_replace_fragment(self):
# img 태그는 Confluence 마크업으로 교체되어야 한다
assert '<img src=' not in patched

def test_adf_panel_with_anchor_preserves_adf_wrapper(self):
"""ADF panel이 replace_fragment 경로를 타도 ac:adf-extension wrapper를 유지한다."""
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

def test_clean_callout_still_uses_text_transfer(self):
"""ac:image 없는 clean callout은 기존 text-transfer 경로를 유지한다."""
xhtml = (
Expand Down
Loading