Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 76 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ A non-interactive command-line interface for the [Uploadcare](https://uploadcare

## Features

- **File management** — upload, list, copy, store, and delete files
- **File management** — upload, list, search, copy, store, and delete files
- **File tags** — attach tags while uploading; list, replace, update, or clear them later
- **Project management** — create, update, delete projects; manage API secrets and usage metrics
- **JSON & NDJSON output** — structured output with field filtering and `jq` support
- **Stdin piping** — compose commands for batch operations
Expand Down Expand Up @@ -52,7 +53,13 @@ export UPLOADCARE_SECRET_KEY="your-secret-key"
uploadcare file list

# Upload a file
uploadcare file upload photo.jpg
uploadcare file upload photo.jpg --tag vacation --tag featured

# Search by text and tags
uploadcare file search invoice --tag-all approved --tag-none archived

# Add and remove multiple tags atomically
uploadcare tag update <uuid> --delete draft --add approved --add featured

# Get file info as JSON
uploadcare file info <uuid> --json all
Expand Down Expand Up @@ -164,6 +171,7 @@ Commands validate that the required credentials are present before executing. Mi
uploadcare
├── file
│ ├── list List files in project
│ ├── search Search files by text, fields, ranges, and tags
│ ├── info Get file details
│ ├── upload Upload local file(s)
│ ├── upload-from-url Upload file from URL
Expand All @@ -172,6 +180,11 @@ uploadcare
│ ├── local-copy Copy file within Uploadcare storage
│ ├── remote-copy Copy file to remote storage
│ └── download Download file(s) from the CDN to local disk
├── tag
│ ├── list List a file's tags
│ ├── replace Replace a file's complete tag set
│ ├── update Atomically add and delete tags
│ └── clear Remove all tags from a file
├── metadata
│ ├── list List all metadata keys for a file
│ ├── get Get a metadata value by key
Expand Down Expand Up @@ -225,6 +238,67 @@ uploadcare
| `-v, --verbose` | Log HTTP requests/responses to stderr |
| `--no-color` | Disable colored output |

### File search

Search accepts an optional full-text query plus exact, phrase, range, image,
and tag filters. At least one query or filter is required. Full-text and phrase
values must contain at least four characters.

```bash
# Full-text search with exact MIME type and tag filters
uploadcare file search invoice \
--exact detected_mime_type=application/pdf \
--tag-all approved \
--tag-none archived \
--sort score \
--sort=-datetime_uploaded

# Exact metadata match
uploadcare file search --exact 'metadata[camera]=Canon' --json uuid,filename,tags,highlight

# Stream every reachable page as NDJSON
uploadcare file search --tag-any featured --page-all --json uuid,tags
```

Range filters are `--uploaded-gt`, `--uploaded-gte`, `--uploaded-lt`,
`--uploaded-lte`, and the corresponding `--size-*` flags. `--limit` accepts
1–100 and `--offset + --limit` cannot exceed 1000. The API serves at most the
first 1000 matches of a search, so `--page-all` streams up to 1000 results.
Pages are filled by following the API's next cursor, so offset-stepped pages
may occasionally overlap; prefer `--page-all` when completeness matters.
Search uses an asynchronous index, so recent uploads, metadata changes, and
tag changes may take time to appear.

### File tags

Tags are normalized to lowercase, de-duplicated in first-seen order, and may
contain `a-z`, `0-9`, `.`, `_`, and `-`. Each tag can contain up to 100
characters, and a file can have up to 50 tags.

```bash
# Add tags during direct or URL upload
uploadcare file upload photo.jpg --tag vacation --tag featured
uploadcare file upload-from-url https://example.com/photo.jpg --tag remote

# Inspect and replace the complete tag set
uploadcare tag list <uuid>
uploadcare tag replace <uuid> approved featured

# Repeat --add and --delete as many times as needed
uploadcare tag update <uuid> \
--delete draft \
--delete needs-review \
--add approved \
--add featured

# Preview a mutation or remove every tag
uploadcare tag update <uuid> --delete draft --add approved --dry-run
uploadcare tag clear <uuid>
```

Updates apply every deletion before every addition. If the same tag is supplied
to both operations, it is present afterward.

## Output modes

**Human-readable** (default) — tabular output to stdout:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ require (
github.com/spf13/cobra v1.10.2
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/uploadcare/uploadcare-go/v2 v2.0.0
github.com/uploadcare/uploadcare-go/v2 v2.1.0
go.yaml.in/yaml/v3 v3.0.4
)

Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/uploadcare/uploadcare-go/v2 v2.0.0 h1:tZc3OjMcZyhuKEWnb2OV4IaZaWTnu96JJqo32n9on88=
github.com/uploadcare/uploadcare-go/v2 v2.0.0/go.mod h1:nVtcYFEeUnxMjXbEsXzDefko4MdJpXjzBGRJtxwoCjU=
github.com/uploadcare/uploadcare-go/v2 v2.1.0 h1:9Rwj2+axr7g+/4mh/kLaYs/F2Hz9pvB9WzwUUKnZ4Ko=
github.com/uploadcare/uploadcare-go/v2 v2.1.0/go.mod h1:nVtcYFEeUnxMjXbEsXzDefko4MdJpXjzBGRJtxwoCjU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
Expand Down
125 changes: 125 additions & 0 deletions internal/client/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package client
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -75,6 +76,10 @@ func mapFileInfo(info file.Info) *service.File {
IsStored: info.StoredAt != nil,
URL: info.URL,
Metadata: info.Metadata,
Tags: info.Tags,
}
if f.Tags == nil {
f.Tags = []string{}
}

if info.OriginalFileURL != nil {
Expand Down Expand Up @@ -112,6 +117,7 @@ func mapUploadFileInfo(info upload.FileInfo) *service.File {
IsImage: info.IsImage,
IsReady: info.IsReady,
IsStored: info.IsStored,
Tags: []string{},
}
}

Expand Down Expand Up @@ -166,6 +172,123 @@ func (s *fileService) Iterate(ctx context.Context, opts service.FileListOptions,
return nil
}

func (s *fileService) Search(ctx context.Context, opts service.FileSearchOptions) (*service.FileSearchResult, error) {
search, err := s.sdkFileSvc.Search(ctx, buildSearchParams(opts))
if err != nil {
return nil, err
}

files := make([]service.File, 0, opts.Limit)
for len(files) < opts.Limit && search.Next() {
match, err := search.ReadResult()
// Next() can report a pending next pointer for a page that turns
// out empty (stale matches filtered server-side); that is clean
// exhaustion, not an error.
if errors.Is(err, ucare.ErrEndOfResults) {
break
}
if err != nil {
return nil, err
}
files = append(files, *mapSearchMatch(*match))
}
return &service.FileSearchResult{Files: files, Total: search.Total()}, nil
}

func (s *fileService) IterateSearch(ctx context.Context, opts service.FileSearchOptions, fn func(service.File) error) (uint64, error) {
search, err := s.sdkFileSvc.Search(ctx, buildSearchParams(opts))
if err != nil {
return 0, err
}
total := search.Total()
// The API serves at most the first MaxSearchOffsetLimit matches of a
// search; stop cleanly at the window instead of following a next
// cursor the server cannot satisfy.
remaining := file.MaxSearchOffsetLimit - opts.Offset
for read := 0; read < remaining && search.Next(); read++ {
match, err := search.ReadResult()
if errors.Is(err, ucare.ErrEndOfResults) {
break
}
if err != nil {
return total, err
}
if err := fn(*mapSearchMatch(*match)); err != nil {
return total, err
}
}
return total, nil
}

func buildSearchParams(opts service.FileSearchOptions) file.SearchParams {
params := file.SearchParams{
Limit: ucare.Uint64(uint64(opts.Limit)),
Offset: ucare.Uint64(uint64(opts.Offset)),
Query: opts.Query,
Exact: opts.Exact,
IsImage: opts.IsImage,
Fuzziness: opts.Fuzziness,
}
if opts.IncludeAppData {
params.Include = ucare.String(file.SearchIncludeAppData)
}
if opts.Phrase != nil {
params.Phrase = &file.SearchPhrase{
OriginalFilename: opts.Phrase.OriginalFilename,
Metadata: opts.Phrase.Metadata,
DetectedMimeType: opts.Phrase.DetectedMimeType,
}
}
if opts.DatetimeUploaded != nil {
params.DatetimeUploaded = &file.SearchDatetime{
Gt: opts.DatetimeUploaded.Gt, Gte: opts.DatetimeUploaded.Gte,
Lt: opts.DatetimeUploaded.Lt, Lte: opts.DatetimeUploaded.Lte,
}
}
if opts.Size != nil {
params.Size = &file.SearchSize{
Gt: opts.Size.Gt, Gte: opts.Size.Gte,
Lt: opts.Size.Lt, Lte: opts.Size.Lte,
}
}
if opts.Tags != nil {
params.Tags = &file.SearchTags{Any: opts.Tags.Any, All: opts.Tags.All, None: opts.Tags.None}
}
for _, sort := range opts.Sort {
params.Sort = append(params.Sort, file.SearchSort(sort))
}
return params
}

func mapSearchMatch(match file.SearchMatch) *service.File {
f := mapFileInfo(file.Info{
BasicFileInfo: file.BasicFileInfo{
ID: match.ID,
MimeType: match.MimeType,
OriginalFileName: match.OriginalFileName,
Size: match.Size,
IsImage: match.IsImage,
IsReady: match.IsReady,
},
RemovedAt: match.RemovedAt,
StoredAt: match.StoredAt,
UploadedAt: match.UploadedAt,
OriginalFileURL: match.OriginalFileURL,
URL: match.URL,
Metadata: match.Metadata,
Tags: match.Tags,
AppData: match.AppData,
})
if match.Highlight != nil {
f.Highlight = &service.FileSearchHighlight{
OriginalFilename: match.Highlight.OriginalFileName,
DetectedMimeType: match.Highlight.DetectedMimeType,
Metadata: match.Highlight.Metadata,
}
}
return f
}

func buildListParams(opts service.FileListOptions) (file.ListParams, error) {
params := file.ListParams{}
if opts.Ordering != "" {
Expand Down Expand Up @@ -213,6 +336,7 @@ func (s *fileService) Upload(ctx context.Context, params service.UploadParams) (
ContentType: params.ContentType,
ToStore: toStore,
Metadata: params.Metadata,
Tags: params.Tags,
MultipartThreshold: params.MultipartThreshold,
}

Expand Down Expand Up @@ -250,6 +374,7 @@ func (s *fileService) UploadFromURL(ctx context.Context, params service.URLUploa
URL: params.URL,
ToStore: toStore,
Metadata: params.Metadata,
Tags: params.Tags,
}
if params.CheckDuplicates {
sdkParams.CheckURLDuplicates = ucare.String(upload.URLDuplicatesTrue)
Expand Down
82 changes: 82 additions & 0 deletions internal/client/file_search_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
package client

import (
"encoding/json"
"reflect"
"testing"

"github.com/uploadcare/uploadcare-cli/internal/service"
"github.com/uploadcare/uploadcare-go/v2/file"
"github.com/uploadcare/uploadcare-go/v2/tag"
)

func TestBuildSearchParams(t *testing.T) {
isImage := false
size := uint64(1024)
opts := service.FileSearchOptions{
Limit: 25, Offset: 10, IncludeAppData: true, Query: "invoice",
Phrase: &service.FileSearchPhrase{Metadata: "project alpha"},
Exact: map[string][]string{"detected_mime_type": {"application/pdf"}},
Size: &service.FileSearchSize{Gte: &size},
IsImage: &isImage,
Tags: &service.FileSearchTags{All: []string{"approved"}, None: []string{"archived"}},
Sort: []string{"score", "-datetime_uploaded"},
}

params := buildSearchParams(opts)
if params.Limit == nil || *params.Limit != 25 || params.Offset == nil || *params.Offset != 10 {
t.Fatalf("pagination params = %+v", params)
}
if params.Include == nil || *params.Include != file.SearchIncludeAppData {
t.Errorf("include = %v", params.Include)
}
if params.Phrase == nil || params.Phrase.Metadata != "project alpha" {
t.Errorf("phrase = %+v", params.Phrase)
}
if params.Size == nil || params.Size.Gte == nil || *params.Size.Gte != 1024 {
t.Errorf("size = %+v", params.Size)
}
if params.Tags == nil || !reflect.DeepEqual(params.Tags.All, []string{"approved"}) {
t.Errorf("tags = %+v", params.Tags)
}
wantSort := []file.SearchSort{file.SortByScore, file.SortByUploadedAtDesc}
if !reflect.DeepEqual(params.Sort, wantSort) {
t.Errorf("sort = %v, want %v", params.Sort, wantSort)
}
}

func TestMapSearchMatch(t *testing.T) {
match := file.SearchMatch{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", OriginalFileName: "invoice.pdf",
Size: 123, MimeType: "application/pdf", IsReady: true,
Metadata: map[string]string{"customer": "Acme"}, Tags: []string{"approved"},
AppData: map[string]json.RawMessage{"scan": json.RawMessage(`{"safe":true}`)},
Highlight: &file.SearchHighlight{
OriginalFileName: []string{"<em>invoice</em>.pdf"},
Metadata: map[string]string{"customer": "<em>Acme</em>"},
},
}

got := mapSearchMatch(match)
if got.UUID != match.ID || got.Filename != "invoice.pdf" || !reflect.DeepEqual(got.Tags, []string{"approved"}) {
t.Fatalf("mapped file = %+v", got)
}
if got.Highlight == nil || !reflect.DeepEqual(got.Highlight.OriginalFilename, []string{"<em>invoice</em>.pdf"}) {
t.Errorf("highlight = %+v", got.Highlight)
}
if len(got.AppData) == 0 {
t.Error("appdata was not mapped")
}

untagged := mapSearchMatch(file.SearchMatch{ID: match.ID})
if untagged.Tags == nil {
t.Error("nil tags were not normalized to an empty slice")
}
}

func TestMapTagResult(t *testing.T) {
got := mapTagResult(tag.Result{Tags: []string{"approved"}, Added: []string{"approved"}, Deleted: []string{"draft"}})
if !reflect.DeepEqual(got.Tags, []string{"approved"}) || !reflect.DeepEqual(got.Deleted, []string{"draft"}) {
t.Fatalf("mapped result = %+v", got)
}
}
Loading
Loading