diff --git a/confluence-mdx/bin/reverse_sync/patch_builder.py b/confluence-mdx/bin/reverse_sync/patch_builder.py
index 0e951d9a1..edc9664ca 100644
--- a/confluence-mdx/bin/reverse_sync/patch_builder.py
+++ b/confluence-mdx/bin/reverse_sync/patch_builder.py
@@ -23,6 +23,7 @@
from reverse_sync.reconstructors import (
sidecar_block_requires_reconstruction,
reconstruct_fragment_with_sidecar,
+ container_sidecar_requires_reconstruction,
)
from reverse_sync.list_patcher import (
build_list_item_patches,
@@ -217,6 +218,28 @@ def _flush_containing_changes(
return patches
+def _find_best_list_mapping_by_text(
+ old_plain: str,
+ mappings: List[BlockMapping],
+ used_ids: set,
+) -> Optional[BlockMapping]:
+ """old_plain prefix로 미사용 list mapping을 찾는다.
+
+ sidecar lookup이 잘못된 list mapping을 반환했을 때 plain text로 올바른
+ mapping을 복원하기 위한 fallback이다.
+ prefix 40자를 기준으로 xhtml_plain_text를 검색한다.
+ """
+ prefix = old_plain[:40].strip()
+ if not prefix:
+ return None
+ for m in mappings:
+ if m.type != 'list' or m.block_id in used_ids:
+ continue
+ if prefix in m.xhtml_plain_text:
+ return m
+ return None
+
+
def _resolve_mapping_for_change(
change: BlockChange,
old_plain: str,
@@ -391,6 +414,34 @@ def _mark_used(block_id: str, m: BlockMapping):
change, old_plain, mappings, used_ids,
mdx_to_sidecar, xpath_to_mapping)
+ # legacy sidecar mapping이 커버하지 못한 list 블록:
+ # roundtrip sidecar v3 identity로 fallback하여 mapping 복원
+ mapping_via_v3_fallback = False
+ if mapping is None and strategy == 'list' and roundtrip_sidecar is not None:
+ id_block = change.old_block or change.new_block
+ if id_block and id_block.content:
+ fallback_sc = find_sidecar_block_by_identity(
+ roundtrip_sidecar.blocks,
+ sha256_text(id_block.content),
+ (id_block.line_start, id_block.line_end),
+ )
+ if fallback_sc is not None:
+ resolved = xpath_to_mapping.get(fallback_sc.xhtml_xpath)
+ if resolved is not None:
+ mapping = resolved
+ mapping_via_v3_fallback = True
+
+ # sidecar가 잘못된 list mapping을 반환한 경우 (ac: 포함 + plain text 불일치):
+ # plain text prefix로 올바른 mapping 복원
+ if (strategy == 'list' and mapping is not None
+ and _contains_preserved_anchor_markup(mapping.xhtml_text)
+ and old_plain[:40].strip() not in mapping.xhtml_plain_text):
+ text_fallback = _find_best_list_mapping_by_text(
+ old_plain, mappings, used_ids)
+ if text_fallback is not None:
+ mapping = text_fallback
+ mapping_via_v3_fallback = True
+
if strategy == 'skip':
continue
@@ -404,12 +455,13 @@ def _mark_used(block_id: str, m: BlockMapping):
mapping is not None
and not _contains_preserved_anchor_markup(mapping.xhtml_text)
and roundtrip_sidecar is not None
- and list_sidecar is None
+ and (list_sidecar is None or mapping_via_v3_fallback)
)
if (mapping is not None
- and not _contains_preserved_anchor_markup(mapping.xhtml_text)
and (
+ # anchor case: sidecar anchor metadata가 있으면 ac: 포함 여부 무관
sidecar_block_requires_reconstruction(list_sidecar)
+ # clean case: preserved anchor 없는 clean list
or should_replace_clean_list
)):
_mark_used(mapping.block_id, mapping)
@@ -450,6 +502,20 @@ def _mark_used(block_id: str, m: BlockMapping):
change.new_block.content, change.new_block.type)
if strategy == 'containing':
+ sidecar_block = _find_roundtrip_sidecar_block(
+ change, mapping, roundtrip_sidecar, xpath_to_sidecar_block,
+ )
+ if container_sidecar_requires_reconstruction(sidecar_block):
+ _mark_used(mapping.block_id, mapping)
+ patches.append(
+ _build_replace_fragment_patch(
+ mapping,
+ change.new_block,
+ sidecar_block=sidecar_block,
+ mapping_lost_info=mapping_lost_info,
+ )
+ )
+ continue
bid = mapping.block_id
if bid not in containing_changes:
containing_changes[bid] = (mapping, [])
diff --git a/confluence-mdx/bin/reverse_sync/reconstructors.py b/confluence-mdx/bin/reverse_sync/reconstructors.py
index fe2f720ba..9e1b8150b 100644
--- a/confluence-mdx/bin/reverse_sync/reconstructors.py
+++ b/confluence-mdx/bin/reverse_sync/reconstructors.py
@@ -6,7 +6,7 @@
from __future__ import annotations
import difflib
-from typing import TYPE_CHECKING, Optional
+from typing import TYPE_CHECKING, List, Optional, Tuple
from bs4 import BeautifulSoup, NavigableString, Tag
@@ -160,6 +160,25 @@ def _find_direct_list_item_paragraph(li: Tag) -> Tag:
return li
+def _remove_html_img_if_same_image(p: Tag, anchor_xhtml: str) -> None:
+ """anchor_xhtml이 ac:image인 경우 p 내의 동일 파일명 태그를 제거한다.
+
+ emit_block이 MDX
태그를 HTML
로 변환하지 않고 유지하는 경우,
+ anchor 재삽입 시 ac:image와 중복되지 않도록 기존
를 제거한다.
+ """
+ anchor_soup = BeautifulSoup(anchor_xhtml, 'html.parser')
+ ri_attachment = anchor_soup.find('ri:attachment')
+ if ri_attachment is None:
+ return
+ filename = ri_attachment.get('ri:filename', '')
+ if not filename:
+ return
+ for img in list(p.find_all('img')):
+ src = img.get('src', '')
+ if src and (src.endswith('/' + filename) or src == filename):
+ img.decompose()
+
+
def _rebuild_list_fragment(new_fragment: str, recon: dict) -> str:
"""list fragment에 sidecar anchor entries를 경로 기반으로 재주입한다."""
soup = BeautifulSoup(new_fragment, 'html.parser')
@@ -178,11 +197,171 @@ def _rebuild_list_fragment(new_fragment: str, recon: dict) -> str:
p = _find_direct_list_item_paragraph(li)
new_p_plain = extract_plain_text(str(p))
new_offset = map_anchor_offset(old_plain, new_p_plain, entry['offset'])
+ _remove_html_img_if_same_image(p, entry['raw_xhtml'])
insert_anchor_at_offset(p, new_offset, entry['raw_xhtml'])
return str(soup)
+# ── container 재구성 헬퍼 ──────────────────────────────────────────────────────
+
+def _has_inline_markup(fragment: str) -> bool:
+ """fragment의
에 ac:image 외 인라인 태그가 있으면 True를 반환한다.
+
+ container reconstruction 필요 여부를 결정하는 데 사용한다.
+ """
+ if not fragment:
+ return False
+ soup = BeautifulSoup(fragment, 'html.parser')
+ p = soup.find('p')
+ if p is None:
+ return False
+ return any(
+ isinstance(child, Tag) and child.name != 'ac:image'
+ for child in p.children
+ )
+
+
+def _collect_text_nodes_with_offsets(
+ element: Tag,
+ start_offset: int,
+ nodes: List[Tuple],
+) -> int:
+ """element 내부 텍스트 노드와 old_plain 기준 offset 범위를 수집한다."""
+ for child in element.children:
+ if isinstance(child, NavigableString):
+ text = str(child)
+ end_offset = start_offset + len(text)
+ parent_name = child.parent.name if isinstance(child.parent, Tag) else ''
+ nodes.append((child, start_offset, end_offset, parent_name))
+ start_offset = end_offset
+ continue
+ if not isinstance(child, Tag):
+ continue
+ if child.name == 'ac:emoticon':
+ start_offset += len(child.get('ac:emoji-fallback', ''))
+ continue
+ if child.name == 'ac:image':
+ continue
+ start_offset = _collect_text_nodes_with_offsets(child, start_offset, nodes)
+ return start_offset
+
+
+def _rewrite_inline_segments_on_template(root: Tag, new_plain: str) -> Optional[str]:
+ """direct inline tag 구조를 유지한 채 paragraph 텍스트를 재배치한다.
+
+ inline 태그가 없거나
이 있으면 None을 반환한다 (fallback으로 text node 재배치).
+ """
+ segments: list = []
+ tokens: list = []
+ for child in root.children:
+ if isinstance(child, NavigableString):
+ segments.append(('text', str(child), ''))
+ continue
+ if not isinstance(child, Tag):
+ continue
+ if child.name == 'br':
+ return None
+ raw = str(child)
+ plain = extract_plain_text(raw)
+ token = f"__RS_INLINE_{len(tokens)}__"
+ tokens.append((token, plain))
+ segments.append(('tag', raw, token))
+
+ if not tokens:
+ return None
+
+ tokenized_new = new_plain
+ cursor = 0
+ for token, plain in tokens:
+ pos = tokenized_new.find(plain, cursor)
+ if pos < 0:
+ return None
+ tokenized_new = tokenized_new[:pos] + token + tokenized_new[pos + len(plain):]
+ cursor = pos + len(token)
+
+ text_parts: list = []
+ cursor = 0
+ for token, _ in tokens:
+ pos = tokenized_new.find(token, cursor)
+ if pos < 0:
+ return None
+ text_parts.append(tokenized_new[cursor:pos])
+ cursor = pos + len(token)
+ text_parts.append(tokenized_new[cursor:])
+
+ rebuilt: list = []
+ remaining_text_parts = iter(text_parts)
+ for kind, value, _ in segments:
+ if kind == 'text':
+ rebuilt.append(next(remaining_text_parts, ''))
+ else:
+ rebuilt.append(value)
+ rebuilt.append(''.join(remaining_text_parts))
+
+ return f"<{root.name}>{''.join(rebuilt)}{root.name}>"
+
+
+def _rewrite_paragraph_on_template(template_fragment: str, new_fragment: str) -> str:
+ """원본 paragraph inline markup을 유지한 채 텍스트만 새 fragment 기준으로 갱신한다.
+
+ 1. inline tag 구조가 단순하면 tokenize 방식으로 재배치.
+ 2. 복잡한 경우 text node별 offset 매핑으로 재배치.
+ 텍스트가 동일하면 template_fragment를 그대로 반환한다.
+ """
+ old_plain = extract_plain_text(template_fragment)
+ new_plain = extract_plain_text(new_fragment)
+ if old_plain == new_plain:
+ return template_fragment
+
+ template_soup = BeautifulSoup(template_fragment, 'html.parser')
+ root = next((child for child in template_soup.contents if isinstance(child, Tag)), None)
+ if root is None:
+ return new_fragment
+
+ preserved_inline = _rewrite_inline_segments_on_template(root, new_plain)
+ if preserved_inline is not None:
+ return preserved_inline
+
+ text_nodes: list = []
+ _collect_text_nodes_with_offsets(root, 0, text_nodes)
+ if not text_nodes:
+ return new_fragment
+ if len(text_nodes) == 1:
+ text_nodes[0][0].replace_with(NavigableString(new_plain))
+ return str(template_soup)
+
+ for node, start_offset, end_offset, parent_name in text_nodes:
+ new_start = map_anchor_offset(old_plain, new_plain, start_offset, affinity='before')
+ new_end = map_anchor_offset(old_plain, new_plain, end_offset, affinity='after')
+ replacement_text = new_plain[new_start:new_end]
+ if parent_name == 'code':
+ original_text = str(node)
+ if replacement_text.replace('`', '') == original_text.replace('`', ''):
+ replacement_text = original_text
+ node.replace_with(NavigableString(replacement_text))
+
+ return str(template_soup)
+
+
+def _reconstruct_child_with_anchors(child_frag: str, child_meta: dict) -> str:
+ """child fragment에 anchor를 offset 매핑으로 재삽입한다."""
+ anchors = child_meta.get('anchors', [])
+ if not anchors:
+ return child_frag
+ soup = BeautifulSoup(child_frag, 'html.parser')
+ p = soup.find('p')
+ if p is None:
+ return child_frag
+ old_plain = child_meta.get('plain_text', '')
+ new_plain = extract_plain_text(child_frag)
+ for anchor in reversed(anchors):
+ new_offset = map_anchor_offset(old_plain, new_plain, anchor['offset'])
+ _remove_html_img_if_same_image(p, anchor['raw_xhtml'])
+ insert_anchor_at_offset(p, new_offset, anchor['raw_xhtml'])
+ return str(soup)
+
+
def sidecar_block_requires_reconstruction(
sidecar_block: Optional['SidecarBlock'],
) -> bool:
@@ -203,6 +382,8 @@ def sidecar_block_requires_reconstruction(
'offset' in item and 'raw_xhtml' in item
for item in recon.get('items', [])
)
+ if recon.get('kind') == 'container':
+ return container_sidecar_requires_reconstruction(sidecar_block)
return False
@@ -223,9 +404,98 @@ def reconstruct_fragment_with_sidecar(
return reconstruct_inline_anchor_fragment(old_plain, valid_anchors, new_fragment)
if kind == 'list':
return _rebuild_list_fragment(new_fragment, recon)
+ if kind == 'container':
+ return reconstruct_container_fragment(new_fragment, sidecar_block)
return new_fragment
+def container_sidecar_requires_reconstruction(
+ sidecar_block: Optional['SidecarBlock'],
+) -> bool:
+ """container sidecar block에 anchor 재구성이 필요한 child가 있으면 True를 반환한다.
+
+ ac:image anchor 또는 list item anchor가 있는 child가 하나 이상 있어야 True.
+ reconstruction이 트리거된 후에는 같은 container 내 inline markup도 함께 보존한다.
+ """
+ if sidecar_block is None or sidecar_block.reconstruction is None:
+ return False
+ recon = sidecar_block.reconstruction
+ if recon.get('kind') != 'container':
+ return False
+ return any(
+ c.get('anchors') or c.get('items')
+ for c in recon.get('children', [])
+ )
+
+
+def reconstruct_container_fragment(
+ new_fragment: str,
+ sidecar_block: Optional['SidecarBlock'],
+) -> str:
+ """Container (callout/ADF panel) fragment에 sidecar child 메타데이터로 재구성한다.
+
+ anchor 없는 clean container는 new_fragment를 그대로 반환한다.
+ anchor가 있어 재구성이 트리거된 경우 아래 세 단계를 각 child에 적용한다:
+ 1. inline markup 보존: 원본 fragment를 template으로 bold·italic·link 유지
+ 2. anchor 재삽입: ac:image를 offset 매핑으로 복원
+ 3. outer wrapper 보존: sidecar xhtml_fragment를 template으로 macro 속성 유지
+ """
+ if sidecar_block is None or sidecar_block.reconstruction is None:
+ return new_fragment
+ recon = sidecar_block.reconstruction
+ if recon.get('kind') != 'container':
+ return new_fragment
+ children_meta = recon.get('children', [])
+ if not any(c.get('anchors') or c.get('items') for c in children_meta):
+ return new_fragment # clean container — emit_block result 그대로 사용
+
+ # emitted new_fragment에서 body children 추출
+ emitted_soup = BeautifulSoup(new_fragment, 'html.parser')
+ emitted_body = emitted_soup.find('ac:rich-text-body') or emitted_soup.find('ac:adf-content')
+ if emitted_body is None:
+ return new_fragment
+
+ emitted_children = [c for c in emitted_body.children if isinstance(c, Tag)]
+
+ # 각 child 재구성
+ rebuilt_fragments = []
+ for i, child_tag in enumerate(emitted_children):
+ if i >= len(children_meta):
+ rebuilt_fragments.append(str(child_tag))
+ continue
+ child_meta = children_meta[i]
+ stored_fragment = child_meta.get('fragment', '')
+ child_frag = str(child_tag)
+
+ # Step 1: inline markup 보존 (stored fragment를 template으로 재구성)
+ if stored_fragment and _has_inline_markup(stored_fragment):
+ child_frag = _rewrite_paragraph_on_template(stored_fragment, child_frag)
+
+ # Step 2: anchor 재삽입
+ if child_meta.get('anchors'):
+ child_frag = _reconstruct_child_with_anchors(child_frag, child_meta)
+
+ rebuilt_fragments.append(child_frag)
+
+ # Step 3: outer wrapper template (macro 속성 보존)
+ outer_template = sidecar_block.xhtml_fragment
+ template_soup = BeautifulSoup(outer_template or new_fragment, 'html.parser')
+ template_body = (
+ template_soup.find('ac:rich-text-body') or template_soup.find('ac:adf-content')
+ )
+ if template_body is None:
+ return new_fragment
+
+ for child in list(template_body.contents):
+ child.extract()
+ for frag in rebuilt_fragments:
+ frag_soup = BeautifulSoup(frag, 'html.parser')
+ for node in list(frag_soup.contents):
+ template_body.append(node.extract())
+
+ return str(template_soup)
+
+
def reconstruct_inline_anchor_fragment(
old_fragment: str,
anchors: list,
@@ -255,6 +525,7 @@ def reconstruct_inline_anchor_fragment(
# offset을 역순으로 처리하여 앞쪽 삽입이 뒤쪽 offset에 영향 미치지 않게 함
for anchor in reversed(anchors):
new_offset = map_anchor_offset(old_plain, new_plain, anchor['offset'])
+ _remove_html_img_if_same_image(p, anchor['raw_xhtml'])
insert_anchor_at_offset(p, new_offset, anchor['raw_xhtml'])
return str(soup)
diff --git a/confluence-mdx/bin/reverse_sync/sidecar.py b/confluence-mdx/bin/reverse_sync/sidecar.py
index afa21d378..f865b9431 100644
--- a/confluence-mdx/bin/reverse_sync/sidecar.py
+++ b/confluence-mdx/bin/reverse_sync/sidecar.py
@@ -371,18 +371,40 @@ def _build_reconstruction_metadata(
metadata["ordered"] = mapping.xhtml_xpath.startswith("ol[")
metadata["items"] = _build_list_anchor_entries(fragment)
elif mapping.children:
+ children_meta = []
+ for child_id in mapping.children:
+ if child_id not in id_to_mapping:
+ continue
+ child_m = id_to_mapping[child_id]
+ # mapping_recorder가 paragraph의 xhtml_text를 inner content(래퍼 제거)로
+ # 저장하므로 fragment 필드에는 전체 태그 형태로 복원한다
+ if child_m.type == "paragraph":
+ child_fragment = f'
{child_m.xhtml_text}
' + else: + child_fragment = child_m.xhtml_text + child_data: Dict[str, Any] = { + "xpath": child_m.xhtml_xpath, + "fragment": child_fragment, + "plain_text": child_m.xhtml_plain_text, + "type": child_m.type, + } + if child_m.type == "paragraph": + anchors = _build_anchor_entries(child_fragment) + if anchors: + child_data["anchors"] = anchors + elif child_m.type == "list": + items = _build_list_anchor_entries(child_m.xhtml_text) + if items: + child_data["items"] = items + children_meta.append(child_data) + metadata["kind"] = "container" + metadata["children"] = children_meta + metadata["child_xpaths"] = [c["xpath"] for c in children_meta] child_plain_texts = [ - id_to_mapping[child_id].xhtml_plain_text.strip() - for child_id in mapping.children - if child_id in id_to_mapping and id_to_mapping[child_id].xhtml_plain_text.strip() + c["plain_text"].strip() for c in children_meta if c["plain_text"].strip() ] if child_plain_texts: metadata["old_plain_text"] = " ".join(child_plain_texts) - metadata["child_xpaths"] = [ - id_to_mapping[child_id].xhtml_xpath - for child_id in mapping.children - if child_id in id_to_mapping - ] return metadata diff --git a/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py new file mode 100644 index 000000000..8164812de --- /dev/null +++ b/confluence-mdx/tests/test_reverse_sync_reconstruct_container.py @@ -0,0 +1,501 @@ +"""Phase 4 container reconstruction 메타데이터 및 reconstructor 테스트.""" +import pytest +from pathlib import Path +from bs4 import BeautifulSoup + +from reverse_sync.sidecar import build_sidecar, _build_reconstruction_metadata +from reverse_sync.mapping_recorder import BlockMapping, record_mapping +from reverse_sync.xhtml_normalizer import ( + normalize_fragment, + extract_plain_text, + extract_fragment_by_xpath, +) +from reverse_sync.reconstructors import ( + reconstruct_container_fragment, + container_sidecar_requires_reconstruction, + _has_inline_markup, + _rewrite_paragraph_on_template, +) +from reverse_sync.sidecar import SidecarBlock + + +CALLOUT_XHTML = ( + 'First paragraph.
' + 'Second paragraph.
' + 'text {image_xhtml} more
' + 'simple text
') is False + + def test_strong_returns_true(self): + assert _has_inline_markup('text bold
') is True + + def test_link_returns_true(self): + assert _has_inline_markup( + 'see
text
Click here to continue.
' + new_frag = 'Click here to proceed.
' + result = _rewrite_paragraph_on_template(template, new_frag) + assert '' in result + assert 'proceed' in result + + def test_link_preserved_on_text_change(self): + """ac:link 구조를 유지하며 주변 텍스트를 갱신한다.""" + template = ( + '자세한 내용은
자세한 내용은 가이드 참고하세요.
' + result = _rewrite_paragraph_on_template(template, new_frag) + assert 'ac:link' in result + assert '가이드' in result + + def test_same_text_returns_template(self): + """텍스트가 동일하면 template_fragment를 그대로 반환한다.""" + template = 'text bold
' + new_frag = 'text bold
' + result = _rewrite_paragraph_on_template(template, new_frag) + assert result == template + + def test_em_tag_preserved(self): + template = '이것은 강조된 텍스트입니다.
' + new_frag = '이것은 강조된 다른 텍스트입니다.
' + result = _rewrite_paragraph_on_template(template, new_frag) + assert '' in result + + +class TestContainerSidecarRequiresReconstruction: + def test_returns_false_for_none(self): + assert container_sidecar_requires_reconstruction(None) is False + + def test_returns_false_for_clean_container(self): + block = SidecarBlock( + block_index=0, xhtml_xpath='macro-info[1]', + xhtml_fragment='text
', + 'plain_text': 'text', 'type': 'paragraph'}, + ], + 'child_xpaths': ['macro-info[1]/p[1]'], + }, + ) + assert container_sidecar_requires_reconstruction(block) is False + + def test_returns_true_when_child_has_anchors(self): + block = SidecarBlock( + block_index=0, xhtml_xpath='macro-info[1]', + xhtml_fragment='text
Click here to continue.
', + 'plain_text': 'Click here to continue.', + 'type': 'paragraph', + }, + ], + 'child_xpaths': ['macro-info[1]/p[1]'], + }, + ) + assert container_sidecar_requires_reconstruction(block) is False + + +class TestReconstructContainerFragment: + def _make_sidecar_block(self, children_meta): + return SidecarBlock( + block_index=0, + xhtml_xpath='macro-info[1]', + xhtml_fragment='', + reconstruction={ + 'kind': 'container', + 'children': children_meta, + 'child_xpaths': [c['xpath'] for c in children_meta], + }, + ) + + def test_clean_container_returned_as_is(self): + """anchor 없는 container는 new_fragment를 그대로 반환한다.""" + new_frag = ( + 'Updated text.
Original text.
', + 'plain_text': 'Original text.', 'type': 'paragraph'}, + ]) + result = reconstruct_container_fragment(new_frag, block) + assert result == new_frag + + def test_container_with_anchor_reinjects_image(self): + """child에 ac:image가 있으면 new_fragment에 재삽입된다.""" + image_xhtml = 'updated text
text {image_xhtml}
', + 'plain_text': 'text', + 'type': 'paragraph', + 'anchors': [{'kind': 'image', 'offset': len('text '), 'raw_xhtml': image_xhtml}], + }, + ]) + result = reconstruct_container_fragment(new_fragment, block) + assert 'ac:image' in result + assert 'test.png' in result + + def test_container_with_inline_markup_preserves_strong_when_anchor_present(self): + """anchor child가 있어 reconstruction이 트리거되면, inline markup child도 함께 보존된다. + + anchor 없는 child라도 inline markup이 있으면 _rewrite_paragraph_on_template으로 보존. + """ + image_xhtml = 'Title
' + f'See diagram {image_xhtml} for details.
' + 'Title
' + 'See diagram for details.
' + 'Title
', + 'plain_text': 'Title', + 'type': 'paragraph', + }, + { + 'xpath': 'macro-info[1]/p[2]', + 'fragment': f'See diagram {image_xhtml} for details.
', + 'plain_text': 'See diagram for details.', + 'type': 'paragraph', + 'anchors': [{'kind': 'image', 'offset': 12, 'raw_xhtml': image_xhtml}], + }, + ], + 'child_xpaths': ['macro-info[1]/p[1]', 'macro-info[1]/p[2]'], + }, + ) + result = reconstruct_container_fragment(new_fragment, block) + # anchor child: ac:image 재삽입됨 + assert 'diagram.png' in result + # inline markup child: 보존됨 + assert '' in result + + def test_outer_wrapper_macro_attributes_preserved(self): + """sidecar xhtml_fragment의 macro 속성이 결과 wrapper에 유지된다.""" + xhtml_fragment = ( + 'Original.
Updated
Original {image_xhtml}
', + 'plain_text': 'Original', + 'type': 'paragraph', + 'anchors': [{'kind': 'image', 'offset': 9, 'raw_xhtml': image_xhtml}], + }, + ], + 'child_xpaths': ['macro-info[1]/p[1]'], + }, + ) + result = reconstruct_container_fragment(new_fragment, block) + assert 'ac:schema-version="1"' in result + assert 'ac:parameter' in result + assert 'x.png' in result + + def test_no_sidecar_block_returns_new_fragment(self): + new_frag = ( + 'x
Original text {image_xhtml} end.
' + '
end.\n"
+ "
end.\n"
+ "Original text.
' + 'First
", + "plain_text": "First", + "type": "paragraph", + }, + { + "xpath": "macro-info[1]/p[2]", + "fragment": "Second
", + "plain_text": "Second", + "type": "paragraph", + }, + ], } diff --git a/confluence-mdx/tests/testcases/1454342158/expected.roundtrip.json b/confluence-mdx/tests/testcases/1454342158/expected.roundtrip.json index b620396ff..ecfc5339a 100644 --- a/confluence-mdx/tests/testcases/1454342158/expected.roundtrip.json +++ b/confluence-mdx/tests/testcases/1454342158/expected.roundtrip.json @@ -16,7 +16,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "" + "old_plain_text": "none" } }, { @@ -94,8 +94,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "현재 여러개의 Identity Provider를 동시에 지원하지 않습니다. Internal database (QueryPie 사용자 ID/PW) 인증외에 하나의 IdP를 사용해야 합니다.MFA (Multi-factor Authentication) 지원은 Internal DB, LDAP, Custom Identity Provider에서만 가능합니다. 11.5.0 부터 Internal DB와 LDAP 또는 Internal DB와 Custom Identity Provider를 설정해서 사용할 경우 각각의 설정에서 MFA를 사용할 수 있습니다.예) LDAP에서 MFA를 설정하고 Internal DB에 MFA를 설정한 경우 사용자 계정이 LDAP 계정이면 LDAP의 MFA 설정에 의해 제어되고 Internal DB의 사용자 계정은 Internal DB의 MFA 설정에 의해 제어됩니다.Schedule에 의한 주기적 동기화는 LDAP, Okta, One Login, Custom Identity Provider에서만 가능합니다.", + "children": [ + { + "xpath": "macro-info[1]/ul[1]", + "fragment": "현재 여러개의 Identity Provider를 동시에 지원하지 않습니다. Internal database (QueryPie 사용자 ID/PW) 인증외에 하나의 IdP를 사용해야 합니다.
MFA (Multi-factor Authentication) 지원은 Internal DB, LDAP, Custom Identity Provider에서만 가능합니다.
11.5.0 부터 Internal DB와 LDAP 또는 Internal DB와 Custom Identity Provider를 설정해서 사용할 경우 각각의 설정에서 MFA를 사용할 수 있습니다.
예) LDAP에서 MFA를 설정하고 Internal DB에 MFA를 설정한 경우 사용자 계정이 LDAP 계정이면 LDAP의 MFA 설정에 의해 제어되고 Internal DB의 사용자 계정은 Internal DB의 MFA 설정에 의해 제어됩니다.
Schedule에 의한 주기적 동기화는 LDAP, Okta, One Login, Custom Identity Provider에서만 가능합니다.
주의: 설정 후 한번이라도 사용자를 동기화 했다면, 설정한 IdP를 삭제(삭제후 다른 IdP로 변경)하는 것이 불가능합니다. IdP를 변경하거나 삭제를 해야하는 경우 Customer Portal 을 통해 문의 부탁드립니다.
", + "plain_text": "주의: 설정 후 한번이라도 사용자를 동기화 했다면, 설정한 IdP를 삭제(삭제후 다른 IdP로 변경)하는 것이 불가능합니다. IdP를 변경하거나 삭제를 해야하는 경우 Customer Portal 을 통해 문의 부탁드립니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-note[1]/p[1]" ] @@ -257,7 +273,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "LDAP 상세설정" + "old_plain_text": "" } }, { @@ -428,8 +444,34 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "LDAP Attribute Mapping LDAP에서 관리 중인 사용자 속성을 QueryPie 내 Attribute와 매핑하여 동기화하려면, Enable Attribute Synchronization 옵션을 활성화하고 아래 정보를 입력합니다. 우측 상단의 Add Row 버튼을 클릭하면 새로운 매핑 행이 추가되며, 각 행마다 LDAP Attribute와 대응되는 QueryPie Attribute를 지정할 수 있습니다. 해당 기능은 Admin > General > User Management > Profile Editor에서 Source Priority가 Inherit from profile source로 설정된 QueryPie Attribute에 한해 적용됩니다.QueryPie Attribute인 Username (loginId), Primary Email (email) 항목은 LDAP 연동 설정 시 별도로 입력되므로, 해당 항목은 LDAP–QueryPie Attribute Mapping UI에는 노출되지 않습니다. 매핑 행 삭제 또는 변경 시, Save 버튼을 클릭해야 UI 상에서 변경 사항이 반영되며, Synchronize를 추가로 클릭해야 LDAP과 실제 동기화가 수행됩니다. 즉, Save는 화면상 변경, Synchronize는 시스템 반영을 의미합니다.", + "children": [ + { + "xpath": "macro-info[2]/p[1]", + "fragment": "LDAP Attribute Mapping
", + "plain_text": "LDAP Attribute Mapping", + "type": "paragraph" + }, + { + "xpath": "macro-info[2]/p[2]", + "fragment": "LDAP에서 관리 중인 사용자 속성을 QueryPie 내 Attribute와 매핑하여 동기화하려면, Enable Attribute Synchronization 옵션을 활성화하고 아래 정보를 입력합니다.
", + "plain_text": "LDAP에서 관리 중인 사용자 속성을 QueryPie 내 Attribute와 매핑하여 동기화하려면, Enable Attribute Synchronization 옵션을 활성화하고 아래 정보를 입력합니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[2]/p[3]", + "fragment": "우측 상단의 Add Row 버튼을 클릭하면 새로운 매핑 행이 추가되며, 각 행마다 LDAP Attribute와 대응되는 QueryPie Attribute를 지정할 수 있습니다.
해당 기능은 Admin > General > User Management > Profile Editor에서 Source Priority가 Inherit from profile source로 설정된 QueryPie Attribute에 한해 적용됩니다.
QueryPie Attribute인 Username (loginId), Primary Email (email) 항목은 LDAP 연동 설정 시 별도로 입력되므로, 해당 항목은 LDAP–QueryPie Attribute Mapping UI에는 노출되지 않습니다.
매핑 행 삭제 또는 변경 시, Save 버튼을 클릭해야 UI 상에서 변경 사항이 반영되며, Synchronize를 추가로 클릭해야 LDAP과 실제 동기화가 수행됩니다. 즉, Save는 화면상 변경, Synchronize는 시스템 반영을 의미합니다.
Active Directory를 LDAP 연동할 경우 Anonymous 항목을 반드시 false로 설정해야합니다. AD는 기본적으로 익명 바인드 상태에서의 검색 작업을 허용하지 않습니다.
", + "plain_text": "Active Directory를 LDAP 연동할 경우 Anonymous 항목을 반드시 false로 설정해야합니다. AD는 기본적으로 익명 바인드 상태에서의 검색 작업을 허용하지 않습니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "ac:adf-extension[1]/p[1]" ] @@ -498,7 +548,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin > Applications > Applications > Browse App Catalog > QueryPie 검색" + "old_plain_text": "" } }, { @@ -545,7 +595,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin > Directory > Profile Editor > QueryPie User > Add Attribute" + "old_plain_text": "" } }, { @@ -577,7 +627,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin > Directory > Profile Editor > QueryPie User > Mappings" + "old_plain_text": "" } }, { @@ -609,7 +659,7 @@ "lost_info": {}, "reconstruction": { "kind": "paragraph", - "old_plain_text": "", + "old_plain_text": " ", "anchors": [] } }, @@ -640,7 +690,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin > Applications > Applications > QueryPie App" + "old_plain_text": "" } }, { @@ -672,7 +722,7 @@ "lost_info": {}, "reconstruction": { "kind": "paragraph", - "old_plain_text": "", + "old_plain_text": " ", "anchors": [] } }, @@ -703,7 +753,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin > Applications > Applications > QueryPie App" + "old_plain_text": "" } }, { @@ -735,7 +785,7 @@ "lost_info": {}, "reconstruction": { "kind": "paragraph", - "old_plain_text": "", + "old_plain_text": " ", "anchors": [] } }, @@ -815,7 +865,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin Console > Security > Administrators > Roles > Create new role" + "old_plain_text": "" } }, { @@ -878,7 +928,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta 상세설정 (1)" + "old_plain_text": "" } }, { @@ -909,8 +959,94 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", - "old_plain_text": "Assertion Consumer Service (ACS): SP(Service provider)에 위치한 특정 Endpoint(URL)로, IdP로부터 SAML Assertion을 수신하여 검증하고 사용자의 로그인을 처리하는 역할을 합니다. Assertion Consumer Service Index의 필요성 및 역할 하나의 SP는 다양한 이유로 여러 개의 ACS URL을 가질 수 있습니다. 이때AssertionConsumerServiceIndex가 필수적인 역할을 합니다. SP가 인증을 요청하는 SAML 메시지(AuthnRequest)에 이 인덱스 값을 포함하여 IdP에 보내면, IdP는 해당 인덱스에 매핑된 ACS URL로 정확하게 SAML 어설션을 전송합니다. 만약 이 인덱스가 명시되지 않으면, 일반적으로 미리 약속된 기본(Default) ACS URL로 어설션을 보내게 됩니다. 이러한 인덱스 기반의 라우팅은 다음과 같은 구체적인 상황에서 매우 유용합니다. 주요 사용 사례 다양한 프로토콜 바인딩(Binding) 지원: SAML 어설션은 HTTP POST, HTTP-Artifact 등 여러 방식으로 전송될 수 있습니다. SP는 각 바인딩 방식에 따라 별도의 ACS URL을 운영할 수 있습니다. 예를 들어, index=\"0\"은 HTTP POST를 위한 ACS URL을, index=\"1\"은 HTTP-Artifact를 위한 ACS URL을 가리키도록 설정할 수 있습니다. 다중 테넌트(Multi-tenant) 아키텍처 지원: 하나의 SaaS 애플리케이션이 여러 고객사(테넌트)를 지원하는 경우, 각 테넌트별로 고유한 ACS URL을 할당할 수 있습니다. 이를 통해 각 고객사의 인증 흐름을 격리하고 맞춤형으로 관리할 수 있습니다. 애플리케이션 내 다른 인증 흐름 구분: 같은 애플리케이션이라도 사용자의 역할이나 접근 경로에 따라 다른 인증 후 처리가 필요할 수 있습니다. 예를 들어, 일반 사용자와 관리자의 로그인 후 리디렉션 페이지를 다르게 설정하고 싶을 때, 각각 다른 ACS URL을 사용하고 이를 인덱스로 구분할 수 있습니다. 동적 또는 특수한 ACS URL 처리: 특정 상황이나 클라이언트의 요구에 따라 동적으로 생성된 ACS URL로 어설션을 받아야 할 때, 인덱스를 통해 정적으로 정의된 여러 URL 중 하나를 선택하도록 유도할 수 있습니다. okta app 설정의 Audience URI(SP Entity ID) Other Requestable SSO URLs 의 Index", + "kind": "container", + "old_plain_text": "Assertion Consumer Service (ACS): SP(Service provider)에 위치한 특정 Endpoint(URL)로, IdP로부터 SAML Assertion을 수신하여 검증하고 사용자의 로그인을 처리하는 역할을 합니다. Assertion Consumer Service Index의 필요성 및 역할 하나의 SP는 다양한 이유로 여러 개의 ACS URL을 가질 수 있습니다. 이때AssertionConsumerServiceIndex가 필수적인 역할을 합니다. SP가 인증을 요청하는 SAML 메시지(AuthnRequest)에 이 인덱스 값을 포함하여 IdP에 보내면, IdP는 해당 인덱스에 매핑된 ACS URL로 정확하게 SAML 어설션을 전송합니다. 만약 이 인덱스가 명시되지 않으면, 일반적으로 미리 약속된 기본(Default) ACS URL로 어설션을 보내게 됩니다. 이러한 인덱스 기반의 라우팅은 다음과 같은 구체적인 상황에서 매우 유용합니다. 주요 사용 사례 다양한 프로토콜 바인딩(Binding) 지원: SAML 어설션은 HTTP POST, HTTP-Artifact 등 여러 방식으로 전송될 수 있습니다. SP는 각 바인딩 방식에 따라 별도의 ACS URL을 운영할 수 있습니다. 예를 들어, index=\"0\"은 HTTP POST를 위한 ACS URL을, index=\"1\"은 HTTP-Artifact를 위한 ACS URL을 가리키도록 설정할 수 있습니다. 다중 테넌트(Multi-tenant) 아키텍처 지원: 하나의 SaaS 애플리케이션이 여러 고객사(테넌트)를 지원하는 경우, 각 테넌트별로 고유한 ACS URL을 할당할 수 있습니다. 이를 통해 각 고객사의 인증 흐름을 격리하고 맞춤형으로 관리할 수 있습니다. 애플리케이션 내 다른 인증 흐름 구분: 같은 애플리케이션이라도 사용자의 역할이나 접근 경로에 따라 다른 인증 후 처리가 필요할 수 있습니다. 예를 들어, 일반 사용자와 관리자의 로그인 후 리디렉션 페이지를 다르게 설정하고 싶을 때, 각각 다른 ACS URL을 사용하고 이를 인덱스로 구분할 수 있습니다. 동적 또는 특수한 ACS URL 처리: 특정 상황이나 클라이언트의 요구에 따라 동적으로 생성된 ACS URL로 어설션을 받아야 할 때, 인덱스를 통해 정적으로 정의된 여러 URL 중 하나를 선택하도록 유도할 수 있습니다.", + "children": [ + { + "xpath": "macro-info[3]/p[1]", + "fragment": "Assertion Consumer Service (ACS): SP(Service provider)에 위치한 특정 Endpoint(URL)로, IdP로부터 SAML Assertion을 수신하여 검증하고 사용자의 로그인을 처리하는 역할을 합니다.
", + "plain_text": "Assertion Consumer Service (ACS): SP(Service provider)에 위치한 특정 Endpoint(URL)로, IdP로부터 SAML Assertion을 수신하여 검증하고 사용자의 로그인을 처리하는 역할을 합니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/p[2]", + "fragment": "
Assertion Consumer Service Index의 필요성 및 역할
하나의 SP는 다양한 이유로 여러 개의 ACS URL을 가질 수 있습니다. 이때AssertionConsumerServiceIndex가 필수적인 역할을 합니다.
", + "plain_text": "하나의 SP는 다양한 이유로 여러 개의 ACS URL을 가질 수 있습니다. 이때AssertionConsumerServiceIndex가 필수적인 역할을 합니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/p[4]", + "fragment": "SP가 인증을 요청하는 SAML 메시지(AuthnRequest)에 이 인덱스 값을 포함하여 IdP에 보내면, IdP는 해당 인덱스에 매핑된 ACS URL로 정확하게 SAML 어설션을 전송합니다. 만약 이 인덱스가 명시되지 않으면, 일반적으로 미리 약속된 기본(Default) ACS URL로 어설션을 보내게 됩니다.
", + "plain_text": "SP가 인증을 요청하는 SAML 메시지(AuthnRequest)에 이 인덱스 값을 포함하여 IdP에 보내면, IdP는 해당 인덱스에 매핑된 ACS URL로 정확하게 SAML 어설션을 전송합니다. 만약 이 인덱스가 명시되지 않으면, 일반적으로 미리 약속된 기본(Default) ACS URL로 어설션을 보내게 됩니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/p[5]", + "fragment": "이러한 인덱스 기반의 라우팅은 다음과 같은 구체적인 상황에서 매우 유용합니다.
", + "plain_text": "이러한 인덱스 기반의 라우팅은 다음과 같은 구체적인 상황에서 매우 유용합니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/p[6]", + "fragment": "주요 사용 사례
", + "plain_text": "주요 사용 사례", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/ul[1]", + "fragment": "다양한 프로토콜 바인딩(Binding) 지원:
SAML 어설션은 HTTP POST, HTTP-Artifact 등 여러 방식으로 전송될 수 있습니다. SP는 각 바인딩 방식에 따라 별도의 ACS URL을 운영할 수 있습니다. 예를 들어, index=\"0\"은 HTTP POST를 위한 ACS URL을, index=\"1\"은 HTTP-Artifact를 위한 ACS URL을 가리키도록 설정할 수 있습니다.
", + "plain_text": "SAML 어설션은 HTTP POST, HTTP-Artifact 등 여러 방식으로 전송될 수 있습니다. SP는 각 바인딩 방식에 따라 별도의 ACS URL을 운영할 수 있습니다. 예를 들어, index=\"0\"은 HTTP POST를 위한 ACS URL을, index=\"1\"은 HTTP-Artifact를 위한 ACS URL을 가리키도록 설정할 수 있습니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/ul[2]", + "fragment": "다중 테넌트(Multi-tenant) 아키텍처 지원:
하나의 SaaS 애플리케이션이 여러 고객사(테넌트)를 지원하는 경우, 각 테넌트별로 고유한 ACS URL을 할당할 수 있습니다. 이를 통해 각 고객사의 인증 흐름을 격리하고 맞춤형으로 관리할 수 있습니다.
", + "plain_text": "하나의 SaaS 애플리케이션이 여러 고객사(테넌트)를 지원하는 경우, 각 테넌트별로 고유한 ACS URL을 할당할 수 있습니다. 이를 통해 각 고객사의 인증 흐름을 격리하고 맞춤형으로 관리할 수 있습니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/ul[3]", + "fragment": "애플리케이션 내 다른 인증 흐름 구분:
같은 애플리케이션이라도 사용자의 역할이나 접근 경로에 따라 다른 인증 후 처리가 필요할 수 있습니다. 예를 들어, 일반 사용자와 관리자의 로그인 후 리디렉션 페이지를 다르게 설정하고 싶을 때, 각각 다른 ACS URL을 사용하고 이를 인덱스로 구분할 수 있습니다.
", + "plain_text": "같은 애플리케이션이라도 사용자의 역할이나 접근 경로에 따라 다른 인증 후 처리가 필요할 수 있습니다. 예를 들어, 일반 사용자와 관리자의 로그인 후 리디렉션 페이지를 다르게 설정하고 싶을 때, 각각 다른 ACS URL을 사용하고 이를 인덱스로 구분할 수 있습니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[3]/ul[4]", + "fragment": "동적 또는 특수한 ACS URL 처리:
특정 상황이나 클라이언트의 요구에 따라 동적으로 생성된 ACS URL로 어설션을 받아야 할 때, 인덱스를 통해 정적으로 정의된 여러 URL 중 하나를 선택하도록 유도할 수 있습니다.
", + "plain_text": "특정 상황이나 클라이언트의 요구에 따라 동적으로 생성된 ACS URL로 어설션을 받아야 할 때, 인덱스를 통해 정적으로 정의된 여러 URL 중 하나를 선택하도록 유도할 수 있습니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[3]/p[1]", "macro-info[3]/p[2]", @@ -941,7 +1077,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta 상세설정 (2)" + "old_plain_text": "" } }, { @@ -988,8 +1124,22 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "Application ID 확인하는 방법 2개 이상의 QueryPie Application 을 사용하는 경우, Okta Admin > Applications 으로 이동하여 QueryPie 앱의 디테일 화면으로 들어가면 상단 URL 에서 위의 스크린샷에 표시된 것과 같은 Application ID 를 확인하실 수 있습니다.", + "children": [ + { + "xpath": "macro-info[4]/p[1]", + "fragment": "Application ID 확인하는 방법
", + "plain_text": "Application ID 확인하는 방법", + "type": "paragraph" + }, + { + "xpath": "macro-info[4]/p[2]", + "fragment": "2개 이상의 QueryPie Application 을 사용하는 경우, Okta Admin > Applications 으로 이동하여 QueryPie 앱의 디테일 화면으로 들어가면 상단 URL 에서 위의 스크린샷에 표시된 것과 같은 Application ID 를 확인하실 수 있습니다.
", + "plain_text": "2개 이상의 QueryPie Application 을 사용하는 경우, Okta Admin > Applications 으로 이동하여 QueryPie 앱의 디테일 화면으로 들어가면 상단 URL 에서 위의 스크린샷에 표시된 것과 같은 Application ID 를 확인하실 수 있습니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[4]/p[1]", "macro-info[4]/p[2]" @@ -1008,7 +1158,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Okta Admin > Applications > QueryPie App 상단 URL" + "old_plain_text": "" } }, { @@ -1023,7 +1173,7 @@ "lost_info": {}, "reconstruction": { "kind": "paragraph", - "old_plain_text": "", + "old_plain_text": " ", "anchors": [] } }, @@ -1085,8 +1235,22 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "해당 연동 방식으로는 사용자 및 그룹은 Okta → QueryPie로의 단방향 동기화를 지원합니다. SCIM 프로비저닝 연동까지 구현하고자 하는 경우, [Okta] 프로비저닝 연동 가이드 내 절차대로 대신 진행하여 주시기 바랍니다.", + "children": [ + { + "xpath": "macro-info[5]/p[1]", + "fragment": "해당 연동 방식으로는 사용자 및 그룹은 Okta → QueryPie로의 단방향 동기화를 지원합니다.
", + "plain_text": "해당 연동 방식으로는 사용자 및 그룹은 Okta → QueryPie로의 단방향 동기화를 지원합니다.", + "type": "paragraph" + }, + { + "xpath": "macro-info[5]/p[2]", + "fragment": "SCIM 프로비저닝 연동까지 구현하고자 하는 경우, [Okta] 프로비저닝 연동 가이드 내 절차대로 대신 진행하여 주시기 바랍니다.
", + "plain_text": "SCIM 프로비저닝 연동까지 구현하고자 하는 경우, [Okta] 프로비저닝 연동 가이드 내 절차대로 대신 진행하여 주시기 바랍니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[5]/p[1]", "macro-info[5]/p[2]" @@ -1152,8 +1316,28 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", - "old_plain_text": "One Login SAML Custom Connector 설정 및 Metadata XML 다운로드 OneLogin에 접속한 후 화면 상단의 Applications > Applications 메뉴를 클릭합니다.Add App 버튼을 클릭합니다.검색 영역에 'SAML Custom Connector (Advanced)'을 입력한 후 검색 결과를 클릭합니다.Display Name에 QueryPie에서 확인된 Application Name to be used in OneLogin의 내용을 복사해서 붙여넣고 Audiance (Entity ID), Recipient, ACS (Consumer) URL Validator, ACS (Consumer) URL도 각각 정보를 복사하여 One Login 설정에 붙여 넣습니다.Save 버튼을 눌러 저장합니다. 화면 좌측의 Configuration 메뉴를 선택한 뒤 화면 우측 상단의 More Actions > SAML Metadata를 클릭합니다.다운로드된 XML 파일을 확인합니다. One Login SAML Custom Connector 설정에 대한 자세한 내용은 https://onelogin.service-now.com/support?id=kb_article&sys_id=8a1f3d501b392510c12a41d5ec4bcbcc&kb_category=de885d2187372d10695f0f66cebb351f 의 내용을 참고 바랍니다.", + "kind": "container", + "old_plain_text": "One Login SAML Custom Connector 설정 및 Metadata XML 다운로드 OneLogin에 접속한 후 화면 상단의 Applications > Applications 메뉴를 클릭합니다.Add App 버튼을 클릭합니다.검색 영역에 'SAML Custom Connector (Advanced)'을 입력한 후 검색 결과를 클릭합니다.Display Name에 QueryPie에서 확인된 Application Name to be used in OneLogin의 내용을 복사해서 붙여넣고 Audiance (Entity ID), Recipient, ACS (Consumer) URL Validator, ACS (Consumer) URL도 각각 정보를 복사하여 One Login 설정에 붙여 넣습니다.Save 버튼을 눌러 저장합니다. 화면 좌측의 Configuration 메뉴를 선택한 뒤 화면 우측 상단의 More Actions > SAML Metadata를 클릭합니다.다운로드된 XML 파일을 확인합니다. One Login SAML Custom Connector 설정에 대한 자세한 내용은 https://onelogin.service-now.com/support?id=kb_article&sys_id=8a1f3d501b392510c12a41d5ec4bcbcc&kb_category=de885d2187372d10695f0f66cebb351f 의 내용을 참고 바랍니다.", + "children": [ + { + "xpath": "macro-info[6]/p[1]", + "fragment": "One Login SAML Custom Connector 설정 및 Metadata XML 다운로드
", + "plain_text": "One Login SAML Custom Connector 설정 및 Metadata XML 다운로드", + "type": "paragraph" + }, + { + "xpath": "macro-info[6]/ul[1]", + "fragment": "OneLogin에 접속한 후 화면 상단의 Applications > Applications 메뉴를 클릭합니다.
Add App 버튼을 클릭합니다.
검색 영역에 'SAML Custom Connector (Advanced)'을 입력한 후 검색 결과를 클릭합니다.
Display Name에 QueryPie에서 확인된 Application Name to be used in OneLogin의 내용을 복사해서 붙여넣고 Audiance (Entity ID), Recipient, ACS (Consumer) URL Validator, ACS (Consumer) URL도 각각 정보를 복사하여 One Login 설정에 붙여 넣습니다.
Save 버튼을 눌러 저장합니다.
화면 좌측의 Configuration 메뉴를 선택한 뒤 화면 우측 상단의 More Actions > SAML Metadata를 클릭합니다.
다운로드된 XML 파일을 확인합니다.
One Login SAML Custom Connector 설정에 대한 자세한 내용은 https://onelogin.service-now.com/support?id=kb_article&sys_id=8a1f3d501b392510c12a41d5ec4bcbcc&kb_category=de885d2187372d10695f0f66cebb351f 의 내용을 참고 바랍니다.
", + "plain_text": "One Login SAML Custom Connector 설정에 대한 자세한 내용은 https://onelogin.service-now.com/support?id=kb_article&sys_id=8a1f3d501b392510c12a41d5ec4bcbcc&kb_category=de885d2187372d10695f0f66cebb351f 의 내용을 참고 바랍니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[6]/p[1]", "macro-info[6]/ul[1]", @@ -1190,7 +1374,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "One Login 상세설정 (1)" + "old_plain_text": "" } }, { @@ -1222,7 +1406,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "One Login 상세설정 (2)" + "old_plain_text": "" } }, { @@ -1317,7 +1501,7 @@ "lost_info": {}, "reconstruction": { "kind": "paragraph", - "old_plain_text": "< 참고 > ", + "old_plain_text": "< 참고 > AWS SSO 연동 ", "anchors": [] } }, diff --git a/confluence-mdx/tests/testcases/544112828/expected.roundtrip.json b/confluence-mdx/tests/testcases/544112828/expected.roundtrip.json index 0c3b4a639..09b4a4b52 100644 --- a/confluence-mdx/tests/testcases/544112828/expected.roundtrip.json +++ b/confluence-mdx/tests/testcases/544112828/expected.roundtrip.json @@ -47,7 +47,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "" + "old_plain_text": "21falseOverviewdefaultlisttrue" } }, { @@ -109,7 +109,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "QueryPie Web > 프로필 메뉴" + "old_plain_text": "" } }, { @@ -140,7 +140,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "QueryPie Web > Agent Downloads 팝업창" + "old_plain_text": "" } }, { @@ -154,8 +154,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "QueryPie Agent는 Mac, Windows, Linux OS를 지원합니다.", + "children": [ + { + "xpath": "macro-info[1]/p[1]", + "fragment": "QueryPie Agent는 Mac, Windows, Linux OS를 지원합니다.
", + "plain_text": "QueryPie Agent는 Mac, Windows, Linux OS를 지원합니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[1]/p[1]" ] @@ -189,7 +197,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Mac OS 설치 프로그램" + "old_plain_text": "" } }, { @@ -220,7 +228,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > QueryPie Host 입력" + "old_plain_text": "" } }, { @@ -313,7 +321,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "QueryPie Web > Agent Login Page" + "old_plain_text": "" } }, { @@ -344,7 +352,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "QueryPie Web > Agent Login Success Page" + "old_plain_text": "" } }, { @@ -375,7 +383,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Chrome - Agent App 열기 모달" + "old_plain_text": "" } }, { @@ -421,7 +429,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > DB Connection Information" + "old_plain_text": "" } }, { @@ -452,7 +460,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "3rd Party Client를 이용한 DB 커넥션 접속" + "old_plain_text": "" } }, { @@ -546,7 +554,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > Server > Select a Role" + "old_plain_text": "" } }, { @@ -560,8 +568,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "역할이 두 개 이상이라면, Agent 로그인 후 Server 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다.", + "children": [ + { + "xpath": "macro-info[2]/p[1]", + "fragment": "역할이 두 개 이상이라면, Agent 로그인 후 Server 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다.
", + "plain_text": "역할이 두 개 이상이라면, Agent 로그인 후 Server 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[2]/p[1]" ] @@ -611,7 +627,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > Server > Open Connection with" + "old_plain_text": "" } }, { @@ -643,7 +659,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > Server > Open New Session" + "old_plain_text": "" } }, { @@ -705,7 +721,7 @@ "lost_info": {}, "reconstruction": { "kind": "code", - "old_plain_text": "wide760" + "old_plain_text": "wide760$ cd .ssh" } }, { @@ -736,7 +752,7 @@ "lost_info": {}, "reconstruction": { "kind": "code", - "old_plain_text": "wide760" + "old_plain_text": "wide760$ vi config" } }, { @@ -767,7 +783,7 @@ "lost_info": {}, "reconstruction": { "kind": "code", - "old_plain_text": "wide760" + "old_plain_text": "wide760Host {{Server Name}}\n Hostname {{Server URL}}\n Port {{Server SSH Port}}\n ProxyCommand qpa ssh %r %h %p" } }, { @@ -781,8 +797,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "config 파일 작성 시 Seamless SSH 설정하고자 하는 서버마다 서버 이름, URL, 포트를 입력함으로써 서버를 특정합니다. 서버 간에 URL, 포트가 겹치지 않는 경우 아래와 같이 입력하여도 접속이 가능합니다.", + "children": [ + { + "xpath": "macro-info[3]/p[1]", + "fragment": "config 파일 작성 시 Seamless SSH 설정하고자 하는 서버마다 서버 이름, URL, 포트를 입력함으로써 서버를 특정합니다. 서버 간에 URL, 포트가 겹치지 않는 경우 아래와 같이 입력하여도 접속이 가능합니다.
", + "plain_text": "config 파일 작성 시 Seamless SSH 설정하고자 하는 서버마다 서버 이름, URL, 포트를 입력함으로써 서버를 특정합니다. 서버 간에 URL, 포트가 겹치지 않는 경우 아래와 같이 입력하여도 접속이 가능합니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[3]/p[1]" ] @@ -816,7 +840,7 @@ "lost_info": {}, "reconstruction": { "kind": "code", - "old_plain_text": "wide760" + "old_plain_text": "wide760$ ssh deploy@{{Server Name}}" } }, { @@ -925,7 +949,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > Kubernetes > Select a Role" + "old_plain_text": "" } }, { @@ -939,8 +963,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "역할이 두 개 이상이라면, Agent 로그인 후 Kubernetes 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다.", + "children": [ + { + "xpath": "macro-info[4]/p[1]", + "fragment": "역할이 두 개 이상이라면, Agent 로그인 후 Kubernetes 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다.
", + "plain_text": "역할이 두 개 이상이라면, Agent 로그인 후 Kubernetes 기능 사용을 위해 역할 선택을 먼저 완료해야 합니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[4]/p[1]" ] @@ -1005,7 +1037,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > Policy Information" + "old_plain_text": "" } }, { @@ -1051,7 +1083,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Agent > Settings > Configure Kubeconfig Path" + "old_plain_text": "" } }, { @@ -1099,7 +1131,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "경로 지정 팝업" + "old_plain_text": "" } }, { @@ -1147,7 +1179,7 @@ "lost_info": {}, "reconstruction": { "kind": "code", - "old_plain_text": "wide760" + "old_plain_text": "wide760export KUBECONFIG=\"${HOME}/.kube/config:${HOME}/.kube/querypie-kubeconfig\"" } }, { @@ -1178,7 +1210,7 @@ "lost_info": {}, "reconstruction": { "kind": "code", - "old_plain_text": "wide760" + "old_plain_text": "wide760export KUBECONFIG=\"${KUBECONFIG}:Audit Log Export 기능 추가에 따라, 각 로그 화면에서 제공되던 ‘Excel File Download’ 버튼은 QueryPie v9.15.0부터 지원이 중단되었습니다.
", + "plain_text": "Audit Log Export 기능 추가에 따라, 각 로그 화면에서 제공되던 ‘Excel File Download’ 버튼은 QueryPie v9.15.0부터 지원이 중단되었습니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-note[1]/p[1]" ] @@ -111,7 +119,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Administrator > Audit > General > Audit Log Export" + "old_plain_text": "" } }, { @@ -190,7 +198,7 @@ "lost_info": {}, "reconstruction": { "kind": "html_block", - "old_plain_text": "Administrator > Audit > General > Audit Log Export > Create New Task" + "old_plain_text": "" } }, { @@ -221,8 +229,22 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "유의 사항 당일 날짜를 선택하여 로그를 추출하는 경우, 실시간으로 데이터가 쌓이기 때문에 Preview 시점의 결과와 실제 Create를 통해 생성된 파일의 내용이 다를 수 있습니다.", + "children": [ + { + "xpath": "macro-info[1]/p[1]", + "fragment": "유의 사항
", + "plain_text": "유의 사항", + "type": "paragraph" + }, + { + "xpath": "macro-info[1]/p[2]", + "fragment": "당일 날짜를 선택하여 로그를 추출하는 경우, 실시간으로 데이터가 쌓이기 때문에 Preview 시점의 결과와 실제 Create를 통해 생성된 파일의 내용이 다를 수 있습니다.
", + "plain_text": "당일 날짜를 선택하여 로그를 추출하는 경우, 실시간으로 데이터가 쌓이기 때문에 Preview 시점의 결과와 실제 Create를 통해 생성된 파일의 내용이 다를 수 있습니다.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[1]/p[1]", "macro-info[1]/p[2]" @@ -240,8 +262,142 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", - "old_plain_text": "필터 표현식 (1) 필터 표현식을 사용하시기 위해 'See Log Template and Description'을 통해 로그별 key와 각각의 type 및 포함하는 value를 참고해주시기 바랍니다. (2) 사용 가능한 필터 표현식은 데이터의 종류에 따라 아래와 같이 나뉩니다. - Number Type 지원하는 표현식 : >, <, <=, >=, ==, !=예 : x > `10`, x == `10` - String Type 지원하는 표현식 : == (equals), != (not equals), contains예 : x == 'abc', x != 'abc', contains(x, 'ab') - Boolean Type 지원하는 표현식 : == (equals), != (not equals), && (and), || (or)예 : x == `true`, x && y, (x > `0`) && (y == `0`) - Array Type 예 : x[? @ == 'value'], list[? @ > `10`] (3) 여러 조건을 함께 사용하기 위해 아래의 문자를 활용할 수 있습니다. - AND 조건 : && 연산자를 활용합니다. - OR 조건 : || 연산자를 활용합니다. - 복합 조건 : 함께 처리해야하는 조건은 괄호(( ))로 묶어서 활용합니다. (4) 예시 - Query Audit 중 쿼리 실행 로그만 추출하려는 경우 필요한 표현식 : actionType == 'SQL_EXECUTION' - Query Audit 중 웹에디터에 수행한 쿼리 실행 로그만 추출하려는 경우 필요한 표현식 : actionType == 'SQL_EXECUTION' && executedFrom == 'WEB_EDITOR' - DB Access History 중 특정 데이터베이스 2종류 대해서만 추출하려는 경우 필요한 표현식 : connectionName == 'database1' || connectionName == 'database2' - DB Access History 중 특정 데이터베이스 2종류이면서 Replication Type이 SINGLE인 경우에 대해서만 추출하려는 경우 필요한 표현식 : (connectionName == 'database1' || connectionName == 'database2') && replicationType == 'SINGLE'", + "kind": "container", + "old_plain_text": "필터 표현식 (1) 필터 표현식을 사용하시기 위해 'See Log Template and Description'을 통해 로그별 key와 각각의 type 및 포함하는 value를 참고해주시기 바랍니다. (2) 사용 가능한 필터 표현식은 데이터의 종류에 따라 아래와 같이 나뉩니다. - Number Type 지원하는 표현식 : >, <, <=, >=, ==, !=예 : x > `10`, x == `10` - String Type 지원하는 표현식 : == (equals), != (not equals), contains예 : x == 'abc', x != 'abc', contains(x, 'ab') - Boolean Type 지원하는 표현식 : == (equals), != (not equals), && (and), || (or)예 : x == `true`, x && y, (x > `0`) && (y == `0`) - Array Type 예 : x[? @ == 'value'], list[? @ > `10`] (3) 여러 조건을 함께 사용하기 위해 아래의 문자를 활용할 수 있습니다. - AND 조건 : && 연산자를 활용합니다. - OR 조건 : || 연산자를 활용합니다. - 복합 조건 : 함께 처리해야하는 조건은 괄호(( ))로 묶어서 활용합니다. (4) 예시 - Query Audit 중 쿼리 실행 로그만 추출하려는 경우 필요한 표현식 : actionType == 'SQL_EXECUTION' - Query Audit 중 웹에디터에 수행한 쿼리 실행 로그만 추출하려는 경우 필요한 표현식 : actionType == 'SQL_EXECUTION' && executedFrom == 'WEB_EDITOR' - DB Access History 중 특정 데이터베이스 2종류 대해서만 추출하려는 경우 필요한 표현식 : connectionName == 'database1' || connectionName == 'database2' - DB Access History 중 특정 데이터베이스 2종류이면서 Replication Type이 SINGLE인 경우에 대해서만 추출하려는 경우 필요한 표현식 : (connectionName == 'database1' || connectionName == 'database2') && replicationType == 'SINGLE'", + "children": [ + { + "xpath": "ac:adf-extension[1]/p[1]", + "fragment": "필터 표현식
", + "plain_text": "필터 표현식", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[2]", + "fragment": "(1) 필터 표현식을 사용하시기 위해 'See Log Template and Description'을 통해 로그별 key와 각각의 type 및 포함하는 value를 참고해주시기 바랍니다.
(2) 사용 가능한 필터 표현식은 데이터의 종류에 따라 아래와 같이 나뉩니다.
", + "plain_text": "(2) 사용 가능한 필터 표현식은 데이터의 종류에 따라 아래와 같이 나뉩니다.", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[4]", + "fragment": "- Number Type
", + "plain_text": "- Number Type", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[5]", + "fragment": "지원하는 표현식 : >, <, <=, >=, ==, !=
예 : x > `10`, x == `10`
- String Type
", + "plain_text": "- String Type", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[7]", + "fragment": "지원하는 표현식 : == (equals), != (not equals), contains
예 : x == 'abc', x != 'abc',
contains(x, 'ab')
- Boolean Type
", + "plain_text": "- Boolean Type", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[9]", + "fragment": "지원하는 표현식 : == (equals), != (not equals), && (and), || (or)
예 : x == `true`, x && y,
(x > `0`) && (y == `0`)
- Array Type
", + "plain_text": "- Array Type", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[11]", + "fragment": "예 : x[? @ == 'value'], list[? @ > `10`]
(3) 여러 조건을 함께 사용하기 위해 아래의 문자를 활용할 수 있습니다.
", + "plain_text": "(3) 여러 조건을 함께 사용하기 위해 아래의 문자를 활용할 수 있습니다.", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[14]", + "fragment": "- AND 조건 : && 연산자를 활용합니다.
- OR 조건 : || 연산자를 활용합니다.
- 복합 조건 : 함께 처리해야하는 조건은 괄호(( ))로 묶어서 활용합니다.
(4) 예시
", + "plain_text": "(4) 예시", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[1]/p[19]", + "fragment": "- Query Audit 중 쿼리 실행 로그만 추출하려는 경우 필요한 표현식 : actionType == 'SQL_EXECUTION'
- Query Audit 중 웹에디터에 수행한 쿼리 실행 로그만 추출하려는 경우 필요한 표현식 : actionType == 'SQL_EXECUTION' && executedFrom == 'WEB_EDITOR'
- DB Access History 중 특정 데이터베이스 2종류 대해서만 추출하려는 경우 필요한 표현식 : connectionName == 'database1' || connectionName == 'database2'
- DB Access History 중 특정 데이터베이스 2종류이면서 Replication Type이 SINGLE인 경우에 대해서만 추출하려는 경우 필요한 표현식 : (connectionName == 'database1' || connectionName == 'database2') && replicationType == 'SINGLE'
Query Audit의 내보내기 파일의 Privilege Type 명시 기준
", + "plain_text": "Query Audit의 내보내기 파일의 Privilege Type 명시 기준", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[2]/p[2]", + "fragment": "내보내기 파일의 ‘Privilege Type’ 컬럼에는 실행시점에는 어느 권한이 필요했는지가 기록됩니다. 아래와 같이 작동합니다.
", + "plain_text": "내보내기 파일의 ‘Privilege Type’ 컬럼에는 실행시점에는 어느 권한이 필요했는지가 기록됩니다. 아래와 같이 작동합니다.", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[2]/ol[1]", + "fragment": "기본 권한으로 실행되는 명령어(SET, SHOW 등)는 해당 컬럼의 값이 공백입니다.
INSERT 등 권한에 따라 수행된 로그에는 SQL Type이 명시됩니다.
Redis의 경우에는 커맨드명이 명시됩니다.
다운로드 파일에 암호 포함하기
", + "plain_text": "다운로드 파일에 암호 포함하기", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[3]/p[2]", + "fragment": "다운로드 대상 파일은 ‘*.csv 또는 *.json 파일을 압축한 *.zip 파일’입니다.
", + "plain_text": "다운로드 대상 파일은 ‘*.csv 또는 *.json 파일을 압축한 *.zip 파일’입니다.", + "type": "paragraph" + }, + { + "xpath": "ac:adf-extension[3]/p[3]", + "fragment": "해당 압축 파일에 대한 암호를 지정하기 위해서는 ‘General Setting > Security’ 메뉴에서 Export a file with Encryption 옵션을 ‘Required’로 지정해야 합니다.
This is an info panel.
", + "plain_text": "This is an info panel.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-info[1]/p[1]" ] @@ -48,8 +56,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "This is a note panel.", + "children": [ + { + "xpath": "ac:adf-extension[1]/p[1]", + "fragment": "This is a note panel.
", + "plain_text": "This is a note panel.", + "type": "paragraph" + } + ], "child_xpaths": [ "ac:adf-extension[1]/p[1]" ] @@ -81,8 +97,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "This is an error panel.", + "children": [ + { + "xpath": "macro-warning[1]/p[1]", + "fragment": "This is an error panel.
", + "plain_text": "This is an error panel.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-warning[1]/p[1]" ] @@ -114,8 +138,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "This is a success panel.", + "children": [ + { + "xpath": "macro-tip[1]/p[1]", + "fragment": "This is a success panel.
", + "plain_text": "This is a success panel.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-tip[1]/p[1]" ] @@ -147,8 +179,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "This is a warning panel.", + "children": [ + { + "xpath": "macro-note[1]/p[1]", + "fragment": "This is a warning panel.
", + "plain_text": "This is a warning panel.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-note[1]/p[1]" ] @@ -180,8 +220,16 @@ ], "lost_info": {}, "reconstruction": { - "kind": "html_block", + "kind": "container", "old_plain_text": "This is a custom panel.", + "children": [ + { + "xpath": "macro-panel[1]/p[1]", + "fragment": "This is a custom panel.
", + "plain_text": "This is a custom panel.", + "type": "paragraph" + } + ], "child_xpaths": [ "macro-panel[1]/p[1]" ]