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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 69 additions & 14 deletions cmd/contacts.go
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package cmd

import (
"encoding/json"
"fmt"
"math"
"regexp"
"strconv"
"strings"
"unicode"

"github.com/loops-so/cli/internal/api"
"github.com/loops-so/cli/internal/cmdutil"
Expand Down Expand Up @@ -121,26 +126,76 @@ var contactsFindCmd = &cobra.Command{
return nil
}

w := newTableWriter(cmd.OutOrStdout())
fmt.Fprintln(w, "USER ID\tEMAIL\tFIRST NAME\tLAST NAME\tSUBSCRIBED\tSOURCE\tUSER GROUP\tOPT-IN STATUS")
for _, c := range contacts {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
deref(c.UserID),
c.Email,
deref(c.FirstName),
deref(c.LastName),
strconv.FormatBool(c.Subscribed),
c.Source,
c.UserGroup,
deref(c.OptInStatus),
)
for i, c := range contacts {
props := c.Properties()
keys := c.PropertyKeys()
labels := make([]string, len(keys))
maxLabelWidth := 0
for i, key := range keys {
label := addSpacesToCamelCase(key, false)
labels[i] = label
if len(label) > maxLabelWidth {
maxLabelWidth = len(label)
}
}
for idx, key := range keys {
padding := maxLabelWidth - len(labels[idx]) + 1
fmt.Fprintf(cmd.OutOrStdout(), "%s:%*s%s\n", labels[idx], padding, "", formatContactTableValue(props[key]))
}
if i < len(contacts)-1 {
fmt.Fprintln(cmd.OutOrStdout())
}
}
w.Flush()

return nil
},
}

var camelCaseMatcher = regexp.MustCompile("([A-Z])")

func formatContactTableValue(value any) string {
switch v := value.(type) {
case nil:
return "null"
case string:
return v
case bool:
return strconv.FormatBool(v)
case float64:
if math.Trunc(v) == v {
return strconv.FormatInt(int64(v), 10)
}
return strconv.FormatFloat(v, 'f', -1, 64)
default:
b, err := json.Marshal(v)
if err != nil {
return fmt.Sprintf("%v", v)
}
return string(b)
}
}

func addSpacesToCamelCase(value string, lowerCase bool) string {
withSpaces := strings.TrimSpace(camelCaseMatcher.ReplaceAllString(value, " $1"))
if withSpaces == "" {
return withSpaces
}

runes := []rune(withSpaces)
runes[0] = unicode.ToUpper(runes[0])
result := string(runes)

if !lowerCase {
return result
}

parts := strings.Fields(result)
for i := range parts {
parts[i] = strings.ToLower(parts[i])
}
return strings.Join(parts, " ")
}

// create

type contactCreateResult struct {
Expand Down
83 changes: 83 additions & 0 deletions cmd/contacts_find_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cmd

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

"github.com/loops-so/cli/internal/api"
Expand Down Expand Up @@ -68,3 +70,84 @@ func TestRunContactsFind(t *testing.T) {
}
})
}

func TestContactPropertyKeys(t *testing.T) {
var contacts []api.Contact
body := `[{"id":"cnt_abc123","email":"bob@example.com","subscribed":true,"mailingLists":{},"thisKey":"thisValue","plan":"pro","score":42,"isActive":false}]`
if err := json.Unmarshal([]byte(body), &contacts); err != nil {
t.Fatalf("failed to decode contact test fixture: %v", err)
}

got := contacts[0].PropertyKeys()
want := []string{
"id",
"email",
"subscribed",
"mailingLists",
"thisKey",
"plan",
"score",
"isActive",
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("PropertyKeys() = %v, want %v", got, want)
}

props := contacts[0].Properties()
if props["thisKey"] != "thisValue" {
t.Fatalf("thisKey = %v, want thisValue", props["thisKey"])
}
if props["plan"] != "pro" {
t.Fatalf("plan = %v, want pro", props["plan"])
}
if props["score"] != float64(42) {
t.Fatalf("score = %v, want 42", props["score"])
}
if props["isActive"] != false {
t.Fatalf("isActive = %v, want false", props["isActive"])
}
}

func TestFormatContactTableValue(t *testing.T) {
tests := []struct {
name string
value any
want string
}{
{name: "nil", value: nil, want: "null"},
{name: "string", value: "abc", want: "abc"},
{name: "bool", value: true, want: "true"},
{name: "whole number", value: float64(42), want: "42"},
{name: "decimal", value: float64(42.5), want: "42.5"},
{name: "object", value: map[string]any{"a": "b"}, want: `{"a":"b"}`},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatContactTableValue(tt.value); got != tt.want {
t.Fatalf("formatContactTableValue(%v) = %q, want %q", tt.value, got, tt.want)
}
})
}
}

func TestAddSpacesToCamelCase(t *testing.T) {
tests := []struct {
name string
input string
lowerCase bool
want string
}{
{name: "camel case", input: "firstName", want: "First Name"},
{name: "single word", input: "email", want: "Email"},
{name: "lowercase words", input: "optInStatus", lowerCase: true, want: "opt in status"},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := addSpacesToCamelCase(tt.input, tt.lowerCase); got != tt.want {
t.Fatalf("addSpacesToCamelCase(%q, %v) = %q, want %q", tt.input, tt.lowerCase, got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/loops-so/cli

go 1.26.1
go 1.26.2

require (
github.com/spf13/cobra v1.10.2
Expand Down
144 changes: 144 additions & 0 deletions internal/api/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,150 @@ type Contact struct {
UserID *string `json:"userId"`
MailingLists map[string]bool `json:"mailingLists"`
OptInStatus *string `json:"optInStatus"`
properties map[string]any
propertyKeys []string
}

func (c *Contact) UnmarshalJSON(data []byte) error {
type contactAlias Contact
var alias contactAlias
if err := json.Unmarshal(data, &alias); err != nil {
return err
}

var props map[string]any
if err := json.Unmarshal(data, &props); err != nil {
return err
}
keys, err := jsonObjectKeysInOrder(data)
if err != nil {
return err
}

*c = Contact(alias)
c.properties = props
c.propertyKeys = keys
return nil
}

func (c Contact) MarshalJSON() ([]byte, error) {
if len(c.properties) > 0 && len(c.propertyKeys) > 0 {
var buf bytes.Buffer
buf.WriteByte('{')
for i, key := range c.propertyKeys {
if i > 0 {
buf.WriteByte(',')
}
keyJSON, err := json.Marshal(key)
if err != nil {
return nil, err
}
valJSON, err := json.Marshal(c.properties[key])
if err != nil {
return nil, err
}
buf.Write(keyJSON)
buf.WriteByte(':')
buf.Write(valJSON)
}
buf.WriteByte('}')
return buf.Bytes(), nil
}

if len(c.properties) > 0 {
return json.Marshal(c.properties)
}

props := c.Properties()
return json.Marshal(props)
}

func (c Contact) Properties() map[string]any {
if len(c.properties) > 0 {
clone := make(map[string]any, len(c.properties))
for k, v := range c.properties {
clone[k] = v
}
return clone
}

props := map[string]any{
"id": c.ID,
"email": c.Email,
"source": c.Source,
"subscribed": c.Subscribed,
"userGroup": c.UserGroup,
"mailingLists": c.MailingLists,
}
if c.FirstName != nil {
props["firstName"] = *c.FirstName
} else {
props["firstName"] = nil
}
if c.LastName != nil {
props["lastName"] = *c.LastName
} else {
props["lastName"] = nil
}
if c.UserID != nil {
props["userId"] = *c.UserID
} else {
props["userId"] = nil
}
if c.OptInStatus != nil {
props["optInStatus"] = *c.OptInStatus
} else {
props["optInStatus"] = nil
}

return props
}

func (c Contact) PropertyKeys() []string {
if len(c.propertyKeys) > 0 {
keys := make([]string, len(c.propertyKeys))
copy(keys, c.propertyKeys)
return keys
}
keys := make([]string, 0, len(c.properties))
for key := range c.properties {
keys = append(keys, key)
}
return keys
}

func jsonObjectKeysInOrder(data []byte) ([]string, error) {
dec := json.NewDecoder(bytes.NewReader(data))
tok, err := dec.Token()
if err != nil {
return nil, err
}
delim, ok := tok.(json.Delim)
if !ok || delim != '{' {
return nil, fmt.Errorf("expected JSON object")
}

keys := make([]string, 0)
for dec.More() {
keyTok, err := dec.Token()
if err != nil {
return nil, err
}
key, ok := keyTok.(string)
if !ok {
return nil, fmt.Errorf("expected JSON object key")
}
keys = append(keys, key)

var ignored json.RawMessage
if err := dec.Decode(&ignored); err != nil {
return nil, err
}
}
if _, err := dec.Token(); err != nil {
return nil, err
}
return keys, nil
}

type CreateContactRequest struct {
Expand Down
Loading