From 41fd44c17c63d84142d0a46136d0462cd1c57b2c Mon Sep 17 00:00:00 2001 From: Alex Gusev Date: Wed, 26 Aug 2026 12:42:44 +0600 Subject: [PATCH] Search and tags --- README.md | 78 +++++- go.mod | 2 +- go.sum | 4 +- internal/client/file.go | 125 +++++++++ internal/client/file_search_test.go | 82 ++++++ internal/client/tag.go | 63 +++++ internal/cmd/file.go | 9 +- internal/cmd/file_search.go | 319 ++++++++++++++++++++++ internal/cmd/file_search_test.go | 128 +++++++++ internal/cmd/file_test.go | 16 ++ internal/cmd/file_upload.go | 39 ++- internal/cmd/file_upload_from_url.go | 38 ++- internal/cmd/file_upload_from_url_test.go | 20 ++ internal/cmd/file_upload_test.go | 20 ++ internal/cmd/helpers.go | 25 ++ internal/cmd/root.go | 1 + internal/cmd/schema.go | 56 ++-- internal/cmd/tag.go | 268 ++++++++++++++++++ internal/cmd/tag_test.go | 129 +++++++++ internal/service/interfaces.go | 113 +++++++- internal/validate/search.go | 116 ++++++++ internal/validate/tag.go | 47 ++++ internal/validate/tag_search_test.go | 48 ++++ 23 files changed, 1681 insertions(+), 65 deletions(-) create mode 100644 internal/client/file_search_test.go create mode 100644 internal/client/tag.go create mode 100644 internal/cmd/file_search.go create mode 100644 internal/cmd/file_search_test.go create mode 100644 internal/cmd/tag.go create mode 100644 internal/cmd/tag_test.go create mode 100644 internal/validate/search.go create mode 100644 internal/validate/tag.go create mode 100644 internal/validate/tag_search_test.go diff --git a/README.md b/README.md index 65d4042..7097a57 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 --delete draft --add approved --add featured # Get file info as JSON uploadcare file info --json all @@ -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 @@ -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 @@ -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 +uploadcare tag replace approved featured + +# Repeat --add and --delete as many times as needed +uploadcare tag update \ + --delete draft \ + --delete needs-review \ + --add approved \ + --add featured + +# Preview a mutation or remove every tag +uploadcare tag update --delete draft --add approved --dry-run +uploadcare tag clear +``` + +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: diff --git a/go.mod b/go.mod index 7786e83..470ba49 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index f6d0073..04f3ad6 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/client/file.go b/internal/client/file.go index bf7de0f..791fb5c 100644 --- a/internal/client/file.go +++ b/internal/client/file.go @@ -3,6 +3,7 @@ package client import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -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 { @@ -112,6 +117,7 @@ func mapUploadFileInfo(info upload.FileInfo) *service.File { IsImage: info.IsImage, IsReady: info.IsReady, IsStored: info.IsStored, + Tags: []string{}, } } @@ -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 != "" { @@ -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, } @@ -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) diff --git a/internal/client/file_search_test.go b/internal/client/file_search_test.go new file mode 100644 index 0000000..00b7640 --- /dev/null +++ b/internal/client/file_search_test.go @@ -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{"invoice.pdf"}, + Metadata: map[string]string{"customer": "Acme"}, + }, + } + + 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{"invoice.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) + } +} diff --git a/internal/client/tag.go b/internal/client/tag.go new file mode 100644 index 0000000..280fc7b --- /dev/null +++ b/internal/client/tag.go @@ -0,0 +1,63 @@ +package client + +import ( + "context" + "net/http" + + "github.com/uploadcare/uploadcare-cli/internal/output" + "github.com/uploadcare/uploadcare-cli/internal/service" + "github.com/uploadcare/uploadcare-go/v2/tag" + "github.com/uploadcare/uploadcare-go/v2/ucare" +) + +type tagService struct { + sdk tag.Service +} + +// NewTagService creates a service.TagService backed by the Uploadcare SDK. +func NewTagService(publicKey, secretKey string, httpClient *http.Client, _ *output.VerboseLogger) (service.TagService, error) { + creds := ucare.APICreds{PublicKey: publicKey, SecretKey: secretKey} + conf, err := ucare.NewConfig(creds, + ucare.WithSignBasedAuthentication(), + ucare.WithHTTPClient(httpClient), + ucare.WithUserAgent(UserAgent), + ) + if err != nil { + return nil, err + } + sdkClient, err := ucare.NewClient(creds, conf) + if err != nil { + return nil, err + } + return &tagService{sdk: tag.NewService(sdkClient)}, nil +} + +func (s *tagService) List(ctx context.Context, fileUUID string) ([]string, error) { + return s.sdk.List(ctx, fileUUID) +} + +func (s *tagService) Replace(ctx context.Context, fileUUID string, tags []string) (*service.TagChangeResult, error) { + result, err := s.sdk.Replace(ctx, fileUUID, tags) + if err != nil { + return nil, err + } + return mapTagResult(result), nil +} + +func (s *tagService) Update(ctx context.Context, fileUUID string, opts service.TagUpdateOptions) (*service.TagChangeResult, error) { + result, err := s.sdk.Update(ctx, fileUUID, tag.UpdateParams{Add: opts.Add, Delete: opts.Delete}) + if err != nil { + return nil, err + } + return mapTagResult(result), nil +} + +func mapTagResult(result tag.Result) *service.TagChangeResult { + return &service.TagChangeResult{ + Tags: append([]string{}, result.Tags...), + Added: append([]string{}, result.Added...), + Deleted: append([]string{}, result.Deleted...), + } +} + +var _ service.TagService = (*tagService)(nil) diff --git a/internal/cmd/file.go b/internal/cmd/file.go index 0263619..1b200f1 100644 --- a/internal/cmd/file.go +++ b/internal/cmd/file.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -19,7 +20,7 @@ func newFileCmd(fileSvc service.FileService) *cobra.Command { Short: "Manage files", Long: `Manage files in the current Uploadcare project. -Subcommands cover the full file lifecycle: upload, list, inspect, +Subcommands cover the full file lifecycle: upload, list, search, inspect, store, delete, and copy. Most subcommands support --json for structured output and --dry-run for safe previews. @@ -29,6 +30,7 @@ stdin (--from-stdin), and can be piped from "file list --page-all".`, cmd.AddCommand(newFileInfoCmd(fileSvc)) cmd.AddCommand(newFileListCmd(fileSvc)) + cmd.AddCommand(newFileSearchCmd(fileSvc)) cmd.AddCommand(newFileUploadCmd(fileSvc)) cmd.AddCommand(newFileUploadFromURLCmd(fileSvc)) cmd.AddCommand(newFileStoreCmd(fileSvc)) @@ -55,7 +57,7 @@ Use --include-appdata to also return application-specific data JSON fields: uuid, size, filename, mime_type, is_image, is_stored, is_ready, datetime_uploaded, datetime_stored, datetime_removed, -original_file_url, metadata, appdata (with --include-appdata).`, +original_file_url, metadata, tags, appdata (with --include-appdata).`, Example: ` # Get file info as a table uploadcare file info 740e1b8c-1ad8-4324-b7ec-112345678900 @@ -286,6 +288,9 @@ func fileInfoTable(file *service.File) *output.TableData { if file.DatetimeRemoved != nil { table.AddRow("Removed At:", formatTime(*file.DatetimeRemoved)) } + if len(file.Tags) > 0 { + table.AddRow("Tags:", strings.Join(file.Tags, ", ")) + } table.AddRow("URL:", file.OriginalFileURL) return table } diff --git a/internal/cmd/file_search.go b/internal/cmd/file_search.go new file mode 100644 index 0000000..0b19000 --- /dev/null +++ b/internal/cmd/file_search.go @@ -0,0 +1,319 @@ +package cmd + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/uploadcare/uploadcare-cli/internal/output" + "github.com/uploadcare/uploadcare-cli/internal/service" + "github.com/uploadcare/uploadcare-cli/internal/validate" +) + +func newFileSearchCmd(fileSvc service.FileService) *cobra.Command { + var ( + phraseValues, exactValues []string + uploadedGt, uploadedGte string + uploadedLt, uploadedLte string + sizeGt, sizeGte, sizeLt, sizeLte uint64 + isImage string + fuzziness bool + tagAny, tagAll, tagNone []string + sortValues []string + limit, offset int + pageAll, includeAppData bool + ) + + buildOptions := func(cmd *cobra.Command, args []string) (service.FileSearchOptions, error) { + opts := service.FileSearchOptions{ + Limit: limit, Offset: offset, IncludeAppData: includeAppData, + Fuzziness: fuzziness, Sort: sortValues, + } + if len(args) == 1 { + opts.Query = args[0] + } + phrase, err := parseSearchPhrases(phraseValues) + if err != nil { + return opts, err + } + opts.Phrase = phrase + opts.Exact, err = parseSearchExact(exactValues) + if err != nil { + return opts, err + } + + dateRange := &service.FileSearchDatetime{} + dateFlags := []struct { + name, raw string + dst **time.Time + }{ + {"uploaded-gt", uploadedGt, &dateRange.Gt}, + {"uploaded-gte", uploadedGte, &dateRange.Gte}, + {"uploaded-lt", uploadedLt, &dateRange.Lt}, + {"uploaded-lte", uploadedLte, &dateRange.Lte}, + } + for _, value := range dateFlags { + if !cmd.Flags().Changed(value.name) { + continue + } + parsed, err := time.Parse(time.RFC3339, value.raw) + if err != nil { + return opts, fmt.Errorf("invalid --%s value %q: expected RFC3339 timestamp", value.name, value.raw) + } + *value.dst = &parsed + } + if dateRange.Gt != nil || dateRange.Gte != nil || dateRange.Lt != nil || dateRange.Lte != nil { + opts.DatetimeUploaded = dateRange + } + + sizeRange := &service.FileSearchSize{} + if cmd.Flags().Changed("size-gt") { + sizeRange.Gt = &sizeGt + } + if cmd.Flags().Changed("size-gte") { + sizeRange.Gte = &sizeGte + } + if cmd.Flags().Changed("size-lt") { + sizeRange.Lt = &sizeLt + } + if cmd.Flags().Changed("size-lte") { + sizeRange.Lte = &sizeLte + } + if sizeRange.Gt != nil || sizeRange.Gte != nil || sizeRange.Lt != nil || sizeRange.Lte != nil { + opts.Size = sizeRange + } + + if cmd.Flags().Changed("is-image") { + switch isImage { + case "true": + b := true + opts.IsImage = &b + case "false": + b := false + opts.IsImage = &b + default: + return opts, fmt.Errorf("invalid --is-image value: %q (must be \"true\" or \"false\")", isImage) + } + } + + if len(tagAny) > 0 || len(tagAll) > 0 || len(tagNone) > 0 { + tags := &service.FileSearchTags{} + if tags.Any, err = normalizeTagFilter("--tag-any", tagAny); err != nil { + return opts, err + } + if tags.All, err = normalizeTagFilter("--tag-all", tagAll); err != nil { + return opts, err + } + if tags.None, err = normalizeTagFilter("--tag-none", tagNone); err != nil { + return opts, err + } + opts.Tags = tags + } + return opts, nil + } + + cmd := &cobra.Command{ + Use: "search [query]", + Short: "Search files by text, fields, ranges, and tags", + Long: `Search the project's file index. + +At least one query or filter is required. The optional query and each phrase +value must contain at least four characters. Repeat --exact to match any of +several exact values, and repeat --sort to define an ordered sort list. + +The API serves at most the first 1000 matches of a search: --offset plus +--limit cannot exceed 1000, and --page-all stops after streaming the first +1000 matches. + +The search index updates asynchronously, so recent file, metadata, and tag +changes may not be immediately visible. Removed files are excluded. Pages +are filled by following the API's next cursor, so offset-stepped pages may +occasionally overlap; prefer --page-all when completeness matters.`, + Example: ` # Search PDFs that have approved but not archived tags + uploadcare file search invoice \ + --exact detected_mime_type=application/pdf \ + --tag-all approved --tag-none archived \ + --sort score --sort=-datetime_uploaded + + # Search an exact metadata value + uploadcare file search --exact 'metadata[camera]=Canon' --json uuid,filename,tags,highlight`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + searchOpts, err := buildOptions(cmd, args) + if err != nil { + return usageError(err) + } + if err := validate.FileSearch(searchOpts); err != nil { + return usageError(err) + } + + svc := fileSvc + if svc == nil { + svc, err = fileServiceFromCmd(cmd) + if err != nil { + return err + } + } + formatOpts := formatOptionsFromCmd(cmd) + if pageAll { + // Page through the window with the largest page the API + // allows unless the user picked an explicit page size. + if !cmd.Flags().Changed("limit") { + searchOpts.Limit = min(validate.MaxSearchLimit, validate.MaxSearchWindow-searchOpts.Offset) + } + return runFileSearchAll(cmd, svc, searchOpts, formatOpts, includeAppData) + } + + result, err := svc.Search(cmd.Context(), searchOpts) + if err != nil { + return err + } + formatter := output.New(formatOpts) + if formatOpts.JSON { + err = formatter.Format(cmd.OutOrStdout(), result.Files) + } else { + err = formatter.Format(cmd.OutOrStdout(), fileSearchTable(result.Files, includeAppData)) + } + if err != nil { + return err + } + if !formatOpts.Quiet { + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "Found %d matches; showing %d.\n", result.Total, len(result.Files)) + } + return err + }, + } + + f := cmd.Flags() + f.StringArrayVar(&phraseValues, "phrase", nil, "Phrase match as field=value (repeatable by field)") + f.StringArrayVar(&exactValues, "exact", nil, "Exact match as field=value (repeatable)") + f.StringVar(&uploadedGt, "uploaded-gt", "", "Uploaded after RFC3339 timestamp") + f.StringVar(&uploadedGte, "uploaded-gte", "", "Uploaded at or after RFC3339 timestamp") + f.StringVar(&uploadedLt, "uploaded-lt", "", "Uploaded before RFC3339 timestamp") + f.StringVar(&uploadedLte, "uploaded-lte", "", "Uploaded at or before RFC3339 timestamp") + f.Uint64Var(&sizeGt, "size-gt", 0, "Size greater than bytes") + f.Uint64Var(&sizeGte, "size-gte", 0, "Size greater than or equal to bytes") + f.Uint64Var(&sizeLt, "size-lt", 0, "Size less than bytes") + f.Uint64Var(&sizeLte, "size-lte", 0, "Size less than or equal to bytes") + f.StringVar(&isImage, "is-image", "", "Filter by image status (true/false)") + f.BoolVar(&fuzziness, "fuzziness", false, "Enable fuzzy text and phrase matching") + f.StringArrayVar(&tagAny, "tag-any", nil, "Match at least one tag (repeatable)") + f.StringArrayVar(&tagAll, "tag-all", nil, "Match every tag (repeatable)") + f.StringArrayVar(&tagNone, "tag-none", nil, "Exclude any matching tag (repeatable)") + f.StringArrayVar(&sortValues, "sort", nil, "Sort field, optionally prefixed with - (repeatable, max 4)") + f.IntVar(&limit, "limit", 20, "Number of matches per page (1-100)") + f.IntVar(&offset, "offset", 0, "Result offset (offset + limit must not exceed 1000)") + f.BoolVar(&pageAll, "page-all", false, "Stream all search result pages (at most the first 1000 matches)") + f.BoolVar(&includeAppData, "include-appdata", false, "Include application data") + return cmd +} + +func normalizeTagFilter(flag string, values []string) ([]string, error) { + if len(values) == 0 { + return nil, nil + } + normalized, err := validate.NormalizeTags(values, validate.MaxTagCount) + if err != nil { + return nil, fmt.Errorf("%s: %w", flag, err) + } + return normalized, nil +} + +func parseSearchPhrases(values []string) (*service.FileSearchPhrase, error) { + if len(values) == 0 { + return nil, nil + } + phrase := &service.FileSearchPhrase{} + seen := make(map[string]struct{}, len(values)) + for _, entry := range values { + field, value, err := splitSearchFieldValue("--phrase", entry) + if err != nil { + return nil, err + } + if _, duplicate := seen[field]; duplicate { + return nil, fmt.Errorf("--phrase field %q may appear only once", field) + } + seen[field] = struct{}{} + switch field { + case "original_filename": + phrase.OriginalFilename = value + case "metadata": + phrase.Metadata = value + case "detected_mime_type": + phrase.DetectedMimeType = value + default: + return nil, fmt.Errorf("unsupported --phrase field %q", field) + } + } + return phrase, nil +} + +func parseSearchExact(values []string) (map[string][]string, error) { + if len(values) == 0 { + return nil, nil + } + exact := make(map[string][]string) + for _, entry := range values { + field, value, err := splitSearchFieldValue("--exact", entry) + if err != nil { + return nil, err + } + exact[field] = append(exact[field], value) + } + return exact, nil +} + +func splitSearchFieldValue(flag, entry string) (string, string, error) { + field, value, ok := strings.Cut(entry, "=") + if !ok || field == "" || value == "" { + return "", "", fmt.Errorf("%s value %q must use non-empty field=value syntax", flag, entry) + } + return field, value, nil +} + +func fileSearchTable(files []service.File, includeAppData bool) *output.TableData { + headers := []string{"UUID", "SIZE", "FILENAME", "MIME TYPE", "UPLOADED"} + if includeAppData { + headers = append(headers, "APPDATA") + } + table := output.NewTableData(headers...) + for _, file := range files { + row := []string{file.UUID, strconv.FormatInt(file.Size, 10), file.Filename, file.MimeType, formatTime(file.DatetimeUploaded)} + if includeAppData { + row = append(row, truncateAppData(file.AppData, 50)) + } + table.AddRow(row...) + } + return table +} + +func runFileSearchAll(cmd *cobra.Command, svc service.FileService, searchOpts service.FileSearchOptions, opts output.FormatOptions, includeAppData bool) error { + count := 0 + total, err := svc.IterateSearch(cmd.Context(), searchOpts, func(file service.File) error { + count++ + if opts.Quiet { + return nil + } + if opts.JSON { + return output.NDJSONLine(cmd.OutOrStdout(), &file, opts.Fields, opts.JQ) + } + if includeAppData { + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%d\t%s\t%s\t%s\t%s\n", + file.UUID, file.Size, file.Filename, file.MimeType, formatTime(file.DatetimeUploaded), + truncateAppData(file.AppData, 50)) + return err + } + _, err := fmt.Fprintf(cmd.OutOrStdout(), "%s\t%d\t%s\t%s\t%s\n", + file.UUID, file.Size, file.Filename, file.MimeType, formatTime(file.DatetimeUploaded)) + return err + }) + if err != nil { + return err + } + if !opts.Quiet { + _, err = fmt.Fprintf(cmd.ErrOrStderr(), "Found %d matches; showing %d.\n", total, count) + } + return err +} diff --git a/internal/cmd/file_search_test.go b/internal/cmd/file_search_test.go new file mode 100644 index 0000000..92ad3f3 --- /dev/null +++ b/internal/cmd/file_search_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "context" + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/uploadcare/uploadcare-cli/internal/service" +) + +func TestFileSearch_MultipleFiltersAndJSON(t *testing.T) { + var got service.FileSearchOptions + file := *testFile() + file.Tags = []string{"approved", "featured"} + mock := &mockFileService{ + searchFunc: func(_ context.Context, opts service.FileSearchOptions) (*service.FileSearchResult, error) { + got = opts + return &service.FileSearchResult{Files: []service.File{file}, Total: 7}, nil + }, + } + + root := newTestRoot(mock) + stdout, stderr, err := executeCommand(t, root, + "file", "search", "invoice", + "--phrase", "metadata=project alpha", + "--exact", "detected_mime_type=application/pdf", + "--exact", "detected_mime_type=image/tiff", + "--tag-all", " Approved ", "--tag-all", "FEATURED", + "--tag-none", "archived", + "--sort", "score", "--sort=-datetime_uploaded", + "--is-image=false", "--limit", "25", "--offset", "10", + "--json", "all", + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got.Query != "invoice" || got.Limit != 25 || got.Offset != 10 { + t.Fatalf("unexpected options: %+v", got) + } + if got.IsImage == nil || *got.IsImage { + t.Fatalf("is_image = %v, want pointer to false", got.IsImage) + } + if !reflect.DeepEqual(got.Exact["detected_mime_type"], []string{"application/pdf", "image/tiff"}) { + t.Errorf("exact values = %v", got.Exact) + } + if !reflect.DeepEqual(got.Tags.All, []string{"approved", "featured"}) || !reflect.DeepEqual(got.Tags.None, []string{"archived"}) { + t.Errorf("tags = %+v", got.Tags) + } + if !reflect.DeepEqual(got.Sort, []string{"score", "-datetime_uploaded"}) { + t.Errorf("sort = %v", got.Sort) + } + var results []map[string]any + if err := json.Unmarshal([]byte(stdout), &results); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, stdout) + } + if len(results) != 1 || results[0]["uuid"] != file.UUID { + t.Fatalf("unexpected results: %s", stdout) + } + if !strings.Contains(stderr, "Found 7 matches; showing 1.") { + t.Errorf("missing result status: %q", stderr) + } +} + +func TestFileSearch_RejectsInvalidIsImageValue(t *testing.T) { + called := false + mock := &mockFileService{searchFunc: func(_ context.Context, _ service.FileSearchOptions) (*service.FileSearchResult, error) { + called = true + return nil, nil + }} + + _, _, err := executeCommand(t, newTestRoot(mock), "file", "search", "--is-image", "yes") + if err == nil { + t.Fatal("expected validation error") + } + exitErr, ok := err.(*ExitError) + if !ok || exitErr.Code != 2 { + t.Fatalf("error = %T %v, want exit code 2", err, err) + } + if called { + t.Fatal("service was called for invalid --is-image value") + } +} + +func TestFileSearch_RequiresCriterionBeforeServiceCall(t *testing.T) { + called := false + mock := &mockFileService{searchFunc: func(_ context.Context, _ service.FileSearchOptions) (*service.FileSearchResult, error) { + called = true + return nil, nil + }} + + _, _, err := executeCommand(t, newTestRoot(mock), "file", "search", "--sort", "score") + if err == nil { + t.Fatal("expected validation error") + } + exitErr, ok := err.(*ExitError) + if !ok || exitErr.Code != 2 { + t.Fatalf("error = %T %v, want exit code 2", err, err) + } + if called { + t.Fatal("service was called for invalid search") + } +} + +func TestFileSearch_PageAllStreamsResults(t *testing.T) { + mock := &mockFileService{ + iterateSearchFunc: func(_ context.Context, _ service.FileSearchOptions, fn func(service.File) error) (uint64, error) { + for i := 0; i < 2; i++ { + if err := fn(*testFile()); err != nil { + return 2, err + } + } + return 2, nil + }, + } + stdout, stderr, err := executeCommand(t, newTestRoot(mock), + "file", "search", "invoice", "--page-all", "--json", "uuid") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if lines := strings.Count(strings.TrimSpace(stdout), "\n") + 1; lines != 2 { + t.Fatalf("got %d NDJSON lines: %q", lines, stdout) + } + if !strings.Contains(stderr, "Found 2 matches; showing 2.") { + t.Errorf("missing status: %q", stderr) + } +} diff --git a/internal/cmd/file_test.go b/internal/cmd/file_test.go index 0dc51b7..ad37770 100644 --- a/internal/cmd/file_test.go +++ b/internal/cmd/file_test.go @@ -18,6 +18,8 @@ type mockFileService struct { infoFunc func(ctx context.Context, uuid string, includeAppData bool) (*service.File, error) listFunc func(ctx context.Context, opts service.FileListOptions) (*service.FileListResult, error) iterateFunc func(ctx context.Context, opts service.FileListOptions, fn func(service.File) error) error + searchFunc func(ctx context.Context, opts service.FileSearchOptions) (*service.FileSearchResult, error) + iterateSearchFunc func(ctx context.Context, opts service.FileSearchOptions, fn func(service.File) error) (uint64, error) uploadFunc func(ctx context.Context, params service.UploadParams) (*service.File, error) uploadFromURLFunc func(ctx context.Context, params service.URLUploadParams) (*service.File, error) storeFunc func(ctx context.Context, uuids []string) (*service.BatchResult, error) @@ -48,6 +50,20 @@ func (m *mockFileService) Iterate(ctx context.Context, opts service.FileListOpti return errors.New("not implemented") } +func (m *mockFileService) Search(ctx context.Context, opts service.FileSearchOptions) (*service.FileSearchResult, error) { + if m.searchFunc != nil { + return m.searchFunc(ctx, opts) + } + return nil, errors.New("not implemented") +} + +func (m *mockFileService) IterateSearch(ctx context.Context, opts service.FileSearchOptions, fn func(service.File) error) (uint64, error) { + if m.iterateSearchFunc != nil { + return m.iterateSearchFunc(ctx, opts, fn) + } + return 0, errors.New("not implemented") +} + func (m *mockFileService) Upload(ctx context.Context, params service.UploadParams) (*service.File, error) { if m.uploadFunc != nil { return m.uploadFunc(ctx, params) diff --git a/internal/cmd/file_upload.go b/internal/cmd/file_upload.go index e64da3a..836660d 100644 --- a/internal/cmd/file_upload.go +++ b/internal/cmd/file_upload.go @@ -12,6 +12,7 @@ import ( "github.com/spf13/cobra" "github.com/uploadcare/uploadcare-cli/internal/output" "github.com/uploadcare/uploadcare-cli/internal/service" + "github.com/uploadcare/uploadcare-cli/internal/validate" ) type uploadFileEntry struct { @@ -24,6 +25,7 @@ func newFileUploadCmd(fileSvc service.FileService) *cobra.Command { var ( store string metadata []string + tags []string multipartThreshold int64 forceMultipart bool forceDirect bool @@ -50,14 +52,14 @@ The --store flag controls file storage behavior: true Store the file immediately false Leave the file unstored (auto-deleted after 24h) -Attach metadata at upload time with --metadata key=value (repeatable). +Attach metadata with --metadata key=value and tags with --tag (repeatable). Use --dry-run to validate files without actually uploading. Use --progress to show upload progress on stderr. Returns a single JSON object for one file, or an array for multiple files. JSON fields: uuid, size, filename, mime_type, is_image, is_stored, -is_ready, datetime_uploaded, original_file_url, metadata.`, +is_ready, datetime_uploaded, original_file_url, metadata, tags.`, Example: ` # Upload a single file uploadcare file upload photo.jpg @@ -67,6 +69,9 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, # Upload with metadata uploadcare file upload photo.jpg --metadata source=camera --metadata project=vacation + # Upload with multiple tags + uploadcare file upload photo.jpg --tag vacation --tag featured + # Upload multiple files, get JSON output uploadcare file upload *.jpg --json uuid,filename,size @@ -86,6 +91,10 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, default: return ExitErrorf(2, "invalid --store value: %q (must be \"auto\", \"true\", or \"false\")", store) } + normalizedTags, err := validate.NormalizeTags(tags, validate.MaxTagCount) + if err != nil { + return usageError(fmt.Errorf("--tag: %w", err)) + } svc := fileSvc if svc == nil { @@ -140,7 +149,7 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, } if dryRun { - return runUploadDryRun(cmd, entries, opts, formatter) + return runUploadDryRun(cmd, entries, normalizedTags, opts, formatter) } var threshold *int64 @@ -188,6 +197,7 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, ContentType: entry.contentType, Store: store, Metadata: meta, + Tags: normalizedTags, MultipartThreshold: threshold, }) _ = f.Close() @@ -224,6 +234,7 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, f := cmd.Flags() f.StringVar(&store, "store", "auto", "File storage behavior (auto, true, false)") f.StringSliceVar(&metadata, "metadata", nil, "Metadata key=value pairs (repeatable)") + f.StringArrayVar(&tags, "tag", nil, "Tag attached to every uploaded file (repeatable)") f.Int64Var(&multipartThreshold, "multipart-threshold", 10485760, "Size threshold for multipart upload in bytes") f.BoolVar(&forceMultipart, "force-multipart", false, "Force multipart upload") f.BoolVar(&forceDirect, "force-direct", false, "Force direct upload") @@ -234,11 +245,12 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, return cmd } -func runUploadDryRun(cmd *cobra.Command, entries []uploadFileEntry, opts output.FormatOptions, formatter output.Formatter) error { +func runUploadDryRun(cmd *cobra.Command, entries []uploadFileEntry, tags []string, opts output.FormatOptions, formatter output.Formatter) error { type dryRunEntry struct { - Path string `json:"path"` - Size int64 `json:"size"` - ContentType string `json:"content_type"` + Path string `json:"path"` + Size int64 `json:"size"` + ContentType string `json:"content_type"` + Tags []string `json:"tags,omitempty"` } var dryEntries []dryRunEntry for _, e := range entries { @@ -246,6 +258,7 @@ func runUploadDryRun(cmd *cobra.Command, entries []uploadFileEntry, opts output. Path: e.path, Size: e.size, ContentType: e.contentType, + Tags: tags, }) } @@ -253,9 +266,17 @@ func runUploadDryRun(cmd *cobra.Command, entries []uploadFileEntry, opts output. return formatter.Format(cmd.OutOrStdout(), dryEntries) } - table := output.NewTableData("PATH", "SIZE", "CONTENT-TYPE") + headers := []string{"PATH", "SIZE", "CONTENT-TYPE"} + if len(tags) > 0 { + headers = append(headers, "TAGS") + } + table := output.NewTableData(headers...) for _, e := range dryEntries { - table.AddRow(e.Path, strconv.FormatInt(e.Size, 10), e.ContentType) + row := []string{e.Path, strconv.FormatInt(e.Size, 10), e.ContentType} + if len(tags) > 0 { + row = append(row, strings.Join(tags, ", ")) + } + table.AddRow(row...) } return formatter.Format(cmd.OutOrStdout(), table) } diff --git a/internal/cmd/file_upload_from_url.go b/internal/cmd/file_upload_from_url.go index 7d13434..6c5aea0 100644 --- a/internal/cmd/file_upload_from_url.go +++ b/internal/cmd/file_upload_from_url.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "strconv" + "strings" "time" "github.com/spf13/cobra" @@ -15,6 +16,7 @@ func newFileUploadFromURLCmd(fileSvc service.FileService) *cobra.Command { var ( store string metadata []string + tags []string timeout time.Duration checkDuplicates bool saveDuplicates bool @@ -47,8 +49,10 @@ Use --dry-run to validate URLs without uploading. Returns a single JSON object for one URL, or an array for multiple URLs. +Tags can be attached during upload with repeatable --tag flags. + JSON fields: uuid, size, filename, mime_type, is_image, is_stored, -is_ready, datetime_uploaded, original_file_url, metadata.`, +is_ready, datetime_uploaded, original_file_url, metadata, tags.`, Example: ` # Upload from a single URL uploadcare file upload-from-url https://example.com/photo.jpg @@ -59,6 +63,9 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, uploadcare file upload-from-url https://example.com/photo.jpg \ --metadata source=web --check-duplicates --save-duplicates + # Upload with multiple tags + uploadcare file upload-from-url https://example.com/photo.jpg --tag remote --tag featured + # Upload multiple URLs, get only UUIDs uploadcare file upload-from-url \ https://example.com/a.jpg https://example.com/b.jpg \ @@ -73,6 +80,10 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, default: return ExitErrorf(2, "invalid --store value: %q (must be \"auto\", \"true\", or \"false\")", store) } + normalizedTags, err := validate.NormalizeTags(tags, validate.MaxTagCount) + if err != nil { + return usageError(fmt.Errorf("--tag: %w", err)) + } svc := fileSvc if svc == nil { @@ -111,7 +122,7 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, } if dryRun { - return runUploadFromURLDryRun(cmd, urls, opts, formatter) + return runUploadFromURLDryRun(cmd, urls, normalizedTags, opts, formatter) } var results []*service.File @@ -120,6 +131,7 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, URL: u, Store: store, Metadata: meta, + Tags: normalizedTags, Timeout: timeout, CheckDuplicates: checkDuplicates, SaveDuplicates: saveDuplicates, @@ -152,6 +164,7 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, f := cmd.Flags() f.StringVar(&store, "store", "auto", "File storage behavior (auto, true, false)") f.StringSliceVar(&metadata, "metadata", nil, "Metadata key=value pairs (repeatable)") + f.StringArrayVar(&tags, "tag", nil, "Tag attached during upload (repeatable)") f.DurationVar(&timeout, "timeout", 5*time.Minute, "Max wait time for upload to complete") f.BoolVar(&checkDuplicates, "check-duplicates", false, "Check for duplicate URLs") f.BoolVar(&saveDuplicates, "save-duplicates", false, "Save duplicate URL information") @@ -161,10 +174,11 @@ is_ready, datetime_uploaded, original_file_url, metadata.`, return cmd } -func runUploadFromURLDryRun(cmd *cobra.Command, urls []string, opts output.FormatOptions, formatter output.Formatter) error { +func runUploadFromURLDryRun(cmd *cobra.Command, urls, tags []string, opts output.FormatOptions, formatter output.Formatter) error { type dryRunEntry struct { - URL string `json:"url"` - Status string `json:"status"` + URL string `json:"url"` + Status string `json:"status"` + Tags []string `json:"tags,omitempty"` } var entries []dryRunEntry for _, u := range urls { @@ -172,16 +186,24 @@ func runUploadFromURLDryRun(cmd *cobra.Command, urls []string, opts output.Forma if err := validate.URL(u); err != nil { status = err.Error() } - entries = append(entries, dryRunEntry{URL: u, Status: status}) + entries = append(entries, dryRunEntry{URL: u, Status: status, Tags: tags}) } if opts.JSON { return formatter.Format(cmd.OutOrStdout(), entries) } - table := output.NewTableData("URL", "STATUS") + headers := []string{"URL", "STATUS"} + if len(tags) > 0 { + headers = append(headers, "TAGS") + } + table := output.NewTableData(headers...) for _, e := range entries { - table.AddRow(e.URL, e.Status) + row := []string{e.URL, e.Status} + if len(tags) > 0 { + row = append(row, strings.Join(tags, ", ")) + } + table.AddRow(row...) } return formatter.Format(cmd.OutOrStdout(), table) } diff --git a/internal/cmd/file_upload_from_url_test.go b/internal/cmd/file_upload_from_url_test.go index 0311cc5..bed7ea2 100644 --- a/internal/cmd/file_upload_from_url_test.go +++ b/internal/cmd/file_upload_from_url_test.go @@ -131,6 +131,26 @@ func TestFileUploadFromURL_Metadata(t *testing.T) { } } +func TestFileUploadFromURL_TagFlags(t *testing.T) { + var capturedParams service.URLUploadParams + mock := &mockFileService{ + uploadFromURLFunc: func(_ context.Context, params service.URLUploadParams) (*service.File, error) { + capturedParams = params + return testFile(), nil + }, + } + + _, _, err := executeCommand(t, newTestRoot(mock), "file", "upload-from-url", + "--tag", " Remote ", "--tag", "featured", "--tag", "REMOTE", + "https://example.com/image.jpg") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.Join(capturedParams.Tags, ","); got != "remote,featured" { + t.Errorf("tags = %q, want remote,featured", got) + } +} + func TestFileUploadFromURL_CheckDuplicates(t *testing.T) { var capturedParams service.URLUploadParams diff --git a/internal/cmd/file_upload_test.go b/internal/cmd/file_upload_test.go index 0076a20..2d5cea1 100644 --- a/internal/cmd/file_upload_test.go +++ b/internal/cmd/file_upload_test.go @@ -201,6 +201,26 @@ func TestFileUpload_MetadataFlag(t *testing.T) { } } +func TestFileUpload_TagFlags(t *testing.T) { + tmpFile := createTestFile(t, "test.txt", "hello") + var capturedTags []string + mock := &mockFileService{ + uploadFunc: func(_ context.Context, params service.UploadParams) (*service.File, error) { + capturedTags = params.Tags + return testFile(), nil + }, + } + + _, _, err := executeCommand(t, newTestRoot(mock), "file", "upload", + "--tag", " Featured ", "--tag", "vacation", "--tag", "FEATURED", tmpFile) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got := strings.Join(capturedTags, ","); got != "featured,vacation" { + t.Errorf("tags = %q, want featured,vacation", got) + } +} + func TestParseMetadata(t *testing.T) { tests := []struct { name string diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index e3723a6..e9946c5 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -86,6 +86,31 @@ func metadataServiceFromCmd(cmd *cobra.Command) (service.MetadataService, error) return svc, nil } +// tagServiceFromCmd resolves credentials and creates a TagService. +func tagServiceFromCmd(cmd *cobra.Command) (service.TagService, error) { + opts := formatOptionsFromCmd(cmd) + verbose := output.NewVerboseLogger(opts.Verbose, cmd.ErrOrStderr()) + + loader, err := configLoaderFromCmd(cmd, verbose) + if err != nil { + return nil, &ExitError{Code: 3, Err: err} + } + creds, err := loader.ResolveProjectCredentials(verbose) + if err != nil { + return nil, &ExitError{Code: 3, Err: err} + } + if err := creds.RequireBoth(); err != nil { + return nil, &ExitError{Code: 3, Err: err} + } + + httpClient := client.NewVerboseHTTPClient(verbose) + svc, err := client.NewTagService(creds.PublicKey, creds.SecretKey, httpClient, verbose) + if err != nil { + return nil, &ExitError{Code: 1, Err: err} + } + return svc, nil +} + // groupServiceFromCmd resolves credentials and creates a GroupService. func groupServiceFromCmd(cmd *cobra.Command) (service.GroupService, error) { opts := formatOptionsFromCmd(cmd) diff --git a/internal/cmd/root.go b/internal/cmd/root.go index b26506f..81fdd41 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -101,6 +101,7 @@ Use "uploadcare api-schema" for machine-readable command metadata.`, rootCmd.AddCommand(newVersionCmd(version, commit, date)) rootCmd.AddCommand(newAPISchemaCmd(version)) rootCmd.AddCommand(newFileCmd(nil)) + rootCmd.AddCommand(newTagCmd(nil)) rootCmd.AddCommand(newMetadataCmd(nil)) rootCmd.AddCommand(newGroupCmd(nil)) rootCmd.AddCommand(newWebhookCmd(nil)) diff --git a/internal/cmd/schema.go b/internal/cmd/schema.go index 92b20aa..2149e8e 100644 --- a/internal/cmd/schema.go +++ b/internal/cmd/schema.go @@ -84,6 +84,8 @@ No authentication required.`, "For batch operations (file store, file delete), exit code 1 means partial success — check the 'problems' field in JSON output.", "When piping between commands, use --json uuid or --jq '.uuid' to emit just the UUID for --from-stdin consumption.", "For file download, use --output-dir for multiple UUIDs or --from-stdin; --output - streams bytes to stdout and cannot be combined with --json.", + "File search requires at least one query or filter. Search results can lag recent file, metadata, and tag changes. The API serves at most the first 1000 matches of a search.", + "Tag flags are repeatable. Tag update applies all --delete values before all --add values. Tag mutations with --dry-run add \"status\": \"would change\" to their JSON output.", "For 'project usage': --to must be strictly before today in UTC. Using today's date or a future date will return a validation error.", }, URLAPI: buildURLAPISchema(), @@ -167,40 +169,34 @@ func collectCommands(cmd *cobra.Command, prefix string) []cmdSchema { return result } +// extractArgs derives argument bounds by probing the command's Args +// validator with growing argument lists — the validator is what the CLI +// actually enforces, while the Use string is display-only and ambiguous +// (e.g. " ..." with MinimumNArgs(2)). func extractArgs(cmd *cobra.Command) argsSchema { - // Parse from Use string: "command ..." or "command ..." - use := cmd.Use - parts := strings.Fields(use) - if len(parts) <= 1 { + validator := cmd.Args + if validator == nil { return argsSchema{Min: 0, Max: 0} } - argParts := parts[1:] - min := 0 - max := 0 - hasVariadic := false - - for _, p := range argParts { - if strings.HasSuffix(p, "...") { - hasVariadic = true + const probeMax = 8 + minArgs, maxArgs := -1, -1 + for n := 0; n <= probeMax; n++ { + if validator(cmd, make([]string, n)) != nil { + continue } - if strings.HasPrefix(p, "<") { - min++ - max++ - } else if strings.HasPrefix(p, "[") { - max++ + if minArgs == -1 { + minArgs = n } + maxArgs = n } - - if hasVariadic { - // ArbitraryArgs — set min to 0 for optional variadic - if min > 0 { - min-- // The variadic arg itself is optional beyond 0 - } - max = -1 // unlimited + if minArgs == -1 { + return argsSchema{Min: 0, Max: 0} } - - return argsSchema{Min: min, Max: max} + if validator(cmd, make([]string, probeMax*8)) == nil { + maxArgs = -1 // unlimited + } + return argsSchema{Min: minArgs, Max: maxArgs} } func parseExamples(example string) []string { @@ -244,13 +240,19 @@ func parseExamples(example string) []string { // jsonFieldsForCommand returns the known JSON field names for a command. // These correspond to the struct JSON tags used when --json output is active. func jsonFieldsForCommand(path string) []string { - fileFields := []string{"uuid", "size", "filename", "mime_type", "is_image", "is_stored", "is_ready", "datetime_uploaded", "datetime_stored", "datetime_removed", "url", "original_file_url", "metadata", "appdata"} + fileFields := []string{"uuid", "size", "filename", "mime_type", "is_image", "is_stored", "is_ready", "datetime_uploaded", "datetime_stored", "datetime_removed", "url", "original_file_url", "metadata", "tags", "appdata"} switch path { case "file info", "file upload", "file upload-from-url", "file local-copy": return fileFields case "file list": return fileFields + case "file search": + return append(fileFields, "highlight") + case "tag list": + return []string{"tags"} + case "tag replace", "tag update", "tag clear": + return []string{"tags", "added", "deleted", "status"} case "file store", "file delete": return []string{"results", "problems"} case "file remote-copy": diff --git a/internal/cmd/tag.go b/internal/cmd/tag.go new file mode 100644 index 0000000..b61a1f3 --- /dev/null +++ b/internal/cmd/tag.go @@ -0,0 +1,268 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/uploadcare/uploadcare-cli/internal/output" + "github.com/uploadcare/uploadcare-cli/internal/service" + "github.com/uploadcare/uploadcare-cli/internal/validate" +) + +func newTagCmd(tagSvc service.TagService) *cobra.Command { + cmd := &cobra.Command{ + Use: "tag", + Short: "Manage file tags", + Long: `Manage ordered per-file tags. + +Tags are normalized to lowercase and may contain letters, digits, dots, +underscores, and hyphens. Use --dry-run on mutation commands to preview a +change without applying it.`, + } + cmd.AddCommand(newTagListCmd(tagSvc)) + cmd.AddCommand(newTagReplaceCmd(tagSvc)) + cmd.AddCommand(newTagUpdateCmd(tagSvc)) + cmd.AddCommand(newTagClearCmd(tagSvc)) + return cmd +} + +func newTagListCmd(tagSvc service.TagService) *cobra.Command { + return &cobra.Command{ + Use: "list ", + Short: "List a file's tags", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validate.UUID(args[0]); err != nil { + return usageError(err) + } + svc, err := resolveTagService(cmd, tagSvc) + if err != nil { + return err + } + tags, err := svc.List(cmd.Context(), args[0]) + if err != nil { + return err + } + opts := formatOptionsFromCmd(cmd) + formatter := output.New(opts) + if opts.JSON { + if tags == nil { + tags = []string{} + } + return formatter.Format(cmd.OutOrStdout(), map[string][]string{"tags": tags}) + } + if len(tags) == 0 { + if opts.Quiet { + return nil + } + _, err = fmt.Fprintln(cmd.OutOrStdout(), "No tags found") + return err + } + table := output.NewTableData() + for _, value := range tags { + table.AddRow(value) + } + return formatter.Format(cmd.OutOrStdout(), table) + }, + } +} + +func newTagReplaceCmd(tagSvc service.TagService) *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "replace ...", + Short: "Replace a file's complete tag set", + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validate.UUID(args[0]); err != nil { + return usageError(err) + } + tags, err := validate.NormalizeTags(args[1:], validate.MaxTagCount) + if err != nil { + return usageError(err) + } + return runTagReplace(cmd, tagSvc, args[0], tags, dryRun) + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would change without applying") + return cmd +} + +func newTagUpdateCmd(tagSvc service.TagService) *cobra.Command { + var add, deleteTags []string + var dryRun bool + cmd := &cobra.Command{ + Use: "update ", + Short: "Atomically add and delete tags", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validate.UUID(args[0]); err != nil { + return usageError(err) + } + if len(add) == 0 && len(deleteTags) == 0 { + return usageError(fmt.Errorf("at least one --add or --delete is required")) + } + normalizedAdd, err := validate.NormalizeTags(add, validate.MaxTagCount) + if err != nil { + return usageError(fmt.Errorf("--add: %w", err)) + } + normalizedDelete, err := validate.NormalizeTags(deleteTags, 0) + if err != nil { + return usageError(fmt.Errorf("--delete: %w", err)) + } + svc, err := resolveTagService(cmd, tagSvc) + if err != nil { + return err + } + var result *service.TagChangeResult + if dryRun { + current, err := svc.List(cmd.Context(), args[0]) + if err != nil { + return err + } + result = updateDiff(current, normalizedAdd, normalizedDelete) + } else { + result, err = svc.Update(cmd.Context(), args[0], service.TagUpdateOptions{Add: normalizedAdd, Delete: normalizedDelete}) + if err != nil { + return err + } + } + return formatTagChange(cmd, result, dryRun) + }, + } + cmd.Flags().StringArrayVar(&add, "add", nil, "Tag to add (repeatable)") + cmd.Flags().StringArrayVar(&deleteTags, "delete", nil, "Tag to delete (repeatable)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would change without applying") + return cmd +} + +func newTagClearCmd(tagSvc service.TagService) *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "clear ", + Short: "Remove all tags from a file", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if err := validate.UUID(args[0]); err != nil { + return usageError(err) + } + return runTagReplace(cmd, tagSvc, args[0], []string{}, dryRun) + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Show what would change without applying") + return cmd +} + +func runTagReplace(cmd *cobra.Command, injected service.TagService, uuid string, tags []string, dryRun bool) error { + svc, err := resolveTagService(cmd, injected) + if err != nil { + return err + } + var result *service.TagChangeResult + if dryRun { + current, err := svc.List(cmd.Context(), uuid) + if err != nil { + return err + } + result = replacementDiff(current, tags) + } else { + result, err = svc.Replace(cmd.Context(), uuid, tags) + if err != nil { + return err + } + } + return formatTagChange(cmd, result, dryRun) +} + +func resolveTagService(cmd *cobra.Command, injected service.TagService) (service.TagService, error) { + if injected != nil { + return injected, nil + } + return tagServiceFromCmd(cmd) +} + +func formatTagChange(cmd *cobra.Command, result *service.TagChangeResult, dryRun bool) error { + opts := formatOptionsFromCmd(cmd) + formatter := output.New(opts) + if opts.JSON { + if dryRun { + return formatter.Format(cmd.OutOrStdout(), struct { + Status string `json:"status"` + *service.TagChangeResult + }{"would change", result}) + } + return formatter.Format(cmd.OutOrStdout(), result) + } + table := output.NewTableData("FIELD", "VALUE") + if dryRun { + table.AddRow("Status", "Would change") + } + table.AddRow("Tags", displayTags(result.Tags)) + table.AddRow("Added", displayTags(result.Added)) + table.AddRow("Deleted", displayTags(result.Deleted)) + return formatter.Format(cmd.OutOrStdout(), table) +} + +func displayTags(tags []string) string { + if len(tags) == 0 { + return "(none)" + } + return strings.Join(tags, ", ") +} + +func replacementDiff(current, replacement []string) *service.TagChangeResult { + currentSet := stringSet(current) + replacementSet := stringSet(replacement) + result := &service.TagChangeResult{ + Tags: append([]string{}, replacement...), Added: []string{}, Deleted: []string{}, + } + for _, value := range replacement { + if _, exists := currentSet[value]; !exists { + result.Added = append(result.Added, value) + } + } + for _, value := range current { + if _, exists := replacementSet[value]; !exists { + result.Deleted = append(result.Deleted, value) + } + } + return result +} + +func updateDiff(current, add, deleteTags []string) *service.TagChangeResult { + deleteSet := stringSet(deleteTags) + result := &service.TagChangeResult{Tags: []string{}, Added: []string{}, Deleted: []string{}} + remaining := make(map[string]struct{}, len(current)+len(add)) + for _, value := range current { + if _, deleted := deleteSet[value]; deleted { + result.Deleted = append(result.Deleted, value) + continue + } + if _, duplicate := remaining[value]; !duplicate { + remaining[value] = struct{}{} + result.Tags = append(result.Tags, value) + } + } + for _, value := range add { + if _, exists := remaining[value]; exists { + continue + } + remaining[value] = struct{}{} + result.Tags = append(result.Tags, value) + result.Added = append(result.Added, value) + } + return result +} + +func stringSet(values []string) map[string]struct{} { + set := make(map[string]struct{}, len(values)) + for _, value := range values { + set[value] = struct{}{} + } + return set +} + +func usageError(err error) error { + return &ExitError{Code: 2, Err: err} +} diff --git a/internal/cmd/tag_test.go b/internal/cmd/tag_test.go new file mode 100644 index 0000000..f585390 --- /dev/null +++ b/internal/cmd/tag_test.go @@ -0,0 +1,129 @@ +package cmd + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/spf13/cobra" + "github.com/uploadcare/uploadcare-cli/internal/service" +) + +type mockTagService struct { + listFunc func(context.Context, string) ([]string, error) + replaceFunc func(context.Context, string, []string) (*service.TagChangeResult, error) + updateFunc func(context.Context, string, service.TagUpdateOptions) (*service.TagChangeResult, error) +} + +func (m *mockTagService) List(ctx context.Context, uuid string) ([]string, error) { + if m.listFunc != nil { + return m.listFunc(ctx, uuid) + } + return nil, errors.New("not implemented") +} + +func (m *mockTagService) Replace(ctx context.Context, uuid string, tags []string) (*service.TagChangeResult, error) { + if m.replaceFunc != nil { + return m.replaceFunc(ctx, uuid, tags) + } + return nil, errors.New("not implemented") +} + +func (m *mockTagService) Update(ctx context.Context, uuid string, opts service.TagUpdateOptions) (*service.TagChangeResult, error) { + if m.updateFunc != nil { + return m.updateFunc(ctx, uuid, opts) + } + return nil, errors.New("not implemented") +} + +func newTagTestRoot(mock service.TagService) *cobra.Command { + root := &cobra.Command{Use: "uploadcare", SilenceUsage: true, SilenceErrors: true} + flags := root.PersistentFlags() + flags.String("json", "", "Output as JSON") + flags.String("jq", "", "jq expression") + flags.BoolP("quiet", "q", false, "Suppress output") + flags.BoolP("verbose", "v", false, "Verbose output") + root.AddCommand(newTagCmd(mock)) + return root +} + +func TestTagUpdate_AcceptsMultipleAddAndDeleteFlags(t *testing.T) { + var got service.TagUpdateOptions + mock := &mockTagService{updateFunc: func(_ context.Context, _ string, opts service.TagUpdateOptions) (*service.TagChangeResult, error) { + got = opts + return &service.TagChangeResult{Tags: []string{"one", "two"}, Added: []string{"one", "two"}, Deleted: []string{"old"}}, nil + }} + + stdout, _, err := executeCommand(t, newTagTestRoot(mock), + "tag", "update", "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "--add", " One ", "--add", "two", "--add", "ONE", + "--delete", "old", "--delete", "ONE", "--json", "all") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !reflect.DeepEqual(got.Add, []string{"one", "two"}) { + t.Errorf("add = %v", got.Add) + } + if !reflect.DeepEqual(got.Delete, []string{"old", "one"}) { + t.Errorf("delete = %v", got.Delete) + } + var result service.TagChangeResult + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !reflect.DeepEqual(result.Tags, []string{"one", "two"}) { + t.Errorf("result = %+v", result) + } +} + +func TestTagUpdate_DryRunDeletesBeforeAdding(t *testing.T) { + updated := false + mock := &mockTagService{ + listFunc: func(context.Context, string) ([]string, error) { + return []string{"keep", "swap"}, nil + }, + updateFunc: func(context.Context, string, service.TagUpdateOptions) (*service.TagChangeResult, error) { + updated = true + return nil, nil + }, + } + + stdout, _, err := executeCommand(t, newTagTestRoot(mock), + "tag", "update", "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "--delete", "swap", "--add", "swap", "--add", "new", "--dry-run", "--json", "all") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if updated { + t.Fatal("dry-run called Update") + } + var result struct { + Tags, Added, Deleted []string + Status string + } + if err := json.Unmarshal([]byte(stdout), &result); err != nil { + t.Fatalf("invalid JSON: %v", err) + } + if !reflect.DeepEqual(result.Tags, []string{"keep", "swap", "new"}) || + !reflect.DeepEqual(result.Added, []string{"swap", "new"}) || + !reflect.DeepEqual(result.Deleted, []string{"swap"}) { + t.Fatalf("unexpected dry-run result: %+v", result) + } + if result.Status != "would change" { + t.Errorf("dry-run JSON status = %q, want \"would change\"", result.Status) + } +} + +func TestTagUpdate_RequiresAtLeastOneOperation(t *testing.T) { + _, _, err := executeCommand(t, newTagTestRoot(&mockTagService{}), + "tag", "update", "a1b2c3d4-e5f6-7890-abcd-ef1234567890") + if err == nil { + t.Fatal("expected error") + } + exitErr, ok := err.(*ExitError) + if !ok || exitErr.Code != 2 { + t.Fatalf("error = %T %v, want exit code 2", err, err) + } +} diff --git a/internal/service/interfaces.go b/internal/service/interfaces.go index 4405b84..8b725e7 100644 --- a/internal/service/interfaces.go +++ b/internal/service/interfaces.go @@ -46,20 +46,29 @@ func ApplyCDNEffects(originalFileURL, uuid, effects string) string { // File represents an Uploadcare file resource. type File struct { - UUID string `json:"uuid"` - Size int64 `json:"size"` - Filename string `json:"filename"` - MimeType string `json:"mime_type"` - IsImage bool `json:"is_image"` - IsStored bool `json:"is_stored"` - IsReady bool `json:"is_ready"` - DatetimeUploaded time.Time `json:"datetime_uploaded"` - DatetimeStored *time.Time `json:"datetime_stored"` - DatetimeRemoved *time.Time `json:"datetime_removed"` - URL string `json:"url"` - OriginalFileURL string `json:"original_file_url"` - Metadata map[string]string `json:"metadata"` - AppData json.RawMessage `json:"appdata,omitempty"` + UUID string `json:"uuid"` + Size int64 `json:"size"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + IsImage bool `json:"is_image"` + IsStored bool `json:"is_stored"` + IsReady bool `json:"is_ready"` + DatetimeUploaded time.Time `json:"datetime_uploaded"` + DatetimeStored *time.Time `json:"datetime_stored"` + DatetimeRemoved *time.Time `json:"datetime_removed"` + URL string `json:"url"` + OriginalFileURL string `json:"original_file_url"` + Metadata map[string]string `json:"metadata"` + Tags []string `json:"tags"` + AppData json.RawMessage `json:"appdata,omitempty"` + Highlight *FileSearchHighlight `json:"highlight,omitempty"` +} + +// FileSearchHighlight contains the fields that matched a search query. +type FileSearchHighlight struct { + OriginalFilename []string `json:"original_filename,omitempty"` + DetectedMimeType []string `json:"detected_mime_type,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` } // FileListOptions specifies parameters for listing files. @@ -80,6 +89,58 @@ type FileListResult struct { Total int `json:"total"` } +// FileSearchPhrase specifies phrase-match fields for file search. +type FileSearchPhrase struct { + OriginalFilename string + Metadata string + DetectedMimeType string +} + +// FileSearchDatetime specifies uploaded-at range boundaries. +type FileSearchDatetime struct { + Gt *time.Time + Gte *time.Time + Lt *time.Time + Lte *time.Time +} + +// FileSearchSize specifies file-size range boundaries. +type FileSearchSize struct { + Gt *uint64 + Gte *uint64 + Lt *uint64 + Lte *uint64 +} + +// FileSearchTags specifies tag filters. Any, All, and None are combined. +type FileSearchTags struct { + Any []string + All []string + None []string +} + +// FileSearchOptions specifies parameters for full-text file search. +type FileSearchOptions struct { + Limit int + Offset int + IncludeAppData bool + Query string + Phrase *FileSearchPhrase + Exact map[string][]string + DatetimeUploaded *FileSearchDatetime + Size *FileSearchSize + IsImage *bool + Fuzziness bool + Tags *FileSearchTags + Sort []string +} + +// FileSearchResult is a page of file search matches. +type FileSearchResult struct { + Files []File `json:"results"` + Total uint64 `json:"total"` +} + // UploadParams configures a direct file upload. type UploadParams struct { Data io.ReadSeeker @@ -88,6 +149,7 @@ type UploadParams struct { ContentType string Store string // "auto", "true", "false" Metadata map[string]string + Tags []string MultipartThreshold *int64 } @@ -102,12 +164,26 @@ type URLUploadParams struct { URL string Store string // "auto", "true", "false" Metadata map[string]string + Tags []string Wait bool Timeout time.Duration CheckDuplicates bool SaveDuplicates bool } +// TagUpdateOptions specifies tags to add and delete in one request. +type TagUpdateOptions struct { + Add []string + Delete []string +} + +// TagChangeResult describes the resulting tags and the applied changes. +type TagChangeResult struct { + Tags []string `json:"tags"` + Added []string `json:"added"` + Deleted []string `json:"deleted"` +} + // DownloadParams configures a file download from the CDN. type DownloadParams struct { UUID string @@ -353,6 +429,8 @@ type MimeType struct { type FileService interface { List(ctx context.Context, opts FileListOptions) (*FileListResult, error) Iterate(ctx context.Context, opts FileListOptions, fn func(File) error) error + Search(ctx context.Context, opts FileSearchOptions) (*FileSearchResult, error) + IterateSearch(ctx context.Context, opts FileSearchOptions, fn func(File) error) (uint64, error) Info(ctx context.Context, uuid string, includeAppData bool) (*File, error) Upload(ctx context.Context, params UploadParams) (*File, error) UploadFromURL(ctx context.Context, params URLUploadParams) (*File, error) @@ -363,6 +441,13 @@ type FileService interface { Download(ctx context.Context, params DownloadParams) (*DownloadResult, error) } +// TagService provides file tag operations. +type TagService interface { + List(ctx context.Context, fileUUID string) ([]string, error) + Replace(ctx context.Context, fileUUID string, tags []string) (*TagChangeResult, error) + Update(ctx context.Context, fileUUID string, opts TagUpdateOptions) (*TagChangeResult, error) +} + // MetadataService provides file metadata CRUD operations. type MetadataService interface { List(ctx context.Context, fileUUID string) (map[string]string, error) diff --git a/internal/validate/search.go b/internal/validate/search.go new file mode 100644 index 0000000..0cc80b9 --- /dev/null +++ b/internal/validate/search.go @@ -0,0 +1,116 @@ +package validate + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "github.com/uploadcare/uploadcare-cli/internal/service" + "github.com/uploadcare/uploadcare-go/v2/file" +) + +// Limits and sort keys come from the SDK so an SDK upgrade cannot leave this +// pre-flight validation rejecting values the API accepts. +const ( + MinSearchTextLength = file.MinSearchQueryLength + MaxSearchLimit = file.MaxSearchLimit + MaxSearchWindow = file.MaxSearchOffsetLimit + MaxSearchSortKeys = file.MaxSearchSortKeys +) + +var searchMetadataKeyPattern = regexp.MustCompile(`^metadata\[[\w.:-]{1,64}\]$`) + +var validSearchSortKeys = map[string]struct{}{ + string(file.SortByScore): {}, string(file.SortByScoreDesc): {}, + string(file.SortByUploadedAt): {}, string(file.SortByUploadedAtDesc): {}, + string(file.SortBySize): {}, string(file.SortBySizeDesc): {}, + string(file.SortByOriginalFilename): {}, string(file.SortByOriginalFilenameDesc): {}, +} + +// FileSearch validates search options before credentials are resolved or a +// request is sent. Tag filters are expected to be already normalized via +// NormalizeTags. The SDK performs the same validation defensively. +func FileSearch(opts service.FileSearchOptions) error { + if !hasSearchCondition(opts) { + return fmt.Errorf("search requires at least one query or filter") + } + if opts.Query != "" && utf8.RuneCountInString(opts.Query) < MinSearchTextLength { + return fmt.Errorf("search query must be at least %d characters", MinSearchTextLength) + } + if opts.Phrase != nil { + phrases := map[string]string{ + "original_filename": opts.Phrase.OriginalFilename, + "metadata": opts.Phrase.Metadata, + "detected_mime_type": opts.Phrase.DetectedMimeType, + } + for field, value := range phrases { + if value != "" && utf8.RuneCountInString(value) < MinSearchTextLength { + return fmt.Errorf("search phrase %q must be at least %d characters", field, MinSearchTextLength) + } + } + if opts.Phrase.OriginalFilename != "" && len(opts.Exact["original_filename"]) > 0 { + return fmt.Errorf("search field %q cannot appear in both --phrase and --exact", "original_filename") + } + if opts.Phrase.DetectedMimeType != "" && len(opts.Exact["detected_mime_type"]) > 0 { + return fmt.Errorf("search field %q cannot appear in both --phrase and --exact", "detected_mime_type") + } + if opts.Phrase.Metadata != "" { + for key := range opts.Exact { + if strings.HasPrefix(key, "metadata[") { + return fmt.Errorf("search field %q cannot appear in both --phrase and --exact", key) + } + } + } + } + for key, values := range opts.Exact { + if !validSearchExactKey(key) { + return fmt.Errorf("unsupported --exact field %q", key) + } + if len(values) == 0 { + return fmt.Errorf("--exact %q requires a non-empty value", key) + } + for _, value := range values { + if value == "" { + return fmt.Errorf("--exact %q requires a non-empty value", key) + } + } + } + if opts.Limit < 1 || opts.Limit > MaxSearchLimit { + return fmt.Errorf("--limit must be between 1 and %d", MaxSearchLimit) + } + if opts.Offset < 0 || opts.Offset > MaxSearchWindow-opts.Limit { + return fmt.Errorf("--offset plus --limit must not exceed %d", MaxSearchWindow) + } + if len(opts.Sort) > MaxSearchSortKeys { + return fmt.Errorf("--sort accepts at most %d values", MaxSearchSortKeys) + } + seenSort := make(map[string]struct{}, len(opts.Sort)) + for _, key := range opts.Sort { + if _, ok := validSearchSortKeys[key]; !ok { + return fmt.Errorf("unsupported --sort value %q", key) + } + base := strings.TrimPrefix(key, "-") + if _, ok := seenSort[base]; ok { + return fmt.Errorf("--sort values must be unique and cannot include both directions of %q", base) + } + seenSort[base] = struct{}{} + } + return nil +} + +func hasSearchCondition(opts service.FileSearchOptions) bool { + return opts.Query != "" || + (opts.Phrase != nil && (opts.Phrase.OriginalFilename != "" || opts.Phrase.Metadata != "" || opts.Phrase.DetectedMimeType != "")) || + len(opts.Exact) > 0 || opts.DatetimeUploaded != nil || opts.Size != nil || opts.IsImage != nil || + (opts.Tags != nil && (len(opts.Tags.Any) > 0 || len(opts.Tags.All) > 0 || len(opts.Tags.None) > 0)) +} + +func validSearchExactKey(key string) bool { + switch key { + case "uuid", "detected_mime_type", "original_filename": + return true + default: + return searchMetadataKeyPattern.MatchString(key) + } +} diff --git a/internal/validate/tag.go b/internal/validate/tag.go new file mode 100644 index 0000000..cc77ac9 --- /dev/null +++ b/internal/validate/tag.go @@ -0,0 +1,47 @@ +package validate + +import ( + "fmt" + "regexp" + "strings" + "unicode/utf8" + + "github.com/uploadcare/uploadcare-go/v2/tag" +) + +// Limits come from the SDK so an SDK upgrade cannot leave this pre-flight +// validation rejecting values the API accepts. +const ( + MaxTagLength = tag.MaxLength + MaxTagCount = tag.MaxCount +) + +var tagPattern = regexp.MustCompile(`^[a-z0-9._-]+$`) + +// NormalizeTags returns normalized, de-duplicated tags in first-seen order. +// A maxCount of zero disables count validation. +func NormalizeTags(tags []string, maxCount int) ([]string, error) { + normalized := make([]string, 0, len(tags)) + seen := make(map[string]struct{}, len(tags)) + for _, raw := range tags { + value := strings.ToLower(strings.TrimSpace(raw)) + if value == "" { + return nil, fmt.Errorf("tag must not be blank") + } + if utf8.RuneCountInString(value) > MaxTagLength { + return nil, fmt.Errorf("tag %q exceeds the maximum length of %d characters", value, MaxTagLength) + } + if !tagPattern.MatchString(value) { + return nil, fmt.Errorf("tag %q contains invalid characters (allowed: a-z, 0-9, dot, underscore, hyphen)", value) + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + normalized = append(normalized, value) + } + if maxCount > 0 && len(normalized) > maxCount { + return nil, fmt.Errorf("too many tags: %d (maximum %d)", len(normalized), maxCount) + } + return normalized, nil +} diff --git a/internal/validate/tag_search_test.go b/internal/validate/tag_search_test.go new file mode 100644 index 0000000..82fbefa --- /dev/null +++ b/internal/validate/tag_search_test.go @@ -0,0 +1,48 @@ +package validate + +import ( + "reflect" + "strings" + "testing" + + "github.com/uploadcare/uploadcare-cli/internal/service" +) + +func TestNormalizeTags(t *testing.T) { + got, err := NormalizeTags([]string{" Cat ", "ANIMAL", "cat", "with.dot"}, MaxTagCount) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"cat", "animal", "with.dot"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} + +func TestNormalizeTagsRejectsInvalidValue(t *testing.T) { + if _, err := NormalizeTags([]string{"has space"}, MaxTagCount); err == nil { + t.Fatal("expected invalid-character error") + } + if _, err := NormalizeTags([]string{strings.Repeat("a", MaxTagLength+1)}, MaxTagCount); err == nil { + t.Fatal("expected length error") + } +} + +func TestFileSearchValidation(t *testing.T) { + valid := service.FileSearchOptions{Query: "cats", Limit: 20} + if err := FileSearch(valid); err != nil { + t.Fatalf("valid search rejected: %v", err) + } + conflict := service.FileSearchOptions{ + Phrase: &service.FileSearchPhrase{Metadata: "camera"}, + Exact: map[string][]string{"metadata[source]": {"web"}}, + Limit: 20, + } + if err := FileSearch(conflict); err == nil { + t.Fatal("expected phrase/exact conflict") + } + window := service.FileSearchOptions{Query: "cats", Limit: 20, Offset: 981} + if err := FileSearch(window); err == nil { + t.Fatal("expected pagination window error") + } +}