Skip to content

Commit 63158c6

Browse files
committed
Merge M22: files list/view/download + inline scrape + api binary-output fix
2 parents 81b1e69 + 30f2f8a commit 63158c6

25 files changed

Lines changed: 1410 additions & 31 deletions

AGENTS.md

Lines changed: 11 additions & 6 deletions
Large diffs are not rendered by default.

README.md

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ An unofficial command-line client for the [Planfix](https://planfix.com) REST AP
66

77
> **Unofficial.** pfix is an independent open-source project. It is **not** an official Planfix product and is not affiliated with, endorsed, sponsored, or funded by Planfix. The Planfix name is used only to describe the API this tool connects to.
88
9-
> **Status:** functional and actively developed. Typed commands cover tasks, projects, contacts, users, reports, data tags, templates, custom fields, and objects; anything not covered yet is reachable through the raw `api` passthrough (remaining work is on the [roadmap](#roadmap)). Command and flag conventions may still change before v1.0.
9+
> **Status:** functional and actively developed. Typed commands cover tasks, projects, contacts, users, reports, data tags, templates, custom fields, objects, and files; anything not covered yet is reachable through the raw `api` passthrough (remaining work is on the [roadmap](#roadmap)). Command and flag conventions may still change before v1.0.
1010
1111
## Install
1212

@@ -293,6 +293,28 @@ pfix datatag list # table of data tags
293293
pfix datatag view 4 # a tag's definition (--json for its field list)
294294
```
295295

296+
### Files
297+
298+
List and download the files on a task, contact, or project, and the editor
299+
images embedded in a description or comment, which the attachment API never
300+
returns:
301+
302+
```sh
303+
pfix task files 17 # attached files: table of ID / NAME / SIZE
304+
pfix task files 17 --source inline # editor-uploaded images, scraped from the HTML
305+
pfix task files 17 --description-only # only files attached via the description
306+
pfix contact files 42 --source inline
307+
pfix project files 12 --limit 20 --offset 20 # project pages instead of --description-only
308+
309+
pfix file view 6340746 # one file's metadata (ID / NAME / SIZE / LINK)
310+
pfix file download 6340746 # writes ./<file-name> (looks up the name first)
311+
pfix file download 6340746 -o report.pdf # writes to a chosen path
312+
pfix file download 6340746 -o - # streams the bytes to stdout
313+
```
314+
315+
`file download` refuses to overwrite an existing file unless `--force`, and
316+
rejects `--json`/`--jq` — it writes raw bytes, not JSON.
317+
296318
### Raw API passthrough
297319

298320
`pfix api <path>` makes an authenticated request to any Planfix REST endpoint and prints the raw JSON response — handy for endpoints without a dedicated command yet, and for scripting.
@@ -352,7 +374,7 @@ pfix config use staging # set the active profile (current_profile)
352374

353375
## Roadmap
354376

355-
- Typed `directory` and `file` commands.
377+
- A typed `directory` command.
356378
- Running saved reports (`report` currently covers definitions only).
357379

358380
## Development

internal/cmd/api/api.go

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@ func runAPI(ctx context.Context, o *apiOptions, path string) error {
125125
return err
126126
}
127127
if !o.silent {
128-
if err := output.EmitJSON(o.out, data, o.jq); err != nil {
128+
if err := emitBody(o.out, resp.Header.Get("Content-Type"), data, o.jq); err != nil {
129129
return err
130130
}
131131
}
@@ -218,3 +218,34 @@ func writeHeaders(w io.Writer, h http.Header) {
218218
fmt.Fprintf(w, "%s: %s\n", k, strings.Join(h[k], ", "))
219219
}
220220
}
221+
222+
// emitBody writes an API response body. JSON (incl. Planfix error envelopes)
223+
// goes through EmitJSON so --json/--jq and pretty-printing work; any other
224+
// content type — a file download, an HTML error page — is written verbatim so
225+
// binary stays byte-exact. --jq over a non-JSON body is an explicit error, not
226+
// a confusing "not valid JSON".
227+
func emitBody(w io.Writer, contentType string, data []byte, jq string) error {
228+
if shouldTreatAsJSON(contentType, data) {
229+
return output.EmitJSON(w, data, jq)
230+
}
231+
if jq != "" {
232+
return fmt.Errorf("--jq: response is not JSON (Content-Type %q)", contentType)
233+
}
234+
_, err := w.Write(data)
235+
return err
236+
}
237+
238+
// shouldTreatAsJSON reports whether a response body should be rendered as JSON.
239+
// application/json always is. A download carries a concrete binary Content-Type
240+
// and never qualifies. An empty or text/plain type (a test server, or a plain
241+
// endpoint) is treated as JSON only when the body actually parses, preserving
242+
// pretty-print and --jq for those.
243+
func shouldTreatAsJSON(contentType string, data []byte) bool {
244+
if strings.HasPrefix(contentType, "application/json") {
245+
return true
246+
}
247+
if contentType == "" || strings.HasPrefix(contentType, "text/plain") {
248+
return json.Valid(data)
249+
}
250+
return false
251+
}

internal/cmd/api/api_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,3 +170,53 @@ func TestSplitAndMagicValue(t *testing.T) {
170170
t.Errorf("magicValue bool = %v", got)
171171
}
172172
}
173+
174+
func TestRunAPIBinaryPassthroughByteExact(t *testing.T) {
175+
// A PNG-like body that does NOT end in a newline; must emerge unchanged.
176+
raw := []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x00, 0xff}
177+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
178+
w.Header().Set("Content-Type", "image/png")
179+
w.Write(raw)
180+
}))
181+
defer srv.Close()
182+
183+
out := &strings.Builder{}
184+
if err := runAPI(context.Background(), optsFor(srv.URL, nil, out), "file/1/download"); err != nil {
185+
t.Fatalf("runAPI: %v", err)
186+
}
187+
if out.String() != string(raw) {
188+
t.Fatalf("binary body altered: got %q want %q", out.String(), string(raw))
189+
}
190+
}
191+
192+
func TestRunAPIBinaryJSONLikeNotPrettyPrinted(t *testing.T) {
193+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
194+
w.Header().Set("Content-Type", "image/png")
195+
io.WriteString(w, "123")
196+
}))
197+
defer srv.Close()
198+
199+
out := &strings.Builder{}
200+
if err := runAPI(context.Background(), optsFor(srv.URL, nil, out), "file/1/download"); err != nil {
201+
t.Fatalf("runAPI: %v", err)
202+
}
203+
if out.String() != "123" {
204+
t.Fatalf("binary JSON-like body must be verbatim, got %q", out.String())
205+
}
206+
}
207+
208+
func TestRunAPIJQOnNonJSONErrors(t *testing.T) {
209+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
210+
w.Header().Set("Content-Type", "image/png")
211+
io.WriteString(w, "bytes")
212+
}))
213+
defer srv.Close()
214+
215+
out := &strings.Builder{}
216+
o := optsFor(srv.URL, nil, out)
217+
o.jq = ".x"
218+
err := runAPI(context.Background(), o, "file/1/download")
219+
if err == nil || !strings.Contains(err.Error(), "not JSON") {
220+
t.Fatalf("want a non-JSON --jq error, got %v", err)
221+
}
222+
}

internal/cmd/contact/contact.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package contact
33
import (
44
"github.com/spf13/cobra"
55

6+
"github.com/a68366/pfix-cli/internal/cmd/files"
67
"github.com/a68366/pfix-cli/internal/cmd/groups"
78
"github.com/a68366/pfix-cli/internal/cmd/processes"
89
"github.com/a68366/pfix-cli/internal/cmdutil"
@@ -17,6 +18,6 @@ func NewCmd(g *cmdutil.GlobalOpts) *cobra.Command {
1718
cg := groups.NewCmd(g, "contact")
1819
cg.Short = "List contact groups (categories)"
1920
cg.Long = "List contact groups — the contact categories such as Клиент, Партнёр, Поставщик."
20-
cmd.AddCommand(newListCmd(g), newViewCmd(g), newCreateCmd(g), newUpdateCmd(g), processes.NewCmd(g, "contact"), cg)
21+
cmd.AddCommand(newListCmd(g), newViewCmd(g), newCreateCmd(g), newUpdateCmd(g), processes.NewCmd(g, "contact"), cg, files.NewCmd(g, files.Options{Type: "contact", DescriptionOnly: true}))
2122
return cmd
2223
}

internal/cmd/file/download.go

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
package file
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"io"
7+
"os"
8+
"path/filepath"
9+
"strconv"
10+
"strings"
11+
12+
"github.com/spf13/cobra"
13+
14+
"github.com/a68366/pfix-cli/internal/cmdutil"
15+
"github.com/a68366/pfix-cli/internal/planfix"
16+
)
17+
18+
type downloadOptions struct {
19+
id int
20+
output string // -o: "" auto-name, "-" stdout, else path/dir
21+
force bool
22+
quiet bool
23+
json bool
24+
client func() (*planfix.Client, error)
25+
out io.Writer
26+
errOut io.Writer
27+
}
28+
29+
func newDownloadCmd(g *cmdutil.GlobalOpts) *cobra.Command {
30+
o := &downloadOptions{}
31+
cmd := &cobra.Command{
32+
Use: "download <id>",
33+
Short: "Download a file's bytes",
34+
Long: "Download a file's bytes.\n\n" +
35+
"By default writes ./<file-name> (a metadata lookup resolves the name). -o <path>\n" +
36+
"writes to that path; -o <dir>/ writes <dir>/<file-name>; -o - streams to stdout.\n" +
37+
"Refuses to overwrite an existing file unless --force.",
38+
Args: cobra.ExactArgs(1),
39+
RunE: func(cmd *cobra.Command, args []string) error {
40+
id, err := cmdutil.ValidateID(args[0])
41+
if err != nil {
42+
return err
43+
}
44+
o.id = id
45+
o.quiet, o.json = g.Quiet, g.JSON
46+
o.client = g.ClientFunc()
47+
o.out = cmd.OutOrStdout()
48+
o.errOut = cmd.ErrOrStderr()
49+
return runDownload(cmd.Context(), o)
50+
},
51+
}
52+
cmd.Flags().StringVarP(&o.output, "output", "o", "", "Output path, a directory, or - for stdout")
53+
cmd.Flags().BoolVar(&o.force, "force", false, "Overwrite an existing file")
54+
return cmd
55+
}
56+
57+
func runDownload(ctx context.Context, o *downloadOptions) error {
58+
// --jq implies --json (GlobalOpts.PreRun), so o.json covers both.
59+
if o.json {
60+
return fmt.Errorf("file download writes raw bytes; --json/--jq are not supported (use -o -)")
61+
}
62+
client, err := o.client()
63+
if err != nil {
64+
return err
65+
}
66+
idPath := "file/" + strconv.Itoa(o.id)
67+
68+
dest, toStdout, err := resolveDest(ctx, o, client, idPath)
69+
if err != nil {
70+
return err
71+
}
72+
73+
var w io.Writer
74+
var f *os.File
75+
if toStdout {
76+
w = o.out
77+
} else {
78+
flag := os.O_WRONLY | os.O_CREATE | os.O_EXCL
79+
if o.force {
80+
flag = os.O_WRONLY | os.O_CREATE | os.O_TRUNC
81+
}
82+
f, err = os.OpenFile(dest, flag, 0o644)
83+
if err != nil {
84+
if os.IsExist(err) {
85+
return fmt.Errorf("%s exists (use --force to overwrite)", dest)
86+
}
87+
return err
88+
}
89+
w = f
90+
}
91+
92+
resp, err := client.Stream(ctx, idPath+"/download")
93+
if err != nil {
94+
cleanupPartial(f)
95+
return err
96+
}
97+
defer resp.Body.Close()
98+
if resp.StatusCode >= 300 {
99+
data, _ := io.ReadAll(resp.Body)
100+
cleanupPartial(f)
101+
return cmdutil.DescribeAPIError(planfix.ParseError(resp.StatusCode, data))
102+
}
103+
104+
n, err := io.Copy(w, resp.Body)
105+
if err != nil {
106+
cleanupPartial(f)
107+
return err
108+
}
109+
if resp.ContentLength >= 0 && n != resp.ContentLength {
110+
cleanupPartial(f)
111+
return fmt.Errorf("download truncated: got %d bytes, expected %d", n, resp.ContentLength)
112+
}
113+
if f != nil {
114+
if err := f.Close(); err != nil {
115+
return err
116+
}
117+
}
118+
if !o.quiet && !toStdout {
119+
fmt.Fprintf(o.errOut, "Saved %s (%d bytes)\n", dest, n)
120+
}
121+
return nil
122+
}
123+
124+
// resolveDest decides where bytes go. "-" → stdout. "" → ./<api-name>. A value
125+
// that names an existing directory, or ends in a path separator, → <dir>/<api-name>.
126+
// Any other value is a literal path. The API name is validated with SafeFileName
127+
// only when pfix (not the user) supplies the final segment.
128+
func resolveDest(ctx context.Context, o *downloadOptions, client *planfix.Client, idPath string) (string, bool, error) {
129+
if o.output == "-" {
130+
return "", true, nil
131+
}
132+
fetchName := func() (string, error) {
133+
raw, err := client.JSON(ctx, "GET", idPath, nil)
134+
if err != nil {
135+
return "", cmdutil.DescribeAPIError(err)
136+
}
137+
var env struct {
138+
File struct {
139+
Name string `json:"name"`
140+
} `json:"file"`
141+
}
142+
if err := cmdutil.DecodeJSON(raw, &env); err != nil {
143+
return "", err
144+
}
145+
return cmdutil.SafeFileName(env.File.Name)
146+
}
147+
if o.output == "" {
148+
name, err := fetchName()
149+
if err != nil {
150+
return "", false, err
151+
}
152+
return name, false, nil
153+
}
154+
isDir := strings.HasSuffix(o.output, string(os.PathSeparator))
155+
if !isDir {
156+
if fi, err := os.Stat(o.output); err == nil && fi.IsDir() {
157+
isDir = true
158+
}
159+
}
160+
if isDir {
161+
name, err := fetchName()
162+
if err != nil {
163+
return "", false, err
164+
}
165+
return filepath.Join(o.output, name), false, nil
166+
}
167+
return o.output, false, nil
168+
}
169+
170+
func cleanupPartial(f *os.File) {
171+
if f != nil {
172+
f.Close()
173+
os.Remove(f.Name())
174+
}
175+
}

0 commit comments

Comments
 (0)