diff --git a/confluence-mdx/bin/reverse_sync/inline_detector.py b/confluence-mdx/bin/reverse_sync/inline_detector.py new file mode 100644 index 000000000..d54781832 --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/inline_detector.py @@ -0,0 +1,103 @@ +"""인라인 포맷 변경 감지 — MDX content의 inline 마커 변경을 감지한다.""" +import re + +from text_utils import collapse_ws + + +# ── Inline format 변경 감지 ── + +_INLINE_CODE_RE = re.compile(r'`([^`]+)`') +_INLINE_BOLD_RE = re.compile(r'\*\*(.+?)\*\*') +_INLINE_ITALIC_RE = re.compile(r'(? list: + """MDX content에서 inline 포맷 마커를 위치순으로 추출한다.""" + markers = [] + for m in _INLINE_CODE_RE.finditer(content): + markers.append(('code', m.start(), m.group(1))) + for m in _INLINE_BOLD_RE.finditer(content): + markers.append(('bold', m.start(), m.group(1))) + for m in _INLINE_ITALIC_RE.finditer(content): + markers.append(('italic', m.start(), m.group(1))) + for m in _INLINE_LINK_RE.finditer(content): + markers.append(('link', m.start(), m.group(1), m.group(2))) + return sorted(markers, key=lambda x: x[1]) + + +def _strip_positions(markers: list) -> list: + """마커 리스트에서 위치(index 1)를 제거하여 type+content만 비교 가능하게 한다.""" + return [(m[0],) + m[2:] for m in markers] + + +def _extract_marker_spans(content: str) -> list: + """MDX content에서 inline 포맷 마커의 (start, end) 위치 범위를 추출한다.""" + spans = [] + for m in _INLINE_CODE_RE.finditer(content): + spans.append((m.start(), m.end())) + for m in _INLINE_BOLD_RE.finditer(content): + spans.append((m.start(), m.end())) + for m in _INLINE_ITALIC_RE.finditer(content): + spans.append((m.start(), m.end())) + for m in _INLINE_LINK_RE.finditer(content): + spans.append((m.start(), m.end())) + return sorted(spans) + + +def _extract_between_marker_texts(content: str) -> list: + """연속된 inline 마커 사이의 텍스트를 추출한다.""" + spans = _extract_marker_spans(content) + between = [] + for i in range(len(spans) - 1): + between.append(content[spans[i][1]:spans[i + 1][0]]) + return between + + +def has_inline_format_change(old_content: str, new_content: str) -> bool: + """old/new MDX 콘텐츠의 inline 포맷 마커가 다른지 감지한다. + + 마커 type/content 변경뿐 아니라, 연속된 마커 사이의 텍스트가 + 변경된 경우도 inline 변경으로 판단한다 (XHTML code 요소 경계에서 + text-only 패치가 올바르게 동작하지 않기 때문). + """ + old_markers = _strip_positions(_extract_inline_markers(old_content)) + new_markers = _strip_positions(_extract_inline_markers(new_content)) + if old_markers != new_markers: + return True + + # 마커가 있을 때, 연속된 마커 사이 텍스트 변경 감지 + if old_markers: + old_between = _extract_between_marker_texts(old_content) + new_between = _extract_between_marker_texts(new_content) + if ([collapse_ws(s) for s in old_between] + != [collapse_ws(s) for s in new_between]): + return True + + return False + + +def has_inline_boundary_change(old_content: str, new_content: str) -> bool: + """inline 마커의 경계 이동을 감지한다. + + 마커 type 추가/제거, 마커 간 텍스트 변경(경계 이동)을 감지한다. + 마커 내부 content만 변경된 경우는 무시한다 (text-only 패치로 처리 가능). + flat list의 전체 리스트 재생성 판단에 사용한다. + (has_inline_format_change보다 보수적 — 이미지 등 XHTML 고유 요소 보존) + """ + old_markers = _extract_inline_markers(old_content) + new_markers = _extract_inline_markers(new_content) + old_types = [m[0] for m in old_markers] + new_types = [m[0] for m in new_markers] + if old_types != new_types: + return True + + # 마커가 있을 때, 연속된 마커 사이 텍스트 변경 감지 (경계 이동) + if old_markers: + old_between = _extract_between_marker_texts(old_content) + new_between = _extract_between_marker_texts(new_content) + if ([collapse_ws(s) for s in old_between] + != [collapse_ws(s) for s in new_between]): + return True + + return False diff --git a/confluence-mdx/bin/reverse_sync/list_patcher.py b/confluence-mdx/bin/reverse_sync/list_patcher.py new file mode 100644 index 000000000..2214275d4 --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/list_patcher.py @@ -0,0 +1,225 @@ +"""리스트 블록 패치 — MDX list 블록 변경을 XHTML에 패치한다.""" +import re +from typing import Dict, List, Optional + +from reverse_sync.block_diff import BlockChange +from reverse_sync.mapping_recorder import BlockMapping +from reverse_sync.sidecar import SidecarEntry, find_mapping_by_sidecar +from reverse_sync.inline_detector import has_inline_format_change, has_inline_boundary_change +from reverse_sync.mdx_to_xhtml_inline import mdx_block_to_inner_xhtml +from mdx_to_storage.inline import convert_inline +from text_utils import normalize_mdx_to_plain, collapse_ws, strip_list_marker, strip_for_compare + + +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 + + return None + + +def split_list_items(content: str) -> List[str]: + """리스트 블록 content를 개별 항목으로 분리한다.""" + items = [] + current: List[str] = [] + for line in content.split('\n'): + stripped = line.strip() + if not stripped: + if current: + items.append('\n'.join(current)) + current = [] + continue + # 새 리스트 항목 시작 + if (re.match(r'^[-*+]\s+', stripped) or re.match(r'^\d+\.\s+', stripped)) and current: + items.append('\n'.join(current)) + current = [line] + else: + current.append(line) + if current: + items.append('\n'.join(current)) + return items + + +def extract_list_marker_prefix(text: str) -> str: + """텍스트에서 선행 리스트 마커 prefix를 추출한다.""" + m = re.match(r'^([-*+]\s+|\d+\.\s+)', text) + return m.group(0) if m else '' + + +def build_list_item_patches( + change: BlockChange, + mappings: List[BlockMapping], + used_ids: 'set | None' = None, + mdx_to_sidecar: Optional[Dict[int, SidecarEntry]] = None, + xpath_to_mapping: Optional[Dict[str, 'BlockMapping']] = None, + id_to_mapping: Optional[Dict[str, BlockMapping]] = None, +) -> List[Dict[str, str]]: + """리스트 블록의 각 항목을 개별 매핑과 대조하여 패치를 생성한다. + + sidecar에서 얻은 parent mapping의 children을 통해 child 매핑을 해석한다. + """ + from reverse_sync.patch_builder import _find_containing_mapping, _flush_containing_changes + from reverse_sync.text_transfer import transfer_text_changes + + old_items = split_list_items(change.old_block.content) + new_items = split_list_items(change.new_block.content) + if len(old_items) != len(new_items): + # 항목 수가 다르면 (삭제/추가) 전체 리스트 inner XHTML 재생성 + parent = None + if mdx_to_sidecar is not None and xpath_to_mapping is not None: + parent = find_mapping_by_sidecar( + change.index, mdx_to_sidecar, xpath_to_mapping) + if parent is not None: + new_inner = mdx_block_to_inner_xhtml( + change.new_block.content, change.new_block.type) + return [{ + 'xhtml_xpath': parent.xhtml_xpath, + 'old_plain_text': parent.xhtml_plain_text, + 'new_inner_xhtml': new_inner, + }] + return [] + + # sidecar에서 parent mapping 획득 + parent_mapping = None + if mdx_to_sidecar is not None and xpath_to_mapping is not None: + parent_mapping = find_mapping_by_sidecar( + change.index, mdx_to_sidecar, xpath_to_mapping) + + patches = [] + # 매칭 실패한 항목을 상위 블록 기준으로 그룹화 + containing_changes: dict = {} # block_id → (mapping, [(old_plain, new_plain)]) + # flat list에서 inline 포맷 변경이 감지되면 전체 리스트 inner XHTML 재생성 + _flat_inline_change = False + for old_item, new_item in zip(old_items, new_items): + if old_item == new_item: + continue + old_plain = normalize_mdx_to_plain(old_item, 'list') + + # parent mapping의 children에서 child 해석 시도 + mapping = None + if parent_mapping is not None and parent_mapping.children and id_to_mapping is not None: + mapping = _resolve_child_mapping( + old_plain, parent_mapping, id_to_mapping) + + if mapping is not None: + if used_ids is not None: + used_ids.add(mapping.block_id) + + # inline 포맷 변경 감지 → new_inner_xhtml 패치 + if has_inline_format_change(old_item, new_item): + new_item_text = re.sub(r'^[-*+]\s+', '', new_item.strip()) + new_item_text = re.sub(r'^\d+\.\s+', '', new_item_text) + new_inner = convert_inline(new_item_text) + patches.append({ + 'xhtml_xpath': mapping.xhtml_xpath, + 'old_plain_text': mapping.xhtml_plain_text, + 'new_inner_xhtml': new_inner, + }) + else: + new_plain = normalize_mdx_to_plain(new_item, 'list') + + xhtml_text = mapping.xhtml_plain_text + prefix = extract_list_marker_prefix(xhtml_text) + if prefix and collapse_ws(old_plain) != collapse_ws(xhtml_text): + xhtml_body = xhtml_text[len(prefix):] + # XHTML body가 이미 new_plain과 일치하면 건너뛰기 + if collapse_ws(new_plain) == collapse_ws(xhtml_body): + continue + if collapse_ws(old_plain) != collapse_ws(xhtml_body): + new_plain = transfer_text_changes( + old_plain, new_plain, xhtml_body) + new_plain = prefix + new_plain + elif collapse_ws(old_plain) != collapse_ws(xhtml_text): + # XHTML이 이미 new_plain과 일치하면 건너뛰기 + if collapse_ws(new_plain) == collapse_ws(xhtml_text): + continue + new_plain = transfer_text_changes( + old_plain, new_plain, xhtml_text) + + patches.append({ + 'xhtml_xpath': mapping.xhtml_xpath, + 'old_plain_text': mapping.xhtml_plain_text, + 'new_plain_text': new_plain, + }) + else: + # child 매칭 실패: inline 마커 경계 이동 감지 + # has_inline_boundary_change: type 추가/제거 및 마커 간 텍스트 변경만 감지 + # (마커 내부 content만 변경된 경우는 무시하여 이미지 등 XHTML 고유 요소 보존) + if has_inline_boundary_change(old_item, new_item): + _flat_inline_change = True + + # parent 또는 텍스트 포함 매핑을 containing block으로 사용 + container = parent_mapping + if container is not None and used_ids is not None: + # parent 텍스트에 항목이 포함되지 않으면 더 나은 매핑 찾기 + _item_ns = strip_for_compare(old_plain) + _cont_ns = strip_for_compare(container.xhtml_plain_text) + if _item_ns and _cont_ns and _item_ns not in _cont_ns: + better = _find_containing_mapping( + old_plain, mappings, used_ids) + if better is not None: + container = better + elif used_ids is not None: + container = _find_containing_mapping(old_plain, mappings, used_ids) + if container is not None: + new_plain = normalize_mdx_to_plain(new_item, 'list') + bid = container.block_id + if bid not in containing_changes: + containing_changes[bid] = (container, []) + containing_changes[bid][1].append((old_plain, new_plain)) + + # flat list에서 inline 포맷 변경이 감지된 경우: + # containing block 텍스트 패치 대신 전체 리스트 inner XHTML 재생성 + if _flat_inline_change and parent_mapping is not None: + containing_changes.pop(parent_mapping.block_id, None) + new_inner = mdx_block_to_inner_xhtml( + change.new_block.content, change.new_block.type) + patches.append({ + 'xhtml_xpath': parent_mapping.xhtml_xpath, + 'old_plain_text': parent_mapping.xhtml_plain_text, + 'new_inner_xhtml': new_inner, + }) + + # 상위 블록에 대한 그룹화된 변경 적용 + patches.extend(_flush_containing_changes(containing_changes, used_ids)) + return patches diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 26e6d232f..53389f6d3 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -1,122 +1,33 @@ """패치 빌더 — MDX diff 변경과 XHTML 매핑을 결합하여 XHTML 패치를 생성.""" -import html as html_module import re from typing import Dict, List, Optional -from reverse_sync.block_diff import BlockChange +from reverse_sync.block_diff import BlockChange, NON_CONTENT_TYPES from reverse_sync.mapping_recorder import BlockMapping from mdx_to_storage.parser import Block as MdxBlock from text_utils import ( - normalize_mdx_to_plain, collapse_ws, strip_list_marker, + normalize_mdx_to_plain, collapse_ws, strip_for_compare, ) from reverse_sync.text_transfer import transfer_text_changes from reverse_sync.sidecar import find_mapping_by_sidecar, SidecarEntry from reverse_sync.lost_info_patcher import apply_lost_info from reverse_sync.mdx_to_xhtml_inline import mdx_block_to_xhtml_element, mdx_block_to_inner_xhtml -from mdx_to_storage.inline import convert_inline - - -# ── Inline format 변경 감지 ── - -_INLINE_CODE_RE = re.compile(r'`([^`]+)`') -_INLINE_BOLD_RE = re.compile(r'\*\*(.+?)\*\*') -_INLINE_ITALIC_RE = re.compile(r'(? list: - """MDX content에서 inline 포맷 마커를 위치순으로 추출한다.""" - markers = [] - for m in _INLINE_CODE_RE.finditer(content): - markers.append(('code', m.start(), m.group(1))) - for m in _INLINE_BOLD_RE.finditer(content): - markers.append(('bold', m.start(), m.group(1))) - for m in _INLINE_ITALIC_RE.finditer(content): - markers.append(('italic', m.start(), m.group(1))) - for m in _INLINE_LINK_RE.finditer(content): - markers.append(('link', m.start(), m.group(1), m.group(2))) - return sorted(markers, key=lambda x: x[1]) - - -def _strip_positions(markers: list) -> list: - """마커 리스트에서 위치(index 1)를 제거하여 type+content만 비교 가능하게 한다.""" - return [(m[0],) + m[2:] for m in markers] - - -def _extract_marker_spans(content: str) -> list: - """MDX content에서 inline 포맷 마커의 (start, end) 위치 범위를 추출한다.""" - spans = [] - for m in _INLINE_CODE_RE.finditer(content): - spans.append((m.start(), m.end())) - for m in _INLINE_BOLD_RE.finditer(content): - spans.append((m.start(), m.end())) - for m in _INLINE_ITALIC_RE.finditer(content): - spans.append((m.start(), m.end())) - for m in _INLINE_LINK_RE.finditer(content): - spans.append((m.start(), m.end())) - return sorted(spans) - - -def _extract_between_marker_texts(content: str) -> list: - """연속된 inline 마커 사이의 텍스트를 추출한다.""" - spans = _extract_marker_spans(content) - between = [] - for i in range(len(spans) - 1): - between.append(content[spans[i][1]:spans[i + 1][0]]) - return between - - -def has_inline_format_change(old_content: str, new_content: str) -> bool: - """old/new MDX 콘텐츠의 inline 포맷 마커가 다른지 감지한다. - - 마커 type/content 변경뿐 아니라, 연속된 마커 사이의 텍스트가 - 변경된 경우도 inline 변경으로 판단한다 (XHTML code 요소 경계에서 - text-only 패치가 올바르게 동작하지 않기 때문). - """ - old_markers = _strip_positions(_extract_inline_markers(old_content)) - new_markers = _strip_positions(_extract_inline_markers(new_content)) - if old_markers != new_markers: - return True - - # 마커가 있을 때, 연속된 마커 사이 텍스트 변경 감지 - if old_markers: - old_between = _extract_between_marker_texts(old_content) - new_between = _extract_between_marker_texts(new_content) - if ([collapse_ws(s) for s in old_between] - != [collapse_ws(s) for s in new_between]): - return True - - return False - - -def has_inline_boundary_change(old_content: str, new_content: str) -> bool: - """inline 마커의 경계 이동을 감지한다. - - 마커 type 추가/제거, 마커 간 텍스트 변경(경계 이동)을 감지한다. - 마커 내부 content만 변경된 경우는 무시한다 (text-only 패치로 처리 가능). - flat list의 전체 리스트 재생성 판단에 사용한다. - (has_inline_format_change보다 보수적 — 이미지 등 XHTML 고유 요소 보존) - """ - old_markers = _extract_inline_markers(old_content) - new_markers = _extract_inline_markers(new_content) - old_types = [m[0] for m in old_markers] - new_types = [m[0] for m in new_markers] - if old_types != new_types: - return True - - # 마커가 있을 때, 연속된 마커 사이 텍스트 변경 감지 (경계 이동) - if old_markers: - old_between = _extract_between_marker_texts(old_content) - new_between = _extract_between_marker_texts(new_content) - if ([collapse_ws(s) for s in old_between] - != [collapse_ws(s) for s in new_between]): - return True - - return False - - -NON_CONTENT_TYPES = frozenset(('empty', 'frontmatter', 'import_statement')) +from reverse_sync.inline_detector import ( + has_inline_format_change, + has_inline_boundary_change, + _extract_inline_markers, +) +from reverse_sync.list_patcher import ( + build_list_item_patches, + _resolve_child_mapping, +) +from reverse_sync.table_patcher import ( + build_table_row_patches, + split_table_rows, + normalize_table_row, + is_markdown_table, +) _BLOCK_MARKER_RE = re.compile(r'#{1,6}|\d+\.') @@ -365,307 +276,6 @@ def _mark_used(block_id: str, m: BlockMapping): return patches -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 - - return None - - -def is_markdown_table(content: str) -> bool: - """Content가 Markdown table 형식인지 판별한다.""" - lines = [l.strip() for l in content.strip().split('\n') if l.strip()] - if len(lines) < 2: - return False - pipe_lines = sum(1 for l in lines if l.startswith('|') and l.endswith('|')) - return pipe_lines >= 2 - - -def split_table_rows(content: str) -> List[str]: - """Markdown table content를 데이터 행(non-separator) 목록으로 분리한다.""" - rows = [] - for line in content.strip().split('\n'): - s = line.strip() - if not s: - continue - # separator 행 건너뛰기 (| --- | --- | ...) - if re.match(r'^\|[\s\-:|]+\|$', s): - continue - if s.startswith('|') and s.endswith('|'): - rows.append(s) - return rows - - -def normalize_table_row(row: str) -> str: - """Markdown table row를 XHTML plain text 대응 형태로 변환한다.""" - cells = [c.strip() for c in row.split('|')[1:-1]] - parts = [] - for cell in cells: - s = cell - s = re.sub(r'\*\*(.+?)\*\*', r'\1', s) - s = re.sub(r'`([^`]+)`', r'\1', s) - s = re.sub(r'(?(.*?)', - lambda m: m.group(2) + m.group(1).capitalize(), - s, - ) - s = re.sub(r'<[^>]+/?>', '', s) - s = html_module.unescape(s) - s = s.strip() - if s: - parts.append(s) - return ' '.join(parts) - - -def build_table_row_patches( - change: BlockChange, - mappings: List[BlockMapping], - used_ids: 'set | None' = None, - mdx_to_sidecar: Optional[Dict[int, SidecarEntry]] = None, - xpath_to_mapping: Optional[Dict[str, 'BlockMapping']] = None, -) -> List[Dict[str, str]]: - """Markdown table 블록의 변경된 행을 XHTML table에 패치한다. - - sidecar를 통해 parent table mapping을 찾아 containing block으로 사용한다. - """ - old_rows = split_table_rows(change.old_block.content) - new_rows = split_table_rows(change.new_block.content) - if len(old_rows) != len(new_rows): - return [] - - # sidecar에서 parent mapping 획득 - container = None - if mdx_to_sidecar is not None and xpath_to_mapping is not None: - container = find_mapping_by_sidecar( - change.index, mdx_to_sidecar, xpath_to_mapping) - - if container is None: - return [] - - patches = [] - containing_changes: dict = {} # block_id → (mapping, [(old_plain, new_plain)]) - for old_row, new_row in zip(old_rows, new_rows): - if old_row == new_row: - continue - old_plain = normalize_table_row(old_row) - new_plain = normalize_table_row(new_row) - if not old_plain or old_plain == new_plain: - continue - bid = container.block_id - if bid not in containing_changes: - containing_changes[bid] = (container, []) - containing_changes[bid][1].append((old_plain, new_plain)) - - patches.extend(_flush_containing_changes(containing_changes, used_ids)) - return patches - - -def split_list_items(content: str) -> List[str]: - """리스트 블록 content를 개별 항목으로 분리한다.""" - items = [] - current: List[str] = [] - for line in content.split('\n'): - stripped = line.strip() - if not stripped: - if current: - items.append('\n'.join(current)) - current = [] - continue - # 새 리스트 항목 시작 - if (re.match(r'^[-*+]\s+', stripped) or re.match(r'^\d+\.\s+', stripped)) and current: - items.append('\n'.join(current)) - current = [line] - else: - current.append(line) - if current: - items.append('\n'.join(current)) - return items - - -def build_list_item_patches( - change: BlockChange, - mappings: List[BlockMapping], - used_ids: 'set | None' = None, - mdx_to_sidecar: Optional[Dict[int, SidecarEntry]] = None, - xpath_to_mapping: Optional[Dict[str, 'BlockMapping']] = None, - id_to_mapping: Optional[Dict[str, BlockMapping]] = None, -) -> List[Dict[str, str]]: - """리스트 블록의 각 항목을 개별 매핑과 대조하여 패치를 생성한다. - - sidecar에서 얻은 parent mapping의 children을 통해 child 매핑을 해석한다. - """ - old_items = split_list_items(change.old_block.content) - new_items = split_list_items(change.new_block.content) - if len(old_items) != len(new_items): - # 항목 수가 다르면 (삭제/추가) 전체 리스트 inner XHTML 재생성 - parent = None - if mdx_to_sidecar is not None and xpath_to_mapping is not None: - parent = find_mapping_by_sidecar( - change.index, mdx_to_sidecar, xpath_to_mapping) - if parent is not None: - new_inner = mdx_block_to_inner_xhtml( - change.new_block.content, change.new_block.type) - return [{ - 'xhtml_xpath': parent.xhtml_xpath, - 'old_plain_text': parent.xhtml_plain_text, - 'new_inner_xhtml': new_inner, - }] - return [] - - # sidecar에서 parent mapping 획득 - parent_mapping = None - if mdx_to_sidecar is not None and xpath_to_mapping is not None: - parent_mapping = find_mapping_by_sidecar( - change.index, mdx_to_sidecar, xpath_to_mapping) - - patches = [] - # 매칭 실패한 항목을 상위 블록 기준으로 그룹화 - containing_changes: dict = {} # block_id → (mapping, [(old_plain, new_plain)]) - # flat list에서 inline 포맷 변경이 감지되면 전체 리스트 inner XHTML 재생성 - _flat_inline_change = False - for old_item, new_item in zip(old_items, new_items): - if old_item == new_item: - continue - old_plain = normalize_mdx_to_plain(old_item, 'list') - - # parent mapping의 children에서 child 해석 시도 - mapping = None - if parent_mapping is not None and parent_mapping.children and id_to_mapping is not None: - mapping = _resolve_child_mapping( - old_plain, parent_mapping, id_to_mapping) - - if mapping is not None: - if used_ids is not None: - used_ids.add(mapping.block_id) - - # inline 포맷 변경 감지 → new_inner_xhtml 패치 - if has_inline_format_change(old_item, new_item): - new_item_text = re.sub(r'^[-*+]\s+', '', new_item.strip()) - new_item_text = re.sub(r'^\d+\.\s+', '', new_item_text) - new_inner = convert_inline(new_item_text) - patches.append({ - 'xhtml_xpath': mapping.xhtml_xpath, - 'old_plain_text': mapping.xhtml_plain_text, - 'new_inner_xhtml': new_inner, - }) - else: - new_plain = normalize_mdx_to_plain(new_item, 'list') - - xhtml_text = mapping.xhtml_plain_text - prefix = extract_list_marker_prefix(xhtml_text) - if prefix and collapse_ws(old_plain) != collapse_ws(xhtml_text): - xhtml_body = xhtml_text[len(prefix):] - # XHTML body가 이미 new_plain과 일치하면 건너뛰기 - if collapse_ws(new_plain) == collapse_ws(xhtml_body): - continue - if collapse_ws(old_plain) != collapse_ws(xhtml_body): - new_plain = transfer_text_changes( - old_plain, new_plain, xhtml_body) - new_plain = prefix + new_plain - elif collapse_ws(old_plain) != collapse_ws(xhtml_text): - # XHTML이 이미 new_plain과 일치하면 건너뛰기 - if collapse_ws(new_plain) == collapse_ws(xhtml_text): - continue - new_plain = transfer_text_changes( - old_plain, new_plain, xhtml_text) - - patches.append({ - 'xhtml_xpath': mapping.xhtml_xpath, - 'old_plain_text': mapping.xhtml_plain_text, - 'new_plain_text': new_plain, - }) - else: - # child 매칭 실패: inline 마커 경계 이동 감지 - # has_inline_boundary_change: type 추가/제거 및 마커 간 텍스트 변경만 감지 - # (마커 내부 content만 변경된 경우는 무시하여 이미지 등 XHTML 고유 요소 보존) - if has_inline_boundary_change(old_item, new_item): - _flat_inline_change = True - - # parent 또는 텍스트 포함 매핑을 containing block으로 사용 - container = parent_mapping - if container is not None and used_ids is not None: - # parent 텍스트에 항목이 포함되지 않으면 더 나은 매핑 찾기 - _item_ns = strip_for_compare(old_plain) - _cont_ns = strip_for_compare(container.xhtml_plain_text) - if _item_ns and _cont_ns and _item_ns not in _cont_ns: - better = _find_containing_mapping( - old_plain, mappings, used_ids) - if better is not None: - container = better - elif used_ids is not None: - container = _find_containing_mapping(old_plain, mappings, used_ids) - if container is not None: - new_plain = normalize_mdx_to_plain(new_item, 'list') - bid = container.block_id - if bid not in containing_changes: - containing_changes[bid] = (container, []) - containing_changes[bid][1].append((old_plain, new_plain)) - - # flat list에서 inline 포맷 변경이 감지된 경우: - # containing block 텍스트 패치 대신 전체 리스트 inner XHTML 재생성 - if _flat_inline_change and parent_mapping is not None: - containing_changes.pop(parent_mapping.block_id, None) - new_inner = mdx_block_to_inner_xhtml( - change.new_block.content, change.new_block.type) - patches.append({ - 'xhtml_xpath': parent_mapping.xhtml_xpath, - 'old_plain_text': parent_mapping.xhtml_plain_text, - 'new_inner_xhtml': new_inner, - }) - - # 상위 블록에 대한 그룹화된 변경 적용 - patches.extend(_flush_containing_changes(containing_changes, used_ids)) - return patches - - -def extract_list_marker_prefix(text: str) -> str: - """텍스트에서 선행 리스트 마커 prefix를 추출한다.""" - m = re.match(r'^([-*+]\s+|\d+\.\s+)', text) - return m.group(0) if m else '' - - def _build_delete_patch( change: BlockChange, mdx_to_sidecar: Dict[int, SidecarEntry], diff --git a/confluence-mdx/bin/reverse_sync/rehydrator.py b/confluence-mdx/bin/reverse_sync/rehydrator.py index 091bff910..d9e2dc8af 100644 --- a/confluence-mdx/bin/reverse_sync/rehydrator.py +++ b/confluence-mdx/bin/reverse_sync/rehydrator.py @@ -18,13 +18,12 @@ from .lost_info_patcher import apply_lost_info from .mdx_block_parser import MdxBlock, parse_mdx_blocks +from .block_diff import NON_CONTENT_TYPES as _NON_CONTENT from .sidecar import RoundtripSidecar, SidecarBlock, load_sidecar, sha256_text FallbackRenderer = Callable[[str], str] -_NON_CONTENT = frozenset(("empty", "frontmatter", "import_statement")) - _MDX_TYPE_TO_PARSER_TYPE = { "heading": "heading", "paragraph": "paragraph", diff --git a/confluence-mdx/bin/reverse_sync/sidecar.py b/confluence-mdx/bin/reverse_sync/sidecar.py index d7edb1ae2..19aec37fc 100644 --- a/confluence-mdx/bin/reverse_sync/sidecar.py +++ b/confluence-mdx/bin/reverse_sync/sidecar.py @@ -21,6 +21,7 @@ import yaml from reverse_sync.mapping_recorder import BlockMapping +from reverse_sync.block_diff import NON_CONTENT_TYPES # --------------------------------------------------------------------------- @@ -180,8 +181,7 @@ def build_sidecar( top_mappings = [m for m in xhtml_mappings if m.block_id not in child_ids] # 3. MDX content 블록 (frontmatter, empty, import 제외) - NON_CONTENT = frozenset(("empty", "frontmatter", "import_statement")) - mdx_content_blocks = [b for b in mdx_blocks if b.type not in NON_CONTENT] + mdx_content_blocks = [b for b in mdx_blocks if b.type not in NON_CONTENT_TYPES] # 4. Block 생성 — fragment와 top-level mapping을 정렬 sidecar_blocks: List[SidecarBlock] = [] @@ -328,12 +328,10 @@ def generate_sidecar_mapping( mdx_blocks = parse_mdx_blocks(mdx) # 콘텐츠 블록만 필터 (frontmatter, empty, import 제외) - NON_CONTENT = frozenset(('empty', 'frontmatter', 'import_statement')) - entries = [] mdx_content_indices = [ i for i, b in enumerate(mdx_blocks) - if b.type not in NON_CONTENT + if b.type not in NON_CONTENT_TYPES ] # Empty MDX 블록 중 콘텐츠 영역 내의 것만 매핑 대상으로 추적 # (frontmatter/import 사이의 빈 줄은 XHTML에 대응하지 않음) diff --git a/confluence-mdx/bin/reverse_sync/table_patcher.py b/confluence-mdx/bin/reverse_sync/table_patcher.py new file mode 100644 index 000000000..157ff379f --- /dev/null +++ b/confluence-mdx/bin/reverse_sync/table_patcher.py @@ -0,0 +1,100 @@ +"""테이블 블록 패치 — MDX table 블록 변경을 XHTML에 패치한다.""" +import html as html_module +import re +from typing import Dict, List, Optional + +from reverse_sync.block_diff import BlockChange +from reverse_sync.mapping_recorder import BlockMapping +from reverse_sync.sidecar import SidecarEntry, find_mapping_by_sidecar + + +def is_markdown_table(content: str) -> bool: + """Content가 Markdown table 형식인지 판별한다.""" + lines = [l.strip() for l in content.strip().split('\n') if l.strip()] + if len(lines) < 2: + return False + pipe_lines = sum(1 for l in lines if l.startswith('|') and l.endswith('|')) + return pipe_lines >= 2 + + +def split_table_rows(content: str) -> List[str]: + """Markdown table content를 데이터 행(non-separator) 목록으로 분리한다.""" + rows = [] + for line in content.strip().split('\n'): + s = line.strip() + if not s: + continue + # separator 행 건너뛰기 (| --- | --- | ...) + if re.match(r'^\|[\s\-:|]+\|$', s): + continue + if s.startswith('|') and s.endswith('|'): + rows.append(s) + return rows + + +def normalize_table_row(row: str) -> str: + """Markdown table row를 XHTML plain text 대응 형태로 변환한다.""" + cells = [c.strip() for c in row.split('|')[1:-1]] + parts = [] + for cell in cells: + s = cell + s = re.sub(r'\*\*(.+?)\*\*', r'\1', s) + s = re.sub(r'`([^`]+)`', r'\1', s) + s = re.sub(r'(?(.*?)', + lambda m: m.group(2) + m.group(1).capitalize(), + s, + ) + s = re.sub(r'<[^>]+/?>', '', s) + s = html_module.unescape(s) + s = s.strip() + if s: + parts.append(s) + return ' '.join(parts) + + +def build_table_row_patches( + change: BlockChange, + mappings: List[BlockMapping], + used_ids: 'set | None' = None, + mdx_to_sidecar: Optional[Dict[int, SidecarEntry]] = None, + xpath_to_mapping: Optional[Dict[str, 'BlockMapping']] = None, +) -> List[Dict[str, str]]: + """Markdown table 블록의 변경된 행을 XHTML table에 패치한다. + + sidecar를 통해 parent table mapping을 찾아 containing block으로 사용한다. + """ + from reverse_sync.patch_builder import _flush_containing_changes + + old_rows = split_table_rows(change.old_block.content) + new_rows = split_table_rows(change.new_block.content) + if len(old_rows) != len(new_rows): + return [] + + # sidecar에서 parent mapping 획득 + container = None + if mdx_to_sidecar is not None and xpath_to_mapping is not None: + container = find_mapping_by_sidecar( + change.index, mdx_to_sidecar, xpath_to_mapping) + + if container is None: + return [] + + patches = [] + containing_changes: dict = {} # block_id → (mapping, [(old_plain, new_plain)]) + for old_row, new_row in zip(old_rows, new_rows): + if old_row == new_row: + continue + old_plain = normalize_table_row(old_row) + new_plain = normalize_table_row(new_row) + if not old_plain or old_plain == new_plain: + continue + bid = container.block_id + if bid not in containing_changes: + containing_changes[bid] = (container, []) + containing_changes[bid][1].append((old_plain, new_plain)) + + patches.extend(_flush_containing_changes(containing_changes, used_ids)) + return patches diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 3a77fc201..97081777a 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -3,6 +3,7 @@ from bs4 import BeautifulSoup, NavigableString, Tag import difflib import re +from reverse_sync.mapping_recorder import _iter_block_children def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str: @@ -138,17 +139,6 @@ def _replace_inner_html(element: Tag, new_inner_xhtml: str): element.append(child.extract()) -def _iter_block_children(parent): - """블록 레벨 자식을 순회한다. ac:layout은 cell 내부로 진입한다.""" - for child in parent.children: - if isinstance(child, Tag) and child.name == 'ac:layout': - for section in child.find_all('ac:layout-section', recursive=False): - for cell in section.find_all('ac:layout-cell', recursive=False): - yield from cell.children - else: - yield child - - def _find_element_by_xpath(soup: BeautifulSoup, xpath: str): """간이 XPath로 요소를 찾는다. diff --git a/confluence-mdx/docs/analysis-reverse-sync-refactoring.md b/confluence-mdx/docs/analysis-reverse-sync-refactoring.md new file mode 100644 index 000000000..37a0d1e90 --- /dev/null +++ b/confluence-mdx/docs/analysis-reverse-sync-refactoring.md @@ -0,0 +1,298 @@ +# Reverse Sync 리팩토링 분석 + +> 작성일: 2026-02-26 +> 대상: `confluence-mdx/bin/reverse_sync/` + `reverse_sync_cli.py` + +## 1. 분석 목적 + +최근 reverse-sync 관련 커밋 12건 중 **10건이 버그 수정**이다. 이 빈도는 단순한 구현 실수가 아니라 **설계 수준의 구조적 문제**에서 비롯되었을 가능성이 높다. 이 문서는 반복 버그의 근본 원인을 디자인 결함 관점에서 분석하고, 리팩토링 대상을 도출한다. + +--- + +## 2. 최근 버그 패턴 분류 + +최근 커밋에서 수정된 버그를 원인별로 분류한다. + +### 2.1 텍스트 위치 매핑 오류 (4건) + +| 커밋 | 증상 | 근본 원인 | +|------|------|-----------| +| `2e9de1a` | `find_insert_pos(char_map, 0)`이 `char_map[0]` 존재 시에도 0 반환 | `text_transfer.py`의 문자 단위 정렬에서 경계 조건 누락 | +| `fb7efeec` | 인접 text node 경계에서 insert opcode 양쪽 적용 | `xhtml_patcher.py`의 `_map_text_range`에서 half-open range 미적용 | +| `60c5390` | `` 뒤 조사 앞 공백 미제거 | `_apply_text_changes`에서 인라인 태그 경계 gap 처리 누락 | +| `1e4dd43` | 재실행 시 "네이티브로 네이티브로" 중복 | `transfer_text_changes`가 이미 적용된 텍스트를 재매핑 | + +### 2.2 인라인 포맷 변경 감지 실패 (3건) + +| 커밋 | 증상 | 근본 원인 | +|------|------|-----------| +| `6438a16` | 연속 인라인 마커 사이 텍스트 변경(쉼표 등) 미감지 | `has_inline_format_change`가 마커 간 텍스트를 검사하지 않음 | +| `ea28d65` | flat list에서 backtick 변경 누락 | `_resolve_child_mapping` 실패 시 inline 변경 추적 경로 없음 | +| `7ebaf87` | inline format 변경이 text-only 패치로 처리됨 | inline 변경 감지 로직 자체가 부재 (이 커밋에서 신규 구현) | + +### 2.3 리스트 처리 실패 (2건) + +| 커밋 | 증상 | 근본 원인 | +|------|------|-----------| +| `f5f307f` | 리스트 항목 수 변경 시 빈 패치 반환 | `build_list_item_patches`에 항목 수 불일치 분기 없음 | +| `acf4e3c` | 중첩 리스트 텍스트 붕괴 | `SequenceMatcher`의 `autojunk` 기본값이 CJK에 부적합 | + +### 2.4 정규화/검증 불일치 (2건) + +| 커밋 | 증상 | 근본 원인 | +|------|------|-----------| +| `29cdc9f` | 날짜 locale 불일치로 verify 실패 | forward converter의 locale이 reverse-sync 파이프라인과 불일치 | +| `18679d0` | callout 매핑 불완전으로 verify 실패 | `roundtrip_verifier`의 정규화 함수 부족 | + +--- + +## 3. 디자인 결함 분석 + +### 3.1 근본 원인: "텍스트 패칭" 전략의 본질적 취약성 + +현재 reverse-sync의 핵심 전략은 다음과 같다: + +``` +MDX diff → 텍스트 변경 추출 → XHTML 내 텍스트 위치 매핑 → 문자 단위 치환 +``` + +이 전략은 **XHTML의 DOM 구조를 보존하면서 텍스트만 교체**하는 것이 목표이지만, 근본적인 한계가 있다: + +**문제 1: 두 개의 독립된 좌표계 사이의 매핑** + +MDX의 텍스트 위치와 XHTML의 텍스트 위치는 서로 다른 좌표계이다. 이 둘을 문자 단위로 정렬(`align_chars`)하는 것은 본질적으로 불안정하다. + +- MDX에서 `**bold**`는 8글자, XHTML에서 `bold`는 23글자 +- Markdown의 인라인 마커, HTML 엔티티, Confluence 전용 태그 등이 좌표를 왜곡 +- `text_transfer.py`의 `align_chars()`는 비공백 문자만 정렬하므로, 공백 위치의 미묘한 차이에서 오류 발생 + +**이것이 2.1의 4건의 버그가 모두 "위치 매핑"에서 발생한 이유이다.** + +**문제 2: "텍스트 변경"과 "구조 변경"의 구분 불가능** + +현재 파이프라인은 모든 변경을 "텍스트 변경"으로 시작하되, 특정 조건에서 "구조 변경"(inner XHTML 재생성)으로 전환한다. 이 전환 조건이 `has_inline_format_change()` 등의 휴리스틱에 의존하므로: + +- 감지하지 못하는 edge case가 계속 발견된다 (2.2의 3건) +- 감지 로직 추가 → 새로운 edge case 발견 → 또 추가, 의 무한 루프 + +**문제 3: 리스트의 이중 구조** + +MDX 리스트는 단일 블록(`type: 'list'`)이지만, XHTML에서는 `