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
17 changes: 17 additions & 0 deletions confluence-mdx/bin/reverse_sync/list_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ def _resolve_child_mapping(
if old_unmarked == child_nospace:
return child

# 5차: 앞부분 prefix 일치 (emoticon/lost_info 차이 허용)
# XHTML에서 ac:emoticon이 텍스트로 치환되지 않는 경우,
# 전체 문자열 비교가 실패할 수 있으므로 앞부분 20자로 비교한다.
# 단, old_nospace가 child보다 2배 이상 긴 경우는 잘못된 매칭으로 판단한다
# (callout 전체 텍스트가 내부 paragraph 첫 줄과 prefix를 공유하는 경우 방지).
_PREFIX_LEN = 20
if len(old_nospace) >= _PREFIX_LEN:
old_prefix = old_nospace[:_PREFIX_LEN]
for child_id in parent_mapping.children:
child = id_to_mapping.get(child_id)
if child:
child_nospace = re.sub(r'\s+', '', child.xhtml_plain_text)
if (len(child_nospace) >= _PREFIX_LEN
and child_nospace[:_PREFIX_LEN] == old_prefix
and len(old_nospace) <= len(child_nospace) * 2):
return child

return None


Expand Down
31 changes: 29 additions & 2 deletions confluence-mdx/bin/reverse_sync/mapping_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,28 @@ class BlockMapping:
_CALLOUT_MACRO_NAMES = frozenset({'tip', 'info', 'note', 'warning', 'panel'})


def _get_text_with_emoticons(element) -> str:
"""get_text()와 동일하지만 ac:emoticon의 fallback 텍스트를 포함한다.

Confluence의 <ac:emoticon> 태그는 self-closing으로 텍스트 노드가 없어서
get_text()에서 누락되지만, MDX에서는 리터럴 이모지로 표현된다.
이 함수는 ac:emoticon의 ac:emoji-fallback 속성값을 텍스트로 포함시켜
MDX 정규화 텍스트와의 매칭을 가능하게 한다.
"""
parts = []
for item in element.children:
if isinstance(item, NavigableString):
parts.append(str(item))
elif isinstance(item, Tag):
if item.name == 'ac:emoticon':
fallback = item.get('ac:emoji-fallback', '')
if fallback:
parts.append(fallback)
else:
parts.append(_get_text_with_emoticons(item))
return ''.join(parts)


def _iter_block_children(parent):
"""블록 레벨 자식을 순회한다. ac:layout은 cell 내부로 진입한다."""
for child in parent.children:
Expand Down Expand Up @@ -70,7 +92,12 @@ def record_mapping(xhtml: str) -> List[BlockMapping]:
_add_mapping(mappings, counters, f'macro-{macro_name}', str(child), plain,
block_type='code')
else:
plain = child.get_text()
# Callout 매크로: body 텍스트만 추출 (파라미터 메타데이터 제외)
if macro_name in _CALLOUT_MACRO_NAMES:
rich_body = child.find('ac:rich-text-body')
plain = _get_text_with_emoticons(rich_body) if rich_body else child.get_text()
else:
plain = child.get_text()
_add_mapping(mappings, counters, f'macro-{macro_name}', str(child), plain,
block_type='html_block')
# Callout 매크로: 자식 요소 개별 매핑 추가
Expand Down Expand Up @@ -145,7 +172,7 @@ def _add_container_children(
child_counters[tag] = child_counters.get(tag, 0) + 1
child_xpath = f"{parent_xpath}/{tag}[{child_counters[tag]}]"

plain = child.get_text()
plain = _get_text_with_emoticons(child)
if tag in ('ul', 'ol', 'table'):
inner = str(child)
else:
Expand Down
14 changes: 14 additions & 0 deletions confluence-mdx/bin/reverse_sync/sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,20 @@ def _find_text_match(
if prefix in mdx_sig or mdx_sig[:50] in xhtml_sig:
return ptr

# 4차: 짧은 prefix 포함 매칭 (emoticon/lost_info 차이 허용)
# XHTML ac:emoticon 태그가 텍스트로 치환되지 않는 경우,
# 전체 문자열의 substring 비교가 실패할 수 있으므로
# 앞부분 20자만으로 포함 관계를 검사한다.
_SHORT_PREFIX = 20
for ptr in range(start_ptr, end_ptr):
mdx_idx = mdx_content_indices[ptr]
mdx_sig = _strip_all_ws(mdx_plains[mdx_idx])
if len(mdx_sig) < _SHORT_PREFIX:
continue
mdx_prefix = mdx_sig[:_SHORT_PREFIX]
if mdx_prefix in xhtml_sig:
return ptr

return None


Expand Down
8 changes: 6 additions & 2 deletions confluence-mdx/bin/reverse_sync/xhtml_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
import re
from reverse_sync.mapping_recorder import _iter_block_children

from reverse_sync.mapping_recorder import _get_text_with_emoticons


def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str:
"""XHTML에 패치를 적용한다.
Expand Down Expand Up @@ -63,14 +65,16 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str:
for element, patch in resolved_modifies:
if 'new_inner_xhtml' in patch:
old_text = patch.get('old_plain_text', '')
current_plain = element.get_text()
# ac:emoticon의 fallback 텍스트를 포함하여 비교
current_plain = _get_text_with_emoticons(element)
if old_text and current_plain.strip() != old_text.strip():
continue
_replace_inner_html(element, patch['new_inner_xhtml'])
else:
old_text = patch['old_plain_text']
new_text = patch['new_plain_text']
current_plain = element.get_text()
# ac:emoticon의 fallback 텍스트를 포함하여 비교
current_plain = _get_text_with_emoticons(element)
if current_plain.strip() != old_text.strip():
continue
_apply_text_changes(element, old_text, new_text)
Expand Down
46 changes: 46 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_mapping_recorder.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,52 @@ def test_adf_extension_non_callout_no_children():
assert mappings[0].children == []


def test_callout_panel_excludes_parameter_metadata():
"""panel callout의 xhtml_plain_text가 파라미터 메타데이터를 포함하지 않는다."""
xhtml = (
'<ac:structured-macro ac:name="panel">'
'<ac:parameter ac:name="panelIcon">:purple_circle:</ac:parameter>'
'<ac:parameter ac:name="panelIconId">1f7e3</ac:parameter>'
'<ac:parameter ac:name="panelIconText">🟣</ac:parameter>'
'<ac:parameter ac:name="bgColor">#F4F5F7</ac:parameter>'
'<ac:rich-text-body>'
'<p><strong>본문 텍스트입니다.</strong></p>'
'</ac:rich-text-body>'
'</ac:structured-macro>'
)
mappings = record_mapping(xhtml)
parent = mappings[0]
assert parent.xhtml_xpath == 'macro-panel[1]'
# 파라미터 메타데이터가 제외되고 body 텍스트만 포함
assert ':purple_circle:' not in parent.xhtml_plain_text
assert '#F4F5F7' not in parent.xhtml_plain_text
assert '본문 텍스트입니다.' in parent.xhtml_plain_text


def test_callout_includes_emoticon_fallback_text():
"""ac:emoticon의 fallback 텍스트가 xhtml_plain_text에 포함된다."""
xhtml = (
'<ac:structured-macro ac:name="panel">'
'<ac:parameter ac:name="panelIcon">:purple_circle:</ac:parameter>'
'<ac:rich-text-body>'
'<p><strong>클릭해서 확대해서 보세요. </strong>'
'<ac:emoticon ac:emoji-fallback="🔎" ac:emoji-id="1f50e" '
'ac:emoji-shortname=":mag_right:" ac:name="blue-star"></ac:emoticon>'
' )</p>'
'</ac:rich-text-body>'
'</ac:structured-macro>'
)
mappings = record_mapping(xhtml)

parent = mappings[0]
assert '🔎' in parent.xhtml_plain_text

# child paragraph에도 emoticon fallback이 포함
child = mappings[1]
assert child.xhtml_xpath.endswith('/p[1]')
assert '🔎' in child.xhtml_plain_text


from pathlib import Path

def test_mapping_real_testcase():
Expand Down
11 changes: 11 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_patch_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ def test_missing_child_id(self):
result = _resolve_child_mapping('some text here', parent, id_map)
assert result is None

def test_prefix_match_rejects_long_text(self):
# 5차 prefix: old_plain이 child보다 훨씬 길 때 잘못된 매칭 방지
# callout 전체 텍스트가 내부 paragraph와 같은 prefix를 공유하는 경우
child_text = '11.4.0부터 속성 기반 승인자 지정시 여러개의 속성을 지정할 수 있도록 개선되었습니다.'
long_old = child_text + ' ' + '기존 Attribute 기반 승인자 지정시 하나의 Attribute만 지정할 수 있었으나...' * 3
child = _make_mapping('c1', child_text)
parent = _make_mapping('p1', 'parent', children=['c1'])
id_map = {'c1': child, 'p1': parent}
result = _resolve_child_mapping(long_old, parent, id_map)
assert result is None


# ── Helper 함수 테스트 ──

Expand Down
53 changes: 53 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_sidecar.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,27 @@ def test_short_text_no_prefix_match(self):
result = _find_text_match('AB', indices, plains, 0, 5)
assert result is None

def test_short_prefix_match_with_emoticon_difference(self):
"""4차: emoticon 차이가 있어도 앞부분 20자 prefix가 일치하면 매칭한다."""
# XHTML에서 ac:emoticon이 텍스트로 추출되지 않는 경우,
# 끝부분에 이모지가 빠져서 전체 문자열 비교가 실패하지만
# 앞부분 prefix로 매칭할 수 있어야 한다.
xhtml_text = '9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요. )'
mdx_text = '9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요. 🔎 )'
indices = [0]
plains = {0: mdx_text}
result = _find_text_match(xhtml_text, indices, plains, 0, 5)
assert result == 0

def test_short_prefix_match_with_metadata_prefix(self):
"""4차: XHTML에 파라미터 메타데이터 prefix가 있어도 MDX prefix로 매칭한다."""
xhtml_text = ':purple_circle:1f7e3🟣#F4F5F79.12.0 이후부터 적용되는 신규 메뉴 가이드입니다.'
mdx_text = '9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요. 🔎 )'
indices = [0]
plains = {0: mdx_text}
result = _find_text_match(xhtml_text, indices, plains, 0, 5)
assert result == 0


# ── generate_sidecar_mapping ──────────────────────────────────

Expand Down Expand Up @@ -421,6 +442,38 @@ def test_callout_macro_with_children(self):
]
assert len(container_entries) >= 1

def test_callout_panel_with_emoticon_maps_to_mdx(self):
"""panel callout + emoticon이 있는 XHTML이 MDX callout에 매핑된다."""
xhtml = (
'<ac:structured-macro ac:name="panel">'
'<ac:parameter ac:name="panelIcon">:purple_circle:</ac:parameter>'
'<ac:parameter ac:name="panelIconId">1f7e3</ac:parameter>'
'<ac:parameter ac:name="panelIconText">🟣</ac:parameter>'
'<ac:parameter ac:name="bgColor">#F4F5F7</ac:parameter>'
'<ac:rich-text-body>'
'<p><strong>9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요. </strong>'
'<ac:emoticon ac:emoji-fallback="🔎" ac:emoji-id="1f50e" '
'ac:emoji-shortname=":mag_right:" ac:name="blue-star"></ac:emoticon>'
' )</p>'
'</ac:rich-text-body>'
'</ac:structured-macro>'
)
mdx = (
'---\ntitle: Test\n---\n\n'
'import { Callout } from \'nextra/components\'\n\n'
'<Callout type="info" emoji="🟣">\n'
'**9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요.** 🔎 )\n'
'</Callout>\n'
)
result = generate_sidecar_mapping(xhtml, mdx)
data = yaml.safe_load(result)

panel_entry = next(
e for e in data['mappings']
if e['xhtml_xpath'] == 'macro-panel[1]')
assert len(panel_entry['mdx_blocks']) >= 1, \
f"panel callout이 MDX 블록에 매핑되지 않음: {panel_entry}"


# ── 실제 테스트 케이스 기반 통합 테스트 ───────────────────────

Expand Down