From 61c6ef171081fbdc4d41b00cae2c94270e5f036a Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 27 Jul 2026 13:27:29 +0900 Subject: [PATCH 1/4] =?UTF-8?q?confluence-mdx:=20folder=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EB=B0=8F=20MDX=20=EB=B3=80=ED=99=98=EC=9D=84=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=ED=95=A9=EB=8B=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Confluence page 계층의 folder를 저장하고 MDX landing page로 변환할 수 있도록 지원합니다. - page와 folder를 구분하는 typed content tree와 cursor pagination을 구현합니다. - folder 직계 자식 목록, navigation, generated output manifest를 생성합니다. - unsupported content type과 folder reverse sync를 안전하게 차단합니다. - OpenSpec 계약과 fetch 및 conversion 회귀 테스트를 추가합니다. 🤖 Generated with Codex Co-Authored-By: Atlas --- confluence-mdx/README.md | 45 +- confluence-mdx/bin/convert_all.py | 522 ++++++++++++++++-- confluence-mdx/bin/converter/cli.py | 88 +-- confluence-mdx/bin/fetch/api_client.py | 81 ++- confluence-mdx/bin/fetch/models.py | 30 +- confluence-mdx/bin/fetch/processor.py | 182 ++++-- confluence-mdx/bin/fetch/stages.py | 144 +++-- confluence-mdx/bin/fetch_cli.py | 12 +- confluence-mdx/bin/reverse_sync_cli.py | 26 + .../tests/test_convert_all_folders.py | 454 +++++++++++++++ confluence-mdx/tests/test_fetch_folders.py | 415 ++++++++++++++ .../changes/confluence-folder-mdx/design.md | 233 ++++++++ .../changes/confluence-folder-mdx/proposal.md | 38 ++ .../spec.md | 199 +++++++ .../changes/confluence-folder-mdx/tasks.md | 66 +++ 15 files changed, 2280 insertions(+), 255 deletions(-) create mode 100644 confluence-mdx/tests/test_convert_all_folders.py create mode 100644 confluence-mdx/tests/test_fetch_folders.py create mode 100644 openspec/changes/confluence-folder-mdx/design.md create mode 100644 openspec/changes/confluence-folder-mdx/proposal.md create mode 100644 openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md create mode 100644 openspec/changes/confluence-folder-mdx/tasks.md diff --git a/confluence-mdx/README.md b/confluence-mdx/README.md index 9d1c7c221..90cd8b453 100644 --- a/confluence-mdx/README.md +++ b/confluence-mdx/README.md @@ -79,11 +79,13 @@ pip3 install requests beautifulsoup4 pyyaml ## 데이터 수집, 변환 절차의 개요 1. `confluence-mdx/var/`에 Confluence 문서 데이터를 저장합니다. - - 개별 문서마다 `/page.xhtml`, `/page.v1.yaml` 등을 저장합니다. + - page는 `/page.xhtml`, `/page.v1.yaml` 등을 저장합니다. + - folder는 `/folder.v2.yaml`, `/children.v2.yaml`을 저장합니다. - 전체 문서 목록을 `var/pages..yaml`에 저장합니다 (예: `var/pages.qm.yaml`). - `fetch_cli.py`를 사용합니다. 2. `src/content/ko/` 아래에 MDX 문서를 생성합니다. - - `var/pages..yaml`을 기반으로 모든 페이지를 변환합니다. + - `var/pages..yaml`을 기반으로 page와 folder를 변환합니다. + - folder MDX에는 Confluence 순서의 직계 자식 page/folder 목록을 표시합니다. - `convert_all.py`를 사용합니다. 무작정 따라해 보기 @@ -107,18 +109,27 @@ $ bin/convert_all.py # 전체 변환 ### 1. Confluence 문서 데이터 수집 (fetch_cli.py) -`fetch_cli.py`는 Confluence REST API를 이용하여 지정한 문서와 그 하위 페이지들을 수집하여 저장하는 스크립트입니다. +`fetch_cli.py`는 Confluence REST API를 이용하여 지정한 문서와 그 하위 page/folder를 수집하여 저장하는 스크립트입니다. 이 스크립트는 다음과 같은 기능을 수행합니다: -- 각 페이지의 ID, 탐색 경로(breadcrumbs), 제목을 탭으로 구분된 형식으로 출력합니다. -- 각 페이지 ID에 대한 디렉토리를 생성하고 다음 파일을 저장합니다: - - XHTML 형식의 문서 내용 (`page.xhtml`) - - 페이지 메타데이터 (`page.yaml`) - - 첨부 파일(있는 경우) +- 각 content의 ID, 탐색 경로(breadcrumbs), 제목을 탭으로 구분된 형식으로 출력합니다. +- page는 XHTML, V1/V2 metadata, 직계 자식 snapshot, attachment metadata를 저장합니다. +- folder는 `folder.v2.yaml`과 pagination을 합친 `children.v2.yaml`만 저장합니다. +- `pages..yaml`에는 `type: page|folder`를 기록합니다. + +Hierarchy freshness는 실행 mode에 따라 다릅니다. + +| Mode | Page 내용 | Page/folder hierarchy | +| --- | --- | --- | +| `--remote` | 갱신합니다. | `direct-children` API로 전체 갱신합니다. | +| `--recent` | CQL로 발견한 기존 page만 갱신합니다. | 저장된 `children.v2.yaml`을 유지합니다. | +| `--local` | 저장된 data를 사용합니다. | 저장된 `children.v2.yaml`을 유지합니다. | + +Folder 생성·이동·이름 변경·삭제를 반영하려면 `--remote`를 실행해야 합니다. `--recent`는 hierarchy를 부분 갱신하지 않습니다. 실행 방법: ```bash -# 기본 설정으로 실행 - Confluence API 를 호출하고, 그 결과를 var/ 아래에 저장합니다. +# 기본 설정은 --recent이며, 최근 변경된 기존 page 내용만 갱신합니다. bin/fetch_cli.py # API 호출과 함께, 첨부파일을 다운로드하여 저장합니다. @@ -154,13 +165,21 @@ bin/fetch_cli.py --log-level DEBUG 실행 결과: - `var/` 디렉토리에 문서 데이터가 저장됩니다. -- 각 페이지 ID에 해당하는 디렉토리에 `page.yaml`과 `page.xhtml` 파일이 저장됩니다. +- page ID 디렉토리에는 `page.v1.yaml`, `page.v2.yaml`, `children.v2.yaml`, `page.xhtml` 등이 저장됩니다. +- folder ID 디렉토리에는 `folder.v2.yaml`과 `children.v2.yaml`이 저장됩니다. ### 2. 전체 변환 (convert_all.py) -`convert_all.py`는 `var/pages..yaml`을 기반으로 모든 페이지를 MDX로 변환하는 스크립트입니다. +`convert_all.py`는 `var/pages..yaml`을 기반으로 모든 page와 folder를 MDX로 변환하는 스크립트입니다. 변환 전에 번역 누락을 자동 검증합니다. +- page는 기존 XHTML converter로 변환합니다. +- folder는 `title`, `confluenceUrl`, `## 하위 문서`와 직계 자식 link 목록을 가진 landing page로 완전히 재생성합니다. +- 지원되는 직계 자식이 없는 folder에는 `하위 문서가 없습니다.`를 표시합니다. +- navigation `_meta.ts`는 전체 catalog 변환이 끝난 뒤 생성합니다. +- `var/convert-manifest..yaml`에 생성한 MDX와 `_meta.ts`를 기록합니다. +- 변환 전체가 성공한 경우에만 이전 manifest가 소유한 stale output을 삭제합니다. + 실행 방법: ```bash # 전체 변환 (번역 검증 포함, 기본: --sync-code qm) @@ -174,7 +193,7 @@ bin/convert_all.py --verify-translations ``` 실행 결과: -- `target/ko/` 디렉토리에 MDX 파일들이 생성됩니다. +- `target/ko/` 디렉토리에 page/folder MDX와 `_meta.ts`가 생성됩니다. - `target/public/` 디렉토리에 첨부파일이 저장됩니다. - 한국어 제목의 번역이 누락된 경우, 오류와 함께 누락 목록을 출력합니다. - `etc/korean-titles-translations.txt`에 번역을 추가한 후 재실행합니다. @@ -250,4 +269,4 @@ make help ```bash deactivate -``` \ No newline at end of file +``` diff --git a/confluence-mdx/bin/convert_all.py b/confluence-mdx/bin/convert_all.py index 753c436a2..e5d1cedd8 100755 --- a/confluence-mdx/bin/convert_all.py +++ b/confluence-mdx/bin/convert_all.py @@ -1,6 +1,6 @@ #!/usr/bin/env python3 """ -Batch converter: pages.yaml 기반으로 모든 Confluence 페이지를 MDX로 변환합니다. +Batch converter: pages.yaml 기반으로 모든 Confluence page/folder를 MDX로 변환합니다. translate_titles.py, generate_commands_for_xhtml2markdown.py, xhtml2markdown.ko.sh를 하나의 명령으로 대체합니다. @@ -12,24 +12,29 @@ """ import argparse -import logging import os import re import subprocess import sys +import tempfile from pathlib import Path -from typing import Dict, List +from typing import Any, Dict, List, Mapping, Sequence +from urllib.parse import quote, urlsplit import yaml # Resolve project root (confluence-mdx/) from this script's location _SCRIPT_DIR = Path(__file__).resolve().parent # confluence-mdx/bin/ _PROJECT_DIR = _SCRIPT_DIR.parent # confluence-mdx/ +_SUPPORTED_CONTENT_TYPES = frozenset({"page", "folder"}) +_DEFAULT_CONFLUENCE_BASE_URL = "https://querypie.atlassian.net/wiki" # Ensure bin/ is on sys.path if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) +from fetch.sync_profiles import SYNC_PROFILES + def _resolve(rel: str) -> str: """Resolve a relative path against _PROJECT_DIR (confluence-mdx/).""" @@ -40,7 +45,7 @@ def _resolve(rel: str) -> str: def load_pages_yaml(pages_yaml_path: str) -> List[Dict]: - """Load pages.yaml and return list of page entries.""" + """Load pages.yaml and return typed content entries.""" with open(pages_yaml_path, 'r', encoding='utf-8') as f: pages = yaml.safe_load(f) if not isinstance(pages, list): @@ -77,64 +82,478 @@ def verify_translations(pages: List[Dict], translations: Dict[str, str]) -> List return missing +class ConversionError(RuntimeError): + """Raised when a catalog or generated output contract is invalid.""" + + +def _output_relative_path(node: Mapping[str, Any]) -> Path: + path_parts = node.get("path", []) + if not isinstance(path_parts, list) or not path_parts: + raise ConversionError(f"Content {node.get('page_id')} has no valid path") + + normalized_parts = [str(part) for part in path_parts] + if any( + not part or part in (".", "..") or Path(part).is_absolute() + for part in normalized_parts + ): + raise ConversionError( + f"Content {node.get('page_id')} has an unsafe path: {path_parts!r}" + ) + return Path(*normalized_parts[:-1], f"{normalized_parts[-1]}.mdx") + + +def _load_yaml_mapping(path: Path, description: str) -> Dict[str, Any]: + if not path.exists(): + raise ConversionError(f"Missing {description}: {path}") + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except yaml.YAMLError as exc: + raise ConversionError(f"Invalid YAML in {description} {path}: {exc}") from exc + if not isinstance(data, dict): + raise ConversionError(f"{description} must be a mapping: {path}") + return data + + +def _child_position(child: Mapping[str, Any]) -> int: + try: + return int(child.get("childPosition", 0)) + except (TypeError, ValueError): + return 0 + + +def _supported_children( + parent: Mapping[str, Any], + var_dir: Path, + nodes_by_id: Mapping[str, Mapping[str, Any]], +) -> List[Mapping[str, Any]]: + parent_id = str(parent["page_id"]) + data = _load_yaml_mapping( + var_dir / parent_id / "children.v2.yaml", + f"direct children snapshot for {parent_id}", + ) + results = data.get("results", []) + if not isinstance(results, list): + raise ConversionError( + f"children.v2.yaml results must be a list for parent {parent_id}" + ) + + supported: List[Mapping[str, Any]] = [] + for child in sorted( + (item for item in results if isinstance(item, dict)), + key=_child_position, + ): + child_id_value = child.get("id") + if child_id_value is None: + print( + f"WARNING: skipping malformed child without id parent_id={parent_id}: {child!r}", + file=sys.stderr, + ) + continue + child_id = str(child_id_value) + catalog_node = nodes_by_id.get(child_id) + child_type = str( + child.get("type") + or (catalog_node or {}).get("type") + or "page" + ) + if child_type not in _SUPPORTED_CONTENT_TYPES: + print( + "WARNING: skipping unsupported Confluence child " + f"parent_id={parent_id} id={child_id} " + f"type={child_type} title={child.get('title', '')!r}", + file=sys.stderr, + ) + continue + if catalog_node is None: + raise ConversionError( + f"Supported child {child_id} ({child_type}) of parent {parent_id} " + "is missing from pages YAML" + ) + supported.append(catalog_node) + return supported + + +def _single_quoted_yaml(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def _folder_confluence_url( + folder_data: Mapping[str, Any], + base_url: str, + space_key: str, + folder_id: str, +) -> str: + links = folder_data.get("_links", {}) + if not isinstance(links, dict): + raise ConversionError("folder.v2.yaml _links must be a mapping") + webui = links.get("webui") + + effective_base = str(links.get("base") or base_url).rstrip("/") + if not effective_base: + raise ConversionError("Cannot build folder confluenceUrl without a base URL") + if not space_key: + raise ConversionError("Cannot build folder confluenceUrl without a space key") + + if not webui: + return ( + f"{effective_base}/spaces/{quote(space_key, safe='')}/folder/" + f"{quote(folder_id, safe='')}" + ) + + webui_str = str(webui) + if webui_str.startswith(("https://", "http://")): + return webui_str + + base_parts = urlsplit(effective_base) + base_path = base_parts.path.rstrip("/") + if ( + webui_str.startswith("/") + and base_path + and ( + webui_str == base_path + or webui_str.startswith(f"{base_path}/") + ) + ): + return f"{base_parts.scheme}://{base_parts.netloc}{webui_str}" + return f"{effective_base}/{webui_str.lstrip('/')}" + + +def _markdown_link_title(value: str) -> str: + return value.replace("\\", "\\\\").replace("[", "\\[").replace("]", "\\]") + + +def generate_folder_mdx( + folder: Mapping[str, Any], + nodes_by_id: Mapping[str, Mapping[str, Any]], + var_dir: Path, + output_base_dir: Path, + base_url: str, + space_key: str = "QM", +) -> Path: + """Generate a deterministic folder landing page and return its relative path.""" + folder_id = str(folder["page_id"]) + relative_path = _output_relative_path(folder) + output_path = output_base_dir / relative_path + folder_data = _load_yaml_mapping( + var_dir / folder_id / "folder.v2.yaml", + f"folder metadata for {folder_id}", + ) + confluence_url = _folder_confluence_url( + folder_data, + base_url, + space_key, + folder_id, + ) + children = _supported_children(folder, var_dir, nodes_by_id) + + title = str(folder.get("title") or folder_data.get("title") or "").strip() + if not title: + raise ConversionError(f"Folder {folder_id} has no title") + + lines = [ + "---", + f"title: {_single_quoted_yaml(title)}", + f"confluenceUrl: {_single_quoted_yaml(confluence_url)}", + "---", + "", + f"# {title}", + "", + "## 하위 문서", + "", + ] + + if children: + for child in children: + child_relative_path = _output_relative_path(child).with_suffix("") + link = os.path.relpath( + child_relative_path, + start=relative_path.parent, + ).replace(os.sep, "/") + if not link.startswith("."): + link = f"./{link}" + child_title = _markdown_link_title(str(child.get("title") or "")) + lines.append(f"- [{child_title}]({link})") + else: + lines.append("하위 문서가 없습니다.") + lines.append("") + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text("\n".join(lines), encoding="utf-8") + return relative_path + + +def _typescript_string(value: str) -> str: + return value.replace("\\", "\\\\").replace("'", "\\'") + + +def generate_navigation( + pages: Sequence[Mapping[str, Any]], + var_dir: Path, + output_base_dir: Path, +) -> List[Dict[str, str]]: + """Generate non-root navigation files after all MDX outputs exist.""" + if not pages: + return [] + + nodes_by_id = {str(page["page_id"]): page for page in pages} + root_id = str(pages[0]["page_id"]) + entries: List[Dict[str, str]] = [] + + for parent in pages: + parent_id = str(parent["page_id"]) + if parent_id == root_id: + continue + + children = _supported_children(parent, var_dir, nodes_by_id) + if not children: + continue + + parent_relative_path = _output_relative_path(parent) + meta_relative_path = parent_relative_path.with_suffix("") / "_meta.ts" + meta_path = output_base_dir / meta_relative_path + meta_lines = ["export default {"] + + for child in children: + child_relative_path = _output_relative_path(child) + child_output_path = output_base_dir / child_relative_path + if not child_output_path.is_file(): + raise ConversionError( + f"Cannot add child {child['page_id']} to navigation for {parent_id}: " + f"missing MDX {child_output_path}" + ) + slug = _typescript_string(str(child_relative_path.stem)) + title = _typescript_string(str(child.get("title") or "")) + meta_lines.append(f" '{slug}': '{title}',") + + meta_lines.extend(["};", ""]) + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text("\n".join(meta_lines), encoding="utf-8") + entries.append({ + "page_id": parent_id, + "type": str(parent.get("type") or "page"), + "kind": "navigation", + "path": meta_relative_path.as_posix(), + }) + + return entries + + +def _manifest_outputs(path: Path, expected_sync_code: str) -> List[Dict[str, str]]: + if not path.exists(): + return [] + data = _load_yaml_mapping(path, "conversion manifest") + manifest_sync_code = data.get("sync_code") + if manifest_sync_code != expected_sync_code: + raise ConversionError( + f"conversion manifest sync_code mismatch: " + f"expected {expected_sync_code!r}, got {manifest_sync_code!r}" + ) + outputs = data.get("outputs", []) + if not isinstance(outputs, list) or not all( + isinstance(item, dict) for item in outputs + ): + raise ConversionError(f"conversion manifest outputs must be a list: {path}") + return outputs + + +def _validated_manifest_path(output_root: Path, relative_value: Any) -> Path: + if not isinstance(relative_value, str): + raise ConversionError(f"Manifest path must be a string: {relative_value!r}") + relative_path = Path(relative_value) + if relative_path.is_absolute(): + raise ConversionError(f"Manifest path must be relative: {relative_value}") + + resolved = (output_root / relative_path).resolve() + try: + resolved.relative_to(output_root) + except ValueError as exc: + raise ConversionError( + f"Manifest path escapes output root: {relative_value}" + ) from exc + + if resolved.suffix != ".mdx" and resolved.name != "_meta.ts": + raise ConversionError( + f"Manifest path is not an owned MDX/navigation file: {relative_value}" + ) + return resolved + + +def _remove_empty_parents(path: Path, output_root: Path) -> None: + parent = path.parent + while parent != output_root: + try: + parent.rmdir() + except OSError: + break + parent = parent.parent + + +def finalize_manifest( + manifest_path: Path, + sync_code: str, + current_outputs: Sequence[Mapping[str, str]], + output_base_dir: Path, +) -> None: + """Safely remove stale owned files and atomically replace the manifest.""" + output_root = output_base_dir.resolve() + previous_outputs = _manifest_outputs(manifest_path, sync_code) + + previous_by_path: Dict[str, Mapping[str, str]] = {} + for entry in previous_outputs: + relative_value = entry.get("path") + _validated_manifest_path(output_root, relative_value) + previous_by_path[str(relative_value)] = entry + + current_by_path: Dict[str, Mapping[str, str]] = {} + for entry in current_outputs: + relative_value = entry.get("path") + current_path = _validated_manifest_path(output_root, relative_value) + if not current_path.is_file(): + raise ConversionError( + f"Current generated output is missing: {relative_value}" + ) + current_by_path[str(relative_value)] = entry + + for stale_relative_path in sorted( + set(previous_by_path) - set(current_by_path), + reverse=True, + ): + stale_path = _validated_manifest_path(output_root, stale_relative_path) + if stale_path.exists() or stale_path.is_symlink(): + if not stale_path.is_file() and not stale_path.is_symlink(): + raise ConversionError( + f"Refusing to delete non-file manifest path: {stale_relative_path}" + ) + stale_path.unlink() + _remove_empty_parents(stale_path, output_root) + + manifest_path.parent.mkdir(parents=True, exist_ok=True) + manifest_data = { + "version": 1, + "sync_code": sync_code, + "outputs": sorted(current_outputs, key=lambda entry: entry["path"]), + } + with tempfile.NamedTemporaryFile( + mode="w", + encoding="utf-8", + dir=manifest_path.parent, + prefix=f".{manifest_path.name}.", + suffix=".tmp", + delete=False, + ) as temp_file: + yaml.safe_dump( + manifest_data, + temp_file, + allow_unicode=True, + sort_keys=False, + ) + temp_path = Path(temp_file.name) + os.replace(temp_path, manifest_path) + + def convert_all(pages: List[Dict], var_dir: str, output_base_dir: str, public_dir: str, - log_level: str, pages_yaml: str = '') -> int: - """Run converter/cli.py for each page. Returns number of failures.""" + log_level: str, pages_yaml: str = '', + manifest_path: str = '', sync_code: str = 'qm', + base_url: str = _DEFAULT_CONFLUENCE_BASE_URL, + space_key: str = '') -> int: + """Convert typed catalog nodes and return the number of failures.""" # Skip the root page root_page_id = pages[0]['page_id'] if pages else None targets = [p for p in pages if p['page_id'] != root_page_id] + nodes_by_id = {str(page["page_id"]): page for page in pages} + var_path = Path(var_dir) + output_base_path = Path(output_base_dir) + profile = SYNC_PROFILES.get(sync_code) + effective_space_key = space_key or ( + profile.space_key if profile else sync_code.upper() + ) total = len(targets) failures = 0 + generated_outputs: List[Dict[str, str]] = [] for i, page in enumerate(targets, 1): - page_id = page['page_id'] - path_parts = page.get('path', []) - if not path_parts: - print(f"[{i}/{total}] SKIP {page_id} (no path)", file=sys.stderr) + page_id = str(page['page_id']) + content_type = str(page.get("type") or "page") + if content_type not in _SUPPORTED_CONTENT_TYPES: + print( + f"[{i}/{total}] SKIP {page_id} (unsupported type {content_type})", + file=sys.stderr, + ) continue - # Compute paths (same logic as generate_commands_for_xhtml2markdown.py) - if len(path_parts) == 1: - rel_dir = '.' - filename = f"{path_parts[0]}.mdx" - else: - rel_dir = os.path.join(*path_parts[:-1]) - filename = f"{path_parts[-1]}.mdx" - - input_file = os.path.join(var_dir, page_id, 'page.xhtml') - output_dir = os.path.join(output_base_dir, rel_dir) - output_file = os.path.normpath(os.path.join(output_dir, filename)) - attachment_dir = os.path.normpath(os.path.join('/', rel_dir, Path(filename).stem)) - - if not os.path.exists(input_file): - print(f"[{i}/{total}] SKIP {page_id} (no page.xhtml)", file=sys.stderr) - continue - - os.makedirs(output_dir, exist_ok=True) - - cmd = [ - sys.executable, str(_SCRIPT_DIR / 'converter' / 'cli.py'), - input_file, output_file, - f'--public-dir={public_dir}', - f'--attachment-dir={attachment_dir}', - f'--log-level={log_level}', - ] - if pages_yaml: - cmd.append(f'--pages-yaml={pages_yaml}') - - print(f"[{i}/{total}] {page_id} → {output_file}", file=sys.stderr) - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: + try: + relative_path = _output_relative_path(page) + output_file = output_base_path / relative_path + if content_type == "folder": + print(f"[{i}/{total}] {page_id} → {output_file}", file=sys.stderr) + generate_folder_mdx( + page, + nodes_by_id, + var_path, + output_base_path, + base_url, + effective_space_key, + ) + else: + input_file = var_path / page_id / "page.xhtml" + if not input_file.exists(): + raise ConversionError(f"Missing page XHTML: {input_file}") + + output_file.parent.mkdir(parents=True, exist_ok=True) + attachment_dir = Path("/") / relative_path.with_suffix("") + cmd = [ + sys.executable, str(_SCRIPT_DIR / 'converter' / 'cli.py'), + str(input_file), str(output_file), + f'--public-dir={public_dir}', + f'--attachment-dir={attachment_dir}', + f'--log-level={log_level}', + ] + if pages_yaml: + cmd.append(f'--pages-yaml={pages_yaml}') + + print(f"[{i}/{total}] {page_id} → {output_file}", file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + raise ConversionError(result.stderr.strip()) + + generated_outputs.append({ + "page_id": page_id, + "type": content_type, + "kind": "mdx", + "path": relative_path.as_posix(), + }) + except Exception as exc: + failures += 1 + print(f" ERROR: {exc}", file=sys.stderr) + + if failures == 0: + try: + generated_outputs.extend( + generate_navigation(pages, var_path, output_base_path) + ) + except Exception as exc: failures += 1 - print(f" ERROR: {result.stderr.strip()}", file=sys.stderr) + print(f" ERROR: navigation generation failed: {exc}", file=sys.stderr) + + if failures == 0 and manifest_path: + try: + finalize_manifest( + Path(manifest_path), + sync_code, + generated_outputs, + output_base_path, + ) + except Exception as exc: + failures += 1 + print(f" ERROR: manifest finalization failed: {exc}", file=sys.stderr) return failures def main(): parser = argparse.ArgumentParser( - description='Batch convert all Confluence pages to MDX using pages.yaml' + description='Batch convert Confluence pages and folders to MDX using pages.yaml' ) parser.add_argument('--sync-code', default='qm', help='Sync profile code; used to auto-derive --pages-yaml (default: %(default)s)') @@ -148,6 +567,10 @@ def main(): help='Public assets directory (default: target/public)') parser.add_argument('--translations', default='etc/korean-titles-translations.txt', help='Path to translations file') + parser.add_argument('--base-url', default=_DEFAULT_CONFLUENCE_BASE_URL, + help='Confluence base URL for generated folder links') + parser.add_argument('--space-key', default=None, + help='Confluence space key for generated folder links (default: sync profile)') parser.add_argument('--verify-translations', action='store_true', help='Verify translation coverage and exit') parser.add_argument('--log-level', default='warning', @@ -165,6 +588,11 @@ def main(): args.output_dir = _resolve(args.output_dir) args.public_dir = _resolve(args.public_dir) args.translations = _resolve(args.translations) + manifest_path = os.path.join(args.var_dir, f"convert-manifest.{args.sync_code}.yaml") + profile = SYNC_PROFILES.get(args.sync_code) + space_key = args.space_key or ( + profile.space_key if profile else args.sync_code.upper() + ) # Load data pages = load_pages_yaml(args.pages_yaml) @@ -188,7 +616,11 @@ def main(): # Run conversions failures = convert_all(pages, args.var_dir, args.output_dir, args.public_dir, args.log_level, - pages_yaml=args.pages_yaml) + pages_yaml=args.pages_yaml, + manifest_path=manifest_path, + sync_code=args.sync_code, + base_url=args.base_url, + space_key=space_key) if failures: print(f"\nCompleted with {failures} failure(s) out of {len(pages)} pages", file=sys.stderr) diff --git a/confluence-mdx/bin/converter/cli.py b/confluence-mdx/bin/converter/cli.py index 5586722b0..9a36a7ee9 100755 --- a/confluence-mdx/bin/converter/cli.py +++ b/confluence-mdx/bin/converter/cli.py @@ -11,9 +11,7 @@ import os import sys from pathlib import Path -from typing import Optional, List - -import yaml +from typing import Optional # Resolve project root (confluence-mdx/) from this script's location # bin/converter/cli.py -> .parent=converter/ -> .parent=bin/ -> .parent=confluence-mdx/ @@ -27,88 +25,13 @@ import converter.context as ctx from converter.context import ( PAGES_BY_TITLE, PAGES_BY_ID, - PagesDict, PageV1, + PageV1, load_pages_yaml, load_page_v1_yaml, build_link_mapping, - set_page_v1, get_page_v1, get_attachments, - clean_text, + set_page_v1, get_attachments, ) from converter.core import ConfluenceToMarkdown -def generate_meta_from_children(input_dir: str, output_file_path: str, pages_by_id: PagesDict) -> None: - """Generate a Nextra sidebar _meta.ts file using children.v2.yaml in input_dir. - - Reads children.v2.yaml if present. - - Sorts children by childPosition. - - Uses pages_by_id to resolve each child's filename slug from pages.yaml path. - - Warns when a child id is not found in pages_by_id. - - Validates that a corresponding MDX file (slug_key.mdx) exists next to _meta.ts; otherwise warns and skips. - - Writes _meta.ts under dirname(output_file_path)/stem/_meta.ts. - Swallows exceptions with logging to keep conversion resilient. - """ - try: - children_yaml_path = os.path.join(input_dir, 'children.v2.yaml') - if os.path.exists(children_yaml_path): - with open(children_yaml_path, 'r', encoding='utf-8') as yf: - children_data = yaml.safe_load(yf) - results = children_data.get('results') if isinstance(children_data, dict) else None - if isinstance(results, list) and len(results) > 0: - def _pos(item: dict) -> int: - try: - return int(item.get('childPosition', 0)) - except Exception: - return 0 - ordered = sorted(results, key=_pos) - - # Determine where _meta.ts and child mdx files should live - meta_dir = os.path.join(os.path.dirname(output_file_path), Path(output_file_path).stem) - os.makedirs(meta_dir, exist_ok=True) - - entries: List[str] = [] - for child in ordered: - if not isinstance(child, dict): - continue - child_id = str(child.get('id')) if child.get('id') is not None else None - title = clean_text(child.get('title')) - if not child_id: - logging.warning(f"children.v2.yaml entry missing id: {child}") - continue - page_info = pages_by_id.get(child_id) - if not page_info: - logging.warning(f"Child page id {child_id} not found in pages.yaml while generating _meta.ts from {children_yaml_path}") - # Continue but skip since we cannot determine filename - continue - # Determine slug/filename from page_info.path if available - slug_key: Optional[str] = None - try: - path_list = page_info.get('path') if isinstance(page_info, dict) else None - if isinstance(path_list, list) and len(path_list) > 0: - slug_key = str(path_list[-1]) - except Exception: - slug_key = None - if not slug_key: - logging.warning(f"Child page id {child_id} has no valid path in pages.yaml; skipping entry in _meta.ts") - continue - - key_repr = f"'{slug_key}'" - title_repr = (title or '').strip().replace("'", "\\'") - entries.append(f" {key_repr}: '{title_repr}',") - - if entries: - meta_path = os.path.join(meta_dir, '_meta.ts') - content = 'export default {\n' + "\n".join(entries) + '\n};\n' - with open(meta_path, 'w', encoding='utf-8') as mf: - mf.write(content) - logging.info(f"Generated sidebar meta at {meta_path} from {children_yaml_path}") - else: - logging.info("No sidebar entries generated: children list empty after processing") - else: - logging.info("children.v2.yaml has no 'results' or it is empty; skipping _meta.ts") - else: - logging.debug("No children.v2.yaml found; skipping _meta.ts generation") - except Exception as meta_err: - logging.error(f"Failed to generate _meta.ts: {meta_err}") - - def main(): parser = argparse.ArgumentParser(description='Convert Confluence XHTML to Markdown') parser.add_argument('input_file', help='Input XHTML file path') @@ -178,7 +101,7 @@ def main(): # 원본 XHTML 보존 — sidecar mapping에서 사용 xhtml_original = html_content - # Load pages YAML for internal link resolution and _meta.ts generation. + # Load pages YAML for internal link resolution. # Priority: --pages-yaml arg > pages.qm.yaml (new naming) > pages.yaml (legacy). var_dir = os.path.join(input_dir, '..') if args.pages_yaml: @@ -226,9 +149,6 @@ def main(): except Exception as e: logging.warning(f"Sidecar mapping 생성 실패 (변환은 성공): {e}") - # Generate _meta.ts from children.v2.yaml to preserve child order for Netra sidebar - generate_meta_from_children(input_dir, ctx.OUTPUT_FILE_PATH, PAGES_BY_ID) - logging.info(f"Successfully converted {args.input_file} to {args.output_file}") except Exception as e: diff --git a/confluence-mdx/bin/fetch/api_client.py b/confluence-mdx/bin/fetch/api_client.py index 51967ca08..cf12d6b68 100644 --- a/confluence-mdx/bin/fetch/api_client.py +++ b/confluence-mdx/bin/fetch/api_client.py @@ -3,7 +3,7 @@ import logging from datetime import datetime, timedelta from typing import Dict, List, Optional, Protocol -from urllib.parse import quote +from urllib.parse import quote, urljoin import requests from requests.auth import HTTPBasicAuth @@ -18,10 +18,10 @@ class ApiClientProtocol(Protocol): def make_request(self, url: str, description: str) -> Optional[Dict]: ... - def get_page_data(self, page_id: str) -> Optional[Dict]: + def get_page_data_v2(self, page_id: str, content_type: str = "page") -> Optional[Dict]: ... - def get_child_pages(self, page_id: str) -> Optional[Dict]: + def get_direct_children(self, page_id: str, content_type: str = "page") -> Optional[Dict]: ... def get_attachments(self, page_id: str) -> Optional[Dict]: @@ -43,7 +43,20 @@ def make_request(self, url: str, description: str) -> Optional[Dict]: self.logger.debug(f"Making {description} request to: {url}") response = requests.get(url, headers=self.headers, auth=self.auth) response.raise_for_status() - return response.json() + data = response.json() + if isinstance(data, dict): + response_links = getattr(response, "links", {}) + next_link = ( + response_links.get("next", {}).get("url") + if isinstance(response_links, dict) + else None + ) + if next_link: + links = data.get("_links", {}) + links = dict(links) if isinstance(links, dict) else {} + links.setdefault("next", next_link) + data["_links"] = links + return data except Exception as e: self.logger.error(f"Error making {description} request to {url}: {str(e)}") raise ApiError(f"Failed to make {description} request: {str(e)}") @@ -64,18 +77,58 @@ def get_page_data_v2(self, page_id: str, content_type: str = "page") -> Optional url = f"{self.config.base_url}/api/v2/pages/{page_id}?body-format=atlas_doc_format" return self.make_request(url, "V2 API page data") - def get_child_pages(self, page_id: str, content_type: str = "page") -> Optional[Dict]: - """Get child pages using V2 API. - - Uses /api/v2/folders/{id}/children for folder content type, - /api/v2/pages/{id}/children for page content type. - The type=page filter is omitted so that folder children are also included. - """ + def get_direct_children(self, page_id: str, content_type: str = "page") -> Optional[Dict]: + """Get every direct child using the V2 API with cursor pagination.""" if content_type == "folder": - url = f"{self.config.base_url}/api/v2/folders/{page_id}/children?limit=100" + url = f"{self.config.base_url}/api/v2/folders/{page_id}/direct-children?limit=100" else: - url = f"{self.config.base_url}/api/v2/pages/{page_id}/children?limit=100" - return self.make_request(url, "V2 API child pages") + url = f"{self.config.base_url}/api/v2/pages/{page_id}/direct-children?limit=100" + + combined: Optional[Dict] = None + results: List[Dict] = [] + seen_urls: set[str] = set() + + while url: + if url in seen_urls: + raise ApiError(f"Detected pagination cycle while fetching direct children for {page_id}") + seen_urls.add(url) + + data = self.make_request(url, "V2 API direct children") + if not data: + if combined is None: + return data + break + + if combined is None: + combined = dict(data) + + page_results = data.get("results", []) + if not isinstance(page_results, list): + raise ApiError(f"Invalid direct children response for {page_id}: results is not a list") + results.extend(page_results) + + links = data.get("_links", {}) + next_url = links.get("next") if isinstance(links, dict) else None + url = ( + urljoin(f"{self.config.base_url.rstrip('/')}/", str(next_url)) + if next_url + else "" + ) + + if combined is None: + return None + + combined["results"] = results + links = combined.get("_links") + if isinstance(links, dict): + links = dict(links) + links.pop("next", None) + combined["_links"] = links + return combined + + def get_child_pages(self, page_id: str, content_type: str = "page") -> Optional[Dict]: + """Backward-compatible alias for the typed direct-children request.""" + return self.get_direct_children(page_id, content_type) def get_attachments(self, page_id: str) -> Optional[Dict]: """Get attachments using V1 API""" diff --git a/confluence-mdx/bin/fetch/models.py b/confluence-mdx/bin/fetch/models.py index 399992d80..34942b836 100644 --- a/confluence-mdx/bin/fetch/models.py +++ b/confluence-mdx/bin/fetch/models.py @@ -1,15 +1,27 @@ -"""Data models for Confluence pages.""" +"""Data models for Confluence content.""" from dataclasses import dataclass from typing import Dict, List, Optional, Any @dataclass -class Page: - """Class to represent a Confluence page with its metadata and content""" +class ContentRef: + """A typed reference returned by the Confluence direct-children API.""" + + id: str + type: str + title: str = "" + child_position: int = 0 + + +@dataclass +class ContentNode: + """A page or folder represented in the serialized content catalog.""" + page_id: str title: str title_orig: str + content_type: str = "page" breadcrumbs: Optional[List[str]] = None breadcrumbs_en: Optional[List[str]] = None path: Optional[List[str]] = None @@ -23,12 +35,13 @@ def __post_init__(self): self.path = [] @classmethod - def from_dict(cls, data: Dict[str, Any]) -> 'Page': - """Create a Page instance from a dictionary""" + def from_dict(cls, data: Dict[str, Any]) -> 'ContentNode': + """Create a content node from a catalog dictionary.""" return cls( page_id=data.get('page_id', ''), title=data.get('title', ''), title_orig=data.get('title_orig', ''), + content_type=data.get('type', data.get('content_type', 'page')), breadcrumbs=data.get('breadcrumbs', []), breadcrumbs_en=data.get('breadcrumbs_en', []), path=data.get('path', []) @@ -38,6 +51,7 @@ def to_dict(self) -> Dict[str, Any]: """Convert Page instance to dictionary""" return { 'page_id': self.page_id, + 'type': self.content_type, 'title': self.title, 'title_orig': self.title_orig, 'breadcrumbs': self.breadcrumbs, @@ -46,6 +60,10 @@ def to_dict(self) -> Dict[str, Any]: } def to_output_line(self) -> str: - """Convert to output line format: page_id \t breadcrumbs \t title""" + """Convert to output line format: page_id \t breadcrumbs \t title.""" breadcrumbs_str = " />> ".join(self.breadcrumbs) if self.breadcrumbs else "" return f"{self.page_id}\t{breadcrumbs_str}\t{self.title}" + + +# Backward-compatible import for callers that still refer to the old model name. +Page = ContentNode diff --git a/confluence-mdx/bin/fetch/processor.py b/confluence-mdx/bin/fetch/processor.py index d9145ae96..685cfd312 100644 --- a/confluence-mdx/bin/fetch/processor.py +++ b/confluence-mdx/bin/fetch/processor.py @@ -1,18 +1,18 @@ -"""Confluence page processing orchestrator.""" +"""Confluence page/folder processing orchestrator.""" import logging import os import sys import traceback from datetime import datetime, timezone -from typing import Dict, Generator, List, Optional +from typing import Dict, Generator, List, Optional, Set from fetch.config import Config from fetch.api_client import ApiClient from fetch.file_manager import FileManager from fetch.translation import TranslationService from fetch.stages import Stage1Processor, Stage2Processor, Stage3Processor, Stage4Processor -from fetch.models import Page +from fetch.models import ContentNode, ContentRef from text_utils import slugify @@ -37,32 +37,44 @@ def __init__(self, config: Config, logger: logging.Logger): # Load translations self.translation_service.load_translations() - def process_page_complete(self, page_id: str, start_page_id: Optional[str] = None) -> Optional[Page]: - """Process a single page through all 4 stages""" + def process_page_complete( + self, + page_id: str, + start_page_id: Optional[str] = None, + content_type: str = "page", + parent_breadcrumbs: Optional[List[str]] = None, + include_children: bool = True, + ) -> Optional[ContentNode]: + """Process a page or folder through the applicable stages.""" try: - self.logger.info(f"Processing page ID {page_id} through all stages") + self.logger.info(f"Processing {content_type} ID {page_id}") # Stage 1: API Data Collection - self.stage1.process(page_id) + self.stage1.process(page_id, content_type, include_children) # Stage 2: Content Extraction - self.stage2.process(page_id) + self.stage2.process(page_id, content_type) # Stage 3: Attachment Download - self.stage3.process(page_id) + self.stage3.process(page_id, content_type) # Stage 4: Document Listing - page = self.stage4.process(page_id, start_page_id) - - self.logger.info(f"Completed all stages for page ID {page_id}") + page = self.stage4.process( + page_id, + start_page_id, + content_type=content_type, + parent_breadcrumbs=parent_breadcrumbs, + ) + + self.logger.info(f"Completed all stages for {content_type} ID {page_id}") return page except Exception as e: - self.logger.error(f"Error processing page ID {page_id}: {str(e)}") - return None + self.logger.error(f"Error processing {content_type} ID {page_id}: {str(e)}") + raise - def get_child_page_ids(self, page_id: str) -> List[str]: - """Get child page IDs for recursive processing""" + def get_child_content_refs(self, page_id: str) -> List[ContentRef]: + """Load supported typed child references for recursive processing.""" try: directory = self.stage1.get_page_directory(page_id) yaml_filepath = os.path.join(directory, "children.v2.yaml") @@ -70,33 +82,108 @@ def get_child_page_ids(self, page_id: str) -> List[str]: if os.path.exists(yaml_filepath): data = self.file_manager.load_yaml(yaml_filepath) if data: - child_ids = [child["id"] for child in data.get("results", [])] - self.logger.debug(f"Found {len(child_ids)} child pages for page ID {page_id}") - return child_ids + refs: List[ContentRef] = [] + results = data.get("results", []) + if not isinstance(results, list): + self.logger.error( + f"Invalid children.v2.yaml for parent {page_id}: results is not a list" + ) + return [] + + for child in results: + if not isinstance(child, dict) or child.get("id") is None: + self.logger.warning( + f"Skipping malformed child for parent {page_id}: {child}" + ) + continue + + child_type = str(child.get("type") or "page") + child_id = str(child["id"]) + title = str(child.get("title") or "") + if child_type not in ("page", "folder"): + self.logger.warning( + "Skipping unsupported Confluence child " + f"parent_id={page_id} id={child_id} " + f"type={child_type} title={title!r}" + ) + continue + + try: + position = int(child.get("childPosition", 0)) + except (TypeError, ValueError): + position = 0 + refs.append(ContentRef( + id=child_id, + type=child_type, + title=title, + child_position=position, + )) + + refs.sort(key=lambda ref: ref.child_position) + self.logger.debug( + f"Found {len(refs)} supported children for parent ID {page_id}" + ) + return refs else: self.logger.warning(f"No children.v2.yaml found for page ID {page_id}") return [] except Exception as e: - self.logger.error(f"Error getting child page IDs for page ID {page_id}: {str(e)}") + self.logger.error(f"Error getting child content for page ID {page_id}: {str(e)}") return [] + return [] - def fetch_page_tree_recursive(self, page_id: str, start_page_id: Optional[str] = None, use_local: bool = False) -> Generator[Page, None, None]: - """Recursively fetch page tree through all 4 stages""" + def get_child_page_ids(self, page_id: str) -> List[str]: + """Backward-compatible helper returning supported child IDs.""" + return [ref.id for ref in self.get_child_content_refs(page_id)] + + def fetch_page_tree_recursive( + self, + page_id: str, + start_page_id: Optional[str] = None, + use_local: bool = False, + content_type: Optional[str] = None, + parent_breadcrumbs: Optional[List[str]] = None, + visited: Optional[Set[str]] = None, + ) -> Generator[ContentNode, None, None]: + """Recursively fetch a typed content tree.""" try: self.logger.debug(f"Processing page tree for page ID {page_id}") # If start_page_id is not provided, use the current page_id as the starting point if start_page_id is None: start_page_id = page_id + if content_type is None: + content_type = ( + self.config.root_content_type + if page_id == start_page_id + else "page" + ) + if visited is None: + visited = set() + if page_id in visited: + self.logger.warning(f"Skipping cycle or duplicate content ID {page_id}") + return + visited.add(page_id) # Process current page through all 4 stages if use_local: # In local mode, skip Stage 1 (API calls) and Stage 3 (attachment download) # Only process Stage 2 (content extraction) and Stage 4 (document listing) - self.stage2.process(page_id) - page = self.stage4.process(page_id, start_page_id) + self.stage2.process(page_id, content_type) + page = self.stage4.process( + page_id, + start_page_id, + content_type=content_type, + parent_breadcrumbs=parent_breadcrumbs, + ) else: - page = self.process_page_complete(page_id, start_page_id) + page = self.process_page_complete( + page_id, + start_page_id, + content_type=content_type, + parent_breadcrumbs=parent_breadcrumbs, + include_children=True, + ) if page: # Update translations if available @@ -109,13 +196,22 @@ def fetch_page_tree_recursive(self, page_id: str, start_page_id: Optional[str] = yield page - # Process child pages recursively - child_ids = self.get_child_page_ids(page_id) - for child_id in child_ids: - yield from self.fetch_page_tree_recursive(child_id, start_page_id, use_local) + child_parent_breadcrumbs = ( + [] if page_id == start_page_id else list(page.breadcrumbs) + ) + 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, + visited=visited, + ) except Exception as e: self.logger.error(f"Error processing page ID {page_id}: {str(e)}") self.logger.debug(traceback.format_exc()) + raise def _get_fetch_state_path(self, start_page_id: str) -> str: """Return the path to the fetch state file for a specific start_page_id.""" @@ -242,7 +338,12 @@ def run(self) -> None: skipped_count += 1 continue - page = self.process_page_complete(page_id, start_page_id) + page = self.process_page_complete( + page_id, + start_page_id, + content_type="page", + include_children=False, + ) if page: # Update translations if available if self.translation_service.translations: @@ -268,7 +369,12 @@ def run(self) -> None: page_count = 0 yaml_entries = [] - for page in self.fetch_page_tree_recursive(start_page_id, start_page_id, use_local=True): + for page in self.fetch_page_tree_recursive( + start_page_id, + start_page_id, + use_local=True, + content_type=self.config.root_content_type, + ): if page: page_count += 1 yaml_entries.append(page.to_dict()) @@ -279,7 +385,12 @@ def run(self) -> None: page_count = 0 yaml_entries = [] - for page in self.fetch_page_tree_recursive(start_page_id, start_page_id, use_local=True): + for page in self.fetch_page_tree_recursive( + start_page_id, + start_page_id, + use_local=True, + content_type=self.config.root_content_type, + ): if page: page_count += 1 yaml_entries.append(page.to_dict()) @@ -291,7 +402,12 @@ def run(self) -> None: page_count = 0 yaml_entries = [] - for page in self.fetch_page_tree_recursive(start_page_id, start_page_id, use_local=False): + for page in self.fetch_page_tree_recursive( + start_page_id, + start_page_id, + use_local=False, + content_type=self.config.root_content_type, + ): if page: # Exclude start_page_id from stdout (root page is not converted to MDX) if page.page_id != start_page_id: diff --git a/confluence-mdx/bin/fetch/stages.py b/confluence-mdx/bin/fetch/stages.py index eb28a7fa9..4c8c09141 100644 --- a/confluence-mdx/bin/fetch/stages.py +++ b/confluence-mdx/bin/fetch/stages.py @@ -8,7 +8,7 @@ from fetch.config import Config from fetch.api_client import ApiClient from fetch.file_manager import FileManager -from fetch.models import Page +from fetch.models import ContentNode from text_utils import clean_text @@ -33,53 +33,62 @@ def get_cache_page_directory(self, page_id: str) -> str: class Stage1Processor(StageBase): """Stage 1: API Data Collection - Fetch and save API responses to YAML files.""" - def process(self, page_id: str) -> None: - self.logger.info(f"Stage 1: Collecting API data for page ID {page_id}") + def process( + self, + page_id: str, + content_type: str = "page", + include_children: bool = True, + ) -> None: + self.logger.info( + f"Stage 1: Collecting API data for {content_type} ID {page_id}" + ) # Skip API calls if using local mode if self.config.mode == "local": - self.logger.info(f"Stage 1 skipped for page ID {page_id} (local mode)") + self.logger.info(f"Stage 1 skipped for {content_type} ID {page_id} (local mode)") return directory = self.get_page_directory(page_id) self.file_manager.ensure_directory(directory) - # Determine content type for API routing: - # 1. Prefer the type stored in page.v2.yaml (present on re-runs). - # 2. Fall back to config.root_content_type when processing the root - # page on a clean environment (page.v2.yaml does not yet exist). - # 3. Default to "page" for all other pages without cached data. - v2_path = os.path.join(self.get_page_directory(page_id), "page.v2.yaml") - existing_v2 = self.file_manager.load_yaml(v2_path) if os.path.exists(v2_path) else None - if existing_v2: - content_type = existing_v2.get("type", "page") - elif page_id == self.config.default_start_page_id: - content_type = self.config.root_content_type + if content_type == "folder": + api_operations = [ + { + 'operation': lambda: self.api_client.get_page_data_v2(page_id, "folder"), + 'description': "V2 API folder data", + 'filename': "folder.v2.yaml", + 'required': True, + }, + ] else: - content_type = "page" - - api_operations = [ - { - 'operation': lambda: self.api_client.get_page_data_v1(page_id), - 'description': "V1 API page data", - 'filename': "page.v1.yaml" - }, - { - 'operation': lambda: self.api_client.get_page_data_v2(page_id, content_type), - 'description': "V2 API page data", - 'filename': "page.v2.yaml" - }, - { - 'operation': lambda: self.api_client.get_child_pages(page_id, content_type), - 'description': "V2 API child pages", - 'filename': "children.v2.yaml" - }, - { - 'operation': lambda: self.api_client.get_attachments(page_id), - 'description': "V1 API attachments", - 'filename': "attachments.v1.yaml" - }, - ] + api_operations = [ + { + 'operation': lambda: self.api_client.get_page_data_v1(page_id), + 'description': "V1 API page data", + 'filename': "page.v1.yaml", + 'required': True, + }, + { + 'operation': lambda: self.api_client.get_page_data_v2(page_id, "page"), + 'description': "V2 API page data", + 'filename': "page.v2.yaml", + 'required': True, + }, + { + 'operation': lambda: self.api_client.get_attachments(page_id), + 'description': "V1 API attachments", + 'filename': "attachments.v1.yaml", + 'required': False, + }, + ] + + if include_children: + api_operations.append({ + 'operation': lambda: self.api_client.get_direct_children(page_id, content_type), + 'description': "V2 API direct children", + 'filename': "children.v2.yaml", + 'required': True, + }) for operation_info in api_operations: try: @@ -88,10 +97,16 @@ def process(self, page_id: str) -> None: filepath = os.path.join(directory, operation_info['filename']) self.file_manager.save_yaml(filepath, data) self._log_operation_result(page_id, operation_info['description'], data) + elif operation_info.get('required', False): + raise ValueError( + f"{operation_info['description']} returned no data for ID {page_id}" + ) except Exception as e: self.logger.error(f"Failed to collect {operation_info['description']} for page ID {page_id}: {str(e)}") + if operation_info.get('required', False): + raise - self.logger.info(f"Stage 1 completed for page ID {page_id}") + self.logger.info(f"Stage 1 completed for {content_type} ID {page_id}") def _log_operation_result(self, page_id: str, description: str, data: Dict) -> None: """Log specific information for different operations.""" @@ -108,7 +123,11 @@ def _log_operation_result(self, page_id: str, description: str, data: Dict) -> N class Stage2Processor(StageBase): """Stage 2: Content Extraction - Extract and save page content.""" - def process(self, page_id: str) -> bool: + def process(self, page_id: str, content_type: str = "page") -> bool: + if content_type == "folder": + self.logger.info(f"Stage 2 skipped for folder ID {page_id}") + return True + self.logger.debug(f"Stage 2: Extracting content for page ID {page_id}") directory = self.get_page_directory(page_id) @@ -158,7 +177,11 @@ def _extract_v2_content(self, page_id: str, v2_data: Dict, directory: str) -> No class Stage3Processor(StageBase): """Stage 3: Attachment Download - Download attachments if specified.""" - def process(self, page_id: str) -> bool: + def process(self, page_id: str, content_type: str = "page") -> bool: + if content_type == "folder": + self.logger.info(f"Stage 3 skipped for folder ID {page_id}") + return True + # Check if attachments should be downloaded if not self.config.download_attachments: self.logger.info(f"Stage 3 skipped for page ID {page_id} (attachments not requested)") @@ -253,18 +276,30 @@ def _download_single_attachment(self, page_id: str, attachment: Dict, directory: class Stage4Processor(StageBase): """Stage 4: Document Listing - Generate document information for output listing.""" - def process(self, page_id: str, start_page_id: Optional[str] = None) -> Optional[Page]: - self.logger.debug(f"Stage 4: Generating document list for page ID {page_id}") + def process( + self, + page_id: str, + start_page_id: Optional[str] = None, + content_type: str = "page", + parent_breadcrumbs: Optional[List[str]] = None, + ) -> Optional[ContentNode]: + self.logger.debug( + f"Stage 4: Generating document list for {content_type} ID {page_id}" + ) directory = self.get_page_directory(page_id) v1_data = self.file_manager.load_yaml(os.path.join(directory, "page.v1.yaml")) + v2_filename = "folder.v2.yaml" if content_type == "folder" else "page.v2.yaml" + v2_data = self.file_manager.load_yaml(os.path.join(directory, v2_filename)) - if not v1_data: - self.logger.error(f"V1 data not available for document listing for page ID {page_id}") + if content_type == "folder" and not v2_data: + self.logger.error(f"Folder data not available for document listing for ID {page_id}") + return None + if content_type == "page" and not v1_data and not v2_data: + self.logger.error(f"Page data not available for document listing for ID {page_id}") return None - # Extract title from V1 data - title_orig = v1_data.get("title") + title_orig = (v1_data or {}).get("title") or (v2_data or {}).get("title") if not title_orig: return None @@ -272,18 +307,19 @@ def process(self, page_id: str, start_page_id: Optional[str] = None) -> Optional if not title: return None - # Extract ancestors from V1 data - ancestors = v1_data.get("ancestors", []) if v1_data else [] - - # Build breadcrumbs - breadcrumbs = self._build_breadcrumbs(page_id, ancestors, title, start_page_id) + if parent_breadcrumbs is not None: + breadcrumbs = [*parent_breadcrumbs, title] + else: + ancestors = v1_data.get("ancestors", []) if v1_data else [] + breadcrumbs = self._build_breadcrumbs(page_id, ancestors, title, start_page_id) self.logger.debug(f"Stage 4 completed for page ID {page_id}: {title}") - return Page( + return ContentNode( page_id=page_id, title=title, title_orig=title_orig, + content_type=content_type, breadcrumbs=breadcrumbs, ) diff --git a/confluence-mdx/bin/fetch_cli.py b/confluence-mdx/bin/fetch_cli.py index 0f103c49e..6e6723b5a 100755 --- a/confluence-mdx/bin/fetch_cli.py +++ b/confluence-mdx/bin/fetch_cli.py @@ -12,9 +12,9 @@ 4. Document Listing: Generate and output a document list with breadcrumbs Modes: - --local: Process local files only, starting from default_start_page_id hierarchically - --remote: Download and process via API, starting from default_start_page_id hierarchically - --recent: Download recently modified pages, then process like --local (default) + --local: Rebuild the catalog from the stored page/folder hierarchy + --remote: Refresh the full page/folder hierarchy and content via API + --recent: Refresh modified page content, then reuse the stored hierarchy (default) Usage examples: bin/fetch_cli.py # Same as --recent: download recent pages then process locally @@ -66,11 +66,11 @@ def main(): # Mode selection (mutually exclusive) mode_group = parser.add_mutually_exclusive_group() mode_group.add_argument("--local", action="store_const", dest="mode", const="local", - help="Process local files only, starting from default_start_page_id hierarchically") + help="Rebuild from the stored page/folder hierarchy without API calls") mode_group.add_argument("--remote", action="store_const", dest="mode", const="remote", - help="Download and process via API, starting from default_start_page_id hierarchically") + help="Refresh the full page/folder hierarchy and content via API") mode_group.add_argument("--recent", action="store_const", dest="mode", const="recent", - help="Download recently modified pages, then process like --local") + help="Refresh modified page content and reuse the stored hierarchy") parser.add_argument("--output-dir", default=Config().default_output_dir, help="Directory to store output files (default: %(default)s)") diff --git a/confluence-mdx/bin/reverse_sync_cli.py b/confluence-mdx/bin/reverse_sync_cli.py index f9839f95a..6990942a3 100755 --- a/confluence-mdx/bin/reverse_sync_cli.py +++ b/confluence-mdx/bin/reverse_sync_cli.py @@ -125,10 +125,32 @@ def _resolve_page_id(ko_mdx_path: str) -> str: pages = yaml.safe_load(pages_path.read_text()) for page in pages: if page.get('path') == path_parts: + if page.get('type', 'page') == 'folder': + raise ValueError( + f"MDX path '{ko_mdx_path}' is a generated Confluence folder " + "landing page and cannot be reverse-synced" + ) return page['page_id'] raise ValueError(f"MDX path '{ko_mdx_path}' not found in var/pages.qm.yaml") +def _ensure_reverse_sync_page(page_id: str) -> None: + """Reject generated folder landing pages even when --page-id is explicit.""" + pages_path = _PROJECT_DIR / 'var' / 'pages.qm.yaml' + if not pages_path.exists(): + return + pages = yaml.safe_load(pages_path.read_text()) or [] + for page in pages: + if str(page.get('page_id')) != str(page_id): + continue + if page.get('type', 'page') == 'folder': + raise ValueError( + f"Content ID '{page_id}' is a generated Confluence folder " + "landing page and cannot be reverse-synced" + ) + return + + def _resolve_attachment_dir(page_id: str) -> str: """page_id에서 pages.qm.yaml의 path를 조회하여 attachment-dir를 반환.""" pages = yaml.safe_load((_PROJECT_DIR / 'var' / 'pages.qm.yaml').read_text()) @@ -590,6 +612,10 @@ def _add_common_args(parser: argparse.ArgumentParser): def _do_verify(args, *, config=None, prepare_push: bool = False) -> dict: """CLI 입력을 typed request로 변환하여 prepare lifecycle을 실행합니다.""" + explicit_page_id = getattr(args, "page_id", None) + if explicit_page_id: + _ensure_reverse_sync_page(explicit_page_id) + request = VerificationRequest( improved_mdx=args.improved_mdx, original_mdx=getattr(args, "original_mdx", None), diff --git a/confluence-mdx/tests/test_convert_all_folders.py b/confluence-mdx/tests/test_convert_all_folders.py new file mode 100644 index 000000000..686ec8732 --- /dev/null +++ b/confluence-mdx/tests/test_convert_all_folders.py @@ -0,0 +1,454 @@ +from argparse import Namespace +from pathlib import Path + +import pytest +import yaml + +from convert_all import ( + ConversionError, + convert_all, + finalize_manifest, + generate_folder_mdx, + generate_navigation, +) + + +def _write_yaml(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(data, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + + +def _node(page_id: str, content_type: str, title: str, path: list[str]) -> dict: + return { + "page_id": page_id, + "type": content_type, + "title": title, + "title_orig": title, + "breadcrumbs": [title], + "breadcrumbs_en": [title], + "path": path, + } + + +def _folder_data(folder_id: str, title: str) -> dict: + return { + "id": folder_id, + "type": "folder", + "title": title, + "_links": { + "base": "https://querypie.atlassian.net/wiki", + }, + } + + +def test_folder_mdx_contains_only_direct_supported_children_in_position_order( + tmp_path, + capsys, +): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + folder = _node("folder", "folder", "MCP Server", ["admin", "mcp-server"]) + page_a = _node("page-a", "page", "Page A", ["admin", "mcp-server", "page-a"]) + nested = _node( + "nested", + "folder", + "Nested Folder", + ["admin", "mcp-server", "nested-folder"], + ) + descendant = _node( + "descendant", + "page", + "Descendant", + ["admin", "mcp-server", "nested-folder", "descendant"], + ) + nodes = { + node["page_id"]: node + for node in (folder, page_a, nested, descendant) + } + _write_yaml(var_dir / "folder" / "folder.v2.yaml", _folder_data( + "folder", + "MCP Server", + )) + _write_yaml(var_dir / "folder" / "children.v2.yaml", { + "results": [ + { + "id": "page-a", + "type": "page", + "title": "stale title", + "childPosition": 2, + }, + { + "id": "nested", + "type": "folder", + "title": "stale nested title", + "childPosition": 1, + }, + { + "id": "whiteboard", + "type": "whiteboard", + "title": "Board", + "childPosition": 3, + }, + ], + }) + _write_yaml(var_dir / "nested" / "folder.v2.yaml", _folder_data( + "nested", + "Nested Folder", + )) + _write_yaml(var_dir / "nested" / "children.v2.yaml", { + "results": [{ + "id": "descendant", + "type": "page", + "title": "Descendant", + "childPosition": 1, + }], + }) + + relative_path = generate_folder_mdx( + folder, + nodes, + var_dir, + output_dir, + "https://querypie.atlassian.net/wiki", + ) + + assert relative_path == Path("admin/mcp-server.mdx") + content = (output_dir / relative_path).read_text() + assert "title: 'MCP Server'" in content + assert ( + "confluenceUrl: " + "'https://querypie.atlassian.net/wiki/spaces/QM/folder/folder'" + ) in content + assert "# MCP Server" in content + assert "## 하위 문서" in content + assert content.index("Nested Folder") < content.index("Page A") + assert ( + "- [Nested Folder](./mcp-server/nested-folder)" + in content + ) + assert "- [Page A](./mcp-server/page-a)" in content + assert "Descendant" not in content + assert "Board" not in content + assert "type=whiteboard" in capsys.readouterr().err + + nested_relative_path = generate_folder_mdx( + nested, + nodes, + var_dir, + output_dir, + "https://querypie.atlassian.net/wiki", + ) + nested_content = (output_dir / nested_relative_path).read_text() + assert "- [Descendant](./nested-folder/descendant)" in nested_content + + +def test_empty_folder_mdx_has_empty_state(tmp_path): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + folder = _node("empty", "folder", "Empty", ["empty"]) + _write_yaml(var_dir / "empty" / "folder.v2.yaml", _folder_data("empty", "Empty")) + _write_yaml(var_dir / "empty" / "children.v2.yaml", {"results": []}) + + generate_folder_mdx( + folder, + {"empty": folder}, + var_dir, + output_dir, + "https://querypie.atlassian.net/wiki", + ) + + assert "하위 문서가 없습니다." in (output_dir / "empty.mdx").read_text() + + +def test_navigation_is_generated_after_page_and_folder_mdx_exist(tmp_path): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + root = _node("root", "page", "Root", ["root"]) + parent = _node("parent", "page", "Parent", ["parent"]) + folder = _node("folder", "folder", "Folder", ["parent", "folder"]) + page = _node("page", "page", "Page", ["parent", "page"]) + pages = [root, parent, folder, page] + + _write_yaml(var_dir / "parent" / "children.v2.yaml", { + "results": [ + {"id": "page", "type": "page", "childPosition": 2}, + {"id": "folder", "type": "folder", "childPosition": 1}, + ], + }) + _write_yaml(var_dir / "folder" / "children.v2.yaml", {"results": []}) + _write_yaml(var_dir / "page" / "children.v2.yaml", {"results": []}) + for node in pages[1:]: + path = Path(*node["path"][:-1], f"{node['path'][-1]}.mdx") + (output_dir / path).parent.mkdir(parents=True, exist_ok=True) + (output_dir / path).write_text("# generated\n") + + entries = generate_navigation(pages, var_dir, output_dir) + + meta_path = output_dir / "parent" / "_meta.ts" + content = meta_path.read_text() + assert content.index("'folder': 'Folder'") < content.index("'page': 'Page'") + assert entries == [{ + "page_id": "parent", + "type": "page", + "kind": "navigation", + "path": "parent/_meta.ts", + }] + assert not (output_dir / "root" / "_meta.ts").exists() + + +def test_manifest_removes_only_previous_owned_outputs(tmp_path): + output_dir = tmp_path / "output" + manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + stale = output_dir / "old" / "folder.mdx" + manual = output_dir / "old" / "manual.txt" + current = output_dir / "new" / "folder.mdx" + stale.parent.mkdir(parents=True) + current.parent.mkdir(parents=True) + stale.write_text("old") + manual.write_text("manual") + current.write_text("new") + _write_yaml(manifest_path, { + "version": 1, + "sync_code": "qm", + "outputs": [{ + "page_id": "folder", + "type": "folder", + "kind": "mdx", + "path": "old/folder.mdx", + }], + }) + current_outputs = [{ + "page_id": "folder", + "type": "folder", + "kind": "mdx", + "path": "new/folder.mdx", + }] + + finalize_manifest(manifest_path, "qm", current_outputs, output_dir) + + assert not stale.exists() + assert manual.read_text() == "manual" + assert current.read_text() == "new" + assert yaml.safe_load(manifest_path.read_text())["outputs"] == current_outputs + + +def test_first_manifest_does_not_delete_untracked_existing_mdx(tmp_path): + output_dir = tmp_path / "output" + existing = output_dir / "legacy.mdx" + existing.parent.mkdir(parents=True) + existing.write_text("legacy") + manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + + finalize_manifest(manifest_path, "qm", [], output_dir) + + assert existing.read_text() == "legacy" + assert yaml.safe_load(manifest_path.read_text())["outputs"] == [] + + +def test_manifest_rejects_path_outside_output_root(tmp_path): + output_dir = tmp_path / "output" + output_dir.mkdir() + outside = tmp_path / "outside.mdx" + outside.write_text("keep") + manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + _write_yaml(manifest_path, { + "version": 1, + "sync_code": "qm", + "outputs": [{ + "page_id": "folder", + "type": "folder", + "kind": "mdx", + "path": "../outside.mdx", + }], + }) + + with pytest.raises(ConversionError, match="escapes output root"): + finalize_manifest(manifest_path, "qm", [], output_dir) + + assert outside.read_text() == "keep" + + +def test_manifest_rejects_different_sync_profile(tmp_path): + output_dir = tmp_path / "output" + output_dir.mkdir() + manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + _write_yaml(manifest_path, { + "version": 1, + "sync_code": "qcp", + "outputs": [], + }) + + with pytest.raises(ConversionError, match="sync_code mismatch"): + finalize_manifest(manifest_path, "qm", [], output_dir) + + +def test_conversion_failure_preserves_previous_output_and_manifest(tmp_path): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + public_dir = tmp_path / "public" + manifest_path = var_dir / "convert-manifest.qm.yaml" + previous_output = output_dir / "old.mdx" + previous_output.parent.mkdir(parents=True) + previous_output.write_text("old") + previous_manifest = { + "version": 1, + "sync_code": "qm", + "outputs": [{ + "page_id": "old", + "type": "page", + "kind": "mdx", + "path": "old.mdx", + }], + } + _write_yaml(manifest_path, previous_manifest) + pages = [ + _node("root", "page", "Root", ["root"]), + _node("missing", "page", "Missing", ["missing"]), + ] + + failures = convert_all( + pages, + str(var_dir), + str(output_dir), + str(public_dir), + "warning", + manifest_path=str(manifest_path), + ) + + assert failures == 1 + assert previous_output.read_text() == "old" + assert yaml.safe_load(manifest_path.read_text()) == previous_manifest + + +def test_convert_all_generates_folder_and_manifest(tmp_path): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + public_dir = tmp_path / "public" + manifest_path = var_dir / "convert-manifest.qm.yaml" + root = _node("root", "page", "Root", ["root"]) + folder = _node("folder", "folder", "Folder", ["folder"]) + _write_yaml(var_dir / "folder" / "folder.v2.yaml", _folder_data( + "folder", + "Folder", + )) + _write_yaml(var_dir / "folder" / "children.v2.yaml", {"results": []}) + + failures = convert_all( + [root, folder], + str(var_dir), + str(output_dir), + str(public_dir), + "warning", + manifest_path=str(manifest_path), + sync_code="qm", + ) + + assert failures == 0 + assert (output_dir / "folder.mdx").is_file() + manifest = yaml.safe_load(manifest_path.read_text()) + assert manifest["outputs"] == [{ + "page_id": "folder", + "type": "folder", + "kind": "mdx", + "path": "folder.mdx", + }] + + +def test_convert_all_generates_page_folder_and_central_navigation(tmp_path): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + public_dir = tmp_path / "public" + manifest_path = var_dir / "convert-manifest.qm.yaml" + pages_yaml = var_dir / "pages.qm.yaml" + root = _node("root", "page", "Root", ["root"]) + parent = _node("parent", "page", "Parent", ["parent"]) + folder = _node("folder", "folder", "Folder", ["parent", "folder"]) + pages = [root, parent, folder] + _write_yaml(pages_yaml, pages) + _write_yaml(var_dir / "parent" / "page.v1.yaml", { + "id": "parent", + "type": "page", + "title": "Parent", + "ancestors": [], + "body": {}, + "_links": { + "base": "https://querypie.atlassian.net/wiki", + "webui": "/spaces/QM/pages/parent", + }, + }) + (var_dir / "parent" / "page.xhtml").write_text( + "

Parent body

", + encoding="utf-8", + ) + _write_yaml(var_dir / "parent" / "children.v2.yaml", { + "results": [{ + "id": "folder", + "type": "folder", + "title": "Folder", + "childPosition": 1, + }], + }) + _write_yaml(var_dir / "folder" / "folder.v2.yaml", _folder_data( + "folder", + "Folder", + )) + _write_yaml(var_dir / "folder" / "children.v2.yaml", {"results": []}) + + failures = convert_all( + pages, + str(var_dir), + str(output_dir), + str(public_dir), + "warning", + pages_yaml=str(pages_yaml), + manifest_path=str(manifest_path), + sync_code="qm", + ) + + assert failures == 0 + assert (output_dir / "parent.mdx").is_file() + assert (output_dir / "parent" / "folder.mdx").is_file() + assert "'folder': 'Folder'" in ( + output_dir / "parent" / "_meta.ts" + ).read_text() + output_paths = { + entry["path"] + for entry in yaml.safe_load(manifest_path.read_text())["outputs"] + } + assert output_paths == { + "parent.mdx", + "parent/folder.mdx", + "parent/_meta.ts", + } + + +def test_reverse_sync_rejects_generated_folder_landing_page(tmp_path, monkeypatch): + import reverse_sync_cli + + monkeypatch.setattr(reverse_sync_cli, "_PROJECT_DIR", tmp_path) + _write_yaml(tmp_path / "var" / "pages.qm.yaml", [{ + "page_id": "folder", + "type": "folder", + "path": ["admin", "folder"], + }]) + + with pytest.raises(ValueError, match="cannot be reverse-synced"): + reverse_sync_cli._ensure_reverse_sync_page("folder") + + with pytest.raises(ValueError, match="cannot be reverse-synced"): + reverse_sync_cli._resolve_page_id( + "src/content/ko/admin/folder.mdx" + ) + + with pytest.raises(ValueError, match="cannot be reverse-synced"): + reverse_sync_cli._do_verify(Namespace( + improved_mdx="unused.mdx", + original_mdx=None, + page_id="folder", + page_dir=None, + lenient=False, + no_normalize=False, + )) diff --git a/confluence-mdx/tests/test_fetch_folders.py b/confluence-mdx/tests/test_fetch_folders.py new file mode 100644 index 000000000..0ba3edaa2 --- /dev/null +++ b/confluence-mdx/tests/test_fetch_folders.py @@ -0,0 +1,415 @@ +import logging +from pathlib import Path + +import pytest +import yaml + +from fetch.api_client import ApiClient +from fetch.config import Config +from fetch.exceptions import ApiError +from fetch.file_manager import FileManager +from fetch.processor import ConfluencePageProcessor +from fetch.stages import Stage1Processor + + +def _config(tmp_path: Path, *, mode: str = "local", root_type: str = "page") -> Config: + return Config( + base_url="https://example.atlassian.net/wiki", + default_output_dir=str(tmp_path / "var"), + cache_dir=str(tmp_path / "cache"), + translations_file=str(tmp_path / "translations.txt"), + default_start_page_id="root", + root_content_type=root_type, + mode=mode, + ) + + +def _write_yaml(path: Path, data) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(data, allow_unicode=True, sort_keys=False), + encoding="utf-8", + ) + + +def _page_data(page_id: str, title: str) -> dict: + return { + "id": page_id, + "type": "page", + "title": title, + "ancestors": [], + "body": {}, + } + + +def test_make_request_normalizes_link_header_next_into_response_body( + tmp_path, + monkeypatch, +): + client = ApiClient(_config(tmp_path), logging.getLogger(__name__)) + + class FakeResponse: + links = { + "next": { + "url": ( + "https://example.atlassian.net/wiki/api/v2/pages/root/" + "direct-children?cursor=next" + ), + }, + } + + def raise_for_status(self): + return None + + def json(self): + return {"results": [], "_links": {}} + + monkeypatch.setattr("fetch.api_client.requests.get", lambda *args, **kwargs: FakeResponse()) + + result = client.make_request("https://example.test", "test") + + assert result["_links"]["next"].endswith("cursor=next") + + +def test_direct_children_uses_page_endpoint_and_merges_cursor_pages( + tmp_path, + monkeypatch, +): + client = ApiClient(_config(tmp_path), logging.getLogger(__name__)) + requested_urls = [] + responses = [ + { + "results": [{"id": "a", "type": "page"}], + "_links": {"next": "/wiki/api/v2/pages/root/direct-children?cursor=next"}, + }, + { + "results": [{"id": "b", "type": "folder"}], + "_links": {}, + }, + ] + + def fake_request(url, description): + requested_urls.append((url, description)) + return responses.pop(0) + + monkeypatch.setattr(client, "make_request", fake_request) + + result = client.get_direct_children("root", "page") + + assert [item["id"] for item in result["results"]] == ["a", "b"] + assert "next" not in result["_links"] + assert requested_urls == [ + ( + "https://example.atlassian.net/wiki/api/v2/pages/root/direct-children?limit=100", + "V2 API direct children", + ), + ( + "https://example.atlassian.net/wiki/api/v2/pages/root/direct-children?cursor=next", + "V2 API direct children", + ), + ] + + +def test_direct_children_uses_folder_endpoint(tmp_path, monkeypatch): + client = ApiClient(_config(tmp_path), logging.getLogger(__name__)) + requested_urls = [] + + def fake_request(url, description): + requested_urls.append(url) + return {"results": [], "_links": {}} + + monkeypatch.setattr(client, "make_request", fake_request) + + assert client.get_direct_children("folder-1", "folder")["results"] == [] + assert requested_urls == [ + "https://example.atlassian.net/wiki/api/v2/folders/folder-1/direct-children?limit=100" + ] + + +class _FolderApi: + def __init__(self, *, fail_children: bool = False): + self.fail_children = fail_children + self.direct_children_calls = 0 + + def get_page_data_v2(self, page_id, content_type="page"): + assert content_type == "folder" + return { + "id": page_id, + "type": "folder", + "title": "Folder", + "_links": {"webui": f"/spaces/QM/folder/{page_id}"}, + } + + def get_direct_children(self, page_id, content_type="page"): + self.direct_children_calls += 1 + if self.fail_children: + raise ApiError("pagination failed") + return {"results": [], "_links": {}} + + +def test_stage1_folder_writes_only_folder_metadata_and_children(tmp_path): + config = _config(tmp_path, mode="remote", root_type="folder") + api = _FolderApi() + stage = Stage1Processor( + config, + api, + FileManager(logging.getLogger(__name__)), + logging.getLogger(__name__), + ) + + stage.process("folder-1", "folder") + + folder_dir = Path(config.default_output_dir) / "folder-1" + assert sorted(path.name for path in folder_dir.iterdir()) == [ + "children.v2.yaml", + "folder.v2.yaml", + ] + + +def test_stage1_preserves_previous_children_when_pagination_fails(tmp_path): + config = _config(tmp_path, mode="remote", root_type="folder") + folder_dir = Path(config.default_output_dir) / "folder-1" + previous = {"results": [{"id": "old", "type": "page"}]} + _write_yaml(folder_dir / "children.v2.yaml", previous) + stage = Stage1Processor( + config, + _FolderApi(fail_children=True), + FileManager(logging.getLogger(__name__)), + logging.getLogger(__name__), + ) + + with pytest.raises(ApiError, match="pagination failed"): + stage.process("folder-1", "folder") + + assert yaml.safe_load((folder_dir / "children.v2.yaml").read_text()) == previous + + +class _RecentPageApi: + def __init__(self): + self.direct_children_calls = 0 + + def get_page_data_v1(self, page_id): + return _page_data(page_id, "Page") + + def get_page_data_v2(self, page_id, content_type="page"): + return {"id": page_id, "type": "page", "title": "Page"} + + def get_attachments(self, page_id): + return {"results": []} + + def get_direct_children(self, page_id, content_type="page"): + self.direct_children_calls += 1 + return {"results": []} + + +def test_recent_page_fetch_does_not_refresh_children_snapshot(tmp_path): + config = _config(tmp_path, mode="recent") + api = _RecentPageApi() + page_dir = Path(config.default_output_dir) / "page-1" + previous = {"results": [{"id": "cached-child"}]} + _write_yaml(page_dir / "children.v2.yaml", previous) + stage = Stage1Processor( + config, + api, + FileManager(logging.getLogger(__name__)), + logging.getLogger(__name__), + ) + + stage.process("page-1", "page", include_children=False) + + assert api.direct_children_calls == 0 + assert yaml.safe_load((page_dir / "children.v2.yaml").read_text()) == previous + + +def test_local_mixed_tree_preserves_types_paths_order_and_warns( + tmp_path, + caplog, +): + config = _config(tmp_path, mode="local") + var_dir = Path(config.default_output_dir) + + _write_yaml(var_dir / "root" / "page.v1.yaml", _page_data("root", "Root")) + _write_yaml(var_dir / "root" / "page.v2.yaml", {"id": "root", "title": "Root"}) + _write_yaml(var_dir / "root" / "children.v2.yaml", { + "results": [ + { + "id": "folder", + "type": "folder", + "title": "Folder", + "childPosition": 1, + }, + ], + }) + + _write_yaml(var_dir / "folder" / "folder.v2.yaml", { + "id": "folder", + "type": "folder", + "title": "Folder", + }) + _write_yaml(var_dir / "folder" / "children.v2.yaml", { + "results": [ + { + "id": "page-a", + "type": "page", + "title": "Page A", + "childPosition": 2, + }, + { + "id": "nested", + "type": "folder", + "title": "Nested", + "childPosition": 1, + }, + { + "id": "board", + "type": "whiteboard", + "title": "Board", + "childPosition": 3, + }, + { + "id": "database", + "type": "database", + "title": "Database", + "childPosition": 4, + }, + { + "id": "embed", + "type": "embed", + "title": "Embed", + "childPosition": 5, + }, + ], + }) + + _write_yaml(var_dir / "nested" / "folder.v2.yaml", { + "id": "nested", + "type": "folder", + "title": "Nested", + }) + _write_yaml(var_dir / "nested" / "children.v2.yaml", { + "results": [ + { + "id": "page-b", + "type": "page", + "title": "Page B", + "childPosition": 1, + }, + ], + }) + + for page_id, title in (("page-a", "Page A"), ("page-b", "Page B")): + _write_yaml(var_dir / page_id / "page.v1.yaml", _page_data(page_id, title)) + _write_yaml(var_dir / page_id / "page.v2.yaml", { + "id": page_id, + "type": "page", + "title": title, + }) + _write_yaml(var_dir / page_id / "children.v2.yaml", {"results": []}) + + processor = ConfluencePageProcessor(config, logging.getLogger(__name__)) + with caplog.at_level(logging.WARNING): + nodes = list(processor.fetch_page_tree_recursive( + "root", + "root", + use_local=True, + content_type="page", + )) + + assert [node.page_id for node in nodes] == [ + "root", + "folder", + "nested", + "page-b", + "page-a", + ] + assert [node.content_type for node in nodes] == [ + "page", + "folder", + "folder", + "page", + "page", + ] + assert nodes[1].breadcrumbs == ["Folder"] + assert nodes[2].breadcrumbs == ["Folder", "Nested"] + assert nodes[3].path == ["folder", "nested", "page-b"] + assert "type=whiteboard" in caplog.text + assert "type=database" in caplog.text + assert "type=embed" in caplog.text + + +class _RemoteTreeApi: + def __init__(self): + self.v2_calls = [] + self.children_calls = [] + + def get_page_data_v1(self, page_id): + return _page_data(page_id, {"root": "Root", "page": "Page"}[page_id]) + + def get_page_data_v2(self, page_id, content_type="page"): + self.v2_calls.append((page_id, content_type)) + if content_type == "folder": + return { + "id": page_id, + "type": "folder", + "title": "Folder", + "_links": {"webui": f"/spaces/QM/folder/{page_id}"}, + } + return { + "id": page_id, + "type": "page", + "title": {"root": "Root", "page": "Page"}[page_id], + } + + def get_attachments(self, page_id): + return {"results": []} + + def get_direct_children(self, page_id, content_type="page"): + self.children_calls.append((page_id, content_type)) + if page_id == "root": + return {"results": [{ + "id": "folder", + "type": "folder", + "title": "Folder", + "childPosition": 1, + }]} + if page_id == "folder": + return {"results": [{ + "id": "page", + "type": "page", + "title": "Page", + "childPosition": 1, + }]} + return {"results": []} + + +def test_remote_tree_routes_new_non_root_folder_to_folder_api(tmp_path): + config = _config(tmp_path, mode="remote") + processor = ConfluencePageProcessor(config, logging.getLogger(__name__)) + api = _RemoteTreeApi() + processor.api_client = api + for stage in ( + processor.stage1, + processor.stage2, + processor.stage3, + processor.stage4, + ): + stage.api_client = api + + nodes = list(processor.fetch_page_tree_recursive( + "root", + "root", + use_local=False, + content_type="page", + )) + + assert [node.page_id for node in nodes] == ["root", "folder", "page"] + assert ("folder", "folder") in api.v2_calls + assert api.children_calls == [ + ("root", "page"), + ("folder", "folder"), + ("page", "page"), + ] + folder_dir = Path(config.default_output_dir) / "folder" + assert (folder_dir / "folder.v2.yaml").is_file() + assert (folder_dir / "children.v2.yaml").is_file() + assert not (folder_dir / "page.v1.yaml").exists() diff --git a/openspec/changes/confluence-folder-mdx/design.md b/openspec/changes/confluence-folder-mdx/design.md new file mode 100644 index 000000000..c675f5513 --- /dev/null +++ b/openspec/changes/confluence-folder-mdx/design.md @@ -0,0 +1,233 @@ +## Context + +현재 fetch pipeline은 다음 결합 때문에 page 아래 folder를 완전하게 처리하지 못합니다. + +- `ApiClient.get_child_pages()`가 page-only `/children` endpoint를 사용합니다. +- 재귀 호출이 child의 `id`만 전달하여 `type`을 잃습니다. +- cache가 없는 non-root content는 항상 `page`로 간주합니다. +- `Stage4Processor`가 `page.v1.yaml`의 ancestor와 title을 요구합니다. +- `convert_all.py`는 `page.xhtml`이 있는 항목만 변환합니다. +- `_meta.ts` 생성이 개별 XHTML 변환의 side effect입니다. + +Folder는 Confluence 본문을 갖지 않지만, 문서 사이트에서는 이동 가능한 landing page이자 하위 문서의 경로·순서를 결정하는 정식 content node입니다. + +## Goals / Non-Goals + +### Goals + +- page 아래와 folder 아래의 folder를 동일한 규칙으로 발견하고 저장합니다. +- folder와 직계 자식 API 응답을 `var/{folder_id}/`에 보존합니다. +- folder 자체에 MDX landing page를 생성합니다. +- folder MDX에는 직계 자식 `page`와 `folder`만 Confluence 순서대로 표시합니다. +- nested folder는 현재 목록에서 link 하나로만 표현하고, nested folder의 자식은 해당 folder MDX에서 표시합니다. +- folder 이동·이름 변경·삭제 후 이전 생성 파일을 안전하게 정리합니다. +- 기존 page 변환과 QM/QCP sync profile을 회귀시키지 않습니다. + +### Non-Goals + +- `database`, `whiteboard`, `embed`를 MDX로 변환하거나 navigation에 노출하는 기능 +- `--recent`에서 folder 생성·이동·이름 변경을 즉시 감지하는 기능 +- folder MDX의 수동 편집 보존 +- folder MDX의 reverse sync +- 기존 attachment lifecycle 전체를 재설계하는 작업 + +## Decisions + +### Decision: `page`와 `folder`를 typed content node로 순회합니다 + +재귀 순회의 입력을 page ID 문자열에서 최소 다음 정보를 가진 child reference로 변경합니다. + +```text +ContentRef + id + type + title + childPosition +``` + +내부 모델은 `ContentNode`로 일반화하되, 기존 catalog consumer와의 호환성을 위해 serialized identity key는 당분간 `page_id`를 유지하고 `type: page|folder`를 추가합니다. + +```yaml +- page_id: "2167636017" + type: folder + title: MCP Server + title_orig: MCP Server + breadcrumbs: + - 관리자 매뉴얼 + - MCP Server + breadcrumbs_en: + - Administrator Manual + - MCP Server + path: + - administrator-manual + - mcp-server +``` + +부모가 `page`이면 `GET /api/v2/pages/{id}/direct-children`, 부모가 `folder`이면 `GET /api/v2/folders/{id}/direct-children`을 사용합니다. 두 endpoint 모두 cursor pagination을 끝까지 따라가고, 결과를 `childPosition`으로 안정 정렬합니다. + +`page`와 `folder` 외 child는 재귀 순회와 catalog에서 제외합니다. 경고에는 최소 `parent_id`, `id`, `type`, `title`을 남겨 누락이 의도된 범위 제외임을 확인할 수 있게 합니다. + +#### 고려한 대안 + +1. `descendants` API 한 번으로 tree를 구성하는 방식 + - 전체 발견에는 효율적이지만, folder별 직계 자식 snapshot 저장 요구를 만족하려면 결국 각 folder의 `direct-children` 요청이 추가됩니다. + - discovery 결과와 저장된 직계 자식 결과 사이의 불일치 처리도 필요하므로 이번 변경에서는 선택하지 않습니다. +2. 현재 page 중심 stage에 folder 조건문만 추가하는 방식 + - `Stage2`, `Stage3`, `Stage4`, converter마다 예외가 반복되고 새로운 content type 추가 시 분기가 확산되므로 선택하지 않습니다. +3. typed `direct-children` 재귀 순회 + - 현재 구조에서 가장 작은 변경으로 API routing, ordering, local replay를 같은 snapshot에 맞출 수 있어 선택합니다. + +### Decision: raw 저장 형식은 content type별로 구분합니다 + +Page는 기존 파일을 유지합니다. + +```text +var/{page_id}/ +├── page.v1.yaml +├── page.v2.yaml +├── children.v2.yaml +├── attachments.v1.yaml +└── page.xhtml +``` + +Folder는 다음 파일만 생성합니다. + +```text +var/{folder_id}/ +├── folder.v2.yaml +└── children.v2.yaml +``` + +- `folder.v2.yaml`: `GET /api/v2/folders/{id}` metadata 응답 +- `children.v2.yaml`: 모든 cursor page의 `results`를 합친 직계 자식 snapshot + +Folder에는 `page.v1.yaml`, `page.v2.yaml`, `page.xhtml`, `page.html`, `page.adf`, `attachments.v1.yaml`, attachment binary를 만들지 않습니다. 기존에 잘못 생성된 page 전용 파일이 있더라도 folder 처리의 입력으로 사용하지 않습니다. + +Pagination을 합친 `children.v2.yaml`은 기존 consumer가 읽는 `results` shape을 유지합니다. 전체 수집이 실패하면 이전 snapshot을 부분 결과로 덮어쓰지 않고 해당 node의 remote fetch를 실패로 처리합니다. + +### Decision: breadcrumb와 path는 tree traversal context에서 계산합니다 + +Folder는 V1 ancestor 응답이 없으므로 `Stage4Processor`가 `page.v1.yaml`만 읽는 현재 방식으로는 catalog entry를 만들 수 없습니다. 순회 함수가 부모의 `breadcrumbs`를 child에게 전달하고, page/folder metadata의 title을 붙여 현재 tree snapshot 기준 breadcrumb를 계산합니다. + +- page title: `page.v1.yaml`의 정제된 title을 우선하고 V2 metadata를 fallback으로 사용합니다. +- folder title: `folder.v2.yaml`의 정제된 title을 사용합니다. +- path: 기존 title translation과 `slugify` 규칙을 page와 folder에 동일하게 적용합니다. +- child display title과 link path: `children.v2.yaml`에 복제된 title/path가 아니라 최신 catalog entry를 사용합니다. +- child order: 부모의 `children.v2.yaml`에 저장된 `childPosition`을 사용합니다. + +이 분리는 `--recent`가 기존 page title/body를 갱신했을 때 folder의 cached child snapshot을 다시 받지 않아도 landing page의 label과 link가 최신 catalog를 사용하게 합니다. + +### Decision: 계층 구조는 `--remote`에서만 갱신합니다 + +실행 모드별 책임은 다음과 같습니다. + +| Mode | API 호출 | 계층 snapshot | catalog | +| --- | --- | --- | --- | +| `--remote` | page/folder metadata와 `direct-children`, 필요 시 page body/attachment | 전체 갱신 | 새 snapshot으로 재구성 | +| `--recent` | CQL로 발견한 기존 page의 metadata/body/attachment | 갱신하지 않음 | 저장된 `children.v2.yaml`을 따라 재구성 | +| `--local` | 없음 | 갱신하지 않음 | 저장된 metadata와 `children.v2.yaml`만으로 재구성 | + +`--recent`의 page fetch는 `children.v2.yaml`을 덮어쓰지 않습니다. 부분적으로만 새 계층이 섞이면 metadata가 없는 folder를 발견하거나 일부 이동만 반영하는 불완전한 catalog가 만들어질 수 있기 때문입니다. + +운영자는 folder 생성·이동·이름 변경·삭제를 반영해야 할 때 `--remote`를 실행합니다. 이 eventual consistency는 승인된 동작입니다. + +### Decision: folder MDX는 별도 deterministic generator가 만듭니다 + +`convert_all.py`는 catalog node의 `type`에 따라 변환기를 선택합니다. + +- `page`: 기존 XHTML converter를 실행합니다. +- `folder`: folder MDX generator를 실행합니다. + +Folder generator는 XHTML converter나 `mapping.yaml` 생성기를 호출하지 않습니다. Folder MDX는 변환기가 전부 소유하며 매 실행마다 완전히 덮어씁니다. + +예상 출력은 다음과 같습니다. + +```mdx +--- +title: 'MCP Server' +confluenceUrl: 'https://querypie.atlassian.net/wiki/spaces/QM/folder/2167636017' +--- + +# MCP Server + +## 하위 문서 + +- [MAC General Configurations](./mcp-server/mac-general-configurations) +- [MCP Server Connection Management](./mcp-server/mcp-server-connection-management) +- [MCP Access Control](./mcp-server/mcp-access-control) +``` + +Link는 현재 folder MDX 파일에서 child MDX 파일까지의 상대 filesystem path를 계산한 뒤 `.mdx` suffix를 제거하고 POSIX separator로 기록합니다. 이를 통해 깊이가 다른 folder와 nested folder도 같은 알고리즘을 사용합니다. + +Nested folder가 직계 자식이면 해당 folder landing page link만 한 줄로 표시합니다. Nested folder의 child는 현재 MDX에 펼치지 않습니다. + +지원되는 직계 자식이 없는 folder도 MDX를 생성합니다. + +```mdx +## 하위 문서 + +하위 문서가 없습니다. +``` + +`confluenceUrl`은 `folder.v2.yaml`의 `_links.base`, sync profile의 `space_key`, folder ID를 사용해 `{base}/spaces/{space_key}/folder/{id}` 형식으로 생성합니다. API가 `_links.webui`를 제공하면 해당 값을 우선 사용할 수 있지만, `GET /folders/{id}`의 응답 계약은 `_links.webui`를 보장하지 않으므로 필수 입력으로 간주하지 않습니다. `_links.base`가 없으면 `convert_all.py --base-url` 값을 사용합니다. + +### Decision: navigation 생성은 content conversion과 분리합니다 + +현재 `converter/cli.py`의 `generate_meta_from_children()` side effect를 제거하고, `convert_all.py`의 catalog-level navigation pass로 이동합니다. + +Navigation pass는 각 parent node의 `children.v2.yaml`과 catalog를 사용하여 다음을 생성합니다. + +```text +administrator-manual/_meta.ts + mcp-server: MCP Server + +administrator-manual/mcp-server/_meta.ts + mac-general-configurations: MAC General Configurations + mcp-server-connection-management: MCP Server Connection Management + mcp-access-control: MCP Access Control +``` + +목록과 마찬가지로 지원되는 직계 자식만 포함하고 `childPosition` 순서를 유지합니다. Child의 MDX가 현재 conversion plan에 없거나 생성에 실패한 경우 해당 navigation entry를 만들지 않고 전체 conversion을 실패로 보고합니다. + +### Decision: sync profile별 manifest로 stale output을 정리합니다 + +`convert_all.py`는 sync profile별 manifest에 자신이 생성한 MDX와 `_meta.ts`를 기록합니다. Manifest는 `var/convert-manifest..yaml`에 저장하며 최소 `page_id`, `type`, output 상대 경로를 보존합니다. + +정리 순서는 다음과 같습니다. + +1. 이전 manifest를 읽습니다. +2. 현재 catalog의 page/folder MDX와 navigation을 모두 생성하고 검증합니다. +3. 하나라도 실패하면 이전 파일 삭제와 manifest 교체를 수행하지 않습니다. +4. 모두 성공하면 `previous_paths - current_paths`만 삭제합니다. +5. 빈 directory만 아래에서 위로 제거하고, manifest 밖의 파일이나 비어 있지 않은 directory는 보존합니다. +6. 현재 manifest를 atomic replace합니다. + +모든 삭제 대상은 resolve 후 configured output root 내부인지 검사합니다. Manifest가 가리키더라도 output root 밖의 경로, 허용하지 않은 suffix, 예상하지 않은 `_meta.ts` 위치는 삭제하지 않고 오류로 처리합니다. + +Folder 이동·이름 변경 시 folder landing MDX뿐 아니라 경로가 바뀐 descendant page/folder MDX와 generated `_meta.ts`도 같은 방식으로 정리됩니다. Attachment cleanup은 이번 변경 범위에 포함하지 않습니다. + +### Decision: root node는 catalog에 남기되 기존 출력 정책을 유지합니다 + +QM의 page root와 QCP의 folder root 모두 typed node로 수집합니다. Root는 breadcrumb/path 계산의 기준이며 catalog에 포함하지만, 기존과 같이 sync root 자체의 MDX는 생성하지 않습니다. Root의 직계 자식 navigation 생성 여부는 현재 site root 정책을 유지하고 회귀 테스트로 고정합니다. + +## Risks / Trade-offs + +- `--remote`는 각 parent의 `direct-children`을 호출하므로 전체 동기화 시간이 유지되거나 늘어날 수 있습니다. 정확한 raw snapshot과 단순한 local replay를 우선합니다. +- `--recent` 직후에는 Confluence 계층과 로컬 출력이 일시적으로 다를 수 있습니다. 이는 승인된 eventual consistency이며 로그와 README에 명시합니다. +- 중앙 navigation pass로 이동하면 기존 converter 단독 실행에서 `_meta.ts`가 생성되지 않습니다. 단일 XHTML 변환과 전체 site navigation 생성의 책임을 분리하고, README와 테스트 명령을 갱신해야 합니다. +- manifest 도입 전 생성된 stale 파일은 소유권을 증명할 수 없어 최초 실행에서 자동 삭제하지 않습니다. 첫 성공 실행이 baseline manifest를 만든 뒤부터 안전한 정리가 가능합니다. +- folder MDX는 reverse sync 대상이 아닙니다. Folder에 `mapping.yaml`이 없고 Confluence body가 없다는 점을 명확한 진단으로 표시해야 합니다. + +## Migration Plan + +1. typed model과 API client pagination을 추가합니다. +2. `--remote`로 전체 QM/QCP tree를 다시 받아 folder raw snapshot과 typed catalog를 생성합니다. +3. folder generator와 중앙 navigation pass를 추가합니다. +4. 최초 `convert_all.py` 성공 시 manifest baseline을 기록합니다. 이 실행에서는 기존 manifest가 없으므로 stale output을 삭제하지 않습니다. +5. 두 번째 fixture run에서 folder 이동·이름 변경·삭제를 재현하여 stale output 정리를 검증합니다. +6. README에 mode별 hierarchy freshness와 folder 저장/출력 형식을 기록합니다. +7. 구현과 검증이 완료되면 change-local spec을 accepted `contract-confluence-mdx-conversion` spec으로 승격합니다. + +## Open Questions + +승인된 요구사항 기준으로 구현을 막는 미해결 질문은 없습니다. diff --git a/openspec/changes/confluence-folder-mdx/proposal.md b/openspec/changes/confluence-folder-mdx/proposal.md new file mode 100644 index 000000000..78ab8a4b9 --- /dev/null +++ b/openspec/changes/confluence-folder-mdx/proposal.md @@ -0,0 +1,38 @@ +## Why + +현재 Confluence 수집기는 page 전용 child API와 `page.xhtml` 중심 변환 흐름을 사용합니다. 이 때문에 page 아래에 있는 folder를 발견하지 못하고, folder 하위 page가 `var/pages..yaml`과 MDX 출력에서 함께 누락됩니다. + +Confluence folder를 본문 없는 예외로만 취급하면 계층 탐색, navigation, landing page, 이동·이름 변경 후 정리 동작이 서로 달라집니다. Folder를 page와 함께 content tree의 정식 노드로 저장하고 변환하는 계약이 필요합니다. + +## What Changes + +- `page`와 `folder`를 구분하는 typed content tree를 도입합니다. +- `--remote`가 page와 folder의 `direct-children` API를 끝까지 순회하여 전체 계층을 갱신합니다. +- folder API 응답을 `var/{folder_id}/folder.v2.yaml`과 `children.v2.yaml`에 저장합니다. +- `pages..yaml`에 각 노드의 `type`과 계층·출력 경로를 기록합니다. +- `convert_all.py`가 folder용 MDX landing page를 생성합니다. +- folder MDX에는 Confluence 순서의 직계 자식 `page`와 `folder`만 표시합니다. +- 생성된 MDX와 navigation의 소유권을 manifest로 기록하고, 이동·이름 변경·삭제로 더 이상 유효하지 않은 생성 파일을 안전하게 제거합니다. +- `--recent`는 저장된 계층을 사용하여 기존 page의 내용만 갱신하고, 계층 변화는 다음 `--remote` 실행에서 반영합니다. + +## Capabilities + +### New Capabilities + +- Confluence folder metadata와 직계 자식 관계의 로컬 snapshot +- 직계 자식 목록을 제공하는 folder MDX landing page +- sync profile별 생성 파일 manifest와 stale output 정리 + +### Modified Capabilities + +- Confluence child traversal을 page-only 순회에서 typed `page`/`folder` 순회로 변경합니다. +- catalog와 navigation 생성을 XHTML 존재 여부와 분리합니다. +- `--remote`, `--recent`, `--local`의 계층 갱신 책임을 명확히 구분합니다. + +## Impact + +- 주요 구현 surface: `confluence-mdx/bin/fetch/**`, `confluence-mdx/bin/convert_all.py`, `confluence-mdx/bin/converter/cli.py` +- 저장 형식: `confluence-mdx/var/{content_id}/**`, `confluence-mdx/var/pages..yaml`, sync profile별 conversion manifest +- 출력 형식: `src/content/ko/**`를 가리키는 `confluence-mdx/target/ko/**`의 MDX와 `_meta.ts` +- 테스트 surface: API endpoint/pagination, mixed content tree, 실행 모드, folder MDX, navigation, stale output cleanup +- 호환성: 기존 page 변환과 folder가 sync root인 QCP profile을 유지해야 합니다. diff --git a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md new file mode 100644 index 000000000..35e2cfe79 --- /dev/null +++ b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md @@ -0,0 +1,199 @@ +# contract-confluence-mdx-conversion + +## Purpose + +Confluence의 `page`와 `folder` 계층을 손실 없이 저장하고, QueryPie 문서 사이트의 MDX와 navigation으로 결정적으로 변환하는 계약을 정의합니다. + +## References + +- GitHub issue #1028 +- Atlassian REST API v2 `Children`, `Folder` +- `confluence-mdx/bin/fetch_cli.py` +- `confluence-mdx/bin/convert_all.py` + +## ADDED Requirements + +### Requirement: Typed content tree + +Confluence fetcher는 지원되는 모든 content node를 `page` 또는 `folder` type과 함께 보존하고 순회해야 합니다(SHALL). + +#### Scenario: page 아래 folder 발견 + +- GIVEN page의 `direct-children` 응답에 `type: folder`인 child가 있습니다. +- WHEN `fetch_cli.py --remote`를 실행합니다. +- THEN fetcher는 child ID와 `folder` type을 다음 재귀 호출까지 보존해야 합니다(SHALL). +- AND folder endpoint로 metadata와 직계 자식을 요청해야 합니다(SHALL). + +#### Scenario: 지원하지 않는 child type + +- GIVEN 직계 자식에 `database`, `whiteboard`, `embed` 중 하나가 있습니다. +- WHEN content tree를 순회합니다. +- THEN 해당 child를 catalog, MDX, navigation에서 제외해야 합니다(SHALL). +- AND parent ID, child ID, type, title을 식별할 수 있는 경고를 기록해야 합니다(SHALL). + +### Requirement: Direct children API and pagination + +Fetcher는 parent type에 맞는 V2 `direct-children` endpoint를 사용하고 모든 cursor page를 수집해야 합니다(SHALL). + +#### Scenario: page의 직계 자식 + +- GIVEN parent type이 `page`입니다. +- WHEN 직계 자식을 원격 수집합니다. +- THEN `/api/v2/pages/{id}/direct-children`을 호출해야 합니다(SHALL). + +#### Scenario: folder의 직계 자식 + +- GIVEN parent type이 `folder`입니다. +- WHEN 직계 자식을 원격 수집합니다. +- THEN `/api/v2/folders/{id}/direct-children`을 호출해야 합니다(SHALL). + +#### Scenario: cursor가 있는 응답 + +- GIVEN `direct-children` 응답에 다음 cursor가 있습니다. +- WHEN 직계 자식 snapshot을 저장합니다. +- THEN 다음 cursor가 없어질 때까지 요청해야 합니다(SHALL). +- AND 모든 `results`를 빠짐없이 `children.v2.yaml`에 저장해야 합니다(SHALL). +- AND 중간 요청이 실패하면 부분 snapshot으로 이전 파일을 덮어쓰지 않아야 합니다(SHALL NOT). + +### Requirement: Folder raw storage + +Fetcher는 folder API 결과를 해당 content ID 디렉터리에 type별 파일로 저장해야 합니다(SHALL). + +#### Scenario: folder 저장 + +- GIVEN folder ID가 `2167636017`입니다. +- WHEN folder를 원격 수집합니다. +- THEN metadata를 `var/2167636017/folder.v2.yaml`에 저장해야 합니다(SHALL). +- AND pagination을 합친 직계 자식을 `var/2167636017/children.v2.yaml`에 저장해야 합니다(SHALL). +- AND folder용 `page.v1.yaml`, `page.v2.yaml`, `page.xhtml`, attachment artifact를 새로 만들지 않아야 합니다(SHALL NOT). + +### Requirement: Typed catalog and paths + +`pages..yaml`은 각 지원 node의 `page_id`, `type`, title, breadcrumb, path를 기록해야 합니다(SHALL). + +#### Scenario: folder가 포함된 path + +- GIVEN `관리자 매뉴얼` page 아래 `MCP Server` folder와 그 아래 page가 있습니다. +- WHEN catalog를 생성합니다. +- THEN folder entry에 `type: folder`가 있어야 합니다(SHALL). +- AND 하위 page의 path에 folder의 `mcp-server` segment가 있어야 합니다(SHALL). + +#### Scenario: local replay + +- GIVEN page/folder metadata와 `children.v2.yaml`이 `var/`에 저장되어 있습니다. +- WHEN `fetch_cli.py --local`을 실행합니다. +- THEN API 호출 없이 같은 typed catalog와 ordering을 재구성해야 합니다(SHALL). + +### Requirement: Hierarchy freshness by mode + +Fetcher는 hierarchy snapshot을 `--remote`에서 갱신하고 `--recent`와 `--local`에서는 저장된 snapshot을 사용해야 합니다(SHALL). + +#### Scenario: remote hierarchy refresh + +- GIVEN Confluence에서 folder가 생성, 이동, 이름 변경 또는 삭제되었습니다. +- WHEN `fetch_cli.py --remote`를 실행합니다. +- THEN 전체 지원 tree와 catalog가 현재 hierarchy를 반영해야 합니다(SHALL). + +#### Scenario: recent content refresh + +- GIVEN 마지막 `--remote` 이후 hierarchy가 변경되었습니다. +- WHEN `fetch_cli.py --recent`를 실행합니다. +- THEN 기존 page metadata/body/attachment를 갱신할 수 있습니다(MAY). +- AND 저장된 `children.v2.yaml` hierarchy를 갱신하지 않아야 합니다(SHALL NOT). +- AND hierarchy 변경은 다음 `--remote` 전까지 반영되지 않을 수 있습니다(MAY). + +### Requirement: Folder MDX landing page + +Converter는 root가 아닌 모든 catalog `folder`에 deterministic MDX landing page를 생성해야 합니다(SHALL). + +#### Scenario: 직계 자식 목록 + +- GIVEN folder에 직계 자식 `page`와 `folder`가 있습니다. +- WHEN `convert_all.py`를 실행합니다. +- THEN folder MDX에 `title`과 `confluenceUrl` frontmatter를 기록해야 합니다(SHALL). +- AND `confluenceUrl`은 API `_links.base`, sync profile의 `space_key`, folder ID로 생성해야 합니다(SHALL). +- AND 동일한 title의 H1과 `## 하위 문서` heading을 기록해야 합니다(SHALL). +- AND 지원되는 직계 자식을 `childPosition` 순서의 link 목록으로 기록해야 합니다(SHALL). +- AND link label과 target path는 catalog에서 해석해야 합니다(SHALL). + +#### Scenario: folder metadata에 web UI link가 없음 + +- GIVEN `folder.v2.yaml`의 `_links`에 `base`만 있고 `webui`가 없습니다. +- WHEN folder MDX를 생성합니다. +- THEN `{base}/spaces/{space_key}/folder/{id}` 형식의 `confluenceUrl`을 생성해야 합니다(SHALL). + +#### Scenario: nested folder + +- GIVEN folder의 직계 자식이 다른 folder이고 그 아래 descendant page가 있습니다. +- WHEN 부모 folder MDX를 생성합니다. +- THEN nested folder landing page link를 직계 자식 한 항목으로 표시해야 합니다(SHALL). +- AND descendant page를 부모 folder 목록에 펼치지 않아야 합니다(SHALL NOT). +- AND descendant page는 nested folder MDX의 직계 자식 목록에 표시해야 합니다(SHALL). + +#### Scenario: 빈 folder + +- GIVEN 지원되는 직계 자식이 없는 folder입니다. +- WHEN folder MDX를 생성합니다. +- THEN MDX 파일을 생성해야 합니다(SHALL). +- AND `## 하위 문서` 아래에 `하위 문서가 없습니다.`를 표시해야 합니다(SHALL). + +#### Scenario: 재변환 + +- GIVEN 기존 folder MDX에 수동 편집이 있습니다. +- WHEN `convert_all.py`를 다시 실행합니다. +- THEN frontmatter, 제목, 직계 자식 목록을 전부 재생성하여 기존 내용을 덮어써야 합니다(SHALL). + +### Requirement: Navigation generation + +Converter는 page XHTML 변환 여부와 독립적으로 typed catalog와 직계 자식 snapshot에서 navigation을 생성해야 합니다(SHALL). + +#### Scenario: folder navigation + +- GIVEN parent page 아래 folder와 folder 아래 page가 있습니다. +- WHEN 전체 변환이 성공합니다. +- THEN parent `_meta.ts`에 folder를 기록해야 합니다(SHALL). +- AND folder directory의 `_meta.ts`에 직계 자식 page/folder를 `childPosition` 순서로 기록해야 합니다(SHALL). + +### Requirement: Generated output lifecycle + +Converter는 sync profile별 manifest로 자신이 만든 MDX와 navigation을 추적하고 stale output만 안전하게 제거해야 합니다(SHALL). + +#### Scenario: folder 이동 또는 이름 변경 + +- GIVEN 이전 성공 변환의 manifest가 있습니다. +- AND `--remote` 결과에서 folder 또는 descendant의 output path가 달라졌습니다. +- WHEN 새 catalog의 전체 변환이 성공합니다. +- THEN 새 경로에 output을 생성해야 합니다(SHALL). +- AND 이전 manifest에는 있지만 현재 manifest에는 없는 생성 파일을 삭제해야 합니다(SHALL). +- AND manifest에 없는 파일을 삭제하지 않아야 합니다(SHALL NOT). + +#### Scenario: conversion failure + +- GIVEN 이전 성공 변환의 manifest가 있습니다. +- WHEN 현재 전체 변환 중 하나 이상의 output 생성이 실패합니다. +- THEN 이전 output을 stale file로 삭제하지 않아야 합니다(SHALL NOT). +- AND 이전 manifest를 교체하지 않아야 합니다(SHALL NOT). + +#### Scenario: unsafe manifest path + +- GIVEN manifest entry가 configured output root 밖을 가리키거나 허용되지 않은 파일을 가리킵니다. +- WHEN stale cleanup을 실행합니다. +- THEN 해당 경로를 삭제하지 않아야 합니다(SHALL NOT). +- AND conversion을 오류로 종료해야 합니다(SHALL). + +### Requirement: Existing profile compatibility + +Typed folder 지원은 기존 page 변환과 folder root sync profile을 유지해야 합니다(SHALL). + +#### Scenario: page root profile + +- GIVEN QM처럼 sync root type이 `page`입니다. +- WHEN remote fetch와 conversion을 실행합니다. +- THEN 기존 page body, attachment, MDX path를 유지해야 합니다(SHALL). + +#### Scenario: folder root profile + +- GIVEN QCP처럼 sync root type이 `folder`입니다. +- WHEN remote fetch와 conversion을 실행합니다. +- THEN root부터 typed tree를 수집해야 합니다(SHALL). +- AND 기존 정책에 따라 sync root 자체의 MDX는 생성하지 않아야 합니다(SHALL NOT). diff --git a/openspec/changes/confluence-folder-mdx/tasks.md b/openspec/changes/confluence-folder-mdx/tasks.md new file mode 100644 index 000000000..51c80248e --- /dev/null +++ b/openspec/changes/confluence-folder-mdx/tasks.md @@ -0,0 +1,66 @@ +## 1. Contract + +- [x] 1.1 `proposal.md`, `design.md`, change-local `contract-confluence-mdx-conversion` spec을 reviewer와 확정합니다. +- [x] 1.2 `confluence-mdx/README.md`에 folder raw 저장 형식과 `--remote`/`--recent` hierarchy freshness 계약을 반영합니다. +- [x] 1.3 issue #1028의 “folder MDX 미생성”과 “`--recent` 즉시 hierarchy reconcile” 요구를 승인된 계약으로 교체합니다. + +## 2. Implementation + +- [x] 2.1 `bin/fetch/api_client.py`에 page/folder `direct-children` endpoint와 cursor pagination을 구현합니다. +- [x] 2.2 `bin/fetch/models.py`의 내부 모델을 typed `ContentNode`로 일반화하고 catalog에 `type`을 직렬화합니다. +- [x] 2.3 `bin/fetch/processor.py`의 재귀 입력이 `{id, type, title, childPosition}`을 보존하도록 변경합니다. +- [x] 2.4 `bin/fetch/stages.py`에서 folder는 `folder.v2.yaml`과 `children.v2.yaml`만 저장하고 page-only API/body/attachment stage를 실행하지 않도록 분리합니다. +- [x] 2.5 breadcrumb와 path를 parent traversal context에서 계산하여 folder가 V1 ancestor 없이 catalog에 포함되도록 변경합니다. +- [x] 2.6 `--recent`가 `children.v2.yaml`을 갱신하지 않고 저장된 hierarchy만 재사용하도록 page content fetch operation을 분리합니다. +- [x] 2.7 `bin/convert_all.py`에 deterministic folder MDX generator를 추가합니다. +- [x] 2.8 `_meta.ts` 생성을 `bin/converter/cli.py`의 XHTML side effect에서 catalog-level navigation pass로 이동합니다. +- [x] 2.9 `var/convert-manifest..yaml`의 atomic update와 stale generated output 안전 삭제를 구현합니다. +- [x] 2.10 folder MDX가 reverse sync 대상이 아닐 때 명확한 오류를 반환하도록 관련 entry point를 확인하고 필요한 guard를 추가합니다. + +## 3. Verification + +- [x] 3.1 API client 단위 테스트에서 page/folder endpoint와 cursor 2개 이상의 pagination merge를 검증합니다. +- [x] 3.2 mixed tree fixture `page → folder → (page, nested folder → page)`로 type 보존, breadcrumb, path, ordering을 검증합니다. +- [x] 3.3 `database`, `whiteboard`, `embed`가 catalog/MDX/navigation에서 제외되고 식별 가능한 경고가 남는지 검증합니다. +- [x] 3.4 folder raw fixture에서 `folder.v2.yaml`, `children.v2.yaml`만 생성되고 page-only artifact가 생성되지 않는지 검증합니다. +- [x] 3.5 `--remote`가 hierarchy를 갱신하고 `--recent`/`--local`이 cached hierarchy를 유지하는 mode 테스트를 추가합니다. +- [x] 3.6 folder MDX의 frontmatter, H1, `## 하위 문서`, direct-child 상대 link, `childPosition` 순서를 golden test로 검증합니다. +- [x] 3.7 nested folder의 descendant가 부모 folder MDX에 펼쳐지지 않고 nested folder MDX에만 나타나는지 검증합니다. +- [x] 3.8 빈 folder가 `하위 문서가 없습니다.`를 포함한 MDX를 생성하는지 검증합니다. +- [x] 3.9 page와 folder가 섞인 `_meta.ts`가 MDX 존재 여부와 순서를 정확히 반영하는지 검증합니다. +- [x] 3.10 folder 이동·이름 변경·삭제 fixture에서 이전 manifest 소유 파일만 삭제되고 비소유 파일은 보존되는지 검증합니다. +- [x] 3.11 conversion 중간 실패 시 stale file 삭제와 manifest 교체가 일어나지 않는지 검증합니다. +- [ ] 3.12 QM 대상 folder `2167636017`과 QCP folder root profile의 smoke 결과를 확인합니다. +- [x] 3.13 focused Python test, `git diff --check`, 관련 `rg` source scan을 실행합니다. + +권장 focused 명령: + +```bash +cd confluence-mdx +source venv/bin/activate +pytest -q tests/test_fetch_folders.py tests/test_convert_all_folders.py +bin/fetch_cli.py --local --sync-code qm +bin/convert_all.py --sync-code qm +git diff --check +``` + +실제 Confluence hierarchy 확인이 필요한 smoke는 credential이 있는 환경에서 수행합니다. + +```bash +bin/fetch_cli.py --remote --sync-code qm --start-page-id 544178405 +bin/convert_all.py --sync-code qm +``` + +## 4. Spec / 구현 drift 확인 + +- [x] 4.1 `pages..yaml`을 읽는 모든 consumer가 추가된 `type` 필드를 무시하거나 올바르게 사용하는지 source scan합니다. +- [x] 4.2 `_meta.ts`를 생성하는 다른 code path가 남아 중복 write하지 않는지 확인합니다. +- [x] 4.3 README, CLI help, sync profile 설명에서 `--recent`를 full hierarchy sync처럼 설명하는 stale 문구가 없는지 확인합니다. +- [x] 4.4 folder MDX가 translation/skeleton/reverse-sync workflow에서 일반 page body로 잘못 취급되지 않는지 확인합니다. +- [x] 4.5 manifest cleanup이 sync code가 다른 출력이나 attachment를 삭제하지 않는지 확인합니다. + +## 5. OpenSpec Cleanup + +- [ ] 5.1 구현과 검증이 완료되면 `contract-confluence-mdx-conversion`을 `openspec/specs/`에 accepted spec으로 반영합니다. +- [ ] 5.2 `openspec/specs/README.md` inventory를 갱신합니다. +- [ ] 5.3 완료된 change를 `openspec/archive/-confluence-folder-mdx/`로 이동합니다. From 3ef5132724dd44c631d7abec4da12bb9195d5b7b Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 27 Jul 2026 15:32:15 +0900 Subject: [PATCH 2/4] =?UTF-8?q?confluence-mdx:=20=EB=B3=80=ED=99=98=20mani?= =?UTF-8?q?fest=20=EC=86=8C=EC=9C=A0=EA=B6=8C=EC=9D=84=20=EB=B3=B4?= =?UTF-8?q?=EC=A1=B4=ED=95=A9=EB=8B=88=EB=8B=A4=20(#1051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- confluence-mdx/bin/convert_all.py | 42 +++++++++++++++- confluence-mdx/compose.yml | 4 ++ .../tests/test_convert_all_folders.py | 49 +++++++++++++++++++ confluence-mdx/var/convert-manifest.qcp.yaml | 3 ++ confluence-mdx/var/convert-manifest.qm.yaml | 3 ++ .../changes/confluence-folder-mdx/design.md | 18 ++++--- .../spec.md | 15 ++++++ .../changes/confluence-folder-mdx/tasks.md | 2 + 8 files changed, 126 insertions(+), 10 deletions(-) create mode 100644 confluence-mdx/var/convert-manifest.qcp.yaml create mode 100644 confluence-mdx/var/convert-manifest.qm.yaml diff --git a/confluence-mdx/bin/convert_all.py b/confluence-mdx/bin/convert_all.py index e5d1cedd8..39ddbdaef 100755 --- a/confluence-mdx/bin/convert_all.py +++ b/confluence-mdx/bin/convert_all.py @@ -28,6 +28,8 @@ _PROJECT_DIR = _SCRIPT_DIR.parent # confluence-mdx/ _SUPPORTED_CONTENT_TYPES = frozenset({"page", "folder"}) _DEFAULT_CONFLUENCE_BASE_URL = "https://querypie.atlassian.net/wiki" +_MANIFEST_PREFIX = "convert-manifest." +_MANIFEST_SUFFIX = ".yaml" # Ensure bin/ is on sys.path if str(_SCRIPT_DIR) not in sys.path: @@ -356,6 +358,16 @@ def _manifest_outputs(path: Path, expected_sync_code: str) -> List[Dict[str, str return outputs +def _manifest_sync_code(path: Path) -> str: + name = path.name + if not name.startswith(_MANIFEST_PREFIX) or not name.endswith(_MANIFEST_SUFFIX): + raise ConversionError(f"Invalid conversion manifest filename: {path}") + sync_code = name[len(_MANIFEST_PREFIX):-len(_MANIFEST_SUFFIX)] + if not sync_code: + raise ConversionError(f"Missing sync code in conversion manifest: {path}") + return sync_code + + def _validated_manifest_path(output_root: Path, relative_value: Any) -> Path: if not isinstance(relative_value, str): raise ConversionError(f"Manifest path must be a string: {relative_value!r}") @@ -378,6 +390,27 @@ def _validated_manifest_path(output_root: Path, relative_value: Any) -> Path: return resolved +def _other_profile_owned_paths( + manifest_path: Path, + sync_code: str, + output_root: Path, +) -> set[str]: + """Load and validate outputs owned by sibling sync profile manifests.""" + owned_paths: set[str] = set() + manifest_pattern = f"{_MANIFEST_PREFIX}*{_MANIFEST_SUFFIX}" + for candidate in sorted(manifest_path.parent.glob(manifest_pattern)): + if candidate == manifest_path: + continue + candidate_sync_code = _manifest_sync_code(candidate) + if candidate_sync_code == sync_code: + continue + for entry in _manifest_outputs(candidate, candidate_sync_code): + relative_value = entry.get("path") + _validated_manifest_path(output_root, relative_value) + owned_paths.add(str(relative_value)) + return owned_paths + + def _remove_empty_parents(path: Path, output_root: Path) -> None: parent = path.parent while parent != output_root: @@ -394,7 +427,7 @@ def finalize_manifest( current_outputs: Sequence[Mapping[str, str]], output_base_dir: Path, ) -> None: - """Safely remove stale owned files and atomically replace the manifest.""" + """Remove exclusively owned stale files and atomically replace the manifest.""" output_root = output_base_dir.resolve() previous_outputs = _manifest_outputs(manifest_path, sync_code) @@ -414,8 +447,13 @@ def finalize_manifest( ) current_by_path[str(relative_value)] = entry + other_profile_paths = _other_profile_owned_paths( + manifest_path, + sync_code, + output_root, + ) for stale_relative_path in sorted( - set(previous_by_path) - set(current_by_path), + set(previous_by_path) - set(current_by_path) - other_profile_paths, reverse=True, ): stale_path = _validated_manifest_path(output_root, stale_relative_path) diff --git a/confluence-mdx/compose.yml b/confluence-mdx/compose.yml index 55d8acc47..e83424b41 100644 --- a/confluence-mdx/compose.yml +++ b/confluence-mdx/compose.yml @@ -55,6 +55,10 @@ services: # can read the catalog. Add a new line here for each new Space. - ./var/pages.qm.yaml:/workdir/var/pages.qm.yaml - ./var/pages.qcp.yaml:/workdir/var/pages.qcp.yaml + # Persist generated-output ownership across `docker compose run --rm`. + # Add a manifest mount together with each new sync profile. + - ./var/convert-manifest.qm.yaml:/workdir/var/convert-manifest.qm.yaml + - ./var/convert-manifest.qcp.yaml:/workdir/var/convert-manifest.qcp.yaml # Mount output directories to host (matching symlink structure in target/) # target/ko -> ../../src/content/ko - ../src/content/ko:/workdir/target/ko diff --git a/confluence-mdx/tests/test_convert_all_folders.py b/confluence-mdx/tests/test_convert_all_folders.py index 686ec8732..53b2ae6c9 100644 --- a/confluence-mdx/tests/test_convert_all_folders.py +++ b/confluence-mdx/tests/test_convert_all_folders.py @@ -235,6 +235,40 @@ def test_manifest_removes_only_previous_owned_outputs(tmp_path): assert yaml.safe_load(manifest_path.read_text())["outputs"] == current_outputs +def test_manifest_preserves_stale_output_owned_by_another_profile(tmp_path): + output_dir = tmp_path / "output" + manifest_dir = tmp_path / "var" + qm_manifest = manifest_dir / "convert-manifest.qm.yaml" + qcp_manifest = manifest_dir / "convert-manifest.qcp.yaml" + shared_output = output_dir / "shared" / "folder.mdx" + shared_output.parent.mkdir(parents=True) + shared_output.write_text("fresh QM output") + owned_output = { + "page_id": "qm-folder", + "type": "folder", + "kind": "mdx", + "path": "shared/folder.mdx", + } + _write_yaml(qm_manifest, { + "version": 1, + "sync_code": "qm", + "outputs": [owned_output], + }) + _write_yaml(qcp_manifest, { + "version": 1, + "sync_code": "qcp", + "outputs": [{ + **owned_output, + "page_id": "old-qcp-folder", + }], + }) + + finalize_manifest(qcp_manifest, "qcp", [], output_dir) + + assert shared_output.read_text() == "fresh QM output" + assert yaml.safe_load(qcp_manifest.read_text())["outputs"] == [] + + def test_first_manifest_does_not_delete_untracked_existing_mdx(tmp_path): output_dir = tmp_path / "output" existing = output_dir / "legacy.mdx" @@ -285,6 +319,21 @@ def test_manifest_rejects_different_sync_profile(tmp_path): finalize_manifest(manifest_path, "qm", [], output_dir) +def test_compose_persists_each_supported_profile_manifest(): + project_dir = Path(__file__).resolve().parents[1] + compose = yaml.safe_load((project_dir / "compose.yml").read_text()) + volumes = compose["services"]["confluence-mdx"]["volumes"] + + for sync_code in ("qm", "qcp"): + relative_path = f"./var/convert-manifest.{sync_code}.yaml" + assert ( + f"{relative_path}:/workdir/var/convert-manifest.{sync_code}.yaml" + in volumes + ) + manifest = yaml.safe_load((project_dir / relative_path).read_text()) + assert manifest["sync_code"] == sync_code + + def test_conversion_failure_preserves_previous_output_and_manifest(tmp_path): var_dir = tmp_path / "var" output_dir = tmp_path / "output" diff --git a/confluence-mdx/var/convert-manifest.qcp.yaml b/confluence-mdx/var/convert-manifest.qcp.yaml new file mode 100644 index 000000000..15a700867 --- /dev/null +++ b/confluence-mdx/var/convert-manifest.qcp.yaml @@ -0,0 +1,3 @@ +version: 1 +sync_code: qcp +outputs: [] diff --git a/confluence-mdx/var/convert-manifest.qm.yaml b/confluence-mdx/var/convert-manifest.qm.yaml new file mode 100644 index 000000000..666ff3ad8 --- /dev/null +++ b/confluence-mdx/var/convert-manifest.qm.yaml @@ -0,0 +1,3 @@ +version: 1 +sync_code: qm +outputs: [] diff --git a/openspec/changes/confluence-folder-mdx/design.md b/openspec/changes/confluence-folder-mdx/design.md index c675f5513..0dc94744c 100644 --- a/openspec/changes/confluence-folder-mdx/design.md +++ b/openspec/changes/confluence-folder-mdx/design.md @@ -191,18 +191,20 @@ administrator-manual/mcp-server/_meta.ts ### Decision: sync profile별 manifest로 stale output을 정리합니다 -`convert_all.py`는 sync profile별 manifest에 자신이 생성한 MDX와 `_meta.ts`를 기록합니다. Manifest는 `var/convert-manifest..yaml`에 저장하며 최소 `page_id`, `type`, output 상대 경로를 보존합니다. +`convert_all.py`는 sync profile별 manifest에 자신이 생성한 MDX와 `_meta.ts`를 기록합니다. Manifest는 `var/convert-manifest..yaml`에 저장하며 최소 `page_id`, `type`, output 상대 경로를 보존합니다. Docker Compose는 지원 profile의 manifest 파일을 host에 명시적으로 bind mount합니다. 따라서 `docker compose run --rm` container가 종료되어도 manifest가 repository 작업 트리에 남고, 다음 실행과 생성 PR이 같은 소유권 상태를 이어받습니다. 정리 순서는 다음과 같습니다. 1. 이전 manifest를 읽습니다. -2. 현재 catalog의 page/folder MDX와 navigation을 모두 생성하고 검증합니다. -3. 하나라도 실패하면 이전 파일 삭제와 manifest 교체를 수행하지 않습니다. -4. 모두 성공하면 `previous_paths - current_paths`만 삭제합니다. -5. 빈 directory만 아래에서 위로 제거하고, manifest 밖의 파일이나 비어 있지 않은 directory는 보존합니다. -6. 현재 manifest를 atomic replace합니다. +2. 같은 manifest directory의 다른 sync profile manifest를 읽고 공유 output 소유권을 검증합니다. +3. 현재 catalog의 page/folder MDX와 navigation을 모두 생성하고 검증합니다. +4. 하나라도 실패하면 이전 파일 삭제와 manifest 교체를 수행하지 않습니다. +5. 모두 성공하면 `previous_paths - current_paths - other_profile_paths`만 삭제합니다. +6. 다른 profile이 계속 소유하는 stale 경로는 파일을 보존하되 현재 profile manifest에서는 제거합니다. +7. 빈 directory만 아래에서 위로 제거하고, manifest 밖의 파일이나 비어 있지 않은 directory는 보존합니다. +8. 현재 manifest를 atomic replace합니다. -모든 삭제 대상은 resolve 후 configured output root 내부인지 검사합니다. Manifest가 가리키더라도 output root 밖의 경로, 허용하지 않은 suffix, 예상하지 않은 `_meta.ts` 위치는 삭제하지 않고 오류로 처리합니다. +모든 현재·이전·다른 profile manifest 경로는 resolve 후 configured output root 내부인지 검사합니다. 어느 manifest든 output root 밖의 경로, 허용하지 않은 suffix, 예상하지 않은 `_meta.ts` 위치를 가리키면 삭제를 시작하기 전에 오류로 처리합니다. Folder 이동·이름 변경 시 folder landing MDX뿐 아니라 경로가 바뀐 descendant page/folder MDX와 generated `_meta.ts`도 같은 방식으로 정리됩니다. Attachment cleanup은 이번 변경 범위에 포함하지 않습니다. @@ -223,7 +225,7 @@ QM의 page root와 QCP의 folder root 모두 typed node로 수집합니다. Root 1. typed model과 API client pagination을 추가합니다. 2. `--remote`로 전체 QM/QCP tree를 다시 받아 folder raw snapshot과 typed catalog를 생성합니다. 3. folder generator와 중앙 navigation pass를 추가합니다. -4. 최초 `convert_all.py` 성공 시 manifest baseline을 기록합니다. 이 실행에서는 기존 manifest가 없으므로 stale output을 삭제하지 않습니다. +4. 지원 profile별 빈 manifest baseline과 Compose bind mount를 추가합니다. 최초 `convert_all.py` 성공 시 host manifest에 baseline을 기록하며, 이 실행에서는 이전 소유권이 없으므로 stale output을 삭제하지 않습니다. 5. 두 번째 fixture run에서 folder 이동·이름 변경·삭제를 재현하여 stale output 정리를 검증합니다. 6. README에 mode별 hierarchy freshness와 folder 저장/출력 형식을 기록합니다. 7. 구현과 검증이 완료되면 change-local spec을 accepted `contract-confluence-mdx-conversion` spec으로 승격합니다. diff --git a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md index 35e2cfe79..7fbac0cbc 100644 --- a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md +++ b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md @@ -174,6 +174,21 @@ Converter는 sync profile별 manifest로 자신이 만든 MDX와 navigation을 - THEN 이전 output을 stale file로 삭제하지 않아야 합니다(SHALL NOT). - AND 이전 manifest를 교체하지 않아야 합니다(SHALL NOT). +#### Scenario: ephemeral container 재실행 + +- GIVEN `docker compose run --rm`으로 변환을 실행합니다. +- WHEN 성공한 변환이 profile manifest를 갱신하고 container가 종료됩니다. +- THEN manifest 변경은 host의 추적 가능한 경로에 남아야 합니다(SHALL). +- AND 다음 container 실행은 이전 성공 변환의 manifest를 읽어야 합니다(SHALL). + +#### Scenario: 공유 output root의 profile 소유권 이전 + +- GIVEN 두 sync profile이 같은 output root를 사용합니다. +- AND 현재 profile에서 stale인 경로를 다른 profile의 manifest가 소유합니다. +- WHEN 현재 profile의 stale cleanup을 실행합니다. +- THEN 다른 profile이 소유한 output을 삭제하지 않아야 합니다(SHALL NOT). +- AND 현재 profile manifest에서는 해당 stale 경로의 소유권을 제거해야 합니다(SHALL). + #### Scenario: unsafe manifest path - GIVEN manifest entry가 configured output root 밖을 가리키거나 허용되지 않은 파일을 가리킵니다. diff --git a/openspec/changes/confluence-folder-mdx/tasks.md b/openspec/changes/confluence-folder-mdx/tasks.md index 51c80248e..bc7c68758 100644 --- a/openspec/changes/confluence-folder-mdx/tasks.md +++ b/openspec/changes/confluence-folder-mdx/tasks.md @@ -16,6 +16,7 @@ - [x] 2.8 `_meta.ts` 생성을 `bin/converter/cli.py`의 XHTML side effect에서 catalog-level navigation pass로 이동합니다. - [x] 2.9 `var/convert-manifest..yaml`의 atomic update와 stale generated output 안전 삭제를 구현합니다. - [x] 2.10 folder MDX가 reverse sync 대상이 아닐 때 명확한 오류를 반환하도록 관련 entry point를 확인하고 필요한 guard를 추가합니다. +- [x] 2.11 Compose 실행 사이에 profile manifest를 host에 보존하고 다른 profile 소유 output을 stale cleanup에서 제외합니다. ## 3. Verification @@ -32,6 +33,7 @@ - [x] 3.11 conversion 중간 실패 시 stale file 삭제와 manifest 교체가 일어나지 않는지 검증합니다. - [ ] 3.12 QM 대상 folder `2167636017`과 QCP folder root profile의 smoke 결과를 확인합니다. - [x] 3.13 focused Python test, `git diff --check`, 관련 `rg` source scan을 실행합니다. +- [x] 3.14 ephemeral container manifest mount와 공유 output root의 profile 소유권 이전 회귀 테스트를 추가합니다. 권장 focused 명령: From 42dff222670d87d9b9b5e56a104643e467e23326 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 27 Jul 2026 17:58:07 +0900 Subject: [PATCH 3/4] =?UTF-8?q?confluence-mdx:=20manifest=20atomic=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B4=EC=9D=84=20=EB=B3=B4=EA=B0=95=ED=95=A9?= =?UTF-8?q?=EB=8B=88=EB=8B=A4=20(#1051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary ephemeral container에서 manifest persistence와 atomic replace를 함께 보장합니다. - profile manifest를 전용 directory로 이동하고 directory 단위로 bind mount합니다. - 개별 file mount의 mount point 교체 위험을 OpenSpec 계약에 반영합니다. - Compose mount와 profile별 baseline 경로를 회귀 테스트로 검증합니다. 🤖 Generated with Codex --- confluence-mdx/README.md | 2 +- confluence-mdx/bin/convert_all.py | 6 ++- confluence-mdx/compose.yml | 7 ++- .../tests/test_convert_all_folders.py | 44 +++++++++++++------ .../convert-manifest.qcp.yaml | 0 .../convert-manifest.qm.yaml | 0 .../changes/confluence-folder-mdx/design.md | 6 ++- .../spec.md | 2 + .../changes/confluence-folder-mdx/tasks.md | 6 +-- 9 files changed, 49 insertions(+), 24 deletions(-) rename confluence-mdx/var/{ => convert-manifests}/convert-manifest.qcp.yaml (100%) rename confluence-mdx/var/{ => convert-manifests}/convert-manifest.qm.yaml (100%) diff --git a/confluence-mdx/README.md b/confluence-mdx/README.md index 90cd8b453..be3f18573 100644 --- a/confluence-mdx/README.md +++ b/confluence-mdx/README.md @@ -177,7 +177,7 @@ bin/fetch_cli.py --log-level DEBUG - folder는 `title`, `confluenceUrl`, `## 하위 문서`와 직계 자식 link 목록을 가진 landing page로 완전히 재생성합니다. - 지원되는 직계 자식이 없는 folder에는 `하위 문서가 없습니다.`를 표시합니다. - navigation `_meta.ts`는 전체 catalog 변환이 끝난 뒤 생성합니다. -- `var/convert-manifest..yaml`에 생성한 MDX와 `_meta.ts`를 기록합니다. +- `var/convert-manifests/convert-manifest..yaml`에 생성한 MDX와 `_meta.ts`를 기록합니다. - 변환 전체가 성공한 경우에만 이전 manifest가 소유한 stale output을 삭제합니다. 실행 방법: diff --git a/confluence-mdx/bin/convert_all.py b/confluence-mdx/bin/convert_all.py index 39ddbdaef..3ee7b3031 100755 --- a/confluence-mdx/bin/convert_all.py +++ b/confluence-mdx/bin/convert_all.py @@ -626,7 +626,11 @@ def main(): args.output_dir = _resolve(args.output_dir) args.public_dir = _resolve(args.public_dir) args.translations = _resolve(args.translations) - manifest_path = os.path.join(args.var_dir, f"convert-manifest.{args.sync_code}.yaml") + manifest_path = os.path.join( + args.var_dir, + "convert-manifests", + f"convert-manifest.{args.sync_code}.yaml", + ) profile = SYNC_PROFILES.get(args.sync_code) space_key = args.space_key or ( profile.space_key if profile else args.sync_code.upper() diff --git a/confluence-mdx/compose.yml b/confluence-mdx/compose.yml index e83424b41..8549941bf 100644 --- a/confluence-mdx/compose.yml +++ b/confluence-mdx/compose.yml @@ -55,10 +55,9 @@ services: # can read the catalog. Add a new line here for each new Space. - ./var/pages.qm.yaml:/workdir/var/pages.qm.yaml - ./var/pages.qcp.yaml:/workdir/var/pages.qcp.yaml - # Persist generated-output ownership across `docker compose run --rm`. - # Add a manifest mount together with each new sync profile. - - ./var/convert-manifest.qm.yaml:/workdir/var/convert-manifest.qm.yaml - - ./var/convert-manifest.qcp.yaml:/workdir/var/convert-manifest.qcp.yaml + # Persist generated-output ownership across `docker compose run --rm` + # while allowing atomic replacement of manifest files. + - ./var/convert-manifests:/workdir/var/convert-manifests # Mount output directories to host (matching symlink structure in target/) # target/ko -> ../../src/content/ko - ../src/content/ko:/workdir/target/ko diff --git a/confluence-mdx/tests/test_convert_all_folders.py b/confluence-mdx/tests/test_convert_all_folders.py index 53b2ae6c9..91a2819c7 100644 --- a/confluence-mdx/tests/test_convert_all_folders.py +++ b/confluence-mdx/tests/test_convert_all_folders.py @@ -201,7 +201,9 @@ def test_navigation_is_generated_after_page_and_folder_mdx_exist(tmp_path): def test_manifest_removes_only_previous_owned_outputs(tmp_path): output_dir = tmp_path / "output" - manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + manifest_path = ( + tmp_path / "var" / "convert-manifests" / "convert-manifest.qm.yaml" + ) stale = output_dir / "old" / "folder.mdx" manual = output_dir / "old" / "manual.txt" current = output_dir / "new" / "folder.mdx" @@ -237,7 +239,7 @@ def test_manifest_removes_only_previous_owned_outputs(tmp_path): def test_manifest_preserves_stale_output_owned_by_another_profile(tmp_path): output_dir = tmp_path / "output" - manifest_dir = tmp_path / "var" + manifest_dir = tmp_path / "var" / "convert-manifests" qm_manifest = manifest_dir / "convert-manifest.qm.yaml" qcp_manifest = manifest_dir / "convert-manifest.qcp.yaml" shared_output = output_dir / "shared" / "folder.mdx" @@ -274,7 +276,9 @@ def test_first_manifest_does_not_delete_untracked_existing_mdx(tmp_path): existing = output_dir / "legacy.mdx" existing.parent.mkdir(parents=True) existing.write_text("legacy") - manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + manifest_path = ( + tmp_path / "var" / "convert-manifests" / "convert-manifest.qm.yaml" + ) finalize_manifest(manifest_path, "qm", [], output_dir) @@ -287,7 +291,9 @@ def test_manifest_rejects_path_outside_output_root(tmp_path): output_dir.mkdir() outside = tmp_path / "outside.mdx" outside.write_text("keep") - manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + manifest_path = ( + tmp_path / "var" / "convert-manifests" / "convert-manifest.qm.yaml" + ) _write_yaml(manifest_path, { "version": 1, "sync_code": "qm", @@ -308,7 +314,9 @@ def test_manifest_rejects_path_outside_output_root(tmp_path): def test_manifest_rejects_different_sync_profile(tmp_path): output_dir = tmp_path / "output" output_dir.mkdir() - manifest_path = tmp_path / "var" / "convert-manifest.qm.yaml" + manifest_path = ( + tmp_path / "var" / "convert-manifests" / "convert-manifest.qm.yaml" + ) _write_yaml(manifest_path, { "version": 1, "sync_code": "qcp", @@ -319,16 +327,20 @@ def test_manifest_rejects_different_sync_profile(tmp_path): finalize_manifest(manifest_path, "qm", [], output_dir) -def test_compose_persists_each_supported_profile_manifest(): +def test_compose_persists_manifest_directory_for_atomic_replace(): project_dir = Path(__file__).resolve().parents[1] compose = yaml.safe_load((project_dir / "compose.yml").read_text()) volumes = compose["services"]["confluence-mdx"]["volumes"] + manifest_mount = ( + "./var/convert-manifests:/workdir/var/convert-manifests" + ) + assert manifest_mount in volumes for sync_code in ("qm", "qcp"): - relative_path = f"./var/convert-manifest.{sync_code}.yaml" - assert ( - f"{relative_path}:/workdir/var/convert-manifest.{sync_code}.yaml" - in volumes + relative_path = ( + Path("var") + / "convert-manifests" + / f"convert-manifest.{sync_code}.yaml" ) manifest = yaml.safe_load((project_dir / relative_path).read_text()) assert manifest["sync_code"] == sync_code @@ -338,7 +350,9 @@ def test_conversion_failure_preserves_previous_output_and_manifest(tmp_path): var_dir = tmp_path / "var" output_dir = tmp_path / "output" public_dir = tmp_path / "public" - manifest_path = var_dir / "convert-manifest.qm.yaml" + manifest_path = ( + var_dir / "convert-manifests" / "convert-manifest.qm.yaml" + ) previous_output = output_dir / "old.mdx" previous_output.parent.mkdir(parents=True) previous_output.write_text("old") @@ -376,7 +390,9 @@ def test_convert_all_generates_folder_and_manifest(tmp_path): var_dir = tmp_path / "var" output_dir = tmp_path / "output" public_dir = tmp_path / "public" - manifest_path = var_dir / "convert-manifest.qm.yaml" + manifest_path = ( + var_dir / "convert-manifests" / "convert-manifest.qm.yaml" + ) root = _node("root", "page", "Root", ["root"]) folder = _node("folder", "folder", "Folder", ["folder"]) _write_yaml(var_dir / "folder" / "folder.v2.yaml", _folder_data( @@ -410,7 +426,9 @@ def test_convert_all_generates_page_folder_and_central_navigation(tmp_path): var_dir = tmp_path / "var" output_dir = tmp_path / "output" public_dir = tmp_path / "public" - manifest_path = var_dir / "convert-manifest.qm.yaml" + manifest_path = ( + var_dir / "convert-manifests" / "convert-manifest.qm.yaml" + ) pages_yaml = var_dir / "pages.qm.yaml" root = _node("root", "page", "Root", ["root"]) parent = _node("parent", "page", "Parent", ["parent"]) diff --git a/confluence-mdx/var/convert-manifest.qcp.yaml b/confluence-mdx/var/convert-manifests/convert-manifest.qcp.yaml similarity index 100% rename from confluence-mdx/var/convert-manifest.qcp.yaml rename to confluence-mdx/var/convert-manifests/convert-manifest.qcp.yaml diff --git a/confluence-mdx/var/convert-manifest.qm.yaml b/confluence-mdx/var/convert-manifests/convert-manifest.qm.yaml similarity index 100% rename from confluence-mdx/var/convert-manifest.qm.yaml rename to confluence-mdx/var/convert-manifests/convert-manifest.qm.yaml diff --git a/openspec/changes/confluence-folder-mdx/design.md b/openspec/changes/confluence-folder-mdx/design.md index 0dc94744c..bedb8e01b 100644 --- a/openspec/changes/confluence-folder-mdx/design.md +++ b/openspec/changes/confluence-folder-mdx/design.md @@ -191,7 +191,9 @@ administrator-manual/mcp-server/_meta.ts ### Decision: sync profile별 manifest로 stale output을 정리합니다 -`convert_all.py`는 sync profile별 manifest에 자신이 생성한 MDX와 `_meta.ts`를 기록합니다. Manifest는 `var/convert-manifest..yaml`에 저장하며 최소 `page_id`, `type`, output 상대 경로를 보존합니다. Docker Compose는 지원 profile의 manifest 파일을 host에 명시적으로 bind mount합니다. 따라서 `docker compose run --rm` container가 종료되어도 manifest가 repository 작업 트리에 남고, 다음 실행과 생성 PR이 같은 소유권 상태를 이어받습니다. +`convert_all.py`는 sync profile별 manifest에 자신이 생성한 MDX와 `_meta.ts`를 기록합니다. Manifest는 `var/convert-manifests/convert-manifest..yaml`에 저장하며 최소 `page_id`, `type`, output 상대 경로를 보존합니다. Docker Compose는 `var/convert-manifests/` directory를 host에 bind mount합니다. 따라서 `docker compose run --rm` container가 종료되어도 manifest가 repository 작업 트리에 남고, 다음 실행과 생성 PR이 같은 소유권 상태를 이어받습니다. + +Manifest file 자체를 개별 bind mount하는 방식은 선택하지 않습니다. Manifest 갱신은 같은 directory의 임시 파일을 `os.replace()`로 교체하는 atomic write이므로, mount point인 개별 file을 교체하면 container runtime에서 `EBUSY`가 발생할 수 있습니다. Directory mount는 host persistence와 atomic replace를 함께 보장합니다. 정리 순서는 다음과 같습니다. @@ -225,7 +227,7 @@ QM의 page root와 QCP의 folder root 모두 typed node로 수집합니다. Root 1. typed model과 API client pagination을 추가합니다. 2. `--remote`로 전체 QM/QCP tree를 다시 받아 folder raw snapshot과 typed catalog를 생성합니다. 3. folder generator와 중앙 navigation pass를 추가합니다. -4. 지원 profile별 빈 manifest baseline과 Compose bind mount를 추가합니다. 최초 `convert_all.py` 성공 시 host manifest에 baseline을 기록하며, 이 실행에서는 이전 소유권이 없으므로 stale output을 삭제하지 않습니다. +4. 지원 profile별 빈 manifest baseline과 Compose directory bind mount를 추가합니다. 최초 `convert_all.py` 성공 시 host manifest에 baseline을 기록하며, 이 실행에서는 이전 소유권이 없으므로 stale output을 삭제하지 않습니다. 5. 두 번째 fixture run에서 folder 이동·이름 변경·삭제를 재현하여 stale output 정리를 검증합니다. 6. README에 mode별 hierarchy freshness와 folder 저장/출력 형식을 기록합니다. 7. 구현과 검증이 완료되면 change-local spec을 accepted `contract-confluence-mdx-conversion` spec으로 승격합니다. diff --git a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md index 7fbac0cbc..fb308cfb9 100644 --- a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md +++ b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md @@ -180,6 +180,8 @@ Converter는 sync profile별 manifest로 자신이 만든 MDX와 navigation을 - WHEN 성공한 변환이 profile manifest를 갱신하고 container가 종료됩니다. - THEN manifest 변경은 host의 추적 가능한 경로에 남아야 합니다(SHALL). - AND 다음 container 실행은 이전 성공 변환의 manifest를 읽어야 합니다(SHALL). +- AND manifest directory를 mount하여 atomic file 교체를 지원해야 합니다(SHALL). +- AND atomic replace 대상 manifest file 자체를 개별 mount point로 사용하지 않아야 합니다(SHALL NOT). #### Scenario: 공유 output root의 profile 소유권 이전 diff --git a/openspec/changes/confluence-folder-mdx/tasks.md b/openspec/changes/confluence-folder-mdx/tasks.md index bc7c68758..c7f2d9ebe 100644 --- a/openspec/changes/confluence-folder-mdx/tasks.md +++ b/openspec/changes/confluence-folder-mdx/tasks.md @@ -14,9 +14,9 @@ - [x] 2.6 `--recent`가 `children.v2.yaml`을 갱신하지 않고 저장된 hierarchy만 재사용하도록 page content fetch operation을 분리합니다. - [x] 2.7 `bin/convert_all.py`에 deterministic folder MDX generator를 추가합니다. - [x] 2.8 `_meta.ts` 생성을 `bin/converter/cli.py`의 XHTML side effect에서 catalog-level navigation pass로 이동합니다. -- [x] 2.9 `var/convert-manifest..yaml`의 atomic update와 stale generated output 안전 삭제를 구현합니다. +- [x] 2.9 `var/convert-manifests/convert-manifest..yaml`의 atomic update와 stale generated output 안전 삭제를 구현합니다. - [x] 2.10 folder MDX가 reverse sync 대상이 아닐 때 명확한 오류를 반환하도록 관련 entry point를 확인하고 필요한 guard를 추가합니다. -- [x] 2.11 Compose 실행 사이에 profile manifest를 host에 보존하고 다른 profile 소유 output을 stale cleanup에서 제외합니다. +- [x] 2.11 Compose 실행 사이에 profile manifest directory를 host에 보존하고 다른 profile 소유 output을 stale cleanup에서 제외합니다. ## 3. Verification @@ -33,7 +33,7 @@ - [x] 3.11 conversion 중간 실패 시 stale file 삭제와 manifest 교체가 일어나지 않는지 검증합니다. - [ ] 3.12 QM 대상 folder `2167636017`과 QCP folder root profile의 smoke 결과를 확인합니다. - [x] 3.13 focused Python test, `git diff --check`, 관련 `rg` source scan을 실행합니다. -- [x] 3.14 ephemeral container manifest mount와 공유 output root의 profile 소유권 이전 회귀 테스트를 추가합니다. +- [x] 3.14 ephemeral container의 manifest directory mount, atomic replace, 공유 output root의 profile 소유권 이전 회귀 테스트를 추가합니다. 권장 focused 명령: From 0e2e5ff1874772708cc3d0504b1f53d5f3bbba55 Mon Sep 17 00:00:00 2001 From: JK Date: Mon, 27 Jul 2026 18:09:31 +0900 Subject: [PATCH 4/4] =?UTF-8?q?confluence-mdx:=20profile=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EA=B2=BD=EB=A1=9C=20=EC=B6=A9=EB=8F=8C=EC=9D=84=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=ED=95=A9=EB=8B=88=EB=8B=A4=20(#1051)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary 공유 output root에서 sync profile 간 현재 출력 경로 충돌을 생성 전에 차단합니다. - full-all이 모든 profile catalog를 먼저 갱신한 뒤 변환하도록 순서를 조정합니다. - sibling catalog의 출력 계획과 겹치는 경로를 preflight에서 거부합니다. - 소유권 이전, stable collision, 실행 순서를 회귀 테스트와 OpenSpec 계약에 반영합니다. 🤖 Generated with Codex --- confluence-mdx/bin/convert_all.py | 132 ++++++++++++++ confluence-mdx/scripts/entrypoint.sh | 4 +- .../tests/test_convert_all_folders.py | 171 ++++++++++++++++++ .../changes/confluence-folder-mdx/design.md | 20 +- .../spec.md | 9 + .../changes/confluence-folder-mdx/tasks.md | 1 + 6 files changed, 328 insertions(+), 9 deletions(-) diff --git a/confluence-mdx/bin/convert_all.py b/confluence-mdx/bin/convert_all.py index 3ee7b3031..99fd88661 100755 --- a/confluence-mdx/bin/convert_all.py +++ b/confluence-mdx/bin/convert_all.py @@ -368,6 +368,18 @@ def _manifest_sync_code(path: Path) -> str: return sync_code +def _catalog_sync_code(path: Path) -> str: + prefix = "pages." + suffix = ".yaml" + name = path.name + if not name.startswith(prefix) or not name.endswith(suffix): + raise ConversionError(f"Invalid pages catalog filename: {path}") + sync_code = name[len(prefix):-len(suffix)] + if not sync_code: + raise ConversionError(f"Missing sync code in pages catalog: {path}") + return sync_code + + def _validated_manifest_path(output_root: Path, relative_value: Any) -> Path: if not isinstance(relative_value, str): raise ConversionError(f"Manifest path must be a string: {relative_value!r}") @@ -390,6 +402,113 @@ def _validated_manifest_path(output_root: Path, relative_value: Any) -> Path: return resolved +def _planned_output_paths( + pages: Sequence[Mapping[str, Any]], + var_dir: Path, +) -> set[str]: + """Calculate all MDX/navigation paths without creating output files.""" + if not pages: + return set() + + root_id = str(pages[0]["page_id"]) + nodes_by_id = {str(page["page_id"]): page for page in pages} + planned_paths: set[str] = set() + + for page in pages: + if str(page["page_id"]) == root_id: + continue + content_type = str(page.get("type") or "page") + if content_type in _SUPPORTED_CONTENT_TYPES: + planned_paths.add(_output_relative_path(page).as_posix()) + + for parent in pages: + parent_id = str(parent["page_id"]) + if parent_id == root_id: + continue + if _supported_children(parent, var_dir, nodes_by_id): + parent_path = _output_relative_path(parent) + planned_paths.add( + (parent_path.with_suffix("") / "_meta.ts").as_posix() + ) + + return planned_paths + + +def _other_profile_planned_paths( + manifest_path: Path, + sync_code: str, + var_dir: Path, + output_root: Path, +) -> Dict[str, set[str]]: + """Load current sibling catalogs, falling back to manifests if absent.""" + manifest_dir = manifest_path.parent + sibling_codes = { + _catalog_sync_code(path) + for path in var_dir.glob("pages.*.yaml") + } + sibling_codes.update( + _manifest_sync_code(path) + for path in manifest_dir.glob( + f"{_MANIFEST_PREFIX}*{_MANIFEST_SUFFIX}" + ) + ) + sibling_codes.discard(sync_code) + + planned_by_profile: Dict[str, set[str]] = {} + for sibling_code in sorted(sibling_codes): + catalog_path = var_dir / f"pages.{sibling_code}.yaml" + if catalog_path.exists(): + sibling_pages = load_pages_yaml(str(catalog_path)) + sibling_paths = _planned_output_paths(sibling_pages, var_dir) + else: + sibling_manifest = ( + manifest_dir + / f"{_MANIFEST_PREFIX}{sibling_code}{_MANIFEST_SUFFIX}" + ) + sibling_paths = set() + for entry in _manifest_outputs( + sibling_manifest, + sibling_code, + ): + relative_path = entry.get("path") + _validated_manifest_path(output_root, relative_path) + sibling_paths.add(str(relative_path)) + + for relative_path in sibling_paths: + _validated_manifest_path(output_root, relative_path) + planned_by_profile[sibling_code] = sibling_paths + + return planned_by_profile + + +def _ensure_exclusive_output_plan( + manifest_path: Path, + sync_code: str, + pages: Sequence[Mapping[str, Any]], + var_dir: Path, + output_root: Path, +) -> None: + """Reject cross-profile current output collisions before writing files.""" + current_paths = _planned_output_paths(pages, var_dir) + for relative_path in current_paths: + _validated_manifest_path(output_root, relative_path) + for sibling_code, sibling_paths in _other_profile_planned_paths( + manifest_path, + sync_code, + var_dir, + output_root, + ).items(): + conflicts = sorted(current_paths & sibling_paths) + if conflicts: + conflict_summary = ", ".join(conflicts[:5]) + if len(conflicts) > 5: + conflict_summary += f", ... ({len(conflicts)} total)" + raise ConversionError( + "Current output path collision between sync profiles " + f"{sync_code!r} and {sibling_code!r}: {conflict_summary}" + ) + + def _other_profile_owned_paths( manifest_path: Path, sync_code: str, @@ -510,6 +629,19 @@ def convert_all(pages: List[Dict], var_dir: str, output_base_dir: str, public_di failures = 0 generated_outputs: List[Dict[str, str]] = [] + if manifest_path: + try: + _ensure_exclusive_output_plan( + Path(manifest_path), + sync_code, + pages, + var_path, + output_base_path.resolve(), + ) + except Exception as exc: + print(f" ERROR: output ownership preflight failed: {exc}", file=sys.stderr) + return 1 + for i, page in enumerate(targets, 1): page_id = str(page['page_id']) content_type = str(page.get("type") or "page") diff --git a/confluence-mdx/scripts/entrypoint.sh b/confluence-mdx/scripts/entrypoint.sh index f6cadb71d..dd0184a35 100755 --- a/confluence-mdx/scripts/entrypoint.sh +++ b/confluence-mdx/scripts/entrypoint.sh @@ -62,9 +62,11 @@ case "${1:-help}" in print_image_info shift for CODE in qm qcp; do - echo "# Starting full workflow for Space: $CODE..." echo "+ bin/fetch_cli.py --sync-code $CODE $@" bin/fetch_cli.py --sync-code "$CODE" "$@" + done + for CODE in qm qcp; do + echo "# Starting conversion for Space: $CODE..." echo "+ bin/convert_all.py --sync-code $CODE" bin/convert_all.py --sync-code "$CODE" done diff --git a/confluence-mdx/tests/test_convert_all_folders.py b/confluence-mdx/tests/test_convert_all_folders.py index 91a2819c7..c9e0812f0 100644 --- a/confluence-mdx/tests/test_convert_all_folders.py +++ b/confluence-mdx/tests/test_convert_all_folders.py @@ -1,3 +1,4 @@ +import subprocess from argparse import Namespace from pathlib import Path @@ -271,6 +272,176 @@ def test_manifest_preserves_stale_output_owned_by_another_profile(tmp_path): assert yaml.safe_load(qcp_manifest.read_text())["outputs"] == [] +def test_convert_all_rejects_current_cross_profile_collision_before_write( + tmp_path, + capsys, +): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + public_dir = tmp_path / "public" + manifest_dir = var_dir / "convert-manifests" + qm_manifest = manifest_dir / "convert-manifest.qm.yaml" + qcp_manifest = manifest_dir / "convert-manifest.qcp.yaml" + qm_root = _node("qm-root", "page", "QM Root", ["qm-root"]) + qm_folder = _node( + "qm-folder", + "folder", + "QM Folder", + ["shared", "folder"], + ) + qcp_pages = [ + _node("qcp-root", "folder", "QCP Root", ["qcp-root"]), + _node( + "qcp-folder", + "folder", + "QCP Folder", + ["shared", "folder"], + ), + ] + _write_yaml(var_dir / "pages.qcp.yaml", qcp_pages) + _write_yaml( + var_dir / "qm-folder" / "children.v2.yaml", + {"results": []}, + ) + _write_yaml( + var_dir / "qcp-folder" / "children.v2.yaml", + {"results": []}, + ) + existing_output = output_dir / "shared" / "folder.mdx" + existing_output.parent.mkdir(parents=True) + existing_output.write_text("QCP output", encoding="utf-8") + _write_yaml(qcp_manifest, { + "version": 1, + "sync_code": "qcp", + "outputs": [{ + "page_id": "qcp-folder", + "type": "folder", + "kind": "mdx", + "path": "shared/folder.mdx", + }], + }) + + failures = convert_all( + [qm_root, qm_folder], + str(var_dir), + str(output_dir), + str(public_dir), + "warning", + manifest_path=str(qm_manifest), + sync_code="qm", + ) + + assert failures == 1 + assert existing_output.read_text(encoding="utf-8") == "QCP output" + assert not qm_manifest.exists() + assert "Current output path collision" in capsys.readouterr().err + + +def test_convert_all_allows_ownership_transfer_after_sibling_catalog_update( + tmp_path, +): + var_dir = tmp_path / "var" + output_dir = tmp_path / "output" + public_dir = tmp_path / "public" + manifest_dir = var_dir / "convert-manifests" + qm_manifest = manifest_dir / "convert-manifest.qm.yaml" + qcp_manifest = manifest_dir / "convert-manifest.qcp.yaml" + qm_root = _node("qm-root", "page", "QM Root", ["qm-root"]) + qm_folder = _node( + "qm-folder", + "folder", + "QM Folder", + ["shared", "folder"], + ) + _write_yaml( + var_dir / "pages.qcp.yaml", + [_node("qcp-root", "folder", "QCP Root", ["qcp-root"])], + ) + _write_yaml( + var_dir / "qm-folder" / "folder.v2.yaml", + _folder_data("qm-folder", "QM Folder"), + ) + _write_yaml( + var_dir / "qm-folder" / "children.v2.yaml", + {"results": []}, + ) + _write_yaml(qcp_manifest, { + "version": 1, + "sync_code": "qcp", + "outputs": [{ + "page_id": "old-qcp-folder", + "type": "folder", + "kind": "mdx", + "path": "shared/folder.mdx", + }], + }) + + failures = convert_all( + [qm_root, qm_folder], + str(var_dir), + str(output_dir), + str(public_dir), + "warning", + manifest_path=str(qm_manifest), + sync_code="qm", + ) + + assert failures == 0 + assert "# QM Folder" in ( + output_dir / "shared" / "folder.mdx" + ).read_text(encoding="utf-8") + assert { + entry["path"] + for entry in yaml.safe_load(qm_manifest.read_text())["outputs"] + } == {"shared/folder.mdx"} + + +def test_full_all_fetches_all_catalogs_before_any_conversion( + tmp_path, + monkeypatch, +): + project_dir = Path(__file__).resolve().parents[1] + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + container_workdir = tmp_path / "workdir" + (container_workdir / "var").mkdir(parents=True) + entrypoint_path = tmp_path / "entrypoint.sh" + entrypoint_path.write_text( + (project_dir / "scripts" / "entrypoint.sh") + .read_text(encoding="utf-8") + .replace("/workdir", str(container_workdir)), + encoding="utf-8", + ) + calls_path = tmp_path / "calls.log" + for command, label in ( + ("fetch_cli.py", "fetch"), + ("convert_all.py", "convert"), + ): + command_path = bin_dir / command + command_path.write_text( + "#!/bin/bash\n" + f'echo "{label} $*" >> "$CALLS_PATH"\n', + encoding="utf-8", + ) + command_path.chmod(0o755) + monkeypatch.setenv("CALLS_PATH", str(calls_path)) + + subprocess.run( + ["bash", str(entrypoint_path), "full-all"], + cwd=tmp_path, + check=True, + capture_output=True, + text=True, + ) + + assert calls_path.read_text(encoding="utf-8").splitlines() == [ + "fetch --sync-code qm", + "fetch --sync-code qcp", + "convert --sync-code qm", + "convert --sync-code qcp", + ] + + def test_first_manifest_does_not_delete_untracked_existing_mdx(tmp_path): output_dir = tmp_path / "output" existing = output_dir / "legacy.mdx" diff --git a/openspec/changes/confluence-folder-mdx/design.md b/openspec/changes/confluence-folder-mdx/design.md index bedb8e01b..cad49abf4 100644 --- a/openspec/changes/confluence-folder-mdx/design.md +++ b/openspec/changes/confluence-folder-mdx/design.md @@ -197,14 +197,18 @@ Manifest file 자체를 개별 bind mount하는 방식은 선택하지 않습니 정리 순서는 다음과 같습니다. -1. 이전 manifest를 읽습니다. -2. 같은 manifest directory의 다른 sync profile manifest를 읽고 공유 output 소유권을 검증합니다. -3. 현재 catalog의 page/folder MDX와 navigation을 모두 생성하고 검증합니다. -4. 하나라도 실패하면 이전 파일 삭제와 manifest 교체를 수행하지 않습니다. -5. 모두 성공하면 `previous_paths - current_paths - other_profile_paths`만 삭제합니다. -6. 다른 profile이 계속 소유하는 stale 경로는 파일을 보존하되 현재 profile manifest에서는 제거합니다. -7. 빈 directory만 아래에서 위로 제거하고, manifest 밖의 파일이나 비어 있지 않은 directory는 보존합니다. -8. 현재 manifest를 atomic replace합니다. +1. `full-all`은 모든 sync profile의 catalog를 먼저 갱신한 뒤 profile별 변환을 시작합니다. +2. 현재 catalog의 page/folder MDX와 navigation output plan을 계산합니다. +3. 같은 `var/`의 다른 sync profile catalog에서 output plan을 계산하고, 현재 plan과 겹치는 경로가 있으면 output 생성 전에 전체 conversion을 실패로 처리합니다. +4. 이전 manifest와 같은 manifest directory의 다른 sync profile manifest를 읽고 공유 output 소유권을 검증합니다. +5. 현재 catalog의 page/folder MDX와 navigation을 모두 생성하고 검증합니다. +6. 하나라도 실패하면 이전 파일 삭제와 manifest 교체를 수행하지 않습니다. +7. 모두 성공하면 `previous_paths - current_paths - other_profile_paths`만 삭제합니다. +8. 다른 profile이 계속 소유하는 stale 경로는 파일을 보존하되 현재 profile manifest에서는 제거합니다. +9. 빈 directory만 아래에서 위로 제거하고, manifest 밖의 파일이나 비어 있지 않은 directory는 보존합니다. +10. 현재 manifest를 atomic replace합니다. + +다른 profile의 최신 catalog가 없고 manifest만 있으면 manifest output을 보수적인 current plan으로 사용합니다. 따라서 단일 profile 실행에서 profile 간 경로 이전이 필요한 경우에는 모든 catalog를 먼저 갱신하는 `full-all`을 사용해야 합니다. 이 preflight는 stable path collision이 뒤 profile의 변환으로 앞 profile의 output을 덮어쓰는 것을 막으면서, 다른 profile의 최신 catalog에서 제거된 경로는 안전하게 소유권을 이전할 수 있게 합니다. 모든 현재·이전·다른 profile manifest 경로는 resolve 후 configured output root 내부인지 검사합니다. 어느 manifest든 output root 밖의 경로, 허용하지 않은 suffix, 예상하지 않은 `_meta.ts` 위치를 가리키면 삭제를 시작하기 전에 오류로 처리합니다. diff --git a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md index fb308cfb9..395f96f48 100644 --- a/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md +++ b/openspec/changes/confluence-folder-mdx/specs/contract-confluence-mdx-conversion/spec.md @@ -191,6 +191,15 @@ Converter는 sync profile별 manifest로 자신이 만든 MDX와 navigation을 - THEN 다른 profile이 소유한 output을 삭제하지 않아야 합니다(SHALL NOT). - AND 현재 profile manifest에서는 해당 stale 경로의 소유권을 제거해야 합니다(SHALL). +#### Scenario: 공유 output root의 current path 충돌 + +- GIVEN 두 sync profile이 같은 output root를 사용합니다. +- AND 두 profile의 최신 catalog가 동일한 MDX 또는 navigation 경로를 current output으로 계획합니다. +- WHEN `full-all` 또는 profile conversion을 실행합니다. +- THEN 모든 profile catalog를 변환 전에 갱신해야 합니다(SHALL). +- AND 충돌 경로의 output을 생성하거나 기존 output을 덮어쓰기 전에 conversion을 오류로 종료해야 합니다(SHALL). +- AND 어느 profile의 manifest도 교체하지 않아야 합니다(SHALL NOT). + #### Scenario: unsafe manifest path - GIVEN manifest entry가 configured output root 밖을 가리키거나 허용되지 않은 파일을 가리킵니다. diff --git a/openspec/changes/confluence-folder-mdx/tasks.md b/openspec/changes/confluence-folder-mdx/tasks.md index c7f2d9ebe..96975d87f 100644 --- a/openspec/changes/confluence-folder-mdx/tasks.md +++ b/openspec/changes/confluence-folder-mdx/tasks.md @@ -34,6 +34,7 @@ - [ ] 3.12 QM 대상 folder `2167636017`과 QCP folder root profile의 smoke 결과를 확인합니다. - [x] 3.13 focused Python test, `git diff --check`, 관련 `rg` source scan을 실행합니다. - [x] 3.14 ephemeral container의 manifest directory mount, atomic replace, 공유 output root의 profile 소유권 이전 회귀 테스트를 추가합니다. +- [x] 3.15 `full-all` catalog 선갱신과 profile 간 current output path 충돌 사전 차단 회귀 테스트를 추가합니다. 권장 focused 명령: