diff --git a/cmd/contacts.go b/cmd/contacts.go index 73c686f..5efe70a 100644 --- a/cmd/contacts.go +++ b/cmd/contacts.go @@ -2,8 +2,11 @@ package cmd import ( "context" + "encoding/base64" + "encoding/json" "fmt" "os" + "strings" "github.com/omriariav/workspace-cli/internal/client" "github.com/omriariav/workspace-cli/internal/printer" @@ -55,6 +58,72 @@ var contactsDeleteCmd = &cobra.Command{ RunE: runContactsDelete, } +var contactsUpdateCmd = &cobra.Command{ + Use: "update ", + Short: "Update a contact", + Long: "Updates an existing contact by resource name. Specify fields to update via flags.", + Args: cobra.ExactArgs(1), + RunE: runContactsUpdate, +} + +var contactsBatchCreateCmd = &cobra.Command{ + Use: "batch-create", + Short: "Batch create contacts", + Long: "Creates multiple contacts from a JSON file. The file should contain an array of contact objects.", + RunE: runContactsBatchCreate, +} + +var contactsBatchUpdateCmd = &cobra.Command{ + Use: "batch-update", + Short: "Batch update contacts", + Long: "Updates multiple contacts from a JSON file. The file should contain a map of resource names to contact objects.", + RunE: runContactsBatchUpdate, +} + +var contactsBatchDeleteCmd = &cobra.Command{ + Use: "batch-delete", + Short: "Batch delete contacts", + Long: "Deletes multiple contacts by resource names.", + RunE: runContactsBatchDelete, +} + +var contactsDirectoryCmd = &cobra.Command{ + Use: "directory", + Short: "List directory people", + Long: "Lists people in the organization's directory. Requires directory.readonly scope.", + RunE: runContactsDirectory, +} + +var contactsDirectorySearchCmd = &cobra.Command{ + Use: "directory-search", + Short: "Search directory people", + Long: "Searches people in the organization's directory by query. Requires directory.readonly scope.", + RunE: runContactsDirectorySearch, +} + +var contactsPhotoCmd = &cobra.Command{ + Use: "photo ", + Short: "Update contact photo", + Long: "Updates a contact's photo from an image file (JPEG or PNG).", + Args: cobra.ExactArgs(1), + RunE: runContactsPhoto, +} + +var contactsDeletePhotoCmd = &cobra.Command{ + Use: "delete-photo ", + Short: "Delete contact photo", + Long: "Deletes a contact's photo by resource name.", + Args: cobra.ExactArgs(1), + RunE: runContactsDeletePhoto, +} + +var contactsResolveCmd = &cobra.Command{ + Use: "resolve", + Short: "Resolve multiple contacts", + Long: "Gets multiple contacts by their resource names in a single batch request.", + RunE: runContactsResolve, +} + func init() { rootCmd.AddCommand(contactsCmd) contactsCmd.AddCommand(contactsListCmd) @@ -62,6 +131,15 @@ func init() { contactsCmd.AddCommand(contactsGetCmd) contactsCmd.AddCommand(contactsCreateCmd) contactsCmd.AddCommand(contactsDeleteCmd) + contactsCmd.AddCommand(contactsUpdateCmd) + contactsCmd.AddCommand(contactsBatchCreateCmd) + contactsCmd.AddCommand(contactsBatchUpdateCmd) + contactsCmd.AddCommand(contactsBatchDeleteCmd) + contactsCmd.AddCommand(contactsDirectoryCmd) + contactsCmd.AddCommand(contactsDirectorySearchCmd) + contactsCmd.AddCommand(contactsPhotoCmd) + contactsCmd.AddCommand(contactsDeletePhotoCmd) + contactsCmd.AddCommand(contactsResolveCmd) // List flags contactsListCmd.Flags().Int64("max", 50, "Maximum number of contacts to return") @@ -71,6 +149,42 @@ func init() { contactsCreateCmd.Flags().String("email", "", "Contact email address") contactsCreateCmd.Flags().String("phone", "", "Contact phone number") contactsCreateCmd.MarkFlagRequired("name") + + // Update flags + contactsUpdateCmd.Flags().String("name", "", "Updated contact name") + contactsUpdateCmd.Flags().String("email", "", "Updated email address") + contactsUpdateCmd.Flags().String("phone", "", "Updated phone number") + contactsUpdateCmd.Flags().String("organization", "", "Updated organization name") + contactsUpdateCmd.Flags().String("title", "", "Updated job title") + contactsUpdateCmd.Flags().String("etag", "", "Etag for concurrency control (from get command)") + + // Batch create flags + contactsBatchCreateCmd.Flags().String("file", "", "Path to JSON file with contacts array (required)") + contactsBatchCreateCmd.MarkFlagRequired("file") + + // Batch update flags + contactsBatchUpdateCmd.Flags().String("file", "", "Path to JSON file with contacts map (required)") + contactsBatchUpdateCmd.MarkFlagRequired("file") + + // Batch delete flags + contactsBatchDeleteCmd.Flags().StringArray("resources", nil, "Resource names to delete (repeatable, e.g. --resources people/c1 --resources people/c2)") + contactsBatchDeleteCmd.MarkFlagRequired("resources") + + // Directory flags + contactsDirectoryCmd.Flags().Int64("max", 50, "Maximum number of directory people to return") + + // Directory search flags + contactsDirectorySearchCmd.Flags().String("query", "", "Search query (required)") + contactsDirectorySearchCmd.Flags().Int64("max", 50, "Maximum number of results to return") + contactsDirectorySearchCmd.MarkFlagRequired("query") + + // Photo flags + contactsPhotoCmd.Flags().String("file", "", "Path to image file, JPEG or PNG (required)") + contactsPhotoCmd.MarkFlagRequired("file") + + // Resolve flags + contactsResolveCmd.Flags().StringArray("ids", nil, "Resource names to resolve (repeatable, e.g. --ids people/c1 --ids people/c2)") + contactsResolveCmd.MarkFlagRequired("ids") } const personFields = "names,emailAddresses,phoneNumbers,organizations" @@ -272,12 +386,468 @@ func runContactsDelete(cmd *cobra.Command, args []string) error { }) } +func runContactsUpdate(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + resourceName := args[0] + + name, _ := cmd.Flags().GetString("name") + email, _ := cmd.Flags().GetString("email") + phone, _ := cmd.Flags().GetString("phone") + organization, _ := cmd.Flags().GetString("organization") + title, _ := cmd.Flags().GetString("title") + etag, _ := cmd.Flags().GetString("etag") + + // Build the person object and update mask from provided flags + person := &people.Person{} + var updateFields []string + + if etag != "" { + person.Etag = etag + } + + if name != "" { + person.Names = []*people.Name{{UnstructuredName: name}} + updateFields = append(updateFields, "names") + } + + if email != "" { + person.EmailAddresses = []*people.EmailAddress{{Value: email}} + updateFields = append(updateFields, "emailAddresses") + } + + if phone != "" { + person.PhoneNumbers = []*people.PhoneNumber{{Value: phone}} + updateFields = append(updateFields, "phoneNumbers") + } + + if organization != "" || title != "" { + org := &people.Organization{} + if organization != "" { + org.Name = organization + } + if title != "" { + org.Title = title + } + person.Organizations = []*people.Organization{org} + updateFields = append(updateFields, "organizations") + } + + if len(updateFields) == 0 { + return p.PrintError(fmt.Errorf("at least one field to update must be specified (--name, --email, --phone, --organization, --title)")) + } + + updateMask := strings.Join(updateFields, ",") + + updated, err := svc.People.UpdateContact(resourceName, person). + UpdatePersonFields(updateMask). + PersonFields(personFields). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update contact: %w", err)) + } + + result := formatPerson(updated) + result["status"] = "updated" + return p.Print(result) +} + +func runContactsBatchCreate(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + filePath, _ := cmd.Flags().GetString("file") + + data, err := os.ReadFile(filePath) + if err != nil { + return p.PrintError(fmt.Errorf("failed to read file %s: %w", filePath, err)) + } + + var contacts []*people.Person + if err := json.Unmarshal(data, &contacts); err != nil { + return p.PrintError(fmt.Errorf("failed to parse JSON file: %w", err)) + } + + if len(contacts) == 0 { + return p.PrintError(fmt.Errorf("no contacts found in file")) + } + + contactsToCreate := make([]*people.ContactToCreate, len(contacts)) + for i, c := range contacts { + contactsToCreate[i] = &people.ContactToCreate{ + ContactPerson: c, + } + } + + req := &people.BatchCreateContactsRequest{ + Contacts: contactsToCreate, + ReadMask: personFields, + } + + resp, err := svc.People.BatchCreateContacts(req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch create contacts: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.CreatedPeople)) + for _, pr := range resp.CreatedPeople { + if pr.Person != nil { + results = append(results, formatPerson(pr.Person)) + } + } + + return p.Print(map[string]interface{}{ + "status": "created", + "contacts": results, + "count": len(results), + }) +} + +func runContactsBatchUpdate(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + filePath, _ := cmd.Flags().GetString("file") + + data, err := os.ReadFile(filePath) + if err != nil { + return p.PrintError(fmt.Errorf("failed to read file %s: %w", filePath, err)) + } + + // The file format: {"contacts": {"people/c123": {...}, ...}, "update_mask": "names,emailAddresses"} + var fileData struct { + Contacts map[string]people.Person `json:"contacts"` + UpdateMask string `json:"update_mask"` + } + if err := json.Unmarshal(data, &fileData); err != nil { + return p.PrintError(fmt.Errorf("failed to parse JSON file: %w", err)) + } + + if len(fileData.Contacts) == 0 { + return p.PrintError(fmt.Errorf("no contacts found in file")) + } + + if fileData.UpdateMask == "" { + return p.PrintError(fmt.Errorf("update_mask is required in the JSON file")) + } + + req := &people.BatchUpdateContactsRequest{ + Contacts: fileData.Contacts, + UpdateMask: fileData.UpdateMask, + ReadMask: personFields, + } + + resp, err := svc.People.BatchUpdateContacts(req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch update contacts: %w", err)) + } + + results := make(map[string]interface{}, len(resp.UpdateResult)) + for resourceName, pr := range resp.UpdateResult { + if pr.Person != nil { + results[resourceName] = formatPerson(pr.Person) + } + } + + return p.Print(map[string]interface{}{ + "status": "updated", + "results": results, + "count": len(results), + }) +} + +func runContactsBatchDelete(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + resources, _ := cmd.Flags().GetStringArray("resources") + + req := &people.BatchDeleteContactsRequest{ + ResourceNames: resources, + } + + _, err = svc.People.BatchDeleteContacts(req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to batch delete contacts: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "deleted", + "resource_names": resources, + "count": len(resources), + }) +} + +func runContactsDirectory(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + maxResults, _ := cmd.Flags().GetInt64("max") + + var allPeople []*people.Person + pageToken := "" + pageSize := maxResults + if pageSize > 1000 { + pageSize = 1000 + } + + for { + call := svc.People.ListDirectoryPeople(). + ReadMask(personFields). + Sources("DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE"). + PageSize(pageSize) + if pageToken != "" { + call = call.PageToken(pageToken) + } + + resp, err := call.Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to list directory people: %w", err)) + } + + allPeople = append(allPeople, resp.People...) + + if resp.NextPageToken == "" || int64(len(allPeople)) >= maxResults { + break + } + pageToken = resp.NextPageToken + } + + // Trim to max + if int64(len(allPeople)) > maxResults { + allPeople = allPeople[:maxResults] + } + + results := make([]map[string]interface{}, 0, len(allPeople)) + for _, person := range allPeople { + results = append(results, formatPerson(person)) + } + + return p.Print(map[string]interface{}{ + "contacts": results, + "count": len(results), + }) +} + +func runContactsDirectorySearch(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + query, _ := cmd.Flags().GetString("query") + maxResults, _ := cmd.Flags().GetInt64("max") + + pageSize := maxResults + if pageSize > 500 { + pageSize = 500 + } + + resp, err := svc.People.SearchDirectoryPeople(). + Query(query). + ReadMask(personFields). + Sources("DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE"). + PageSize(pageSize). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to search directory people: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.People)) + for _, person := range resp.People { + results = append(results, formatPerson(person)) + } + + return p.Print(map[string]interface{}{ + "contacts": results, + "count": len(results), + "query": query, + }) +} + +func runContactsPhoto(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + resourceName := args[0] + filePath, _ := cmd.Flags().GetString("file") + + photoData, err := os.ReadFile(filePath) + if err != nil { + return p.PrintError(fmt.Errorf("failed to read photo file %s: %w", filePath, err)) + } + + encodedPhoto := base64.StdEncoding.EncodeToString(photoData) + + req := &people.UpdateContactPhotoRequest{ + PhotoBytes: encodedPhoto, + PersonFields: personFields, + } + + resp, err := svc.People.UpdateContactPhoto(resourceName, req).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to update contact photo: %w", err)) + } + + result := map[string]interface{}{ + "status": "photo_updated", + "resource_name": resourceName, + } + if resp.Person != nil { + result = formatPerson(resp.Person) + result["status"] = "photo_updated" + } + + return p.Print(result) +} + +func runContactsDeletePhoto(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + resourceName := args[0] + + _, err = svc.People.DeleteContactPhoto(resourceName).Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to delete contact photo: %w", err)) + } + + return p.Print(map[string]interface{}{ + "status": "photo_deleted", + "resource_name": resourceName, + }) +} + +func runContactsResolve(cmd *cobra.Command, args []string) error { + p := printer.New(os.Stdout, GetFormat()) + ctx := context.Background() + + factory, err := client.NewFactory(ctx) + if err != nil { + return p.PrintError(err) + } + + svc, err := factory.People() + if err != nil { + return p.PrintError(err) + } + + ids, _ := cmd.Flags().GetStringArray("ids") + + resp, err := svc.People.GetBatchGet(). + ResourceNames(ids...). + PersonFields(personFields). + Do() + if err != nil { + return p.PrintError(fmt.Errorf("failed to resolve contacts: %w", err)) + } + + results := make([]map[string]interface{}, 0, len(resp.Responses)) + var notFound []string + for _, pr := range resp.Responses { + if pr.Person != nil { + results = append(results, formatPerson(pr.Person)) + } else { + notFound = append(notFound, pr.RequestedResourceName) + } + } + + out := map[string]interface{}{"contacts": results, "count": len(results)} + if len(notFound) > 0 { + out["not_found"] = notFound + } + return p.Print(out) +} + // formatPerson converts a People API Person into a display map. func formatPerson(person *people.Person) map[string]interface{} { result := map[string]interface{}{ "resource_name": person.ResourceName, } + if person.Etag != "" { + result["etag"] = person.Etag + } + if len(person.Names) > 0 { result["name"] = person.Names[0].DisplayName } diff --git a/cmd/contacts_test.go b/cmd/contacts_test.go index 5ef6fbd..d311979 100644 --- a/cmd/contacts_test.go +++ b/cmd/contacts_test.go @@ -22,6 +22,15 @@ func TestContactsCommands(t *testing.T) { {"get", "get "}, {"create", "create"}, {"delete", "delete "}, + {"update", "update "}, + {"batch-create", "batch-create"}, + {"batch-update", "batch-update"}, + {"batch-delete", "batch-delete"}, + {"directory", "directory"}, + {"directory-search", "directory-search"}, + {"photo", "photo "}, + {"delete-photo", "delete-photo "}, + {"resolve", "resolve"}, } for _, tt := range tests { @@ -64,6 +73,108 @@ func TestContactsCreateCommand_Flags(t *testing.T) { } } +// TestContactsUpdateCommand_Flags tests update command flags +func TestContactsUpdateCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "update") + if cmd == nil { + t.Fatal("contacts update command not found") + } + + flags := []string{"name", "email", "phone", "organization", "title", "etag"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +// TestContactsBatchCreateCommand_Flags tests batch-create command flags +func TestContactsBatchCreateCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "batch-create") + if cmd == nil { + t.Fatal("contacts batch-create command not found") + } + + if cmd.Flags().Lookup("file") == nil { + t.Error("expected --file flag") + } +} + +// TestContactsBatchUpdateCommand_Flags tests batch-update command flags +func TestContactsBatchUpdateCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "batch-update") + if cmd == nil { + t.Fatal("contacts batch-update command not found") + } + + if cmd.Flags().Lookup("file") == nil { + t.Error("expected --file flag") + } +} + +// TestContactsBatchDeleteCommand_Flags tests batch-delete command flags +func TestContactsBatchDeleteCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "batch-delete") + if cmd == nil { + t.Fatal("contacts batch-delete command not found") + } + + if cmd.Flags().Lookup("resources") == nil { + t.Error("expected --resources flag") + } +} + +// TestContactsDirectoryCommand_Flags tests directory command flags +func TestContactsDirectoryCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "directory") + if cmd == nil { + t.Fatal("contacts directory command not found") + } + + if cmd.Flags().Lookup("max") == nil { + t.Error("expected --max flag") + } +} + +// TestContactsDirectorySearchCommand_Flags tests directory-search command flags +func TestContactsDirectorySearchCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "directory-search") + if cmd == nil { + t.Fatal("contacts directory-search command not found") + } + + flags := []string{"query", "max"} + for _, flag := range flags { + if cmd.Flags().Lookup(flag) == nil { + t.Errorf("expected --%s flag", flag) + } + } +} + +// TestContactsPhotoCommand_Flags tests photo command flags +func TestContactsPhotoCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "photo") + if cmd == nil { + t.Fatal("contacts photo command not found") + } + + if cmd.Flags().Lookup("file") == nil { + t.Error("expected --file flag") + } +} + +// TestContactsResolveCommand_Flags tests resolve command flags +func TestContactsResolveCommand_Flags(t *testing.T) { + cmd := findSubcommand(contactsCmd, "resolve") + if cmd == nil { + t.Fatal("contacts resolve command not found") + } + + if cmd.Flags().Lookup("ids") == nil { + t.Error("expected --ids flag") + } +} + // mockPeopleServer creates a test server that mocks People API responses func mockPeopleServer(t *testing.T, handlers map[string]func(w http.ResponseWriter, r *http.Request)) *httptest.Server { return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -300,10 +411,456 @@ func TestContactsDelete_Success(t *testing.T) { } } +// TestContactsUpdate_Success tests updating a contact +func TestContactsUpdate_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people/c123:updateContact": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("expected PATCH, got %s", r.Method) + } + + updateMask := r.URL.Query().Get("updatePersonFields") + if updateMask == "" { + t.Error("expected updatePersonFields parameter") + } + + json.NewEncoder(w).Encode(&people.Person{ + ResourceName: "people/c123", + Etag: "etag2", + Names: []*people.Name{{DisplayName: "Jane Doe"}}, + EmailAddresses: []*people.EmailAddress{ + {Value: "jane@example.com"}, + }, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + person := &people.Person{ + Etag: "etag1", + Names: []*people.Name{{UnstructuredName: "Jane Doe"}}, + EmailAddresses: []*people.EmailAddress{ + {Value: "jane@example.com"}, + }, + } + + updated, err := svc.People.UpdateContact("people/c123", person). + UpdatePersonFields("names,emailAddresses"). + PersonFields(personFields). + Do() + if err != nil { + t.Fatalf("failed to update contact: %v", err) + } + + if updated.ResourceName != "people/c123" { + t.Errorf("expected resource name 'people/c123', got '%s'", updated.ResourceName) + } + if updated.Names[0].DisplayName != "Jane Doe" { + t.Errorf("expected 'Jane Doe', got '%s'", updated.Names[0].DisplayName) + } +} + +// TestContactsBatchCreate_Success tests batch creating contacts +func TestContactsBatchCreate_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people:batchCreateContacts": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + + json.NewEncoder(w).Encode(&people.BatchCreateContactsResponse{ + CreatedPeople: []*people.PersonResponse{ + { + Person: &people.Person{ + ResourceName: "people/c100", + Names: []*people.Name{{DisplayName: "Contact One"}}, + }, + }, + { + Person: &people.Person{ + ResourceName: "people/c101", + Names: []*people.Name{{DisplayName: "Contact Two"}}, + }, + }, + }, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + req := &people.BatchCreateContactsRequest{ + Contacts: []*people.ContactToCreate{ + {ContactPerson: &people.Person{Names: []*people.Name{{UnstructuredName: "Contact One"}}}}, + {ContactPerson: &people.Person{Names: []*people.Name{{UnstructuredName: "Contact Two"}}}}, + }, + ReadMask: personFields, + } + + resp, err := svc.People.BatchCreateContacts(req).Do() + if err != nil { + t.Fatalf("failed to batch create contacts: %v", err) + } + + if len(resp.CreatedPeople) != 2 { + t.Errorf("expected 2 created people, got %d", len(resp.CreatedPeople)) + } + if resp.CreatedPeople[0].Person.ResourceName != "people/c100" { + t.Errorf("expected 'people/c100', got '%s'", resp.CreatedPeople[0].Person.ResourceName) + } +} + +// TestContactsBatchUpdate_Success tests batch updating contacts +func TestContactsBatchUpdate_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people:batchUpdateContacts": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + + json.NewEncoder(w).Encode(&people.BatchUpdateContactsResponse{ + UpdateResult: map[string]people.PersonResponse{ + "people/c100": { + Person: &people.Person{ + ResourceName: "people/c100", + Names: []*people.Name{{DisplayName: "Updated One"}}, + }, + }, + }, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + req := &people.BatchUpdateContactsRequest{ + Contacts: map[string]people.Person{ + "people/c100": { + Etag: "etag1", + Names: []*people.Name{{UnstructuredName: "Updated One"}}, + }, + }, + UpdateMask: "names", + ReadMask: personFields, + } + + resp, err := svc.People.BatchUpdateContacts(req).Do() + if err != nil { + t.Fatalf("failed to batch update contacts: %v", err) + } + + if len(resp.UpdateResult) != 1 { + t.Errorf("expected 1 update result, got %d", len(resp.UpdateResult)) + } + result, ok := resp.UpdateResult["people/c100"] + if !ok { + t.Fatal("expected people/c100 in update results") + } + if result.Person.Names[0].DisplayName != "Updated One" { + t.Errorf("expected 'Updated One', got '%s'", result.Person.Names[0].DisplayName) + } +} + +// TestContactsBatchDelete_Success tests batch deleting contacts +func TestContactsBatchDelete_Success(t *testing.T) { + deleteCalled := false + + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people:batchDeleteContacts": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("expected POST, got %s", r.Method) + } + deleteCalled = true + json.NewEncoder(w).Encode(map[string]interface{}{}) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + req := &people.BatchDeleteContactsRequest{ + ResourceNames: []string{"people/c100", "people/c101"}, + } + + _, err = svc.People.BatchDeleteContacts(req).Do() + if err != nil { + t.Fatalf("failed to batch delete contacts: %v", err) + } + + if !deleteCalled { + t.Error("batch delete endpoint was not called") + } +} + +// TestContactsDirectory_Success tests listing directory people +func TestContactsDirectory_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people:listDirectoryPeople": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + + json.NewEncoder(w).Encode(&people.ListDirectoryPeopleResponse{ + People: []*people.Person{ + { + ResourceName: "people/d1", + Names: []*people.Name{{DisplayName: "Directory User"}}, + EmailAddresses: []*people.EmailAddress{ + {Value: "user@company.com"}, + }, + }, + }, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + resp, err := svc.People.ListDirectoryPeople(). + ReadMask(personFields). + Sources("DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE"). + PageSize(50). + Do() + if err != nil { + t.Fatalf("failed to list directory people: %v", err) + } + + if len(resp.People) != 1 { + t.Errorf("expected 1 person, got %d", len(resp.People)) + } + if resp.People[0].Names[0].DisplayName != "Directory User" { + t.Errorf("expected 'Directory User', got '%s'", resp.People[0].Names[0].DisplayName) + } +} + +// TestContactsDirectorySearch_Success tests searching directory people +func TestContactsDirectorySearch_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people:searchDirectoryPeople": func(w http.ResponseWriter, r *http.Request) { + query := r.URL.Query().Get("query") + if query != "John" { + t.Errorf("expected query 'John', got '%s'", query) + } + + json.NewEncoder(w).Encode(&people.SearchDirectoryPeopleResponse{ + People: []*people.Person{ + { + ResourceName: "people/d2", + Names: []*people.Name{{DisplayName: "John Directory"}}, + EmailAddresses: []*people.EmailAddress{ + {Value: "john@company.com"}, + }, + }, + }, + TotalSize: 1, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + resp, err := svc.People.SearchDirectoryPeople(). + Query("John"). + ReadMask(personFields). + Sources("DIRECTORY_SOURCE_TYPE_DOMAIN_PROFILE"). + PageSize(50). + Do() + if err != nil { + t.Fatalf("failed to search directory people: %v", err) + } + + if len(resp.People) != 1 { + t.Errorf("expected 1 person, got %d", len(resp.People)) + } + if resp.People[0].Names[0].DisplayName != "John Directory" { + t.Errorf("expected 'John Directory', got '%s'", resp.People[0].Names[0].DisplayName) + } +} + +// TestContactsPhoto_Success tests updating a contact photo +func TestContactsPhoto_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people/c123:updateContactPhoto": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "PATCH" { + t.Errorf("expected PATCH, got %s", r.Method) + } + + var req people.UpdateContactPhotoRequest + json.NewDecoder(r.Body).Decode(&req) + + if req.PhotoBytes == "" { + t.Error("expected non-empty photo bytes") + } + + json.NewEncoder(w).Encode(&people.UpdateContactPhotoResponse{ + Person: &people.Person{ + ResourceName: "people/c123", + Names: []*people.Name{{DisplayName: "John Doe"}}, + }, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + req := &people.UpdateContactPhotoRequest{ + PhotoBytes: "dGVzdHBob3Rv", // base64 of "testphoto" + PersonFields: personFields, + } + + resp, err := svc.People.UpdateContactPhoto("people/c123", req).Do() + if err != nil { + t.Fatalf("failed to update contact photo: %v", err) + } + + if resp.Person == nil { + t.Fatal("expected person in response") + } + if resp.Person.ResourceName != "people/c123" { + t.Errorf("expected 'people/c123', got '%s'", resp.Person.ResourceName) + } +} + +// TestContactsDeletePhoto_Success tests deleting a contact photo +func TestContactsDeletePhoto_Success(t *testing.T) { + deleteCalled := false + + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people/c123:deleteContactPhoto": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Errorf("expected DELETE, got %s", r.Method) + } + deleteCalled = true + json.NewEncoder(w).Encode(&people.DeleteContactPhotoResponse{}) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + _, err = svc.People.DeleteContactPhoto("people/c123").Do() + if err != nil { + t.Fatalf("failed to delete contact photo: %v", err) + } + + if !deleteCalled { + t.Error("delete photo endpoint was not called") + } +} + +// TestContactsResolve_Success tests resolving multiple contacts +func TestContactsResolve_Success(t *testing.T) { + handlers := map[string]func(w http.ResponseWriter, r *http.Request){ + "/v1/people:batchGet": func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("expected GET, got %s", r.Method) + } + + resourceNames := r.URL.Query()["resourceNames"] + if len(resourceNames) != 2 { + t.Errorf("expected 2 resource names, got %d", len(resourceNames)) + } + + json.NewEncoder(w).Encode(&people.GetPeopleResponse{ + Responses: []*people.PersonResponse{ + { + Person: &people.Person{ + ResourceName: "people/c1", + Names: []*people.Name{{DisplayName: "John Doe"}}, + }, + RequestedResourceName: "people/c1", + }, + { + Person: &people.Person{ + ResourceName: "people/c2", + Names: []*people.Name{{DisplayName: "Jane Smith"}}, + }, + RequestedResourceName: "people/c2", + }, + }, + }) + }, + } + + server := mockPeopleServer(t, handlers) + defer server.Close() + + svc, err := people.NewService(context.Background(), option.WithoutAuthentication(), option.WithEndpoint(server.URL)) + if err != nil { + t.Fatalf("failed to create people service: %v", err) + } + + resp, err := svc.People.GetBatchGet(). + ResourceNames("people/c1", "people/c2"). + PersonFields(personFields). + Do() + if err != nil { + t.Fatalf("failed to resolve contacts: %v", err) + } + + if len(resp.Responses) != 2 { + t.Errorf("expected 2 responses, got %d", len(resp.Responses)) + } + if resp.Responses[0].Person.Names[0].DisplayName != "John Doe" { + t.Errorf("expected 'John Doe', got '%s'", resp.Responses[0].Person.Names[0].DisplayName) + } + if resp.Responses[1].Person.Names[0].DisplayName != "Jane Smith" { + t.Errorf("expected 'Jane Smith', got '%s'", resp.Responses[1].Person.Names[0].DisplayName) + } +} + // TestFormatPerson tests the formatPerson helper func TestFormatPerson(t *testing.T) { person := &people.Person{ ResourceName: "people/c1", + Etag: "abc123", Names: []*people.Name{{DisplayName: "Test User"}}, EmailAddresses: []*people.EmailAddress{ {Value: "test@example.com"}, @@ -322,6 +879,9 @@ func TestFormatPerson(t *testing.T) { if result["resource_name"] != "people/c1" { t.Errorf("expected resource_name 'people/c1', got '%v'", result["resource_name"]) } + if result["etag"] != "abc123" { + t.Errorf("expected etag 'abc123', got '%v'", result["etag"]) + } if result["name"] != "Test User" { t.Errorf("expected name 'Test User', got '%v'", result["name"]) } @@ -359,4 +919,7 @@ func TestFormatPerson_Minimal(t *testing.T) { if _, ok := result["emails"]; ok { t.Error("expected no emails field for person without emails") } + if _, ok := result["etag"]; ok { + t.Error("expected no etag field for person without etag") + } } diff --git a/cmd/skills_test.go b/cmd/skills_test.go index b9abb85..2a18b09 100644 --- a/cmd/skills_test.go +++ b/cmd/skills_test.go @@ -401,7 +401,7 @@ func TestSkillCommands_MatchCLI(t *testing.T) { }, "contacts": { parentCmd: contactsCmd, - subcommands: []string{"list", "search", "get", "create", "delete"}, + subcommands: []string{"list", "search", "get", "create", "delete", "update", "batch-create", "batch-update", "batch-delete", "directory", "directory-search", "photo", "delete-photo", "resolve"}, }, } diff --git a/skills/contacts/SKILL.md b/skills/contacts/SKILL.md index 54430b4..ac45c2d 100644 --- a/skills/contacts/SKILL.md +++ b/skills/contacts/SKILL.md @@ -40,7 +40,16 @@ For initial setup, see the `gws-auth` skill. | Search by email | `gws contacts search "john@example.com"` | | Get contact details | `gws contacts get ` | | Create a contact | `gws contacts create --name "Jane Smith" --email "jane@example.com"` | +| Update a contact | `gws contacts update --name "New Name"` | | Delete a contact | `gws contacts delete ` | +| Batch create contacts | `gws contacts batch-create --file contacts.json` | +| Batch update contacts | `gws contacts batch-update --file updates.json` | +| Batch delete contacts | `gws contacts batch-delete --resources people/c1 --resources people/c2` | +| List directory people | `gws contacts directory` | +| Search directory | `gws contacts directory-search --query "John"` | +| Update contact photo | `gws contacts photo --file photo.jpg` | +| Delete contact photo | `gws contacts delete-photo ` | +| Resolve multiple contacts | `gws contacts resolve --ids people/c1 --ids people/c2` | ## Detailed Usage @@ -172,6 +181,187 @@ gws contacts get people/c1234567890 # Review first gws contacts delete people/c1234567890 # Then delete ``` +### update — Update a contact + +```bash +gws contacts update [flags] +``` + +Updates an existing contact by resource name. Specify fields to update via flags. + +**Arguments:** +- `resource-name` — Resource identifier (required, e.g., `people/c1234567890`) + +**Flags:** +- `--name string` — Updated contact name +- `--email string` — Updated email address +- `--phone string` — Updated phone number +- `--organization string` — Updated organization name +- `--title string` — Updated job title +- `--etag string` — Etag for concurrency control (from get command) + +**Examples:** +```bash +gws contacts update people/c1234567890 --name "Jane Doe" +gws contacts update people/c1234567890 --email "new@example.com" --phone "555-9999" +gws contacts update people/c1234567890 --organization "Acme Inc" --title "Manager" +``` + +### batch-create — Batch create contacts + +```bash +gws contacts batch-create --file +``` + +Creates multiple contacts from a JSON file. The file should contain an array of contact objects. + +**Flags:** +- `--file string` — Path to JSON file with contacts array (required) + +**File format:** +```json +[ + {"names": [{"unstructuredName": "John Doe"}], "emailAddresses": [{"value": "john@example.com"}]}, + {"names": [{"unstructuredName": "Jane Smith"}], "phoneNumbers": [{"value": "555-1234"}]} +] +``` + +**Examples:** +```bash +gws contacts batch-create --file contacts.json +``` + +### batch-update — Batch update contacts + +```bash +gws contacts batch-update --file +``` + +Updates multiple contacts from a JSON file. The file should contain a map of resource names to contact objects. + +**Flags:** +- `--file string` — Path to JSON file with contacts map (required) + +**File format:** +```json +{ + "contacts": { + "people/c123": {"etag": "...", "names": [{"unstructuredName": "Updated Name"}]}, + "people/c456": {"etag": "...", "emailAddresses": [{"value": "new@example.com"}]} + }, + "update_mask": "names,emailAddresses" +} +``` + +**Examples:** +```bash +gws contacts batch-update --file updates.json +``` + +### batch-delete — Batch delete contacts + +```bash +gws contacts batch-delete --resources [--resources ...] +``` + +Deletes multiple contacts by resource names. + +**Flags:** +- `--resources string` — Resource names to delete (repeatable) + +**Examples:** +```bash +gws contacts batch-delete --resources people/c1 --resources people/c2 +``` + +### directory — List directory people + +```bash +gws contacts directory [flags] +``` + +Lists people in the organization's directory. Requires directory.readonly scope. + +**Flags:** +- `--max int` — Maximum number of directory people to return (default 50) +- `--query string` — Filter directory results + +**Examples:** +```bash +gws contacts directory +gws contacts directory --max 100 +``` + +### directory-search — Search directory people + +```bash +gws contacts directory-search --query [flags] +``` + +Searches people in the organization's directory by query. + +**Flags:** +- `--query string` — Search query (required) +- `--max int` — Maximum number of results to return (default 50) + +**Examples:** +```bash +gws contacts directory-search --query "John" +gws contacts directory-search --query "engineering" --max 100 +``` + +### photo — Update contact photo + +```bash +gws contacts photo --file +``` + +Updates a contact's photo from an image file (JPEG or PNG). + +**Arguments:** +- `resource-name` — Resource identifier (required) + +**Flags:** +- `--file string` — Path to image file, JPEG or PNG (required) + +**Examples:** +```bash +gws contacts photo people/c1234567890 --file photo.jpg +gws contacts photo people/c1234567890 --file avatar.png +``` + +### delete-photo — Delete contact photo + +```bash +gws contacts delete-photo +``` + +Deletes a contact's photo by resource name. + +**Arguments:** +- `resource-name` — Resource identifier (required) + +**Examples:** +```bash +gws contacts delete-photo people/c1234567890 +``` + +### resolve — Resolve multiple contacts + +```bash +gws contacts resolve --ids [--ids ...] +``` + +Gets multiple contacts by their resource names in a single batch request. + +**Flags:** +- `--ids string` — Resource names to resolve (repeatable) + +**Examples:** +```bash +gws contacts resolve --ids people/c1 --ids people/c2 +``` + ## Output Modes ```bash @@ -188,6 +378,8 @@ gws contacts list --format text # Human-readable text - The `list` command paginates automatically up to the `--max` limit (default 50) - Search is more efficient than listing all contacts and filtering client-side - When creating contacts, `--name` is required, but `--email` and `--phone` are optional -- Organization info is read-only (returned by list/get/search but not settable via create) +- Organization info can be set via `update` command (`--organization`, `--title` flags) - Use `--quiet` on any command to suppress JSON output (useful for scripted actions) -- For bulk operations, pipe JSON output to `jq` for filtering and extracting resource names +- For bulk operations, use `batch-create`, `batch-update`, `batch-delete` for better efficiency +- Use `resolve` to fetch multiple contacts in one request instead of multiple `get` calls +- Directory commands (`directory`, `directory-search`) require Google Workspace and `directory.readonly` scope diff --git a/skills/contacts/references/commands.md b/skills/contacts/references/commands.md index d85b163..f0a3649 100644 --- a/skills/contacts/references/commands.md +++ b/skills/contacts/references/commands.md @@ -1,6 +1,6 @@ # Contacts Commands Reference -Complete flag and option reference for `gws contacts` commands — 5 commands total. +Complete flag and option reference for `gws contacts` commands — 14 commands total. > **Disclaimer:** `gws` is not the official Google CLI. This is an independent, open-source project not endorsed by or affiliated with Google. @@ -287,6 +287,384 @@ gws contacts search "test-contact" --format json | \ --- +## gws contacts update + +Updates an existing contact by resource name. Specify fields to update via flags. + +``` +Usage: gws contacts update [flags] +``` + +| Argument | Type | Required | Description | +|----------|------|----------|-------------| +| `resource-name` | string | Yes | Resource identifier (e.g., `people/c1234567890`) | + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--name` | string | | No | Updated contact name | +| `--email` | string | | No | Updated email address | +| `--phone` | string | | No | Updated phone number | +| `--organization` | string | | No | Updated organization name | +| `--title` | string | | No | Updated job title | +| `--etag` | string | | No | Etag for concurrency control (from get command) | + +### Output Fields (JSON) + +Returns the updated contact object with: +- `status` — Always `"updated"` +- `resource_name` — Contact's resource identifier +- `etag` — New etag after update +- All standard contact fields + +### Examples + +```bash +# Update contact name +gws contacts update people/c1234567890 --name "Jane Doe" + +# Update email and phone +gws contacts update people/c1234567890 --email "new@example.com" --phone "555-9999" + +# Update organization info +gws contacts update people/c1234567890 --organization "Acme Inc" --title "Manager" + +# Update with etag for concurrency control +ETAG=$(gws contacts get people/c1234567890 --format json | jq -r '.etag') +gws contacts update people/c1234567890 --name "Updated Name" --etag "$ETAG" +``` + +### Notes + +- At least one field to update must be specified +- The `updatePersonFields` mask is automatically derived from the flags provided +- Use `--etag` for optimistic concurrency control — the update will fail if the contact was modified since the etag was fetched +- Only specified fields are updated; unspecified fields remain unchanged + +--- + +## gws contacts batch-create + +Creates multiple contacts from a JSON file. + +``` +Usage: gws contacts batch-create [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file` | string | | Yes | Path to JSON file with contacts array | + +### File Format + +The JSON file should contain an array of Person objects: + +```json +[ + { + "names": [{"unstructuredName": "John Doe"}], + "emailAddresses": [{"value": "john@example.com"}] + }, + { + "names": [{"unstructuredName": "Jane Smith"}], + "phoneNumbers": [{"value": "555-1234"}] + } +] +``` + +### Output Fields (JSON) + +Returns: +- `status` — Always `"created"` +- `contacts` — Array of created contact objects +- `count` — Number of contacts created + +### Examples + +```bash +# Batch create contacts from file +gws contacts batch-create --file contacts.json +``` + +### Notes + +- Allows up to 200 contacts in a single request +- Each contact must have at least a name + +--- + +## gws contacts batch-update + +Updates multiple contacts from a JSON file. + +``` +Usage: gws contacts batch-update [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file` | string | | Yes | Path to JSON file with contacts map | + +### File Format + +The JSON file should contain a map of resource names to Person objects plus an update mask: + +```json +{ + "contacts": { + "people/c123": { + "etag": "abc", + "names": [{"unstructuredName": "Updated Name"}] + }, + "people/c456": { + "etag": "def", + "emailAddresses": [{"value": "new@example.com"}] + } + }, + "update_mask": "names,emailAddresses" +} +``` + +### Output Fields (JSON) + +Returns: +- `status` — Always `"updated"` +- `results` — Map of resource names to updated contact objects +- `count` — Number of contacts updated + +### Examples + +```bash +# Batch update contacts from file +gws contacts batch-update --file updates.json +``` + +### Notes + +- Allows up to 200 contacts in a single request +- The `update_mask` field is required +- Each contact should include its etag for concurrency control + +--- + +## gws contacts batch-delete + +Deletes multiple contacts by resource names. + +``` +Usage: gws contacts batch-delete [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--resources` | string[] | | Yes | Resource names to delete (repeatable) | + +### Output Fields (JSON) + +Returns: +- `status` — Always `"deleted"` +- `resource_names` — Array of deleted resource names +- `count` — Number of contacts deleted + +### Examples + +```bash +# Delete multiple contacts +gws contacts batch-delete --resources people/c1 --resources people/c2 + +# Delete contacts from search results +gws contacts search "test" --format json | \ + jq -r '.contacts[].resource_name' | \ + xargs -I {} echo "--resources {}" | \ + xargs gws contacts batch-delete +``` + +### Notes + +- **This operation is permanent and cannot be undone** +- Allows up to 500 resource names in a single request +- Use `--resources` flag repeatedly for each resource name + +--- + +## gws contacts directory + +Lists people in the organization's directory. + +``` +Usage: gws contacts directory [flags] +``` + +| Flag | Type | Default | Description | +|------|------|---------|-------------| +| `--max` | int | 50 | Maximum number of directory people to return | +| `--query` | string | | Filter directory results | + +### Output Fields (JSON) + +Returns an object with: +- `contacts` — Array of directory people objects +- `count` — Number of people returned + +### Examples + +```bash +# List directory people +gws contacts directory + +# List more directory people +gws contacts directory --max 200 +``` + +### Notes + +- Requires the `directory.readonly` scope +- Only available for Google Workspace accounts (not personal Gmail) +- Returns people from the organization's domain directory + +--- + +## gws contacts directory-search + +Searches people in the organization's directory by query. + +``` +Usage: gws contacts directory-search [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--query` | string | | Yes | Search query | +| `--max` | int | 50 | No | Maximum number of results to return | + +### Output Fields (JSON) + +Returns an object with: +- `contacts` — Array of matching directory people objects +- `count` — Number of results returned +- `query` — The search query used + +### Examples + +```bash +# Search directory by name +gws contacts directory-search --query "John" + +# Search directory with more results +gws contacts directory-search --query "engineering" --max 100 +``` + +### Notes + +- Requires the `directory.readonly` scope +- Only available for Google Workspace accounts +- Searches across names and email addresses in the directory + +--- + +## gws contacts photo + +Updates a contact's photo from an image file. + +``` +Usage: gws contacts photo [flags] +``` + +| Argument | Type | Required | Description | +|----------|------|----------|-------------| +| `resource-name` | string | Yes | Resource identifier (e.g., `people/c1234567890`) | + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--file` | string | | Yes | Path to image file, JPEG or PNG | + +### Output Fields (JSON) + +Returns: +- `status` — Always `"photo_updated"` +- `resource_name` — Contact's resource identifier +- All standard contact fields (if available) + +### Examples + +```bash +# Update contact photo +gws contacts photo people/c1234567890 --file photo.jpg +gws contacts photo people/c1234567890 --file avatar.png +``` + +### Notes + +- Only JPEG and PNG formats are supported +- The image is base64-encoded before sending to the API +- Large images may be resized by the API + +--- + +## gws contacts delete-photo + +Deletes a contact's photo by resource name. + +``` +Usage: gws contacts delete-photo +``` + +| Argument | Type | Required | Description | +|----------|------|----------|-------------| +| `resource-name` | string | Yes | Resource identifier (e.g., `people/c1234567890`) | + +No additional flags. + +### Output Fields (JSON) + +Returns: +- `status` — Always `"photo_deleted"` +- `resource_name` — Contact's resource identifier + +### Examples + +```bash +# Delete contact photo +gws contacts delete-photo people/c1234567890 +``` + +--- + +## gws contacts resolve + +Gets multiple contacts by their resource names in a single batch request. + +``` +Usage: gws contacts resolve [flags] +``` + +| Flag | Type | Default | Required | Description | +|------|------|---------|----------|-------------| +| `--ids` | string[] | | Yes | Resource names to resolve (repeatable) | + +### Output Fields (JSON) + +Returns an object with: +- `contacts` — Array of resolved contact objects +- `count` — Number of contacts resolved + +### Examples + +```bash +# Resolve multiple contacts +gws contacts resolve --ids people/c1 --ids people/c2 + +# Resolve contacts from a list +gws contacts resolve --ids people/c1234567890 --ids people/c9876543210 +``` + +### Notes + +- More efficient than making individual `get` calls for multiple contacts +- Uses `people.getBatchGet` API method +- Returns contacts in the order they were requested + +--- + ## Common Workflows ### Import Contacts from CSV @@ -310,13 +688,15 @@ gws contacts list --max 1000 --format json > contacts.json gws contacts list --format json | jq '.contacts[] | select(.emails == null)' ``` -### Update Contact (Get, Delete, Recreate) +### Update Contact ```bash -# Note: There's no direct update command, so update = delete + create -OLD=$(gws contacts search "Jane" --format json | jq -r '.contacts[0].resource_name') -gws contacts delete $OLD -gws contacts create --name "Jane Smith" --email "jane.new@example.com" --phone "555-9999" +# Direct update using the update command +gws contacts update people/c1234567890 --name "Jane Smith" --email "jane.new@example.com" --phone "555-9999" + +# Search and update +RESOURCE=$(gws contacts search "Jane" --format json | jq -r '.contacts[0].resource_name') +gws contacts update $RESOURCE --name "Jane Smith-Doe" ``` ### Merge Duplicate Contacts