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
71 changes: 62 additions & 9 deletions confluence-mdx/bin/reverse_sync/patch_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def _contains_preserved_anchor_markup(xhtml_text: str) -> bool:
return "<ac:" in xhtml_text or "<ri:" in xhtml_text


def _contains_preserved_link_markup(xhtml_text: str) -> bool:
"""링크 계열 preserved anchor가 포함된 경우만 가시 공백 raw transfer 대상이다."""
return "<ac:link" in xhtml_text


def _is_clean_block(
block_type: str,
mapping: Optional[BlockMapping],
Expand Down Expand Up @@ -178,6 +183,40 @@ def _detect_list_item_space_change(old_content: str, new_content: str) -> bool:
return has_space_change


def _normalize_list_for_content_compare(content: str) -> str:
"""리스트 변경 비교용 plain text를 생성한다.

리스트 항목 내부의 continuation line 줄바꿈은 emitter에서 공백 하나로 합쳐지므로
내용 변경 판정에서는 무시한다. 대신 항목 경계와 항목 내부의 실제 공백 수 차이는
그대로 보존해 no-op reflow와 가시 공백 변경을 구분한다.
"""
lines = content.strip().split('\n')
item_chunks: List[str] = []
current_chunk: List[str] = []

def _flush_current() -> None:
if not current_chunk:
return
plain = normalize_mdx_to_plain('\n'.join(current_chunk), 'list')
if plain:
item_chunks.append(plain.replace('\n', ' '))

for line in lines:
if not line.strip():
continue
if re.match(r'^\s*(?:\d+\.(?:\s+|$)|[-*+]\s+)', line):
_flush_current()
current_chunk = [line]
continue
if current_chunk:
current_chunk.append(line)
else:
current_chunk = [line]

_flush_current()
return '\n'.join(item_chunks)


def _build_inline_fixups(
old_content: str,
new_content: str,
Expand Down Expand Up @@ -1056,10 +1095,15 @@ def _mark_used(block_id: str, m: BlockMapping):
)
# v3 fallback, sidecar 없음, 또는 실제 텍스트 변경이 있는 경우 whole-fragment 재생성
# (Phase 5 Axis 3: build_list_item_patches fallback 제거)
# 실제 텍스트 변경 여부: normalize+collapse_ws로 비교하여 링크 공백 등 형식 차이 무시
_old_plain = collapse_ws(normalize_mdx_to_plain(change.old_block.content, 'list'))
_new_plain = collapse_ws(normalize_mdx_to_plain(change.new_block.content, 'list'))
has_content_change = _old_plain != _new_plain
# 내용 비교는 가시 공백 수 변화는 보존하되, continuation line reflow처럼
# emitter 결과가 동일한 줄바꿈 정리는 무시한다.
_old_plain_raw = _normalize_list_for_content_compare(change.old_block.content)
_new_plain_raw = _normalize_list_for_content_compare(change.new_block.content)
has_content_change = _old_plain_raw != _new_plain_raw
# _apply_mdx_diff_to_xhtml에 전달할 기본값은 collapse_ws 적용:
# XHTML plain text에는 줄바꿈이 없으므로 clean list 정렬에는 공백 축약본이 맞다.
_old_plain = collapse_ws(_old_plain_raw)
_new_plain = collapse_ws(_new_plain_raw)
# ol start 변경 감지: 숫자 목록의 시작 번호가 달라진 경우
_old_start = re.match(r'^\s*(\d+)\.', change.old_block.content)
_new_start = re.match(r'^\s*(\d+)\.', change.new_block.content)
Expand Down Expand Up @@ -1130,12 +1174,21 @@ def _mark_used(block_id: str, m: BlockMapping):
patches.append(patch_entry)
_text_change_patches[bid] = patch_entry
if has_content_change:
# XHTML text를 정규화하여 MDX와 공백 1:1 매핑 보장
# (strong trailing space 등으로 인한 이중 공백 문제 방지)
_xhtml_plain_normalized = collapse_ws(
_text_change_patches[bid]['new_plain_text'])
preserve_visible_ws = _contains_preserved_link_markup(
mapping.xhtml_text
)
transfer_old_plain = _old_plain_raw if preserve_visible_ws else _old_plain
transfer_new_plain = _new_plain_raw if preserve_visible_ws else _new_plain
transfer_xhtml_plain = _text_change_patches[bid]['new_plain_text']
if not preserve_visible_ws:
# XHTML text를 정규화하여 MDX와 공백 1:1 매핑 보장
# (strong trailing space 등으로 인한 이중 공백 문제 방지)
transfer_xhtml_plain = collapse_ws(transfer_xhtml_plain)
_text_change_patches[bid]['new_plain_text'] = _apply_mdx_diff_to_xhtml(
_old_plain, _new_plain, _xhtml_plain_normalized)
transfer_old_plain,
transfer_new_plain,
transfer_xhtml_plain,
)
if has_ol_start_change:
_text_change_patches[bid]['ol_start'] = int(_new_start.group(1))
if has_inline_boundary:
Expand Down
211 changes: 205 additions & 6 deletions confluence-mdx/tests/test_reverse_sync_patch_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,20 +139,19 @@ def test_path1_direct_sidecar_match_list_with_content_change_regenerates(self):
assert patches[0]['xhtml_xpath'] == 'ul[1]'
assert patches[0]['action'] == 'replace_fragment'

# Path 1b: 직접 sidecar 매칭 + 형식 전용 변경 (텍스트 동일) → skip
# 예: [ **General** ] → [**General**] (링크 내 공백, collapse_ws 후 텍스트 동일)
def test_path1b_direct_sidecar_format_only_change_skips(self):
# Path 1b: 직접 sidecar 매칭 + URL만 변경 (링크 텍스트 동일) → skip
def test_path1b_direct_sidecar_url_only_change_skips(self):
child = _make_mapping('c1', 'General text', xpath='li[1]')
parent = _make_mapping('p1', 'General text more', xpath='ul[1]',
type_='list', children=['c1'])
mappings = [parent, child]
xpath_to_mapping = {m.xhtml_xpath: m for m in mappings}

# 링크 공백 변경만: [**General**](url) → [ **General** ](url), 텍스트 동일
# URL만 변경, 링크 텍스트 동일: normalize 후 텍스트 동일 → skip
change = _make_change(
0,
'* [**General**](company-management/general) text\n',
'* [ **General** ](company-management/general) text\n',
'* [**General**](company-management/general-v2) text\n',
type_='list',
)
mdx_to_sidecar = self._setup_sidecar('ul[1]', 0)
Expand All @@ -165,9 +164,36 @@ def test_path1b_direct_sidecar_format_only_change_skips(self):
mappings, mdx_to_sidecar, xpath_to_mapping,
roundtrip_sidecar=roundtrip_sidecar)

# 형식만 변경(링크 공백), normalize+collapse_ws 후 텍스트 동일 → skip
# URL만 변경, normalize 후 텍스트 동일 → skip
assert patches == []

def test_path1b_link_boundary_whitespace_generates_patch(self):
"""링크 경계 공백 변경은 패치를 생성해야 한다."""
child = _make_mapping('c1', 'General text', xpath='li[1]')
parent = _make_mapping('p1', 'General 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,
'* [**General**](company-management/general) text\n',
'* [ **General** ](company-management/general) text\n',
type_='list',
)
mdx_to_sidecar = self._setup_sidecar('ul[1]', 0)
roundtrip_sidecar = _make_roundtrip_sidecar([
SidecarBlock(0, 'ul[1]', '<li><p><a href="">General</a> text</p></li>', 'hash1', (1, 1))
])

patches, *_ = build_patches(
[change], [change.old_block], [change.new_block],
mappings, mdx_to_sidecar, xpath_to_mapping,
roundtrip_sidecar=roundtrip_sidecar)

assert len(patches) == 1
assert patches[0]['action'] == 'replace_fragment'

# Path 1c: sidecar 매칭 → list type + roundtrip_sidecar 없음 + content change
# → clean list이면 replace_fragment (Phase 5: has_content_change → patch)
def test_path1c_sidecar_match_list_without_roundtrip_sidecar_with_content_change_patches(self):
Expand Down Expand Up @@ -2766,3 +2792,176 @@ def test_nested_blank_item_removal_does_not_touch_other_nested_lists(self):
f"변경되지 않은 두 번째 하위 리스트는 2개 항목을 유지해야 합니다. "
f"patched XHTML: {patched[:400]}"
)


class TestWhitespaceOnlyChangeGeneratesPatch:
"""공백만 변경된 리스트 블록도 패치가 생성되어야 한다.

build_patches의 has_content_change가 collapse_ws로 비교하면
공백 변경이 무시되어 패치가 생성되지 않는 버그 재현.
sidecar가 있는 clean list에서 has_any_change=False → 패치 누락.
"""

def _build_list_patches(self, xhtml, old_content, new_content):
"""리스트 공백 변경 패치 생성 헬퍼."""
old_block = MdxBlock(
type='list', content=old_content, line_start=1, line_end=1)
new_block = MdxBlock(
type='list', content=new_content, line_start=1, line_end=1)
change = BlockChange(
index=0, change_type='modified',
old_block=old_block, new_block=new_block,
)
mapping = BlockMapping(
block_id='list-1', type='list', xhtml_xpath='ul[1]',
xhtml_text=xhtml,
xhtml_plain_text=old_content.lstrip('* ').strip(),
xhtml_element_index=0, children=[],
)
sidecar_block = SidecarBlock(
block_index=0, xhtml_xpath='ul[1]',
xhtml_fragment=xhtml,
mdx_content_hash=sha256_text(old_content),
mdx_line_range=(1, 1),
)
roundtrip_sidecar = _make_roundtrip_sidecar([sidecar_block])
patches, _, skipped = build_patches(
[change], [old_block], [new_block],
mappings=[mapping],
roundtrip_sidecar=roundtrip_sidecar,
)
return patches, skipped

def test_double_space_to_single_generates_patch(self):
"""이중 공백 → 단일 공백 변경 시 패치가 생성된다."""
xhtml = '<ul><li><p>privilege가 모두 "Read-Only" 인 경우</p></li></ul>'
patches, skipped = self._build_list_patches(
xhtml,
'* privilege가 모두 "Read-Only" 인 경우\n',
'* privilege가 모두 "Read-Only" 인 경우\n',
)
assert len(patches) > 0, (
f"공백 축소 변경이 패치를 생성해야 합니다. skipped={skipped}"
)
patched = patch_xhtml(xhtml, patches)
assert '모두 "Read-Only"' in patched, (
f"패치 후 단일 공백이어야 합니다: {patched}"
)

def test_single_space_to_double_generates_patch(self):
"""단일 공백 → 이중 공백 확대도 패치가 생성된다."""
xhtml = '<ul><li><p>텍스트 뒤에</p></li></ul>'
patches, skipped = self._build_list_patches(
xhtml,
'* 텍스트 뒤에\n',
'* 텍스트 뒤에\n',
)
assert len(patches) > 0, (
f"공백 확대 변경이 패치를 생성해야 합니다. skipped={skipped}"
)
patched = patch_xhtml(xhtml, patches)
assert '텍스트 뒤에' in patched, (
f"패치 후 이중 공백이어야 합니다: {patched}"
)

def test_continuation_line_reflow_only_skips_patch(self):
"""continuation line 재배치는 동일 XHTML이면 패치를 만들지 않아야 한다."""
xhtml = '<ul><li><p>hello world</p></li></ul>'
patches, skipped = self._build_list_patches(
xhtml,
'* hello world\n',
'* hello\n world\n',
)
assert patches == [], (
f"continuation line reflow만 바뀐 경우 no-op 패치를 만들면 안 됩니다. "
f"patches={patches}, skipped={skipped}"
)


class TestPreservedAnchorListWhitespaceTransfer:
"""preserved anchor 리스트에서도 가시 공백 변경은 text transfer로 반영되어야 한다."""

def test_double_space_around_link_is_transferred(self):
xhtml = (
'<ul><li><p>'
'텍스트 <ac:link><ri:page ri:content-title="링크"/>'
'<ac:link-body>링크</ac:link-body></ac:link> 뒤에'
'</p></li></ul>'
)
old_content = '* 텍스트 [링크](url) 뒤에\n'
new_content = '* 텍스트 [링크](url) 뒤에\n'
change = _make_change(0, old_content, new_content, type_='list')
mapping = BlockMapping(
block_id='list-anchor-1',
type='list',
xhtml_xpath='ul[1]',
xhtml_text=xhtml,
xhtml_plain_text='텍스트 링크 뒤에',
xhtml_element_index=0,
children=[],
)
roundtrip_sidecar = _make_roundtrip_sidecar([
SidecarBlock(0, 'ul[1]', xhtml, sha256_text(old_content), (1, 1))
])

patches, _, skipped = build_patches(
[change], [change.old_block], [change.new_block],
mappings=[mapping],
roundtrip_sidecar=roundtrip_sidecar,
)

assert len(patches) == 1, (
f"preserved anchor 리스트 공백 확대도 패치를 생성해야 합니다. skipped={skipped}"
)
assert patches[0]['new_plain_text'] == '텍스트 링크 뒤에'

patched = patch_xhtml(xhtml, patches)
assert '<ac:link-body>링크</ac:link-body>' in patched
assert '텍스트 <ac:link' in patched, (
f"링크 앞의 이중 공백이 XHTML에 반영되어야 합니다: {patched}"
)

def test_image_anchor_list_keeps_collapsed_text_diff(self):
"""이미지 anchor 리스트는 raw 공백이 아니라 collapsed diff를 써야 한다."""
xhtml = (
'<ol><li><p>목록 좌측 상단에서 <code>Delete</code>버튼을 클릭합니다</p>'
'<ac:image ac:align="center">'
'<ri:attachment ri:filename="x.png"></ri:attachment>'
'</ac:image><p> </p></li></ol>'
)
old_content = (
'4. 목록 좌측 상단에서 `Delete`버튼을 클릭합니다 <br/>\n'
' <figure data-layout="center" data-align="center">\n'
' <img src="/x.png" alt="img" width="736" />\n'
' </figure>\n'
)
new_content = (
'4. 목록 좌측 상단에서 `Delete` 버튼을 클릭합니다.<br/>\n'
' <figure data-layout="center" data-align="center">\n'
' <img src="/x.png" alt="img" width="736" />\n'
' </figure>\n'
)
change = _make_change(0, old_content, new_content, type_='list')
mapping = BlockMapping(
block_id='list-image-1',
type='list',
xhtml_xpath='ol[1]',
xhtml_text=xhtml,
xhtml_plain_text='목록 좌측 상단에서 Delete버튼을 클릭합니다',
xhtml_element_index=0,
children=[],
)
roundtrip_sidecar = _make_roundtrip_sidecar([
SidecarBlock(0, 'ol[1]', xhtml, sha256_text(old_content), (1, 4))
])

patches, _, skipped = build_patches(
[change], [change.old_block], [change.new_block],
mappings=[mapping],
roundtrip_sidecar=roundtrip_sidecar,
)

assert len(patches) == 1, (
f"이미지 preserved anchor 리스트도 패치를 생성해야 합니다. skipped={skipped}"
)
assert patches[0]['new_plain_text'] == '목록 좌측 상단에서 Delete 버튼을 클릭합니다.'
Loading