diff --git a/README.md b/README.md index a8ecbea..ae60a06 100644 --- a/README.md +++ b/README.md @@ -186,6 +186,27 @@ hspt tasks create --subject "Follow up" --body "Call about renewal" --priority H hspt calls create --body "Discussed pricing" --direction OUTBOUND --duration 300 ``` +The `tasks` and `emails` commands also support a `search` subcommand backed by the +HubSpot CRM Search API, with repeatable `--filter` and `--sort` flags: + +```bash +# Open tasks for a specific owner, oldest first +hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_id=77999105" --sort "hs_timestamp:asc" + +# Overdue tasks (not started, due on or before a date — ISO dates are converted automatically) +hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hs_timestamp<=2026-03-17" --sort "hs_timestamp:asc" + +# Logged emails by direction, newest first +hspt emails search --filter "hs_email_direction=EMAIL" --sort "hs_timestamp:desc" --limit 20 + +# Emails whose subject contains a phrase +hspt emails search --filter "hs_email_subject:CONTAINS_TOKEN:Dev Academy" --limit 10 +``` + +Filters accept shorthand (`prop=value`, `prop!=value`, `prop>=value`, `prop<=value`, +`prop>value`, `prop=" for ">". +var shorthandOperators = []struct { + token string + operator string +}{ + {">=", "GTE"}, + {"<=", "LTE"}, + {"!=", "NEQ"}, + {"=", "EQ"}, + {">", "GT"}, + {"<", "LT"}, +} + +// dateProperties are HubSpot properties whose values are stored as Unix +// millisecond timestamps. ISO-8601 dates supplied for these properties are +// converted automatically so users can write human-readable dates. +var dateProperties = map[string]bool{ + "hs_timestamp": true, + "hs_task_timestamp": true, + "hs_createdate": true, + "hs_lastmodifieddate": true, + "createdate": true, + "lastmodifieddate": true, + "closedate": true, + "hs_task_completion_date": true, + "hubspot_owner_assigneddate": true, +} + +// ParseFilters converts raw --filter flag values into HubSpot search filters. +// +// Supported forms: +// +// prop=value EQ +// prop!=value NEQ +// prop>=value GTE +// prop<=value LTE +// prop>value GT +// prop 0 { + prop := trimmed[:idx] + rest := trimmed[idx+1:] + opEnd := strings.Index(rest, ":") + op := rest + if opEnd >= 0 { + op = rest[:opEnd] + } + if isOperatorToken(op) { + return parseExplicitFilter(prop, strings.ToUpper(op), rest, opEnd) + } + } + + // Shorthand comparison form: propvalue. + for _, sh := range shorthandOperators { + if idx := strings.Index(trimmed, sh.token); idx > 0 { + prop := strings.TrimSpace(trimmed[:idx]) + value := strings.TrimSpace(trimmed[idx+len(sh.token):]) + if prop == "" { + return api.SearchFilter{}, fmt.Errorf("filter %q is missing a property name", raw) + } + return api.SearchFilter{ + PropertyName: prop, + Operator: sh.operator, + Value: convertDateValue(prop, value), + }, nil + } + } + + return api.SearchFilter{}, fmt.Errorf("invalid filter %q: expected prop=value, prop>=value, or prop:OPERATOR:value", raw) +} + +func parseExplicitFilter(prop, operator, rest string, opEnd int) (api.SearchFilter, error) { + if prop == "" { + return api.SearchFilter{}, fmt.Errorf("filter is missing a property name") + } + + // Operators that take no value. + if opEnd < 0 { + switch operator { + case "HAS_PROPERTY", "NOT_HAS_PROPERTY": + return api.SearchFilter{PropertyName: prop, Operator: operator}, nil + default: + return api.SearchFilter{}, fmt.Errorf("operator %s requires a value (use %s:%s:value)", operator, prop, operator) + } + } + + value := rest[opEnd+1:] + + switch operator { + case "BETWEEN": + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 { + return api.SearchFilter{}, fmt.Errorf("BETWEEN requires two values (use %s:BETWEEN:low:high)", prop) + } + return api.SearchFilter{ + PropertyName: prop, + Operator: operator, + Value: convertDateValue(prop, parts[0]), + HighValue: convertDateValue(prop, parts[1]), + }, nil + case "IN", "NOT_IN": + var values []string + for _, v := range strings.Split(value, ",") { + v = strings.TrimSpace(v) + if v != "" { + values = append(values, convertDateValue(prop, v)) + } + } + if len(values) == 0 { + return api.SearchFilter{}, fmt.Errorf("%s requires at least one value (use %s:%s:a,b,c)", operator, prop, operator) + } + return api.SearchFilter{PropertyName: prop, Operator: operator, Values: values}, nil + default: + return api.SearchFilter{ + PropertyName: prop, + Operator: operator, + Value: convertDateValue(prop, value), + }, nil + } +} + +// isOperatorToken reports whether s looks like a HubSpot operator name +// (uppercase letters and underscores, at least two characters). This keeps +// the explicit-form detection from swallowing shorthand values. +func isOperatorToken(s string) bool { + if len(s) < 2 { + return false + } + for _, r := range s { + if (r < 'A' || r > 'Z') && r != '_' { + return false + } + } + return true +} + +// convertDateValue converts an ISO-8601 date/datetime to a Unix millisecond +// string when the property is a known date property. Non-date properties and +// values that are already numeric or unparseable are returned unchanged. +func convertDateValue(prop, value string) string { + value = strings.TrimSpace(value) + if value == "" || !dateProperties[prop] { + return value + } + + // Already a Unix millisecond timestamp. + if isAllDigits(value) { + return value + } + + layouts := []string{ + time.RFC3339, + "2006-01-02T15:04:05", + "2006-01-02 15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if t, err := time.Parse(layout, value); err == nil { + return fmt.Sprintf("%d", t.UTC().UnixMilli()) + } + } + + return value +} + +func isAllDigits(s string) bool { + if s == "" { + return false + } + for _, r := range s { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// ParseSort converts raw --sort flag values into HubSpot search sorts. +// +// Supported forms: +// +// prop ascending (default) +// prop:asc ascending +// prop:desc descending +func ParseSort(raw []string) ([]api.SearchSort, error) { + var sorts []api.SearchSort + + for _, r := range raw { + trimmed := strings.TrimSpace(r) + if trimmed == "" { + return nil, fmt.Errorf("empty sort") + } + + prop := trimmed + direction := "ASCENDING" + + if idx := strings.LastIndex(trimmed, ":"); idx > 0 { + prop = strings.TrimSpace(trimmed[:idx]) + dir := strings.ToLower(strings.TrimSpace(trimmed[idx+1:])) + switch dir { + case "asc", "ascending": + direction = "ASCENDING" + case "desc", "descending": + direction = "DESCENDING" + default: + return nil, fmt.Errorf("invalid sort direction %q in %q (use asc or desc)", dir, r) + } + } + + if prop == "" { + return nil, fmt.Errorf("sort %q is missing a property name", r) + } + + sorts = append(sorts, api.SearchSort{PropertyName: prop, Direction: direction}) + } + + return sorts, nil +} diff --git a/internal/cmd/shared/search_test.go b/internal/cmd/shared/search_test.go new file mode 100644 index 0000000..951de0b --- /dev/null +++ b/internal/cmd/shared/search_test.go @@ -0,0 +1,274 @@ +package shared + +import ( + "reflect" + "testing" + + "github.com/open-cli-collective/hubspot-cli/api" +) + +func TestParseFilters(t *testing.T) { + tests := []struct { + name string + input []string + want []api.SearchFilter + wantErr bool + }{ + { + name: "shorthand EQ", + input: []string{"hs_task_status=NOT_STARTED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}, + }, + }, + { + name: "shorthand NEQ", + input: []string{"hs_email_status!=BOUNCED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_email_status", Operator: "NEQ", Value: "BOUNCED"}, + }, + }, + { + name: "shorthand GTE", + input: []string{"hs_task_priority>=2"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_priority", Operator: "GTE", Value: "2"}, + }, + }, + { + name: "shorthand LTE does not split on equals", + input: []string{"hubspot_owner_id<=99"}, + want: []api.SearchFilter{ + {PropertyName: "hubspot_owner_id", Operator: "LTE", Value: "99"}, + }, + }, + { + name: "shorthand GT", + input: []string{"amount>100"}, + want: []api.SearchFilter{ + {PropertyName: "amount", Operator: "GT", Value: "100"}, + }, + }, + { + name: "shorthand LT", + input: []string{"amount<100"}, + want: []api.SearchFilter{ + {PropertyName: "amount", Operator: "LT", Value: "100"}, + }, + }, + { + name: "explicit operator with value", + input: []string{"hs_email_subject:CONTAINS_TOKEN:Dev Academy"}, + want: []api.SearchFilter{ + {PropertyName: "hs_email_subject", Operator: "CONTAINS_TOKEN", Value: "Dev Academy"}, + }, + }, + { + name: "explicit EQ operator", + input: []string{"hs_task_status:EQ:NOT_STARTED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}, + }, + }, + { + // Lowercase tokens after the first colon are treated as part of a + // shorthand value, not as an explicit operator. With no shorthand + // comparison token present this is an error, which keeps values that + // happen to contain colons (URLs, times) from being misparsed. + name: "lowercase operator-looking token is not an explicit operator", + input: []string{"hs_email_subject:contains_token:hello"}, + wantErr: true, + }, + { + name: "HAS_PROPERTY without value", + input: []string{"hubspot_owner_id:HAS_PROPERTY"}, + want: []api.SearchFilter{ + {PropertyName: "hubspot_owner_id", Operator: "HAS_PROPERTY"}, + }, + }, + { + name: "BETWEEN range", + input: []string{"hs_task_priority:BETWEEN:1:3"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_priority", Operator: "BETWEEN", Value: "1", HighValue: "3"}, + }, + }, + { + name: "IN set membership", + input: []string{"hs_task_status:IN:NOT_STARTED,IN_PROGRESS"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "IN", Values: []string{"NOT_STARTED", "IN_PROGRESS"}}, + }, + }, + { + name: "NOT_IN set membership", + input: []string{"hs_task_status:NOT_IN:COMPLETED,DEFERRED"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "NOT_IN", Values: []string{"COMPLETED", "DEFERRED"}}, + }, + }, + { + name: "ISO date converted to unix millis for date property (shorthand)", + input: []string{"hs_timestamp<=2026-03-17"}, + want: []api.SearchFilter{ + // 2026-03-17T00:00:00Z == 1773705600000 ms + {PropertyName: "hs_timestamp", Operator: "LTE", Value: "1773705600000"}, + }, + }, + { + name: "ISO datetime converted for date property", + input: []string{"hs_timestamp=2026-03-17T12:00:00Z"}, + want: []api.SearchFilter{ + {PropertyName: "hs_timestamp", Operator: "EQ", Value: "1773748800000"}, + }, + }, + { + name: "BETWEEN with ISO dates converts both bounds", + input: []string{"hs_timestamp:BETWEEN:2026-03-17:2026-03-18"}, + want: []api.SearchFilter{ + {PropertyName: "hs_timestamp", Operator: "BETWEEN", Value: "1773705600000", HighValue: "1773792000000"}, + }, + }, + { + name: "numeric value for date property is left unchanged", + input: []string{"hs_timestamp>=1773705600000"}, + want: []api.SearchFilter{ + {PropertyName: "hs_timestamp", Operator: "GTE", Value: "1773705600000"}, + }, + }, + { + name: "date-looking value on non-date property is not converted", + input: []string{"hs_email_subject=2026-03-17"}, + want: []api.SearchFilter{ + {PropertyName: "hs_email_subject", Operator: "EQ", Value: "2026-03-17"}, + }, + }, + { + name: "multiple filters", + input: []string{"hs_task_status=NOT_STARTED", "hubspot_owner_id=77999105"}, + want: []api.SearchFilter{ + {PropertyName: "hs_task_status", Operator: "EQ", Value: "NOT_STARTED"}, + {PropertyName: "hubspot_owner_id", Operator: "EQ", Value: "77999105"}, + }, + }, + { + name: "empty filter is an error", + input: []string{""}, + wantErr: true, + }, + { + name: "no operator is an error", + input: []string{"hs_task_status"}, + wantErr: true, + }, + { + name: "missing property name is an error", + input: []string{"=NOT_STARTED"}, + wantErr: true, + }, + { + name: "BETWEEN with one value is an error", + input: []string{"hs_task_priority:BETWEEN:1"}, + wantErr: true, + }, + { + name: "value operator without value is an error", + input: []string{"hs_email_subject:CONTAINS_TOKEN"}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFilters(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseFilters(%v) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("ParseFilters(%v) unexpected error: %v", tt.input, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseFilters(%v) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} + +func TestParseFiltersEmptyInput(t *testing.T) { + got, err := ParseFilters(nil) + if err != nil { + t.Fatalf("ParseFilters(nil) unexpected error: %v", err) + } + if got != nil { + t.Errorf("ParseFilters(nil) = %+v, want nil", got) + } +} + +func TestParseSort(t *testing.T) { + tests := []struct { + name string + input []string + want []api.SearchSort + wantErr bool + }{ + { + name: "ascending explicit", + input: []string{"hs_timestamp:asc"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "ASCENDING"}}, + }, + { + name: "descending explicit", + input: []string{"hs_timestamp:desc"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "DESCENDING"}}, + }, + { + name: "default direction is ascending", + input: []string{"hs_timestamp"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "ASCENDING"}}, + }, + { + name: "long form directions", + input: []string{"createdate:descending", "amount:ascending"}, + want: []api.SearchSort{ + {PropertyName: "createdate", Direction: "DESCENDING"}, + {PropertyName: "amount", Direction: "ASCENDING"}, + }, + }, + { + name: "case-insensitive direction", + input: []string{"hs_timestamp:DESC"}, + want: []api.SearchSort{{PropertyName: "hs_timestamp", Direction: "DESCENDING"}}, + }, + { + name: "invalid direction is an error", + input: []string{"hs_timestamp:sideways"}, + wantErr: true, + }, + { + name: "empty sort is an error", + input: []string{""}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseSort(tt.input) + if tt.wantErr { + if err == nil { + t.Fatalf("ParseSort(%v) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Fatalf("ParseSort(%v) unexpected error: %v", tt.input, err) + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ParseSort(%v) = %+v, want %+v", tt.input, got, tt.want) + } + }) + } +} diff --git a/internal/cmd/shared/searchcmd.go b/internal/cmd/shared/searchcmd.go new file mode 100644 index 0000000..32fd876 --- /dev/null +++ b/internal/cmd/shared/searchcmd.go @@ -0,0 +1,118 @@ +package shared + +import ( + "github.com/spf13/cobra" + + "github.com/open-cli-collective/hubspot-cli/api" + "github.com/open-cli-collective/hubspot-cli/internal/cmd/root" +) + +// SearchCmdConfig describes the object-specific pieces of a `search` subcommand. +// Everything else (flag wiring, filter/sort parsing, the search request, +// pagination, and rendering) is shared across object types. +type SearchCmdConfig struct { + // ObjectType is the HubSpot CRM object type to search. + ObjectType api.ObjectType + // Noun is the singular human-readable object name (e.g. "task", "email") + // used in the empty-result and result-count messages. + Noun string + // Short and Long are the cobra command descriptions. + Short string + Long string + // Example is the cobra command example text. + Example string + // DefaultProperties are fetched when the user does not pass --properties. + DefaultProperties []string + // Headers are the table column headers. + Headers []string + // Row maps a result object to a table row. It is called once per result and + // must return values aligned with Headers. + Row func(obj api.CRMObject) []string +} + +// NewSearchCmd builds a `search` subcommand for a CRM object type. The +// object-specific behavior is supplied via cfg; the command wiring, filter/sort +// parsing (via ParseFilters/ParseSort), request building, pagination, and +// rendering are shared across object types. +func NewSearchCmd(opts *root.Options, cfg SearchCmdConfig) *cobra.Command { + var filterArgs []string + var sortArgs []string + var limit int + var after string + var properties []string + + cmd := &cobra.Command{ + Use: "search", + Short: cfg.Short, + Long: cfg.Long, + Example: cfg.Example, + RunE: func(cmd *cobra.Command, args []string) error { + v := opts.View() + + client, err := opts.APIClient() + if err != nil { + return err + } + + if len(properties) == 0 { + properties = cfg.DefaultProperties + } + + filters, err := ParseFilters(filterArgs) + if err != nil { + return err + } + + sorts, err := ParseSort(sortArgs) + if err != nil { + return err + } + + req := api.SearchRequest{ + Properties: properties, + Limit: limit, + After: after, + Sorts: sorts, + } + if len(filters) > 0 { + req.FilterGroups = []api.SearchFilterGroup{ + {Filters: filters}, + } + } + + result, err := client.SearchObjects(cfg.ObjectType, req) + if err != nil { + return err + } + + if len(result.Results) == 0 { + v.Info("No %ss found matching criteria", cfg.Noun) + return nil + } + + rows := make([][]string, 0, len(result.Results)) + for _, obj := range result.Results { + rows = append(rows, cfg.Row(obj)) + } + + v.Info("Found %d %s(s)", len(result.Results), cfg.Noun) + if err := v.Render(cfg.Headers, rows, result); err != nil { + return err + } + + if result.Paging != nil && result.Paging.Next != nil { + v.Info("\nMore results available. Use --after %s to get the next page.", result.Paging.Next.After) + } + + return nil + }, + } + + cmd.Flags().StringArrayVar(&filterArgs, "filter", nil, "Filter condition (e.g. prop=value, prop>=value, prop:OPERATOR:value); repeatable") + cmd.Flags().StringArrayVar(&sortArgs, "sort", nil, "Sort condition (e.g. hs_timestamp:asc or hs_timestamp:desc); repeatable") + cmd.Flags().IntVar(&limit, "limit", 10, "Maximum number of results") + cmd.Flags().StringVar(&after, "after", "", "Pagination cursor for the next page") + cmd.Flags().StringSliceVar(&properties, "properties", nil, "Properties to include (comma-separated)") + + return cmd +} diff --git a/internal/cmd/tasks/tasks.go b/internal/cmd/tasks/tasks.go index 65f2e4f..735cfb0 100644 --- a/internal/cmd/tasks/tasks.go +++ b/internal/cmd/tasks/tasks.go @@ -8,6 +8,7 @@ import ( "github.com/open-cli-collective/hubspot-cli/api" "github.com/open-cli-collective/hubspot-cli/internal/cmd/root" + "github.com/open-cli-collective/hubspot-cli/internal/cmd/shared" ) // DefaultProperties are the default properties to fetch for tasks @@ -26,6 +27,7 @@ func Register(parent *cobra.Command, opts *root.Options) { cmd.AddCommand(newCreateCmd(opts)) cmd.AddCommand(newUpdateCmd(opts)) cmd.AddCommand(newDeleteCmd(opts)) + cmd.AddCommand(newSearchCmd(opts)) parent.AddCommand(cmd) } @@ -361,6 +363,34 @@ func newDeleteCmd(opts *root.Options) *cobra.Command { return cmd } +func newSearchCmd(opts *root.Options) *cobra.Command { + return shared.NewSearchCmd(opts, shared.SearchCmdConfig{ + ObjectType: api.ObjectTypeTasks, + Noun: "task", + Short: "Search tasks", + Long: "Search tasks using the HubSpot CRM Search API with filtering and sorting.", + Example: ` # Open tasks for a specific owner, oldest first + hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hubspot_owner_id=77999105" --sort "hs_timestamp:asc" + + # Overdue tasks (not started, due on or before a date) + hspt tasks search --filter "hs_task_status=NOT_STARTED" --filter "hs_timestamp<=2026-03-17" --sort "hs_timestamp:asc" + + # Tasks whose subject contains a phrase + hspt tasks search --filter "hs_task_subject:CONTAINS_TOKEN:renewal" --limit 25`, + DefaultProperties: DefaultProperties, + Headers: []string{"ID", "SUBJECT", "STATUS", "PRIORITY", "TIMESTAMP"}, + Row: func(obj api.CRMObject) []string { + return []string{ + obj.ID, + truncate(obj.GetProperty("hs_task_subject"), 40), + obj.GetProperty("hs_task_status"), + obj.GetProperty("hs_task_priority"), + obj.GetProperty("hs_timestamp"), + } + }, + }) +} + func truncate(s string, maxLen int) string { if len(s) <= maxLen { return s