feat(contacts): update contacts from JSON
Supersedes #200. - Add `contacts update --from-file <path|->` + `--ignore-etag` - Update mask from JSON keys; clears via `[]`/`null` - ETag safety + resourceName validation - Docs/tests/README/CHANGELOG updates Co-authored-by: Jeremy Rossi <jeremy@jeremyrossi.com>
This commit is contained in:
parent
4bffa81c2f
commit
e61769cb1b
@ -5,6 +5,7 @@
|
||||
### Added
|
||||
- Forms: add `forms` command group (create/get forms, list/get responses).
|
||||
- Apps Script: add `appscript` command group (create/get projects, fetch content, run deployed functions).
|
||||
- Contacts: update contacts from JSON via `contacts update --from-file` (PR #200 — thanks @jrossi).
|
||||
|
||||
### Fixed
|
||||
- Gmail: when `gmail attachment --out` points to a directory (or ends with a trailing slash), combine with `--name` and avoid false cache hits on directories. (#248) — thanks @zerone0x.
|
||||
|
||||
@ -873,6 +873,11 @@ gog contacts update people/<resourceName> \
|
||||
--birthday "1990-05-12" \
|
||||
--notes "Met at WWDC"
|
||||
|
||||
# Update via JSON (see docs/contacts-json-update.md)
|
||||
gog contacts get people/<resourceName> --json | \
|
||||
jq '(.contact.urls //= []) | (.contact.urls += [{"value":"obsidian://open?vault=notes&file=People/John%20Doe","type":"profile"}])' | \
|
||||
gog contacts update people/<resourceName> --from-file -
|
||||
|
||||
gog contacts delete people/<resourceName>
|
||||
|
||||
# Workspace directory (requires Google Workspace)
|
||||
|
||||
56
docs/contacts-json-update.md
Normal file
56
docs/contacts-json-update.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Update Contacts From JSON
|
||||
|
||||
`gog contacts update` supports JSON input via `--from-file`, so you can update People API fields without adding new CLI flags.
|
||||
|
||||
## Usage
|
||||
|
||||
Update from a file:
|
||||
|
||||
```bash
|
||||
gog contacts get people/c123456 --json > contact.json
|
||||
|
||||
# Edit contact.json (see notes below)
|
||||
gog contacts update people/c123456 --from-file contact.json
|
||||
```
|
||||
|
||||
Update from stdin:
|
||||
|
||||
```bash
|
||||
gog contacts get people/c123456 --json | \
|
||||
jq '(.contact.urls //= []) | (.contact.urls += [{"value":"https://example.com","type":"profile"}])' | \
|
||||
gog contacts update people/c123456 --from-file -
|
||||
```
|
||||
|
||||
## Input Formats
|
||||
|
||||
The command accepts:
|
||||
|
||||
- Wrapped (from `gog contacts get --json`): `{"contact": { ...person... }}`
|
||||
- Direct Person object: `{ ...person... }`
|
||||
|
||||
## What Can Be Updated
|
||||
|
||||
`--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`, ...).
|
||||
|
||||
If the JSON contains unsupported fields (for `updateContact`), gog errors instead of silently ignoring them.
|
||||
|
||||
Notes:
|
||||
|
||||
- Some fields are “singleton” for contact sources. Don’t include more than one value for `biographies`, `birthdays`, `genders`, or `names`.
|
||||
- If you update `memberships`, the Person must include contact group memberships or the API will error.
|
||||
|
||||
## Clearing Fields
|
||||
|
||||
Clearing list fields is supported by including the key with an empty value:
|
||||
|
||||
- Use `[]` to clear a list field (example: `"urls": []`)
|
||||
- Use `null` to clear a list field (example: `"biographies": null`)
|
||||
|
||||
## Concurrency (ETags)
|
||||
|
||||
To avoid overwriting concurrent contact edits, gog compares the JSON etag with the current contact etag:
|
||||
|
||||
- If they mismatch, update fails with an etag error.
|
||||
- Use `--ignore-etag` to apply your JSON changes to the latest version anyway.
|
||||
@ -295,7 +295,7 @@ Flag aliases:
|
||||
- `gog contacts list [--max N] [--page TOKEN]`
|
||||
- `gog contacts get <people/...|email>`
|
||||
- `gog contacts create --given NAME [--family NAME] [--email addr] [--phone num]`
|
||||
- `gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT]`
|
||||
- `gog contacts update <people/...> [--given NAME] [--family NAME] [--email addr] [--phone num] [--birthday YYYY-MM-DD] [--notes TEXT] [--from-file PATH|-] [--ignore-etag]`
|
||||
- `gog contacts delete <people/...>`
|
||||
- `gog contacts directory list [--max N] [--page TOKEN]`
|
||||
- `gog contacts directory search <query> [--max N] [--page TOKEN]`
|
||||
|
||||
@ -15,8 +15,10 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
contactsReadMask = "names,emailAddresses,phoneNumbers"
|
||||
contactsGetReadMask = contactsReadMask + ",birthdays"
|
||||
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"
|
||||
)
|
||||
|
||||
type ContactsListCmd struct {
|
||||
@ -218,6 +220,8 @@ type ContactsUpdateCmd struct {
|
||||
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)"`
|
||||
|
||||
// Extra People API fields (not previously exposed by gog)
|
||||
Birthday string `name:"birthday" help:"Birthday in YYYY-MM-DD (empty clears)"`
|
||||
@ -240,7 +244,14 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
return err
|
||||
}
|
||||
|
||||
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies").Do()
|
||||
if strings.TrimSpace(c.FromFile) != "" {
|
||||
if flagProvided(kctx, "given") || flagProvided(kctx, "family") || flagProvided(kctx, "email") || flagProvided(kctx, "phone") || flagProvided(kctx, "birthday") || flagProvided(kctx, "notes") {
|
||||
return usage("can't combine --from-file with other update flags")
|
||||
}
|
||||
return c.updateFromJSON(ctx, svc, resourceName, u)
|
||||
}
|
||||
|
||||
existing, err := svc.People.Get(resourceName).PersonFields(contactsReadMask + ",birthdays,biographies,metadata").Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@ -266,7 +277,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
}
|
||||
if flagProvided(kctx, "email") {
|
||||
if strings.TrimSpace(c.Email) == "" {
|
||||
existing.EmailAddresses = nil
|
||||
existing.EmailAddresses = nil // will be forced to [] for patch
|
||||
} else {
|
||||
existing.EmailAddresses = []*people.EmailAddress{{Value: strings.TrimSpace(c.Email)}}
|
||||
}
|
||||
@ -274,7 +285,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
}
|
||||
if flagProvided(kctx, "phone") {
|
||||
if strings.TrimSpace(c.Phone) == "" {
|
||||
existing.PhoneNumbers = nil
|
||||
existing.PhoneNumbers = nil // will be forced to [] for patch
|
||||
} else {
|
||||
existing.PhoneNumbers = []*people.PhoneNumber{{Value: strings.TrimSpace(c.Phone)}}
|
||||
}
|
||||
@ -283,7 +294,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
|
||||
if flagProvided(kctx, "birthday") {
|
||||
if strings.TrimSpace(c.Birthday) == "" {
|
||||
existing.Birthdays = nil
|
||||
existing.Birthdays = nil // will be forced to [] for patch
|
||||
} else {
|
||||
d, parseErr := parseYYYYMMDD(strings.TrimSpace(c.Birthday))
|
||||
if parseErr != nil {
|
||||
@ -299,7 +310,7 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
|
||||
if flagProvided(kctx, "notes") {
|
||||
if strings.TrimSpace(c.Notes) == "" {
|
||||
existing.Biographies = nil
|
||||
existing.Biographies = nil // will be forced to [] for patch
|
||||
} else {
|
||||
existing.Biographies = []*people.Biography{{
|
||||
Value: c.Notes,
|
||||
@ -314,6 +325,11 @@ func (c *ContactsUpdateCmd) Run(ctx context.Context, kctx *kong.Context, flags *
|
||||
return usage("no updates provided")
|
||||
}
|
||||
|
||||
for _, f := range updateFields {
|
||||
// Clearing list fields requires forcing them into the patch payload (Google API client omits empty values by default).
|
||||
forceSendEmptyPersonListField(existing, f)
|
||||
}
|
||||
|
||||
updated, err := svc.People.UpdateContact(resourceName, existing).
|
||||
UpdatePersonFields(strings.Join(updateFields, ",")).
|
||||
Do()
|
||||
|
||||
405
internal/cmd/contacts_update_json.go
Normal file
405
internal/cmd/contacts_update_json.go
Normal file
@ -0,0 +1,405 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"google.golang.org/api/people/v1"
|
||||
|
||||
"github.com/steipete/gogcli/internal/outfmt"
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
// contactsUpdateMaskFields matches the documented updatePersonFields values for
|
||||
// people.people.updateContact.
|
||||
var contactsUpdateMaskFields = map[string]struct{}{
|
||||
"addresses": {},
|
||||
"biographies": {},
|
||||
"birthdays": {},
|
||||
"calendarUrls": {},
|
||||
"clientData": {},
|
||||
"emailAddresses": {},
|
||||
"events": {},
|
||||
"externalIds": {},
|
||||
"genders": {},
|
||||
"imClients": {},
|
||||
"interests": {},
|
||||
"locales": {},
|
||||
"locations": {},
|
||||
"memberships": {},
|
||||
"miscKeywords": {},
|
||||
"names": {},
|
||||
"nicknames": {},
|
||||
"occupations": {},
|
||||
"organizations": {},
|
||||
"phoneNumbers": {},
|
||||
"relations": {},
|
||||
"sipAddresses": {},
|
||||
"urls": {},
|
||||
"userDefined": {},
|
||||
}
|
||||
|
||||
const (
|
||||
contactsJSONKeyContact = "contact"
|
||||
contactsJSONKeyETag = "etag"
|
||||
contactsJSONKeyMetadata = "metadata"
|
||||
contactsJSONKeyResource = "resourceName"
|
||||
)
|
||||
|
||||
func contactsPersonFieldToGoField(personField string) string {
|
||||
personField = strings.TrimSpace(personField)
|
||||
if personField == "" {
|
||||
return ""
|
||||
}
|
||||
return strings.ToUpper(personField[:1]) + personField[1:]
|
||||
}
|
||||
|
||||
func appendUnique(ss []string, v string) []string {
|
||||
for _, cur := range ss {
|
||||
if cur == v {
|
||||
return ss
|
||||
}
|
||||
}
|
||||
return append(ss, v)
|
||||
}
|
||||
|
||||
var contactsPersonListForceSend = map[string]func(*people.Person) bool{
|
||||
"addresses": func(p *people.Person) bool {
|
||||
if p.Addresses == nil {
|
||||
p.Addresses = []*people.Address{}
|
||||
}
|
||||
return len(p.Addresses) == 0
|
||||
},
|
||||
"biographies": func(p *people.Person) bool {
|
||||
if p.Biographies == nil {
|
||||
p.Biographies = []*people.Biography{}
|
||||
}
|
||||
return len(p.Biographies) == 0
|
||||
},
|
||||
"birthdays": func(p *people.Person) bool {
|
||||
if p.Birthdays == nil {
|
||||
p.Birthdays = []*people.Birthday{}
|
||||
}
|
||||
return len(p.Birthdays) == 0
|
||||
},
|
||||
"calendarUrls": func(p *people.Person) bool {
|
||||
if p.CalendarUrls == nil {
|
||||
p.CalendarUrls = []*people.CalendarUrl{}
|
||||
}
|
||||
return len(p.CalendarUrls) == 0
|
||||
},
|
||||
"clientData": func(p *people.Person) bool {
|
||||
if p.ClientData == nil {
|
||||
p.ClientData = []*people.ClientData{}
|
||||
}
|
||||
return len(p.ClientData) == 0
|
||||
},
|
||||
"emailAddresses": func(p *people.Person) bool {
|
||||
if p.EmailAddresses == nil {
|
||||
p.EmailAddresses = []*people.EmailAddress{}
|
||||
}
|
||||
return len(p.EmailAddresses) == 0
|
||||
},
|
||||
"events": func(p *people.Person) bool {
|
||||
if p.Events == nil {
|
||||
p.Events = []*people.Event{}
|
||||
}
|
||||
return len(p.Events) == 0
|
||||
},
|
||||
"externalIds": func(p *people.Person) bool {
|
||||
if p.ExternalIds == nil {
|
||||
p.ExternalIds = []*people.ExternalId{}
|
||||
}
|
||||
return len(p.ExternalIds) == 0
|
||||
},
|
||||
"genders": func(p *people.Person) bool {
|
||||
if p.Genders == nil {
|
||||
p.Genders = []*people.Gender{}
|
||||
}
|
||||
return len(p.Genders) == 0
|
||||
},
|
||||
"imClients": func(p *people.Person) bool {
|
||||
if p.ImClients == nil {
|
||||
p.ImClients = []*people.ImClient{}
|
||||
}
|
||||
return len(p.ImClients) == 0
|
||||
},
|
||||
"interests": func(p *people.Person) bool {
|
||||
if p.Interests == nil {
|
||||
p.Interests = []*people.Interest{}
|
||||
}
|
||||
return len(p.Interests) == 0
|
||||
},
|
||||
"locales": func(p *people.Person) bool {
|
||||
if p.Locales == nil {
|
||||
p.Locales = []*people.Locale{}
|
||||
}
|
||||
return len(p.Locales) == 0
|
||||
},
|
||||
"locations": func(p *people.Person) bool {
|
||||
if p.Locations == nil {
|
||||
p.Locations = []*people.Location{}
|
||||
}
|
||||
return len(p.Locations) == 0
|
||||
},
|
||||
"memberships": func(p *people.Person) bool {
|
||||
if p.Memberships == nil {
|
||||
p.Memberships = []*people.Membership{}
|
||||
}
|
||||
return len(p.Memberships) == 0
|
||||
},
|
||||
"miscKeywords": func(p *people.Person) bool {
|
||||
if p.MiscKeywords == nil {
|
||||
p.MiscKeywords = []*people.MiscKeyword{}
|
||||
}
|
||||
return len(p.MiscKeywords) == 0
|
||||
},
|
||||
"names": func(p *people.Person) bool {
|
||||
if p.Names == nil {
|
||||
p.Names = []*people.Name{}
|
||||
}
|
||||
return len(p.Names) == 0
|
||||
},
|
||||
"nicknames": func(p *people.Person) bool {
|
||||
if p.Nicknames == nil {
|
||||
p.Nicknames = []*people.Nickname{}
|
||||
}
|
||||
return len(p.Nicknames) == 0
|
||||
},
|
||||
"occupations": func(p *people.Person) bool {
|
||||
if p.Occupations == nil {
|
||||
p.Occupations = []*people.Occupation{}
|
||||
}
|
||||
return len(p.Occupations) == 0
|
||||
},
|
||||
"organizations": func(p *people.Person) bool {
|
||||
if p.Organizations == nil {
|
||||
p.Organizations = []*people.Organization{}
|
||||
}
|
||||
return len(p.Organizations) == 0
|
||||
},
|
||||
"phoneNumbers": func(p *people.Person) bool {
|
||||
if p.PhoneNumbers == nil {
|
||||
p.PhoneNumbers = []*people.PhoneNumber{}
|
||||
}
|
||||
return len(p.PhoneNumbers) == 0
|
||||
},
|
||||
"relations": func(p *people.Person) bool {
|
||||
if p.Relations == nil {
|
||||
p.Relations = []*people.Relation{}
|
||||
}
|
||||
return len(p.Relations) == 0
|
||||
},
|
||||
"sipAddresses": func(p *people.Person) bool {
|
||||
if p.SipAddresses == nil {
|
||||
p.SipAddresses = []*people.SipAddress{}
|
||||
}
|
||||
return len(p.SipAddresses) == 0
|
||||
},
|
||||
"urls": func(p *people.Person) bool {
|
||||
if p.Urls == nil {
|
||||
p.Urls = []*people.Url{}
|
||||
}
|
||||
return len(p.Urls) == 0
|
||||
},
|
||||
"userDefined": func(p *people.Person) bool {
|
||||
if p.UserDefined == nil {
|
||||
p.UserDefined = []*people.UserDefined{}
|
||||
}
|
||||
return len(p.UserDefined) == 0
|
||||
},
|
||||
}
|
||||
|
||||
func forceSendEmptyPersonListField(p *people.Person, personField string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
personField = strings.TrimSpace(personField)
|
||||
if personField == "" {
|
||||
return
|
||||
}
|
||||
|
||||
ensureFn := contactsPersonListForceSend[personField]
|
||||
if ensureFn == nil {
|
||||
return
|
||||
}
|
||||
if !ensureFn(p) {
|
||||
return
|
||||
}
|
||||
|
||||
goField := contactsPersonFieldToGoField(personField)
|
||||
p.ForceSendFields = appendUnique(p.ForceSendFields, goField)
|
||||
}
|
||||
|
||||
func forceSendEmptyPersonListFields(p *people.Person, personFields []string) {
|
||||
for _, f := range personFields {
|
||||
forceSendEmptyPersonListField(p, f)
|
||||
}
|
||||
}
|
||||
|
||||
func firstNonEmpty(ss ...string) string {
|
||||
for _, s := range ss {
|
||||
if strings.TrimSpace(s) != "" {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func contactSourceETag(p *people.Person) string {
|
||||
if p == nil || p.Metadata == nil {
|
||||
return ""
|
||||
}
|
||||
for _, s := range p.Metadata.Sources {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(s.Type, "CONTACT") && strings.TrimSpace(s.Etag) != "" {
|
||||
return strings.TrimSpace(s.Etag)
|
||||
}
|
||||
}
|
||||
for _, s := range p.Metadata.Sources {
|
||||
if s == nil {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(s.Etag) != "" {
|
||||
return strings.TrimSpace(s.Etag)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func openFileOrStdin(path string) (io.Reader, func(), error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return nil, nil, usage("missing --from-file path")
|
||||
}
|
||||
if path == "-" {
|
||||
return os.Stdin, nil, nil
|
||||
}
|
||||
// #nosec G304 -- user-controlled CLI input; reading arbitrary files is expected here.
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
return f, func() { _ = f.Close() }, nil
|
||||
}
|
||||
|
||||
func parseContactsUpdateJSON(data []byte) (*people.Person, map[string]json.RawMessage, error) {
|
||||
data = []byte(strings.TrimSpace(string(data)))
|
||||
if len(data) == 0 {
|
||||
return nil, nil, usage("empty JSON input")
|
||||
}
|
||||
|
||||
// Support wrapped format from `gog contacts get --json`: {"contact": {...}}.
|
||||
var outer map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &outer); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse JSON: %w", err)
|
||||
}
|
||||
if raw, ok := outer[contactsJSONKeyContact]; ok && len(raw) > 0 && raw[0] == '{' {
|
||||
data = raw
|
||||
}
|
||||
|
||||
var present map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &present); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse JSON object: %w", err)
|
||||
}
|
||||
var p people.Person
|
||||
if err := json.Unmarshal(data, &p); err != nil {
|
||||
return nil, nil, fmt.Errorf("parse contact JSON: %w", err)
|
||||
}
|
||||
return &p, present, nil
|
||||
}
|
||||
|
||||
func contactsUpdateMaskFromKeys(keys map[string]json.RawMessage) ([]string, error) {
|
||||
update := make([]string, 0, len(keys))
|
||||
unsupported := make([]string, 0)
|
||||
for k := range keys {
|
||||
if _, ok := contactsUpdateMaskFields[k]; ok {
|
||||
update = append(update, k)
|
||||
continue
|
||||
}
|
||||
switch k {
|
||||
case contactsJSONKeyResource, contactsJSONKeyETag, contactsJSONKeyMetadata:
|
||||
// Allowed (but not part of updatePersonFields).
|
||||
continue
|
||||
default:
|
||||
unsupported = append(unsupported, k)
|
||||
}
|
||||
}
|
||||
if len(unsupported) > 0 {
|
||||
sort.Strings(unsupported)
|
||||
return nil, usage("JSON contains unsupported keys for contacts update: " + strings.Join(unsupported, ", ") + ". Include only fields you want to change (for example: urls, biographies, addresses, organizations, ...). Tip: start from `gog contacts get ... --json` and delete keys you don't want to update.")
|
||||
}
|
||||
sort.Strings(update)
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func (c *ContactsUpdateCmd) updateFromJSON(ctx context.Context, svc *people.Service, resourceName string, u *ui.UI) error {
|
||||
reader, closeFn, err := openFileOrStdin(strings.TrimSpace(c.FromFile))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if closeFn != nil {
|
||||
defer closeFn()
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read JSON: %w", err)
|
||||
}
|
||||
|
||||
inputPerson, presentKeys, err := parseContactsUpdateJSON(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updateFields, err := contactsUpdateMaskFromKeys(presentKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(updateFields) == 0 {
|
||||
return usage("no updatable fields found in JSON (needs one of updatePersonFields fields like urls, biographies, ...)")
|
||||
}
|
||||
|
||||
// Fetch current metadata/etag (required by updateContact).
|
||||
cur, err := svc.People.Get(resourceName).PersonFields("metadata").Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
curETag := firstNonEmpty(contactSourceETag(cur), strings.TrimSpace(cur.Etag))
|
||||
inputETag := firstNonEmpty(contactSourceETag(inputPerson), strings.TrimSpace(inputPerson.Etag))
|
||||
if inputETag == "" {
|
||||
u.Err().Println("warning: JSON input is missing an etag; consider starting from `gog contacts get ... --json`")
|
||||
} else if !c.IgnoreETag && curETag != "" && inputETag != curETag {
|
||||
return usage("etag mismatch (contact changed). Re-run `gog contacts get ... --json`, re-apply edits, retry (or pass --ignore-etag).")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(inputPerson.ResourceName) != "" && strings.TrimSpace(inputPerson.ResourceName) != resourceName {
|
||||
return usage("resourceName in JSON does not match CLI argument")
|
||||
}
|
||||
|
||||
// Enforce resourceName and required metadata.
|
||||
inputPerson.ResourceName = resourceName
|
||||
inputPerson.Metadata = cur.Metadata
|
||||
if curETag != "" {
|
||||
inputPerson.Etag = curETag
|
||||
}
|
||||
|
||||
forceSendEmptyPersonListFields(inputPerson, updateFields)
|
||||
|
||||
updated, err := svc.People.UpdateContact(resourceName, inputPerson).
|
||||
UpdatePersonFields(strings.Join(updateFields, ",")).
|
||||
Do()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if outfmt.IsJSON(ctx) {
|
||||
return outfmt.WriteJSON(ctx, os.Stdout, map[string]any{"contact": updated})
|
||||
}
|
||||
u.Out().Printf("resource\t%s", updated.ResourceName)
|
||||
return nil
|
||||
}
|
||||
196
internal/cmd/contacts_update_json_more_test.go
Normal file
196
internal/cmd/contacts_update_json_more_test.go
Normal file
@ -0,0 +1,196 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/steipete/gogcli/internal/ui"
|
||||
)
|
||||
|
||||
func TestContactsUpdate_FromFile_JSON_CanClearFields(t *testing.T) {
|
||||
var gotUpdateFields string
|
||||
var gotURLsPresent bool
|
||||
var gotURLsLen int
|
||||
var gotBioPresent bool
|
||||
|
||||
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",
|
||||
"metadata": map[string]any{
|
||||
"sources": []map[string]any{
|
||||
{"type": "CONTACT", "etag": "etag-cur"},
|
||||
},
|
||||
},
|
||||
})
|
||||
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 urls, ok := body["urls"]; ok {
|
||||
gotURLsPresent = true
|
||||
if arr, ok := urls.([]any); ok {
|
||||
gotURLsLen = len(arr)
|
||||
}
|
||||
}
|
||||
if _, ok := body["biographies"]; ok {
|
||||
gotBioPresent = true
|
||||
}
|
||||
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)
|
||||
|
||||
withStdin(t, `{"resourceName":"people/c1","etag":"etag-cur","urls":[],"biographies":null}`, func() {
|
||||
if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Sorted mask.
|
||||
if gotUpdateFields != "biographies,urls" {
|
||||
t.Fatalf("unexpected updatePersonFields: %q", gotUpdateFields)
|
||||
}
|
||||
if !gotURLsPresent || gotURLsLen != 0 {
|
||||
t.Fatalf("expected urls present as empty list, present=%v len=%d", gotURLsPresent, gotURLsLen)
|
||||
}
|
||||
if !gotBioPresent {
|
||||
t.Fatalf("expected biographies present (clear)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsUpdate_FromFile_JSON_UnsupportedFieldErrors(t *testing.T) {
|
||||
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(closeSrv)
|
||||
stubPeopleServices(t, svc)
|
||||
|
||||
withStdin(t, `{"resourceName":"people/c1","photos":[]}`, func() {
|
||||
err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-"}, context.Background(), &RootFlags{Account: "a@b.com"})
|
||||
if err == nil || !strings.Contains(err.Error(), "photos") {
|
||||
t.Fatalf("expected unsupported field error mentioning photos, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContactsUpdate_FromFile_JSON_ETagMismatch(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",
|
||||
"metadata": map[string]any{
|
||||
"sources": []map[string]any{
|
||||
{"type": "CONTACT", "etag": "etag-cur"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(closeSrv)
|
||||
stubPeopleServices(t, svc)
|
||||
|
||||
withStdin(t, `{"resourceName":"people/c1","etag":"etag-old","urls":[{"value":"https://example.com"}]}`, func() {
|
||||
err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-"}, context.Background(), &RootFlags{Account: "a@b.com"})
|
||||
if err == nil || !strings.Contains(err.Error(), "etag mismatch") {
|
||||
t.Fatalf("expected etag mismatch error, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestContactsUpdate_FromFile_JSON_IgnoreETagAllowsUpdate(t *testing.T) {
|
||||
var gotETag 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",
|
||||
"metadata": map[string]any{
|
||||
"sources": []map[string]any{
|
||||
{"type": "CONTACT", "etag": "etag-cur"},
|
||||
},
|
||||
},
|
||||
})
|
||||
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)
|
||||
}
|
||||
gotETag, _ = body["etag"].(string)
|
||||
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)
|
||||
|
||||
withStdin(t, `{"resourceName":"people/c1","etag":"etag-old","urls":[{"value":"https://example.com"}]}`, func() {
|
||||
if err := runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", "-", "--ignore-etag"}, ctx, &RootFlags{Account: "a@b.com"}); err != nil {
|
||||
t.Fatalf("runKong: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
if gotETag != "etag-cur" {
|
||||
t.Fatalf("expected request to use current etag, got %q", gotETag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContactsUpdate_FromFile_CantCombineWithFlags(t *testing.T) {
|
||||
svc, closeSrv := newPeopleService(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(closeSrv)
|
||||
stubPeopleServices(t, svc)
|
||||
|
||||
tmp, err := os.CreateTemp(t.TempDir(), "contact-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateTemp: %v", err)
|
||||
}
|
||||
if _, writeErr := tmp.WriteString(`{"resourceName":"people/c1","urls":[]}`); writeErr != nil {
|
||||
t.Fatalf("write temp: %v", writeErr)
|
||||
}
|
||||
_ = tmp.Close()
|
||||
|
||||
err = runKong(t, &ContactsUpdateCmd{}, []string{"people/c1", "--from-file", tmp.Name(), "--email", "x@example.com"}, context.Background(), &RootFlags{Account: "a@b.com"})
|
||||
if err == nil || !strings.Contains(err.Error(), "can't combine --from-file") {
|
||||
t.Fatalf("expected combine error, got %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user