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/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 ` | Writes `.pdf`; `Exported:` and `Size:` on stdout | +| File is a real PDF | `file '.pdf'` | Reports a PDF document, not HTML | +| Progress is visible | `cfl page export -O out.pdf` | `Exporting: N% complete` on stderr while it renders | +| Stdout is clean | `cfl page export -O out.pdf 2>/dev/null` | Only the `Exported:`/`Size:` block, no progress lines | +| Custom output path | `cfl page export -O handoff.pdf` | Writes `handoff.pdf` | +| Existing file refused | `cfl page export -O handoff.pdf` again | Error: file already exists, suggests `--force` | +| Overwrite | `cfl page export -O handoff.pdf --force` | File replaced | +| Invalid format | `cfl page export --format docx` | Error: invalid export format, valid formats: pdf | +| Invalid timeout | `cfl page export --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 --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 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()