|
5 | 5 | "fmt" |
6 | 6 | "io" |
7 | 7 | "os" |
| 8 | + "path/filepath" |
8 | 9 | "strconv" |
9 | 10 | "strings" |
10 | 11 | "text/tabwriter" |
|
35 | 36 | docDeleteYes bool |
36 | 37 | docDeleteOutput string |
37 | 38 | docDeleteJQ string |
| 39 | + |
| 40 | + docDownloadOutput string |
38 | 41 | ) |
39 | 42 |
|
40 | 43 | var documentCmd = &cobra.Command{ |
@@ -109,13 +112,28 @@ By default the command prompts for confirmation. Pass --yes to skip it.`, |
109 | 112 | RunE: runDocumentDelete, |
110 | 113 | } |
111 | 114 |
|
| 115 | +var documentDownloadCmd = &cobra.Command{ |
| 116 | + Use: "download <docid>", |
| 117 | + Short: "Download a document as PDF", |
| 118 | + Long: `Download a document as PDF via GET /api/v1/documents/{docid}/pdf. |
| 119 | +
|
| 120 | +By default the PDF is saved to the current directory using the filename |
| 121 | +provided by the server (Content-Disposition). Use --output to override: |
| 122 | + --output FILE save to FILE |
| 123 | + --output DIR/ save into DIR/ using the server-provided filename |
| 124 | + --output - write the PDF to stdout`, |
| 125 | + Args: cobra.ExactArgs(1), |
| 126 | + RunE: runDocumentDownload, |
| 127 | +} |
| 128 | + |
112 | 129 | func init() { |
113 | 130 | rootCmd.AddCommand(documentCmd) |
114 | 131 | documentCmd.AddCommand(documentSearchCmd) |
115 | 132 | documentCmd.AddCommand(documentCreateCmd) |
116 | 133 | documentCmd.AddCommand(documentGetCmd) |
117 | 134 | documentCmd.AddCommand(documentEditCmd) |
118 | 135 | documentCmd.AddCommand(documentDeleteCmd) |
| 136 | + documentCmd.AddCommand(documentDownloadCmd) |
119 | 137 |
|
120 | 138 | f := documentSearchCmd.Flags() |
121 | 139 | f.StringVar(&docSearchBody, "body", "", "search condition JSON: inline, file path, or - for stdin") |
@@ -143,6 +161,9 @@ func init() { |
143 | 161 | df.BoolVarP(&docDeleteYes, "yes", "y", false, "skip the interactive confirmation prompt") |
144 | 162 | df.StringVarP(&docDeleteOutput, "output", "o", "", "output format: table|json (default: table on TTY, json otherwise)") |
145 | 163 | df.StringVar(&docDeleteJQ, "jq", "", "apply a gojq filter to the JSON response (forces JSON output)") |
| 164 | + |
| 165 | + dlf := documentDownloadCmd.Flags() |
| 166 | + dlf.StringVarP(&docDownloadOutput, "output", "o", "", "output path: FILE, DIR/, or - for stdout (default: server-provided filename in current directory)") |
146 | 167 | } |
147 | 168 |
|
148 | 169 | func runDocumentSearch(cmd *cobra.Command, args []string) error { |
@@ -312,6 +333,59 @@ func runDocumentDelete(cmd *cobra.Command, args []string) error { |
312 | 333 | }) |
313 | 334 | } |
314 | 335 |
|
| 336 | +func runDocumentDownload(cmd *cobra.Command, args []string) error { |
| 337 | + docID, err := parseDocID(args[0]) |
| 338 | + if err != nil { |
| 339 | + return err |
| 340 | + } |
| 341 | + client, err := newClientFromFlags(cmd.Context()) |
| 342 | + if err != nil { |
| 343 | + return err |
| 344 | + } |
| 345 | + |
| 346 | + filename, data, err := client.DownloadPDF(cmd.Context(), docID) |
| 347 | + if err != nil { |
| 348 | + return err |
| 349 | + } |
| 350 | + |
| 351 | + out := docDownloadOutput |
| 352 | + if out == "-" { |
| 353 | + _, werr := os.Stdout.Write(data) |
| 354 | + return werr |
| 355 | + } |
| 356 | + |
| 357 | + dst := resolveDownloadPath(out, filename, docID) |
| 358 | + if err := os.WriteFile(dst, data, 0o600); err != nil { |
| 359 | + return fmt.Errorf("write pdf: %w", err) |
| 360 | + } |
| 361 | + fmt.Fprintf(os.Stderr, "saved: %s (%d bytes)\n", dst, len(data)) |
| 362 | + return nil |
| 363 | +} |
| 364 | + |
| 365 | +// resolveDownloadPath decides the on-disk path for a downloaded PDF. |
| 366 | +// |
| 367 | +// When out is empty, the server-provided filename is used in the current |
| 368 | +// directory (falling back to "<docid>.pdf"). When out ends with a path |
| 369 | +// separator or names an existing directory, the server-provided filename is |
| 370 | +// placed inside it. Otherwise out is used verbatim as the file path. The |
| 371 | +// server-provided name is base-name-cleaned to avoid path traversal. |
| 372 | +func resolveDownloadPath(out, serverName string, docID int) string { |
| 373 | + name := filepath.Base(filepath.Clean(serverName)) |
| 374 | + if name == "." || name == string(filepath.Separator) || name == "" { |
| 375 | + name = fmt.Sprintf("%d.pdf", docID) |
| 376 | + } |
| 377 | + if out == "" { |
| 378 | + return name |
| 379 | + } |
| 380 | + if strings.HasSuffix(out, string(os.PathSeparator)) || strings.HasSuffix(out, "/") { |
| 381 | + return filepath.Join(out, name) |
| 382 | + } |
| 383 | + if info, err := os.Stat(out); err == nil && info.IsDir() { |
| 384 | + return filepath.Join(out, name) |
| 385 | + } |
| 386 | + return out |
| 387 | +} |
| 388 | + |
315 | 389 | func parseDocID(s string) (int, error) { |
316 | 390 | n, err := strconv.Atoi(strings.TrimSpace(s)) |
317 | 391 | if err != nil || n <= 0 { |
|
0 commit comments