Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions internal/cmd/contacts.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ContactsCmd struct {
Delete ContactsDeleteCmd `cmd:"" name:"delete" aliases:"rm,del,remove" help:"Delete a contact"`
Directory ContactsDirectoryCmd `cmd:"" name:"directory" help:"Directory contacts"`
Other ContactsOtherCmd `cmd:"" name:"other" help:"Other contacts"`
Export ContactsExportCmd `cmd:"" name:"export" help:"Export contacts to .vcf (vCard) format"`
}

type ContactsSearchCmd struct {
Expand Down
64 changes: 64 additions & 0 deletions internal/cmd/contacts_crud.go
Original file line number Diff line number Diff line change
Expand Up @@ -672,3 +672,67 @@ func (c *ContactsDeleteCmd) Run(ctx context.Context, flags *RootFlags) error {
}
return writeDeleteResult(ctx, u, resourceName)
}

type ContactsExportCmd struct {
Max int64 `name:"max" aliases:"limit" help:"Max results" default:"100"`
Page string `name:"page" help:"Page token"`
Out string `name:"out" help:"Output file (default stdout)"`
Match string `name:"match" help:"Only export contacts matching query"`
}

func (c *ContactsExportCmd) Run(ctx context.Context, flags *RootFlags) error {
account, err := requireAccount(flags)
if err != nil {
return err
}

svc, err := newPeopleContactsService(ctx, account)
if err != nil {
return err
}

resp, err := svc.People.Connections.List(peopleMeResource).
PersonFields(contactsGetReadMask).
PageSize(c.Max).
PageToken(c.Page).
Do()
if err != nil {
return err
}

var result []string
for _, p := range resp.Connections {
if p == nil {
continue
}
if c.Match != "" && !contactMatchesQuery(p, c.Match) {
continue
}
result = append(result, exportToVcf(p))
}

if len(result) == 0 {
return nil
}

output := strings.Join(result, "\r\n")
if c.Out != "" {
return os.WriteFile(c.Out, []byte(output), 0o600)
}
fmt.Print(output)
return nil
}

func contactMatchesQuery(p *people.Person, query string) bool {
q := strings.ToLower(query)
if primaryName(p) != "" && strings.Contains(strings.ToLower(primaryName(p)), q) {
return true
}
if primaryEmail(p) != "" && strings.Contains(strings.ToLower(primaryEmail(p)), q) {
return true
}
if primaryPhone(p) != "" && strings.Contains(strings.ToLower(primaryPhone(p)), q) {
return true
}
return false
}
152 changes: 152 additions & 0 deletions internal/cmd/contacts_vcf.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package cmd

import (
"fmt"
"strings"

"google.golang.org/api/people/v1"
)

const (
vcfRelHome = "HOME"
vcfRelWork = "WORK"
vcfRelOther = "OTHER"
vcfRelCell = "CELL"
vcfRelMain = "MAIN"
)

func exportToVcf(p *people.Person) string {
if p == nil {
return ""
}
var b strings.Builder
b.WriteString("BEGIN:VCARD\r\n")
b.WriteString("VERSION:3.0\r\n")
if name := personFullName(p); name != "" {
fmt.Fprintf(&b, "FN:%s\r\n", escapeVcfValue(name))
}
if name := personStructuredName(p); name != "" {
fmt.Fprintf(&b, "N:%s\r\n", name)
}
for _, email := range allEmails(p) {
fmt.Fprintf(&b, "EMAIL:%s\r\n", escapeVcfValue(email.value))
}
for _, phone := range allPhones(p) {
fmt.Fprintf(&b, "TEL:%s\r\n", escapeVcfValue(phone.value))
}
for _, addr := range allAddresses(p) {
fmt.Fprintf(&b, "ADR:%s\r\n", escapeVcfValue(addr))
}
if org, title := primaryOrganization(p); org != "" {
fmt.Fprintf(&b, "ORG:%s\r\n", escapeVcfValue(org))
if title != "" {
fmt.Fprintf(&b, "TITLE:%s\r\n", escapeVcfValue(title))
}
}
for _, url := range allURLs(p) {
fmt.Fprintf(&b, "URL:%s\r\n", escapeVcfValue(url))
}
if bio := primaryBio(p); bio != "" {
fmt.Fprintf(&b, "NOTE:%s\r\n", escapeVcfValue(bio))
}
if birthday := primaryBirthday(p); birthday != "" {
fmt.Fprintf(&b, "BDAY:%s\r\n", escapeVcfValue(birthday))
}
b.WriteString("END:VCARD\r\n")
return b.String()
}

func personFullName(p *people.Person) string {
if p == nil || len(p.Names) == 0 || p.Names[0] == nil {
return ""
}
if p.Names[0].DisplayName != "" {
return p.Names[0].DisplayName
}
return strings.TrimSpace(strings.Join([]string{p.Names[0].GivenName, p.Names[0].FamilyName}, " "))
}

func personStructuredName(p *people.Person) string {
if p == nil || len(p.Names) == 0 || p.Names[0] == nil {
return ""
}
n := p.Names[0]
parts := []string{
nullString(n.FamilyName),
nullString(n.GivenName),
nullString(n.MiddleName),
nullString(n.HonorificPrefix),
nullString(n.HonorificSuffix),
}
return strings.Join(parts, ";")
}

func allEmails(p *people.Person) []emailInfo {
if p == nil || len(p.EmailAddresses) == 0 {
return nil
}
var emails []emailInfo
for _, e := range p.EmailAddresses {
if e == nil || e.Value == "" {
continue
}
emails = append(emails, emailInfo{value: e.Value, relType: vcfRelType(e.Type)})
}
return emails
}

type emailInfo struct {
value string
relType string
}

func allPhones(p *people.Person) []phoneInfo {
if p == nil || len(p.PhoneNumbers) == 0 {
return nil
}
var phones []phoneInfo
for _, ph := range p.PhoneNumbers {
if ph == nil || ph.Value == "" {
continue
}
phones = append(phones, phoneInfo{value: ph.Value, relType: vcfRelType(ph.Type)})
}
return phones
}

type phoneInfo struct {
value string
relType string
}

func vcfRelType(t string) string {
switch strings.ToLower(t) {
case "home":
return vcfRelHome
case "work":
return vcfRelWork
case "mobile", "cell":
return vcfRelCell
case "main":
return vcfRelMain
case "other":
return vcfRelOther
default:
if t != "" {
return strings.ToUpper(t)
}
return ""
}
}

func escapeVcfValue(s string) string {
s = strings.ReplaceAll(s, "\\", "\\\\")
s = strings.ReplaceAll(s, ";", "\\;")
s = strings.ReplaceAll(s, ",", "\\,")
s = strings.ReplaceAll(s, "\n", "\\n")
return s
}

func nullString(s string) string {
return s
}
Loading