From ba028cad67c0241fbec4fe477a83de536b0fb757 Mon Sep 17 00:00:00 2001 From: JK Date: Wed, 11 Mar 2026 20:52:08 +0900 Subject: [PATCH] =?UTF-8?q?fix(confluence-mdx):=20reverse-sync=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=20=EC=86=8D=EC=84=B1=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=EC=9D=84=20=EC=A7=80=EC=9B=90=ED=95=A9=EB=8B=88?= =?UTF-8?q?=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MDX 순서 목록의 시작 번호(예: \`3. first\`)가 변경될 때 Confluence XHTML의
    속성이 업데이트되지 않는 버그를 수정합니다. - bin/reverse_sync/list_patcher.py: _get_ordered_list_start() 헬퍼 추가, _regenerate_list_from_parent()에서 시작 번호 변경 감지 시 ol_start 필드 포함 - bin/reverse_sync/xhtml_patcher.py: ol_start 패치 필드로
      속성 갱신/제거 - tests/test_reverse_sync_xhtml_patcher.py: ol_start 패치 테스트 추가 - tests/test_reverse_sync_list_patcher_ol_start.py: _get_ordered_list_start 단위 테스트 추가 Co-Authored-By: Claude Sonnet 4.6 --- .../bin/reverse_sync/list_patcher.py | 27 ++++++++-- .../bin/reverse_sync/xhtml_patcher.py | 14 ++++++ ...test_reverse_sync_list_patcher_ol_start.py | 23 +++++++++ .../tests/test_reverse_sync_xhtml_patcher.py | 49 +++++++++++++++++++ 4 files changed, 109 insertions(+), 4 deletions(-) create mode 100644 confluence-mdx/tests/test_reverse_sync_list_patcher_ol_start.py diff --git a/confluence-mdx/bin/reverse_sync/list_patcher.py b/confluence-mdx/bin/reverse_sync/list_patcher.py index b77722142..d70e3daff 100644 --- a/confluence-mdx/bin/reverse_sync/list_patcher.py +++ b/confluence-mdx/bin/reverse_sync/list_patcher.py @@ -98,6 +98,15 @@ def split_list_items(content: str) -> List[str]: return items +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 + + def _regenerate_list_from_parent( change: BlockChange, parent: Optional[BlockMapping], @@ -125,11 +134,16 @@ def _regenerate_list_from_parent( change.new_block.content, change.new_block.type) xhtml_text = transfer_text_changes( old_plain, new_plain, parent.xhtml_plain_text) - return [{ + fallback_patch: Dict[str, object] = { 'xhtml_xpath': parent.xhtml_xpath, 'old_plain_text': parent.xhtml_plain_text, 'new_plain_text': xhtml_text, - }] + } + old_start = _get_ordered_list_start(change.old_block.content) + new_start = _get_ordered_list_start(change.new_block.content) + if old_start is not None and new_start is not None and old_start != new_start: + fallback_patch['ol_start'] = new_start + return [fallback_patch] new_inner = mdx_block_to_inner_xhtml( change.new_block.content, change.new_block.type) @@ -139,11 +153,16 @@ def _regenerate_list_from_parent( if block_lost: new_inner = apply_lost_info(new_inner, block_lost) - return [{ + patch: Dict[str, object] = { 'xhtml_xpath': parent.xhtml_xpath, 'old_plain_text': parent.xhtml_plain_text, 'new_inner_xhtml': new_inner, - }] + } + old_start = _get_ordered_list_start(change.old_block.content) + new_start = _get_ordered_list_start(change.new_block.content) + if old_start is not None and new_start is not None and old_start != new_start: + patch['ol_start'] = new_start + return [patch] def build_list_item_patches( diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 871474bc3..bfb1f10c1 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -73,6 +73,13 @@ 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']) + if 'ol_start' in patch and isinstance(element, Tag) and element.name == 'ol': + new_start = patch['ol_start'] + if new_start == 1: + if 'start' in element.attrs: + del element['start'] + else: + element['start'] = str(new_start) else: old_text = patch['old_plain_text'] new_text = patch['new_plain_text'] @@ -83,6 +90,13 @@ 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: + if 'start' in element.attrs: + del element['start'] + else: + element['start'] = str(new_start) result = str(soup) result = _restore_cdata(result) diff --git a/confluence-mdx/tests/test_reverse_sync_list_patcher_ol_start.py b/confluence-mdx/tests/test_reverse_sync_list_patcher_ol_start.py new file mode 100644 index 000000000..be754ee04 --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_list_patcher_ol_start.py @@ -0,0 +1,23 @@ +"""_get_ordered_list_start 단위 테스트.""" +from reverse_sync.list_patcher import _get_ordered_list_start + + +class TestGetOrderedListStart: + def test_starts_at_one(self): + assert _get_ordered_list_start('1. first\n2. second\n') == 1 + + def test_starts_at_three(self): + assert _get_ordered_list_start('3. first\n4. second\n') == 3 + + def test_starts_at_zero(self): + assert _get_ordered_list_start('0. zeroth\n1. first\n') == 0 + + def test_unordered_list_returns_none(self): + assert _get_ordered_list_start('* item\n* item2\n') is None + + def test_empty_returns_none(self): + assert _get_ordered_list_start('') is None + + def test_indented_ordered_list(self): + """들여쓰기된 순서 목록도 인식한다.""" + assert _get_ordered_list_start(' 5. first\n 6. second\n') == 5 diff --git a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py index e2f84c672..c3995714e 100644 --- a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py +++ b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py @@ -1,4 +1,5 @@ import pytest +from bs4 import BeautifulSoup from reverse_sync.xhtml_patcher import patch_xhtml @@ -440,3 +441,51 @@ def test_list_patch_with_emoticon_uses_mapping_plain_text(): result = patch_xhtml(xhtml, patches) assert '검색할 수 있습니다.' in result, f'리스트 텍스트 변경이 적용되지 않음: {result}' assert '검색이 가능합니다.' not in result, f'기존 문구가 남아 있음: {result}' + + +class TestOlStartPatch: + """
        속성 변경 패치 테스트.""" + + def test_set_start_attribute_via_inner_xhtml(self): + """new_inner_xhtml 경로에서 ol_start로 start 속성을 설정한다.""" + xhtml = '
        1. first

        2. second

        ' + patches = [{ + 'xhtml_xpath': 'ol[1]', + 'old_plain_text': 'firstsecond', + 'new_inner_xhtml': '
      1. updated first

      2. second

      3. ', + 'ol_start': 3, + }] + result = patch_xhtml(xhtml, patches) + soup = BeautifulSoup(result, 'html.parser') + ol = soup.find('ol') + assert ol['start'] == '3' + assert 'updated first' in result + + def test_remove_start_attribute_when_ol_start_is_one(self): + """ol_start=1이면 기존 start 속성을 제거한다.""" + xhtml = '
        1. first

        ' + patches = [{ + 'xhtml_xpath': 'ol[1]', + 'old_plain_text': 'first', + 'new_inner_xhtml': '
      4. first item

      5. ', + 'ol_start': 1, + }] + result = patch_xhtml(xhtml, patches) + soup = BeautifulSoup(result, 'html.parser') + ol = soup.find('ol') + assert ol.get('start') is None + + def test_set_start_attribute_via_text_transfer(self): + """텍스트 전이 경로에서도 ol_start가 start 속성으로 적용된다.""" + xhtml = '
        1. first item
        ' + patches = [{ + 'xhtml_xpath': 'ol[1]', + 'old_plain_text': 'first item', + 'new_plain_text': 'updated item', + 'ol_start': 5, + }] + result = patch_xhtml(xhtml, patches) + soup = BeautifulSoup(result, 'html.parser') + ol = soup.find('ol') + assert ol['start'] == '5' + assert 'updated' in result