diff --git a/confluence-mdx/bin/reverse_sync/list_patcher.py b/confluence-mdx/bin/reverse_sync/list_patcher.py index 2214275d4..981c42669 100644 --- a/confluence-mdx/bin/reverse_sync/list_patcher.py +++ b/confluence-mdx/bin/reverse_sync/list_patcher.py @@ -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 diff --git a/confluence-mdx/bin/reverse_sync/mapping_recorder.py b/confluence-mdx/bin/reverse_sync/mapping_recorder.py index 94df314ec..9ef485e37 100644 --- a/confluence-mdx/bin/reverse_sync/mapping_recorder.py +++ b/confluence-mdx/bin/reverse_sync/mapping_recorder.py @@ -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의 태그는 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: @@ -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 매크로: 자식 요소 개별 매핑 추가 @@ -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: diff --git a/confluence-mdx/bin/reverse_sync/sidecar.py b/confluence-mdx/bin/reverse_sync/sidecar.py index 19aec37fc..55a22a5de 100644 --- a/confluence-mdx/bin/reverse_sync/sidecar.py +++ b/confluence-mdx/bin/reverse_sync/sidecar.py @@ -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 diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 97081777a..18e2eb22c 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -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에 패치를 적용한다. @@ -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) diff --git a/confluence-mdx/tests/test_reverse_sync_mapping_recorder.py b/confluence-mdx/tests/test_reverse_sync_mapping_recorder.py index 780fbabf7..5c444f336 100644 --- a/confluence-mdx/tests/test_reverse_sync_mapping_recorder.py +++ b/confluence-mdx/tests/test_reverse_sync_mapping_recorder.py @@ -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 = ( + '' + ':purple_circle:' + '1f7e3' + '🟣' + '#F4F5F7' + '' + '

본문 텍스트입니다.

' + '
' + '
' + ) + 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 = ( + '' + ':purple_circle:' + '' + '

클릭해서 확대해서 보세요. ' + '' + ' )

' + '
' + '
' + ) + 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(): diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index fe65087b0..f87433d54 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -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 함수 테스트 ── diff --git a/confluence-mdx/tests/test_reverse_sync_sidecar.py b/confluence-mdx/tests/test_reverse_sync_sidecar.py index 2949f99a0..677c7f703 100644 --- a/confluence-mdx/tests/test_reverse_sync_sidecar.py +++ b/confluence-mdx/tests/test_reverse_sync_sidecar.py @@ -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 ────────────────────────────────── @@ -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 = ( + '' + ':purple_circle:' + '1f7e3' + '🟣' + '#F4F5F7' + '' + '

9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요. ' + '' + ' )

' + '
' + '
' + ) + mdx = ( + '---\ntitle: Test\n---\n\n' + 'import { Callout } from \'nextra/components\'\n\n' + '\n' + '**9.12.0 이후부터 적용되는 신규 메뉴 가이드입니다. (클릭해서 확대해서 보세요.** 🔎 )\n' + '\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}" + # ── 실제 테스트 케이스 기반 통합 테스트 ───────────────────────