Skip to content
Merged
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
73 changes: 70 additions & 3 deletions confluence-mdx/bin/reverse_sync/xhtml_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,15 +299,55 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
last_nonempty_idx = j
break

# 블록 경계 감지: 서로 다른 블록 부모(p, li 등)에 속하는 인접 텍스트 노드 사이에서
# insert가 잘못된 노드에 할당되는 것을 방지한다.
# block_boundary_pairs: (left_idx, right_idx) 쌍 - 블록 경계를 이루는 노드 인덱스
block_boundary_pairs = []
prev_nonempty_idx = -1
for j, (ns, ne, nd) in enumerate(node_ranges):
if ns == ne:
continue
if prev_nonempty_idx >= 0:
prev_node = node_ranges[prev_nonempty_idx][2]
if _find_block_ancestor(prev_node) is not _find_block_ancestor(nd):
block_boundary_pairs.append((prev_nonempty_idx, j))
prev_nonempty_idx = j

# 블록 경계에서 insert가 right 노드에 기본 할당되는 것을 수정한다.
# right 노드 내부에 자체 변경(replace/delete/strictly-inside-insert)이 있으면
# boundary insert는 right의 동반 변경이므로 기본 동작을 유지한다.
# right 노드에 변경이 없으면 insert는 left 노드에 귀속시킨다.
# 예: "이모지 깨지는 이슈" + insert(" 해결") + unchanged → left에 할당
# 예: unchanged + insert("[") + "Authentication]..." → right에 할당
claim_end_set = set()
exclude_start_set = set()
for left_idx, right_idx in block_boundary_pairs:
right_start, right_end, _ = node_ranges[right_idx]
has_changes_in_right = any(
(tag in ('replace', 'delete')
and max(i1, right_start) < min(i2, right_end))
or (tag == 'insert' and right_start < i1 < right_end)
for tag, i1, i2, j1, j2 in opcodes
)
if not has_changes_in_right:
claim_end_set.add(left_idx)
exclude_start_set.add(right_idx)

for i, (node_start, node_end, node) in enumerate(node_ranges):
if node_start == node_end:
continue

# _map_text_range는 half-open [start, end)를 사용하므로,
# 마지막 non-empty 노드에서는 end를 확장하여 trailing insert를 포함한다.
effective_end = node_end + 1 if i == last_nonempty_idx else node_end
# 블록 경계에서는 include_insert_at_end/exclude_insert_at_start로
# insert를 올바른 노드에 할당한다.
include_at_end = i in claim_end_set and i != last_nonempty_idx
exclude_at_start = i in exclude_start_set
new_node_text = _map_text_range(
old_stripped, new_stripped, opcodes, node_start, effective_end
old_stripped, new_stripped, opcodes, node_start, effective_end,
include_insert_at_end=include_at_end,
exclude_insert_at_start=exclude_at_start,
)

node_str = str(node)
Expand All @@ -330,8 +370,31 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str):
node.replace_with(NavigableString(leading + new_node_text + trailing))


def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int) -> str:
"""old_text[start:end] 범위에 대응하는 new_text 부분을 추출한다."""
def _find_block_ancestor(node):
"""텍스트 노드의 가장 가까운 블록 레벨 부모 요소를 찾는다."""
_BLOCK_TAGS = {
'p', 'li', 'td', 'th', 'div', 'blockquote',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
}
parent = node.parent
while parent:
if isinstance(parent, Tag) and parent.name in _BLOCK_TAGS:
return parent
parent = parent.parent
return None


def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int,
include_insert_at_end: bool = False,
exclude_insert_at_start: bool = False) -> str:
"""old_text[start:end] 범위에 대응하는 new_text 부분을 추출한다.

Args:
include_insert_at_end: True이면 i1 == end 위치의 insert도 포함한다.
블록 경계에서 trailing insert를 현재 노드에 할당할 때 사용.
exclude_insert_at_start: True이면 i1 == start 위치의 insert를 제외한다.
이전 노드가 해당 insert를 이미 claim한 경우 중복 방지용.
"""
result_parts = []
for tag, i1, i2, j1, j2 in opcodes:
# 이 opcode가 [start, end) 범위와 겹치는지 확인
Expand Down Expand Up @@ -360,8 +423,12 @@ def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int)
# insert는 old 텍스트에서 위치 i1 == i2
# 이 insert가 현재 노드 범위 [start, end) 안에 위치하면 포함
# half-open range를 사용하여 인접 노드 경계에서 중복 삽입 방지
if exclude_insert_at_start and i1 == start:
continue
if start <= i1 < end:
result_parts.append(new_text[j1:j2])
elif include_insert_at_end and i1 == end:
result_parts.append(new_text[j1:j2])
elif tag == 'delete':
# 삭제된 부분은 new에 아무것도 추가하지 않음
pass
Expand Down
54 changes: 54 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_patch_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,60 @@ def test_child_miss_falls_back_to_containing(self):
assert len(patches) == 1
assert patches[0]['xhtml_xpath'] == 'ul[1]'

def test_flat_list_append_text_at_end_of_item(self):
"""flat list에서 항목 끝에 텍스트를 추가할 때 다음 항목으로 넘치지 않아야 한다.

실제 사례: 9.10.0-9.10.4.mdx
Original: "* [MongoDB] 데이터 조회 시 이모지 깨지는 이슈"
Improved: "* [MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결"
현상: "해결"이 다음 항목 앞에 붙어 "해결[Privilege Type]..."이 됨

실제 사례: 11.1.0-11.1.2.mdx
Original: "* [General] 워크플로우 승인완료되었거나 ... External API"
Improved: "* [General] 워크플로우 승인 완료 상태이거나 ... External API 추가"
현상: "추가"가 다음 항목 앞에 붙어 "추가[DAC]..."이 됨
"""
parent = _make_mapping(
'list-55',
'[MongoDB] Aggregate 커멘드 정책 적용 지원 (Web Editor) '
'[MongoDB] 데이터 조회 시 이모지 깨지는 이슈 '
'[Privilege Type] Default Privilege Type 이름 변경 (-role 제거)',
xpath='ul[5]',
type_='list',
children=[],
)
mappings = [parent]
xpath_to_mapping = {m.xhtml_xpath: m for m in mappings}
id_to_mapping = {m.block_id: m for m in mappings}

change = _make_change(
0,
'* [MongoDB] Aggregate 커멘드 정책 적용 지원 (Web Editor)\n'
'* [MongoDB] 데이터 조회 시 이모지 깨지는 이슈\n'
'* [Privilege Type] Default Privilege Type 이름 변경 (-role 제거)\n',
'* [MongoDB] Aggregate 커맨드 정책 적용 지원(Web Editor)\n'
'* [MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결\n'
'* [Privilege Type] Default Privilege Type 이름 변경 (-role 제거)\n',
type_='list',
)
mdx_to_sidecar = {0: _make_sidecar('ul[5]', [0])}

patches = build_list_item_patches(
change, mappings, set(),
mdx_to_sidecar, xpath_to_mapping, id_to_mapping)

assert len(patches) == 1
p = patches[0]
assert 'new_plain_text' in p
# "해결"이 올바른 위치(이슈 뒤)에 있어야 함
assert '이슈 해결' in p['new_plain_text'], (
f"'이슈 해결'이 연속되어야 함: {p['new_plain_text']!r}"
)
# "해결"이 다음 항목 앞에 붙으면 안 됨
assert '해결[Privilege' not in p['new_plain_text'], (
f"'해결'이 다음 항목 앞에 잘못 삽입됨: {p['new_plain_text']!r}"
)

def test_flat_list_bracket_insert_at_item_start(self):
"""flat list에서 리스트 아이템 맨 앞에 '[' 삽입 시
XHTML의 해당 아이템 위치에 정확히 삽입되어야 한다.
Expand Down
109 changes: 109 additions & 0 deletions confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -301,3 +301,112 @@ def test_insert_space_at_inline_element_boundary_not_duplicated():
assert '</ac:link> 문서를 참조해' in result, (
f'</ac:link> 뒤에 공백이 없거나 다른 위치에 삽입됨: {result}'
)


def test_flat_list_append_text_stays_in_same_item():
"""flat list에서 항목 끝에 텍스트를 추가할 때 다음 항목으로 넘치지 않아야 한다.

재현 시나리오:
XHTML: <ul><li><p>이모지 깨지는 이슈</p></li><li><p>이름 변경</p></li></ul>
교정: '이모지 깨지는 이슈' → '이모지가 깨지는 이슈 해결'

기대: '해결'이 첫 번째 <li> 안에 남음.
버그: _apply_text_changes가 insert를 노드 경계에서 다음 <li>의 text node에 할당하여
'해결이름 변경'이 됨.
"""
xhtml = (
'<ul>'
'<li><p>[MongoDB] 데이터 조회 시 이모지 깨지는 이슈</p></li>'
'<li><p>[Privilege Type] Default Privilege Type 이름 변경</p></li>'
'</ul>'
)
patches = [
{
'xhtml_xpath': 'ul[1]',
'old_plain_text': (
'[MongoDB] 데이터 조회 시 이모지 깨지는 이슈'
'[Privilege Type] Default Privilege Type 이름 변경'
),
'new_plain_text': (
'[MongoDB] 데이터 조회 시 이모지가 깨지는 이슈 해결'
'[Privilege Type] Default Privilege Type 이름 변경'
),
}
]
result = patch_xhtml(xhtml, patches)

# '해결'이 첫 번째 항목에 남아야 함
assert '이슈 해결</p></li>' in result, (
f"'해결'이 첫 번째 <li> 안에 없음: {result}"
)
# '해결'이 두 번째 항목 앞에 붙으면 안 됨
assert '해결[Privilege' not in result, (
f"'해결'이 다음 항목으로 넘침: {result}"
)


def test_flat_list_append_text_multiple_items():
"""여러 항목에서 동시에 텍스트를 추가해도 올바른 항목에 남아야 한다."""
xhtml = (
'<ul>'
'<li><p>item A text</p></li>'
'<li><p>item B text</p></li>'
'<li><p>item C text</p></li>'
'</ul>'
)
patches = [
{
'xhtml_xpath': 'ul[1]',
'old_plain_text': 'item A textitem B textitem C text',
'new_plain_text': 'item A text appendeditem B text modifieditem C text',
}
]
result = patch_xhtml(xhtml, patches)

assert '<li><p>item A text appended</p></li>' in result, (
f"첫 번째 항목에 ' appended' 누락: {result}"
)
assert '<li><p>item B text modified</p></li>' in result, (
f"두 번째 항목에 ' modified' 누락: {result}"
)


def test_flat_list_prepend_bracket_stays_in_correct_item():
"""리스트 항목 앞에 '[' 삽입 시 해당 항목에 남아야 한다 (이전 항목으로 이동하면 안 됨).

재현 시나리오:
old: "DynamoDB 데이터 조회 관련 이슈 개선Authentication Type 변경 시"
new: "DynamoDB 데이터 조회 관련 이슈 개선[Authentication] Type 변경 시"

기대: '[' 가 두 번째 <li>에 삽입됨.
회귀 버그: block boundary fix가 '[' 를 이전 <li>에 잘못 할당.
"""
xhtml = (
'<ul>'
'<li><p>DynamoDB 데이터 조회 관련 이슈 개선</p></li>'
'<li><p>Authentication Type 변경 시 오류 메시지 개선</p></li>'
'</ul>'
)
patches = [
{
'xhtml_xpath': 'ul[1]',
'old_plain_text': (
'DynamoDB 데이터 조회 관련 이슈 개선'
'Authentication Type 변경 시 오류 메시지 개선'
),
'new_plain_text': (
'DynamoDB 데이터 조회 관련 이슈 개선'
'[Authentication] Type 변경 시 오류 메시지 개선'
),
}
]
result = patch_xhtml(xhtml, patches)

# DynamoDB 항목은 변경 없이 그대로
assert '<li><p>DynamoDB 데이터 조회 관련 이슈 개선</p></li>' in result, (
f"DynamoDB 항목이 변경됨: {result}"
)
# '[' 가 두 번째 항목에 삽입됨
assert '<li><p>[Authentication] Type 변경 시 오류 메시지 개선</p></li>' in result, (
f"Authentication 항목에 bracket이 없음: {result}"
)