From ed566c055932a127840cf5b724a7628d7b8e81cb Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 6 Apr 2026 00:30:46 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(reverse=5Fsync):=20=EC=9D=B8=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=EC=9A=94=EC=86=8C=20=EA=B2=BD=EA=B3=84=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=20=EB=B3=80=EA=B2=BD=EC=9D=B4=20XHTML=EC=97=90=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=EB=90=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=88=98=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build_patches의 has_content_change 비교에서 collapse_ws를 제거하여 이중→단일 공백 등 공백 수 변경을 감지합니다 - _apply_mdx_diff_to_xhtml 전달 시에는 collapse_ws를 유지하여 XHTML plain text와의 정렬을 보장합니다 - normalize_mdx_to_plain의 링크 텍스트에 strip()을 적용하여 [ **T** ] ↔ [**T**] 형식 차이를 흡수합니다 Co-Authored-By: Claude Opus 4.6 --- .../bin/reverse_sync/patch_builder.py | 14 +++- confluence-mdx/bin/text_utils.py | 4 +- .../tests/test_reverse_sync_patch_builder.py | 82 +++++++++++++++++-- 3 files changed, 89 insertions(+), 11 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index f0d446a67..cce764d3d 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -1056,10 +1056,16 @@ 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 + # 실제 텍스트 변경 여부: normalize_mdx_to_plain으로 비교. + # collapse_ws를 적용하면 텍스트 공백 수 변경(이중→단일 등)이 무시되어 + # 패치가 생성되지 않으므로, 공백을 보존한 채 비교한다. + _old_plain_raw = normalize_mdx_to_plain(change.old_block.content, 'list') + _new_plain_raw = normalize_mdx_to_plain(change.new_block.content, 'list') + has_content_change = _old_plain_raw != _new_plain_raw + # _apply_mdx_diff_to_xhtml에 전달할 때는 collapse_ws 적용: + # XHTML plain text에는 줄바꿈이 없으므로 MDX 측도 공백을 축약해야 정렬된다. + _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) diff --git a/confluence-mdx/bin/text_utils.py b/confluence-mdx/bin/text_utils.py index 554a0f580..d5988d2c0 100644 --- a/confluence-mdx/bin/text_utils.py +++ b/confluence-mdx/bin/text_utils.py @@ -144,9 +144,11 @@ def normalize_mdx_to_plain(content: str, block_type: str) -> str: s = re.sub(r'(?
  • privilege가 모두 "Read-Only" 인 경우

  • ' + 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 = '' + 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}" + ) From 6a2a08275831fb490c5b0d894ff3c9e29f9c0ae7 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 6 Apr 2026 00:46:09 +0900 Subject: [PATCH 2/5] =?UTF-8?q?fix(reverse=5Fsync):=20normalize=5Fmdx=5Fto?= =?UTF-8?q?=5Fplain=20=EB=A7=81=ED=81=AC=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20.?= =?UTF-8?q?strip()=20=EC=A0=9C=EA=B1=B0=EB=A1=9C=20=EA=B3=B5=EB=B0=B1=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=20=EA=B0=90=EC=A7=80=EB=A5=BC=20=EB=B3=B5?= =?UTF-8?q?=EC=9B=90=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit .strip()은 [ **T** ] ↔ [**T**] 경계 공백 변경과 [Okta 연동하기 ] trailing space 변경을 모두 삼켜서 has_content_change가 False로 평가되어 패치가 누락되었습니다. Co-Authored-By: Claude Opus 4.6 --- confluence-mdx/bin/text_utils.py | 4 +--- .../tests/test_reverse_sync_patch_builder.py | 17 +++++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/confluence-mdx/bin/text_utils.py b/confluence-mdx/bin/text_utils.py index d5988d2c0..554a0f580 100644 --- a/confluence-mdx/bin/text_utils.py +++ b/confluence-mdx/bin/text_utils.py @@ -144,11 +144,9 @@ def normalize_mdx_to_plain(content: str, block_type: str) -> str: s = re.sub(r'(? Date: Mon, 6 Apr 2026 02:16:38 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=ED=86=A0=EB=A1=A0=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81=20(?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isu_001 (confluence-mdx/bin/reverse_sync/patch_builder.py:1064): `normalize_mdx_to_plain()`의 raw 결과를 그대로 비교하면 가시 공백 수정뿐 아니라 리스트 continuation line reflow도 `has_content_change=True`로 분류됩니다. 예를 들어 `* hello world`를 `* hello\n world`로만 바꿔도 이 조건이 참이 되어 `replace_fragment` 패치가 생성되지만, emitter가 만드는 XHTML은 기존과 동일한 `
    • hello world

    `입니다. 이번 변경으로 format-only 줄바꿈 정리까지 reverse-sync 패치 대상으로 승격되어 불필요한 no-op 패치가 생깁니다. --- .../bin/reverse_sync/patch_builder.py | 43 ++++++++++++++++--- .../tests/test_reverse_sync_patch_builder.py | 13 ++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index cce764d3d..c75d08a55 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -178,6 +178,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, @@ -1056,11 +1090,10 @@ def _mark_used(block_id: str, m: BlockMapping): ) # v3 fallback, sidecar 없음, 또는 실제 텍스트 변경이 있는 경우 whole-fragment 재생성 # (Phase 5 Axis 3: build_list_item_patches fallback 제거) - # 실제 텍스트 변경 여부: normalize_mdx_to_plain으로 비교. - # collapse_ws를 적용하면 텍스트 공백 수 변경(이중→단일 등)이 무시되어 - # 패치가 생성되지 않으므로, 공백을 보존한 채 비교한다. - _old_plain_raw = normalize_mdx_to_plain(change.old_block.content, 'list') - _new_plain_raw = normalize_mdx_to_plain(change.new_block.content, 'list') + # 내용 비교는 가시 공백 수 변화는 보존하되, 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에는 줄바꿈이 없으므로 MDX 측도 공백을 축약해야 정렬된다. diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index 249199184..adb567a09 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -2837,3 +2837,16 @@ def test_single_space_to_double_generates_patch(self): assert '텍스트 뒤에' in patched, ( f"패치 후 이중 공백이어야 합니다: {patched}" ) + + def test_continuation_line_reflow_only_skips_patch(self): + """continuation line 재배치는 동일 XHTML이면 패치를 만들지 않아야 한다.""" + xhtml = '
    • hello world

    ' + 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}" + ) From 1e7957619ed4b9dfde1daf96fac2060f0b7a0d9e Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 6 Apr 2026 02:36:24 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20=ED=86=A0=EB=A1=A0=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98=EC=98=81=20(?= =?UTF-8?q?=EB=9D=BC=EC=9A=B4=EB=93=9C=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isu_001 (confluence-mdx/bin/reverse_sync/patch_builder.py:1100): 리스트의 공백 변경을 감지한 뒤에도 preserved anchor(/) 경로에서는 _old_plain/_new_plain에 다시 collapse_ws를 적용해서 실제 text transfer 입력이 동일해집니다. 그래서 * 텍스트 [링크](url) 뒤에 -> * 텍스트 [링크](url) 뒤에 같은 공백 확대는 new_plain_text가 그대로 남아 XHTML에 반영되지 않습니다. - isu_002 (confluence-mdx/tests/test_reverse_sync_patch_builder.py:154): 이 테스트를 URL만 바뀌는 케이스로 바꾸면 이번 PR이 고치려는 핵심 회귀인 `[ **General** ] -> [**General**]` 같은 링크 경계 공백 변경을 더 이상 잠그지 못합니다. `normalize_mdx_to_plain()`은 링크 URL을 버리므로, 공백 비교 로직이 다시 깨져도 이 테스트는 계속 통과합니다. 기존 경계 공백 케이스를 별도 회귀 테스트로 남겨야 합니다. --- .../bin/reverse_sync/patch_builder.py | 23 ++++-- .../tests/test_reverse_sync_patch_builder.py | 76 ++++++++++++++++++- 2 files changed, 89 insertions(+), 10 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index c75d08a55..7e45157b5 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -1095,8 +1095,8 @@ def _mark_used(block_id: str, m: BlockMapping): _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에는 줄바꿈이 없으므로 MDX 측도 공백을 축약해야 정렬된다. + # _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 변경 감지: 숫자 목록의 시작 번호가 달라진 경우 @@ -1169,12 +1169,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_anchor_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: diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index adb567a09..4cdd0189c 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -139,9 +139,8 @@ 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']) @@ -168,6 +167,33 @@ def test_path1b_direct_sidecar_format_only_change_skips(self): # 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]', '
  • General text

  • ', '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): @@ -2850,3 +2876,47 @@ def test_continuation_line_reflow_only_skips_patch(self): 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 = ( + '
    • ' + '텍스트 ' + '링크 뒤에' + '

    ' + ) + 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 '링크' in patched + assert '텍스트 Date: Mon, 6 Apr 2026 02:44:20 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20=EB=A7=81=ED=81=AC=20anchor=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=97=90=EB=A7=8C=20raw=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=84=EC=9D=B4=EB=A5=BC=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bin/reverse_sync/patch_builder.py | 7 ++- .../tests/test_reverse_sync_patch_builder.py | 45 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 7e45157b5..bbc3d4b8e 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -82,6 +82,11 @@ def _contains_preserved_anchor_markup(xhtml_text: str) -> bool: return " bool: + """링크 계열 preserved anchor가 포함된 경우만 가시 공백 raw transfer 대상이다.""" + return "
  • 목록 좌측 상단에서 Delete버튼을 클릭합니다

    ' + '' + '' + '

  • ' + ) + old_content = ( + '4. 목록 좌측 상단에서 `Delete`버튼을 클릭합니다
    \n' + '
    \n' + ' img\n' + '
    \n' + ) + new_content = ( + '4. 목록 좌측 상단에서 `Delete` 버튼을 클릭합니다.
    \n' + '
    \n' + ' img\n' + '
    \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 버튼을 클릭합니다.'