Skip to content

Commit 6a6eecd

Browse files
authored
Merge pull request #1 from splieth/feature/drawio
Add draw.io diagram export as PNG
2 parents 028d4e4 + a5bda65 commit 6a6eecd

6 files changed

Lines changed: 327 additions & 13 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
name = "confluence2md"
33
version = "0.2.0"
44
description = "Export Confluence pages to Markdown files"
5+
readme = "README.md"
56
requires-python = ">=3.12"
67
dependencies = [
78
"atlassian-python-api>=3.41",

src/confluence2md/cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -80,14 +80,14 @@ def _handle_export(args: argparse.Namespace, config: Config) -> None:
8080

8181
if args.page_id:
8282
page = fetch_page(confluence, args.page_id)
83-
path = export_page(page, config.output)
83+
path = export_page(page, config.output, confluence)
8484
print(f"Exported: {path}")
8585

8686
include_children = args.include_children or config.output.include_children
8787
if include_children:
8888
children = fetch_child_pages(confluence, args.page_id)
8989
if children:
90-
child_paths = export_pages(children, config.output)
90+
child_paths = export_pages(children, config.output, confluence)
9191
for p in child_paths:
9292
print(f"Exported: {p}")
9393
total = 1 + len(child_paths)
@@ -99,7 +99,7 @@ def _handle_export(args: argparse.Namespace, config: Config) -> None:
9999
if not pages:
100100
print("No pages found.")
101101
return
102-
paths = export_pages(pages, config.output)
102+
paths = export_pages(pages, config.output, confluence)
103103
for p in paths:
104104
print(f"Exported: {p}")
105105
print(f"\n{len(paths)} page(s) exported to {config.output.directory}")
@@ -108,7 +108,7 @@ def _handle_export(args: argparse.Namespace, config: Config) -> None:
108108
if not pages:
109109
print("No pages found.")
110110
return
111-
paths = export_pages(pages, config.output)
111+
paths = export_pages(pages, config.output, confluence)
112112
for p in paths:
113113
print(f"Exported: {p}")
114114
print(f"\n{len(paths)} page(s) exported to {config.output.directory}")

src/confluence2md/client.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from dataclasses import dataclass, field
2+
from pathlib import Path
23
from typing import Any
34

45
from atlassian import Confluence
@@ -18,6 +19,14 @@ class Page:
1819
parent_title: str = ""
1920

2021

22+
@dataclass
23+
class Attachment:
24+
id: str
25+
title: str
26+
media_type: str
27+
download_url: str
28+
29+
2130
def connect(config: ConfluenceConfig) -> Confluence:
2231
"""Create an authenticated Confluence client."""
2332
if config.username:
@@ -151,3 +160,34 @@ def _build_page_url(raw: dict[str, Any], confluence: Confluence) -> str:
151160
base = links.get("base", confluence.url.rstrip("/"))
152161
return f"{base}{webui}"
153162
return ""
163+
164+
165+
def fetch_attachments(confluence: Confluence, page_id: str) -> list[Attachment]:
166+
"""Fetch all attachments for a page."""
167+
results = confluence.get_attachments_from_content(page_id) # type: ignore[no-untyped-call]
168+
attachments = []
169+
for item in results.get("results", []):
170+
download_url = item.get("_links", {}).get("download", "")
171+
attachments.append(
172+
Attachment(
173+
id=str(item.get("id", "")),
174+
title=item.get("title", ""),
175+
media_type=item.get("metadata", {}).get("mediaType", ""),
176+
download_url=download_url,
177+
)
178+
)
179+
return attachments
180+
181+
182+
def download_attachment(
183+
confluence: Confluence, attachment: Attachment, dest: Path
184+
) -> Path:
185+
"""Download an attachment to the given directory. Returns the file path."""
186+
dest.mkdir(parents=True, exist_ok=True)
187+
filepath = dest / attachment.title
188+
response = confluence.request(
189+
method="GET",
190+
path=attachment.download_url,
191+
)
192+
filepath.write_bytes(response.content)
193+
return filepath

src/confluence2md/renderer.py

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import re
22
from pathlib import Path
3+
from typing import Optional
34

5+
from atlassian import Confluence
46
from markdownify import markdownify
57

6-
from .client import Page
8+
from .client import (
9+
Attachment,
10+
Page,
11+
download_attachment,
12+
fetch_attachments,
13+
)
714
from .config import OutputConfig
815

916

@@ -42,27 +49,132 @@ def render_page(page: Page, config: OutputConfig) -> str:
4249
return "\n".join(lines)
4350

4451

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:
4657
"""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"
4958
output_dir = Path(config.directory)
5059
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"
5180
filepath = output_dir / filename
5281
filepath.write_text(content, encoding="utf-8")
5382
return filepath
5483

5584

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]:
5790
"""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]
5992

6093

6194
def _convert_body(html: str) -> str:
6295
"""Convert Confluence storage format (HTML) to Markdown."""
6396
return markdownify(html, heading_style="ATX", strip=["style"])
6497

6598

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+
66178
def _safe_filename(name: str) -> str:
67179
"""Sanitize a string for use as a filename."""
68180
# Replace characters that are problematic in filenames

0 commit comments

Comments
 (0)