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
This commit is contained in:
OpenClaw 2026-02-07 09:17:24 +00:00 committed by Peter Steinberger
parent 888e7d9b6a
commit 12bf5a23b3
2 changed files with 203 additions and 17 deletions

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,56 @@ 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) == "" {