diff --git a/README.md b/README.md index 13fd0f56..05ffdcee 100644 --- a/README.md +++ b/README.md @@ -991,7 +991,8 @@ gog contacts create \ --email "john@example.com" \ --phone "+1234567890" \ --address "12 St James's Square, London" \ - --relation "spouse=Jane Doe" + --relation "spouse=Jane Doe" \ + --gender "female" gog contacts update people/ \ --given "Jane" \ @@ -999,7 +1000,8 @@ gog contacts update people/ \ --address "1 Infinite Loop, Cupertino" \ --birthday "1990-05-12" \ --notes "Met at WWDC" \ - --relation "friend=Bob" + --relation "friend=Bob" \ + --gender "female" # or: male, unspecified, nonbinary, ... # Update via JSON (see docs/contacts-json-update.md) gog contacts get people/ --json | \ diff --git a/docs/contacts-json-update.md b/docs/contacts-json-update.md index f3cff044..f2670b41 100644 --- a/docs/contacts-json-update.md +++ b/docs/contacts-json-update.md @@ -32,7 +32,24 @@ The command accepts: `--from-file` updates only fields that the People API allows via `people.updateContact` `updatePersonFields`. -Practical rule: include only fields you want to change, at the top level of the JSON object (for example `urls`, `biographies`, `names`, `emailAddresses`, `phoneNumbers`, `addresses`, `organizations`, ...). +Practical rule: include only fields you want to change, at the top level of the JSON object (for example `urls`, `biographies`, `names`, `emailAddresses`, `phoneNumbers`, `addresses`, `organizations`, `genders`, ...). + +The following fields are also available as dedicated CLI flags on `contacts create` and `contacts update`, which is simpler than `--from-file` for single-field edits: + +| Flag | People API field | Notes | +|---|---|---| +| `--given` / `--family` | `names` | | +| `--email` | `emailAddresses` | | +| `--phone` | `phoneNumbers` | | +| `--org` / `--title` | `organizations` | | +| `--address` | `addresses` | | +| `--url` | `urls` | | +| `--note` | `biographies` | | +| `--birthday` | `birthdays` | YYYY-MM-DD | +| `--notes` | `biographies` | plain text | +| `--relation` | `relations` | `type=person` | +| `--custom` | `userDefined` | `key=value` | +| `--gender` | `genders` | e.g. `male`, `female`, `unspecified`, or any custom value | If the JSON contains unsupported fields (for `updateContact`), gog errors instead of silently ignoring them. diff --git a/internal/cmd/contacts_crud.go b/internal/cmd/contacts_crud.go index f574e668..2a10e5cd 100644 --- a/internal/cmd/contacts_crud.go +++ b/internal/cmd/contacts_crud.go @@ -17,8 +17,8 @@ import ( const ( contactsReadMask = "names,emailAddresses,phoneNumbers,organizations,urls" - contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,metadata" - contactsUpdateReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,metadata" + contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,genders,metadata" + contactsUpdateReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,relations,genders,metadata" ) type ContactsListCmd struct { @@ -175,6 +175,9 @@ func (c *ContactsGetCmd) Run(ctx context.Context, flags *RootFlags) error { u.Out().Printf("title\t%s", title) } } + if g := primaryGender(p); g != "" { + u.Out().Printf("gender\t%s", g) + } for _, url := range allURLs(p) { u.Out().Printf("url\t%s", url) } @@ -216,6 +219,7 @@ type ContactsCreateCmd struct { Address []string `name:"address" sep:";" help:"Postal address (can be repeated for multiple addresses)"` Custom []string `name:"custom" help:"Custom field as key=value (can be repeated)"` Relation []string `name:"relation" help:"Relation as type=person (can be repeated)"` + Gender string `name:"gender" help:"Gender (e.g. male, female, unspecified, or any custom value)"` } func parseKeyValuePairs(values []string, allowEmptyClear bool, flag, format string) ([][2]string, bool, error) { @@ -326,6 +330,18 @@ func contactsApplyPersonOrganization(person *people.Person, orgSet bool, org str person.Organizations = []*people.Organization{{Name: curOrg, Title: curTitle}} } +// primaryGender returns the formattedValue of the first gender entry, or the raw +// value if formattedValue is empty. +func primaryGender(p *people.Person) string { + if len(p.Genders) == 0 || p.Genders[0] == nil { + return "" + } + if p.Genders[0].FormattedValue != "" { + return p.Genders[0].FormattedValue + } + return p.Genders[0].Value +} + func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { u := ui.FromContext(ctx) account, err := requireAccount(flags) @@ -390,6 +406,9 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error { p.Relations = relations } } + if trimmed := strings.TrimSpace(c.Gender); trimmed != "" { + p.Genders = []*people.Gender{{Value: trimmed}} + } created, err := svc.People.CreateContact(p).Do() if err != nil { @@ -421,6 +440,7 @@ type ContactsUpdateCmd struct { // Extra People API fields (not previously exposed by gog) Birthday string `name:"birthday" help:"Birthday in YYYY-MM-DD (empty clears)"` Notes string `name:"notes" help:"Notes (stored as People API biography; empty clears)"` + Gender string `name:"gender" help:"Gender (e.g. male, female, unspecified, or any custom value; empty clears)"` } func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *RootFlags) error { @@ -440,7 +460,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * } if strings.TrimSpace(c.FromFile) != "" { - if flagProvided(kctx, "given") || flagProvided(kctx, "family") || flagProvided(kctx, "email") || flagProvided(kctx, "phone") || flagProvided(kctx, "birthday") || flagProvided(kctx, "notes") || flagProvided(kctx, "relation") { + if flagProvided(kctx, "given") || flagProvided(kctx, "family") || flagProvided(kctx, "email") || flagProvided(kctx, "phone") || flagProvided(kctx, "birthday") || flagProvided(kctx, "notes") || flagProvided(kctx, "relation") || flagProvided(kctx, "gender") { return usage("can't combine --from-file with other update flags") } return c.updateFromJSON(ctx, svc, resourceName, u) @@ -466,6 +486,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * wantNotes := flagProvided(kctx, "notes") wantCustom := flagProvided(kctx, "custom") wantRelation := flagProvided(kctx, "relation") + wantGender := flagProvided(kctx, "gender") if wantGiven || wantFamily { contactsApplyPersonName(existing, wantGiven, c.Given, wantFamily, c.Family) @@ -571,6 +592,16 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags * updateFields = append(updateFields, "biographies") } + if wantGender { + trimmed := strings.TrimSpace(c.Gender) + if trimmed == "" { + existing.Genders = nil // will be forced to [] for patch + } else { + existing.Genders = []*people.Gender{{Value: trimmed}} + } + updateFields = append(updateFields, "genders") + } + if len(updateFields) == 0 { return usage("no updates provided") } diff --git a/internal/cmd/contacts_gender_test.go b/internal/cmd/contacts_gender_test.go new file mode 100644 index 00000000..93e85922 --- /dev/null +++ b/internal/cmd/contacts_gender_test.go @@ -0,0 +1,424 @@ +package cmd + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "google.golang.org/api/people/v1" + + "github.com/steipete/gogcli/internal/ui" +) + +// --------------------------------------------------------------------------- +// contacts update --gender +// --------------------------------------------------------------------------- + +func TestContactsUpdate_Gender_Set(t *testing.T) { + var gotGetFields string + var gotUpdateFields string + var gotGenderValue string + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + gotGetFields = r.URL.Query().Get("personFields") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "names": []map[string]any{{"givenName": "Ada", "familyName": "Lovelace"}}, + }) + return + case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost): + gotUpdateFields = r.URL.Query().Get("updatePersonFields") + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if genders, ok := body["genders"].([]any); ok && len(genders) > 0 { + if first, ok := genders[0].(map[string]any); ok { + gotGenderValue = strings.TrimSpace(primaryValue(first, "value")) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", "female"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if !strings.Contains(gotGetFields, "genders") { + t.Fatalf("missing genders in people.get fields: %q", gotGetFields) + } + if !strings.Contains(gotUpdateFields, "genders") { + t.Fatalf("missing genders in updatePersonFields: %q", gotUpdateFields) + } + if gotGenderValue != "female" { + t.Fatalf("unexpected gender payload: %q, want %q", gotGenderValue, "female") + } +} + +func TestContactsUpdate_Gender_CustomValue(t *testing.T) { + var gotGenderValue string + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost): + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if genders, ok := body["genders"].([]any); ok && len(genders) > 0 { + if first, ok := genders[0].(map[string]any); ok { + gotGenderValue = strings.TrimSpace(primaryValue(first, "value")) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", "nonbinary"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if gotGenderValue != "nonbinary" { + t.Fatalf("unexpected custom gender payload: %q, want %q", gotGenderValue, "nonbinary") + } +} + +func TestContactsUpdate_Gender_Clear(t *testing.T) { + var gotUpdateFields string + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "genders": []map[string]any{{"value": "male", "formattedValue": "Male"}}, + }) + return + case strings.Contains(r.URL.Path, ":updateContact") && (r.Method == http.MethodPatch || r.Method == http.MethodPost): + gotUpdateFields = r.URL.Query().Get("updatePersonFields") + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--gender", ""}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if !strings.Contains(gotUpdateFields, "genders") { + t.Fatalf("missing genders in clear updatePersonFields: %q", gotUpdateFields) + } +} + +// --------------------------------------------------------------------------- +// contacts create --gender +// --------------------------------------------------------------------------- + +func TestContactsCreate_Gender_Set(t *testing.T) { + var gotGenderValue string + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, ":createContact") && r.Method == http.MethodPost: + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode body: %v", err) + } + if genders, ok := body["genders"].([]any); ok && len(genders) > 0 { + if first, ok := genders[0].(map[string]any); ok { + gotGenderValue = strings.TrimSpace(primaryValue(first, "value")) + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &ContactsCreateCmd{}, []string{"--given", "Ada", "--gender", "female"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if gotGenderValue != "female" { + t.Fatalf("unexpected gender payload: %q, want %q", gotGenderValue, "female") + } +} + +func TestContactsCreate_Gender_Omitted(t *testing.T) { + var gotBody map[string]any + + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, ":createContact") && r.Method == http.MethodPost: + if err := json.NewDecoder(r.Body).Decode(&gotBody); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"resourceName": "people/c1"}) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + if err := runKong(t, &ContactsCreateCmd{}, []string{"--given", "Ada"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + + if _, present := gotBody["genders"]; present { + t.Fatalf("genders should be absent from payload when --gender not provided, got: %v", gotBody["genders"]) + } +} + +// --------------------------------------------------------------------------- +// contacts get: gender displayed in text output +// --------------------------------------------------------------------------- + +func TestContactsGet_Gender_DisplayedInTextOutput(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "names": []map[string]any{{"givenName": "Ada", "familyName": "Lovelace"}}, + "genders": []map[string]any{{"value": "female", "formattedValue": "Female"}}, + }) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, uiErr := ui.New(ui.Options{Stdout: nil, Stderr: io.Discard, Color: "never"}) + if uiErr != nil { + t.Fatalf("ui.New: %v", uiErr) + } + + out := captureStdout(t, func() { + u2, _ := ui.New(ui.Options{Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u2) + _ = u + if err := runKong(t, &ContactsGetCmd{}, []string{"people/c1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + if !strings.Contains(out, "gender") || !strings.Contains(out, "Female") { + t.Fatalf("expected 'gender' and 'Female' in get output, got: %q", out) + } +} + +func TestContactsGet_Gender_AbsentWhenEmpty(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "names": []map[string]any{{"givenName": "Ada", "familyName": "Lovelace"}}, + }) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + if err := runKong(t, &ContactsGetCmd{}, []string{"people/c1"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + if strings.Contains(out, "gender") { + t.Fatalf("gender line should be absent when no gender set, got: %q", out) + } +} + +// --------------------------------------------------------------------------- +// primaryGender helper unit tests +// --------------------------------------------------------------------------- + +func TestPrimaryGender_FormattedValuePreferred(t *testing.T) { + p := &people.Person{ + Genders: []*people.Gender{ + {Value: "female", FormattedValue: "Female"}, + }, + } + if got := primaryGender(p); got != "Female" { + t.Fatalf("primaryGender: got %q, want %q", got, "Female") + } +} + +func TestPrimaryGender_FallbackToValue(t *testing.T) { + p := &people.Person{ + Genders: []*people.Gender{ + {Value: "nonbinary"}, + }, + } + if got := primaryGender(p); got != "nonbinary" { + t.Fatalf("primaryGender: got %q, want %q", got, "nonbinary") + } +} + +func TestPrimaryGender_EmptyWhenNone(t *testing.T) { + p := &people.Person{} + if got := primaryGender(p); got != "" { + t.Fatalf("primaryGender: expected empty, got %q", got) + } +} + +func TestPrimaryGender_EmptyWhenNilEntry(t *testing.T) { + p := &people.Person{Genders: []*people.Gender{nil}} + if got := primaryGender(p); got != "" { + t.Fatalf("primaryGender nil entry: expected empty, got %q", got) + } +} + +// --------------------------------------------------------------------------- +// contacts update --gender: rejected when combined with --from-file +// --------------------------------------------------------------------------- + +func TestContactsUpdate_Gender_RejectedWithFromFile(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.NotFound(w, r) // should never be called + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + u, err := ui.New(ui.Options{Stdout: io.Discard, Stderr: io.Discard, Color: "never"}) + if err != nil { + t.Fatalf("ui.New: %v", err) + } + ctx := ui.WithUI(context.Background(), u) + + err = runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-", "--gender", "female"}, ctx, &RootFlags{Account: "a@b.com"}) + if err == nil { + t.Fatal("expected error when combining --from-file with --gender, got nil") + } + if !strings.Contains(err.Error(), "can't combine") { + t.Fatalf("expected conflict error, got: %v", err) + } +} + +// --------------------------------------------------------------------------- +// contacts get: gender present in JSON output +// --------------------------------------------------------------------------- + +func TestContactsGet_Gender_InJSONOutput(t *testing.T) { + svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.Contains(r.URL.Path, "people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":"): + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{ + "resourceName": "people/c1", + "names": []map[string]any{{"givenName": "Ada", "familyName": "Lovelace"}}, + "genders": []map[string]any{{"value": "female", "formattedValue": "Female"}}, + }) + return + default: + http.NotFound(w, r) + } + })) + t.Cleanup(closeSrv) + stubPeopleServices(t, svc) + + out := captureStdout(t, func() { + u, _ := ui.New(ui.Options{Stderr: io.Discard, Color: "never"}) + ctx := ui.WithUI(context.Background(), u) + if err := runKong(t, &ContactsGetCmd{}, []string{"people/c1", "--json"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil { + t.Fatalf("runKong: %v", err) + } + }) + + var parsed map[string]any + if err := json.Unmarshal([]byte(out), &parsed); err != nil { + t.Fatalf("output is not valid JSON: %v\noutput: %q", err, out) + } + contact, ok := parsed["contact"].(map[string]any) + if !ok { + t.Fatalf("missing \"contact\" key in JSON output: %q", out) + } + genders, ok := contact["genders"].([]any) + if !ok || len(genders) == 0 { + t.Fatalf("expected genders in JSON output, got: %q", out) + } + first, ok := genders[0].(map[string]any) + if !ok { + t.Fatalf("genders[0] is not an object: %q", out) + } + if v, _ := first["value"].(string); v != "female" { + t.Fatalf("expected genders[0].value=\"female\", got %q", v) + } +}