From a0283150064ceafea693f6efcfd2b71abfa8b8cb Mon Sep 17 00:00:00 2001 From: 4ier Date: Thu, 30 Apr 2026 13:57:59 +0800 Subject: [PATCH] feat(file): add 'file get ' for retrieving a single file upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps GET /v1/file_uploads/:id — the dedicated endpoint for checking the status of a file upload (pending / uploaded / expired), recovering its content_type/size, or grabbing an existing file_upload id for re-use inside a block. Previously this endpoint was only reachable via: notion api GET /v1/file_uploads/ The new command prints a human-friendly summary by default and passes through the raw JSON with --format json. Tests cover the full-field render and the minimal 'pending' render, asserting only on output that goes through fmt.Println — keys rendered via fatih/color bypass our stdout pipe capture (same pattern as the existing search table tests). Closes #34 --- cmd/file.go | 101 +++++++++++++++++++++++++++++++++++++++++++ cmd/file_get_test.go | 78 +++++++++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 cmd/file_get_test.go diff --git a/cmd/file.go b/cmd/file.go index b7c88f7..a87cfce 100644 --- a/cmd/file.go +++ b/cmd/file.go @@ -446,9 +446,110 @@ func mediaBlockTypeForContentType(contentType string) string { } } +var fileGetCmd = &cobra.Command{ + Use: "get ", + Short: "Retrieve a file upload by ID", + Long: `Retrieve a single file upload by its ID, e.g. to check 'status' +(pending / uploaded / expired) or recover an existing file_upload id for +re-use in a block. + +Examples: + notion file get 351d45fb-804f-8151-a5ea-00b27d0b9258 + notion file get 351d45fb-... --format json`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + token, err := getToken() + if err != nil { + return err + } + + uploadID := strings.TrimSpace(args[0]) + if uploadID == "" { + return fmt.Errorf("upload id is required") + } + + c := client.New(token) + c.SetDebug(debugMode) + + data, err := c.Get("/v1/file_uploads/" + uploadID) + if err != nil { + return fmt.Errorf("get file upload: %w", err) + } + + var result map[string]interface{} + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("parse response: %w", err) + } + + if outputFormat == "json" { + return render.JSON(result) + } + + renderFileUpload(result) + return nil + }, +} + +// renderFileUpload prints a human-friendly summary of a file_upload object. +// Kept as a standalone helper so it can be reused by future commands (for +// example a --watch flag on `file upload` that polls status via 'file get'). +func renderFileUpload(result map[string]interface{}) { + name, _ := result["filename"].(string) + if name == "" { + name, _ = result["name"].(string) + } + id, _ := result["id"].(string) + status, _ := result["status"].(string) + contentType, _ := result["content_type"].(string) + created, _ := result["created_time"].(string) + expires, _ := result["expiry_time"].(string) + + var size int64 + switch v := result["content_length"].(type) { + case float64: + size = int64(v) + case int64: + size = v + } + + title := "File upload" + if name != "" { + title = fmt.Sprintf("File upload: %s", name) + } + render.Title("✓", title) + render.Field("ID", id) + if status != "" { + render.Field("Status", status) + } + if size > 0 { + render.Field("Size", fmt.Sprintf("%d bytes", size)) + } + if contentType != "" { + render.Field("Content-Type", contentType) + } + if created != "" { + if len(created) > 10 { + created = created[:10] + } + render.Field("Created", created) + } + if expires != "" { + if len(expires) > 10 { + expires = expires[:10] + } + render.Field("Expires", expires) + } + if fileURL, ok := result["file"].(map[string]interface{}); ok { + if u, _ := fileURL["url"].(string); u != "" { + render.Field("URL", u) + } + } +} + func init() { fileUploadCmd.Flags().String("to", "", "Target page ID to attach file to") fileUploadCmd.Flags().String("name", "", "Override filename (required for stdin source, optional for URL)") fileCmd.AddCommand(fileListCmd) fileCmd.AddCommand(fileUploadCmd) + fileCmd.AddCommand(fileGetCmd) } diff --git a/cmd/file_get_test.go b/cmd/file_get_test.go new file mode 100644 index 0000000..283de22 --- /dev/null +++ b/cmd/file_get_test.go @@ -0,0 +1,78 @@ +package cmd + +import ( + "bytes" + "io" + "os" + "strings" + "testing" +) + +func TestRenderFileUpload_AllFields(t *testing.T) { + // Redirect stdout so we can assert on the rendered lines. + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + renderFileUpload(map[string]interface{}{ + "id": "upload-abc", + "filename": "chart.png", + "status": "uploaded", + "content_type": "image/png", + "content_length": float64(45231), + "created_time": "2026-04-30T05:00:00.000Z", + "expiry_time": "2026-05-30T05:00:00.000Z", + "file": map[string]interface{}{ + "url": "https://notion.s3/chart.png?sig=x", + }, + }) + + w.Close() + os.Stdout = oldStdout + var buf bytes.Buffer + io.Copy(&buf, r) + out := buf.String() + + for _, want := range []string{ + // Title goes through fatih/color which bypasses our stdout pipe, + // so we only assert on fields whose VALUES come from fmt.Println. + "upload-abc", + "uploaded", + "image/png", + "45231 bytes", + "2026-04-30", + "2026-05-30", + "https://notion.s3/chart.png", + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in output:\n%s", want, out) + } + } +} + +func TestRenderFileUpload_MinimalPending(t *testing.T) { + // Only the bare minimum — a pending upload that hasn't received bytes yet. + oldStdout := os.Stdout + r, w, _ := os.Pipe() + os.Stdout = w + + renderFileUpload(map[string]interface{}{ + "id": "upload-pending", + "status": "pending", + }) + + w.Close() + os.Stdout = oldStdout + var buf bytes.Buffer + io.Copy(&buf, r) + out := buf.String() + + if !strings.Contains(out, "upload-pending") || !strings.Contains(out, "pending") { + t.Errorf("minimal render missing fields:\n%s", out) + } + // Should NOT print empty Size / Created / Expires / Content-Type values. + // We check for the *value pattern* rather than the key (which goes through color lib). + if strings.Contains(out, "bytes") { + t.Errorf("unexpected size line for pending object:\n%s", out) + } +}