From a8002e83adc0a954ba2f39229a0f63e222eb4f11 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 01:52:11 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(reverse=5Fsync):=20code=20span=20?= =?UTF-8?q?=EB=92=A4=20=EA=B3=B5=EB=B0=B1=EC=9D=B4=20=EC=86=8C=EC=8B=A4?= =?UTF-8?q?=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _apply_text_changes의 gap whitespace 축소 로직에서, gap 전체가 노드의 leading whitespace인 경우(gap_old <= leading) 축소된 gap만큼만 leading을 조정하도록 변경합니다. 기존에는 gap이 축소되면 leading을 전부 제거하여, {{..}} 안의 구조에서 span 선행 공백이 소실되었습니다. Co-Authored-By: Claude Opus 4.6 --- .../bin/reverse_sync/xhtml_patcher.py | 9 +- .../tests/reverse-sync/883654669/improved.mdx | 172 ++++++++++++++++++ .../tests/reverse-sync/883654669/original.mdx | 172 ++++++++++++++++++ .../tests/reverse-sync/883654669/page.v1.yaml | 5 + .../tests/reverse-sync/883654669/page.xhtml | 28 +++ confluence-mdx/tests/reverse-sync/pages.yaml | 26 +++ 6 files changed, 411 insertions(+), 1 deletion(-) create mode 100644 confluence-mdx/tests/reverse-sync/883654669/improved.mdx create mode 100644 confluence-mdx/tests/reverse-sync/883654669/original.mdx create mode 100644 confluence-mdx/tests/reverse-sync/883654669/page.v1.yaml create mode 100644 confluence-mdx/tests/reverse-sync/883654669/page.xhtml diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 0ad42869a..ce4406823 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -681,7 +681,14 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): elif (gap_new.isspace() and gap_old.isspace() and len(gap_new) < len(gap_old) and len(gap_new) <= len(leading)): - leading = '' + if len(gap_old) <= len(leading): + # gap 전체가 노드의 leading whitespace인 경우: + # 축소된 gap만큼만 leading을 조정한다. + # 예: X 안의 에서 + # gap " "→" " 시 leading도 " "→" "로 조정 + leading = gap_new + else: + leading = '' if trailing_in_range: # diff가 trailing whitespace를 처리했으므로 별도 보존 불필요 diff --git a/confluence-mdx/tests/reverse-sync/883654669/improved.mdx b/confluence-mdx/tests/reverse-sync/883654669/improved.mdx new file mode 100644 index 000000000..7f9865b30 --- /dev/null +++ b/confluence-mdx/tests/reverse-sync/883654669/improved.mdx @@ -0,0 +1,172 @@ +--- +title: 'Slack DM 연동' +confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/883654669/Slack+DM' +--- + +import { Callout } from 'nextra/components' + +# Slack DM 연동 + +### Overview + +Slack App을 QueryPie에 연동하고, QueryPie로부터 Direct Message 알림을 수신할 수 있습니다. + + +본 문서는 **10.2.6 또는 그 이상의 버전**에 적용됩니다. +10.2.5 또는 그 이하 버전의 Slack 연동 방법은 [10.1.0 버전 매뉴얼 문서](https://docs.querypie.com/ko/querypie-manual/10.1.0/workflow-configurations)를 참고해주세요. + + +#### 구성 시 사전에 필요한 권한 + +* Slack Workspace 관리자 권한 계정 (Slack Workspace에 App을 설치하기 위해 필요) +* QueryPie Owner 및 Approval Admin 권한 계정 + +### Slack API 에서 DM App 생성 (via Manifest Files) + +App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니다. + + +1. [https://api.slack.com/apps](https://api.slack.com/apps) 으로 이동하여 `Create an App` 을 클릭합니다.

+
+ image-20231227-065658.png +
+2. Create an app 모달에서, App 생성 방식을 선택합니다. `From a manifest` 를 클릭합니다.

+
+ screenshot-20250218-131725.png +
+3. Pick a workspace 모달에서 QueryPie와 연동할 Slack Workspace를 선택한 뒤 다음 단계로 이동합니다.

+
+ image-20231227-065951.png +
+4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용을 삭제하고 아래 App Manifest를 붙여넣은 뒤 다음 단계로 이동합니다.
:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요.
+ ``` + { + "display_information": { + "name": "{{name}}" + }, + "features": { + "bot_user": { + "display_name": "{{display_name}}", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write", + "users:read.email", + "users:read" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } + } + ``` +5. 설정 내용을 검토하고 `Create` 버튼을 클릭하여 App 생성을 완료합니다.

+
+ image-20240115-220447.png +
+ +### Slack Workspace에 Slack App 설치 + +1. Settings > Install App에서 `Install to Workspace` 버튼을 클릭하여 생성된 앱을 Slack Workspace에 설치합니다. +2. 권한 요청 페이지에서, `허용`을 클릭합니다.
+
+ image-20240115-221520.png +
+3. 설정한 Slack Workspace에서 Slack DM 전용 App이 생성된 것을 확인할 수 있습니다. +
+ image-20240115-221618.png +
+ +### Bot User OAuth Token 획득 + +Features > OAuth & Permissions 메뉴 내 OAuth Tokens for Your Workspace 섹션에서, **Bot User OAuth Token** 을 복사하여 기록해 둡니다. + +
+image-20240418-022640.png +
+ + +### App-Level Token 생성 + + +App manifest로 Slack app을 생성할 때 Socket Mode와 관련 권한들을 활성화할 수는 있지만, App Level Token(xapp-)은 자동으로 생성되지 않습니다. +* App Level Token은 보안 자격 증명(security credential)이기 때문에 manifest로 자동 생성/노출되면 보안상 위험 +* App manifest는 앱의 구성(configuration)만 정의하고, 실제 인증 토큰들은 별도로 생성/관리됨 + + +다음 단계를 수행하여 App-Level Token을 수동으로 생성합니다. + +1. 앱 설정 페이지의 Settings > Basic Information 메뉴의 App-Level Tokens 섹션으로 이동하고, `Generate Token and Scope` 버튼을 클릭합니다.

+
+ screenshot-20250218-140951.png +
+2. Generate an app-level token 모달에서, Add Scope 버튼을 클릭한 뒤 `connections:write` 를 추가합니다.

+
+ screenshot-20250218-141555.png +
+3. 생성된 App-Level Token 모달에서, 앱 토큰을 복사하여 따로 기록해 둡니다. App-Level Token은 `xapp-` 으로 시작합니다. + +### QueryPie에서 Slack DM 설정하기 + +1. Admin > General > System > Integrations > Slack 메뉴로 진입한 뒤, `Configure` 버튼을 클릭하여 설정 모달을 엽니다.

+
+ Administrator > General > System > Integrations > Slack > Create a New Slack Configuration +
+ Administrator > General > System > Integrations > Slack > Create a New Slack Configuration +
+
+2. 설정 모달에 아까 기록해둔 App Token과 Bot User OAuth Token을 입력합니다. +3. 추가 설정 값은 다음과 같습니다. DM으로 Workflow 알림을 받고 메시지 안에서 승인/거절을 수행하려면 모든 설정 토글을 활성화합니다. + * **Send Workflow Notification via Slack DM** : 워크플로우 요청에 대한 Slack DM 전송 활성화 + * **Allow Users to approve or reject on Slack DM** : Slack DM 내에서 승인 또는 거절 기능 활성화

+
+ 액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우) +
+ 액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우) +
+
+4. `Save` 버튼을 누르고 설정을 완료합니다. + +### Slack DM 설정 관리하기 + +1. Slack Configuration을 등록한 뒤, 현재 설정 상태를 화면에서 확인할 수 있습니다.

+
+ Administrator > General > System > Integrations > Slack +
+ Administrator > General > System > Integrations > Slack +
+
+2. `Edit` 버튼을 클릭하여 입력한 설정을 변경할 수 있습니다.
+
+ Administrator > General > System > Integrations > Slack > Edit the Slack Configuration +
+ Administrator > General > System > Integrations > Slack > Edit the Slack Configuration +
+
+ + +### Workflow 요청 시 Slack DM 테스트 + +DB Access Request를 예시로, Slack DM 기능이 정상적으로 작동하는지 테스트를 수행합니다. + +1. QueryPie User > Workflow 페이지에서 Submit Request 버튼을 클릭한 뒤, DB Access Request를 선택하여 요청 작성 화면으로 진입합니다. 요청 작성 화면에서 필요한 정보를 입력하고, 요청을 상신합니다.
+
+ screenshot-20250218-151504.png +
+2. 승인자는 앞에서 추가한 Slack App과의 DM으로 승인해야 할 요청 알림을 수신할 수 있습니다.
**Allow Users to approve or reject on Slack DM** 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.

+
+ screenshot-20250218-152238.png +
+3. DM에서 `Details` 버튼을 클릭 시, QueryPie Admin에서 결재 요청에 대한 상세 내용을 확인하고 승인 또는 거절할 수 있습니다.
+
+ screenshot-20250218-152527.png +
diff --git a/confluence-mdx/tests/reverse-sync/883654669/original.mdx b/confluence-mdx/tests/reverse-sync/883654669/original.mdx new file mode 100644 index 000000000..58cfbb3f4 --- /dev/null +++ b/confluence-mdx/tests/reverse-sync/883654669/original.mdx @@ -0,0 +1,172 @@ +--- +title: 'Slack DM 연동' +confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/pages/883654669/Slack+DM' +--- + +import { Callout } from 'nextra/components' + +# Slack DM 연동 + +### Overview + +Slack App을 QueryPie에 연동하고, QueryPie로부터 Direct Message 알림을 수신할 수 있습니다. + + +본 문서는 **10.2.6 또는 그 이상의 버전**에 적용됩니다. +10.2.5 또는 그 이하 버전의 Slack 연동 방법은 [10.1.0 버전 매뉴얼 문서](https://docs.querypie.com/ko/querypie-manual/10.1.0/workflow-configurations)를 참고해주세요. + + +#### 구성 시 사전에 필요한 권한 + +* Slack Workspace 관리자 권한 계정 (Slack Workspace에 App을 설치하기 위해 필요) +* QueryPie Owner 및 Approval Admin 권한 계정 + +### Slack API 에서 DM App 생성 (via Manifest Files) + +App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니다. + + +1. [https://api.slack.com/apps](https://api.slack.com/apps) 으로 이동하여 `Create an App` 을 클릭합니다.

+
+ image-20231227-065658.png +
+2. Create an app 모달에서, App 생성 방식을 선택합니다. `From a manifest` 를 클릭합니다.

+
+ screenshot-20250218-131725.png +
+3. Pick a workspace 모달에서 QueryPie와 연동할 Slack Workspace를 선택한 뒤 다음 단계로 진행합니다.

+
+ image-20231227-065951.png +
+4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 붙여넣은 뒤 다음 단계로 진행합니다.
:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요.
+ ``` + { + "display_information": { + "name": "{{name}}" + }, + "features": { + "bot_user": { + "display_name": "{{display_name}}", + "always_online": false + } + }, + "oauth_config": { + "scopes": { + "bot": [ + "chat:write", + "users:read.email", + "users:read" + ] + } + }, + "settings": { + "interactivity": { + "is_enabled": true + }, + "org_deploy_enabled": false, + "socket_mode_enabled": true, + "token_rotation_enabled": false + } + } + ``` +5. 설정 내용을 검토하고 `Create` 버튼을 클릭하여 App 생성을 완료합니다.

+
+ image-20240115-220447.png +
+ +### Slack Workspace에 Slack App 설치 + +1. Settings > Install App에서 `Install to Workspace` 버튼을 클릭하여 생성된 앱을 Slack Workspace에 설치합니다. +2. 권한 요청 페이지에서, `허용`을 클릭합니다.
+
+ image-20240115-221520.png +
+3. 설정한 Slack Workspace에서 Slack DM 전용 App이 생성된 것을 확인할 수 있습니다. +
+ image-20240115-221618.png +
+ +### Bot User OAuth Token 획득 + +Features > OAuth & Permissions 메뉴 내 OAuth Tokens for Your Workspace 섹션에서, **Bot User OAuth Token** 을 복사하여 기록해 둡니다. + +
+image-20240418-022640.png +
+ + +### App-Level Token 생성 + + +App manifest로 Slack app을 생성할 때 Socket Mode와 관련 권한들을 활성화할 수는 있지만, App Level Token(xapp-)은 자동으로 생성되지 않습니다. +* App Level Token은 보안 자격 증명(security credential)이기 때문에 manifest로 자동 생성/노출되면 보안상 위험 +* App manifest는 앱의 구성(configuration)만 정의하고, 실제 인증 토큰들은 별도로 생성/관리됨 + + +다음 단계를 수행하여 App-Level Token을 수동으로 생성합니다. + +1. 앱 설정 페이지의 Settings > Basic Information 메뉴의 App-Level Tokens 섹션으로 이동하고, `Generate Token and Scope` 버튼을 클릭합니다.

+
+ screenshot-20250218-140951.png +
+2. Generate an app-level token 모달에서, Add Scope 버튼을 클릭한 뒤 `connections:write` 를 추가합니다.

+
+ screenshot-20250218-141555.png +
+3. 생성된 App-Level Token 모달에서, 앱 토큰을 복사하여 따로 기록해 둡니다. App-Level Token은 `xapp-` 으로 시작합니다. + +### QueryPie에서 Slack DM 설정하기 + +1. Admin > General > System > Integrations > Slack 메뉴로 진입한 뒤, `Configure` 버튼을 클릭하여 설정 모달을 엽니다.

+
+ Administrator > General > System > Integrations > Slack > Create a New Slack Configuration +
+ Administrator > General > System > Integrations > Slack > Create a New Slack Configuration +
+
+2. 설정 모달에 아까 기록해둔 App Token과 Bot User OAuth Token을 입력합니다. +3. 추가 설정 값은 다음과 같습니다. DM으로 Workflow 알림을 받고 메시지 안에서 승인/거절을 수행하려면 모든 설정 토글을 활성화합니다. + * **Send Workflow Notification via Slack DM** : 워크플로우 요청에 대한 Slack DM 전송 활성화 + * **Allow Users to approve or reject on Slack DM** : Slack DM 내에서 승인 또는 거절 기능 활성화

+
+ 액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우) +
+ 액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우) +
+
+4. `Save` 버튼을 누르고 설정을 완료합니다. + +### Slack DM 설정 관리하기 + +1. Slack Configuration을 등록한 뒤, 현재 설정 상태를 화면에서 확인할 수 있습니다.

+
+ Administrator > General > System > Integrations > Slack +
+ Administrator > General > System > Integrations > Slack +
+
+2. `Edit` 버튼을 클릭하여 입력한 설정을 변경할 수 있습니다.
+
+ Administrator > General > System > Integrations > Slack > Edit the Slack Configuration +
+ Administrator > General > System > Integrations > Slack > Edit the Slack Configuration +
+
+ + +### Workflow 요청 시 Slack DM 테스트 + +DB Access Request를 예시로, Slack DM 기능이 정상적으로 작동하는지 테스트를 수행합니다. + +1. QueryPie User > Workflow 페이지에서 Submit Request 버튼을 클릭한 뒤, DB Access Request를 선택하여 요청 작성 화면으로 진입합니다. 요청 작성 화면에서 필요한 정보를 입력하고, 요청을 상신합니다.
+
+ screenshot-20250218-151504.png +
+2. 승인자는 앞에서 추가한 Slack App과의 DM으로 승인해야 할 요청 알림을 수신할 수 있습니다.
**Allow Users to approve or reject on Slack DM** 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.

+
+ screenshot-20250218-152238.png +
+3. DM에서 `Details` 버튼을 클릭 시, QueryPie Admin에서 결재 요청에 대한 상세 내용을 확인하고 승인 또는 거절할 수 있습니다.
+
+ screenshot-20250218-152527.png +
diff --git a/confluence-mdx/tests/reverse-sync/883654669/page.v1.yaml b/confluence-mdx/tests/reverse-sync/883654669/page.v1.yaml new file mode 100644 index 000000000..eba6fca4b --- /dev/null +++ b/confluence-mdx/tests/reverse-sync/883654669/page.v1.yaml @@ -0,0 +1,5 @@ +id: '883654669' +title: Slack DM 연동 +_links: + base: https://querypie.atlassian.net/wiki + webui: /spaces/QM/pages/883654669/Slack+DM diff --git a/confluence-mdx/tests/reverse-sync/883654669/page.xhtml b/confluence-mdx/tests/reverse-sync/883654669/page.xhtml new file mode 100644 index 000000000..af99b8506 --- /dev/null +++ b/confluence-mdx/tests/reverse-sync/883654669/page.xhtml @@ -0,0 +1,28 @@ +

Overview

Slack App을 QueryPie에 연동하고, QueryPie로부터 Direct Message 알림을 수신할 수 있습니다.

본 문서는 10.2.6 또는 그 이상의 버전에 적용됩니다.

10.2.5 또는 그 이하 버전의 Slack 연동 방법은 10.1.0 버전 매뉴얼 문서를 참고해주세요.

12falsediscOverviewlisttrue

구성 시 사전에 필요한 권한

Slack API 에서 DM App 생성 (via Manifest Files)

App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니다.

  1. https://api.slack.com/apps 으로 이동하여 Create an App 을 클릭합니다.

  2. Create an app 모달에서, App 생성 방식을 선택합니다. From a manifest 를 클릭합니다.

  3. Pick a workspace 모달에서 QueryPie와 연동할 Slack Workspace를 선택한 뒤 다음 단계로 진행합니다.

  4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
    미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 붙여넣은 뒤 다음 단계로 진행합니다.
    {{..}} 안의 값은 원하는 값으로 변경해 주세요.

  5. 설정 내용을 검토하고 Create 버튼을 클릭하여 App 생성을 완료합니다.

Slack Workspace에 Slack App 설치

  1. Settings > Install App에서 Install to Workspace 버튼을 클릭하여 생성된 앱을 Slack Workspace에 설치합니다.

  2. 권한 요청 페이지에서, 허용을 클릭합니다.

  3. 설정한 Slack Workspace에서 Slack DM 전용 App이 생성된 것을 확인할 수 있습니다.

Bot User OAuth Token 획득

Features > OAuth & Permissions 메뉴 내 OAuth Tokens for Your Workspace 섹션에서, Bot User OAuth Token 을 복사하여 기록해 둡니다.

App-Level Token 생성

App manifest로 Slack app을 생성할 때 Socket Mode와 관련 권한들을 활성화할 수는 있지만, App Level Token(xapp-)은 자동으로 생성되지 않습니다.

  • App Level Token은 보안 자격 증명(security credential)이기 때문에 manifest로 자동 생성/노출되면 보안상 위험

  • App manifest는 앱의 구성(configuration)만 정의하고, 실제 인증 토큰들은 별도로 생성/관리됨

다음 단계를 수행하여 App-Level Token을 수동으로 생성합니다.

  1. 앱 설정 페이지의 Settings > Basic Information 메뉴의 App-Level Tokens 섹션으로 이동하고, Generate Token and Scope 버튼을 클릭합니다.

  2. Generate an app-level token 모달에서, Add Scope 버튼을 클릭한 뒤 connections:write 를 추가합니다.

  3. 생성된 App-Level Token 모달에서, 앱 토큰을 복사하여 따로 기록해 둡니다. App-Level Token은 xapp- 으로 시작합니다.

QueryPie에서 Slack DM 설정하기

  1. Admin > General > System > Integrations > Slack 메뉴로 진입한 뒤, Configure 버튼을 클릭하여 설정 모달을 엽니다.

    Administrator > General > System > Integrations > Slack > Create a New Slack Configuration

  2. 설정 모달에 아까 기록해둔 App Token과 Bot User OAuth Token을 입력합니다.

  3. 추가 설정 값은 다음과 같습니다. DM으로 Workflow 알림을 받고 메시지 안에서 승인/거절을 수행하려면 모든 설정 토글을 활성화합니다.

    • Send Workflow Notification via Slack DM : 워크플로우 요청에 대한 Slack DM 전송 활성화

    • Allow Users to approve or reject on Slack DM : Slack DM 내에서 승인 또는 거절 기능 활성화

      액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우)

  4. Save 버튼을 누르고 설정을 완료합니다.

Slack DM 설정 관리하기

  1. Slack Configuration을 등록한 뒤, 현재 설정 상태를 화면에서 확인할 수 있습니다.

    Administrator > General > System > Integrations > Slack

  2. Edit 버튼을 클릭하여 입력한 설정을 변경할 수 있습니다.

    Administrator > General > System > Integrations > Slack > Edit the Slack Configuration

Workflow 요청 시 Slack DM 테스트

DB Access Request를 예시로, Slack DM 기능이 정상적으로 작동하는지 테스트를 수행합니다.

  1. QueryPie User > Workflow 페이지에서 Submit Request 버튼을 클릭한 뒤, DB Access Request를 선택하여 요청 작성 화면으로 진입합니다. 요청 작성 화면에서 필요한 정보를 입력하고, 요청을 상신합니다.

  2. 승인자는 앞에서 추가한 Slack App과의 DM으로 승인해야 할 요청 알림을 수신할 수 있습니다.
    Allow Users to approve or reject on Slack DM 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.

  3. DM에서 Details 버튼을 클릭 시, QueryPie Admin에서 결재 요청에 대한 상세 내용을 확인하고 승인 또는 거절할 수 있습니다.

\ No newline at end of file diff --git a/confluence-mdx/tests/reverse-sync/pages.yaml b/confluence-mdx/tests/reverse-sync/pages.yaml index 193064022..f8728ecef 100644 --- a/confluence-mdx/tests/reverse-sync/pages.yaml +++ b/confluence-mdx/tests/reverse-sync/pages.yaml @@ -1598,3 +1598,29 @@ - databases title: (New) Policy Management title_orig: (New) Policy Management +- breadcrumbs: + - 관리자 매뉴얼 + - General + - System + - Integrations + - Slack DM 연동 + breadcrumbs_en: + - Administrator Manual + - General + - System + - Integrations + - Integrating with Slack DM + expected_status: pass + failure_type: 12 + label: code span 뒤 공백 소실 — gap whitespace 축소 시 leading 전체 제거 + mdx_path: administrator-manual/general/system/integrations/integrating-with-slack-dm.mdx + page_confluenceUrl: https://querypie.atlassian.net/wiki/spaces/QM/pages/883654669/Slack+DM + page_id: '883654669' + path: + - administrator-manual + - general + - system + - integrations + - integrating-with-slack-dm + title: Slack DM 연동 + title_orig: Slack DM 연동 From 25b9f57049dba5930a4eab3dcc4632d88177744d Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 15:41:24 +0900 Subject: [PATCH 2/5] =?UTF-8?q?confluence-mdx:=20gap=20whitespace=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C=20=EC=8B=9C=20leading=20=EC=A1=B0=EC=A0=95?= =?UTF-8?q?=20=EB=B2=84=EA=B7=B8=EC=9D=98=20pytest=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit {{..}} 안의 구조에서 gap이 2→1로 축소될 때 leading이 전부 제거되는 버그를 재현하는 테스트케이스입니다. Co-Authored-By: Claude Opus 4.6 --- .../tests/test_reverse_sync_xhtml_patcher.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py index 7cf4d09b1..68846cbcc 100644 --- a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py +++ b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py @@ -639,6 +639,42 @@ def test_gap_fully_deleted(self): result = patch_xhtml(xhtml, patches) assert 'IDENTIFIER조사' in result + def test_gap_is_node_leading_reduces_not_strips(self): + """gap 전체가 노드의 leading whitespace일 때, gap 축소 시 leading을 조정한다. + + 재현 시나리오 (PR #979, page 883654669): + XHTML: {{..}} 안의 값은... + old_plain_text: "...진행합니다. {{..}} 안의 값은..." + new_plain_text: "...이동합니다. {{..}} 안의 값은..." + 현상: gap " "→" " 축소 시 의 leading " "가 전부 제거되어 + 안의 값은이 됨 (올바른 결과: 안의 값은) + 원인: gap_old(2) == leading(2)인 경우에도 leading=''로 전부 제거 + """ + xhtml = ( + '

' + '미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 진행합니다.' + '
' + ' ' + '{{..}}' + ' 안의 값은 원하는 값으로 변경해 주세요.' + '

' + ) + patches = [{ + 'xhtml_xpath': 'p[1]', + # collapse_ws로 " 안의" → " 안의"가 된 후 _apply_mdx_diff_to_xhtml 결과 + 'old_plain_text': '미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 진행합니다. {{..}} 안의 값은 원하는 값으로 변경해 주세요.', + 'new_plain_text': '미리 채워져 있는 내용을 삭제하고 아래 App Manifest를 이동합니다. {{..}} 안의 값은 원하는 값으로 변경해 주세요.', + }] + result = patch_xhtml(xhtml, patches) + # gap " "→" " 축소 시, gap 전체가 노드 leading이므로 leading=" "으로 조정 + assert '> 안의 값은' in result, ( + f"leading should be reduced to 1 space, not stripped entirely: {result}" + ) + # 버그 동작: leading이 전부 제거되어 ">안의"가 되면 안 됨 + assert '>안의 값은' not in result, ( + f"leading whitespace must not be stripped entirely: {result}" + ) + def test_gap_not_reduced_preserves_leading(self): """gap이 축소되지 않으면 leading whitespace를 보존한다.""" xhtml = ( From 5abda6e94b92c4b0e025ab504a0380d60d87a731 Mon Sep 17 00:00:00 2001 From: JK Date: Sat, 4 Apr 2026 20:38:37 +0900 Subject: [PATCH 3/5] =?UTF-8?q?confluence-mdx:=20PR=20#980=20FC=20?= =?UTF-8?q?=EA=B3=B5=EB=B0=B1=20=EC=A0=95=EA=B7=9C=ED=99=94=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=EC=97=90=20=EB=94=B0=EB=9D=BC=20883654669=20fixture?= =?UTF-8?q?=EB=A5=BC=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=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 FC가 이중 공백을 보존하므로 original.mdx, improved.mdx의 `{{..}}` 뒤 공백을 1→2로 맞춥니다. Co-Authored-By: Claude Opus 4.6 --- confluence-mdx/tests/reverse-sync/883654669/improved.mdx | 2 +- confluence-mdx/tests/reverse-sync/883654669/original.mdx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/confluence-mdx/tests/reverse-sync/883654669/improved.mdx b/confluence-mdx/tests/reverse-sync/883654669/improved.mdx index 7f9865b30..e99429bdf 100644 --- a/confluence-mdx/tests/reverse-sync/883654669/improved.mdx +++ b/confluence-mdx/tests/reverse-sync/883654669/improved.mdx @@ -38,7 +38,7 @@ App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니
image-20231227-065951.png
-4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용을 삭제하고 아래 App Manifest를 붙여넣은 뒤 다음 단계로 이동합니다.
:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요.
+4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용을 삭제하고 아래 App Manifest를 붙여넣은 뒤 다음 단계로 이동합니다.
:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요.
``` { "display_information": { diff --git a/confluence-mdx/tests/reverse-sync/883654669/original.mdx b/confluence-mdx/tests/reverse-sync/883654669/original.mdx index 58cfbb3f4..ef2e01803 100644 --- a/confluence-mdx/tests/reverse-sync/883654669/original.mdx +++ b/confluence-mdx/tests/reverse-sync/883654669/original.mdx @@ -38,7 +38,7 @@ App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니
image-20231227-065951.png
-4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 붙여넣은 뒤 다음 단계로 진행합니다.
:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요.
+4. Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 붙여넣은 뒤 다음 단계로 진행합니다.
:light_bulb_on: `{{..}}` 안의 값은 원하는 값으로 변경해 주세요.
``` { "display_information": { From ded30dfddef259e92723ebe53fba706e2b57511f Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 5 Apr 2026 01:35:50 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(reverse=5Fsync):=20mixed=20gap=20?= =?UTF-8?q?=EC=B6=95=EC=86=8C/=ED=99=95=EC=9E=A5=20=EC=8B=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EB=85=B8=EB=93=9C=20whitespace=20=EB=B6=84?= =?UTF-8?q?=EB=B0=B0=EB=A5=BC=20=EA=B5=AC=ED=98=84=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 이전 노드 trailing + 현재 노드 leading에 걸친 gap 변경 시: - 축소: leading 우선 흡수 → 부족하면 prev trailing에서 추가 흡수 - 확장: prev trailing에 공백 추가 - trailing_in_range 이중 적용 방지를 위해 prev_eff_end 추적 Co-Authored-By: Claude Opus 4.6 --- .../bin/reverse_sync/xhtml_patcher.py | 77 ++++++++++++----- .../tests/test_reverse_sync_xhtml_patcher.py | 86 +++++++++++++++++++ 2 files changed, 142 insertions(+), 21 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index ce4406823..edc5a513a 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -628,6 +628,9 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): claim_end_set.add(left_idx) exclude_start_set.add(right_idx) + prev_replaced = None # 직전 non-empty 노드의 교체 NavigableString + prev_eff_end = 0 # 직전 non-empty 노드의 effective_end (trailing_in_range 포함) + for i, (node_start, node_end, node) in enumerate(node_ranges): if node_start == node_end: continue @@ -665,34 +668,63 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): exclude_insert_at_start=exclude_at_start, ) - # 직전 노드 범위와 현재 노드 범위 사이의 gap이 diff로 삭제/축소된 경우, - # leading whitespace를 제거한다. - # 예1: IDENTIFIER 조사 → IDENTIFIER조사 (공백 삭제) - # 예2:

Admin →

Admin (공백 축소: gap 2→1이면 leading 제거) - if leading and i > 0: + # 직전 노드 범위와 현재 노드 범위 사이의 gap이 diff로 변경된 경우, + # gap 변화량을 leading/trailing whitespace에 분배한다. + # - 축소: leading 줄이기 우선 → 부족하면 이전 노드 trailing 추가 흡수 + # - 확장: 이전 노드 trailing 늘리기 + # - 삭제: leading 제거 후 잔여분은 이전 노드 trailing 흡수 + # + # trailing_in_range가 이전 노드에서 True였으면 trailing 부분은 이미 + # diff로 처리된 상태이므로, 이미 처리된 영역을 제외한 나머지 gap만 조정한다. + if i > 0: prev_end = node_ranges[i - 1][1] - if prev_end < node_start: - gap_old = old_stripped[prev_end:node_start] + # trailing_in_range로 이미 diff 처리된 영역을 제외 + gap_start = max(prev_end, prev_eff_end) + if gap_start < node_start: + gap_old = old_stripped[gap_start:node_start] gap_new = _map_text_range( - old_stripped, new_stripped, opcodes, prev_end, node_start + old_stripped, new_stripped, opcodes, gap_start, node_start ) if not gap_new: - leading = '' - elif (gap_new.isspace() and gap_old.isspace() - and len(gap_new) < len(gap_old) - and len(gap_new) <= len(leading)): - if len(gap_old) <= len(leading): - # gap 전체가 노드의 leading whitespace인 경우: - # 축소된 gap만큼만 leading을 조정한다. - # 예: X 안의 에서 - # gap " "→" " 시 leading도 " "→" "로 조정 - leading = gap_new - else: + if gap_old.isspace(): + gap_delta = len(gap_old) + reduce = min(gap_delta, len(leading)) + leading = leading[reduce:] + remaining = gap_delta - reduce + if remaining > 0 and prev_replaced is not None: + ps = str(prev_replaced) + ptw = ps[len(ps.rstrip()):] + trim = min(remaining, len(ptw)) + if trim > 0: + np_ = NavigableString(ps[:len(ps) - trim]) + prev_replaced.replace_with(np_) + prev_replaced = np_ + elif leading: leading = '' + elif gap_new.isspace() and gap_old.isspace(): + gap_delta = len(gap_old) - len(gap_new) + if gap_delta > 0: + reduce = min(gap_delta, len(leading)) + leading = leading[reduce:] + remaining = gap_delta - reduce + if remaining > 0 and prev_replaced is not None: + ps = str(prev_replaced) + ptw = ps[len(ps.rstrip()):] + trim = min(remaining, len(ptw)) + if trim > 0: + np_ = NavigableString(ps[:len(ps) - trim]) + prev_replaced.replace_with(np_) + prev_replaced = np_ + elif gap_delta < 0: + if prev_replaced is not None: + ps = str(prev_replaced) + np_ = NavigableString(ps + ' ' * abs(gap_delta)) + prev_replaced.replace_with(np_) + prev_replaced = np_ if trailing_in_range: # diff가 trailing whitespace를 처리했으므로 별도 보존 불필요 - node.replace_with(NavigableString(leading + new_node_text)) + replacement = NavigableString(leading + new_node_text) else: # 마지막 노드의 edge trailing whitespace가 변경된 경우 반영 if trailing and i == last_nonempty_idx: @@ -700,7 +732,10 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): new_edge_trailing = new_text[len(new_text.rstrip()):] if old_edge_trailing != new_edge_trailing: trailing = new_edge_trailing - node.replace_with(NavigableString(leading + new_node_text + trailing)) + replacement = NavigableString(leading + new_node_text + trailing) + node.replace_with(replacement) + prev_replaced = replacement + prev_eff_end = effective_end def _find_block_ancestor(node): diff --git a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py index 68846cbcc..e453d4886 100644 --- a/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py +++ b/confluence-mdx/tests/test_reverse_sync_xhtml_patcher.py @@ -695,3 +695,89 @@ def test_gap_not_reduced_preserves_leading(self): assert '

내용변경.' in result, ( f"leading space should be preserved when gap not reduced: {result}" ) + + def test_mixed_gap_shrink_distributed(self): + """mixed gap(prev trailing + curr leading) 축소 시 leading 우선 흡수, + 부족하면 prev trailing에서 추가 흡수. + + XHTML 구조:

+ gap = prev trailing(1) + curr leading(2) = 3 + gap 3→2 축소: leading 1칸 줄임 → 단일 노드 해결 + """ + xhtml = '

' + patches = [{ + 'xhtml_xpath': 'root[1]', + 'old_plain_text': '앞 뒤', + 'new_plain_text': '앞 뒤', + }] + result = patch_xhtml(xhtml, patches) + # leading 2→1, prev trailing 유지 + assert '

' in result, ( + f"leading should reduce by 1, not be stripped entirely: {result}" + ) + assert '

' in result, ( + f"prev trailing should be preserved: {result}" + ) + + def test_mixed_gap_shrink_overflows_to_prev_trailing(self): + """mixed gap 축소 시 leading으로 부족하면 prev trailing에서 흡수. + + XHTML:

+ same block parent → block boundary 없음 + gap = prev trailing(2) + curr leading(1) = 3 + trailing_in_range로 이미 처리된 부분을 제외한 잔여 gap만 조정. + """ + xhtml = '

' + patches = [{ + 'xhtml_xpath': 'p[1]', + 'old_plain_text': '앞 뒤', + 'new_plain_text': '앞 뒤', + }] + result = patch_xhtml(xhtml, patches) + # target gap = 1: prev + curr leading 합계가 1이면 OK + # trailing_in_range가 diff로 처리한 부분에 따라 분배 결정 + total_gap = 0 + import re + m = re.search(r'(\s*)', result) + if m: + total_gap += len(m.group(1)) + m2 = re.search(r'>(\s*)뒤', result) + if m2: + # strong 태그 바깥의 leading + pass + # 핵심: gap이 정확히 1이어야 함 + plain = ''.join(re.findall(r'[^\s<>]+|\s+', result.replace('', '').replace('', '').replace('

', '').replace('

', ''))) + assert '앞 뒤' in plain, ( + f"gap should be exactly 1 space in plain text: {result}" + ) + + def test_mixed_gap_fully_deleted(self): + """mixed gap 완전 삭제 시 leading 제거.""" + xhtml = '

' + patches = [{ + 'xhtml_xpath': 'p[1]', + 'old_plain_text': '앞 뒤', + 'new_plain_text': '앞뒤', + }] + result = patch_xhtml(xhtml, patches) + assert '앞뒤' in result.replace('', '').replace('', ''), ( + f"all gap whitespace should be removed: {result}" + ) + + def test_gap_grows_expands_prev_trailing(self): + """gap이 늘어나면 prev trailing이 확장된다. + + same block parent 구조 사용 (block boundary 없이). + """ + xhtml = '

' + patches = [{ + 'xhtml_xpath': 'p[1]', + 'old_plain_text': '앞 뒤', + 'new_plain_text': '앞 뒤', + }] + result = patch_xhtml(xhtml, patches) + # gap 1→2: prev trailing 또는 curr leading에 1칸 추가 + plain = result.replace('', '').replace('', '').replace('

', '').replace('

', '') + assert '앞 뒤' in plain, ( + f"gap should expand to 2 spaces: {result}" + ) From ef86873750445e50bbca0ea70e3496738923e8e1 Mon Sep 17 00:00:00 2001 From: JK Date: Sun, 5 Apr 2026 02:05:49 +0900 Subject: [PATCH 5/5] =?UTF-8?q?/bin/sh:=20ignore=20fix:=20=ED=86=A0?= =?UTF-8?q?=EB=A1=A0=20=EB=A6=AC=EB=B7=B0=20=EA=B2=B0=EA=B3=BC=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20(=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/xhtml_patcher.py:704): 새 gap 분배 로직은 gap_old/gap_new가 공백만으로 이루어진 경우에만 leading을 줄입니다. 그래서 **Secure** :처럼 마크업 토큰과 공백이 함께 바뀌는 케이스에서는 앞 공백이 그대로 남아 reverse-sync/798064641가 **Secure**:대신 **Secure** :로 round-trip 되며 현재 CI의 Test Confluence MDX Converter가 실제로 이 회귀로 실패합니다. --- .../bin/reverse_sync/xhtml_patcher.py | 109 ++++++++++++------ ...est_reverse_sync_reconstruction_goldens.py | 16 +++ 2 files changed, 88 insertions(+), 37 deletions(-) diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index edc5a513a..802f845e3 100644 --- a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py +++ b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py @@ -1,5 +1,5 @@ """XHTML Patcher — fragment 단위 DOM patch를 적용한다.""" -from typing import List, Dict +from typing import List, Dict, Optional, Tuple from bs4 import BeautifulSoup, NavigableString, Tag import difflib import re @@ -685,42 +685,21 @@ def _apply_text_changes(element: Tag, old_text: str, new_text: str): gap_new = _map_text_range( old_stripped, new_stripped, opcodes, gap_start, node_start ) - if not gap_new: - if gap_old.isspace(): - gap_delta = len(gap_old) - reduce = min(gap_delta, len(leading)) - leading = leading[reduce:] - remaining = gap_delta - reduce - if remaining > 0 and prev_replaced is not None: - ps = str(prev_replaced) - ptw = ps[len(ps.rstrip()):] - trim = min(remaining, len(ptw)) - if trim > 0: - np_ = NavigableString(ps[:len(ps) - trim]) - prev_replaced.replace_with(np_) - prev_replaced = np_ - elif leading: - leading = '' - elif gap_new.isspace() and gap_old.isspace(): - gap_delta = len(gap_old) - len(gap_new) - if gap_delta > 0: - reduce = min(gap_delta, len(leading)) - leading = leading[reduce:] - remaining = gap_delta - reduce - if remaining > 0 and prev_replaced is not None: - ps = str(prev_replaced) - ptw = ps[len(ps.rstrip()):] - trim = min(remaining, len(ptw)) - if trim > 0: - np_ = NavigableString(ps[:len(ps) - trim]) - prev_replaced.replace_with(np_) - prev_replaced = np_ - elif gap_delta < 0: - if prev_replaced is not None: - ps = str(prev_replaced) - np_ = NavigableString(ps + ' ' * abs(gap_delta)) - prev_replaced.replace_with(np_) - prev_replaced = np_ + gap_delta = _compute_gap_whitespace_delta(gap_old, gap_new) + if gap_delta is not None: + leading, prev_replaced = _redistribute_gap_whitespace( + leading, prev_replaced, gap_delta + ) + elif not gap_new and leading: + leading = '' + elif leading and prev_eff_end > prev_end and prev_end < node_start: + shared_old = old_stripped[prev_end:prev_eff_end] + shared_new = _map_text_range( + old_stripped, new_stripped, opcodes, prev_end, prev_eff_end + ) + shared_delta = _compute_gap_whitespace_delta(shared_old, shared_new) + if shared_delta and shared_delta > 0: + leading = leading[min(shared_delta, len(leading)):] if trailing_in_range: # diff가 trailing whitespace를 처리했으므로 별도 보존 불필요 @@ -752,6 +731,62 @@ def _find_block_ancestor(node): return None +def _compute_gap_whitespace_delta(gap_old: str, gap_new: str) -> Optional[int]: + """gap 변경에서 공통 토큰을 제외한 공백 길이 변화량만 분리한다.""" + if gap_old == gap_new: + return 0 + + if (not gap_old or gap_old.isspace()) and (not gap_new or gap_new.isspace()): + return len(gap_old) - len(gap_new) + + prefix_len = 0 + max_prefix = min(len(gap_old), len(gap_new)) + while prefix_len < max_prefix and gap_old[prefix_len] == gap_new[prefix_len]: + prefix_len += 1 + + suffix_len = 0 + max_suffix = min(len(gap_old) - prefix_len, len(gap_new) - prefix_len) + while suffix_len < max_suffix and gap_old[-(suffix_len + 1)] == gap_new[-(suffix_len + 1)]: + suffix_len += 1 + + old_end = len(gap_old) - suffix_len if suffix_len else len(gap_old) + new_end = len(gap_new) - suffix_len if suffix_len else len(gap_new) + old_mid = gap_old[prefix_len:old_end] + new_mid = gap_new[prefix_len:new_end] + + if (old_mid and not old_mid.isspace()) or (new_mid and not new_mid.isspace()): + return None + + return len(old_mid) - len(new_mid) + + +def _redistribute_gap_whitespace( + leading: str, + prev_replaced: Optional[NavigableString], + gap_delta: int, +) -> Tuple[str, Optional[NavigableString]]: + """gap 공백 증감을 current leading / previous trailing로 재분배한다.""" + if gap_delta > 0: + reduce = min(gap_delta, len(leading)) + leading = leading[reduce:] + remaining = gap_delta - reduce + if remaining > 0 and prev_replaced is not None: + prev_text = str(prev_replaced) + prev_trailing = prev_text[len(prev_text.rstrip()):] + trim = min(remaining, len(prev_trailing)) + if trim > 0: + replacement = NavigableString(prev_text[:len(prev_text) - trim]) + prev_replaced.replace_with(replacement) + prev_replaced = replacement + elif gap_delta < 0 and prev_replaced is not None: + prev_text = str(prev_replaced) + replacement = NavigableString(prev_text + ' ' * abs(gap_delta)) + prev_replaced.replace_with(replacement) + prev_replaced = replacement + + return leading, prev_replaced + + def _map_text_range(old_text: str, new_text: str, opcodes, start: int, end: int, include_insert_at_end: bool = False, exclude_insert_at_start: bool = False) -> str: diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruction_goldens.py b/confluence-mdx/tests/test_reverse_sync_reconstruction_goldens.py index a692b017d..9d560ed22 100644 --- a/confluence-mdx/tests/test_reverse_sync_reconstruction_goldens.py +++ b/confluence-mdx/tests/test_reverse_sync_reconstruction_goldens.py @@ -20,6 +20,7 @@ TESTCASES = Path(__file__).parent / "testcases" +REVERSE_SYNC_CASES = Path(__file__).parent / "reverse-sync" def _run_pipeline_with_sidecar(xhtml: str, original_mdx: str, improved_mdx: str): @@ -169,3 +170,18 @@ def test_544379140_callout_and_paragraph_changes(self): case['xhtml'], case['original_mdx'], case['improved_mdx'] ) assert normalize_fragment(result) == normalize_fragment(case['expected']) + + def test_798064641_bold_label_colon_spacing(self): + """798064641: bold 라벨 뒤 공백 축소가 마크업 경계에서도 반영되어야 한다.""" + case_dir = REVERSE_SYNC_CASES / '798064641' + if not case_dir.is_dir(): + pytest.skip("reverse-sync/798064641 fixture not found") + + result = _run_pipeline_with_sidecar( + (case_dir / 'page.xhtml').read_text(encoding='utf-8'), + (case_dir / 'original.mdx').read_text(encoding='utf-8'), + (case_dir / 'improved.mdx').read_text(encoding='utf-8'), + ) + + assert 'Secure:' in result + assert 'Secure :' not in result