diff --git a/README.md b/README.md index fed61ca..6353ee7 100644 --- a/README.md +++ b/README.md @@ -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) @@ -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 --email your-email@example.com --api-token your-api-token ``` @@ -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: @@ -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) @@ -107,6 +134,13 @@ confluence-md page --email user@example.com --api-token token --downl # Convert entire page tree confluence-md tree --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 --api-token token --type server ``` ### Output name templates diff --git a/cmd/confluence-md/commands/options.go b/cmd/confluence-md/commands/options.go index f2bf516..16b4e5d 100644 --- a/cmd/confluence-md/commands/options.go +++ b/cmd/confluence-md/commands/options.go @@ -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 { diff --git a/cmd/confluence-md/commands/page.go b/cmd/confluence-md/commands/page.go index 2709c30..db9551e 100644 --- a/cmd/confluence-md/commands/page.go +++ b/cmd/confluence-md/commands/page.go @@ -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" ) @@ -50,7 +49,6 @@ func init() { // Required flags _ = pageCmd.MarkFlagRequired("api-token") - _ = pageCmd.MarkFlagRequired("email") } func runPage(_ *cobra.Command, args []string) error { @@ -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) } @@ -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 { @@ -89,7 +95,7 @@ func runPage(_ *cobra.Command, args []string) error { result := convertSinglePage( client, page, - pageInfo.BaseURL, + pageInfo.Site(), pageOpts, ) diff --git a/cmd/confluence-md/commands/shared.go b/cmd/confluence-md/commands/shared.go index b0562bf..9c59321 100644 --- a/cmd/confluence-md/commands/shared.go +++ b/cmd/confluence-md/commands/shared.go @@ -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, @@ -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 @@ -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") } @@ -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 } diff --git a/cmd/confluence-md/commands/shared_test.go b/cmd/confluence-md/commands/shared_test.go new file mode 100644 index 0000000..dfa9552 --- /dev/null +++ b/cmd/confluence-md/commands/shared_test.go @@ -0,0 +1,153 @@ +package commands + +import ( + "testing" + + confluenceModel "github.com/jackchuka/confluence-md/internal/confluence/model" +) + +func TestUrlToPageInfo(t *testing.T) { + tests := []struct { + name string + url string + typeOverride string + wantErr bool + wantBase string + wantContext string + wantPageID string + wantSpace string + wantTitle string + wantDeploy confluenceModel.Deployment + }{ + { + name: "cloud standard", + url: "https://example.atlassian.net/wiki/spaces/SPACE/pages/12345/Some+Title", + wantBase: "https://example.atlassian.net", + wantContext: "/wiki", + wantPageID: "12345", + wantSpace: "SPACE", + wantDeploy: confluenceModel.DeploymentCloud, + }, + { + name: "server modern spaces url", + url: "https://wiki.example.com/spaces/SPACE/pages/999/Title", + wantBase: "https://wiki.example.com", + wantContext: "", + wantPageID: "999", + wantSpace: "SPACE", + wantDeploy: confluenceModel.DeploymentServer, + }, + { + name: "server viewpage action", + url: "https://wiki.example.com/pages/viewpage.action?pageId=456", + wantBase: "https://wiki.example.com", + wantContext: "", + wantPageID: "456", + wantDeploy: confluenceModel.DeploymentServer, + }, + { + name: "server pretty display url (page id resolved later)", + url: "https://wiki.example.com/display/SPACE/Page+Title", + wantBase: "https://wiki.example.com", + wantContext: "", + wantPageID: "", + wantSpace: "SPACE", + wantTitle: "Page Title", + wantDeploy: confluenceModel.DeploymentServer, + }, + { + name: "server with context path", + url: "https://example.com/confluence/display/SPACE/Title", + wantBase: "https://example.com", + wantContext: "/confluence", + wantSpace: "SPACE", + wantTitle: "Title", + wantDeploy: confluenceModel.DeploymentServer, + }, + { + name: "override cloud host as server", + url: "https://example.atlassian.net/pages/viewpage.action?pageId=1", + typeOverride: "server", + wantBase: "https://example.atlassian.net", + wantPageID: "1", + wantDeploy: confluenceModel.DeploymentServer, + }, + { + name: "cloud missing page id errors", + url: "https://example.atlassian.net/wiki/spaces/SPACE/overview", + wantErr: true, + }, + { + name: "unknown type errors", + url: "https://wiki.example.com/pages/viewpage.action?pageId=1", + typeOverride: "bogus", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + info, err := urlToPageInfo(tt.url, tt.typeOverride) + if tt.wantErr { + if err == nil { + t.Fatalf("expected error, got none (info=%+v)", info) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if info.BaseURL != tt.wantBase { + t.Errorf("BaseURL = %q, want %q", info.BaseURL, tt.wantBase) + } + if info.ContextPath != tt.wantContext { + t.Errorf("ContextPath = %q, want %q", info.ContextPath, tt.wantContext) + } + if info.PageID != tt.wantPageID { + t.Errorf("PageID = %q, want %q", info.PageID, tt.wantPageID) + } + if info.SpaceKey != tt.wantSpace { + t.Errorf("SpaceKey = %q, want %q", info.SpaceKey, tt.wantSpace) + } + if tt.wantTitle != "" && info.Title != tt.wantTitle { + t.Errorf("Title = %q, want %q", info.Title, tt.wantTitle) + } + if info.Deployment != tt.wantDeploy { + t.Errorf("Deployment = %q, want %q", info.Deployment, tt.wantDeploy) + } + }) + } +} + +func TestResolveDeployment(t *testing.T) { + tests := []struct { + host string + override string + want confluenceModel.Deployment + wantErr bool + }{ + {host: "example.atlassian.net", want: confluenceModel.DeploymentCloud}, + {host: "wiki.example.com", want: confluenceModel.DeploymentServer}, + {host: "wiki.example.com", override: "cloud", want: confluenceModel.DeploymentCloud}, + {host: "example.atlassian.net", override: "server", want: confluenceModel.DeploymentServer}, + {host: "example.atlassian.net", override: "self-hosted", want: confluenceModel.DeploymentServer}, + {host: "wiki.example.com", override: "nonsense", wantErr: true}, + } + + for _, tt := range tests { + got, err := resolveDeployment(tt.host, tt.override) + if tt.wantErr { + if err == nil { + t.Errorf("resolveDeployment(%q, %q): expected error", tt.host, tt.override) + } + continue + } + if err != nil { + t.Errorf("resolveDeployment(%q, %q): unexpected error %v", tt.host, tt.override, err) + continue + } + if got != tt.want { + t.Errorf("resolveDeployment(%q, %q) = %q, want %q", tt.host, tt.override, got, tt.want) + } + } +} diff --git a/cmd/confluence-md/commands/tree.go b/cmd/confluence-md/commands/tree.go index e075160..9d96559 100644 --- a/cmd/confluence-md/commands/tree.go +++ b/cmd/confluence-md/commands/tree.go @@ -58,7 +58,6 @@ func init() { // Required flags _ = treeCmd.MarkFlagRequired("api-token") - _ = treeCmd.MarkFlagRequired("email") // Processing flags treeCmd.Flags().IntVar(&treeOpts.MaxDepth, "depth", -1, "Maximum depth to traverse (-1 for unlimited)") @@ -75,7 +74,7 @@ func runTreeCommand(_ *cobra.Command, args []string) error { } pageURL := args[0] - pageInfo, err := urlToPageInfo(pageURL) + pageInfo, err := urlToPageInfo(pageURL, treeOpts.Type) if err != nil { return fmt.Errorf("invalid Confluence URL: %w", err) } @@ -91,14 +90,22 @@ func runTreeCommand(_ *cobra.Command, args []string) error { } treeOpts.OutputNamer = namer - client := confluence.NewClient(pageInfo.BaseURL, treeOpts.Email, treeOpts.APIKey) + client, err := newClientForAuth(pageInfo, treeOpts.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) + } if treeOpts.DryRun { fmt.Println("🔍 Dry run mode - analyzing page tree...") return performDryRun(client, pageInfo.PageID, &treeOpts) } - return performTreeConversion(client, pageInfo.BaseURL, pageInfo.PageID, &treeOpts) + return performTreeConversion(client, pageInfo.Site(), pageInfo.PageID, &treeOpts) } func validateTreeOptions() error { @@ -137,7 +144,7 @@ func performDryRun(client confluence.Client, rootPageID string, opts *TreeOption return nil } -func performTreeConversion(client confluence.Client, baseURL, rootPageID string, opts *TreeOptions) error { +func performTreeConversion(client confluence.Client, site confluenceModel.SiteInfo, rootPageID string, opts *TreeOptions) error { // Create output directory if err := os.MkdirAll(opts.OutputDir, 0755); err != nil { return fmt.Errorf("failed to create output directory: %w", err) @@ -151,7 +158,7 @@ func performTreeConversion(client confluence.Client, baseURL, rootPageID string, // Convert tree recursively using shared pipeline results := &ConversionResults{} - err = convertPageTree(client, tree, opts.OutputDir, baseURL, opts, results) + err = convertPageTree(client, tree, opts.OutputDir, site, opts, results) // Display results fmt.Printf("✅ Conversion complete!\n") @@ -310,7 +317,7 @@ func calculateTreeStats(node *PageNode) *TreeStats { return stats } -func convertPageTree(client confluence.Client, node *PageNode, outputDir string, baseURL string, opts *TreeOptions, results *ConversionResults) error { +func convertPageTree(client confluence.Client, node *PageNode, outputDir string, site confluenceModel.SiteInfo, opts *TreeOptions, results *ConversionResults) error { if node == nil { return nil } @@ -343,7 +350,7 @@ func convertPageTree(client confluence.Client, node *PageNode, outputDir string, } // Use shared conversion pipeline with custom path - result := convertSinglePageWithPath(client, page, baseURL, outputPath, conversionOpts) + result := convertSinglePageWithPath(client, page, site, outputPath, conversionOpts) // Use shared result display printConversionResult(result) @@ -357,7 +364,7 @@ func convertPageTree(client confluence.Client, node *PageNode, outputDir string, // Convert children for _, child := range node.Children { - if err := convertPageTree(client, child, outputDir, baseURL, opts, results); err != nil { + if err := convertPageTree(client, child, outputDir, site, opts, results); err != nil { return err } } diff --git a/internal/confluence/client.go b/internal/confluence/client.go index aab2301..22f3a28 100644 --- a/internal/confluence/client.go +++ b/internal/confluence/client.go @@ -19,24 +19,61 @@ type Client interface { GetPage(pageID string) (*model.ConfluencePage, error) GetChildPages(pageID string) ([]*model.ConfluencePage, error) DownloadAttachmentContent(attachment *model.ConfluenceAttachment) ([]byte, error) - GetUser(accountID string) (*model.ConfluenceUser, error) + GetUser(userID string) (*model.ConfluenceUser, error) + FindPageID(spaceKey, title string) (string, error) + SearchByCQL(cql string, limit int) ([]*model.ConfluencePage, error) +} + +// Config holds the connection settings for a Confluence instance. +type Config struct { + // BaseURL is the scheme and host, e.g. "https://wiki.example.com". + BaseURL string + // Deployment selects the API dialect (cloud or server). + Deployment model.Deployment + // ContextPath is the path prefix in front of the REST API, without a + // trailing slash ("/wiki" for Cloud, "" or e.g. "/confluence" for server). + ContextPath string + + // Email and APIToken are used for Cloud HTTP Basic authentication. + Email string + APIToken string + + // Token is a Personal Access Token used for self-hosted Bearer + // authentication. + Token string } // client represents a Confluence API client type client struct { - baseURL string - email string - apiToken string - httpClient *http.Client - userAgent string + siteURL string // scheme://host, no trailing slash + contextPath string // "/wiki" for Cloud, "" or "/confluence" for server + apiBase string // siteURL + contextPath + "/rest/api" + deployment model.Deployment + email string + apiToken string + token string + httpClient *http.Client + userAgent string } // NewClient creates a new Confluence API client -func NewClient(baseURL, email, apiToken string) Client { +func NewClient(cfg Config) Client { + deployment := cfg.Deployment + if deployment == "" { + deployment = model.DeploymentCloud + } + + siteURL := strings.TrimSuffix(cfg.BaseURL, "/") + contextPath := strings.TrimSuffix(cfg.ContextPath, "/") + return &client{ - baseURL: strings.TrimSuffix(baseURL, "/"), - email: email, - apiToken: apiToken, + siteURL: siteURL, + contextPath: contextPath, + apiBase: siteURL + contextPath + "/rest/api", + deployment: deployment, + email: cfg.Email, + apiToken: cfg.APIToken, + token: cfg.Token, httpClient: &http.Client{ Timeout: 60 * time.Second, }, @@ -44,17 +81,30 @@ func NewClient(baseURL, email, apiToken string) Client { } } +// setAuth applies the appropriate authentication scheme for the deployment. +func (c *client) setAuth(req *http.Request) { + if c.deployment == model.DeploymentServer { + // Self-hosted uses Personal Access Tokens via Bearer auth. + if c.token != "" { + req.Header.Set("Authorization", "Bearer "+c.token) + } + return + } + // Cloud uses HTTP Basic auth with email + API token. + req.SetBasicAuth(c.email, c.apiToken) +} + // GetPage retrieves a Confluence page by ID func (c *client) GetPage(pageID string) (*model.ConfluencePage, error) { // Build URL with expansions to get all needed data - endpoint := fmt.Sprintf("/wiki/rest/api/content/%s", pageID) + endpoint := fmt.Sprintf("/content/%s", pageID) params := url.Values{ "expand": []string{ "body.storage,metadata.labels,version,space,history,children.attachment", }, } - fullURL := c.baseURL + endpoint + "?" + params.Encode() + fullURL := c.apiBase + endpoint + "?" + params.Encode() resp, err := c.makeRequest("GET", fullURL, nil) if err != nil { @@ -83,7 +133,7 @@ const defaultChildPageLimit = 100 // GetChildPages retrieves all child pages for a given page ID func (c *client) GetChildPages(pageID string) ([]*model.ConfluencePage, error) { - endpoint := fmt.Sprintf("/wiki/rest/api/content/%s/child/page", pageID) + endpoint := fmt.Sprintf("/content/%s/child/page", pageID) params := url.Values{ "expand": []string{"body.storage,metadata.labels,version,space,history"}, "limit": []string{strconv.Itoa(defaultChildPageLimit)}, @@ -94,7 +144,7 @@ func (c *client) GetChildPages(pageID string) ([]*model.ConfluencePage, error) { for { params.Set("start", strconv.Itoa(start)) - fullURL := c.baseURL + endpoint + "?" + params.Encode() + fullURL := c.apiBase + endpoint + "?" + params.Encode() resp, err := c.makeRequest("GET", fullURL, nil) if err != nil { @@ -147,7 +197,7 @@ func (c *client) makeRequest(method, url string, body io.Reader) (*http.Response } // Set authentication - req.SetBasicAuth(c.email, c.apiToken) + c.setAuth(req) // Set headers req.Header.Set("Accept", "application/json") @@ -220,7 +270,7 @@ func (c *client) fetchBinary(downloadURL string) (*http.Response, error) { return nil, fmt.Errorf("failed to create request: %w", err) } - req.SetBasicAuth(c.email, c.apiToken) + c.setAuth(req) req.Header.Set("Accept", "*/*") req.Header.Set("User-Agent", c.userAgent) @@ -239,8 +289,8 @@ func (c *client) attachmentRESTDownloadURL(attachment *model.ConfluenceAttachmen return "", false } - return fmt.Sprintf("%s/wiki/rest/api/content/%s/child/attachment/%s/download", - c.baseURL, pageID, attachment.ID), true + return fmt.Sprintf("%s/content/%s/child/attachment/%s/download", + c.apiBase, pageID, attachment.ID), true } // pageIDFromDownloadLink extracts the parent page ID from a download link of the @@ -268,19 +318,18 @@ func (c *client) normalizeDownloadLink(link string) (string, error) { link = "/" + link } - if strings.HasPrefix(link, "/download/") { - link = "/wiki" + link - } - - if strings.HasPrefix(link, "download/") { - link = "/wiki/" + link + // Download links are relative to the instance context path ("/wiki" for + // Cloud, "" or e.g. "/confluence" for self-hosted). Prefix it unless the + // link already carries it. + if c.contextPath != "" && !strings.HasPrefix(link, c.contextPath+"/") { + link = c.contextPath + link } if strings.Contains(link, " ") { link = strings.ReplaceAll(link, " ", "%20") } - full := c.baseURL + link + full := c.siteURL + link parsed, err := url.Parse(full) if err != nil { return "", fmt.Errorf("invalid attachment url %s: %w", full, err) @@ -288,21 +337,27 @@ func (c *client) normalizeDownloadLink(link string) (string, error) { return parsed.String(), nil } -// GetUser retrieves user information by account ID -func (c *client) GetUser(accountID string) (*model.ConfluenceUser, error) { - endpoint := fmt.Sprintf("/wiki/rest/api/user?accountId=%s", url.QueryEscape(accountID)) - fullURL := c.baseURL + endpoint +// GetUser retrieves user information by identifier. On Cloud the identifier is +// an account ID; on self-hosted instances it is a user key. +func (c *client) GetUser(userID string) (*model.ConfluenceUser, error) { + param := "accountId" + if c.deployment == model.DeploymentServer { + param = "key" + } + + query := url.Values{param: []string{userID}} + fullURL := c.apiBase + "/user?" + query.Encode() resp, err := c.makeRequest("GET", fullURL, nil) if err != nil { - return nil, fmt.Errorf("failed to get user %s: %w", accountID, err) + return nil, fmt.Errorf("failed to get user %s: %w", userID, err) } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return nil, c.handleErrorResponse(resp, fmt.Sprintf("get user %s", accountID)) + return nil, c.handleErrorResponse(resp, fmt.Sprintf("get user %s", userID)) } var user model.ConfluenceUser @@ -313,6 +368,88 @@ func (c *client) GetUser(accountID string) (*model.ConfluenceUser, error) { return &user, nil } +// FindPageID resolves a page ID from its space key and title. This is primarily +// used for self-hosted "pretty" URLs (e.g. /display/SPACE/Page+Title) that do +// not embed the numeric page ID. +func (c *client) FindPageID(spaceKey, title string) (string, error) { + if spaceKey == "" || title == "" { + return "", fmt.Errorf("both space key and title are required to look up a page ID") + } + + query := url.Values{ + "spaceKey": []string{spaceKey}, + "title": []string{title}, + "limit": []string{"1"}, + } + fullURL := c.apiBase + "/content?" + query.Encode() + + resp, err := c.makeRequest("GET", fullURL, nil) + if err != nil { + return "", fmt.Errorf("failed to look up page %q in space %q: %w", title, spaceKey, err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return "", c.handleErrorResponse(resp, fmt.Sprintf("look up page %q in space %q", title, spaceKey)) + } + + var searchResult model.ConfluenceSearchResult + if err := json.NewDecoder(resp.Body).Decode(&searchResult); err != nil { + return "", fmt.Errorf("failed to decode page lookup response: %w", err) + } + + if len(searchResult.Results) == 0 || searchResult.Results[0].ID == "" { + return "", fmt.Errorf("no page titled %q found in space %q", title, spaceKey) + } + + return searchResult.Results[0].ID, nil +} + +const defaultSearchLimit = 50 + +// SearchByCQL runs a CQL query and returns the matching pages. It is used to +// resolve dynamic list macros (e.g. contentbylabel) into concrete page links. +func (c *client) SearchByCQL(cql string, limit int) ([]*model.ConfluencePage, error) { + if strings.TrimSpace(cql) == "" { + return nil, fmt.Errorf("cql query is required") + } + if limit <= 0 { + limit = defaultSearchLimit + } + + query := url.Values{ + "cql": []string{cql}, + "limit": []string{strconv.Itoa(limit)}, + "expand": []string{"space,version"}, + } + fullURL := c.apiBase + "/content/search?" + query.Encode() + + resp, err := c.makeRequest("GET", fullURL, nil) + if err != nil { + return nil, fmt.Errorf("failed to run CQL search %q: %w", cql, err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return nil, c.handleErrorResponse(resp, fmt.Sprintf("run CQL search %q", cql)) + } + + var searchResult model.ConfluenceSearchResult + if err := json.NewDecoder(resp.Body).Decode(&searchResult); err != nil { + return nil, fmt.Errorf("failed to decode CQL search response: %w", err) + } + + pages := make([]*model.ConfluencePage, 0, len(searchResult.Results)) + for i := range searchResult.Results { + pages = append(pages, model.ConvertAPIPageToModel(&searchResult.Results[i])) + } + return pages, nil +} + // handleErrorResponse handles error responses from the API func (c *client) handleErrorResponse(resp *http.Response, operation string) error { bodyBytes, err := io.ReadAll(resp.Body) diff --git a/internal/confluence/client_test.go b/internal/confluence/client_test.go new file mode 100644 index 0000000..f7dc0ac --- /dev/null +++ b/internal/confluence/client_test.go @@ -0,0 +1,130 @@ +package confluence + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/jackchuka/confluence-md/internal/confluence/model" +) + +func TestClientServerDeploymentUsesBearerAndNoWikiPrefix(t *testing.T) { + var gotPath, gotAuth string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotAuth = r.Header.Get("Authorization") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"123","title":"Page","space":{"key":"SP"}}`)) + })) + defer srv.Close() + + c := NewClient(Config{ + BaseURL: srv.URL, + Deployment: model.DeploymentServer, + Token: "pat-token", + }) + + page, err := c.GetPage("123") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if page.ID != "123" { + t.Fatalf("unexpected page id: %s", page.ID) + } + if gotPath != "/rest/api/content/123" { + t.Errorf("path = %q, want /rest/api/content/123 (no /wiki prefix)", gotPath) + } + if gotAuth != "Bearer pat-token" { + t.Errorf("Authorization = %q, want Bearer pat-token", gotAuth) + } +} + +func TestClientCloudDeploymentUsesBasicAndWikiPrefix(t *testing.T) { + var gotPath string + var gotUser, gotPass string + var hadBasic bool + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotUser, gotPass, hadBasic = r.BasicAuth() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"id":"123","title":"Page","space":{"key":"SP"}}`)) + })) + defer srv.Close() + + c := NewClient(Config{ + BaseURL: srv.URL, + Deployment: model.DeploymentCloud, + ContextPath: "/wiki", + Email: "user@example.com", + APIToken: "cloud-token", + }) + + if _, err := c.GetPage("123"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if gotPath != "/wiki/rest/api/content/123" { + t.Errorf("path = %q, want /wiki/rest/api/content/123", gotPath) + } + if !hadBasic || gotUser != "user@example.com" || gotPass != "cloud-token" { + t.Errorf("basic auth = (%q,%q,%v), want (user@example.com, cloud-token, true)", gotUser, gotPass, hadBasic) + } +} + +func TestClientGetUserParamByDeployment(t *testing.T) { + tests := []struct { + name string + deployment model.Deployment + wantParam string + }{ + {"cloud", model.DeploymentCloud, "accountId"}, + {"server", model.DeploymentServer, "key"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var gotQuery string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + _, _ = w.Write([]byte(`{"displayName":"Jane"}`)) + })) + defer srv.Close() + + c := NewClient(Config{BaseURL: srv.URL, Deployment: tt.deployment, Token: "x", Email: "e", APIToken: "t"}) + if _, err := c.GetUser("abc123"); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(gotQuery, tt.wantParam+"=abc123") { + t.Errorf("query = %q, want it to contain %s=abc123", gotQuery, tt.wantParam) + } + }) + } +} + +func TestClientNormalizeDownloadLinkContextPath(t *testing.T) { + tests := []struct { + name string + contextPath string + link string + want string + }{ + {"cloud", "/wiki", "/download/attachments/1/a.png", "https://host/wiki/download/attachments/1/a.png"}, + {"server no context", "", "/download/attachments/1/a.png", "https://host/download/attachments/1/a.png"}, + {"server context", "/confluence", "/download/attachments/1/a.png", "https://host/confluence/download/attachments/1/a.png"}, + {"already prefixed", "/wiki", "/wiki/download/attachments/1/a.png", "https://host/wiki/download/attachments/1/a.png"}, + {"absolute passthrough", "/wiki", "https://cdn.example.com/a.png", "https://cdn.example.com/a.png"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &client{siteURL: "https://host", contextPath: tt.contextPath} + got, err := c.normalizeDownloadLink(tt.link) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Errorf("normalizeDownloadLink(%q) = %q, want %q", tt.link, got, tt.want) + } + }) + } +} diff --git a/internal/confluence/mock/mock_client.go b/internal/confluence/mock/mock_client.go index 5daf28e..1102312 100644 --- a/internal/confluence/mock/mock_client.go +++ b/internal/confluence/mock/mock_client.go @@ -55,6 +55,21 @@ func (mr *MockClientMockRecorder) DownloadAttachmentContent(attachment any) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DownloadAttachmentContent", reflect.TypeOf((*MockClient)(nil).DownloadAttachmentContent), attachment) } +// FindPageID mocks base method. +func (m *MockClient) FindPageID(spaceKey, title string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "FindPageID", spaceKey, title) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// FindPageID indicates an expected call of FindPageID. +func (mr *MockClientMockRecorder) FindPageID(spaceKey, title any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FindPageID", reflect.TypeOf((*MockClient)(nil).FindPageID), spaceKey, title) +} + // GetChildPages mocks base method. func (m *MockClient) GetChildPages(pageID string) ([]*model.ConfluencePage, error) { m.ctrl.T.Helper() @@ -84,3 +99,33 @@ func (mr *MockClientMockRecorder) GetPage(pageID any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPage", reflect.TypeOf((*MockClient)(nil).GetPage), pageID) } + +// GetUser mocks base method. +func (m *MockClient) GetUser(userID string) (*model.ConfluenceUser, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetUser", userID) + ret0, _ := ret[0].(*model.ConfluenceUser) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetUser indicates an expected call of GetUser. +func (mr *MockClientMockRecorder) GetUser(userID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetUser", reflect.TypeOf((*MockClient)(nil).GetUser), userID) +} + +// SearchByCQL mocks base method. +func (m *MockClient) SearchByCQL(cql string, limit int) ([]*model.ConfluencePage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SearchByCQL", cql, limit) + ret0, _ := ret[0].([]*model.ConfluencePage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SearchByCQL indicates an expected call of SearchByCQL. +func (mr *MockClientMockRecorder) SearchByCQL(cql, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchByCQL", reflect.TypeOf((*MockClient)(nil).SearchByCQL), cql, limit) +} diff --git a/internal/confluence/model/api.go b/internal/confluence/model/api.go index 96b0e26..bbe72b3 100644 --- a/internal/confluence/model/api.go +++ b/internal/confluence/model/api.go @@ -22,6 +22,8 @@ type ConfluenceAPIPage struct { By struct { Type string `json:"type"` AccountID string `json:"accountId"` + UserKey string `json:"userKey"` + Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` } `json:"by"` @@ -35,6 +37,8 @@ type ConfluenceAPIPage struct { CreatedBy struct { Type string `json:"type"` AccountID string `json:"accountId"` + UserKey string `json:"userKey"` + Username string `json:"username"` DisplayName string `json:"displayName"` Email string `json:"email"` } `json:"createdBy"` @@ -87,6 +91,8 @@ type ConfluenceErrorResponse struct { type ConfluenceUser struct { Type string `json:"type"` AccountID string `json:"accountId"` + UserKey string `json:"userKey"` + Username string `json:"username"` AccountType string `json:"accountType"` Email string `json:"email"` PublicName string `json:"publicName"` @@ -136,11 +142,15 @@ func ConvertAPIPageToModel(apiPage *ConfluenceAPIPage) *ConfluencePage { UpdatedAt: apiPage.Version.When, CreatedBy: User{ AccountID: apiPage.History.CreatedBy.AccountID, + UserKey: apiPage.History.CreatedBy.UserKey, + Username: apiPage.History.CreatedBy.Username, DisplayName: apiPage.History.CreatedBy.DisplayName, Email: apiPage.History.CreatedBy.Email, }, UpdatedBy: User{ AccountID: apiPage.Version.By.AccountID, + UserKey: apiPage.Version.By.UserKey, + Username: apiPage.Version.By.Username, DisplayName: apiPage.Version.By.DisplayName, Email: apiPage.Version.By.Email, }, diff --git a/internal/confluence/model/page.go b/internal/confluence/model/page.go index 4a1c405..06069d5 100644 --- a/internal/confluence/model/page.go +++ b/internal/confluence/model/page.go @@ -3,9 +3,33 @@ package model import ( "fmt" "net/url" + "strings" "time" ) +// Deployment identifies which flavor of Confluence an instance is. +type Deployment string + +const ( + // DeploymentCloud is Atlassian-hosted Confluence Cloud (*.atlassian.net). + DeploymentCloud Deployment = "cloud" + // DeploymentServer is a self-hosted Confluence Server / Data Center instance. + DeploymentServer Deployment = "server" +) + +// SiteInfo captures everything needed to address a specific Confluence instance, +// independent of any single page. +type SiteInfo struct { + // BaseURL is the scheme and host only, e.g. "https://wiki.example.com". + BaseURL string + // Deployment is the flavor of the instance (cloud or server). + Deployment Deployment + // ContextPath is the path prefix in front of Confluence routes and the REST + // API, without a trailing slash. It is "/wiki" for Cloud and typically "" + // (or e.g. "/confluence") for self-hosted instances. + ContextPath string +} + // ConfluencePage represents a page fetched from Confluence API type ConfluencePage struct { ID string `json:"id"` @@ -54,9 +78,14 @@ type ConfluenceAttachment struct { Version int `json:"version"` } -// User represents a Confluence user +// User represents a Confluence user. +// +// Cloud identifies users by AccountID, whereas self-hosted (Server/Data Center) +// identifies them by UserKey and/or Username. type User struct { AccountID string `json:"accountId"` + UserKey string `json:"userKey,omitempty"` + Username string `json:"username,omitempty"` DisplayName string `json:"displayName"` Email string `json:"email,omitempty"` } @@ -71,9 +100,8 @@ func (cp *ConfluencePage) Validate() error { return fmt.Errorf("page title cannot be empty") } - if cp.Content.Storage.Value == "" { - return fmt.Errorf("page content cannot be empty") - } + // Empty content is allowed: container/landing pages legitimately have no + // body of their own (their content is a children macro, or nothing). if cp.SpaceKey == "" { return fmt.Errorf("space key cannot be empty") @@ -89,17 +117,25 @@ func (cp *ConfluencePage) Validate() error { return nil } -// GetURL constructs the Confluence page URL -func (cp *ConfluencePage) GetURL(baseURL string) (string, error) { - base, err := url.Parse(baseURL) +// GetURL constructs the Confluence page URL for the given site. +// +// Cloud pages live under the "/wiki" context at /spaces/.../pages/ID, while +// self-hosted (Server/Data Center) pages are addressed via the version-agnostic +// /pages/viewpage.action?pageId=ID endpoint under the instance context path. +func (cp *ConfluencePage) GetURL(site SiteInfo) (string, error) { + base, err := url.Parse(site.BaseURL) if err != nil { return "", fmt.Errorf("invalid base URL: %w", err) } - pageURL := fmt.Sprintf("%s/wiki/spaces/%s/pages/%s/%s", - base.String(), cp.SpaceKey, cp.ID, url.PathEscape(cp.Title)) + root := strings.TrimSuffix(base.String(), "/") + site.ContextPath - return pageURL, nil + if site.Deployment == DeploymentServer { + return fmt.Sprintf("%s/pages/viewpage.action?pageId=%s", root, cp.ID), nil + } + + return fmt.Sprintf("%s/spaces/%s/pages/%s/%s", + root, cp.SpaceKey, cp.ID, url.PathEscape(cp.Title)), nil } // GetLabelNames returns a slice of label names @@ -143,8 +179,19 @@ func (ca *ConfluenceAttachment) Validate() error { // PageURLInfo contains information extracted from a Confluence page URL type PageURLInfo struct { - BaseURL string - SpaceKey string - PageID string - Title string + BaseURL string + SpaceKey string + PageID string + Title string + Deployment Deployment + ContextPath string +} + +// Site returns the SiteInfo described by this URL. +func (p PageURLInfo) Site() SiteInfo { + return SiteInfo{ + BaseURL: p.BaseURL, + Deployment: p.Deployment, + ContextPath: p.ContextPath, + } } diff --git a/internal/confluence/model/page_test.go b/internal/confluence/model/page_test.go index 285822c..f9c8a04 100644 --- a/internal/confluence/model/page_test.go +++ b/internal/confluence/model/page_test.go @@ -46,11 +46,11 @@ func TestConfluencePageValidate(t *testing.T) { wantErr: "page title cannot be empty", }, { - name: "missing content", + name: "empty content is allowed", mutate: func(p *ConfluencePage) { p.Content.Storage.Value = "" }, - wantErr: "page content cannot be empty", + wantErr: "", }, { name: "missing space key", @@ -88,7 +88,8 @@ func TestConfluencePageValidate(t *testing.T) { func TestConfluencePageGetURL(t *testing.T) { page := validPage() - url, err := page.GetURL("https://example.atlassian.net") + cloud := SiteInfo{BaseURL: "https://example.atlassian.net", Deployment: DeploymentCloud, ContextPath: "/wiki"} + url, err := page.GetURL(cloud) if err != nil { t.Fatalf("unexpected error: %v", err) } @@ -98,9 +99,35 @@ func TestConfluencePageGetURL(t *testing.T) { } } +func TestConfluencePageGetURLServer(t *testing.T) { + page := validPage() + server := SiteInfo{BaseURL: "https://wiki.example.com", Deployment: DeploymentServer, ContextPath: ""} + url, err := page.GetURL(server) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "https://wiki.example.com/pages/viewpage.action?pageId=123" + if url != want { + t.Fatalf("unexpected url: %s want %s", url, want) + } +} + +func TestConfluencePageGetURLServerContextPath(t *testing.T) { + page := validPage() + server := SiteInfo{BaseURL: "https://example.com", Deployment: DeploymentServer, ContextPath: "/confluence"} + url, err := page.GetURL(server) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := "https://example.com/confluence/pages/viewpage.action?pageId=123" + if url != want { + t.Fatalf("unexpected url: %s want %s", url, want) + } +} + func TestConfluencePageGetURLInvalidBase(t *testing.T) { page := validPage() - if _, err := page.GetURL("://bad"); err == nil { + if _, err := page.GetURL(SiteInfo{BaseURL: "://bad", ContextPath: "/wiki"}); err == nil { t.Fatal("expected error for invalid base url") } } diff --git a/internal/converter/converter.go b/internal/converter/converter.go index 594172b..567f123 100644 --- a/internal/converter/converter.go +++ b/internal/converter/converter.go @@ -16,6 +16,7 @@ import ( ) const maxImageSizeBytes = 10 * 1024 * 1024 +const maxFileSizeBytes = 100 * 1024 * 1024 // Converter handles HTML to Markdown conversion type Converter struct { @@ -79,16 +80,17 @@ func (c *Converter) ConvertHTML(html string) (string, error) { // ConvertPage converts a Confluence page to Markdown func (c *Converter) ConvertPage( page *confluenceModel.ConfluencePage, - baseURL string, + site confluenceModel.SiteInfo, outputDir string, ) (*model.MarkdownDocument, error) { if err := page.Validate(); err != nil { return nil, fmt.Errorf("invalid page: %w", err) } + c.plugin.SetSite(site) c.plugin.SetCurrentPage(page) // Create markdown document - doc, err := model.NewMarkdownDocument(page, baseURL) + doc, err := model.NewMarkdownDocument(page, site) if err != nil { return nil, fmt.Errorf("failed to create markdown document: %w", err) } @@ -100,14 +102,17 @@ func (c *Converter) ConvertPage( return nil, fmt.Errorf("failed to convert HTML to Markdown: %w", err) } doc.Content = markdown - // Extract image references for downloading - imageRefs := c.extractImageReferences(htmlContent, doc.Frontmatter.Confluence.PageID, baseURL) - doc.Images = imageRefs + // Extract image and file references for downloading + doc.Images = c.extractImageReferences(htmlContent, doc.Frontmatter.Confluence.PageID, site) + doc.Files = c.extractFileReferences(htmlContent, doc.Frontmatter.Confluence.PageID, site) if c.attachments != nil { if err := c.downloadImages(doc, page, outputDir); err != nil { return nil, fmt.Errorf("failed to download images: %w", err) } + if err := c.downloadFiles(doc, page, outputDir); err != nil { + return nil, fmt.Errorf("failed to download files: %w", err) + } } return doc, nil @@ -118,37 +123,60 @@ func (c *Converter) downloadImages(doc *model.MarkdownDocument, page *confluence if doc == nil { return fmt.Errorf("document cannot be nil") } - if len(doc.Images) == 0 { return nil } - if page == nil { return fmt.Errorf("page context is required to download images") } + return c.downloadRefs(doc.Images, page, outputDir, maxImageSizeBytes, "image") +} + +// downloadFiles fetches non-image attachments (view-file macros) and writes them to disk. +func (c *Converter) downloadFiles(doc *model.MarkdownDocument, page *confluenceModel.ConfluencePage, outputDir string) error { + if doc == nil { + return fmt.Errorf("document cannot be nil") + } + if len(doc.Files) == 0 { + return nil + } + if page == nil { + return fmt.Errorf("page context is required to download files") + } + return c.downloadRefs(doc.Files, page, outputDir, maxFileSizeBytes, "file") +} - for i := range doc.Images { - imageRef := &doc.Images[i] - attachment, data, err := c.attachments.DownloadAttachment(page, imageRef.FileName, 0) +// downloadRefs downloads a set of attachment references into the image folder, +// enforcing a per-item size cap (maxSize <= 0 disables the cap). kind labels +// the item in log and error messages ("image" or "file"). +func (c *Converter) downloadRefs(refs []model.ImageRef, page *confluenceModel.ConfluencePage, outputDir string, maxSize int64, kind string) error { + for i := range refs { + ref := &refs[i] + attachment, data, err := c.attachments.DownloadAttachment(page, ref.FileName, 0) if err != nil { - return fmt.Errorf("failed to download image %s: %w", imageRef.FileName, err) + // A single missing/undownloadable attachment must not abort the + // whole page (common for stale references to deleted files); warn + // and keep the markdown reference pointing at the expected path. + fmt.Printf("⚠️ Warning: skipping %s %s: %v\n", kind, ref.FileName, err) + continue } - if attachment.FileSize > maxImageSizeBytes { - return fmt.Errorf("image %s too large: %d bytes (max %d)", imageRef.FileName, attachment.FileSize, maxImageSizeBytes) + if maxSize > 0 && attachment.FileSize > maxSize { + fmt.Printf("⚠️ Warning: skipping %s %s: too large (%d bytes, max %d)\n", kind, ref.FileName, attachment.FileSize, maxSize) + continue } - imageRef.ContentType = attachment.MediaType - imageRef.Size = attachment.FileSize + ref.ContentType = attachment.MediaType + ref.Size = attachment.FileSize - filePath := filepath.Join(outputDir, c.imageFolder, imageRef.FileName) - fmt.Println("Downloading image:", imageRef.FileName, "to", filePath) + filePath := filepath.Join(outputDir, c.imageFolder, ref.FileName) + fmt.Printf("Downloading %s: %s to %s\n", kind, ref.FileName, filePath) if err := os.MkdirAll(filepath.Dir(filePath), 0755); err != nil { - return fmt.Errorf("failed to create image directory: %w", err) + return fmt.Errorf("failed to create %s directory: %w", kind, err) } if err := os.WriteFile(filePath, data, 0644); err != nil { - return fmt.Errorf("failed to write image %s: %w", imageRef.FileName, err) + return fmt.Errorf("failed to write %s %s: %w", kind, ref.FileName, err) } } diff --git a/internal/converter/converter_test.go b/internal/converter/converter_test.go index b6a87aa..2c40df9 100644 --- a/internal/converter/converter_test.go +++ b/internal/converter/converter_test.go @@ -1,12 +1,14 @@ package converter import ( + "fmt" "os" "path/filepath" "strings" "testing" "time" + mock_confluence "github.com/jackchuka/confluence-md/internal/confluence/mock" confModel "github.com/jackchuka/confluence-md/internal/confluence/model" convModel "github.com/jackchuka/confluence-md/internal/converter/model" mock_attachments "github.com/jackchuka/confluence-md/internal/converter/plugin/attachments/mock" @@ -54,7 +56,8 @@ func TestConverterConvertPage(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - doc, err := conv.ConvertPage(tt.page, "https://example.atlassian.net", ".") + site := confModel.SiteInfo{BaseURL: "https://example.atlassian.net", Deployment: confModel.DeploymentCloud, ContextPath: "/wiki"} + doc, err := conv.ConvertPage(tt.page, site, ".") if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v", tt.wantErr, err) @@ -78,6 +81,51 @@ func TestConverterConvertPage(t *testing.T) { } } +func TestConverterConvertPageLinks(t *testing.T) { + conv := NewConverter(nil) + + page := &confModel.ConfluencePage{ + ID: "100", + Title: "Source Page", + SpaceKey: "MKT", + Version: 1, + Content: confModel.ConfluenceContent{ + Storage: confModel.ContentStorage{ + // 1) title-only page link (same space, no body) — used to vanish + // 2) page link with an explicit link body + // 3) cross-space page link + Value: `

viz .

` + + `

detail Administrátor.

` + + `

jinde .

`, + }, + }, + } + page.Content.Storage.Representation = "storage" + + site := confModel.SiteInfo{BaseURL: "https://dory.eon.cz", Deployment: confModel.DeploymentServer, ContextPath: ""} + doc, err := conv.ConvertPage(page, site, ".") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wants := []string{ + // title-only link resolves to text + pretty display URL (no longer "viz .") + "[UC0075 - Notifikace](https://dory.eon.cz/display/MKT/UC0075+-+Notifikace)", + // explicit body wins as the link text; falls back to current page's space + "[Administrátor](https://dory.eon.cz/display/MKT/EN0012+-+Administr%C3%A1tor)", + // explicit space key is honored + "[Portál EDC](https://dory.eon.cz/display/OPS/Port%C3%A1l+EDC)", + } + for _, want := range wants { + if !strings.Contains(doc.Content, want) { + t.Errorf("expected markdown to contain %q\n got: %s", want, doc.Content) + } + } + if strings.Contains(doc.Content, "viz .") { + t.Errorf("page link was dropped (found \"viz .\"): %s", doc.Content) + } +} + func TestConverterDownloadImages(t *testing.T) { data := []byte("image-bytes") attachment := &confModel.ConfluenceAttachment{Title: "diagram.png", MediaType: "image/png", FileSize: int64(len(data))} @@ -124,6 +172,220 @@ func TestConverterDownloadImages(t *testing.T) { } } +func TestConverterViewFileMacro(t *testing.T) { + pdf := []byte("%PDF-1.7 fake bytes") + attachment := &confModel.ConfluenceAttachment{Title: "Plna_moc.pdf", MediaType: "application/pdf", FileSize: int64(len(pdf))} + + ctrl := gomock.NewController(t) + defer ctrl.Finish() + mockResolver := mock_attachments.NewMockResolver(ctrl) + mockResolver.EXPECT().DownloadAttachment(gomock.Any(), "Plna_moc.pdf", 0).Return(attachment, pdf, nil) + + conv := NewConverter(nil, WithDownloadAttachments("assets")) + conv.attachments = mockResolver + + page := &confModel.ConfluencePage{ + ID: "100", + Title: "TMPL001 v01 - Plná Moc", + SpaceKey: "MKT", + Version: 1, + Content: confModel.ConfluenceContent{ + Storage: confModel.ContentStorage{ + Value: `

Soubor:

` + + `` + + ``, + }, + }, + Attachments: []confModel.ConfluenceAttachment{{ + ID: "att1", + Title: "Plna_moc.pdf", + MediaType: "application/pdf", + FileSize: int64(len(pdf)), + DownloadLink: "/download/attachments/100/Plna_moc.pdf", + }}, + } + page.Content.Storage.Representation = "storage" + + site := confModel.SiteInfo{BaseURL: "https://dory.eon.cz", Deployment: confModel.DeploymentServer} + tmpDir := t.TempDir() + doc, err := conv.ConvertPage(page, site, tmpDir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The macro must become a markdown link to the local file (not an "Unsupported macro" comment). + wantLink := "[Plna_moc.pdf](assets%2FPlna_moc.pdf)" + if !strings.Contains(doc.Content, wantLink) { + t.Errorf("expected link %q, got: %s", wantLink, doc.Content) + } + if strings.Contains(doc.Content, "Unsupported macro") { + t.Errorf("view-file macro left unsupported: %s", doc.Content) + } + + // The attachment must have been downloaded to disk. + got, err := os.ReadFile(filepath.Join(tmpDir, "assets", "Plna_moc.pdf")) + if err != nil { + t.Fatalf("expected downloaded file: %v", err) + } + if string(got) != string(pdf) { + t.Errorf("unexpected file content: %q", string(got)) + } +} + +func TestConverterContentByLabelAndJiraMacros(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mock_confluence.NewMockClient(ctrl) + // user cache lookups during SetCurrentPage may call GetUser; allow any. + mockClient.EXPECT().GetUser(gomock.Any()).Return(nil, fmt.Errorf("n/a")).AnyTimes() + mockClient.EXPECT(). + SearchByCQL(`label = "ep0006" and ancestor = "194514542"`, 100). + Return([]*confModel.ConfluencePage{ + {ID: "1", Title: "US0010 - Import CSV", SpaceKey: "MKT"}, + {ID: "2", Title: "US0011 - Kontrola dat", SpaceKey: "MKT"}, + }, nil) + + conv := NewConverter(mockClient) + + page := &confModel.ConfluencePage{ + ID: "100", + Title: "EP0006 - Manuální import", + SpaceKey: "MKT", + Version: 1, + Content: confModel.ConfluenceContent{ + Storage: confModel.ContentStorage{ + Value: `

MAR-42

` + + `

Související User Stories

` + + `label = "ep0006" and ancestor = "194514542"`, + }, + }, + } + page.Content.Storage.Representation = "storage" + + site := confModel.SiteInfo{BaseURL: "https://dory.eon.cz", Deployment: confModel.DeploymentServer} + doc, err := conv.ConvertPage(page, site, ".") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wants := []string{ + "MAR-42", + "- [US0010 - Import CSV](https://dory.eon.cz/display/MKT/US0010+-+Import+CSV)", + "- [US0011 - Kontrola dat](https://dory.eon.cz/display/MKT/US0011+-+Kontrola+dat)", + } + for _, want := range wants { + if !strings.Contains(doc.Content, want) { + t.Errorf("expected %q in output\n got: %s", want, doc.Content) + } + } + if strings.Contains(doc.Content, "Unsupported macro") { + t.Errorf("a macro was left unsupported: %s", doc.Content) + } +} + +func TestConverterChildrenMacro(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + + mockClient := mock_confluence.NewMockClient(ctrl) + mockClient.EXPECT().GetUser(gomock.Any()).Return(nil, fmt.Errorf("n/a")).AnyTimes() + mockClient.EXPECT().GetChildPages("100").Return([]*confModel.ConfluencePage{ + {ID: "2", Title: "Portál EDC", SpaceKey: "MKT"}, + {ID: "3", Title: "Dodavatel MamaAI", SpaceKey: "MKT"}, + }, nil) + + conv := NewConverter(mockClient) + + page := &confModel.ConfluencePage{ + ID: "100", + Title: "Externí komponenty systému", + SpaceKey: "MKT", + Version: 1, + Content: confModel.ConfluenceContent{ + Storage: confModel.ContentStorage{ + Value: `

WIP architektura

`, + }, + }, + } + page.Content.Storage.Representation = "storage" + + site := confModel.SiteInfo{BaseURL: "https://dory.eon.cz", Deployment: confModel.DeploymentServer} + doc, err := conv.ConvertPage(page, site, ".") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + wants := []string{ + "", + "- [Portál EDC](https://dory.eon.cz/display/MKT/Port%C3%A1l+EDC)", + "- [Dodavatel MamaAI](https://dory.eon.cz/display/MKT/Dodavatel+MamaAI)", + "", + "WIP architektura", + } + for _, want := range wants { + if !strings.Contains(doc.Content, want) { + t.Errorf("expected %q in output\n got: %s", want, doc.Content) + } + } +} + +func TestConverterMarkdownMacro(t *testing.T) { + conv := NewConverter(nil) + + page := &confModel.ConfluencePage{ + ID: "100", + Title: "Navržené řešení", + SpaceKey: "MKT", + Version: 1, + Content: confModel.ConfluenceContent{ + Storage: confModel.ContentStorage{ + Value: `

Úvod.

` + + `` + + ``, + }, + }, + } + page.Content.Storage.Representation = "storage" + + site := confModel.SiteInfo{BaseURL: "https://dory.eon.cz", Deployment: confModel.DeploymentServer} + doc, err := conv.ConvertPage(page, site, ".") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // The macro body is already Markdown: it must survive verbatim, not be + // fenced as code, escaped, or dropped. + wants := []string{ + "## Marginální skóre", + "- první bod", + "| Sloupec | Hodnota |", + "`score = a & b`", // entities decoded back + "`a < b`", + } + for _, want := range wants { + if !strings.Contains(doc.Content, want) { + t.Errorf("expected %q in output\n got: %s", want, doc.Content) + } + } + if strings.Contains(doc.Content, "Unsupported macro") { + t.Errorf("markdown macro left unsupported: %s", doc.Content) + } + if strings.Contains(doc.Content, "```") { + t.Errorf("markdown body must not be fenced as code: %s", doc.Content) + } +} + func TestSaveMarkdownDocument(t *testing.T) { tmpDir := t.TempDir() doc := &convModel.MarkdownDocument{ diff --git a/internal/converter/model/markdown.go b/internal/converter/model/markdown.go index 3cfc05d..1a5d998 100644 --- a/internal/converter/model/markdown.go +++ b/internal/converter/model/markdown.go @@ -13,6 +13,7 @@ type MarkdownDocument struct { Frontmatter Frontmatter `yaml:",inline"` Content string `yaml:"-"` Images []ImageRef `yaml:"-"` + Files []ImageRef `yaml:"-"` // non-image attachments (view-file macros) } // Frontmatter represents YAML frontmatter for the Markdown document @@ -78,8 +79,8 @@ func (md *MarkdownDocument) WithFrontmatter() (string, error) { } // NewMarkdownDocument creates a new MarkdownDocument from a ConfluencePage -func NewMarkdownDocument(page *model.ConfluencePage, baseURL string) (*MarkdownDocument, error) { - pageURL, err := page.GetURL(baseURL) +func NewMarkdownDocument(page *model.ConfluencePage, site model.SiteInfo) (*MarkdownDocument, error) { + pageURL, err := page.GetURL(site) if err != nil { return nil, fmt.Errorf("failed to generate page URL: %w", err) } @@ -99,6 +100,7 @@ func NewMarkdownDocument(page *model.ConfluencePage, baseURL string) (*MarkdownD }, Content: "", // Will be filled by converter Images: []ImageRef{}, + Files: []ImageRef{}, } return doc, nil diff --git a/internal/converter/model/markdown_test.go b/internal/converter/model/markdown_test.go index 5fa0d2e..33a5185 100644 --- a/internal/converter/model/markdown_test.go +++ b/internal/converter/model/markdown_test.go @@ -64,7 +64,8 @@ func TestNewMarkdownDocument(t *testing.T) { UpdatedAt: time.Date(2024, 2, 3, 4, 5, 6, 0, time.UTC), } - doc, err := NewMarkdownDocument(page, "https://example.atlassian.net") + site := model.SiteInfo{BaseURL: "https://example.atlassian.net", Deployment: model.DeploymentCloud, ContextPath: "/wiki"} + doc, err := NewMarkdownDocument(page, site) if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/internal/converter/plugin/confluence.go b/internal/converter/plugin/confluence.go index a212714..3bff0d5 100644 --- a/internal/converter/plugin/confluence.go +++ b/internal/converter/plugin/confluence.go @@ -20,6 +20,7 @@ type ConfluencePlugin struct { attachmentResolver attachments.Resolver client confluence.Client currentPage *model.ConfluencePage + site model.SiteInfo // instance context for building cross-page links userCache map[string]string // accountID -> displayName } @@ -42,80 +43,101 @@ func NewConfluencePluginWithClient(client confluence.Client, resolver attachment } } +// SetSite records the Confluence instance context (base URL, deployment, +// context path) used to build absolute links to other pages. +func (p *ConfluencePlugin) SetSite(site model.SiteInfo) { + p.site = site +} + // SetCurrentPage records which page is currently being converted func (p *ConfluencePlugin) SetCurrentPage(page *model.ConfluencePage) { p.currentPage = page - // Populate user cache from page metadata + // Populate user cache from page metadata. Cloud identifies users by + // account ID, self-hosted by user key, so cache under whichever is present. if page != nil { - if page.CreatedBy.AccountID != "" && page.CreatedBy.DisplayName != "" { - p.userCache[page.CreatedBy.AccountID] = page.CreatedBy.DisplayName - } - if page.UpdatedBy.AccountID != "" && page.UpdatedBy.DisplayName != "" { - p.userCache[page.UpdatedBy.AccountID] = page.UpdatedBy.DisplayName - } + p.cacheUser(page.CreatedBy) + p.cacheUser(page.UpdatedBy) // Extract and cache all user mentions from page content p.extractAndCacheUsers(page) } } +// cacheUser records a user's display name under every identifier it exposes. +func (p *ConfluencePlugin) cacheUser(user model.User) { + if user.DisplayName == "" { + return + } + for _, id := range []string{user.AccountID, user.UserKey} { + if id != "" { + p.userCache[id] = user.DisplayName + } + } +} + // extractAndCacheUsers finds all user references in the page HTML and adds them to cache func (p *ConfluencePlugin) extractAndCacheUsers(page *model.ConfluencePage) { html := page.Content.Storage.Value - accountIDs := ExtractUserAccountIDs(html) + userIDs := ExtractUserIDs(html) - if p.client != nil && len(accountIDs) > 0 { - for _, accountID := range accountIDs { - if _, ok := p.userCache[accountID]; ok { + if p.client != nil && len(userIDs) > 0 { + for _, userID := range userIDs { + if _, ok := p.userCache[userID]; ok { continue } - user, err := p.client.GetUser(accountID) + user, err := p.client.GetUser(userID) if err != nil { continue } if user.DisplayName != "" { - p.userCache[accountID] = user.DisplayName + p.userCache[userID] = user.DisplayName } else if user.PublicName != "" { - p.userCache[accountID] = user.PublicName + p.userCache[userID] = user.PublicName } } } log.Printf("Cached users: %+v", p.userCache) } -// ExtractUserAccountIDs finds all user account IDs in the HTML -func ExtractUserAccountIDs(html string) []string { - accountIDs := make(map[string]bool) +// userRefAttrs are the ri:user identifier attributes that can be resolved to a +// display name through the API: ri:account-id on Cloud and ri:userkey on +// self-hosted instances. (ri:username is a handle and is rendered as-is.) +var userRefAttrs = []string{`ri:account-id="`, `ri:userkey="`} + +// ExtractUserIDs finds all resolvable user identifiers referenced in the HTML. +func ExtractUserIDs(html string) []string { + ids := make(map[string]bool) + + for _, attr := range userRefAttrs { + start := 0 + for { + idx := strings.Index(html[start:], attr) + if idx == -1 { + break + } + idx += start + len(attr) - // Find all ri:account-id attributes - start := 0 - for { - idx := strings.Index(html[start:], `ri:account-id="`) - if idx == -1 { - break - } - idx += start + len(`ri:account-id="`) + // Find the closing quote + endIdx := strings.Index(html[idx:], `"`) + if endIdx == -1 { + break + } - // Find the closing quote - endIdx := strings.Index(html[idx:], `"`) - if endIdx == -1 { - break - } + id := html[idx : idx+endIdx] + if id != "" { + ids[id] = true + } - accountID := html[idx : idx+endIdx] - if accountID != "" { - accountIDs[accountID] = true + start = idx + endIdx + 1 } - - start = idx + endIdx + 1 } // Convert map to slice - result := make([]string, 0, len(accountIDs)) - for id := range accountIDs { + result := make([]string, 0, len(ids)) + for id := range ids { result = append(result, id) } @@ -531,28 +553,93 @@ func (p *ConfluencePlugin) handleImage(ctx converter.Context, w converter.Writer return converter.RenderSuccess } -func (p *ConfluencePlugin) handleEmoticon(ctx converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus { - for _, attr := range n.Attr { - if attr.Key == "ac:emoji-fallback" && attr.Val != "" { - _, _ = w.WriteString(attr.Val + " ") - return converter.RenderTryNext +// emoticonEmoji maps classic Confluence emoticon names (which carry no +// ac:emoji-fallback glyph on older Server/DC instances) to a Unicode symbol. +// Keys are normalized via normalizeEmoticonName (lowercased, separators +// stripped) so aliases like "minus", "minus sign" and "minus-sign" all match. +var emoticonEmoji = map[string]string{ + "tick": "✔️", + "check": "✔️", + "checkmark": "✔️", + "cross": "❌", + "error": "❌", + "minus": "➖", + "minussign": "➖", + "plus": "➕", + "add": "➕", + "information": "ℹ️", + "info": "ℹ️", + "warning": "⚠️", + "question": "❓", + "thumbsup": "👍", + "thumbsdown": "👎", + "lighton": "💡", + "star": "⭐", + "yellowstar": "⭐", + "redstar": "⭐", + "greenstar": "⭐", + "bluestar": "⭐", + "heart": "❤️", + "brokenheart": "💔", + "smile": "🙂", + "sad": "🙁", + "cheeky": "😜", + "laugh": "😄", + "wink": "😉", +} + +// normalizeEmoticonName lowercases a name and strips spaces, hyphens and +// underscores so lookups are resilient to naming variants. +func normalizeEmoticonName(name string) string { + var b strings.Builder + for _, r := range strings.ToLower(name) { + switch r { + case ' ', '-', '_': + // skip separators + default: + b.WriteRune(r) } } + return b.String() +} +func (p *ConfluencePlugin) handleEmoticon(ctx converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus { + var fallback, shortname, name string for _, attr := range n.Attr { - if attr.Key == "ac:emoji-shortname" && attr.Val != "" { - _, _ = w.WriteString(attr.Val + " ") - return converter.RenderTryNext + switch attr.Key { + case "ac:emoji-fallback": + fallback = attr.Val + case "ac:emoji-shortname": + shortname = attr.Val + case "ac:name": + name = attr.Val } } - for _, attr := range n.Attr { - if attr.Key == "ac:name" && attr.Val != "" { - _, _ = fmt.Fprintf(w, ":%s:", attr.Val) + // Prefer the real emoji glyph when Confluence provides one. + if fallback != "" { + _, _ = w.WriteString(fallback + " ") + return converter.RenderTryNext + } + + // Map classic (glyph-less) emoticons to a Unicode symbol. + if name != "" { + if emoji, ok := emoticonEmoji[normalizeEmoticonName(name)]; ok { + _, _ = w.WriteString(emoji + " ") return converter.RenderTryNext } } + // Fall back to the shortname (e.g. :check_mark:), then the raw name. + if shortname != "" { + _, _ = w.WriteString(shortname + " ") + return converter.RenderTryNext + } + if name != "" { + _, _ = fmt.Fprintf(w, ":%s:", name) + return converter.RenderTryNext + } + _, _ = w.WriteString(":emoji: ") return converter.RenderTryNext } @@ -585,6 +672,8 @@ func (p *ConfluencePlugin) handleMacro(ctx converter.Context, w converter.Writer result = p.handleBlockquoteMacro(ctx, n, "💡", "Tip") case "code": result = p.handleCodeMacro(n) + case "markdown": + result = p.handleMarkdownMacro(n) case "mermaid-cloud": result = p.handleMermaidMacro(n) case "expand": @@ -595,8 +684,14 @@ func (p *ConfluencePlugin) handleMacro(ctx converter.Context, w converter.Writer result = p.handleDetailsMacro(ctx, n) case "status": result = p.handleStatusMacro(n) - case "children": - result = "" + case "children", "pagetree": + result = p.handleChildrenMacro() + case "view-file", "viewpdf", "viewdoc", "viewxls", "viewppt", "multimedia": + result = p.handleFileMacro(n) + case "jira": + result = p.handleJiraMacro(n) + case "contentbylabel": + result = p.handleContentByLabelMacro(n) default: result = fmt.Sprintf("", macroName) } @@ -608,6 +703,144 @@ func (p *ConfluencePlugin) handleMacro(ctx converter.Context, w converter.Writer return converter.RenderSuccess } +// handleFileMacro converts a Confluence file-view macro (view-file, viewpdf, +// …) that embeds an attachment into a markdown link to the locally downloaded +// file. The actual download is scheduled separately (see extractFileReferences). +func (p *ConfluencePlugin) handleFileMacro(n *html.Node) string { + filename := findAttachmentFilename(n) + if filename == "" { + return "" + } + localPath := p.imageFolder + "/" + filename + return fmt.Sprintf("[%s](%s)", filename, url.PathEscape(localPath)) +} + +// findAttachmentFilename returns the ri:filename of the first ri:attachment +// found anywhere within the node subtree. +func findAttachmentFilename(n *html.Node) string { + var found string + var walk func(*html.Node) + walk = func(node *html.Node) { + if found != "" { + return + } + if node.Type == html.ElementNode && node.Data == "ri:attachment" { + if fn := attrValue(node, "ri:filename"); fn != "" { + found = fn + return + } + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return found +} + +// macroParam returns the trimmed text of the macro's ac:parameter with the +// given ac:name, or "" if absent. +func macroParam(n *html.Node, name string) string { + for child := n.FirstChild; child != nil; child = child.NextSibling { + if child.Type == html.ElementNode && child.Data == "ac:parameter" && + attrValue(child, "ac:name") == name { + return strings.TrimSpace(nodeText(child)) + } + } + return "" +} + +// handleChildrenMacro materializes a children / pagetree macro into a markdown +// list of links to the page's child pages, fetched via the API. The list is +// wrapped in markers so downstream +// tooling can recognize (and, for empty wrapper pages, strip) it. Without API +// access or children, it degrades to the bare marker comment. +func (p *ConfluencePlugin) handleChildrenMacro() string { + const openMarker = "" + if p.client == nil || p.currentPage == nil || p.currentPage.ID == "" { + return openMarker + } + + children, err := p.client.GetChildPages(p.currentPage.ID) + if err != nil || len(children) == 0 { + return openMarker + } + + var b strings.Builder + b.WriteString(openMarker + "\n") + for _, ch := range children { + space := ch.SpaceKey + if space == "" && p.currentPage != nil { + space = p.currentPage.SpaceKey + } + if u := p.pageDisplayURL(space, ch.Title); u != "" { + fmt.Fprintf(&b, "- [%s](%s)\n", ch.Title, u) + } else { + fmt.Fprintf(&b, "- %s\n", ch.Title) + } + } + b.WriteString("") + return b.String() +} + +// handleJiraMacro renders a Jira issue macro as its issue key. The live status +// would require the Jira API, which is out of scope; the key is preserved so +// the reference isn't lost. +func (p *ConfluencePlugin) handleJiraMacro(n *html.Node) string { + key := macroParam(n, "key") + if key == "" { + return "" + } + return key +} + +// handleContentByLabelMacro resolves a dynamic contentbylabel macro into a +// static markdown list of links by running its CQL query against the API. The +// resulting list is not present in page storage, so without API access (or on +// error) it degrades to a comment recording the query. +func (p *ConfluencePlugin) handleContentByLabelMacro(n *html.Node) string { + cql := macroParam(n, "cql") + if cql == "" { + // Older macros express the query as labels (+ optional spaces) instead + // of a full CQL string; synthesize an equivalent query. + labels := firstNonEmpty(macroParam(n, "labels"), macroParam(n, "label")) + if labels != "" { + var quoted []string + for _, l := range strings.Fields(strings.ReplaceAll(labels, ",", " ")) { + quoted = append(quoted, fmt.Sprintf("%q", l)) + } + cql = "label in (" + strings.Join(quoted, ", ") + ") and type = page" + } + } + + if cql == "" { + return "" + } + + if p.client == nil { + return fmt.Sprintf("", cql) + } + + pages, err := p.client.SearchByCQL(cql, 100) + if err != nil || len(pages) == 0 { + return fmt.Sprintf("", cql) + } + + var b strings.Builder + for _, pg := range pages { + space := pg.SpaceKey + if space == "" && p.currentPage != nil { + space = p.currentPage.SpaceKey + } + if u := p.pageDisplayURL(space, pg.Title); u != "" { + fmt.Fprintf(&b, "- [%s](%s)\n", pg.Title, u) + } else { + fmt.Fprintf(&b, "- %s\n", pg.Title) + } + } + return strings.TrimRight(b.String(), "\n") +} + func (p *ConfluencePlugin) handleBlockquoteMacro(ctx converter.Context, n *html.Node, emoji, label string) string { content := p.convertNestedHTML(ctx, n) prefix := fmt.Sprintf("%s **%s:**", emoji, label) @@ -633,16 +866,46 @@ func (p *ConfluencePlugin) handleBlockquoteMacro(ctx converter.Context, n *html. } // handleCodeMacro converts code macros to code blocks -func (p *ConfluencePlugin) handleCodeMacro(n *html.Node) string { - // Convert node to goquery selection for compatibility with existing logic +// macroSelection renders a macro node into a goquery selection along with its +// inner HTML — the two inputs the macro body and parameter extractors need. +func macroSelection(n *html.Node) (*goquery.Selection, string, error) { var buf strings.Builder _ = html.Render(&buf, n) doc, err := goquery.NewDocumentFromReader(strings.NewReader(buf.String())) + if err != nil { + return nil, "", err + } + rawHTML, _ := doc.Selection.Html() + return doc.Selection, rawHTML, nil +} + +// handleMarkdownMacro emits the body of a markdown macro verbatim. The macro +// wraps authored Markdown in a CDATA plain-text body, so the content needs no +// conversion — only unwrapping. +func (p *ConfluencePlugin) handleMarkdownMacro(n *html.Node) string { + selection, rawHTML, err := macroSelection(n) + if err != nil { + return fmt.Sprintf("", err.Error()) + } + + content := extractPlainTextBodyContent(selection, rawHTML) + if content == "" { + content = extractCodeContent(rawHTML) + } + if strings.TrimSpace(content) == "" { + return "" + } + + // Surrounding blank lines keep the block from merging into adjacent text; + // postprocessing collapses any excess. + return "\n" + content + "\n" +} + +func (p *ConfluencePlugin) handleCodeMacro(n *html.Node) string { + selection, rawHTML, err := macroSelection(n) if err != nil { return fmt.Sprintf("", err.Error()) } - selection := doc.Selection - rawHTML, _ := selection.Html() language := extractLanguageParameter(rawHTML) code := extractPlainTextBodyContent(selection, rawHTML) @@ -872,35 +1135,195 @@ func (p *ConfluencePlugin) handleStatusMacro(n *html.Node) string { return "" } -// handleLink converts Confluence user links and other ac:link elements +// handleLink converts Confluence ac:link elements. Confluence encodes several +// link flavors as with a resource-identifier child (ri:user, +// ri:page, ri:attachment). The default HTML renderer doesn't understand these +// tags, so a page link with no explicit body would otherwise vanish entirely. func (p *ConfluencePlugin) handleLink(ctx converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus { - // Look for ri:user child node + var userRef, pageRef, attachmentRef *html.Node for child := n.FirstChild; child != nil; child = child.NextSibling { - if child.Type == html.ElementNode && child.Data == "ri:user" { - accountID := "" - for _, attr := range child.Attr { - if attr.Key == "ri:account-id" { - accountID = attr.Val - break - } - } + if child.Type != html.ElementNode { + continue + } + switch child.Data { + case "ri:user": + userRef = child + case "ri:page": + pageRef = child + case "ri:attachment": + attachmentRef = child + } + } - if accountID != "" { - if displayName, ok := p.userCache[accountID]; ok { - _, _ = fmt.Fprintf(w, " @%s ", displayName) - } else { - // Fallback to account ID - _, _ = fmt.Fprintf(w, " @user(%s) ", accountID) - } - return converter.RenderTryNext - } + switch { + case userRef != nil: + return p.renderUserLink(w, userRef) + case pageRef != nil: + return p.renderPageLink(w, n, pageRef) + case attachmentRef != nil: + return p.renderAttachmentLink(w, n, attachmentRef) + } + + // Unknown ac:link flavor: at least preserve any explicit body text so the + // content isn't silently dropped. + if body := linkBodyText(n); body != "" { + _, _ = w.WriteString(body) + return converter.RenderSuccess + } + return converter.RenderTryNext +} + +// renderUserLink converts an ac:link wrapping a ri:user reference to a mention. +func (p *ConfluencePlugin) renderUserLink(w converter.Writer, ref *html.Node) converter.RenderStatus { + var accountID, userKey, username string + for _, attr := range ref.Attr { + switch attr.Key { + case "ri:account-id": + accountID = attr.Val + case "ri:userkey": + userKey = attr.Val + case "ri:username": + username = attr.Val } } - // If not a user link, let default handler try + // account-id (Cloud) and userkey (Server/DC) resolve to display names via + // the cache; username is already a human-readable handle. + if id := firstNonEmpty(accountID, userKey); id != "" { + if displayName, ok := p.userCache[id]; ok { + _, _ = fmt.Fprintf(w, " @%s ", displayName) + } else { + _, _ = fmt.Fprintf(w, " @user(%s) ", id) + } + return converter.RenderTryNext + } + + if username != "" { + _, _ = fmt.Fprintf(w, " @%s ", username) + return converter.RenderTryNext + } + return converter.RenderTryNext } +// renderPageLink converts an ac:link wrapping a ri:page reference to a markdown +// link. The display text comes from an explicit link body when present, else +// the referenced page title; the URL is a best-effort "pretty" display URL +// built from the space key and title (no API lookup). +func (p *ConfluencePlugin) renderPageLink(w converter.Writer, link, ref *html.Node) converter.RenderStatus { + title := attrValue(ref, "ri:content-title") + spaceKey := attrValue(ref, "ri:space-key") + if spaceKey == "" && p.currentPage != nil { + spaceKey = p.currentPage.SpaceKey + } + + text := firstNonEmpty(linkBodyText(link), title) + if text == "" { + return converter.RenderTryNext + } + + if u := p.pageDisplayURL(spaceKey, title); u != "" { + _, _ = fmt.Fprintf(w, "[%s](%s)", text, u) + } else { + _, _ = w.WriteString(text) + } + return converter.RenderSuccess +} + +// renderAttachmentLink converts an ac:link wrapping a ri:attachment reference. +func (p *ConfluencePlugin) renderAttachmentLink(w converter.Writer, link, ref *html.Node) converter.RenderStatus { + filename := attrValue(ref, "ri:filename") + text := firstNonEmpty(linkBodyText(link), filename) + if text == "" { + return converter.RenderTryNext + } + + if u := p.attachmentDisplayURL(filename); u != "" { + _, _ = fmt.Fprintf(w, "[%s](%s)", text, u) + } else { + _, _ = w.WriteString(text) + } + return converter.RenderSuccess +} + +// pageDisplayURL builds a best-effort "pretty" URL to another Confluence page +// from its space key and title (Variant A: no API lookup). Returns "" when +// there isn't enough site context to build an absolute URL. +func (p *ConfluencePlugin) pageDisplayURL(spaceKey, title string) string { + if p.site.BaseURL == "" || spaceKey == "" || title == "" { + return "" + } + root := strings.TrimSuffix(p.site.BaseURL, "/") + p.site.ContextPath + // Confluence display URLs encode spaces as '+'. + escaped := strings.ReplaceAll(url.PathEscape(title), "%20", "+") + return fmt.Sprintf("%s/display/%s/%s", root, url.PathEscape(spaceKey), escaped) +} + +// attachmentDisplayURL builds a best-effort download URL for an attachment on +// the current page. Returns "" when there isn't enough context. +func (p *ConfluencePlugin) attachmentDisplayURL(filename string) string { + if p.site.BaseURL == "" || filename == "" || p.currentPage == nil || p.currentPage.ID == "" { + return "" + } + root := strings.TrimSuffix(p.site.BaseURL, "/") + p.site.ContextPath + return fmt.Sprintf("%s/download/attachments/%s/%s", root, p.currentPage.ID, url.PathEscape(filename)) +} + +// attrValue returns the value of the named attribute, or "" if absent. +func attrValue(n *html.Node, key string) string { + for _, a := range n.Attr { + if a.Key == key { + return a.Val + } + } + return "" +} + +// linkBodyText returns the trimmed plain-text content of an ac:link-body or +// ac:plain-text-link-body node anywhere within the ac:link subtree. The search +// is recursive because the HTML5 parser treats a self-closing as an +// open tag, nesting a following inside it rather than beside it. +func linkBodyText(link *html.Node) string { + var found *html.Node + var walk func(*html.Node) + walk = func(n *html.Node) { + if found != nil { + return + } + if n.Type == html.ElementNode && + (n.Data == "ac:link-body" || n.Data == "ac:plain-text-link-body") { + found = n + return + } + for c := n.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + for c := link.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + if found == nil { + return "" + } + return strings.TrimSpace(nodeText(found)) +} + +// nodeText collects the concatenated text of a node subtree. +func nodeText(n *html.Node) string { + var b strings.Builder + var walk func(*html.Node) + walk = func(node *html.Node) { + if node.Type == html.TextNode { + b.WriteString(node.Data) + } + for c := node.FirstChild; c != nil; c = c.NextSibling { + walk(c) + } + } + walk(n) + return b.String() +} + // handleInlineComment preserves inline comment markers func (p *ConfluencePlugin) handleInlineComment(ctx converter.Context, w converter.Writer, n *html.Node) converter.RenderStatus { // Extract the text content diff --git a/internal/converter/plugin/confluence_test.go b/internal/converter/plugin/confluence_test.go index 91bd594..842c548 100644 --- a/internal/converter/plugin/confluence_test.go +++ b/internal/converter/plugin/confluence_test.go @@ -99,6 +99,28 @@ func TestHandleEmoticon(t *testing.T) { } } +func TestHandleEmoticonNameMapping(t *testing.T) { + cases := map[string]string{ + ``: "✔️ ", + ``: "➖ ", + ``: "➖ ", + ``: "ℹ️ ", + // unknown name falls back to the :name: token + ``: ":totally-unknown:", + // explicit fallback glyph still wins over the name map + ``: "✅ ", + } + for input, want := range cases { + plugin := &ConfluencePlugin{} + node := findNode(t, input, "ac:emoticon") + var out strings.Builder + plugin.handleEmoticon(nil, &out, node) + if out.String() != want { + t.Errorf("input %q: got %q, want %q", input, out.String(), want) + } + } +} + func TestHandleTocMacro(t *testing.T) { plugin := &ConfluencePlugin{} node := findNode(t, ``, "ac:structured-macro") diff --git a/internal/converter/plugin/utils.go b/internal/converter/plugin/utils.go index f97e70d..7c1c570 100644 --- a/internal/converter/plugin/utils.go +++ b/internal/converter/plugin/utils.go @@ -6,6 +6,16 @@ import ( "strings" ) +// firstNonEmpty returns the first non-empty string from the given values. +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + // ParseConfluenceImage extracts filename from Confluence ac:image elements func ParseConfluenceImage(html string) string { filenameRegex := regexp.MustCompile(`ri:filename="([^"]+)"`) diff --git a/internal/converter/processing.go b/internal/converter/processing.go index 065d2ba..c22630c 100644 --- a/internal/converter/processing.go +++ b/internal/converter/processing.go @@ -6,6 +6,7 @@ import ( "regexp" "strings" + confluenceModel "github.com/jackchuka/confluence-md/internal/confluence/model" "github.com/jackchuka/confluence-md/internal/converter/model" "github.com/jackchuka/confluence-md/internal/converter/plugin" ) @@ -32,12 +33,14 @@ func (c *Converter) postprocessMarkdown(markdown string) string { } // extractImageReferences finds image attachments referenced in the Confluence HTML. -func (c *Converter) extractImageReferences(html, pageID, baseURL string) []model.ImageRef { +func (c *Converter) extractImageReferences(html, pageID string, site confluenceModel.SiteInfo) []model.ImageRef { var imageRefs []model.ImageRef acImageRegex := regexp.MustCompile(`]*>[\s\S]*?`) matches := acImageRegex.FindAllString(html, -1) + root := strings.TrimSuffix(site.BaseURL, "/") + site.ContextPath + for _, imageHTML := range matches { fileName := plugin.ParseConfluenceImage(imageHTML) if fileName == "" { @@ -45,8 +48,8 @@ func (c *Converter) extractImageReferences(html, pageID, baseURL string) []model } encodedFilename := url.QueryEscape(fileName) - actualURL := fmt.Sprintf("%s/wiki/download/attachments/%s/%s", - strings.TrimSuffix(baseURL, "/"), pageID, encodedFilename) + actualURL := fmt.Sprintf("%s/download/attachments/%s/%s", + root, pageID, encodedFilename) imageRefs = append(imageRefs, model.ImageRef{ OriginalURL: actualURL, @@ -57,10 +60,51 @@ func (c *Converter) extractImageReferences(html, pageID, baseURL string) []model return imageRefs } +// fileMacroRegex matches Confluence file-view macros that embed an attachment. +var fileMacroRegex = regexp.MustCompile( + `]*ac:name="(?:view-file|viewpdf|viewdoc|viewxls|viewppt|multimedia)"[\s\S]*?`) + +var riFilenameRegex = regexp.MustCompile(`ri:filename="([^"]+)"`) + +// extractFileReferences finds non-image attachments embedded via file-view +// macros (view-file, viewpdf, …) so they can be downloaded alongside images. +func (c *Converter) extractFileReferences(html, pageID string, site confluenceModel.SiteInfo) []model.ImageRef { + var fileRefs []model.ImageRef + seen := map[string]bool{} + + root := strings.TrimSuffix(site.BaseURL, "/") + site.ContextPath + + for _, macroHTML := range fileMacroRegex.FindAllString(html, -1) { + m := riFilenameRegex.FindStringSubmatch(macroHTML) + if len(m) < 2 || m[1] == "" || seen[m[1]] { + continue + } + fileName := m[1] + seen[fileName] = true + + actualURL := fmt.Sprintf("%s/download/attachments/%s/%s", + root, pageID, url.QueryEscape(fileName)) + + fileRefs = append(fileRefs, model.ImageRef{ + OriginalURL: actualURL, + FileName: fileName, + }) + } + + return fileRefs +} + // fixMarkdownLinks converts Confluence-specific links into internal references. func fixMarkdownLinks(markdown string) string { - confLinkRegex := regexp.MustCompile(`\[([^\]]+)\]\(/wiki/spaces/([^/]+)/pages/(\d+)/[^)]+\)`) - return confLinkRegex.ReplaceAllString(markdown, "[$1](confluence://pageId/$3)") + // Cloud (/wiki/spaces/...) and self-hosted (/spaces/...) modern page links. + spacesLinkRegex := regexp.MustCompile(`\[([^\]]+)\]\((?:/wiki)?/spaces/[^/]+/pages/(\d+)/[^)]+\)`) + markdown = spacesLinkRegex.ReplaceAllString(markdown, "[$1](confluence://pageId/$2)") + + // Self-hosted legacy links: /pages/viewpage.action?pageId=12345 + viewpageLinkRegex := regexp.MustCompile(`\[([^\]]+)\]\([^)]*?/pages/viewpage\.action\?pageId=(\d+)[^)]*\)`) + markdown = viewpageLinkRegex.ReplaceAllString(markdown, "[$1](confluence://pageId/$2)") + + return markdown } // fixNestedListSpacing removes extraneous blank lines in nested lists.