Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions confluence-mdx/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ bin/convert_all.py --verify-translations
- `target/public/` 디렉토리에 첨부파일이 저장됩니다.
- 한국어 제목의 번역이 누락된 경우, 오류와 함께 누락 목록을 출력합니다.
- `etc/korean-titles-translations.txt`에 번역을 추가한 후 재실행합니다.
- 표시용 영어 제목과 다른 기존 public route segment를 유지해야 하는 경우 `etc/content-slug-overrides.yaml`에 Confluence content ID와 canonical slug를 추가합니다.

## Confluence xhtml 을 Markdown 으로 변환하기

Expand Down
10 changes: 10 additions & 0 deletions confluence-mdx/bin/convert_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,16 @@ def _supported_children(
or (catalog_node or {}).get("type")
or "page"
)
status = str(child.get("status") or "current")
if status != "current":
print(
"WARNING: skipping non-current Confluence child "
f"parent_id={parent_id} id={child_id} "
f"type={child_type} status={status} "
f"title={child.get('title', '')!r}",
file=sys.stderr,
)
continue
if child_type not in _SUPPORTED_CONTENT_TYPES:
print(
"WARNING: skipping unsupported Confluence child "
Expand Down
8 changes: 7 additions & 1 deletion confluence-mdx/bin/fetch/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ class Config:
default_output_dir: str = "var"
cache_dir: str = "cache"
translations_file: str = "etc/korean-titles-translations.txt"
slug_overrides_file: str = "etc/content-slug-overrides.yaml"
email: Optional[str] = None
api_token: Optional[str] = None
download_attachments: bool = False
Expand All @@ -46,7 +47,12 @@ def __post_init__(self):
self.api_token = os.environ.get('ATLASSIAN_TOKEN', 'your-api-token')

# Resolve relative paths against project root (confluence-mdx/)
for field in ('default_output_dir', 'cache_dir', 'translations_file'):
for field in (
'default_output_dir',
'cache_dir',
'translations_file',
'slug_overrides_file',
):
value = getattr(self, field)
if not os.path.isabs(value):
setattr(self, field, str(_PROJECT_DIR / value))
36 changes: 21 additions & 15 deletions confluence-mdx/bin/fetch/processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
from fetch.translation import TranslationService
from fetch.stages import Stage1Processor, Stage2Processor, Stage3Processor, Stage4Processor
from fetch.models import ContentNode, ContentRef
from text_utils import slugify


class ConfluencePageProcessor:
Expand All @@ -26,7 +25,11 @@ def __init__(self, config: Config, logger: logging.Logger):
# Initialize services with dependency injection
self.api_client = ApiClient(config, logger)
self.file_manager = FileManager(logger)
self.translation_service = TranslationService(config.translations_file, logger)
self.translation_service = TranslationService(
config.translations_file,
config.slug_overrides_file,
logger,
)

# Initialize stage processors
self.stage1 = Stage1Processor(config, self.api_client, self.file_manager, logger)
Expand All @@ -36,6 +39,7 @@ def __init__(self, config: Config, logger: logging.Logger):

# Load translations
self.translation_service.load_translations()
self.translation_service.load_slug_overrides()

def process_page_complete(
self,
Expand Down Expand Up @@ -100,6 +104,14 @@ def get_child_content_refs(self, page_id: str) -> List[ContentRef]:
child_type = str(child.get("type") or "page")
child_id = str(child["id"])
title = str(child.get("title") or "")
status = str(child.get("status") or "current")
if status != "current":
self.logger.warning(
"Skipping non-current Confluence child "
f"parent_id={page_id} id={child_id} "
f"type={child_type} status={status} title={title!r}"
)
continue
if child_type not in ("page", "folder"):
self.logger.warning(
"Skipping unsupported Confluence child "
Expand Down Expand Up @@ -143,6 +155,7 @@ def fetch_page_tree_recursive(
use_local: bool = False,
content_type: Optional[str] = None,
parent_breadcrumbs: Optional[List[str]] = None,
parent_path: Optional[List[str]] = None,
visited: Optional[Set[str]] = None,
) -> Generator[ContentNode, None, None]:
"""Recursively fetch a typed content tree."""
Expand Down Expand Up @@ -186,26 +199,24 @@ def fetch_page_tree_recursive(
)

if page:
# Update translations if available
if self.translation_service.translations:
self.translation_service.translate_page(page)
else:
# If no translations available, use original breadcrumbs for English and path
page.breadcrumbs_en = page.breadcrumbs
page.path = [slugify(crumb) for crumb in page.breadcrumbs]
self.translation_service.translate_page(page, parent_path)

yield page

child_parent_breadcrumbs = (
[] if page_id == start_page_id else list(page.breadcrumbs)
)
child_parent_path = (
[] if page_id == start_page_id else list(page.path)
)
for child in self.get_child_content_refs(page_id):
yield from self.fetch_page_tree_recursive(
child.id,
start_page_id,
use_local,
content_type=child.type,
parent_breadcrumbs=child_parent_breadcrumbs,
parent_path=child_parent_path,
visited=visited,
)
except Exception as e:
Expand Down Expand Up @@ -345,12 +356,7 @@ def run(self) -> None:
include_children=False,
)
if page:
# Update translations if available
if self.translation_service.translations:
self.translation_service.translate_page(page)
else:
page.breadcrumbs_en = page.breadcrumbs
page.path = [slugify(crumb) for crumb in page.breadcrumbs]
self.translation_service.translate_page(page)

# Output to stdout during download
breadcrumbs_str = " />> ".join(page.breadcrumbs) if page.breadcrumbs else ""
Expand Down
96 changes: 89 additions & 7 deletions confluence-mdx/bin/fetch/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import logging
import os
from typing import Protocol
from typing import List, Optional, Protocol

import yaml

from fetch.exceptions import TranslationError
from fetch.models import Page
Expand All @@ -15,20 +17,34 @@ class TranslationServiceProtocol(Protocol):
def load_translations(self) -> None:
...

def load_slug_overrides(self) -> None:
...

def translate(self, content: str) -> str:
...

def translate_page(self, page: 'Page') -> None:
def translate_page(
self,
page: 'Page',
parent_path: Optional[List[str]] = None,
) -> None:
...


class TranslationService:
"""Handles Korean to English title translations"""

def __init__(self, translations_file: str, logger: logging.Logger):
def __init__(
self,
translations_file: str,
slug_overrides_file: str,
logger: logging.Logger,
):
self.translations_file = translations_file
self.slug_overrides_file = slug_overrides_file
self.logger = logger
self.translations = {}
self.slug_overrides = {}

def load_translations(self) -> None:
"""Load translations from the translations file"""
Expand All @@ -55,6 +71,52 @@ def load_translations(self) -> None:
self.logger.error(f"Error loading translations from {self.translations_file}: {str(e)}")
raise TranslationError(f"Failed to load translations: {str(e)}")

def load_slug_overrides(self) -> None:
"""Load content ID to canonical slug overrides."""
if not os.path.exists(self.slug_overrides_file):
self.logger.warning(
f"Slug overrides file not found: {self.slug_overrides_file}"
)
return

try:
with open(self.slug_overrides_file, 'r', encoding='utf-8') as f:
data = yaml.safe_load(f)
if data is None:
return
if not isinstance(data, dict):
raise TranslationError(
"Slug overrides must be a content ID to slug mapping"
)

for content_id_value, slug_value in data.items():
content_id = str(content_id_value).strip()
if not content_id or not isinstance(slug_value, str):
raise TranslationError(
f"Invalid slug override: {content_id_value!r}: {slug_value!r}"
)
slug = slug_value.strip()
if not slug or slugify(slug) != slug:
raise TranslationError(
f"Slug override must be a canonical slug: {slug_value!r}"
)
self.slug_overrides[content_id] = slug

self.logger.info(
f"Loaded {len(self.slug_overrides)} slug overrides "
f"from {self.slug_overrides_file}"
)
except TranslationError:
raise
except Exception as e:
self.logger.error(
f"Error loading slug overrides from "
f"{self.slug_overrides_file}: {str(e)}"
)
raise TranslationError(
f"Failed to load slug overrides: {str(e)}"
) from e

def translate(self, content: str) -> str:
"""Translate Korean titles in content to English"""
if not self.translations:
Expand All @@ -72,8 +134,12 @@ def translate(self, content: str) -> str:

return translated_content

def translate_page(self, page: Page) -> None:
"""Update English translations and path using the translator"""
def translate_page(
self,
page: Page,
parent_path: Optional[List[str]] = None,
) -> None:
"""Update display translations and build the canonical path."""
# Translate breadcrumbs to English
page.breadcrumbs_en = []
for crumb in page.breadcrumbs:
Expand All @@ -84,5 +150,21 @@ def translate_page(self, page: Page) -> None:
break
page.breadcrumbs_en.append(translated)

# Create path by slugifying English breadcrumbs
page.path = [slugify(crumb) for crumb in page.breadcrumbs_en]
if parent_path is None:
page.path = [slugify(crumb) for crumb in page.breadcrumbs_en]
elif page.breadcrumbs_en:
page.path = [
*parent_path,
slugify(page.breadcrumbs_en[-1]),
]
else:
page.path = list(parent_path)

slug_override = self.slug_overrides.get(str(page.page_id))
if slug_override:
if not page.path:
raise TranslationError(
f"Cannot apply slug override to content without a path: "
f"{page.page_id}"
)
page.path[-1] = slug_override
9 changes: 9 additions & 0 deletions confluence-mdx/bin/skeleton/ignore_rules.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,3 +77,12 @@ ignores:
# Below are the rules that are not reviewed yet.
#############################################################################

# 사용자가 복사하는 checklist template을 target locale로 번역한 경우
# Lines 326-345: 한국어 template을 영어로 번역
- file: target/en/support/querypie-acp-operational-log-collection-guide.mdx
line_numbers: [326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345]

# 사용자가 복사하는 checklist template을 target locale로 번역한 경우
# Lines 326-345: 한국어 template을 일본어로 번역
- file: target/ja/support/querypie-acp-operational-log-collection-guide.mdx
line_numbers: [326, 327, 328, 329, 330, 331, 332, 333, 334, 335, 336, 337, 338, 339, 340, 341, 342, 343, 344, 345]
2 changes: 2 additions & 0 deletions confluence-mdx/etc/content-slug-overrides.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# Confluence content ID to stable public route segment.
'2262630428': web-client
7 changes: 7 additions & 0 deletions confluence-mdx/etc/korean-titles-translations.txt
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,9 @@ Multi Agent OS별 3rd Party Tool 지원 목록 | Multi Agent 3rd Party Tool Supp
Multi Agent - qpctl CLI 사용 가이드 | Multi Agent - qpctl CLI Usage Guide
Multi Agent 제약사항 | Multi Agent Limitations

# MCP Access Control
MAC을 통해 Remote MCP Servers 사용하기 | Using Remote MCP Servers through MAC

# User Management
Email을 통한 사용자 비밀번호 초기화 | User Password Reset via Email
사용자 프로필 | User Profile
Expand Down Expand Up @@ -87,6 +90,9 @@ LLM Provider 설정 | LLM Provider Configuration
# Databases > DAC General Configurations
Masking Pattern (메뉴 위치 이동) | Masking Pattern (Menu Relocated)

# Databases > DB Access Control
MongoDB / Document DB 의 Privilege Type Mapping | MongoDB / Document DB Privilege Type Mapping

# Databases > Connection Management > Cloud Providers
AWS에서 DB 리소스 동기화 | Synchronizing DB Resources from AWS
MS Azure에서 DB 리소스 동기화 | Synchronizing DB Resources from MS Azure
Expand Down Expand Up @@ -180,3 +186,4 @@ MCP 설정 가이드 | MCP Configuration Guide
기술지원 | Technical Support
프리미엄 지원 | Premium Support
Standard Edition 라이선스 정책 | Standard Edition License Policy
QueryPie ACP 운영 로그 수집 가이드 | QueryPie ACP Operational Log Collection Guide
8 changes: 8 additions & 0 deletions confluence-mdx/tests/test_convert_all_folders.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,13 @@ def test_navigation_is_generated_after_page_and_folder_mdx_exist(tmp_path):
"results": [
{"id": "page", "type": "page", "childPosition": 2},
{"id": "folder", "type": "folder", "childPosition": 1},
{
"id": "draft-page",
"type": "page",
"status": "draft",
"title": "Draft Page",
"childPosition": 3,
},
],
})
_write_yaml(var_dir / "folder" / "children.v2.yaml", {"results": []})
Expand All @@ -191,6 +198,7 @@ def test_navigation_is_generated_after_page_and_folder_mdx_exist(tmp_path):
meta_path = output_dir / "parent" / "_meta.ts"
content = meta_path.read_text()
assert content.index("'folder': 'Folder'") < content.index("'page': 'Page'")
assert "Draft Page" not in content
assert entries == [{
"page_id": "parent",
"type": "page",
Expand Down
Loading
Loading