|
1 | 1 | import re |
2 | 2 | from pathlib import Path |
| 3 | +from typing import Optional |
3 | 4 |
|
| 5 | +from atlassian import Confluence |
4 | 6 | from markdownify import markdownify |
5 | 7 |
|
6 | | -from .client import Page |
| 8 | +from .client import ( |
| 9 | + Attachment, |
| 10 | + Page, |
| 11 | + download_attachment, |
| 12 | + fetch_attachments, |
| 13 | +) |
7 | 14 | from .config import OutputConfig |
8 | 15 |
|
9 | 16 |
|
@@ -42,27 +49,132 @@ def render_page(page: Page, config: OutputConfig) -> str: |
42 | 49 | return "\n".join(lines) |
43 | 50 |
|
44 | 51 |
|
45 | | -def export_page(page: Page, config: OutputConfig) -> Path: |
| 52 | +def export_page( |
| 53 | + page: Page, |
| 54 | + config: OutputConfig, |
| 55 | + confluence: Optional[Confluence] = None, |
| 56 | +) -> Path: |
46 | 57 | """Export a single page to a Markdown file. Returns the file path.""" |
47 | | - content = render_page(page, config) |
48 | | - filename = _safe_filename(config.filename_pattern.format(title=page.title)) + ".md" |
49 | 58 | output_dir = Path(config.directory) |
50 | 59 | output_dir.mkdir(parents=True, exist_ok=True) |
| 60 | + |
| 61 | + body = page.body |
| 62 | + if confluence: |
| 63 | + body = _process_drawio_macros(body, page, output_dir, confluence) |
| 64 | + |
| 65 | + content = render_page( |
| 66 | + Page( |
| 67 | + id=page.id, |
| 68 | + title=page.title, |
| 69 | + space_key=page.space_key, |
| 70 | + body=body, |
| 71 | + labels=page.labels, |
| 72 | + url=page.url, |
| 73 | + version=page.version, |
| 74 | + parent_title=page.parent_title, |
| 75 | + ), |
| 76 | + config, |
| 77 | + ) |
| 78 | + |
| 79 | + filename = _safe_filename(config.filename_pattern.format(title=page.title)) + ".md" |
51 | 80 | filepath = output_dir / filename |
52 | 81 | filepath.write_text(content, encoding="utf-8") |
53 | 82 | return filepath |
54 | 83 |
|
55 | 84 |
|
56 | | -def export_pages(pages: list[Page], config: OutputConfig) -> list[Path]: |
| 85 | +def export_pages( |
| 86 | + pages: list[Page], |
| 87 | + config: OutputConfig, |
| 88 | + confluence: Optional[Confluence] = None, |
| 89 | +) -> list[Path]: |
57 | 90 | """Export multiple pages to Markdown files.""" |
58 | | - return [export_page(page, config) for page in pages] |
| 91 | + return [export_page(page, config, confluence) for page in pages] |
59 | 92 |
|
60 | 93 |
|
61 | 94 | def _convert_body(html: str) -> str: |
62 | 95 | """Convert Confluence storage format (HTML) to Markdown.""" |
63 | 96 | return markdownify(html, heading_style="ATX", strip=["style"]) |
64 | 97 |
|
65 | 98 |
|
| 99 | +def _extract_drawio_diagram_names(html: str) -> list[str]: |
| 100 | + """Extract draw.io diagram names from Confluence storage format HTML.""" |
| 101 | + names: list[str] = [] |
| 102 | + # Confluence macros use ac: namespace prefixes which aren't valid XML |
| 103 | + # without namespace declarations, so we use regex to extract them. |
| 104 | + macro_pattern = re.compile( |
| 105 | + r'<ac:structured-macro[^>]*ac:name=["\']drawio["\'][^>]*>' |
| 106 | + r"(.*?)</ac:structured-macro>", |
| 107 | + re.DOTALL, |
| 108 | + ) |
| 109 | + param_pattern = re.compile( |
| 110 | + r'<ac:parameter[^>]*ac:name=["\']diagramName["\'][^>]*>' |
| 111 | + r"(.*?)</ac:parameter>", |
| 112 | + re.DOTALL, |
| 113 | + ) |
| 114 | + for macro_match in macro_pattern.finditer(html): |
| 115 | + macro_body = macro_match.group(1) |
| 116 | + param_match = param_pattern.search(macro_body) |
| 117 | + if param_match: |
| 118 | + names.append(param_match.group(1).strip()) |
| 119 | + return names |
| 120 | + |
| 121 | + |
| 122 | +def _find_drawio_png( |
| 123 | + diagram_name: str, attachments: list[Attachment] |
| 124 | +) -> Optional[Attachment]: |
| 125 | + """Find the PNG attachment for a draw.io diagram.""" |
| 126 | + # draw.io stores previews with various naming conventions |
| 127 | + candidates = [ |
| 128 | + f"{diagram_name}.png", |
| 129 | + f"{diagram_name}.drawio.png", |
| 130 | + ] |
| 131 | + for att in attachments: |
| 132 | + if att.title in candidates: |
| 133 | + return att |
| 134 | + return None |
| 135 | + |
| 136 | + |
| 137 | +def _process_drawio_macros( |
| 138 | + html: str, |
| 139 | + page: Page, |
| 140 | + output_dir: Path, |
| 141 | + confluence: Confluence, |
| 142 | +) -> str: |
| 143 | + """Replace draw.io macros with image references and download PNGs.""" |
| 144 | + diagram_names = _extract_drawio_diagram_names(html) |
| 145 | + if not diagram_names: |
| 146 | + return html |
| 147 | + |
| 148 | + attachments = fetch_attachments(confluence, page.id) |
| 149 | + |
| 150 | + macro_pattern = re.compile( |
| 151 | + r'<ac:structured-macro[^>]*ac:name=["\']drawio["\'][^>]*>' |
| 152 | + r"(.*?)</ac:structured-macro>", |
| 153 | + re.DOTALL, |
| 154 | + ) |
| 155 | + param_pattern = re.compile( |
| 156 | + r'<ac:parameter[^>]*ac:name=["\']diagramName["\'][^>]*>' |
| 157 | + r"(.*?)</ac:parameter>", |
| 158 | + re.DOTALL, |
| 159 | + ) |
| 160 | + |
| 161 | + def _replace_macro(match: re.Match[str]) -> str: |
| 162 | + macro_body = match.group(1) |
| 163 | + param_match = param_pattern.search(macro_body) |
| 164 | + if not param_match: |
| 165 | + return match.group(0) |
| 166 | + |
| 167 | + diagram_name = param_match.group(1).strip() |
| 168 | + png_attachment = _find_drawio_png(diagram_name, attachments) |
| 169 | + if not png_attachment: |
| 170 | + return match.group(0) |
| 171 | + |
| 172 | + download_attachment(confluence, png_attachment, output_dir) |
| 173 | + return f'<img src="{png_attachment.title}" alt="{diagram_name}" />' |
| 174 | + |
| 175 | + return macro_pattern.sub(_replace_macro, html) |
| 176 | + |
| 177 | + |
66 | 178 | def _safe_filename(name: str) -> str: |
67 | 179 | """Sanitize a string for use as a filename.""" |
68 | 180 | # Replace characters that are problematic in filenames |
|
0 commit comments