Skip to content

Commit 9be73e9

Browse files
feat: add folder URL support to folder export command
- Add Folder.from_url() method to parse folder URLs and extract IDs - Support multiple URL patterns: /spaces/SPACE/folders/ID, /spaces/SPACE/pages/folders/ID - Update folders command to accept both IDs and URLs with automatic detection - Add comprehensive unit tests for URL parsing (4 new tests covering different patterns) - Update README.md with folder URL usage examples - Fix test infrastructure in conftest.py to handle module-level Confluence instance All 89 unit tests passing. Folder export now supports the same flexible input methods as page exports, allowing users to easily export folders by copying URLs from their browser.
1 parent a58b974 commit 9be73e9

File tree

5 files changed

+127
-5
lines changed

5 files changed

+127
-5
lines changed

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ Export all Confluence pages within a folder and all its subfolders by folder ID:
9999
confluence-markdown-exporter folders <folder-id e.g. 3491123> <output path e.g. ./output_path/>
100100
```
101101

102+
or by URL:
103+
104+
```sh
105+
confluence-markdown-exporter folders <folder-url e.g. https://company.atlassian.net/wiki/spaces/MYSPACE/folders/3491123> <output path e.g. ./output_path/>
106+
```
107+
102108
This command **recursively exports all pages** from the specified folder and any nested subfolders within it. You can find the folder ID in the Confluence URL when viewing a folder, or from the folder's properties in Confluence.
103109

104110
#### 2.5. Export all Spaces

confluence_markdown_exporter/confluence.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,41 @@ def from_id(cls, folder_id: str) -> "Folder":
259259
msg = f"Could not access folder with ID {folder_id}: {e}"
260260
raise ValueError(msg) from e
261261

262+
@classmethod
263+
def from_url(cls, folder_url: str) -> "Folder":
264+
"""Retrieve a Folder object given a Confluence folder URL.
265+
266+
Supports URL patterns like:
267+
- https://company.atlassian.net/wiki/spaces/SPACE/folders/123456
268+
- https://company.atlassian.net/wiki/spaces/SPACE/pages/folders/123456
269+
"""
270+
url = urllib.parse.urlparse(folder_url)
271+
hostname = url.hostname
272+
if hostname and hostname not in str(settings.auth.confluence.url):
273+
global confluence # noqa: PLW0603
274+
set_setting("auth.confluence.url", f"{url.scheme}://{hostname}/")
275+
confluence = get_confluence_instance() # Refresh instance with new URL
276+
277+
path = url.path.rstrip("/")
278+
279+
# Try pattern: /wiki/spaces/SPACE/folders/123456
280+
if match := re.search(r"/wiki/spaces/[^/]+/folders/(\d+)", path):
281+
folder_id = match.group(1)
282+
return Folder.from_id(folder_id)
283+
284+
# Try pattern: /wiki/spaces/SPACE/pages/folders/123456
285+
if match := re.search(r"/wiki/spaces/[^/]+/pages/folders/(\d+)", path):
286+
folder_id = match.group(1)
287+
return Folder.from_id(folder_id)
288+
289+
# Try pattern: /wiki/.+?/folders/123456 (generic)
290+
if match := re.search(r"/wiki/.+?/folders/(\d+)", path):
291+
folder_id = match.group(1)
292+
return Folder.from_id(folder_id)
293+
294+
msg = f"Could not parse folder URL {folder_url}."
295+
raise ValueError(msg)
296+
262297

263298
class Label(BaseModel):
264299
id: str

confluence_markdown_exporter/main.py

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ def spaces(
8181

8282
@app.command(help="Export all Confluence pages within one or more folders to Markdown.")
8383
def folders(
84-
folder_ids: Annotated[list[str], typer.Argument(help="Folder ID(s)")],
84+
folders: Annotated[list[str], typer.Argument(help="Folder ID(s) or URL(s)")],
8585
output_path: Annotated[
8686
Path | None,
8787
typer.Option(
@@ -91,11 +91,16 @@ def folders(
9191
) -> None:
9292
from confluence_markdown_exporter.confluence import Folder
9393

94-
with measure(f"Export folders {', '.join(folder_ids)}"):
95-
for folder_id in folder_ids:
94+
with measure(f"Export folders {', '.join(folders)}"):
95+
for folder in folders:
9696
override_output_path_config(output_path)
97-
folder = Folder.from_id(folder_id)
98-
folder.export()
97+
# Detect if it's a URL or ID
98+
_folder = (
99+
Folder.from_url(folder)
100+
if folder.startswith(("http://", "https://"))
101+
else Folder.from_id(folder)
102+
)
103+
_folder.export()
99104

100105

101106
@app.command(help="Export all Confluence pages across all spaces to Markdown.")

tests/conftest.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from pathlib import Path
66
from typing import Any
77
from unittest.mock import MagicMock
8+
from unittest.mock import patch
89

910
import pytest
1011
from pydantic import AnyHttpUrl
@@ -17,6 +18,22 @@
1718
from confluence_markdown_exporter.utils.app_data_store import ExportConfig
1819

1920

21+
def pytest_configure(config: pytest.Config) -> None:
22+
"""Configure pytest by mocking the Confluence instance before import."""
23+
# Mock get_confluence_instance to avoid authentication during test collection
24+
# This is needed because confluence.py creates a module-level instance
25+
patcher = patch("confluence_markdown_exporter.api_clients.get_confluence_instance")
26+
mock = patcher.start()
27+
mock_client = MagicMock()
28+
mock.return_value = mock_client
29+
30+
# Import the module now with the mock in place
31+
import confluence_markdown_exporter.confluence # noqa: F401
32+
33+
# Stop the patcher after the module is loaded so individual tests can mock as needed
34+
patcher.stop()
35+
36+
2037
@pytest.fixture
2138
def temp_config_dir() -> Generator[Path, None, None]:
2239
"""Create a temporary directory for test configuration."""

tests/unit/test_confluence.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,65 @@ def test_from_id(
164164
assert folder.title == "Test Folder"
165165
mock_get_folder.assert_called_once_with("123456")
166166

167+
@patch("confluence_markdown_exporter.confluence.Folder.from_id")
168+
@patch("confluence_markdown_exporter.confluence.settings")
169+
def test_from_url_spaces_folders_pattern(
170+
self, mock_settings: MagicMock, mock_from_id: MagicMock
171+
) -> None:
172+
"""Test creating Folder from URL with /spaces/SPACE/folders/ pattern."""
173+
mock_settings.auth.confluence.url = "https://company.atlassian.net/"
174+
175+
mock_folder = MagicMock()
176+
mock_from_id.return_value = mock_folder
177+
178+
url = "https://company.atlassian.net/wiki/spaces/MYSPACE/folders/123456"
179+
result = Folder.from_url(url)
180+
181+
mock_from_id.assert_called_once_with("123456")
182+
assert result == mock_folder
183+
184+
@patch("confluence_markdown_exporter.confluence.Folder.from_id")
185+
@patch("confluence_markdown_exporter.confluence.settings")
186+
def test_from_url_pages_folders_pattern(
187+
self, mock_settings: MagicMock, mock_from_id: MagicMock
188+
) -> None:
189+
"""Test creating Folder from URL with /pages/folders/ pattern."""
190+
mock_settings.auth.confluence.url = "https://company.atlassian.net/"
191+
192+
mock_folder = MagicMock()
193+
mock_from_id.return_value = mock_folder
194+
195+
url = "https://company.atlassian.net/wiki/spaces/MYSPACE/pages/folders/789012"
196+
result = Folder.from_url(url)
197+
198+
mock_from_id.assert_called_once_with("789012")
199+
assert result == mock_folder
200+
201+
@patch("confluence_markdown_exporter.confluence.Folder.from_id")
202+
@patch("confluence_markdown_exporter.confluence.settings")
203+
def test_from_url_generic_folders_pattern(
204+
self, mock_settings: MagicMock, mock_from_id: MagicMock
205+
) -> None:
206+
"""Test creating Folder from URL with generic /folders/ pattern."""
207+
mock_settings.auth.confluence.url = "https://company.atlassian.net/"
208+
209+
mock_folder = MagicMock()
210+
mock_from_id.return_value = mock_folder
211+
212+
url = "https://company.atlassian.net/wiki/x/folders/345678"
213+
result = Folder.from_url(url)
214+
215+
mock_from_id.assert_called_once_with("345678")
216+
assert result == mock_folder
217+
218+
@patch("confluence_markdown_exporter.confluence.settings")
219+
def test_from_url_invalid_url(self, mock_settings: MagicMock) -> None:
220+
"""Test that invalid folder URL raises ValueError."""
221+
mock_settings.auth.confluence.url = "https://company.atlassian.net/"
222+
223+
with pytest.raises(ValueError, match="Could not parse folder URL"):
224+
Folder.from_url("https://company.atlassian.net/wiki/invalid/path")
225+
167226
@patch("confluence_markdown_exporter.confluence.get_folder_children")
168227
def test_pages_property_with_pages(self, mock_get_children: MagicMock) -> None:
169228
"""Test pages property returns page IDs."""

0 commit comments

Comments
 (0)