Compare commits

...

3 Commits

Author SHA1 Message Date
Peter Steinberger
2d396bcfdd docs(changelog): include PR #199 landing note
Some checks failed
ci / test (push) Has been cancelled
ci / worker (push) Has been cancelled
ci / darwin-cgo-build (push) Has been cancelled
2026-02-16 05:24:37 +01:00
Peter Steinberger
a5bef10740 fix(contacts): hard-validate custom and sort output 2026-02-16 05:23:18 +01:00
OpenClaw
12bf5a23b3 feat(contacts): add support for additional fields
Add support for the following contact fields in create/update commands:
- --org: Organization/company name
- --title: Job title
- --url: URLs (can be repeated for multiple)
- --note: Note/biography
- --custom: Custom key=value fields (can be repeated)

Also updates the read mask to include these fields in list/get output.

Closes #198
2026-02-16 05:22:44 +01:00
5 changed files with 292 additions and 18 deletions

View File

@ -5,6 +5,7 @@
### Added
- Sheets: add `sheets insert` to insert rows/columns into a sheet. (#203) — thanks @andybergon.
- Gmail: add `watch serve --history-types` filtering (`messageAdded|messageDeleted|labelAdded|labelRemoved`) and include `deletedMessageIds` in webhook payloads. (#168) — thanks @salmonumbrella.
- Contacts: support `--org`, `--title`, `--url`, `--note`, and `--custom` on create/update; include custom fields in get output with deterministic ordering. (#199) — thanks @phuctm97.
### Fixed
- Calendar: respond patches only attendees to avoid custom reminders validation errors. (#265) — thanks @sebasrodriguez.

View File

@ -169,3 +169,50 @@ func formatPartialDate(d *people.Date) string {
func sanitizeTab(s string) string {
return strings.ReplaceAll(s, "\t", " ")
}
func primaryOrganization(p *people.Person) (name, title string) {
if p == nil || len(p.Organizations) == 0 || p.Organizations[0] == nil {
return "", ""
}
return p.Organizations[0].Name, p.Organizations[0].Title
}
func primaryURL(p *people.Person) string {
if p == nil || len(p.Urls) == 0 || p.Urls[0] == nil {
return ""
}
return p.Urls[0].Value
}
func allURLs(p *people.Person) []string {
if p == nil || len(p.Urls) == 0 {
return nil
}
urls := make([]string, 0, len(p.Urls))
for _, u := range p.Urls {
if u != nil && u.Value != "" {
urls = append(urls, u.Value)
}
}
return urls
}
func primaryBio(p *people.Person) string {
if p == nil || len(p.Biographies) == 0 || p.Biographies[0] == nil {
return ""
}
return p.Biographies[0].Value
}
func userDefinedFields(p *people.Person) map[string]string {
if p == nil || len(p.UserDefined) == 0 {
return nil
}
fields := make(map[string]string, len(p.UserDefined))
for _, ud := range p.UserDefined {
if ud != nil && ud.Key != "" {
fields[ud.Key] = ud.Value
}
}
return fields
}

View File

@ -4,6 +4,7 @@ import (
"context"
"fmt"
"os"
"sort"
"strings"
"github.com/alecthomas/kong"
@ -15,10 +16,8 @@ import (
)
const (
contactsReadMask = "names,emailAddresses,phoneNumbers"
// contactsGetReadMask is tuned for round-tripping `gog contacts get --json`
// into `gog contacts update --from-file`.
contactsGetReadMask = contactsReadMask + ",birthdays,urls,biographies,addresses,organizations,metadata"
contactsReadMask = "names,emailAddresses,phoneNumbers,organizations,urls"
contactsGetReadMask = contactsReadMask + ",birthdays,biographies,addresses,userDefined,metadata"
)
type ContactsListCmd struct {
@ -165,14 +164,70 @@ func (c *ContactsGetCmd) Run(ctx context.Context, flags *RootFlags) error {
if bd := primaryBirthday(p); bd != "" {
u.Out().Printf("birthday\t%s", bd)
}
if org, title := primaryOrganization(p); org != "" || title != "" {
if org != "" && title != "" {
u.Out().Printf("organization\t%s (%s)", org, title)
} else if org != "" {
u.Out().Printf("organization\t%s", org)
} else {
u.Out().Printf("title\t%s", title)
}
}
for _, url := range allURLs(p) {
u.Out().Printf("url\t%s", url)
}
if bio := primaryBio(p); bio != "" {
u.Out().Printf("note\t%s", bio)
}
customFields := userDefinedFields(p)
if len(customFields) > 0 {
keys := make([]string, 0, len(customFields))
for k := range customFields {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
u.Out().Printf("custom:%s\t%s", k, customFields[k])
}
}
return nil
}
type ContactsCreateCmd struct {
Given string `name:"given" help:"Given name (required)"`
Family string `name:"family" help:"Family name"`
Email string `name:"email" help:"Email address"`
Phone string `name:"phone" help:"Phone number"`
Given string `name:"given" help:"Given name (required)"`
Family string `name:"family" help:"Family name"`
Email string `name:"email" help:"Email address"`
Phone string `name:"phone" help:"Phone number"`
Organization string `name:"org" help:"Organization/company name"`
Title string `name:"title" help:"Job title"`
URL []string `name:"url" help:"URL (can be repeated for multiple URLs)"`
Note string `name:"note" help:"Note/biography"`
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated)"`
}
func parseCustomUserDefined(values []string, allowEmptyClear bool) ([]*people.UserDefined, error) {
if len(values) == 0 {
return nil, nil
}
if len(values) == 1 && strings.TrimSpace(values[0]) == "" {
if allowEmptyClear {
return nil, nil
}
return nil, fmt.Errorf("--custom entry cannot be empty")
}
userDefined := make([]*people.UserDefined, 0, len(values))
for _, kv := range values {
parts := strings.SplitN(strings.TrimSpace(kv), "=", 2)
if len(parts) != 2 || strings.TrimSpace(parts[0]) == "" {
return nil, fmt.Errorf("expected key=value for --custom, got %q", kv)
}
userDefined = append(userDefined, &people.UserDefined{
Key: strings.TrimSpace(parts[0]),
Value: strings.TrimSpace(parts[1]),
})
}
return userDefined, nil
}
func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
@ -202,6 +257,35 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
if strings.TrimSpace(c.Phone) != "" {
p.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(c.Phone)}}
}
if strings.TrimSpace(c.Organization) != "" || strings.TrimSpace(c.Title) != "" {
p.Organizations = []*people.Organization{{
Name: strings.TrimSpace(c.Organization),
Title: strings.TrimSpace(c.Title),
}}
}
if len(c.URL) > 0 {
urls := make([]*people.Url, 0, len(c.URL))
for _, u := range c.URL {
if strings.TrimSpace(u) != "" {
urls = append(urls, &people.Url{Value: strings.TrimSpace(u)})
}
}
if len(urls) > 0 {
p.Urls = urls
}
}
if strings.TrimSpace(c.Note) != "" {
p.Biographies = []*people.Biography{{Value: strings.TrimSpace(c.Note)}}
}
if len(c.Custom) > 0 {
userDefined, parseErr := parseCustomUserDefined(c.Custom, false)
if parseErr != nil {
return usage(parseErr.Error())
}
if len(userDefined) > 0 {
p.UserDefined = userDefined
}
}
created, err := svc.People.CreateContact(p).Do()
if err != nil {
@ -215,13 +299,18 @@ func (c *ContactsCreateCmd) Run(ctx context.Context, flags *RootFlags) error {
}
type ContactsUpdateCmd struct {
ResourceName string `arg:"" name:"resourceName" help:"Resource name (people/...)"`
Given string `name:"given" help:"Given name"`
Family string `name:"family" help:"Family name"`
Email string `name:"email" help:"Email address (empty clears)"`
Phone string `name:"phone" help:"Phone number (empty clears)"`
FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"`
IgnoreETag bool `name:"ignore-etag" help:"Allow updating even if the JSON etag is stale (may overwrite concurrent changes)"`
ResourceName string `arg:"" name:"resourceName" help:"Resource name (people/...)"`
Given string `name:"given" help:"Given name"`
Family string `name:"family" help:"Family name"`
Email string `name:"email" help:"Email address (empty clears)"`
Phone string `name:"phone" help:"Phone number (empty clears)"`
Organization string `name:"org" help:"Organization/company name (empty clears)"`
Title string `name:"title" help:"Job title (empty clears)"`
URL []string `name:"url" help:"URL (can be repeated; empty clears all)"`
Note string `name:"note" help:"Note/biography (empty clears)"`
Custom []string `name:"custom" help:"Custom field as key=value (can be repeated; empty clears all)"`
FromFile string `name:"from-file" help:"Update from contact JSON file (use - for stdin)"`
IgnoreETag bool `name:"ignore-etag" help:"Allow updating even if the JSON etag is stale (may overwrite concurrent changes)"`
// Extra People API fields (not previously exposed by gog)
Birthday string `name:"birthday" help:"Birthday in YYYY-MM-DD (empty clears)"`
@ -251,12 +340,12 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
return c.updateFromJSON(ctx, svc, resourceName, u)
}
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies,metadata").Do()
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies,userDefined,metadata").Do()
if err != nil {
return err
}
updateFields := make([]string, 0, 5)
updateFields := make([]string, 0, 8)
if flagProvided(kctx, "given") || flagProvided(kctx, "family") {
curGiven := ""
@ -291,6 +380,55 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
}
updateFields = append(updateFields, "phoneNumbers")
}
if flagProvided(kctx, "org") || flagProvided(kctx, "title") {
curOrg := ""
curTitle := ""
if len(existing.Organizations) > 0 && existing.Organizations[0] != nil {
curOrg = existing.Organizations[0].Name
curTitle = existing.Organizations[0].Title
}
if flagProvided(kctx, "org") {
curOrg = strings.TrimSpace(c.Organization)
}
if flagProvided(kctx, "title") {
curTitle = strings.TrimSpace(c.Title)
}
if curOrg == "" && curTitle == "" {
existing.Organizations = nil
} else {
existing.Organizations = []*people.Organization{{Name: curOrg, Title: curTitle}}
}
updateFields = append(updateFields, "organizations")
}
if flagProvided(kctx, "url") {
if len(c.URL) == 0 || (len(c.URL) == 1 && strings.TrimSpace(c.URL[0]) == "") {
existing.Urls = nil
} else {
urls := make([]*people.Url, 0, len(c.URL))
for _, u := range c.URL {
if strings.TrimSpace(u) != "" {
urls = append(urls, &people.Url{Value: strings.TrimSpace(u)})
}
}
existing.Urls = urls
}
updateFields = append(updateFields, "urls")
}
if flagProvided(kctx, "note") {
if strings.TrimSpace(c.Note) == "" {
existing.Biographies = nil
} else {
existing.Biographies = []*people.Biography{{Value: strings.TrimSpace(c.Note)}}
}
updateFields = append(updateFields, "biographies")
}
if flagProvided(kctx, "custom") {
userDefined, parseErr := parseCustomUserDefined(c.Custom, true)
if parseErr != nil {
return usage(parseErr.Error())
existing.UserDefined = userDefined
updateFields = append(updateFields, "userDefined")
}
if flagProvided(kctx, "birthday") {
if strings.TrimSpace(c.Birthday) == "" {

View File

@ -95,3 +95,41 @@ func TestPrimaryBirthday_EdgeCases(t *testing.T) {
t.Fatalf("unexpected: %q", got)
}
}
func TestParseCustomUserDefined_InvalidInput(t *testing.T) {
if _, _, err := parseCustomUserDefined([]string{"bad"}, true); err == nil {
t.Fatalf("expected error for missing '='")
}
if _, _, err := parseCustomUserDefined([]string{"=value"}, true); err == nil {
t.Fatalf("expected error for empty key")
}
if _, _, err := parseCustomUserDefined([]string{""}, false); err == nil {
t.Fatalf("expected error for empty custom value")
}
}
func TestParseCustomUserDefined_ValidInput(t *testing.T) {
fields, clear, err := parseCustomUserDefined([]string{"team=devops", " repo = gog"}, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if clear {
t.Fatalf("did not expect clear")
}
if len(fields) != 2 || fields[0].Key != "team" || fields[0].Value != "devops" || fields[1].Key != "repo" || fields[1].Value != "gog" {
t.Fatalf("unexpected fields: %#v", fields)
}
}
func TestParseCustomUserDefined_ClearAll(t *testing.T) {
fields, clear, err := parseCustomUserDefined([]string{""}, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !clear {
t.Fatalf("expected clear")
}
if len(fields) != 0 {
t.Fatalf("expected empty fields, got %v", len(fields))
}
}

View File

@ -68,7 +68,7 @@ func TestExecute_ContactsGet_ByResource_Text(t *testing.T) {
t.Cleanup(func() { newPeopleContactsService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(strings.Contains(r.URL.Path, "/people/c1") && r.Method == http.MethodGet) {
if !(strings.Contains(r.URL.Path, "/people/c1") && r.Method == http.MethodGet && !strings.Contains(r.URL.Path, ":")) {
http.NotFound(w, r)
return
}
@ -106,6 +106,56 @@ func TestExecute_ContactsGet_ByResource_Text(t *testing.T) {
}
}
func TestExecute_ContactsGet_CustomFieldsSorted_Text(t *testing.T) {
origNew := newPeopleContactsService
t.Cleanup(func() { newPeopleContactsService = origNew })
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !(strings.Contains(r.URL.Path, "/people/c1") && r.Method == http.MethodGet) {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"resourceName": "people/c1",
"userDefined": []map[string]any{
{"key": "zzz", "value": "3"},
{"key": "aaa", "value": "1"},
{"key": "mmm", "value": "2"},
},
})
}))
defer srv.Close()
svc, err := people.NewService(context.Background(),
option.WithoutAuthentication(),
option.WithHTTPClient(srv.Client()),
option.WithEndpoint(srv.URL+"/"),
)
if err != nil {
t.Fatalf("NewService: %v", err)
}
newPeopleContactsService = func(context.Context, string) (*people.Service, error) { return svc, nil }
out := captureStdout(t, func() {
_ = captureStderr(t, func() {
if err := Execute([]string{"--account", "a@b.com", "contacts", "get", "people/c1"}); err != nil {
t.Fatalf("Execute: %v", err)
}
})
})
a := strings.Index(out, "custom:aaa\t1")
b := strings.Index(out, "custom:mmm\t2")
c := strings.Index(out, "custom:zzz\t3")
if a < 0 || b < 0 || c < 0 {
t.Fatalf("missing custom fields: %q", out)
}
if !(a < b && b < c) {
t.Fatalf("custom fields not sorted: %q", out)
}
}
func TestExecute_CalendarFreeBusy_Text(t *testing.T) {
origNew := newCalendarService
t.Cleanup(func() { newCalendarService = origNew })