diff --git a/confluence-mdx/bin/reverse_sync/list_patcher.py b/confluence-mdx/bin/reverse_sync/list_patcher.py index d70e3daff..95dcec60f 100644 --- a/confluence-mdx/bin/reverse_sync/list_patcher.py +++ b/confluence-mdx/bin/reverse_sync/list_patcher.py @@ -8,71 +8,16 @@ from reverse_sync.lost_info_patcher import apply_lost_info from reverse_sync.mdx_to_xhtml_inline import mdx_block_to_inner_xhtml from reverse_sync.text_transfer import transfer_text_changes -from mdx_to_storage.inline import convert_inline -from text_utils import normalize_mdx_to_plain, collapse_ws, strip_list_marker, strip_for_compare +from text_utils import normalize_mdx_to_plain -def _resolve_child_mapping( - old_plain: str, - parent_mapping: BlockMapping, - id_to_mapping: Dict[str, BlockMapping], -) -> Optional[BlockMapping]: - """Parent mapping의 children 중에서 old_plain과 일치하는 child를 찾는다.""" - old_norm = collapse_ws(old_plain) - if not old_norm: - return None - - # 1차: collapse_ws 완전 일치 - for child_id in parent_mapping.children: - child = id_to_mapping.get(child_id) - if child and collapse_ws(child.xhtml_plain_text) == old_norm: - return child - - # 2차: 공백 무시 완전 일치 - old_nospace = re.sub(r'\s+', '', old_norm) - 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 child_nospace == old_nospace: - return child - - # 3차: 리스트 마커 제거 후 비교 (XHTML child가 "- text" 형식인 경우) - 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) - child_unmarked = strip_list_marker(child_nospace) - if child_unmarked != child_nospace and old_nospace == child_unmarked: - return child - - # 4차: MDX 쪽 리스트 마커 제거 후 비교 - old_unmarked = strip_list_marker(old_nospace) - if old_unmarked != old_nospace: - 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 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 +def _get_ordered_list_start(content: str) -> Optional[int]: + """MDX 리스트 콘텐츠에서 첫 번째 순서 번호를 반환한다.""" + for line in content.split('\n'): + m = re.match(r'^\s*(\d+)\.\s+', line) + if m: + return int(m.group(1)) return None @@ -158,10 +103,13 @@ def _regenerate_list_from_parent( 'old_plain_text': parent.xhtml_plain_text, 'new_inner_xhtml': new_inner, } + + #
구조를 생성할 수 없으므로 containing 전략 사용 - if change.old_block.type == 'callout': - return ('containing', mapping) - return ('direct', child) - # 블록 텍스트가 parent에 포함되는지 확인 - _old_ns = strip_for_compare(old_plain) - _map_ns = strip_for_compare(mapping.xhtml_plain_text) - if _old_ns and _map_ns and _old_ns not in _map_ns: - if change.old_block.type == 'list': - return ('list', mapping) - return ('containing', mapping) - if mapping is None: - # 폴백: 텍스트 포함 검색으로 containing mapping 찾기 - containing = _find_containing_mapping(old_plain, mappings, used_ids) - if containing is not None: - return ('containing', containing) if change.old_block.type == 'list': return ('list', None) if is_markdown_table(change.old_block.content): return ('table', None) return ('skip', None) - # 매핑 텍스트에 old_plain이 포함되지 않으면 더 나은 매핑 찾기 - if not mapping.children: - old_nospace = strip_for_compare(old_plain) - map_nospace = strip_for_compare(mapping.xhtml_plain_text) - if old_nospace and map_nospace and old_nospace not in map_nospace: - better = _find_containing_mapping(old_plain, mappings, used_ids) - if better is not None: - return ('containing', better) - if change.old_block.type == 'list': - return ('list', mapping) + # callout 블록은 항상 containing 전략 사용 + # (_convert_callout_inner가
구조를 생성할 수 없으므로)
+ if change.old_block.type == 'callout':
+ return ('containing', mapping)
+
+ # Parent mapping이 children을 가지면 containing 전략으로 위임
+ if mapping.children:
+ if change.old_block.type == 'list':
+ return ('list', mapping)
+ return ('containing', mapping)
# list 블록은 list 전략 사용 (direct 교체 시 는 MDX의 empty 줄에 해당하며 content 블록이 아님)
+ if not xm.xhtml_plain_text.strip() and xm.type == 'paragraph':
entries.append({
'xhtml_xpath': xm.xhtml_xpath,
'xhtml_type': xm.type,
@@ -403,44 +470,42 @@ def generate_sidecar_mapping(
})
continue
- # 현재 MDX 블록과 텍스트 비교
- matched_at = _find_text_match(
- xhtml_plain, mdx_content_indices, mdx_plains, mdx_ptr, LOOKAHEAD)
-
- if matched_at is not None:
- # 매치 위치까지 MDX 포인터 이동
- mdx_ptr = matched_at
- mdx_idx = mdx_content_indices[mdx_ptr]
- matched_indices = [mdx_idx]
- mdx_ptr += 1
-
- # children이 있으면 후속 MDX 블록도 이 XHTML 매핑에 대응
- # 단, 다음 top-level XHTML 매핑의 텍스트와 겹치지 않는 범위에서만
- if xm.children:
- num_children = _count_child_mdx_blocks(
- xm, mdx_content_indices, mdx_plains,
- mdx_ptr, top_mappings, collapse_ws,
- )
- for _ in range(num_children):
- if mdx_ptr < len(mdx_content_indices):
- matched_indices.append(mdx_content_indices[mdx_ptr])
- mdx_ptr += 1
-
+ if mdx_ptr >= len(mdx_content_indexed):
entries.append({
'xhtml_xpath': xm.xhtml_xpath,
'xhtml_type': xm.type,
- 'mdx_blocks': matched_indices,
+ 'mdx_blocks': [],
})
+ continue
+
+ mdx_idx, mdx_block = mdx_content_indexed[mdx_ptr]
+
+ if _type_compatible(xm.type, mdx_block.type):
+ entry: Dict[str, Any] = {
+ 'xhtml_xpath': xm.xhtml_xpath,
+ 'xhtml_type': xm.type,
+ 'mdx_blocks': [mdx_idx],
+ 'mdx_line_start': mdx_block.line_start,
+ 'mdx_line_end': mdx_block.line_end,
+ }
+ # compound block (callout/details 등): children 정렬
+ if xm.children and mdx_block.children:
+ child_entries = _align_children(xm, mdx_block, id_to_mapping)
+ if child_entries:
+ entry['children'] = child_entries
+ entries.append(entry)
+ mdx_ptr += 1
else:
- # 텍스트 매치 실패 — MDX 대응 없음 (image, toc 등)
+ # 타입 불일치 → XHTML 블록이 MDX 출력을 생성하지 않음
+ # MDX 포인터는 유지 (MDX 블록이 다음 XHTML과 매칭될 수 있음)
entries.append({
'xhtml_xpath': xm.xhtml_xpath,
'xhtml_type': xm.type,
'mdx_blocks': [],
})
- mapping_data = {
- 'version': 2,
+ mapping_data: Dict[str, Any] = {
+ 'version': 3,
'source_page_id': page_id,
'mdx_file': 'page.mdx',
'mappings': entries,
@@ -450,121 +515,6 @@ def generate_sidecar_mapping(
return yaml.dump(mapping_data, allow_unicode=True, default_flow_style=False)
-def _count_child_mdx_blocks(
- xm,
- mdx_content_indices,
- mdx_plains,
- mdx_ptr,
- top_mappings,
- collapse_ws,
-) -> int:
- """children이 있는 XHTML 매핑에 대응하는 MDX 블록 수를 결정한다.
-
- 다음 비빈 top-level XHTML 매핑의 텍스트와 겹치지 않는 범위에서
- 후속 MDX 블록을 소비한다.
- """
- current_idx = None
- for i, tm in enumerate(top_mappings):
- if tm is xm:
- current_idx = i
- break
- if current_idx is None:
- return len(xm.children)
-
- next_sigs = []
- for tm in top_mappings[current_idx + 1:]:
- sig = _strip_all_ws(collapse_ws(tm.xhtml_plain_text))
- if sig:
- next_sigs.append(sig)
- if len(next_sigs) >= 3:
- break
-
- if not next_sigs:
- return len(xm.children)
-
- count = 0
- max_scan = len(xm.children) + 5
- for offset in range(max_scan):
- ptr = mdx_ptr + offset
- if ptr >= len(mdx_content_indices):
- break
- mdx_idx = mdx_content_indices[ptr]
- mdx_sig = _strip_all_ws(mdx_plains[mdx_idx])
- if not mdx_sig:
- count += 1
- continue
-
- hit = False
- for ns in next_sigs:
- if mdx_sig == ns:
- hit = True
- break
- if len(ns) >= 10 and ns[:50] in mdx_sig:
- hit = True
- break
- if len(mdx_sig) >= 10 and mdx_sig[:50] in ns:
- hit = True
- break
- if hit:
- break
- count += 1
-
- return count
-
-
-def _strip_all_ws(text: str) -> str:
- """모든 공백 문자를 제거한다. 텍스트 서명 비교용."""
- return ''.join(text.split())
-
-
-def _find_text_match(
- xhtml_plain: str,
- mdx_content_indices: List[int],
- mdx_plains: Dict[int, str],
- start_ptr: int,
- lookahead: int,
-) -> Optional[int]:
- """XHTML plain text와 일치하는 MDX 블록을 전방 탐색한다."""
- end_ptr = min(start_ptr + lookahead, len(mdx_content_indices))
- xhtml_sig = _strip_all_ws(xhtml_plain)
-
- for ptr in range(start_ptr, end_ptr):
- mdx_idx = mdx_content_indices[ptr]
- if xhtml_plain == mdx_plains[mdx_idx]:
- return ptr
-
- for ptr in range(start_ptr, end_ptr):
- mdx_idx = mdx_content_indices[ptr]
- mdx_sig = _strip_all_ws(mdx_plains[mdx_idx])
- if xhtml_sig == mdx_sig:
- return ptr
-
- if len(xhtml_sig) >= 10:
- prefix = xhtml_sig[:50]
- for ptr in range(start_ptr, end_ptr):
- mdx_idx = mdx_content_indices[ptr]
- mdx_sig = _strip_all_ws(mdx_plains[mdx_idx])
- if not mdx_sig:
- continue
- 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
-
def find_mapping_by_sidecar(
mdx_block_index: int,
diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py
index bfb1f10c1..9391ec503 100644
--- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py
+++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py
@@ -73,6 +73,7 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str:
if current_plain_with_emoticons.strip() != old_text.strip():
continue
_replace_inner_html(element, patch['new_inner_xhtml'])
+ # 이후 해당 서버에 접속 가능한 계정이 여러 개라면, Account 선택창이 열립니다. 사용하려는
+ xhtml_text: 이후 해당 서버에 접속 가능한 계정이 여러 개라면, Account 선택창이 열립니다. 사용하려는
계정을 선택하고 필요 시 비밀번호를 입력한 뒤, QueryPie Agent를 설치하면, DataGrip, DBeaver와 같은 SQL Client, iTerm/SecureCRT와 같은 SSH Client, Lens, k9s와 같은 3rd Party 애플리케이션을 사용할 수 있습니다. 1. QueryPie 로그인 후 우측 상단 프로필을 클릭하여 QueryPie Web > 프로필 메뉴 2. QueryPie Agent Downloads 팝업창이 실행되면 Step 1에서 사용 중인 PC 운영체제에 맞는 설치 파일을 다운로드한 후 Step 3에 있는 QueryPie URL을 복사해 둡니다. QueryPie Web > Agent Downloads 팝업창 QueryPie Agent는 Mac, Windows, Linux OS를 지원합니다. 3. 다운로드받은 QueryPie Agent 설치 프로그램을 실행하여 설치를 완료합니다. Mac OS 설치 프로그램 4. 설치 완료된 QueryPie Agent를 실행합니다. QueryPie Host 입력란에 미리 복사해뒀던 QueryPie URL을 입력하고 Agent > QueryPie Host 입력 1. Agent 앱 내 로그인 화면에서 2. 웹 브라우저가 열리면, 로그인 페이지에서 인증정보를 입력하고, QueryPie Web > Agent Login Page 3. 로그인을 성공하면 아래와 같이 로그인 성공 화면이 표시되며 이후 Agent로 돌아갑니다. QueryPie Web > Agent Login Success Page 4. Agent 열기를 명시적으로 수행하여 인증정보를 Agent로 전달합니다. Chrome - Agent App 열기 모달 1. 로그인이 정상적으로 완료되면 Agent 앱 내 Databases 탭에서 권한 있는 커넥션들의 접속 정보를 확인할 수 있습니다. Agent > DB Connection Information 2. 위의 접속 정보를 3rd Party 클라이언트에 입력하면 DB 커넥션 접속이 가능합니다. 3rd Party Client를 이용한 DB 커넥션 접속 로그인이 정상적으로 완료되면 Agent 앱 내 Server 탭에서 권한 있는 서버를 확인할 수 있습니다. 사용자 프로필 영역 하단의 Default 역할 선택시, Workflow > Server Access Request 요청에 의해 할당받은 서버 권한을 사용합니다. Agent > Server > Select a Role 역할이 두 개 이상이라면, Agent 로그인 후 Server 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다. 접속할 서버를 우클릭 후 Open Connection with 메뉴를 선택하여, 사용하려는 터미널 툴을 선택합니다. Agent > Server > Open Connection with 이후 해당 서버에 접속 가능한 계정이 여러 개라면, Account 선택창이 열립니다. 사용하려는 계정을 선택하고 필요 시 비밀번호를 입력한 뒤, Agent > Server > Open New Session Seamless SSH란 기존 터미널 사용성을 그대로 유지하면서 QueryPie를 통해 서버에 접속할 수 있는 기능입니다. 다음음의 방법으로 .ssh 폴더에 config 파일을 생성하여 손쉽게 seamless SSH 설정이 가능합니다. 1) 터미널을 열고, .ssh 폴더로 이동합니다. 2) ssh 폴더에서 config 파일을 생성하기 위해 vi 에디터를 엽니다. QueryPie Agent를 설치하면, DataGrip, DBeaver와 같은 SQL Client, iTerm/SecureCRT와 같은 SSH Client, Lens, k9s와 같은 3rd Party 애플리케이션을 사용할 수 있습니다. 1. QueryPie 로그인 후 우측 상단 프로필을 클릭하여 QueryPie Web > 프로필 메뉴 2. QueryPie Agent Downloads 팝업창이 실행되면 Step 1에서 사용 중인 PC 운영체제에 맞는 설치 파일을 다운로드한 후 Step 3에 있는 QueryPie URL을 복사해 둡니다. QueryPie Web > Agent Downloads 팝업창 QueryPie Agent는 Mac, Windows, Linux OS를 지원합니다. 3. 다운로드받은 QueryPie Agent 설치 프로그램을 실행하여 설치를 완료합니다. Mac OS 설치 프로그램 4. 설치 완료된 QueryPie Agent를 실행합니다. QueryPie Host 입력란에 미리 복사해뒀던 QueryPie URL을 입력하고 Agent > QueryPie Host 입력 1. Agent 앱 내 로그인 화면에서 2. 웹 브라우저가 열리면, 로그인 페이지에서 인증정보를 입력하고, QueryPie Web > Agent Login Page 3. 로그인을 성공하면 아래와 같이 로그인 성공 화면이 표시되며 이후 Agent로 돌아갑니다. QueryPie Web > Agent Login Success Page 4. Agent 열기를 명시적으로 수행하여 인증정보를 Agent로 전달합니다. Chrome - Agent App 열기 모달 1. 로그인이 정상적으로 완료되면 Agent 앱 내 Databases 탭에서 권한 있는 커넥션들의 접속 정보를 확인할 수 있습니다. Agent > DB Connection Information 2. 위의 접속 정보를 3rd Party 클라이언트에 입력하면 DB 커넥션 접속이 가능합니다. 3rd Party Client를 이용한 DB 커넥션 접속 로그인이 정상적으로 완료되면 Agent 앱 내 Server 탭에서 권한 있는 서버를 확인할 수 있습니다. 사용자 프로필 영역 하단의 Default 역할 선택시, Workflow > Server Access Request 요청에 의해 할당받은 서버 권한을 사용합니다. Agent > Server > Select a Role 역할이 두 개 이상이라면, Agent 로그인 후 Server 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다. 접속할 서버를 우클릭 후 Open Connection with 메뉴를 선택하여, 사용하려는 터미널 툴을 선택합니다. Agent > Server > Open Connection with 이후 해당 서버에 접속 가능한 계정이 여러 개라면, Account 선택창이 열립니다. 사용하려는 계정을 선택하고 필요 시 비밀번호를 입력한 뒤, Agent > Server > Open New Session Seamless SSH란 기존 터미널 사용성을 그대로 유지하면서 QueryPie를 통해 서버에 접속할 수 있는 기능입니다. 다음음의 방법으로 .ssh 폴더에 config 파일을 생성하여 손쉽게 seamless SSH 설정이 가능합니다. 1) 터미널을 열고, .ssh 폴더로 이동합니다. 2) ssh 폴더에서 config 파일을 생성하기 위해 vi 에디터를 엽니다. config 파일 작성 시 Seamless SSH 설정하고자 하는 서버마다 서버 이름, URL, 포트를 입력함으로써 서버를 특정합니다. 서버 간에 URL, 포트가 겹치지 않는 경우 아래와 같이 입력하여도 접속이 가능합니다.
앞 공백 제거 (_normalize_br_space)
- lenient 모드에서는 이 정규화 이후 _apply_normalizations가 추가로 적용된다.
+
+def _apply_minimal_normalizations(text: str) -> str:
+ """항상 적용하는 최소 정규화 (forward converter 특성에 의한 차이만 처리).
+
+ trailing whitespace는 여기서 처리하지 않는다.
+ 엄격 모드에서는 trailing whitespace 차이가 실패해야 하므로,
+ _apply_normalizations (lenient 전용)에서만 처리한다.
"""
text = _normalize_consecutive_spaces_in_text(text)
text = _normalize_br_space(text)
text = _normalize_table_cell_padding(text)
text = _strip_first_heading(text)
text = text.lstrip('\n')
+ text = _normalize_blank_line_after_blockquote(text)
text = _normalize_trailing_blank_lines(text)
return text
diff --git a/confluence-mdx/bin/reverse_sync/sidecar.py b/confluence-mdx/bin/reverse_sync/sidecar.py
index 55a22a5de..750295df6 100644
--- a/confluence-mdx/bin/reverse_sync/sidecar.py
+++ b/confluence-mdx/bin/reverse_sync/sidecar.py
@@ -5,8 +5,8 @@
build_sidecar, verify_sidecar_integrity,
write_sidecar, load_sidecar, sha256_text
-Mapping lookup (mapping.yaml 기반):
- SidecarEntry, load_sidecar_mapping, build_mdx_to_sidecar_index,
+Mapping lookup (mapping.yaml v3 기반):
+ SidecarChildEntry, SidecarEntry, load_sidecar_mapping, build_mdx_to_sidecar_index,
build_xpath_to_mapping, generate_sidecar_mapping, find_mapping_by_sidecar
"""
@@ -244,14 +244,104 @@ def load_sidecar(path: Path) -> RoundtripSidecar:
# ---------------------------------------------------------------------------
-# Mapping lookup — mapping.yaml 로드 및 인덱스 구축
+# Mapping lookup — mapping.yaml v3 로드 및 인덱스 구축
# ---------------------------------------------------------------------------
+# XHTML record_mapping type → 호환 MDX parse_mdx type
+_TYPE_COMPAT: Dict[str, frozenset] = {
+ 'heading': frozenset({'heading'}),
+ 'paragraph': frozenset({'paragraph'}),
+ 'list': frozenset({'list'}),
+ 'code': frozenset({'code_block'}),
+ 'table': frozenset({'table', 'html_block'}),
+ 'html_block': frozenset({'callout', 'details', 'html_block', 'blockquote',
+ 'figure', 'badge', 'hr'}),
+}
+
+# MDX 출력을 생성하지 않는 XHTML 매크로 이름
+_SKIP_MACROS = frozenset({'toc', 'children'})
+
+
+def _should_skip_xhtml(xm: Any) -> bool:
+ """toc, children 등 MDX 출력이 없는 XHTML 매크로를 판별한다."""
+ xpath = xm.xhtml_xpath
+ for skip_name in _SKIP_MACROS:
+ if xpath.startswith(f'macro-{skip_name}'):
+ return True
+ return False
+
+
+def _type_compatible(xhtml_type: str, mdx_type: str) -> bool:
+ """XHTML 타입과 MDX 블록 타입이 호환되는지 확인한다."""
+ return mdx_type in _TYPE_COMPAT.get(xhtml_type, frozenset())
+
+
+
+def _align_children(
+ xm: Any,
+ mdx_block: Any,
+ id_to_mapping: Dict[str, Any],
+) -> List[Dict]:
+ """XHTML children과 MDX Block.children을 타입 기반 순차 정렬한다.
+
+ 각 XHTML child에 대응하는 MDX child의 절대 line range를 계산하여
+ children entry 목록을 반환한다.
+
+ 절대 line = parent_mdx_block.line_start + child.line_start
+ (callout의 경우 첫 줄이 속성 변경 적용
if 'ol_start' in patch and isinstance(element, Tag) and element.name == 'ol':
new_start = patch['ol_start']
if new_start == 1:
@@ -90,6 +91,7 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str:
if current_plain_with_emoticons.strip() != old_text.strip():
continue
_apply_text_changes(element, old_text, new_text)
+ #
속성 변경 적용 (텍스트 전이 경로)
if 'ol_start' in patch and isinstance(element, Tag) and element.name == 'ol':
new_start = patch['ol_start']
if new_start == 1:
diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py
index acce3b6fd..cf80407e7 100755
--- a/confluence-mdx/bin/reverse_sync_cli.py
+++ b/confluence-mdx/bin/reverse_sync_cli.py
@@ -309,7 +309,7 @@ def run_verify(
# Step 3.5: Sidecar mapping 생성 + 인덱스 구축
from reverse_sync.sidecar import (
- SidecarEntry, generate_sidecar_mapping,
+ SidecarEntry, SidecarChildEntry, generate_sidecar_mapping,
build_mdx_to_sidecar_index, build_xpath_to_mapping,
)
# forward converter가 생성한 mapping.yaml에서 lost_info를 보존
@@ -323,14 +323,25 @@ def run_verify(
(var_dir / 'mapping.yaml').write_text(sidecar_yaml)
sidecar_data = yaml.safe_load(sidecar_yaml) or {}
page_lost_info = sidecar_data.get('lost_info', {})
- sidecar_entries = [
- SidecarEntry(
+ sidecar_entries = []
+ for item in sidecar_data.get('mappings', []):
+ children = [
+ SidecarChildEntry(
+ xhtml_xpath=ch.get('xhtml_xpath', ''),
+ xhtml_block_id=ch.get('xhtml_block_id', ''),
+ mdx_line_start=ch.get('mdx_line_start', 0),
+ mdx_line_end=ch.get('mdx_line_end', 0),
+ )
+ for ch in item.get('children', [])
+ ]
+ sidecar_entries.append(SidecarEntry(
xhtml_xpath=item['xhtml_xpath'],
xhtml_type=item.get('xhtml_type', ''),
mdx_blocks=item.get('mdx_blocks', []),
- )
- for item in sidecar_data.get('mappings', [])
- ]
+ mdx_line_start=item.get('mdx_line_start', 0),
+ mdx_line_end=item.get('mdx_line_end', 0),
+ children=children,
+ ))
mdx_to_sidecar = build_mdx_to_sidecar_index(sidecar_entries)
xpath_to_mapping = build_xpath_to_mapping(original_mappings)
@@ -358,6 +369,13 @@ def run_verify(
yaml.dump(verify_mapping_data, allow_unicode=True, default_flow_style=False))
# Step 6: Forward 변환 → verify.mdx 저장
+ # xhtml_path 옆에 있는 page.v1.yaml을 var/
+
**not bold**"
+ def test_code_with_less_than(self):
+ """inline code 내 < 는 <로 escape 처리된다."""
+ assert convert_inline("`a < b`") == "a < b"
+
+ def test_code_with_ampersand(self):
+ """inline code 내 & 는 &로 escape 처리된다."""
+ assert convert_inline("`a && b`") == "a && b"
+
def test_br_preserved(self):
"""
태그는
로 정규화되어 유지."""
result = convert_inline("line1
line2")
@@ -88,6 +96,11 @@ def test_paragraph_with_code(self):
result = mdx_block_to_inner_xhtml("`User Attribute` 설정\n", "paragraph")
assert result == "User Attribute 설정"
+ def test_paragraph_code_with_html_special_chars(self):
+ """reverse-sync 경로: inline code 내 HTML 특수문자는 escape 처리된다."""
+ result = mdx_block_to_inner_xhtml("`a < b`\n", "paragraph")
+ assert result == "a < b"
+
def test_paragraph_with_bold(self):
result = mdx_block_to_inner_xhtml("**bold** text\n", "paragraph")
assert result == "bold text"
diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py
index fb7d91191..442e3ff91 100644
--- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py
+++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py
@@ -1,9 +1,8 @@
"""patch_builder 유닛 테스트.
-기존 _find_containing_mapping 테스트 + build_patches 6개 분기 경로
+build_patches 분기 경로
+ helper 함수 (is_markdown_table, split_table_rows, normalize_table_row,
-split_list_items, _resolve_child_mapping,
-build_table_row_patches, build_list_item_patches) 테스트.
+split_list_items, build_table_row_patches, build_list_item_patches) 테스트.
"""
from reverse_sync.block_diff import BlockChange
from reverse_sync.mapping_recorder import BlockMapping
@@ -11,7 +10,6 @@
from reverse_sync.sidecar import SidecarEntry
from text_utils import normalize_mdx_to_plain
from reverse_sync.patch_builder import (
- _find_containing_mapping,
_flush_containing_changes,
_resolve_mapping_for_change,
build_patches,
@@ -31,7 +29,6 @@
from reverse_sync.list_patcher import (
build_list_item_patches,
split_list_items,
- _resolve_child_mapping,
)
@@ -82,130 +79,6 @@ def _make_sidecar(xpath: str, mdx_blocks: list) -> SidecarEntry:
return SidecarEntry(xhtml_xpath=xpath, xhtml_type='paragraph', mdx_blocks=mdx_blocks)
-# ── _find_containing_mapping (기존 7개 테스트 유지) ──
-
-
-class TestFindContainingMapping:
- def test_finds_mapping_containing_old_plain(self):
- m1 = _make_mapping('m1', 'Command Audit : Server내 수행 명령어 이력')
- m2 = _make_mapping('m2', 'General User Access History Activity Logs Servers Command Audit : Server내 수행 명령어 이력 Account Lock History')
- mappings = [m1, m2]
- result = _find_containing_mapping(
- 'Command Audit : Server내 수행 명령어 이력', mappings, set())
- assert result is m1
-
- def test_skips_used_ids(self):
- m1 = _make_mapping('m1', 'Command Audit : Server내 수행 명령어 이력')
- m2 = _make_mapping('m2', 'General Servers Command Audit : Server내 수행 명령어 이력 Account Lock')
- mappings = [m1, m2]
- used = {'m1'}
- result = _find_containing_mapping(
- 'Command Audit : Server내 수행 명령어 이력', mappings, used)
- assert result is m2
-
- def test_returns_none_for_short_text(self):
- m1 = _make_mapping('m1', 'hello world foo bar')
- result = _find_containing_mapping('abc', [m1], set())
- assert result is None
-
- def test_returns_none_for_empty_text(self):
- m1 = _make_mapping('m1', 'hello world foo bar')
- result = _find_containing_mapping('', [m1], set())
- assert result is None
-
- def test_returns_none_when_no_mapping_contains_text(self):
- m1 = _make_mapping('m1', 'completely different text here')
- result = _find_containing_mapping(
- 'Command Audit : Server내 수행 명령어 이력', [m1], set())
- assert result is None
-
- def test_ignores_whitespace_differences(self):
- m1 = _make_mapping('m1', 'Command Audit : Server내 수행 명령어 이력')
- result = _find_containing_mapping(
- 'Command Audit : Server내 수행 명령어 이력', [m1], set())
- assert result is m1
-
- def test_ignores_invisible_unicode_chars(self):
- m1 = _make_mapping(
- 'm1',
- 'Account Lock History\u3164 : QueryPie\u200b사용자별 서버 접속 계정')
- result = _find_containing_mapping(
- 'Account Lock History : QueryPie사용자별 서버 접속 계정',
- [m1], set())
- assert result is m1
-
-
-# ── _resolve_child_mapping ──
-
-
-class TestResolveChildMapping:
- def test_exact_match_first_pass(self):
- child = _make_mapping('c1', 'child text')
- parent = _make_mapping('p1', 'parent text', children=['c1'])
- id_map = {'c1': child, 'p1': parent}
- result = _resolve_child_mapping('child text', parent, id_map)
- assert result is child
-
- def test_whitespace_collapsed_match(self):
- child = _make_mapping('c1', 'child text here')
- parent = _make_mapping('p1', 'parent', children=['c1'])
- id_map = {'c1': child, 'p1': parent}
- result = _resolve_child_mapping('child text here', parent, id_map)
- assert result is child
-
- def test_nospace_match(self):
- child = _make_mapping('c1', 'child text')
- parent = _make_mapping('p1', 'parent', children=['c1'])
- id_map = {'c1': child, 'p1': parent}
- # collapse_ws doesn't match, but nospace does
- result = _resolve_child_mapping('childtext', parent, id_map)
- assert result is child
-
- def test_xhtml_list_marker_stripped(self):
- child = _make_mapping('c1', '- item text')
- parent = _make_mapping('p1', 'parent', children=['c1'])
- id_map = {'c1': child, 'p1': parent}
- result = _resolve_child_mapping('item text', parent, id_map)
- assert result is child
-
- def test_mdx_list_marker_stripped(self):
- child = _make_mapping('c1', 'item text')
- parent = _make_mapping('p1', 'parent', children=['c1'])
- id_map = {'c1': child, 'p1': parent}
- result = _resolve_child_mapping('- item text', parent, id_map)
- assert result is child
-
- def test_returns_none_when_no_match(self):
- child = _make_mapping('c1', 'completely different')
- parent = _make_mapping('p1', 'parent', children=['c1'])
- id_map = {'c1': child, 'p1': parent}
- result = _resolve_child_mapping('no match text here', parent, id_map)
- assert result is None
-
- def test_returns_none_for_empty_text(self):
- parent = _make_mapping('p1', 'parent', children=['c1'])
- child = _make_mapping('c1', 'child')
- id_map = {'c1': child, 'p1': parent}
- result = _resolve_child_mapping('', parent, id_map)
- assert result is None
-
- def test_missing_child_id(self):
- parent = _make_mapping('p1', 'parent', children=['missing'])
- id_map = {'p1': parent}
- 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 함수 테스트 ──
@@ -297,15 +170,15 @@ def _setup_sidecar(self, xpath: str, mdx_idx: int):
mdx_to_sidecar = {mdx_idx: entry}
return mdx_to_sidecar
- # Path 1: sidecar 매칭 → children 있음 → child 해석 성공 → 직접 패치
- def test_path1_sidecar_match_child_resolved(self):
+ # Path 1: sidecar 매칭 → list type + children → list 전략 → 전체 리스트 재생성
+ def test_path1_sidecar_match_list_with_children_regenerates(self):
child = _make_mapping('c1', 'child text', xpath='li[1]')
parent = _make_mapping('p1', 'parent text child text more', xpath='ul[1]',
type_='list', children=['c1'])
mappings = [parent, child]
xpath_to_mapping = {m.xhtml_xpath: m for m in mappings}
- change = _make_change(0, 'child text', 'updated child')
+ change = _make_change(0, '- child text', '- updated child', type_='list')
mdx_to_sidecar = self._setup_sidecar('ul[1]', 0)
patches = build_patches(
@@ -313,8 +186,8 @@ def test_path1_sidecar_match_child_resolved(self):
mappings, mdx_to_sidecar, xpath_to_mapping)
assert len(patches) == 1
- assert patches[0]['xhtml_xpath'] == 'li[1]'
- assert 'updated child' in patches[0]['new_inner_xhtml']
+ assert patches[0]['xhtml_xpath'] == 'ul[1]'
+ assert 'new_inner_xhtml' in patches[0]
# Path 2: sidecar 매칭 → children 있음 → child 해석 실패
# → 텍스트 불일치 → list 분리 (item 수 불일치 → inner XHTML 재생성)
@@ -360,21 +233,20 @@ def test_path3_sidecar_child_fail_containing_block(self):
assert len(patches) == 1
assert patches[0]['xhtml_xpath'] == 'div[1]'
- # Path 4: sidecar 미스 → 텍스트 포함 검색 → containing block
+ # Path 4: sidecar 미스 → skip (텍스트 포함 검색 폴백 제거됨)
def test_path4_sidecar_miss_text_search_containing(self):
m1 = _make_mapping('m1', 'this mapping contains the search text here')
mappings = [m1]
xpath_to_mapping = {m.xhtml_xpath: m for m in mappings}
change = _make_change(0, 'search text', 'replaced text')
- mdx_to_sidecar = {} # 빈 sidecar → sidecar 미스
+ mdx_to_sidecar = {} # 빈 sidecar → sidecar 미스 → skip
patches = build_patches(
[change], [change.old_block], [change.new_block],
mappings, mdx_to_sidecar, xpath_to_mapping)
- assert len(patches) == 1
- assert patches[0]['xhtml_xpath'] == m1.xhtml_xpath
+ assert len(patches) == 0
# Path 5: sidecar 미스 → list/table 분리
def test_path5_sidecar_miss_table_split(self):
@@ -391,9 +263,9 @@ def test_path5_sidecar_miss_table_split(self):
assert patches == []
- # Path 6: sidecar 매칭 → children 없음 → 텍스트 불일치 → 재매핑
+ # Path 6: sidecar 매칭 → children 없음 → sidecar를 신뢰하여 직접 매핑
def test_path6_sidecar_match_text_mismatch_remapping(self):
- # sidecar 매핑이 있지만 텍스트가 포함되지 않음 → better 매핑 찾기
+ # sidecar가 p[1]을 가리키면 텍스트 불일치와 무관하게 p[1]로 직접 패치
wrong = _make_mapping('wrong', 'completely wrong mapping', xpath='p[1]')
better = _make_mapping('better', 'contains the target text here', xpath='p[2]')
mappings = [wrong, better]
@@ -407,7 +279,7 @@ def test_path6_sidecar_match_text_mismatch_remapping(self):
mappings, mdx_to_sidecar, xpath_to_mapping)
assert len(patches) == 1
- assert patches[0]['xhtml_xpath'] == 'p[2]'
+ assert patches[0]['xhtml_xpath'] == 'p[1]'
# 직접 매칭 + text_transfer 사용
def test_direct_match_with_transfer(self):
@@ -499,7 +371,7 @@ def test_direct_text_only_change_uses_inner_xhtml_patch(self):
assert 'new_inner_xhtml' in patches[0]
assert 'new_plain_text' not in patches[0]
- # 여러 변경이 동일 containing block에 그룹화
+ # sidecar 미스 → skip (텍스트 포함 검색 폴백 제거됨)
def test_multiple_changes_grouped_to_containing(self):
container = _make_mapping(
'm1', 'first part and second part', xpath='p[1]')
@@ -508,7 +380,7 @@ def test_multiple_changes_grouped_to_containing(self):
change1 = _make_change(0, 'first part', 'first UPDATED')
change2 = _make_change(1, 'second part', 'second UPDATED')
- mdx_to_sidecar = {} # sidecar 미스 → containing 검색
+ mdx_to_sidecar = {} # sidecar 미스 → skip
patches = build_patches(
[change1, change2],
@@ -516,8 +388,7 @@ def test_multiple_changes_grouped_to_containing(self):
[change1.new_block, change2.new_block],
mappings, mdx_to_sidecar, xpath_to_mapping)
- assert len(patches) == 1
- assert 'UPDATED' in patches[0]['new_plain_text']
+ assert len(patches) == 0
def test_direct_heading_inline_code_added(self):
"""heading에서 backtick 추가 시 new_inner_xhtml 패치를 생성한다."""
@@ -1073,6 +944,36 @@ def test_insert_frontmatter_skipped(self):
assert len(patches) == 0
+class TestHeadingWhitespaceSkip:
+ """헤딩의 공백 차이만 있는 변경은 XHTML에 전파되지 않는다."""
+
+ def test_heading_extra_space_in_prefix_skipped(self):
+ """### foo → ### foo (공백만 다름) 변경은 패치를 생성하지 않는다."""
+ mapping = _make_mapping('m1', 'Hello', xpath='h3[1]', type_='heading')
+ sidecar = _make_sidecar('h3[1]', [0])
+ mdx_to_sidecar = {0: sidecar}
+ xpath_to_mapping = {'h3[1]': mapping}
+
+ change = _make_change(0, '### Hello', '### Hello', type_='heading')
+ patches = build_patches(
+ [change], [change.old_block], [change.new_block],
+ [mapping], mdx_to_sidecar, xpath_to_mapping)
+ assert patches == []
+
+ def test_heading_real_text_change_produces_patch(self):
+ """실제 텍스트가 변경된 헤딩은 패치를 생성한다."""
+ mapping = _make_mapping('m1', 'Hello', xpath='h3[1]', type_='heading')
+ sidecar = _make_sidecar('h3[1]', [0])
+ mdx_to_sidecar = {0: sidecar}
+ xpath_to_mapping = {'h3[1]': mapping}
+
+ change = _make_change(0, '### Hello', '### World', type_='heading')
+ patches = build_patches(
+ [change], [change.old_block], [change.new_block],
+ [mapping], mdx_to_sidecar, xpath_to_mapping)
+ assert len(patches) == 1
+
+
# ── _flush_containing_changes ──
@@ -1143,7 +1044,7 @@ class TestResolveMappingForChange:
"""_resolve_mapping_for_change 매핑 해석 함수 테스트."""
def _make_context(self, mappings=None, mdx_to_sidecar=None,
- xpath_to_mapping=None, id_to_mapping=None):
+ xpath_to_mapping=None):
"""공통 컨텍스트 dict를 구성한다."""
mappings = mappings or []
return {
@@ -1151,7 +1052,6 @@ def _make_context(self, mappings=None, mdx_to_sidecar=None,
'used_ids': set(),
'mdx_to_sidecar': mdx_to_sidecar or {},
'xpath_to_mapping': xpath_to_mapping or {},
- 'id_to_mapping': id_to_mapping or {m.block_id: m for m in mappings},
}
def _old_plain(self, change):
@@ -1181,7 +1081,7 @@ def test_sidecar_direct_match_returns_direct(self):
assert strategy == 'direct'
assert mapping.block_id == 'b1'
- def test_sidecar_match_with_children_resolved_returns_direct(self):
+ def test_sidecar_match_with_children_returns_containing(self):
child = _make_mapping('c1', 'child text', xpath='li[1]')
parent = _make_mapping('p1', 'parent text', xpath='ul[1]',
children=['c1'])
@@ -1194,8 +1094,8 @@ def test_sidecar_match_with_children_resolved_returns_direct(self):
change = _make_change(0, 'child text', 'new child')
strategy, mapping = _resolve_mapping_for_change(
change, self._old_plain(change), **ctx)
- assert strategy == 'direct'
- assert mapping.block_id == 'c1'
+ assert strategy == 'containing'
+ assert mapping.block_id == 'p1'
def test_no_sidecar_list_type_returns_list(self):
change = _make_change(0, '- item1\n- item2', '- item1\n- changed', type_='list')
@@ -1212,14 +1112,14 @@ def test_no_sidecar_table_type_returns_table(self):
change, self._old_plain(change), **ctx)
assert strategy == 'table'
- def test_no_sidecar_containing_match_returns_containing(self):
+ def test_no_sidecar_containing_match_returns_skip(self):
m = _make_mapping('b1', 'hello world full text here', xpath='div[1]')
change = _make_change(0, 'hello world', 'hi world')
ctx = self._make_context(mappings=[m])
strategy, mapping = _resolve_mapping_for_change(
change, self._old_plain(change), **ctx)
- assert strategy == 'containing'
- assert mapping.block_id == 'b1'
+ assert strategy == 'skip'
+ assert mapping is None
# ── Inline format 변경 감지 테스트 ──
diff --git a/confluence-mdx/tests/test_reverse_sync_sidecar.py b/confluence-mdx/tests/test_reverse_sync_sidecar.py
index 677c7f703..b860f2002 100644
--- a/confluence-mdx/tests/test_reverse_sync_sidecar.py
+++ b/confluence-mdx/tests/test_reverse_sync_sidecar.py
@@ -7,7 +7,6 @@
- xhtml_xpath → BlockMapping 인덱스 구축
- 2-hop 조회: MDX index → SidecarEntry → BlockMapping
- XHTML + MDX로부터 mapping.yaml 생성 (generate_sidecar_mapping)
- - 텍스트 매칭 내부 함수들 (_find_text_match, _strip_all_ws)
"""
import pytest
import yaml
@@ -21,13 +20,12 @@
sha256_text,
write_sidecar,
SidecarEntry,
+ SidecarChildEntry,
load_sidecar_mapping,
build_mdx_to_sidecar_index,
build_xpath_to_mapping,
find_mapping_by_sidecar,
generate_sidecar_mapping,
- _find_text_match,
- _strip_all_ws,
)
from reverse_sync.mapping_recorder import BlockMapping
@@ -230,104 +228,6 @@ def test_xpath_not_in_mapping_index(self):
assert result is None
-# ── _strip_all_ws ─────────────────────────────────────────────
-
-class TestStripAllWs:
- def test_basic(self):
- assert _strip_all_ws('hello world') == 'helloworld'
-
- def test_tabs_and_newlines(self):
- assert _strip_all_ws('a\tb\nc d') == 'abcd'
-
- def test_empty(self):
- assert _strip_all_ws('') == ''
-
- def test_only_whitespace(self):
- assert _strip_all_ws(' \t\n ') == ''
-
-
-# ── _find_text_match ──────────────────────────────────────────
-
-class TestFindTextMatch:
- def test_exact_match_at_start(self):
- """1차: collapse_ws 후 완전 일치."""
- indices = [0, 1, 2]
- plains = {0: 'Hello World', 1: 'Foo Bar', 2: 'Baz'}
- result = _find_text_match('Hello World', indices, plains, 0, 5)
- assert result == 0
-
- def test_exact_match_at_offset(self):
- indices = [0, 1, 2]
- plains = {0: 'AAA', 1: 'BBB', 2: 'CCC'}
- result = _find_text_match('BBB', indices, plains, 0, 5)
- assert result == 1
-
- def test_whitespace_insensitive_match(self):
- """2차: 공백 무시 완전 일치."""
- indices = [0, 1]
- plains = {0: 'Hello World', 1: 'Foo'}
- # xhtml_plain 'HelloWorld' vs mdx 'Hello World' → strip_all_ws 비교
- result = _find_text_match('Hello World', indices, plains, 0, 5)
- # 1차에서 실패하지만 2차 공백무시에서 매칭
- assert result is not None
-
- def test_prefix_match(self):
- """3차: prefix 포함 매칭."""
- indices = [0]
- long_text = 'A' * 60
- plains = {0: long_text + ' extra'}
- # xhtml_plain의 앞 50자가 mdx에 포함
- result = _find_text_match(long_text, indices, plains, 0, 5)
- assert result is not None
-
- def test_no_match(self):
- indices = [0, 1]
- plains = {0: 'AAA', 1: 'BBB'}
- result = _find_text_match('CCC', indices, plains, 0, 5)
- assert result is None
-
- def test_start_ptr_skips_earlier(self):
- """start_ptr 이전의 블록은 검색하지 않는다."""
- indices = [0, 1, 2]
- plains = {0: 'Target', 1: 'Other', 2: 'More'}
- result = _find_text_match('Target', indices, plains, 1, 5)
- assert result is None # index 0은 검색 범위 밖
-
- def test_lookahead_limit(self):
- """lookahead 범위를 초과하면 매칭하지 않는다."""
- indices = [0, 1, 2, 3, 4, 5]
- plains = {i: f'block-{i}' for i in range(6)}
- result = _find_text_match('block-5', indices, plains, 0, 3)
- assert result is None # lookahead=3이므로 index 0,1,2만 검색
-
- def test_short_text_no_prefix_match(self):
- """10자 미만의 짧은 텍스트는 prefix 매칭을 시도하지 않는다."""
- indices = [0]
- plains = {0: 'AB extra'}
- 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 ──────────────────────────────────
@@ -345,7 +245,7 @@ def test_simple_heading_paragraph(self):
result = generate_sidecar_mapping(xhtml, mdx, '12345')
data = yaml.safe_load(result)
- assert data['version'] == 2
+ assert data['version'] == 3
assert data['source_page_id'] == '12345'
assert len(data['mappings']) >= 2
@@ -356,6 +256,9 @@ def test_simple_heading_paragraph(self):
e for e in data['mappings'] if e['xhtml_type'] == 'paragraph')
assert len(heading_entry['mdx_blocks']) >= 1
assert len(para_entry['mdx_blocks']) >= 1
+ # v3: line range 필드 포함
+ assert heading_entry.get('mdx_line_start', 0) > 0
+ assert para_entry.get('mdx_line_start', 0) > 0
def test_empty_xhtml_block_gets_empty_mdx_blocks(self):
"""이미지 등 텍스트가 없는 XHTML 블록은 빈 mdx_blocks를 받는다."""
@@ -417,7 +320,7 @@ def test_multiple_paragraphs_sequential_matching(self):
assert all_indices == sorted(all_indices)
def test_callout_macro_with_children(self):
- """Callout 매크로 (ac:structured-macro) → 컨테이너 + children 매핑."""
+ """Callout 매크로 (ac:structured-macro) → 단일 MDX callout 블록에 매핑, children 포함."""
xhtml = (
'
xhtml_xpath: ul[3]
- block_id: html_block-43
@@ -841,6 +841,6 @@ blocks:
xhtml_plain_text: ''
xhtml_text: ''
xhtml_xpath: p[36]
-created_at: '2026-02-26T09:02:54.021713+00:00'
+created_at: '2026-03-09T16:43:39.195734+00:00'
page_id: '544112828'
source_xhtml: patched.xhtml
diff --git a/confluence-mdx/tests/testcases/544112828/expected.reverse-sync.patched.xhtml b/confluence-mdx/tests/testcases/544112828/expected.reverse-sync.patched.xhtml
index 83e0254b0..fe5d3e13f 100644
--- a/confluence-mdx/tests/testcases/544112828/expected.reverse-sync.patched.xhtml
+++ b/confluence-mdx/tests/testcases/544112828/expected.reverse-sync.patched.xhtml
@@ -1,4 +1,4 @@
-OK 버튼을 클릭하여 세션을 엽니다.Overview
에이전트 앱 다운로드 및 실행하기
Agent Download 버튼을 클릭합니다. Next 버튼을 클릭하면 로그인 화면으로 진입하게 됩니다. QueryPie Agent에 로그인하기
Login 버튼을 클릭합니다.Continue 버튼을 클릭합니다.에이전트로 데이터베이스 접속하기
접속할 커넥션에 할당된 Port 를 클릭하면, 해당 커넥션의 Proxy Credentials 정보를 확인할 수 있습니다.에이전트를 통한 서버 접속
1. 서버 역할 선택하기
Role 버튼을 클릭하여 원하는 역할을 고르고 OK 버튼을 클릭하세요.
2. OK 버튼을 클릭하여 세션을 엽니다.3. Seamless SSH 설정하기
Overview
에이전트 앱 다운로드 및 실행하기
Agent Download 버튼을 클릭합니다. Next 버튼을 클릭하면 로그인 화면으로 진입하게 됩니다. QueryPie Agent에 로그인하기
Login 버튼을 클릭합니다.Continue 버튼을 클릭합니다.에이전트로 데이터베이스 접속하기
접속할 커넥션에 할당된 Port 를 클릭하면, 해당 커넥션의 Proxy Credentials 정보를 확인할 수 있습니다.에이전트를 통한 서버 접속
1. 서버 역할 선택하기
Role 버튼을 클릭하여 원하는 역할을 고르고 OK 버튼을 클릭하세요.
2. OK 버튼을 클릭하여 세션을 엽니다.3. Seamless SSH 설정하기