From bf78b93f5e24a62ebe27bb55712d125506fb595c Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:18:52 +0900 Subject: [PATCH 1/7] =?UTF-8?q?confluence-mdx:=20reverse=5Fsync=20?= =?UTF-8?q?=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=ED=95=AD=EB=AA=A9=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=AF=B8=EB=B0=98=EC=98=81=20=EB=B2=84=EA=B7=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=EB=A5=BC?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numbered list에서 빈 항목(12.)을 제거하고 이전 항목에 병합할 때, preserved anchor()가 있는 리스트의 XHTML 패치가 누락되는 현상을 재현합니다. Co-Authored-By: Claude Opus 4.6 --- .../tests/reverse-sync/798064641/improved.mdx | 5 +- .../tests/reverse-sync/798064641/original.mdx | 2 +- .../tests/reverse-sync/798064641/page.v1.yaml | 1 + .../tests/test_reverse_sync_patch_builder.py | 115 ++++++++++++++++++ 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/confluence-mdx/tests/reverse-sync/798064641/improved.mdx b/confluence-mdx/tests/reverse-sync/798064641/improved.mdx index 7135a6fd5..2f2eaad98 100644 --- a/confluence-mdx/tests/reverse-sync/798064641/improved.mdx +++ b/confluence-mdx/tests/reverse-sync/798064641/improved.mdx @@ -38,11 +38,10 @@ Email 발송을 위해서는 SMTP 서버가 필요하며, QueryPie에서는 SMTP 7. **Sender Name**: 메일 발신자의 이름으로 표시될 내용을 입력합니다. 8. **Support Email Address (Reply-to)**: 쿼리파이에서 SMTP 서버로 발송하는 메일은 모두 발신전용 메일입니다. Sender Email Address에 입력한 발신전용 메일 주소는 사용자의 회신 메일을 받지 못하므로 경우에 따라 메일 수신자가 기술지원을 요청해야 할 필요가 있을 때 메일을 수신할 주소를 제공할 필요가 있습니다.
10.2.2 기준 암호초기화 email 템플릿, 2차 인증용 인증코드 메일 템플릿에서 여기서 지정한 support email address 를 사용하도록 되어 있습니다. 9. **SMTP Server Requires Authentication 스위치**: SMTP 서버에 접속 및 Email 발송을 위해 인증 필요 여부에 따라 On 또는 Off를 선택합니다. On을 할 경우 User, Password를 입력해야 합니다. - 1. User: 인증을 위한 사용자를 입력합니다. 형식은 일반적으로 email 주소입니다. - 2. Password: 인증 계정의 암호를 입력합니다. + 1. User : 인증을 위한 사용자를 입력합니다. 형식은 일반적으로 email 주소입니다. + 2. Password : 인증 계정의 암호를 입력합니다. 10. **Send Workflow Notification via Email 스위치**: 결재 요청 수신, 승인/반려 처리 등 워크플로우 관련 이벤트 발생 시 관련자에게 이메일 알림을 발송할지 여부를 선택합니다. 11. **Test 버튼**: SMTP 설정이 접속에 문제 없는지 확인합니다.
- 12.
SMTP 설정 팝업 다이얼로그
diff --git a/confluence-mdx/tests/reverse-sync/798064641/original.mdx b/confluence-mdx/tests/reverse-sync/798064641/original.mdx index 8ed4cb32e..749363a3e 100644 --- a/confluence-mdx/tests/reverse-sync/798064641/original.mdx +++ b/confluence-mdx/tests/reverse-sync/798064641/original.mdx @@ -33,7 +33,7 @@ Email 발송을 위해서는 SMTP 서버가 필요하며, QueryPie에서는 SMTP 2. **Host** : SMTP 서버의 hostname (FQDN) 을 입력합니다. 3. **Secure** : 암호화 전송방식을 선택합니다. TLS, STARTTLS 중 하나를 선택할 수 있습니다. 기본값은 TLS입니다.
STARTTLS는 SMTP Server가 TLS를 사용할 수 없다면 평문을 사용합니다. 4. **Port** : SMTP 서버가 사용하는 포트 번호를 입력합니다. - 5. **Timeout (milliseconds)** : SMTP의 응답을 대기하는 임계값을 입력합니다. 기본값은 60000입니다. 반드시 필요한 경우가 아니라면 가급적 이 값을 수정하지 않도록 합니다. + 5. **Timeout (milliseconds)**: SMTP의 응답을 대기하는 임계값을 입력합니다. 기본값은 60000입니다. 반드시 필요한 경우가 아니라면 가급적 이 값을 수정하지 않도록 합니다. 6. **Sender Email Address** : 메일 발신자의 메일 주소를 입력합니다. 7. **Sender Name** : 메일 발신자의 이름으로 표시될 내용을 입력합니다. 8. **Support Email Address (Reply-to)** : 쿼리파이에서 SMTP 서버로 발송하는 메일은 모두 발신전용 메일입니다. Sender Email Address에 입력한 발신전용 메일 주소는 사용자의 회신 메일을 받지 못하므로 경우에 따라 메일 수신자가 기술지원을 요청해야 할 필요가 있을 때 메일을 수신할 주소를 제공할 필요가 있습니다.
10.2.2 기준 암호초기화 email 템플릿, 2차 인증용 인증코드 메일 템플릿에서 여기서 지정한 support email address 를 사용하도록 되어 있습니다. diff --git a/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml b/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml index 4c08e8152..a1340d1e7 100644 --- a/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml +++ b/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml @@ -1,5 +1,6 @@ id: '798064641' title: Email 연동 +expected_status: fail _links: base: https://querypie.atlassian.net/wiki webui: /spaces/QM/pages/798064641/Email diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index 7bbe231d7..03f2eaae4 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -2464,3 +2464,118 @@ def test_text_also_changed_no_extra_replace_fragment(self): assert len(rf_patches) == 0, ( f"Space+text change should not produce list replace_fragment: {rf_patches}" ) + + +# ── numbered list item 제거 시 XHTML 반영 실패 ── + + +class TestListItemRemovalWithPreservedAnchor: + """numbered list에서 빈 항목(12.)을 제거하고 콘텐츠를 이전 항목에 병합할 때 + preserved anchor()가 있는 리스트의 XHTML 패치가 누락되는 버그. + + 재현 시나리오 (page 798064641, integrating-with-email.mdx): + Original MDX: + 11. **Test 버튼** : SMTP 설정이 접속에 문제 없는지 확인합니다.
+ 12. +
+ Improved MDX: + 11. **Test 버튼**: SMTP 설정이 접속에 문제 없는지 확인합니다.
+
+ + 현상: item 12의
  • 가 있어 preserved anchor로 분류 → + whole-fragment 교체가 차단되고, text-level 패치는 항목 구조 변경을 + 처리하지 못해 빈
  • 가 XHTML에 남음 → FC가 "12." 재생성. + """ + + def _setup_sidecar(self, xpath: str, mdx_idx: int): + entry = _make_sidecar(xpath, [mdx_idx]) + return {mdx_idx: entry} + + def test_list_item_removal_merged_into_previous_item(self): + """빈 리스트 항목(2.)을 제거하고 figure를 이전 항목(1.)에 병합하면 + XHTML 패치 적용 후 빈
  • 가 사라져야 한다. + + 최소 재현: 2개 항목의
      — item 1에 텍스트, item 2에 . + improved MDX에서 item 2를 제거하고 figure를 item 1에 병합. + + 현상: preserved anchor() 때문에 replace_fragment 차단, + text-level 패치만 적용 → 빈
    1. 제거 불가 → FC가 "2." 재생성 + """ + # XHTML:
        with 2 items, item 2 has (preserved anchor) + xhtml_text = ( + '
          ' + '
        1. Test 버튼 : 확인합니다.

        2. ' + '
        3. ' + '' + '

          캡션

          ' + '

        4. ' + '
        ' + ) + + list_mapping = _make_mapping( + 'list-1', + 'Test 버튼 : 확인합니다.캡션', + xpath='ol[1]', + type_='list', + ) + list_mapping.xhtml_text = xhtml_text + + mappings = [list_mapping] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + # Original MDX: 2 items (item 2 is empty "2." followed by figure) + old_content = ( + '1. **Test 버튼** : 확인합니다.
        \n' + '2.\n' + '
        \n' + ' 캡션\n' + '
        \n' + ' 캡션\n' + '
        \n' + '
        \n' + ) + + # Improved MDX: item 2 removed, figure merged into item 1 + new_content = ( + '1. **Test 버튼**: 확인합니다.
        \n' + '
        \n' + ' 캡션\n' + '
        \n' + ' 캡션\n' + '
        \n' + '
        \n' + ) + + change = _make_change(0, old_content, new_content, type_='list') + mdx_to_sidecar = self._setup_sidecar('ol[1]', 0) + + sidecar_block = SidecarBlock( + 0, 'ol[1]', xhtml_text, sha256_text(xhtml_text), (1, 8), + ) + roundtrip_sidecar = _make_roundtrip_sidecar([sidecar_block]) + + patches, _, skipped = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping, + roundtrip_sidecar=roundtrip_sidecar, + ) + + # 패치가 생성되어야 한다 + assert len(patches) >= 1, ( + f"리스트 항목 제거 변경에 대한 패치가 생성되어야 합니다. " + f"patches={patches}, skipped={skipped}" + ) + + # 핵심 검증: 패치를 XHTML에 적용한 후 빈
      1. 가 제거되어야 한다 + patched = patch_xhtml(xhtml_text, patches) + + from bs4 import BeautifulSoup + soup = BeautifulSoup(patched, 'html.parser') + ol = soup.find('ol') + assert ol is not None, "패치 후
          이 존재해야 합니다." + items = ol.find_all('li', recursive=False) + # item 2(빈 항목)가 제거되어 1개만 남아야 함 + assert len(items) == 1, ( + f"빈
        1. 항목이 제거되어 1개만 남아야 합니다. " + f"실제 항목 수: {len(items)}, patched XHTML: {patched[:300]}" + ) From c5f105467bee548565fea229305931ceef498dcc Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:29:48 +0900 Subject: [PATCH 2/7] =?UTF-8?q?fix(reverse=5Fsync):=20preserved=20anchor?= =?UTF-8?q?=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EC=95=84=EC=9D=B4?= =?UTF-8?q?=ED=85=9C=20=EC=A0=9C=EA=B1=B0=EB=A5=BC=20XHTML=EC=97=90=20?= =?UTF-8?q?=EB=B0=98=EC=98=81=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit numbered list에서 빈 항목(12.)을 제거하고 이전 항목에 병합할 때, preserved anchor()가 있는 리스트의 XHTML 패치가 누락되는 문제를 수정합니다. - _build_list_item_merge_patch: 아이템 수 변경 시 XHTML DOM 직접 조작 - 제거된 아이템의 등을 이전 아이템으로 이동 - 빈

          제거하여 불필요한
          방지 - 텍스트 변경은 _apply_text_changes로 처리 Co-Authored-By: Claude Opus 4.6 --- .../bin/reverse_sync/patch_builder.py | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 91dc3a35b..0a4eec03e 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -397,6 +397,102 @@ def _classify_table_fragment_skip( return None +def _count_mdx_list_items(content: str) -> Dict[int, int]: + """MDX 리스트 콘텐츠의 각 들여쓰기 레벨별 아이템 수를 반환한다. + + 반환값: {indent_chars: count} dict. + ``2.`` 처럼 마커 뒤에 내용이 없는 빈 항목도 카운트한다. + """ + _is_item_re = re.compile(r'^(\s*)(?:\d+\.|\*|-|\+)(?:\s|$)') + counts: Dict[int, int] = {} + for line in content.split('\n'): + m = _is_item_re.match(line) + if m: + indent = len(m.group(1)) + counts[indent] = counts.get(indent, 0) + 1 + return counts + + +def _build_list_item_merge_patch( + mapping: BlockMapping, + old_content: str, + new_content: str, + old_plain: str, + new_plain: str, +) -> Optional[Dict[str, Any]]: + """preserved anchor 리스트에서 아이템이 제거된 경우 XHTML DOM을 조작하여 + replace_fragment 패치를 생성한다. + + 제거된 아이템의 자식 요소( 등)를 이전 아이템으로 이동하고 + 빈

        2. 를 제거한다. 텍스트 변경은 _apply_text_changes로 처리한다. + """ + from bs4 import BeautifulSoup + from reverse_sync.xhtml_patcher import _apply_text_changes + + old_counts = _count_mdx_list_items(old_content) + new_counts = _count_mdx_list_items(new_content) + + # 아이템 수가 감소한 indent 레벨 찾기 + removals = [] # (indent, removed_count) + for indent, old_count in sorted(old_counts.items()): + new_count = new_counts.get(indent, 0) + if new_count < old_count: + removals.append((indent, old_count - new_count)) + + if not removals: + return None + + soup = BeautifulSoup(mapping.xhtml_text, 'html.parser') + + for indent, removed_count in removals: + # indent 레벨에 해당하는
            /
              찾기 + # indent 0 → root list, indent 4 → nested list (depth 1), etc. + depth = indent // 4 # 일반적으로 MDX indent는 4칸 단위 + target_lists = soup.find_all(['ol', 'ul']) + + # depth에 해당하는 리스트 찾기: 중첩 깊이로 필터링 + def _get_nesting_depth(el): + d = 0 + parent = el.parent + while parent: + if parent.name in ('ol', 'ul'): + d += 1 + parent = parent.parent + return d + + candidates = [el for el in target_lists if _get_nesting_depth(el) == depth] + + for target_ol in candidates: + items = target_ol.find_all('li', recursive=False) + # 끝에서부터 removed_count 개의 아이템을 이전 아이템에 병합 + for _ in range(removed_count): + if len(items) < 2: + break + last_li = items[-1] + prev_li = items[-2] + # last_li의 모든 자식을 prev_li로 이동 + # 빈

              (공백만 포함)는 이동하지 않음 + for child in list(last_li.children): + if (child.name == 'p' + and child.get_text(strip=True) == ''): + child.decompose() + continue + prev_li.append(child.extract()) + last_li.decompose() + items = target_ol.find_all('li', recursive=False) + + # 텍스트 변경 적용 + root = soup.find(['ol', 'ul']) + if root and old_plain != new_plain: + _apply_text_changes(root, old_plain, new_plain) + + return { + 'action': 'replace_fragment', + 'xhtml_xpath': mapping.xhtml_xpath, + 'new_element_xhtml': str(soup), + } + + def _emit_replacement_fragment(block: MdxBlock) -> str: """Block content를 현재 forward emitter 기준 fragment로 변환한다.""" parsed_blocks = [parsed for parsed in parse_mdx(block.content) if parsed.type != "empty"] @@ -958,6 +1054,21 @@ def _mark_used(block_id: str, m: BlockMapping): ) ) continue + # preserved anchor list + 아이템 수 변경: DOM 직접 조작으로

            • 병합/제거 + if (mapping is not None + and _contains_preserved_anchor_markup(mapping.xhtml_text) + and has_content_change): + merge_patch = _build_list_item_merge_patch( + mapping, + change.old_block.content, + change.new_block.content, + _old_plain, + _new_plain, + ) + if merge_patch is not None: + _mark_used(mapping.block_id, mapping) + patches.append(merge_patch) + continue # preserved anchor list: text-level 패치로 ac:/ri: XHTML 구조 보존 # (_apply_mdx_diff_to_xhtml 경로) # 같은 부모의 다중 변경은 순차 집계한다 (이전 결과에 누적 적용) From ed8d373f0ee27ee97811a6d66f08f9092edd06a1 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:30:07 +0900 Subject: [PATCH 3/7] =?UTF-8?q?confluence-mdx:=20798064641=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=BC=80=EC=9D=B4=EC=8A=A4=20expected=5Fstat?= =?UTF-8?q?us=EB=A5=BC=20pass=EB=A1=9C=20=EB=B3=80=EA=B2=BD=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 Co-Authored-By: Claude Opus 4.6 --- confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml b/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml index a1340d1e7..21f111c1f 100644 --- a/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml +++ b/confluence-mdx/tests/reverse-sync/798064641/page.v1.yaml @@ -1,6 +1,6 @@ id: '798064641' title: Email 연동 -expected_status: fail +expected_status: pass _links: base: https://querypie.atlassian.net/wiki webui: /spaces/QM/pages/798064641/Email From 07afc53e4e43a4d0c5af133d5992b1675ea70954 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:33:05 +0900 Subject: [PATCH 4/7] =?UTF-8?q?confluence-mdx:=20798064641=20original.mdx?= =?UTF-8?q?=20fixture=EB=A5=BC=20=EC=9B=90=EB=B3=B8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=B5=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 git show main:src/content/...로 덮어써서 의도치 않게 변경된 fixture를 원래 테스트케이스 생성 시의 내용으로 복원합니다. Co-Authored-By: Claude Opus 4.6 --- confluence-mdx/tests/reverse-sync/798064641/original.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/confluence-mdx/tests/reverse-sync/798064641/original.mdx b/confluence-mdx/tests/reverse-sync/798064641/original.mdx index 749363a3e..8ed4cb32e 100644 --- a/confluence-mdx/tests/reverse-sync/798064641/original.mdx +++ b/confluence-mdx/tests/reverse-sync/798064641/original.mdx @@ -33,7 +33,7 @@ Email 발송을 위해서는 SMTP 서버가 필요하며, QueryPie에서는 SMTP 2. **Host** : SMTP 서버의 hostname (FQDN) 을 입력합니다. 3. **Secure** : 암호화 전송방식을 선택합니다. TLS, STARTTLS 중 하나를 선택할 수 있습니다. 기본값은 TLS입니다.
              STARTTLS는 SMTP Server가 TLS를 사용할 수 없다면 평문을 사용합니다. 4. **Port** : SMTP 서버가 사용하는 포트 번호를 입력합니다. - 5. **Timeout (milliseconds)**: SMTP의 응답을 대기하는 임계값을 입력합니다. 기본값은 60000입니다. 반드시 필요한 경우가 아니라면 가급적 이 값을 수정하지 않도록 합니다. + 5. **Timeout (milliseconds)** : SMTP의 응답을 대기하는 임계값을 입력합니다. 기본값은 60000입니다. 반드시 필요한 경우가 아니라면 가급적 이 값을 수정하지 않도록 합니다. 6. **Sender Email Address** : 메일 발신자의 메일 주소를 입력합니다. 7. **Sender Name** : 메일 발신자의 이름으로 표시될 내용을 입력합니다. 8. **Support Email Address (Reply-to)** : 쿼리파이에서 SMTP 서버로 발송하는 메일은 모두 발신전용 메일입니다. Sender Email Address에 입력한 발신전용 메일 주소는 사용자의 회신 메일을 받지 못하므로 경우에 따라 메일 수신자가 기술지원을 요청해야 할 필요가 있을 때 메일을 수신할 주소를 제공할 필요가 있습니다.
              10.2.2 기준 암호초기화 email 템플릿, 2차 인증용 인증코드 메일 템플릿에서 여기서 지정한 support email address 를 사용하도록 되어 있습니다. From 8d12fe87308c17152ddea1a78869d09c462da2b1 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:34:06 +0900 Subject: [PATCH 5/7] =?UTF-8?q?confluence-mdx:=20798064641=20improved.mdx?= =?UTF-8?q?=20fixture=EB=A5=BC=20=EC=A0=95=EB=A6=AC=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 기존 fixture에서 12. 제거만 반영하고, 의도치 않은 콜론 공백 변경을 복원합니다. Co-Authored-By: Claude Opus 4.6 --- confluence-mdx/tests/reverse-sync/798064641/improved.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/confluence-mdx/tests/reverse-sync/798064641/improved.mdx b/confluence-mdx/tests/reverse-sync/798064641/improved.mdx index 2f2eaad98..e963a3020 100644 --- a/confluence-mdx/tests/reverse-sync/798064641/improved.mdx +++ b/confluence-mdx/tests/reverse-sync/798064641/improved.mdx @@ -38,8 +38,8 @@ Email 발송을 위해서는 SMTP 서버가 필요하며, QueryPie에서는 SMTP 7. **Sender Name**: 메일 발신자의 이름으로 표시될 내용을 입력합니다. 8. **Support Email Address (Reply-to)**: 쿼리파이에서 SMTP 서버로 발송하는 메일은 모두 발신전용 메일입니다. Sender Email Address에 입력한 발신전용 메일 주소는 사용자의 회신 메일을 받지 못하므로 경우에 따라 메일 수신자가 기술지원을 요청해야 할 필요가 있을 때 메일을 수신할 주소를 제공할 필요가 있습니다.
              10.2.2 기준 암호초기화 email 템플릿, 2차 인증용 인증코드 메일 템플릿에서 여기서 지정한 support email address 를 사용하도록 되어 있습니다. 9. **SMTP Server Requires Authentication 스위치**: SMTP 서버에 접속 및 Email 발송을 위해 인증 필요 여부에 따라 On 또는 Off를 선택합니다. On을 할 경우 User, Password를 입력해야 합니다. - 1. User : 인증을 위한 사용자를 입력합니다. 형식은 일반적으로 email 주소입니다. - 2. Password : 인증 계정의 암호를 입력합니다. + 1. User: 인증을 위한 사용자를 입력합니다. 형식은 일반적으로 email 주소입니다. + 2. Password: 인증 계정의 암호를 입력합니다. 10. **Send Workflow Notification via Email 스위치**: 결재 요청 수신, 승인/반려 처리 등 워크플로우 관련 이벤트 발생 시 관련자에게 이메일 알림을 발송할지 여부를 선택합니다. 11. **Test 버튼**: SMTP 설정이 접속에 문제 없는지 확인합니다.
              From 5b3439e6953ce1425d555864247ff4c7bcd22c8c Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 00:49:17 +0900 Subject: [PATCH 6/7] =?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:463): 여기서는 삭제 여부를 들여쓰기별 개수 감소로만 계산한 뒤, 같은 depth의 모든
                /
                  에서 마지막
                • 를 병합합니다. 그래서 preserved anchor가 있는 항목이 중간에서 삭제되거나 같은 depth의 하위 리스트가 둘 이상 있으면 실제로 바뀌지 않은 마지막 항목까지 함께 병합되어 XHTML이 잘못 생성됩니다. --- .../bin/reverse_sync/patch_builder.py | 168 ++++++++++------- .../tests/test_reverse_sync_patch_builder.py | 172 ++++++++++++++++++ 2 files changed, 279 insertions(+), 61 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py index 0a4eec03e..f0d446a67 100644 --- a/confluence-mdx/bin/reverse_sync/patch_builder.py +++ b/confluence-mdx/bin/reverse_sync/patch_builder.py @@ -397,20 +397,90 @@ def _classify_table_fragment_skip( return None -def _count_mdx_list_items(content: str) -> Dict[int, int]: - """MDX 리스트 콘텐츠의 각 들여쓰기 레벨별 아이템 수를 반환한다. +def _extract_mdx_list_entries(content: str) -> List[Dict[str, Any]]: + """MDX 리스트 블록을 path 기반 항목 목록으로 파싱한다.""" + item_re = re.compile(r'^(\s*)(?:\d+\.|\*|-|\+)(?:\s(.*)|$)') + entries: List[Dict[str, Any]] = [] + stack: List[Tuple[int, Tuple[int, ...]]] = [] + current: Optional[Dict[str, Any]] = None + + for raw_line in content.split('\n'): + m = item_re.match(raw_line) + if not m: + if current is not None: + current['continuation_lines'].append(raw_line) + continue - 반환값: {indent_chars: count} dict. - ``2.`` 처럼 마커 뒤에 내용이 없는 빈 항목도 카운트한다. - """ - _is_item_re = re.compile(r'^(\s*)(?:\d+\.|\*|-|\+)(?:\s|$)') - counts: Dict[int, int] = {} - for line in content.split('\n'): - m = _is_item_re.match(line) - if m: - indent = len(m.group(1)) - counts[indent] = counts.get(indent, 0) + 1 - return counts + indent = len(m.group(1)) + marker_text = (m.group(2) or '').strip() + + while stack and indent < stack[-1][0]: + stack.pop() + + if stack and indent == stack[-1][0]: + parent_path = stack[-2][1] if len(stack) >= 2 else () + index = stack[-1][1][-1] + 1 + stack.pop() + elif stack and indent > stack[-1][0]: + parent_path = stack[-1][1] + index = 0 + else: + parent_path = () + index = 0 + + path = parent_path + (index,) + current = { + 'path': path, + 'indent': indent, + 'marker_text': marker_text, + 'continuation_lines': [], + } + entries.append(current) + stack.append((indent, path)) + + return entries + + +def _normalize_list_continuation(lines: List[str]) -> str: + """continuation line 비교용 정규화 문자열.""" + return '\n'.join(line.strip() for line in lines if line.strip()) + + +def _find_removed_blank_item_paths( + old_content: str, + new_content: str, +) -> List[Tuple[int, ...]]: + """이전 형제로 병합된 것으로 보이는 빈 리스트 항목 path를 찾는다.""" + old_entries = _extract_mdx_list_entries(old_content) + new_entries = _extract_mdx_list_entries(new_content) + new_by_path = {entry['path']: entry for entry in new_entries} + removed_paths: List[Tuple[int, ...]] = [] + + for old_entry in old_entries: + path = old_entry['path'] + if old_entry['marker_text'] or path[-1] == 0: + continue + + old_payload = _normalize_list_continuation(old_entry['continuation_lines']) + if not old_payload: + continue + + new_same_path = new_by_path.get(path) + if new_same_path is not None and not new_same_path['marker_text']: + continue + + prev_path = path[:-1] + (path[-1] - 1,) + new_prev = new_by_path.get(prev_path) + if new_prev is None: + continue + + new_prev_payload = _normalize_list_continuation(new_prev['continuation_lines']) + if old_payload not in new_prev_payload: + continue + + removed_paths.append(path) + + return removed_paths def _build_list_item_merge_patch( @@ -427,62 +497,38 @@ def _build_list_item_merge_patch( 빈
                • 를 제거한다. 텍스트 변경은 _apply_text_changes로 처리한다. """ from bs4 import BeautifulSoup + from reverse_sync.reconstructors import _find_list_item_by_path from reverse_sync.xhtml_patcher import _apply_text_changes - old_counts = _count_mdx_list_items(old_content) - new_counts = _count_mdx_list_items(new_content) - - # 아이템 수가 감소한 indent 레벨 찾기 - removals = [] # (indent, removed_count) - for indent, old_count in sorted(old_counts.items()): - new_count = new_counts.get(indent, 0) - if new_count < old_count: - removals.append((indent, old_count - new_count)) - - if not removals: + removed_paths = _find_removed_blank_item_paths(old_content, new_content) + if not removed_paths: return None soup = BeautifulSoup(mapping.xhtml_text, 'html.parser') + root = soup.find(['ol', 'ul']) + if root is None: + return None - for indent, removed_count in removals: - # indent 레벨에 해당하는
                    /
                      찾기 - # indent 0 → root list, indent 4 → nested list (depth 1), etc. - depth = indent // 4 # 일반적으로 MDX indent는 4칸 단위 - target_lists = soup.find_all(['ol', 'ul']) - - # depth에 해당하는 리스트 찾기: 중첩 깊이로 필터링 - def _get_nesting_depth(el): - d = 0 - parent = el.parent - while parent: - if parent.name in ('ol', 'ul'): - d += 1 - parent = parent.parent - return d - - candidates = [el for el in target_lists if _get_nesting_depth(el) == depth] - - for target_ol in candidates: - items = target_ol.find_all('li', recursive=False) - # 끝에서부터 removed_count 개의 아이템을 이전 아이템에 병합 - for _ in range(removed_count): - if len(items) < 2: - break - last_li = items[-1] - prev_li = items[-2] - # last_li의 모든 자식을 prev_li로 이동 - # 빈

                      (공백만 포함)는 이동하지 않음 - for child in list(last_li.children): - if (child.name == 'p' - and child.get_text(strip=True) == ''): - child.decompose() - continue - prev_li.append(child.extract()) - last_li.decompose() - items = target_ol.find_all('li', recursive=False) + applied = False + for path in sorted(removed_paths, reverse=True): + removed_li = _find_list_item_by_path(root, list(path)) + prev_li = _find_list_item_by_path( + root, list(path[:-1] + (path[-1] - 1,))) + if removed_li is None or prev_li is None: + continue + + for child in list(removed_li.children): + if child.name == 'p' and child.get_text(strip=True) == '': + child.decompose() + continue + prev_li.append(child.extract()) + removed_li.decompose() + applied = True + + if not applied: + return None # 텍스트 변경 적용 - root = soup.find(['ol', 'ul']) if root and old_plain != new_plain: _apply_text_changes(root, old_plain, new_plain) diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index 03f2eaae4..f0a2d8463 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -2579,3 +2579,175 @@ def test_list_item_removal_merged_into_previous_item(self): f"빈

                    • 항목이 제거되어 1개만 남아야 합니다. " f"실제 항목 수: {len(items)}, patched XHTML: {patched[:300]}" ) + + def test_middle_blank_item_removal_merges_that_item_only(self): + """중간 빈 항목이 제거되면 마지막 항목이 아니라 해당 항목만 이전 형제로 병합해야 한다.""" + xhtml_text = ( + '
                        ' + '
                      1. One

                      2. ' + '
                      3. ' + '' + '

                        캡션

                        ' + '

                      4. ' + '
                      5. Three

                      6. ' + '
                      ' + ) + + list_mapping = _make_mapping( + 'list-middle', + 'One캡션Three', + xpath='ol[1]', + type_='list', + ) + list_mapping.xhtml_text = xhtml_text + + mappings = [list_mapping] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + old_content = ( + '1. One :\n' + '2.\n' + '
                      \n' + ' 캡션\n' + '
                      \n' + ' 캡션\n' + '
                      \n' + '
                      \n' + '3. Three\n' + ) + new_content = ( + '1. One:\n' + '
                      \n' + ' 캡션\n' + '
                      \n' + ' 캡션\n' + '
                      \n' + '
                      \n' + '2. Three\n' + ) + + change = _make_change(0, old_content, new_content, type_='list') + mdx_to_sidecar = self._setup_sidecar('ol[1]', 0) + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock(0, 'ol[1]', xhtml_text, sha256_text(xhtml_text), (1, 9)), + ]) + + patches, _, skipped = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping, + roundtrip_sidecar=roundtrip_sidecar, + ) + + assert len(patches) >= 1, ( + f"중간 빈 항목 제거 변경에 대한 패치가 생성되어야 합니다. " + f"patches={patches}, skipped={skipped}" + ) + + patched = patch_xhtml(xhtml_text, patches) + + from bs4 import BeautifulSoup + soup = BeautifulSoup(patched, 'html.parser') + ol = soup.find('ol') + assert ol is not None + items = ol.find_all('li', recursive=False) + assert len(items) == 2, ( + f"중간 빈 항목만 제거되어 2개 항목이 남아야 합니다. " + f"실제 항목 수: {len(items)}, patched XHTML: {patched[:300]}" + ) + assert 'Three' in items[1].get_text(), ( + f"마지막 항목 텍스트가 보존되어야 합니다. patched XHTML: {patched[:300]}" + ) + assert items[0].find('ac:image') is not None, ( + f"제거된 빈 항목의 preserved anchor가 이전 항목으로 이동해야 합니다. " + f"patched XHTML: {patched[:300]}" + ) + + def test_nested_blank_item_removal_does_not_touch_other_nested_lists(self): + """같은 depth의 다른 하위 리스트는 건드리지 않고 제거된 경로만 병합해야 한다.""" + xhtml_text = ( + '
                        ' + '
                      1. Parent A

                          ' + '
                        1. Step A1

                        2. ' + '
                        3. ' + '' + '

                          캡션A

                          ' + '

                        4. ' + '
                      2. ' + '
                      3. Parent B

                          ' + '
                        1. Step B1

                        2. ' + '
                        3. Step B2

                        4. ' + '
                      4. ' + '
                      ' + ) + + list_mapping = _make_mapping( + 'list-nested', + 'Parent AStep A1캡션AParent BStep B1Step B2', + xpath='ol[1]', + type_='list', + ) + list_mapping.xhtml_text = xhtml_text + + mappings = [list_mapping] + xpath_to_mapping = {m.xhtml_xpath: m for m in mappings} + + old_content = ( + '1. Parent A\n' + ' 1. Step A1 :\n' + ' 2.\n' + '
                      \n' + ' 캡션A\n' + '
                      \n' + ' 캡션A\n' + '
                      \n' + '
                      \n' + '2. Parent B\n' + ' 1. Step B1\n' + ' 2. Step B2\n' + ) + new_content = ( + '1. Parent A\n' + ' 1. Step A1:\n' + '
                      \n' + ' 캡션A\n' + '
                      \n' + ' 캡션A\n' + '
                      \n' + '
                      \n' + '2. Parent B\n' + ' 1. Step B1\n' + ' 2. Step B2\n' + ) + + change = _make_change(0, old_content, new_content, type_='list') + mdx_to_sidecar = self._setup_sidecar('ol[1]', 0) + roundtrip_sidecar = _make_roundtrip_sidecar([ + SidecarBlock(0, 'ol[1]', xhtml_text, sha256_text(xhtml_text), (1, 12)), + ]) + + patches, _, skipped = build_patches( + [change], [change.old_block], [change.new_block], + mappings, mdx_to_sidecar, xpath_to_mapping, + roundtrip_sidecar=roundtrip_sidecar, + ) + + assert len(patches) >= 1, ( + f"중첩 리스트 항목 제거 변경에 대한 패치가 생성되어야 합니다. " + f"patches={patches}, skipped={skipped}" + ) + + patched = patch_xhtml(xhtml_text, patches) + + from bs4 import BeautifulSoup + soup = BeautifulSoup(patched, 'html.parser') + root_items = soup.find('ol').find_all('li', recursive=False) + first_nested = root_items[0].find('ol') + second_nested = root_items[1].find('ol') + assert first_nested is not None and second_nested is not None + assert len(first_nested.find_all('li', recursive=False)) == 1, ( + f"첫 번째 하위 리스트만 1개 항목으로 줄어야 합니다. patched XHTML: {patched[:400]}" + ) + assert len(second_nested.find_all('li', recursive=False)) == 2, ( + f"변경되지 않은 두 번째 하위 리스트는 2개 항목을 유지해야 합니다. " + f"patched XHTML: {patched[:400]}" + ) From c64b93b91fb338878ed6423a5530a083626483fb Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 01:02:40 +0900 Subject: [PATCH 7/7] =?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=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - isu_002 (confluence-mdx/bin/reverse_sync/xhtml_patcher.py:377): bold 제거와 경계 이동은 처리하지만, bold 추가(len(new) > len(old))는 return으로 무시됩니다. preserved anchor를 포함하는 p에서 새로운 bold 서식을 추가하는 MDX 편집은 이 함수에 도달하지만, 아무 변경 없이 반환되어 bold 적용이 누락됩니다. --- .../bin/reverse_sync/xhtml_patcher.py | 48 +++++++++++++++++++ .../tests/test_reverse_sync_patch_builder.py | 15 ++++++ 2 files changed, 63 insertions(+) diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index e7663d740..0ad42869a 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -343,6 +343,44 @@ def _append_text_to_tag(tag: Tag, text: str): tag.append(NavigableString(text)) +def _wrap_text_in_strong(p_tag: Tag, text: str) -> bool: + """preserved markup 바깥의 text node 일부를 으로 감싼다.""" + if not text: + return False + for node in list(p_tag.descendants): + if not isinstance(node, NavigableString): + continue + if _has_preserved_markup_ancestor(node, p_tag): + continue + node_text = str(node) + idx = node_text.find(text) + if idx == -1: + continue + + before = node_text[:idx] + matched = node_text[idx:idx + len(text)] + after = node_text[idx + len(text):] + + fragment = BeautifulSoup('', 'html.parser') + replacements: list = [] + if before: + replacements.append(NavigableString(before)) + strong = fragment.new_tag('strong') + strong.append(NavigableString(matched)) + replacements.append(strong) + if after: + replacements.append(NavigableString(after)) + + first = replacements[0] + node.replace_with(first) + prev = first + for repl in replacements[1:]: + prev.insert_after(repl) + prev = repl + return True + return False + + def _apply_strong_boundary_fixup(p_tag: Tag, new_inner_xhtml: str): """/ 보존 시 요소만 직접 수정하여 bold 경계를 교정한다. @@ -358,6 +396,16 @@ def _apply_strong_boundary_fixup(p_tag: Tag, new_inner_xhtml: str): old_strongs = p_tag.find_all('strong') new_strongs = new_soup.find_all('strong') + if len(old_strongs) < len(new_strongs): + remaining_old = [s.get_text() for s in old_strongs] + for new_s in new_strongs: + new_text = new_s.get_text() + if new_text in remaining_old: + remaining_old.remove(new_text) + continue + _wrap_text_in_strong(p_tag, new_text) + return + if len(old_strongs) > len(new_strongs): # 새 버전의 bold 텍스트 집합 구축 (매칭용) new_strong_texts = [s.get_text() for s in new_strongs] diff --git a/confluence-mdx/tests/test_reverse_sync_patch_builder.py b/confluence-mdx/tests/test_reverse_sync_patch_builder.py index f0a2d8463..7b0297ba5 100644 --- a/confluence-mdx/tests/test_reverse_sync_patch_builder.py +++ b/confluence-mdx/tests/test_reverse_sync_patch_builder.py @@ -2176,6 +2176,21 @@ def test_preserved_anchor_inside_strong_boundary_fixup_preserves_markup(self): assert 'link' in str(p) assert 'link:' in str(p) + def test_preserved_anchor_strong_added(self): + """preserved anchor가 있는 문단에서도 새 bold가 추가되어야 한다.""" + from bs4 import BeautifulSoup + xhtml = '
                      • link Name

                      ' + soup = BeautifulSoup(xhtml, 'html.parser') + fixups = [{ + 'old_plain': 'link Name', + 'new_plain': 'link Name', + 'new_inner_xhtml': 'link Name', + }] + _apply_inline_fixups(soup, fixups) + p = soup.find('p') + assert 'link' in str(p) + assert 'Name' in str(p) + def test_duplicate_text_uses_match_index(self): """동일 텍스트

                      가 여러 개여도 지정한 occurrence에만 적용한다.""" from bs4 import BeautifulSoup