Skip to content

Commit ba028ca

Browse files
jk-kim0claude
andcommitted
fix(confluence-mdx): reverse-sync에서 <ol start="N"> 속성 변경을 지원합니다
MDX 순서 목록의 시작 번호(예: \`3. first\`)가 변경될 때 Confluence XHTML의 <ol start="N"> 속성이 업데이트되지 않는 버그를 수정합니다. - bin/reverse_sync/list_patcher.py: _get_ordered_list_start() 헬퍼 추가, _regenerate_list_from_parent()에서 시작 번호 변경 감지 시 ol_start 필드 포함 - bin/reverse_sync/xhtml_patcher.py: ol_start 패치 필드로 <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 <noreply@anthropic.com>
1 parent 4e29c1b commit ba028ca

4 files changed

Lines changed: 109 additions & 4 deletions

File tree

confluence-mdx/bin/reverse_sync/list_patcher.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,15 @@ def split_list_items(content: str) -> List[str]:
9898
return items
9999

100100

101+
def _get_ordered_list_start(content: str) -> Optional[int]:
102+
"""MDX 리스트 콘텐츠에서 첫 번째 순서 번호를 반환한다."""
103+
for line in content.split('\n'):
104+
m = re.match(r'^\s*(\d+)\.\s+', line)
105+
if m:
106+
return int(m.group(1))
107+
return None
108+
109+
101110
def _regenerate_list_from_parent(
102111
change: BlockChange,
103112
parent: Optional[BlockMapping],
@@ -125,11 +134,16 @@ def _regenerate_list_from_parent(
125134
change.new_block.content, change.new_block.type)
126135
xhtml_text = transfer_text_changes(
127136
old_plain, new_plain, parent.xhtml_plain_text)
128-
return [{
137+
fallback_patch: Dict[str, object] = {
129138
'xhtml_xpath': parent.xhtml_xpath,
130139
'old_plain_text': parent.xhtml_plain_text,
131140
'new_plain_text': xhtml_text,
132-
}]
141+
}
142+
old_start = _get_ordered_list_start(change.old_block.content)
143+
new_start = _get_ordered_list_start(change.new_block.content)
144+
if old_start is not None and new_start is not None and old_start != new_start:
145+
fallback_patch['ol_start'] = new_start
146+
return [fallback_patch]
133147

134148
new_inner = mdx_block_to_inner_xhtml(
135149
change.new_block.content, change.new_block.type)
@@ -139,11 +153,16 @@ def _regenerate_list_from_parent(
139153
if block_lost:
140154
new_inner = apply_lost_info(new_inner, block_lost)
141155

142-
return [{
156+
patch: Dict[str, object] = {
143157
'xhtml_xpath': parent.xhtml_xpath,
144158
'old_plain_text': parent.xhtml_plain_text,
145159
'new_inner_xhtml': new_inner,
146-
}]
160+
}
161+
old_start = _get_ordered_list_start(change.old_block.content)
162+
new_start = _get_ordered_list_start(change.new_block.content)
163+
if old_start is not None and new_start is not None and old_start != new_start:
164+
patch['ol_start'] = new_start
165+
return [patch]
147166

148167

149168
def build_list_item_patches(

confluence-mdx/bin/reverse_sync/xhtml_patcher.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str:
7373
if current_plain_with_emoticons.strip() != old_text.strip():
7474
continue
7575
_replace_inner_html(element, patch['new_inner_xhtml'])
76+
if 'ol_start' in patch and isinstance(element, Tag) and element.name == 'ol':
77+
new_start = patch['ol_start']
78+
if new_start == 1:
79+
if 'start' in element.attrs:
80+
del element['start']
81+
else:
82+
element['start'] = str(new_start)
7683
else:
7784
old_text = patch['old_plain_text']
7885
new_text = patch['new_plain_text']
@@ -83,6 +90,13 @@ def patch_xhtml(xhtml: str, patches: List[Dict[str, str]]) -> str:
8390
if current_plain_with_emoticons.strip() != old_text.strip():
8491
continue
8592
_apply_text_changes(element, old_text, new_text)
93+
if 'ol_start' in patch and isinstance(element, Tag) and element.name == 'ol':
94+
new_start = patch['ol_start']
95+
if new_start == 1:
96+
if 'start' in element.attrs:
97+
del element['start']
98+
else:
99+
element['start'] = str(new_start)
86100

87101
result = str(soup)
88102
result = _restore_cdata(result)
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
"""_get_ordered_list_start 단위 테스트."""
2+
from reverse_sync.list_patcher import _get_ordered_list_start
3+
4+
5+
class TestGetOrderedListStart:
6+
def test_starts_at_one(self):
7+
assert _get_ordered_list_start('1. first\n2. second\n') == 1
8+
9+
def test_starts_at_three(self):
10+
assert _get_ordered_list_start('3. first\n4. second\n') == 3
11+
12+
def test_starts_at_zero(self):
13+
assert _get_ordered_list_start('0. zeroth\n1. first\n') == 0
14+
15+
def test_unordered_list_returns_none(self):
16+
assert _get_ordered_list_start('* item\n* item2\n') is None
17+
18+
def test_empty_returns_none(self):
19+
assert _get_ordered_list_start('') is None
20+
21+
def test_indented_ordered_list(self):
22+
"""들여쓰기된 순서 목록도 인식한다."""
23+
assert _get_ordered_list_start(' 5. first\n 6. second\n') == 5

confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import pytest
2+
from bs4 import BeautifulSoup
23
from reverse_sync.xhtml_patcher import patch_xhtml
34

45

@@ -440,3 +441,51 @@ def test_list_patch_with_emoticon_uses_mapping_plain_text():
440441
result = patch_xhtml(xhtml, patches)
441442
assert '검색할 수 있습니다.' in result, f'리스트 텍스트 변경이 적용되지 않음: {result}'
442443
assert '검색이 가능합니다.' not in result, f'기존 문구가 남아 있음: {result}'
444+
445+
446+
class TestOlStartPatch:
447+
"""<ol start="N"> 속성 변경 패치 테스트."""
448+
449+
def test_set_start_attribute_via_inner_xhtml(self):
450+
"""new_inner_xhtml 경로에서 ol_start로 start 속성을 설정한다."""
451+
xhtml = '<ol><li><p>first</p></li><li><p>second</p></li></ol>'
452+
patches = [{
453+
'xhtml_xpath': 'ol[1]',
454+
'old_plain_text': 'firstsecond',
455+
'new_inner_xhtml': '<li><p>updated first</p></li><li><p>second</p></li>',
456+
'ol_start': 3,
457+
}]
458+
result = patch_xhtml(xhtml, patches)
459+
soup = BeautifulSoup(result, 'html.parser')
460+
ol = soup.find('ol')
461+
assert ol['start'] == '3'
462+
assert 'updated first' in result
463+
464+
def test_remove_start_attribute_when_ol_start_is_one(self):
465+
"""ol_start=1이면 기존 start 속성을 제거한다."""
466+
xhtml = '<ol start="3"><li><p>first</p></li></ol>'
467+
patches = [{
468+
'xhtml_xpath': 'ol[1]',
469+
'old_plain_text': 'first',
470+
'new_inner_xhtml': '<li><p>first item</p></li>',
471+
'ol_start': 1,
472+
}]
473+
result = patch_xhtml(xhtml, patches)
474+
soup = BeautifulSoup(result, 'html.parser')
475+
ol = soup.find('ol')
476+
assert ol.get('start') is None
477+
478+
def test_set_start_attribute_via_text_transfer(self):
479+
"""텍스트 전이 경로에서도 ol_start가 start 속성으로 적용된다."""
480+
xhtml = '<ol><li>first item</li></ol>'
481+
patches = [{
482+
'xhtml_xpath': 'ol[1]',
483+
'old_plain_text': 'first item',
484+
'new_plain_text': 'updated item',
485+
'ol_start': 5,
486+
}]
487+
result = patch_xhtml(xhtml, patches)
488+
soup = BeautifulSoup(result, 'html.parser')
489+
ol = soup.find('ol')
490+
assert ol['start'] == '5'
491+
assert 'updated' in result

0 commit comments

Comments
 (0)