diff --git a/confluence-mdx/bin/reverse_sync/xhtml_patcher.py b/confluence-mdx/bin/reverse_sync/xhtml_patcher.py index 0ad42869a..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 @@ -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,27 +668,42 @@ 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)):
+ 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를 처리했으므로 별도 보존 불필요
- 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:
@@ -693,7 +711,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):
@@ -710,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/reverse-sync/883654669/improved.mdx b/confluence-mdx/tests/reverse-sync/883654669/improved.mdx
new file mode 100644
index 000000000..e99429bdf
--- /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 알림을 수신할 수 있습니다.
+
+
+
+
+
+
+
+
미리 채워져 있는 내용을 삭제하고 아래 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 생성을 완료합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
**Allow Users to approve or reject on Slack DM** 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.
+
+
+
+
+
+
+
+
+
+
미리 채워져 있는 내용들을 삭제하고 아래의 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 생성을 완료합니다.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
**Allow Users to approve or reject on Slack DM** 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.
+
+
+
+
Slack App을 QueryPie에 연동하고, QueryPie로부터 Direct Message 알림을 수신할 수 있습니다.
본 문서는 10.2.6 또는 그 이상의 버전에 적용됩니다.
10.2.5 또는 그 이하 버전의 Slack 연동 방법은 10.1.0 버전 매뉴얼 문서를 참고해주세요.
Slack Workspace 관리자 권한 계정 (Slack Workspace에 App을 설치하기 위해 필요)
QueryPie Owner 및 Approval Admin 권한 계정
App Manifest를 이용하여 QueryPie DM 연동 전용 Slack App을 생성합니다.
https://api.slack.com/apps 으로 이동하여 Create an App 을 클릭합니다.
Create an app 모달에서, App 생성 방식을 선택합니다. From a manifest 를 클릭합니다.
Pick a workspace 모달에서 QueryPie와 연동할 Slack Workspace를 선택한 뒤 다음 단계로 진행합니다.
Create app from manifest 모달에서 JSON 형식의 App Manifest를 입력합니다.
미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 붙여넣은 뒤 다음 단계로 진행합니다.{{..}} 안의 값은 원하는 값으로 변경해 주세요.
설정 내용을 검토하고 Create 버튼을 클릭하여 App 생성을 완료합니다.
Settings > Install App에서 Install to Workspace 버튼을 클릭하여 생성된 앱을 Slack Workspace에 설치합니다.
권한 요청 페이지에서, 허용을 클릭합니다.
설정한 Slack Workspace에서 Slack DM 전용 App이 생성된 것을 확인할 수 있습니다.
Features > OAuth & Permissions 메뉴 내 OAuth Tokens for Your Workspace 섹션에서, Bot User OAuth Token 을 복사하여 기록해 둡니다.
App manifest로 Slack app을 생성할 때 Socket Mode와 관련 권한들을 활성화할 수는 있지만, App Level Token(xapp-)은 자동으로 생성되지 않습니다.
App Level Token은 보안 자격 증명(security credential)이기 때문에 manifest로 자동 생성/노출되면 보안상 위험
App manifest는 앱의 구성(configuration)만 정의하고, 실제 인증 토큰들은 별도로 생성/관리됨
다음 단계를 수행하여 App-Level Token을 수동으로 생성합니다.
앱 설정 페이지의 Settings > Basic Information 메뉴의 App-Level Tokens 섹션으로 이동하고, Generate Token and Scope 버튼을 클릭합니다.
Generate an app-level token 모달에서, Add Scope 버튼을 클릭한 뒤 connections:write 를 추가합니다.
생성된 App-Level Token 모달에서, 앱 토큰을 복사하여 따로 기록해 둡니다. App-Level Token은 xapp- 으로 시작합니다.
Admin > General > System > Integrations > Slack 메뉴로 진입한 뒤, Configure 버튼을 클릭하여 설정 모달을 엽니다.
Administrator > General > System > Integrations > Slack > Create a New Slack Configuration
설정 모달에 아까 기록해둔 App Token과 Bot User OAuth Token을 입력합니다.
추가 설정 값은 다음과 같습니다. DM으로 Workflow 알림을 받고 메시지 안에서 승인/거절을 수행하려면 모든 설정 토글을 활성화합니다.
Send Workflow Notification via Slack DM : 워크플로우 요청에 대한 Slack DM 전송 활성화
Allow Users to approve or reject on Slack DM : Slack DM 내에서 승인 또는 거절 기능 활성화
액션 허용타입 예시 (좌) / 액션 비허용 타입 예시 (우)
Save 버튼을 누르고 설정을 완료합니다.
Slack Configuration을 등록한 뒤, 현재 설정 상태를 화면에서 확인할 수 있습니다.
Administrator > General > System > Integrations > Slack
Edit 버튼을 클릭하여 입력한 설정을 변경할 수 있습니다.
Administrator > General > System > Integrations > Slack > Edit the Slack Configuration
DB Access Request를 예시로, Slack DM 기능이 정상적으로 작동하는지 테스트를 수행합니다.
QueryPie User > Workflow 페이지에서 Submit Request 버튼을 클릭한 뒤, DB Access Request를 선택하여 요청 작성 화면으로 진입합니다. 요청 작성 화면에서 필요한 정보를 입력하고, 요청을 상신합니다.
승인자는 앞에서 추가한 Slack App과의 DM으로 승인해야 할 요청 알림을 수신할 수 있습니다.
Allow Users to approve or reject on Slack DM 설정이 켜져 있으므로, DM에서 직접 사유를 입력하고 요청을 승인 또는 거절할 수 있습니다.
DM에서 Details 버튼을 클릭 시, QueryPie Admin에서 결재 요청에 대한 상세 내용을 확인하고 승인 또는 거절할 수 있습니다.
{{..}} 안의 값은...
+ old_plain_text: "...진행합니다. {{..}} 안의 값은..."
+ new_plain_text: "...이동합니다. {{..}} 안의 값은..."
+ 현상: gap " "→" " 축소 시 의 leading " "가 전부 제거되어
+ 안의 값은이 됨 (올바른 결과: 안의 값은)
+ 원인: gap_old(2) == leading(2)인 경우에도 leading=''로 전부 제거
+ """
+ xhtml = (
+ ''
+ '미리 채워져 있는 내용들을 삭제하고 아래의 App Manifest를 진행합니다.'
+ '
'
+ ' '
+ '{{..}}'
+ ' 안의 값은 원하는 값으로 변경해 주세요.'
+ '
내용변경.' 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 = '앞
뒤
뒤
' 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}" + )