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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 40 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A CLI tool to convert Confluence pages to Markdown format with a single command.
- Convert single Confluence pages to Markdown
- Convert entire page trees with hierarchical structure
- Download and embed images from Confluence pages
- Support for Confluence Cloud with API authentication
- Works with both Confluence Cloud and self-hosted Server / Data Center instances
- Enhanced support for Confluence-specific elements (user references, status badges, time elements)
- Clean, readable Markdown output
- Cross-platform support (Linux, macOS, Windows)
Expand All @@ -38,13 +38,24 @@ go install github.com/jackchuka/confluence-md/cmd/confluence-md@latest

### Authentication

You'll need:
The tool auto-detects whether a URL points to Confluence Cloud (`*.atlassian.net`)
or a self-hosted Server / Data Center instance. You can force the mode with
`--type cloud|server`.

- Your Confluence email address
- A Confluence API token ([create one here](https://id.atlassian.com/manage-profile/security/api-tokens))
**Confluence Cloud** — authenticates with your email + an API token:

- Your Confluence email address (`--email`)
- A Confluence API token ([create one here](https://id.atlassian.com/manage-profile/security/api-tokens)), passed via `--api-token`

**Self-hosted (Server / Data Center)** — authenticates with a Personal Access Token:

- A [Personal Access Token](https://confluence.atlassian.com/enterprise/using-personal-access-tokens-1026032365.html) (Confluence 7.9+, including 9.x), passed via `--api-token`
- No email is required

### Convert a Single Page

Confluence Cloud:

```bash
confluence-md page <page-url> --email your-email@example.com --api-token your-api-token
```
Expand All @@ -57,6 +68,21 @@ confluence-md page https://example.atlassian.net/wiki/spaces/SPACE/pages/12345/T
--api-token your-api-token-here
```

Self-hosted (Server / Data Center) with a Personal Access Token:

```bash
confluence-md page https://confluence.example.com/pages/viewpage.action?pageId=12345 \
--api-token your-personal-access-token
```

Self-hosted "pretty" URLs (`/display/SPACE/Page+Title`) are also supported — the
page ID is resolved automatically from the space and title:

```bash
confluence-md page "https://confluence.example.com/display/SPACE/Page+Title" \
--api-token your-personal-access-token
```

### Convert a Page Tree

Convert an entire page hierarchy:
Expand All @@ -82,8 +108,9 @@ confluence-md html page.html

### Common Options

- `--email, -e`: Your Confluence email address (**required**)
- `--api-token, -t`: Your Confluence API token (**required**)
- `--email, -e`: Your Confluence email address (**required for Cloud**)
- `--api-token, -t`: Your Confluence API token (Cloud) or Personal Access Token (self-hosted) (**required**)
- `--type`: Deployment type, `cloud` or `server` (default: auto-detect from the URL host)
- `--output, -o`: Output directory (default: current directory)
- `--output-name-template`: Go template for the markdown filename (see below)
- `--download-images`: Download images from Confluence (default: true)
Expand All @@ -107,6 +134,13 @@ confluence-md page <page-url> --email user@example.com --api-token token --downl

# Convert entire page tree
confluence-md tree <page-url> --email user@example.com --api-token token --output ./wiki

# Convert a self-hosted (Server / Data Center) page tree with a Personal Access Token
confluence-md tree https://confluence.example.com/pages/viewpage.action?pageId=12345 \
--api-token your-personal-access-token --output ./wiki

# Force the deployment type when the host isn't a *.atlassian.net address
confluence-md page <page-url> --api-token token --type server
```

### Output name templates
Expand Down
6 changes: 4 additions & 2 deletions cmd/confluence-md/commands/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ import (
type authOptions struct {
APIKey string
Email string
Type string
}

func (a *authOptions) InitFlags(cmd *cobra.Command) {
cmd.Flags().StringVarP(&a.APIKey, "api-token", "t", "", "Confluence API token (required)")
cmd.Flags().StringVarP(&a.Email, "email", "e", "", "Confluence user email (default: extracted from URL)")
cmd.Flags().StringVarP(&a.APIKey, "api-token", "t", "", "Confluence API token (Cloud) or Personal Access Token (self-hosted) (required)")
cmd.Flags().StringVarP(&a.Email, "email", "e", "", "Confluence user email (required for Cloud)")
cmd.Flags().StringVar(&a.Type, "type", "", "Deployment type: 'cloud' or 'server' (default: auto-detect from URL)")
}

type commonOptions struct {
Expand Down
16 changes: 11 additions & 5 deletions cmd/confluence-md/commands/page.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import (
"fmt"
"os"

"github.com/jackchuka/confluence-md/internal/confluence"
"github.com/jackchuka/confluence-md/internal/converter"
"github.com/spf13/cobra"
)
Expand Down Expand Up @@ -50,7 +49,6 @@ func init() {

// Required flags
_ = pageCmd.MarkFlagRequired("api-token")
_ = pageCmd.MarkFlagRequired("email")
}

func runPage(_ *cobra.Command, args []string) error {
Expand All @@ -61,7 +59,7 @@ func runPage(_ *cobra.Command, args []string) error {
pageURL := args[0]

// Extract base URL from page URL
pageInfo, err := urlToPageInfo(pageURL)
pageInfo, err := urlToPageInfo(pageURL, pageOpts.Type)
if err != nil {
return fmt.Errorf("invalid Confluence URL: %w", err)
}
Expand All @@ -73,7 +71,15 @@ func runPage(_ *cobra.Command, args []string) error {
pageOpts.OutputNamer = namer

// Create Confluence client
client := confluence.NewClient(pageInfo.BaseURL, pageOpts.Email, pageOpts.APIKey)
client, err := newClientForAuth(pageInfo, pageOpts.authOptions)
if err != nil {
return err
}

// Self-hosted pretty URLs omit the page ID; resolve it from space + title.
if err := resolvePageID(client, &pageInfo); err != nil {
return fmt.Errorf("failed to resolve page ID: %w", err)
}

page, err := client.GetPage(pageInfo.PageID)
if err != nil {
Expand All @@ -89,7 +95,7 @@ func runPage(_ *cobra.Command, args []string) error {
result := convertSinglePage(
client,
page,
pageInfo.BaseURL,
pageInfo.Site(),
pageOpts,
)

Expand Down
173 changes: 149 additions & 24 deletions cmd/confluence-md/commands/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ type PageConversionResult struct {
}

// convertSinglePage handles the full conversion pipeline for a single page
func convertSinglePage(client confluence.Client, page *confluenceModel.ConfluencePage, baseURL string, opts PageOptions) *PageConversionResult {
return convertSinglePageWithPath(client, page, baseURL, "", opts)
func convertSinglePage(client confluence.Client, page *confluenceModel.ConfluencePage, site confluenceModel.SiteInfo, opts PageOptions) *PageConversionResult {
return convertSinglePageWithPath(client, page, site, "", opts)
}

// convertSinglePageWithPath handles conversion with a custom output path (for tree structure)
func convertSinglePageWithPath(client confluence.Client, page *confluenceModel.ConfluencePage, baseURL, outputPath string, opts PageOptions) *PageConversionResult {
func convertSinglePageWithPath(client confluence.Client, page *confluenceModel.ConfluencePage, site confluenceModel.SiteInfo, outputPath string, opts PageOptions) *PageConversionResult {
result := &PageConversionResult{
PageID: page.ID,
Title: page.Title,
Expand All @@ -78,7 +78,7 @@ func convertSinglePageWithPath(client confluence.Client, page *confluenceModel.C
options = append(options, converter.WithDownloadAttachments(opts.ImageFolder))
}
conv := converter.NewConverter(client, options...)
doc, err := conv.ConvertPage(page, baseURL, filepath.Dir(outputPath))
doc, err := conv.ConvertPage(page, site, filepath.Dir(outputPath))
if err != nil {
result.Error = fmt.Errorf("failed to convert page: %w", err)
return result
Expand Down Expand Up @@ -112,7 +112,57 @@ func printConversionResult(result *PageConversionResult) {
fmt.Println()
}

func urlToPageInfo(pageURL string) (confluenceModel.PageURLInfo, error) {
// routeMarkers are the path segments that separate the instance context path
// from a Confluence route. The context path is everything before the earliest
// marker (e.g. "/wiki" for Cloud, "" or "/confluence" for self-hosted).
var routeMarkers = []string{"/spaces/", "/display/", "/pages/"}

// resolveDeployment determines the deployment type from an explicit override or,
// failing that, from the host (Cloud instances live under *.atlassian.net).
func resolveDeployment(host, override string) (confluenceModel.Deployment, error) {
switch strings.ToLower(strings.TrimSpace(override)) {
case "cloud":
return confluenceModel.DeploymentCloud, nil
case "server", "datacenter", "dc", "self-hosted":
return confluenceModel.DeploymentServer, nil
case "":
if strings.HasSuffix(strings.ToLower(host), ".atlassian.net") {
return confluenceModel.DeploymentCloud, nil
}
return confluenceModel.DeploymentServer, nil
default:
return "", fmt.Errorf("unknown deployment type %q (expected 'cloud' or 'server')", override)
}
}

// contextPathFromPath returns the path prefix in front of the first Confluence
// route marker, without a trailing slash.
func contextPathFromPath(path string) string {
earliest := -1
for _, marker := range routeMarkers {
if idx := strings.Index(path, marker); idx != -1 && (earliest == -1 || idx < earliest) {
earliest = idx
}
}
if earliest <= 0 {
return ""
}
return strings.TrimSuffix(path[:earliest], "/")
}

func isNumeric(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}

func urlToPageInfo(pageURL, typeOverride string) (confluenceModel.PageURLInfo, error) {
if pageURL == "" {
return confluenceModel.PageURLInfo{}, fmt.Errorf("URL is empty")
}
Expand All @@ -122,34 +172,109 @@ func urlToPageInfo(pageURL string) (confluenceModel.PageURLInfo, error) {
return confluenceModel.PageURLInfo{}, fmt.Errorf("invalid URL: %w", err)
}

deployment, err := resolveDeployment(u.Host, typeOverride)
if err != nil {
return confluenceModel.PageURLInfo{}, err
}

baseURL := fmt.Sprintf("%s://%s", u.Scheme, u.Host)
var pageID string
var spaceKey string
var title string
contextPath := contextPathFromPath(u.Path)

var pageID, spaceKey, title string

// Prefer an explicit pageId query parameter (self-hosted viewpage.action links).
if id := u.Query().Get("pageId"); isNumeric(id) {
pageID = id
}

// Extract page ID from path
// Path format: /wiki/spaces/SPACE/pages/12345/Title
// Supported path forms:
// /wiki/spaces/SPACE/pages/12345/Title (Cloud)
// /spaces/SPACE/pages/12345/Title (self-hosted, modern)
// /display/SPACE/Page+Title (self-hosted, pretty)
parts := strings.Split(u.Path, "/")
for i, part := range parts {
if part == "spaces" && i+1 < len(parts) {
spaceKey = parts[i+1]
}
if part == "pages" && i+1 < len(parts) {
pageID = parts[i+1]
}
if i == len(parts)-1 {
title = part
switch part {
case "spaces":
if i+1 < len(parts) {
spaceKey = parts[i+1]
}
case "display":
if i+1 < len(parts) {
spaceKey = parts[i+1]
}
if i+2 < len(parts) {
// Confluence encodes spaces as '+' in pretty display URLs.
title = strings.ReplaceAll(parts[i+2], "+", " ")
}
case "pages":
if pageID == "" && i+1 < len(parts) && isNumeric(parts[i+1]) {
pageID = parts[i+1]
}
if i+2 < len(parts) && parts[i+2] != "" {
title = parts[i+2]
}
}
}

info := confluenceModel.PageURLInfo{
BaseURL: baseURL,
PageID: pageID,
SpaceKey: spaceKey,
Title: title,
Deployment: deployment,
ContextPath: contextPath,
}

// A missing page ID is only recoverable for self-hosted pretty URLs, where
// the caller can resolve it from the space key and title via the API.
if pageID == "" {
if deployment == confluenceModel.DeploymentServer && spaceKey != "" && title != "" {
return info, nil
}
return confluenceModel.PageURLInfo{}, fmt.Errorf("could not extract page ID from URL")
}

return confluenceModel.PageURLInfo{
BaseURL: baseURL,
PageID: pageID,
SpaceKey: spaceKey,
Title: title,
}, nil
return info, nil
}

// newClientForAuth validates authentication for the resolved deployment and
// builds a Confluence client.
func newClientForAuth(info confluenceModel.PageURLInfo, auth authOptions) (confluence.Client, error) {
if auth.APIKey == "" {
return nil, fmt.Errorf("an API token is required (use --api-token)")
}

cfg := confluence.Config{
BaseURL: info.BaseURL,
Deployment: info.Deployment,
ContextPath: info.ContextPath,
}

if info.Deployment == confluenceModel.DeploymentServer {
// Self-hosted uses a Personal Access Token via Bearer auth.
cfg.Token = auth.APIKey
} else {
if auth.Email == "" {
return nil, fmt.Errorf("an email is required for Confluence Cloud (use --email)")
}
cfg.Email = auth.Email
cfg.APIToken = auth.APIKey
}

return confluence.NewClient(cfg), nil
}

// resolvePageID fills in a missing page ID by looking it up from the space key
// and title (used for self-hosted pretty URLs).
func resolvePageID(client confluence.Client, info *confluenceModel.PageURLInfo) error {
if info.PageID != "" {
return nil
}

id, err := client.FindPageID(info.SpaceKey, info.Title)
if err != nil {
return err
}
info.PageID = id
return nil
}
Loading