From 8fefefc007cb8c313c8fdfd0f550d0aa61b6651e Mon Sep 17 00:00:00 2001 From: piekstra Date: Wed, 19 Aug 2026 18:21:48 -0400 Subject: [PATCH 1/2] feat(cfl): add page export to PDF Exporting a page to PDF was the one thing that sent a workflow back to the Confluence web UI, which matters when the page is a document that has to be sent to someone outside the site. Confluence Cloud publishes no REST endpoint for this. What it does have is the action its own UI navigates to, which both starts a server-side render and returns a page carrying the task identifier as metadata, plus a progress endpoint that reports the task and finally names the rendered document. `page export` drives those three legs: start, poll, download. Details worth knowing at the call site: - The XSRF header is what separates an accepted start request from a 403. - Confluence declares which generation of the progress endpoint served the request, and that also decides whether the task result is the document URL or a URL yielding one. Both are handled; neither is guessed. - The document is handed off to a media host with a signed URL that carries its own access, so the credential is sent only to the configured site and never to whatever host the task names. - A rejected credential is answered with a sign-in page under HTTP 200, so the download is checked for the PDF header rather than trusting the status. Progress goes to stderr and the success block to stdout, so redirecting stdout captures the result and nothing else. Without --output-file the page title names the file, reduced to a single path element. --- README.md | 3 + skills/Confluence/CliReference.md | 17 + skills/Confluence/Workflows/ManagePage.md | 30 ++ tools/cfl/CHANGELOG.md | 1 + tools/cfl/README.md | 34 ++ tools/cfl/api/export.go | 294 ++++++++++++++++++ tools/cfl/api/export_test.go | 281 +++++++++++++++++ .../testdata/pdf_export_progress_running.json | 7 + .../pdf_export_progress_succeeded.json | 7 + tools/cfl/api/testdata/pdf_export_start.html | 37 +++ tools/cfl/internal/cmd/OUTPUT_SPEC.md | 29 ++ tools/cfl/internal/cmd/page/export.go | 212 +++++++++++++ tools/cfl/internal/cmd/page/export_test.go | 272 ++++++++++++++++ tools/cfl/internal/cmd/page/page.go | 1 + tools/cfl/internal/present/mutation.go | 16 + tools/cfl/internal/present/mutation_test.go | 22 ++ 16 files changed, 1263 insertions(+) create mode 100644 tools/cfl/api/export.go create mode 100644 tools/cfl/api/export_test.go create mode 100644 tools/cfl/api/testdata/pdf_export_progress_running.json create mode 100644 tools/cfl/api/testdata/pdf_export_progress_succeeded.json create mode 100644 tools/cfl/api/testdata/pdf_export_start.html create mode 100644 tools/cfl/internal/cmd/page/export.go create mode 100644 tools/cfl/internal/cmd/page/export_test.go diff --git a/README.md b/README.md index 9c1e4449..4a5b4244 100644 --- a/README.md +++ b/README.md @@ -408,6 +408,9 @@ cfl search --cql "space = DEV AND type = page" # Copy a page cfl page copy 123456 --title "Copy of Page" +# Export a page to PDF +cfl page export 123456 -O handoff.pdf + # Spaces cfl space list cfl space view DEV diff --git a/skills/Confluence/CliReference.md b/skills/Confluence/CliReference.md index eda591c0..04f40e53 100644 --- a/skills/Confluence/CliReference.md +++ b/skills/Confluence/CliReference.md @@ -61,6 +61,23 @@ cfl [resource] [action] [ID] [flags] | `cfl page copy PAGE_ID --title "Copy" --no-labels` | Copy without labels | | `cfl page delete PAGE_ID` | Delete page (with confirmation) | | `cfl page delete PAGE_ID --force` | Delete without confirmation | +| `cfl page export PAGE_ID` | Export page as PDF (filename from the page title) | +| `cfl page export PAGE_ID -O handoff.pdf` | Export to a specific file | +| `cfl page export PAGE_ID --force` | Overwrite existing file without warning | +| `cfl page export PAGE_ID --timeout 10m` | Allow longer for a large page to render | + +### Page Export Flags + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--format` | | `pdf` | Export format; `pdf` is the only value | +| `--output-file` | `-O` | (from page title) | Output file path | +| `--force` | `-f` | `false` | Overwrite existing file without warning | +| `--timeout` | | `5m` | How long to wait for Confluence to render | + +Confluence renders the document server-side, so the command starts an export +task, polls it, and downloads the result once it finishes. Progress is written +to stderr; stdout carries only the success block. ### Create/Edit Flags diff --git a/skills/Confluence/Workflows/ManagePage.md b/skills/Confluence/Workflows/ManagePage.md index dea3bd91..28b54606 100644 --- a/skills/Confluence/Workflows/ManagePage.md +++ b/skills/Confluence/Workflows/ManagePage.md @@ -14,6 +14,7 @@ Create, edit, copy, move, and delete Confluence pages. | "move page", "reparent" | Move | `cfl page edit PAGE_ID --parent NEW_PARENT_ID` | | "copy page", "duplicate" | Copy | `cfl page copy PAGE_ID --title "Copy Title"` | | "delete page", "remove page" | Delete | `cfl page delete PAGE_ID` | +| "export page", "save as PDF", "send it as a PDF" | Export | `cfl page export PAGE_ID` | ### Content Source Mapping (for create/edit) @@ -131,6 +132,34 @@ Or do it as two explicit steps (capture ID from the table output, then edit). cfl page delete PAGE_ID ``` +### Export Page to PDF + +Use this when a page has to leave Confluence as a document — an attachment to +send to someone outside the site, for instance. + +```bash +# Filename comes from the page title +cfl page export PAGE_ID + +# Name the file yourself +cfl page export PAGE_ID -O handoff.pdf + +# Overwrite an existing file +cfl page export PAGE_ID -O handoff.pdf --force + +# A large page can take longer to render +cfl page export PAGE_ID --timeout 10m +``` + +Confluence renders the PDF server-side, so the command waits on an export task +and reports completion on stderr while it does. Stdout carries only the success +block, so the path is scriptable: + +```bash +# A title-derived filename can contain spaces, so take the rest of the line +PDF=$(cfl page export PAGE_ID 2>/dev/null | sed -n 's/^Exported: //p') +``` + ## Post-Action After any action: @@ -138,4 +167,5 @@ After any action: 2. For edits: confirm what was changed (content, title, parent) 3. For copies: show the new page ID and location 4. For deletes: confirm which page was deleted +5. For exports: show the output path and size 5. For moves: confirm old and new parent diff --git a/tools/cfl/CHANGELOG.md b/tools/cfl/CHANGELOG.md index 8db43db2..6e42674d 100644 --- a/tools/cfl/CHANGELOG.md +++ b/tools/cfl/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- `page export` command to export a page as a PDF, waiting on the server-side render and writing the result to a file - Service account support with bearer auth (`--auth-method bearer`) for scoped API tokens ([#171](https://github.com/open-cli-collective/atlassian-cli/pull/171)) - Wiki-link syntax `[[Page Title]]` and `[[SPACE:Page Title]]` for internal Confluence page links ([#129](https://github.com/open-cli-collective/atlassian-cli/pull/129)) - `space view`, `space create`, `space update`, `space delete` commands for full space management ([#151](https://github.com/open-cli-collective/atlassian-cli/issues/151)) diff --git a/tools/cfl/README.md b/tools/cfl/README.md index a8d96cf0..89a7b047 100644 --- a/tools/cfl/README.md +++ b/tools/cfl/README.md @@ -8,6 +8,7 @@ A command-line interface for Atlassian Confluence Cloud, inspired by [jira-cli]( - **Markdown-first**: Write and view pages in markdown, auto-converted to/from Confluence format - List and browse spaces - Create, view, edit, copy, and delete pages +- Export a page to PDF - Inspect page history and view specific page versions - **Search content** using CQL (Confluence Query Language) - Upload, download, list, and delete attachments @@ -501,6 +502,39 @@ cfl page delete 12345 --force --- +### `cfl page export ` + +Export a page as a PDF. + +```bash +cfl page export 12345 # Filename from the page title +cfl page export 12345 -O handoff.pdf +cfl page export 12345 -O handoff.pdf --force +cfl page export 12345 --timeout 10m # Allow longer for a large page +``` + +| Flag | Short | Default | Description | +|------|-------|---------|-------------| +| `--format` | | `pdf` | Export format; `pdf` is the only supported value | +| `--output-file` | `-O` | (from page title) | Output file path | +| `--force` | `-f` | `false` | Overwrite existing file without warning | +| `--timeout` | | `5m` | How long to wait for Confluence to render the export | + +**Arguments:** +- `` - The page ID (**required**) + +Confluence renders the PDF server-side, so the command starts an export task, +polls it until it finishes, and then downloads the result. Completion updates +go to stderr, leaving stdout to carry only the success block, so redirecting +stdout captures the two-line result and nothing else. + +Without `--output-file` the page title names the file. Titles are free text and +can carry path separators, so the name is reduced to a single file in the +working directory, falling back to the page ID when a title leaves nothing +usable. + +--- + ### `cfl search [query]` Search for pages, blog posts, attachments, and comments across Confluence. diff --git a/tools/cfl/api/export.go b/tools/cfl/api/export.go new file mode 100644 index 00000000..cbeeac20 --- /dev/null +++ b/tools/cfl/api/export.go @@ -0,0 +1,294 @@ +package api //nolint:revive // package name is intentional + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "regexp" + "strings" +) + +// Export task states reported by Confluence. A task that is neither +// finished nor failed reports some other value while it runs; only these +// two decide the outcome. +const ( + ExportStateSucceeded = "SUCCEEDED" + ExportStateFailed = "FAILED" +) + +// ErrExportTaskMissing reports that Confluence answered the export request +// without naming a task. The export cannot be polled, so there is nothing +// to wait for. +var ErrExportTaskMissing = errors.New("no export task in Confluence response") + +// ErrExportFailed reports that Confluence ran the export and it did not +// succeed. +var ErrExportFailed = errors.New("export failed on the Confluence side") + +// ErrExportNotPDF reports that the download returned something other than a +// PDF. A rejected credential is answered with a sign-in page carrying HTTP +// 200, so the status code alone does not establish that a document arrived. +var ErrExportNotPDF = errors.New("export download was not a PDF") + +// pdfMagic opens every PDF document. +const pdfMagic = "%PDF-" + +// PDFExport is a started export, identified by the task Confluence +// created for it. +type PDFExport struct { + // TaskID addresses the task in the progress endpoint. + TaskID string + // V3 selects both the progress endpoint and how Result is read once + // the task finishes. Confluence declares which generation served the + // request; it is not a choice this client makes. + V3 bool +} + +// PDFExportProgress is one reading of an export task. +type PDFExportProgress struct { + Progress int `json:"progress"` + State string `json:"state"` + // Result is empty until the task finishes. It is a download URL under + // V3 and a URL yielding one otherwise. + Result string `json:"result"` + // EstimatedTimeRemaining is milliseconds, as Confluence reports it. + EstimatedTimeRemaining int `json:"estimatedTimeRemaining"` + TimeElapsed int `json:"timeElapsed"` +} + +// Done reports that the task will not progress further. +func (p *PDFExportProgress) Done() bool { + return p.State == ExportStateSucceeded || p.Progress >= 100 +} + +// Failed reports that the task ended without producing a document. +func (p *PDFExportProgress) Failed() bool { + return p.State == ExportStateFailed +} + +var ( + metaTagRe = regexp.MustCompile(`(?i)]*>`) + metaNameRe = regexp.MustCompile(`(?i)\bname="ajs-([a-zA-Z0-9_.-]+)"`) + metaContentRe = regexp.MustCompile(`(?i)\bcontent="([^"]*)"`) +) + +// ajsMeta extracts the ajs-* metadata Confluence embeds in the page it +// returns. Attribute order is the server's to choose, so name and content +// are read independently rather than as one fixed pattern. +func ajsMeta(html string) map[string]string { + meta := make(map[string]string) + for _, tag := range metaTagRe.FindAllString(html, -1) { + name := metaNameRe.FindStringSubmatch(tag) + if name == nil { + continue + } + content := metaContentRe.FindStringSubmatch(tag) + if content == nil { + continue + } + meta[name[1]] = content[1] + } + return meta +} + +// StartPDFExport asks Confluence to render a page as PDF and returns the +// task it created. +// +// Confluence Cloud exposes no JSON endpoint that starts this task. The +// browser navigates to an action that both starts the export and returns a +// progress page, and the task identifier is only ever published as metadata +// inside that page, so the identifier is read from the returned HTML. The +// XSRF header is what separates an accepted request from a 403. +func (c *Client) StartPDFExport(ctx context.Context, pageID string) (*PDFExport, error) { + path := fmt.Sprintf("/spaces/flyingpdf/pdfpageexport.action?pageId=%s", url.QueryEscape(pageID)) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.GetBaseURL()+path, nil) + if err != nil { + return nil, fmt.Errorf("creating export request: %w", err) + } + req.Header.Set("Authorization", c.GetAuthHeader()) + req.Header.Set("X-Atlassian-Token", "no-check") + + resp, err := c.GetHTTPClient().Do(req) + if err != nil { + return nil, fmt.Errorf("starting export: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading export response: %w", err) + } + if resp.StatusCode == http.StatusNotFound { + return nil, fmt.Errorf("starting export: page %s not found, or not visible to this user", pageID) + } + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("starting export: status %d", resp.StatusCode) + } + + meta := ajsMeta(string(body)) + taskID := meta["taskId"] + if taskID == "" { + return nil, ErrExportTaskMissing + } + + return &PDFExport{TaskID: taskID, V3: meta["isV3"] == "true"}, nil +} + +// progressPath addresses the task in whichever progress endpoint served +// the export. +func (e *PDFExport) progressPath() string { + if e.V3 { + return "/api/v2/pdfexporttask/progress/" + e.TaskID + } + return "/services/api/v1/task/" + e.TaskID + "/progress" +} + +// GetPDFExportProgress reads the current state of an export task. +func (c *Client) GetPDFExportProgress(ctx context.Context, export *PDFExport) (*PDFExportProgress, error) { + body, err := c.Get(ctx, export.progressPath()) + if err != nil { + return nil, fmt.Errorf("getting export progress: %w", err) + } + + var progress PDFExportProgress + if err := json.Unmarshal(body, &progress); err != nil { + return nil, fmt.Errorf("parsing export progress response: %w", err) + } + + return &progress, nil +} + +// OpenPDFExport opens the finished document for reading. +// +// Under V3 the result is already the document URL. Otherwise it addresses a +// resource whose body is the URL to fetch, which is why the indirection is +// resolved here rather than by the caller. +func (c *Client) OpenPDFExport(ctx context.Context, export *PDFExport, progress *PDFExportProgress) (io.ReadCloser, error) { + if progress.Result == "" { + return nil, errors.New("export finished without a download URL") + } + + downloadURL := progress.Result + if !export.V3 { + resolved, err := c.resolveExportDownloadURL(ctx, downloadURL) + if err != nil { + return nil, err + } + downloadURL = resolved + } + + resp, err := c.getExportResource(ctx, downloadURL) + if err != nil { + return nil, fmt.Errorf("downloading export: %w", err) + } + if resp.StatusCode != http.StatusOK { + _ = resp.Body.Close() + return nil, fmt.Errorf("downloading export: status %d", resp.StatusCode) + } + + return pdfBody(resp.Body) +} + +// pdfBody returns the response body only once it starts like a PDF, so a +// sign-in page served with HTTP 200 is not written out as a document. The +// inspected bytes stay in the stream. +func pdfBody(body io.ReadCloser) (io.ReadCloser, error) { + buffered := bufio.NewReader(body) + + magic, err := buffered.Peek(len(pdfMagic)) + if err != nil && !errors.Is(err, io.EOF) { + _ = body.Close() + return nil, fmt.Errorf("reading export download: %w", err) + } + if string(magic) != pdfMagic { + _ = body.Close() + return nil, ErrExportNotPDF + } + + return readCloser{Reader: buffered, Closer: body}, nil +} + +// readCloser reads from the buffered view while closing the underlying +// response body. +type readCloser struct { + io.Reader + io.Closer +} + +// resolveExportDownloadURL follows the pre-V3 indirection, where the task +// result names a resource whose body is the document URL. +func (c *Client) resolveExportDownloadURL(ctx context.Context, resultURL string) (string, error) { + resp, err := c.getExportResource(ctx, resultURL) + if err != nil { + return "", fmt.Errorf("resolving export download URL: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("reading export download URL: %w", err) + } + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("resolving export download URL: status %d", resp.StatusCode) + } + + resolved := strings.TrimSpace(strings.Trim(strings.TrimSpace(string(body)), `"`)) + if resolved == "" { + return "", errors.New("export download URL was empty") + } + + return resolved, nil +} + +// getExportResource fetches a URL named by the export task. +// +// A task names its result either as a path on the site or as an absolute +// URL on the media host, and the two differ in what they accept: the media +// URL is signed and carries its own access, whereas a path on the site +// needs the caller's credential. Sending the credential is therefore +// decided by where the URL points, which also keeps it off any host it does +// not belong to. +func (c *Client) getExportResource(ctx context.Context, rawURL string) (*http.Response, error) { + target, onSite, err := c.resolveExportURL(rawURL) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, target, nil) + if err != nil { + return nil, fmt.Errorf("creating download request: %w", err) + } + if onSite { + req.Header.Set("Authorization", c.GetAuthHeader()) + } + + resp, err := c.GetHTTPClient().Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +// resolveExportURL expands a task result against the configured site and +// reports whether it addresses that site. +func (c *Client) resolveExportURL(rawURL string) (string, bool, error) { + base, err := url.Parse(c.GetBaseURL()) + if err != nil { + return "", false, fmt.Errorf("parsing base URL: %w", err) + } + ref, err := url.Parse(rawURL) + if err != nil { + return "", false, fmt.Errorf("parsing export URL: %w", err) + } + + resolved := base.ResolveReference(ref) + + return resolved.String(), base.Host != "" && strings.EqualFold(base.Host, resolved.Host), nil +} diff --git a/tools/cfl/api/export_test.go b/tools/cfl/api/export_test.go new file mode 100644 index 00000000..a7c2a97e --- /dev/null +++ b/tools/cfl/api/export_test.go @@ -0,0 +1,281 @@ +package api //nolint:revive // package name is intentional + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/open-cli-collective/atlassian-go/testutil" +) + +const exportTaskID = "module-11111111-2222-3333-4444-555555555555" + +func TestClient_StartPDFExport(t *testing.T) { + t.Parallel() + fixture := loadTestData(t, "pdf_export_start.html") + + var gotAuth, gotToken, gotPath, gotQuery string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotAuth = r.Header.Get("Authorization") + gotToken = r.Header.Get("X-Atlassian-Token") + gotPath = r.URL.Path + gotQuery = r.URL.Query().Get("pageId") + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(fixture) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + export, err := client.StartPDFExport(context.Background(), "123456") + + testutil.RequireNoError(t, err) + testutil.Equal(t, exportTaskID, export.TaskID) + testutil.True(t, export.V3, "fixture declares isV3") + testutil.Equal(t, "/spaces/flyingpdf/pdfpageexport.action", gotPath) + testutil.Equal(t, "123456", gotQuery) + testutil.NotEmpty(t, gotAuth) + // Without the XSRF header Confluence refuses the request with 403. + testutil.Equal(t, "no-check", gotToken) +} + +func TestClient_StartPDFExport_PageNotFound(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte("not found")) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + _, err := client.StartPDFExport(context.Background(), "999999999") + + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "999999999") + testutil.ErrorContains(t, err, "not found") +} + +func TestClient_StartPDFExport_NoTaskID(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(``)) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + _, err := client.StartPDFExport(context.Background(), "123456") + + testutil.RequireError(t, err) + testutil.True(t, errors.Is(err, ErrExportTaskMissing), "want ErrExportTaskMissing, got "+err.Error()) +} + +// TestAJSMeta_AttributeOrder pins that metadata is read regardless of the +// order the server writes the attributes in. +func TestAJSMeta_AttributeOrder(t *testing.T) { + t.Parallel() + tests := []struct { + name string + html string + }{ + {"name first", ``}, + {"content first", ``}, + {"extra attributes", ``}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + testutil.Equal(t, "task-1", ajsMeta(tt.html)["taskId"]) + }) + } +} + +func TestPDFExport_ProgressPath(t *testing.T) { + t.Parallel() + v3 := &PDFExport{TaskID: "task-1", V3: true} + testutil.Equal(t, "/api/v2/pdfexporttask/progress/task-1", v3.progressPath()) + + legacy := &PDFExport{TaskID: "task-1"} + testutil.Equal(t, "/services/api/v1/task/task-1/progress", legacy.progressPath()) +} + +func TestClient_GetPDFExportProgress(t *testing.T) { + t.Parallel() + running := loadTestData(t, "pdf_export_progress_running.json") + succeeded := loadTestData(t, "pdf_export_progress_succeeded.json") + + tests := []struct { + name string + fixture []byte + wantDone bool + wantState string + }{ + {"running", running, false, "IN_PROGRESS"}, + {"succeeded", succeeded, true, ExportStateSucceeded}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, "/api/v2/pdfexporttask/progress/"+exportTaskID, r.URL.Path) + w.WriteHeader(http.StatusOK) + _, _ = w.Write(tt.fixture) + })) + defer server.Close() + + client := NewClient(server.URL, "user@example.com", "token") + progress, err := client.GetPDFExportProgress(context.Background(), &PDFExport{TaskID: exportTaskID, V3: true}) + + testutil.RequireNoError(t, err) + testutil.Equal(t, tt.wantState, progress.State) + testutil.Equal(t, tt.wantDone, progress.Done()) + testutil.False(t, progress.Failed()) + }) + } +} + +func TestPDFExportProgress_Failed(t *testing.T) { + t.Parallel() + progress := &PDFExportProgress{State: ExportStateFailed, Progress: 40} + testutil.True(t, progress.Failed(), "FAILED state") + testutil.False(t, progress.Done()) +} + +func TestClient_OpenPDFExport_SignedURLGetsNoCredential(t *testing.T) { + t.Parallel() + // The media host stands in for the signed URL Confluence hands back. + var mediaAuth string + var mediaCalled bool + media := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mediaCalled = true + mediaAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("%PDF-1.4\nbody")) + })) + defer media.Close() + + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + t.Error("the site must not be called for an absolute result URL") + w.WriteHeader(http.StatusInternalServerError) + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + reader, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + &PDFExportProgress{Result: media.URL + "/file/abc/binary?token=signed"}, + ) + testutil.RequireNoError(t, err) + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + testutil.RequireNoError(t, err) + testutil.Equal(t, "%PDF-1.4\nbody", string(content)) + testutil.True(t, mediaCalled, "media host was called") + testutil.Equal(t, "", mediaAuth) +} + +func TestClient_OpenPDFExport_SiteURLGetsCredential(t *testing.T) { + t.Parallel() + var gotAuth string + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testutil.Equal(t, "/download/export/page.pdf", r.URL.Path) + gotAuth = r.Header.Get("Authorization") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("%PDF-1.4\nbody")) + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + reader, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + // A result naming a path on the site rather than an absolute URL. + &PDFExportProgress{Result: "/download/export/page.pdf"}, + ) + testutil.RequireNoError(t, err) + defer func() { _ = reader.Close() }() + + _, err = io.ReadAll(reader) + testutil.RequireNoError(t, err) + testutil.NotEmpty(t, gotAuth) +} + +// TestClient_OpenPDFExport_RejectsNonPDF pins the failure that a status +// code cannot catch: a refused credential is answered with a sign-in page +// carrying HTTP 200, which would otherwise be written out as the document. +func TestClient_OpenPDFExport_RejectsNonPDF(t *testing.T) { + t.Parallel() + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("Log in to continue")) + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + _, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + &PDFExportProgress{Result: "/download/export/page.pdf"}, + ) + + testutil.RequireError(t, err) + testutil.True(t, errors.Is(err, ErrExportNotPDF), "want ErrExportNotPDF, got "+err.Error()) +} + +func TestClient_OpenPDFExport_NoResult(t *testing.T) { + t.Parallel() + client := NewClient("https://example.atlassian.net/wiki", "user@example.com", "token") + _, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID, V3: true}, + &PDFExportProgress{Progress: 100, State: ExportStateSucceeded}, + ) + + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "download URL") +} + +// TestClient_OpenPDFExport_LegacyIndirection covers the pre-V3 shape, where +// the task result addresses a resource whose body is the document URL. +func TestClient_OpenPDFExport_LegacyIndirection(t *testing.T) { + t.Parallel() + var documentServed bool + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/download/link": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("/download/export/page.pdf\n")) + case "/download/export/page.pdf": + documentServed = true + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("%PDF-1.4\nbody")) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer site.Close() + + client := NewClient(site.URL, "user@example.com", "token") + reader, err := client.OpenPDFExport( + context.Background(), + &PDFExport{TaskID: exportTaskID}, + &PDFExportProgress{Result: "/download/link"}, + ) + testutil.RequireNoError(t, err) + defer func() { _ = reader.Close() }() + + content, err := io.ReadAll(reader) + testutil.RequireNoError(t, err) + testutil.True(t, documentServed, "document was fetched through the indirection") + testutil.True(t, strings.HasPrefix(string(content), pdfMagic), "content is a PDF") +} diff --git a/tools/cfl/api/testdata/pdf_export_progress_running.json b/tools/cfl/api/testdata/pdf_export_progress_running.json new file mode 100644 index 00000000..74a50f7f --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_progress_running.json @@ -0,0 +1,7 @@ +{ + "progress": 0, + "state": "IN_PROGRESS", + "result": "", + "estimatedTimeRemaining": 30000, + "timeElapsed": 4 +} diff --git a/tools/cfl/api/testdata/pdf_export_progress_succeeded.json b/tools/cfl/api/testdata/pdf_export_progress_succeeded.json new file mode 100644 index 00000000..e5fc6263 --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_progress_succeeded.json @@ -0,0 +1,7 @@ +{ + "progress": 100, + "state": "SUCCEEDED", + "result": "https://api.media.example.com/file/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee/binary?token=signed-token&client=example-client&name=Quarterly%20Handoff.pdf", + "estimatedTimeRemaining": 30000, + "timeElapsed": 35 +} diff --git a/tools/cfl/api/testdata/pdf_export_start.html b/tools/cfl/api/testdata/pdf_export_start.html new file mode 100644 index 00000000..3a877c89 --- /dev/null +++ b/tools/cfl/api/testdata/pdf_export_start.html @@ -0,0 +1,37 @@ + + + + Export to PDF + + + + + + + + + + + +
+

Exporting to PDF

+
+ 0% complete + + +
+ + + diff --git a/tools/cfl/internal/cmd/OUTPUT_SPEC.md b/tools/cfl/internal/cmd/OUTPUT_SPEC.md index 877cb948..3e6e0d32 100644 --- a/tools/cfl/internal/cmd/OUTPUT_SPEC.md +++ b/tools/cfl/internal/cmd/OUTPUT_SPEC.md @@ -380,6 +380,35 @@ Success: Deleted page: (ID: <id>) ``` +## `page export <page-id>` + +Success: + +```text +Exported: <output-path> +Size: <human-readable size> +``` + +Confluence renders the document server-side, so the export runs as a task that +is polled until it finishes. Each change in reported completion emits one +stderr line, which keeps a wait distinguishable from a stall: + +```text +Exporting: <percent>% complete +``` + +Notes: +- Progress is stderr only; stdout carries the success block and nothing else. +- `--format` accepts `pdf` and rejects anything else with + `invalid export format: "<value>" (valid formats: pdf)`. +- Without `--output-file` the filename derives from the page title, reduced to a + single path element, falling back to the page ID when the title leaves nothing + usable. +- An existing output file is refused unless `--force` is passed, and the refusal + precedes the export so no render is spent on it. +- A wait that exceeds `--timeout` fails naming the flag rather than the deadline + alone. + ## `attachment list --page <page-id>` Default columns: diff --git a/tools/cfl/internal/cmd/page/export.go b/tools/cfl/internal/cmd/page/export.go new file mode 100644 index 00000000..b2f1aa94 --- /dev/null +++ b/tools/cfl/internal/cmd/page/export.go @@ -0,0 +1,212 @@ +package page + +import ( + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/cmd/root" + cflpresent "github.com/open-cli-collective/confluence-cli/internal/present" +) + +// exportFormatPDF is the only representation Confluence renders for a +// single page through its export task. +const exportFormatPDF = "pdf" + +// pollInterval paces the progress reads while Confluence renders. +const pollInterval = 2 * time.Second + +type exportOptions struct { + *root.Options + format string + outputFile string + force bool + timeout time.Duration +} + +func newExportCmd(rootOpts *root.Options) *cobra.Command { + opts := &exportOptions{Options: rootOpts} + + cmd := &cobra.Command{ + Use: "export <page-id>", + Short: "Export a page to a file", + Long: `Export a Confluence page as a PDF. + +Confluence renders the document server-side, so the export runs as a task +that is polled until it finishes and the result is then downloaded.`, + Example: ` # Export to a file named after the page + cfl page export 123456 + + # Export to a specific file + cfl page export 123456 -O handoff.pdf + + # Overwrite an existing file + cfl page export 123456 -O handoff.pdf --force + + # Allow longer for a large page + cfl page export 123456 --timeout 10m`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runExport(cmd.Context(), args[0], opts) + }, + } + + cmd.Flags().StringVar(&opts.format, "format", exportFormatPDF, "Export format: pdf") + cmd.Flags().StringVarP(&opts.outputFile, "output-file", "O", "", "Output file path (default: derived from the page title)") + cmd.Flags().BoolVarP(&opts.force, "force", "f", false, "Overwrite existing file without warning") + cmd.Flags().DurationVar(&opts.timeout, "timeout", 5*time.Minute, "How long to wait for Confluence to render the export") + + return cmd +} + +func runExport(ctx context.Context, pageID string, opts *exportOptions) error { + if err := validateExportFormat(opts.format); err != nil { + return err + } + if opts.timeout <= 0 { + return fmt.Errorf("invalid --timeout: %s (must be greater than zero)", opts.timeout) + } + + client, err := opts.APIClient() + if err != nil { + return err + } + + outputPath, err := exportOutputPath(ctx, client, pageID, opts) + if err != nil { + return err + } + + // Check before starting so a refusal costs no server-side render. + if !opts.force { + if _, err := os.Stat(outputPath); err == nil { + return fmt.Errorf("file already exists: %s (use --force to overwrite)", outputPath) + } + } + + export, err := client.StartPDFExport(ctx, pageID) + if err != nil { + return err + } + + progress, err := awaitExport(ctx, client, export, opts) + if err != nil { + return err + } + + reader, err := client.OpenPDFExport(ctx, export, progress) + if err != nil { + return err + } + defer func() { _ = reader.Close() }() + + bytesWritten, err := writeExport(outputPath, reader) + if err != nil { + return err + } + + return cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentExport(outputPath, bytesWritten)) +} + +// validateExportFormat holds --format to the formats the command can +// actually produce, so an unsupported value fails before any work starts. +func validateExportFormat(format string) error { + if format == exportFormatPDF { + return nil + } + return fmt.Errorf("invalid export format: %q (valid formats: %s)", format, exportFormatPDF) +} + +// exportOutputPath resolves where the document is written. An explicit path +// is used as given; otherwise the page title names the file, which costs a +// read of the page. +func exportOutputPath(ctx context.Context, client *api.Client, pageID string, opts *exportOptions) (string, error) { + if opts.outputFile != "" { + return opts.outputFile, nil + } + + page, err := client.GetPage(ctx, pageID, nil) + if err != nil { + return "", fmt.Errorf("getting page: %w", err) + } + + return exportFilename(page.Title, pageID), nil +} + +// exportFilename turns a page title into a filename. Titles are free text +// and carry separators, so the result is reduced to a single path element +// and falls back to the page ID when a title leaves nothing usable. +func exportFilename(title, pageID string) string { + cleaned := strings.Map(func(r rune) rune { + switch r { + case '/', '\\', ':', '*', '?', '"', '<', '>', '|': + return '-' + } + if r < 32 { + return ' ' + } + return r + }, title) + + cleaned = strings.TrimSpace(filepath.Base(strings.TrimSpace(cleaned))) + if cleaned == "" || cleaned == "." || cleaned == ".." { + cleaned = pageID + } + + return cleaned + ".pdf" +} + +// awaitExport polls until Confluence finishes rendering, reporting progress +// on stderr so a wait is distinguishable from a stall. +func awaitExport(ctx context.Context, client *api.Client, export *api.PDFExport, opts *exportOptions) (*api.PDFExportProgress, error) { + ctx, cancel := context.WithTimeout(ctx, opts.timeout) + defer cancel() + + lastReported := -1 + for { + progress, err := client.GetPDFExportProgress(ctx, export) + if err != nil { + return nil, fmt.Errorf("waiting for export: %w", err) + } + if progress.Failed() { + return nil, api.ErrExportFailed + } + if progress.Done() { + return progress, nil + } + + if progress.Progress != lastReported { + lastReported = progress.Progress + _ = cflpresent.Emit(opts.Options, cflpresent.PagePresenter{}.PresentExportProgress(progress.Progress)) + } + + select { + case <-ctx.Done(): + return nil, fmt.Errorf("waiting for export: %w (use --timeout to wait longer)", ctx.Err()) + case <-time.After(pollInterval): + } + } +} + +// writeExport streams the document to disk and reports its size. +func writeExport(outputPath string, reader io.Reader) (int64, error) { + outFile, err := os.Create(outputPath) //nolint:gosec // CLI tool creates user-specified output file + if err != nil { + return 0, fmt.Errorf("creating output file: %w", err) + } + defer func() { _ = outFile.Close() }() + + bytesWritten, err := io.Copy(outFile, reader) + if err != nil { + return 0, fmt.Errorf("writing file: %w", err) + } + + return bytesWritten, nil +} diff --git a/tools/cfl/internal/cmd/page/export_test.go b/tools/cfl/internal/cmd/page/export_test.go new file mode 100644 index 00000000..9c3e5e46 --- /dev/null +++ b/tools/cfl/internal/cmd/page/export_test.go @@ -0,0 +1,272 @@ +package page + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/open-cli-collective/atlassian-go/testutil" + + "github.com/open-cli-collective/confluence-cli/api" + "github.com/open-cli-collective/confluence-cli/internal/cmd/root" +) + +const exportPDFBody = "%PDF-1.4\nexported document" + +// mockExportServer serves the three legs of an export: the action that +// starts the task, the progress reads, and the document itself. runsBefore +// controls how many reads report the task still running. +func mockExportServer(t *testing.T, runsBefore int) *httptest.Server { + t.Helper() + var polls int + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v2/pages/123456": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"id":"123456","title":"Quarterly Handoff","spaceId":"789"}`)) + case "/spaces/flyingpdf/pdfpageexport.action": + w.Header().Set("Content-Type", "text/html;charset=UTF-8") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`<html><head>` + + `<meta name="ajs-taskId" content="module-abc">` + + `<meta name="ajs-isV3" content="true">` + + `</head></html>`)) + case "/api/v2/pdfexporttask/progress/module-abc": + w.WriteHeader(http.StatusOK) + if polls < runsBefore { + polls++ + _, _ = w.Write([]byte(`{"progress":0,"state":"IN_PROGRESS"}`)) + return + } + _, _ = w.Write([]byte(`{"progress":100,"state":"SUCCEEDED","result":"` + server.URL + `/download/export.pdf"}`)) + case "/download/export.pdf": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(exportPDFBody)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + return server +} + +func newExportTestRootOptions() *root.Options { + return &root.Options{ + Output: "table", + NoColor: true, + Stdout: &bytes.Buffer{}, + Stderr: &bytes.Buffer{}, + } +} + +func newExportTestOptions(t *testing.T, server *httptest.Server) *exportOptions { + t.Helper() + rootOpts := newExportTestRootOptions() + rootOpts.SetAPIClient(api.NewClient(server.URL, "user@example.com", "token")) + return &exportOptions{ + Options: rootOpts, + format: exportFormatPDF, + timeout: 30 * time.Second, + } +} + +func TestRunExport_Success(t *testing.T) { + server := mockExportServer(t, 0) + defer server.Close() + + tmpDir := t.TempDir() + origDir, _ := os.Getwd() + _ = os.Chdir(tmpDir) + defer func() { _ = os.Chdir(origDir) }() + + opts := newExportTestOptions(t, server) + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Exported: Quarterly Handoff.pdf\nSize: 26 B\n", opts.Stdout.(*bytes.Buffer).String()) + + content, err := os.ReadFile(filepath.Join(tmpDir, "Quarterly Handoff.pdf")) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, exportPDFBody, string(content)) +} + +func TestRunExport_CustomOutputFile(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "handoff.pdf") + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + testutil.Equal(t, "Exported: "+outputPath+"\nSize: 26 B\n", opts.Stdout.(*bytes.Buffer).String()) + + content, err := os.ReadFile(outputPath) //nolint:gosec // reading test output file + testutil.RequireNoError(t, err) + testutil.Equal(t, exportPDFBody, string(content)) +} + +// TestRunExport_ReportsProgress pins that a wait is visible. Confluence +// renders server-side, so silence for that stretch is indistinguishable +// from a hang. +func TestRunExport_ReportsProgress(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 1) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + testutil.Contains(t, opts.Stderr.(*bytes.Buffer).String(), "Exporting: 0% complete") + // Progress belongs on stderr so stdout carries only the artifact. + testutil.NotContains(t, opts.Stdout.(*bytes.Buffer).String(), "Exporting") +} + +func TestRunExport_FileExists_NoForce(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "handoff.pdf") + testutil.RequireNoError(t, os.WriteFile(outputPath, []byte("existing content"), 0600)) + + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "file already exists") + testutil.ErrorContains(t, err, "--force") + + content, _ := os.ReadFile(outputPath) //nolint:gosec // reading test fixture file + testutil.Equal(t, "existing content", string(content)) +} + +func TestRunExport_FileExists_WithForce(t *testing.T) { + t.Parallel() + server := mockExportServer(t, 0) + defer server.Close() + + outputPath := filepath.Join(t.TempDir(), "handoff.pdf") + testutil.RequireNoError(t, os.WriteFile(outputPath, []byte("existing content"), 0600)) + + opts := newExportTestOptions(t, server) + opts.outputFile = outputPath + opts.force = true + + err := runExport(context.Background(), "123456", opts) + testutil.RequireNoError(t, err) + + content, _ := os.ReadFile(outputPath) //nolint:gosec // reading test output file + testutil.Equal(t, exportPDFBody, string(content)) +} + +func TestRunExport_InvalidFormat(t *testing.T) { + t.Parallel() + opts := &exportOptions{Options: newExportTestRootOptions(), format: "docx", timeout: time.Minute} + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, `invalid export format: "docx"`) + testutil.ErrorContains(t, err, "valid formats: pdf") +} + +func TestRunExport_InvalidTimeout(t *testing.T) { + t.Parallel() + opts := &exportOptions{Options: newExportTestRootOptions(), format: exportFormatPDF} + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "invalid --timeout") +} + +func TestRunExport_TimeoutWaitingForRender(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/spaces/flyingpdf/pdfpageexport.action": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`<html><head><meta name="ajs-taskId" content="module-abc">` + + `<meta name="ajs-isV3" content="true"></head></html>`)) + default: + // The task never finishes. + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"progress":0,"state":"IN_PROGRESS"}`)) + } + })) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + opts.timeout = 50 * time.Millisecond + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "--timeout") +} + +func TestRunExport_ExportFailed(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/spaces/flyingpdf/pdfpageexport.action": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`<html><head><meta name="ajs-taskId" content="module-abc">` + + `<meta name="ajs-isV3" content="true"></head></html>`)) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"progress":40,"state":"FAILED"}`)) + } + })) + defer server.Close() + + opts := newExportTestOptions(t, server) + opts.outputFile = filepath.Join(t.TempDir(), "handoff.pdf") + + err := runExport(context.Background(), "123456", opts) + testutil.RequireError(t, err) + testutil.ErrorContains(t, err, "export failed") +} + +// TestExportFilename covers titles that are free text: they carry path +// separators and characters a filename cannot hold, and must still resolve +// to a single file in the working directory. +func TestExportFilename(t *testing.T) { + t.Parallel() + tests := []struct { + name string + title string + pageID string + want string + }{ + {"plain title", "Quarterly Handoff", "123", "Quarterly Handoff.pdf"}, + {"path separators", "Runbook: DB/Restore", "123", "Runbook- DB-Restore.pdf"}, + {"path traversal", "../../../etc/passwd", "123", "..-..-..-etc-passwd.pdf"}, + {"leading slash", "/etc/passwd", "123", "-etc-passwd.pdf"}, + {"empty title", "", "123", "123.pdf"}, + {"whitespace title", " ", "123", "123.pdf"}, + {"dot title", ".", "123", "123.pdf"}, + {"double dot title", "..", "123", "123.pdf"}, + {"reserved characters", `Report <2026> "final"?`, "123", "Report -2026- -final--.pdf"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := exportFilename(tt.title, tt.pageID) + testutil.Equal(t, tt.want, got) + // Whatever the title held, the result names one file here. + testutil.Equal(t, got, filepath.Base(got)) + }) + } +} diff --git a/tools/cfl/internal/cmd/page/page.go b/tools/cfl/internal/cmd/page/page.go index ef2675d0..7bc55c3c 100644 --- a/tools/cfl/internal/cmd/page/page.go +++ b/tools/cfl/internal/cmd/page/page.go @@ -35,6 +35,7 @@ func Register(rootCmd *cobra.Command, opts *root.Options) { cmd.AddCommand(newEditCmd(opts)) cmd.AddCommand(newDeleteCmd(opts)) cmd.AddCommand(newCopyCmd(opts)) + cmd.AddCommand(newExportCmd(opts)) rootCmd.AddCommand(cmd) } diff --git a/tools/cfl/internal/present/mutation.go b/tools/cfl/internal/present/mutation.go index 6769fc61..37dbdd71 100644 --- a/tools/cfl/internal/present/mutation.go +++ b/tools/cfl/internal/present/mutation.go @@ -68,6 +68,22 @@ func (PagePresenter) PresentCopy(page *api.Page) *sharedpresent.OutputModel { return successWithFields(fmt.Sprintf("Copied page: %s", orDash(page.Title)), fields...) } +func (PagePresenter) PresentExport(outputPath string, sizeBytes int64) *sharedpresent.OutputModel { + return successWithFields( + fmt.Sprintf("Exported: %s", outputPath), + sharedpresent.Field{Label: "Size", Value: formatAttachmentFileSize(sizeBytes)}, + ) +} + +// PresentExportProgress reports that an export is still running. Confluence +// renders the document server-side, so a caller with no output for that +// stretch cannot tell waiting apart from a hang. +func (PagePresenter) PresentExportProgress(percent int) *sharedpresent.OutputModel { + return &sharedpresent.OutputModel{Sections: []sharedpresent.Section{ + stderrInfo(fmt.Sprintf("Exporting: %d%% complete", percent)), + }} +} + func (PagePresenter) PresentDelete(page *api.Page) *sharedpresent.OutputModel { return successMessage(fmt.Sprintf("Deleted page: %s (ID: %s)", orDash(page.Title), orDash(page.ID))) } diff --git a/tools/cfl/internal/present/mutation_test.go b/tools/cfl/internal/present/mutation_test.go index 99439900..57a0666d 100644 --- a/tools/cfl/internal/present/mutation_test.go +++ b/tools/cfl/internal/present/mutation_test.go @@ -110,6 +110,28 @@ func TestAttachmentMutationPresenters(t *testing.T) { testutil.Equal(t, "Deleted attachment: spec.pdf (ID: att-1)", deleteSummary.Message) } +func TestPagePresenterExport(t *testing.T) { + t.Parallel() + + model := PagePresenter{}.PresentExport("handoff.pdf", 261759) + summary := requireMessageSection(t, model, 0) + testutil.Equal(t, sharedpresent.StreamStdout, summary.Stream) + testutil.Equal(t, "Exported: handoff.pdf", summary.Message) + fields := requireDetailSection(t, model, 1) + testutil.Equal(t, []sharedpresent.Field{{Label: "Size", Value: "255.6 KB"}}, fields.Fields) +} + +// TestPagePresenterExportProgress pins progress to stderr so stdout carries +// only the success artifact. +func TestPagePresenterExportProgress(t *testing.T) { + t.Parallel() + + model := PagePresenter{}.PresentExportProgress(40) + msg := requireMessageSection(t, model, 0) + testutil.Equal(t, sharedpresent.StreamStderr, msg.Stream) + testutil.Equal(t, "Exporting: 40% complete", msg.Message) +} + func TestPresentDeletionCancelled(t *testing.T) { t.Parallel() From a9ac951fa4c2c2a4540ff63e22a533005073b787 Mon Sep 17 00:00:00 2001 From: piekstra <piekstra@users.noreply.github.com> Date: Wed, 19 Aug 2026 18:24:12 -0400 Subject: [PATCH 2/2] docs(cfl): add page export to the manual integration test catalog The export drives an undocumented Confluence flow that Atlassian has moved before without notice, so the catalog is where a regression in it would be caught. Also records that bearer auth is unverified for this command rather than leaving the gap implied: the export goes through a web action rather than a REST endpoint, and whether the gateway proxies that action has not been established. --- tools/cfl/integration-tests.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tools/cfl/integration-tests.md b/tools/cfl/integration-tests.md index f83bef89..8bbe3b15 100644 --- a/tools/cfl/integration-tests.md +++ b/tools/cfl/integration-tests.md @@ -142,6 +142,37 @@ All cfl commands should work with both auth methods (no scope limitations for Co --- +### page export + +Confluence Cloud publishes no REST endpoint for PDF export, so this command +drives the same undocumented flow the Confluence UI uses: an action that starts +a server-side render, a progress endpoint, and a download. Atlassian has moved +the progress endpoint before without notice, so these cases are the tripwire for +that happening again, and they are worth re-running after any Confluence Cloud +change that breaks export in the browser. + +| Test Case | Command | Expected Result | +|-----------|---------|-----------------| +| Default filename | `cfl page export <id>` | Writes `<page title>.pdf`; `Exported:` and `Size:` on stdout | +| File is a real PDF | `file '<page title>.pdf'` | Reports a PDF document, not HTML | +| Progress is visible | `cfl page export <id> -O out.pdf` | `Exporting: N% complete` on stderr while it renders | +| Stdout is clean | `cfl page export <id> -O out.pdf 2>/dev/null` | Only the `Exported:`/`Size:` block, no progress lines | +| Custom output path | `cfl page export <id> -O handoff.pdf` | Writes `handoff.pdf` | +| Existing file refused | `cfl page export <id> -O handoff.pdf` again | Error: file already exists, suggests `--force` | +| Overwrite | `cfl page export <id> -O handoff.pdf --force` | File replaced | +| Invalid format | `cfl page export <id> --format docx` | Error: invalid export format, valid formats: pdf | +| Invalid timeout | `cfl page export <id> --timeout 0` | Error: invalid `--timeout`, must be greater than zero | +| Non-existent page | `cfl page export 99999999999` | Error: page not found, or not visible to this user | +| Title needing sanitizing | Export a page whose title contains `/` or `:` | Writes one file in the working directory, separators replaced | +| Large page | `cfl page export <large-id> --timeout 10m` | Completes, or fails naming `--timeout` rather than hanging | + +**Bearer auth is unverified for this command.** The export is driven through a +Confluence web action rather than a REST endpoint, and whether the +`api.atlassian.com` gateway proxies that action has not been established. Run +the default-filename case under bearer auth and record the result here. + +--- + ## Attachment Operations ### attachment list