Skip to content

Commit

Permalink
Implement simple vcard sync
Browse files Browse the repository at this point in the history
Signed-off-by: Charlie Egan <charlieegan3@users.noreply.github.com>
  • Loading branch information
charlieegan3 committed Sep 6, 2021
1 parent b211132 commit 0ee7a75
Show file tree
Hide file tree
Showing 12 changed files with 890 additions and 9 deletions.
32 changes: 25 additions & 7 deletions cmd/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ import (
"github.com/spf13/viper"

"github.com/charlieegan3/airtable-contacts/pkg/airtable"
"github.com/charlieegan3/airtable-contacts/pkg/carddav"
"github.com/charlieegan3/airtable-contacts/pkg/dropbox"
"github.com/charlieegan3/airtable-contacts/pkg/vcard"
)

var syncDropbox bool
var syncDropbox, syncCardDAV, syncFile bool

// syncCmd represents the sync command
var syncCmd = &cobra.Command{
Expand All @@ -49,32 +50,49 @@ var syncCmd = &cobra.Command{
log.Fatalf("failed to download contacts: %s", err)
}

// generate vcard for upload
// generate string for all contacts
vcardString, err := vcard.Generate(
records,
viper.GetBool("vcard.use_v3"),
viper.GetInt("vcard.photo.size"),
"",
)

err = os.WriteFile("out.vcard", []byte(vcardString), 0644)
if err != nil {
log.Fatal(err)
}

if syncCardDAV {
cardDAVClient := carddav.Client{
URL: viper.GetString("carddav.serverURL"),
User: viper.GetString("carddav.user"),
Password: viper.GetString("carddav.password"),
}

// records passed as vcard sync is done on per contact basis
err := carddav.Sync(cardDAVClient, records)
if err != nil {
log.Fatal(err)
}
}
if syncDropbox {
// store in dropbox for sync
dropboxClient := files.New(dbx.Config{
Token: viper.GetString("dropbox.token"),
LogLevel: dbx.LogOff,
})
dropbox.Upload(dropboxClient, viper.GetString("dropbox.path"), []byte(vcardString))
} else {
log.Println("no sync targets set, nothing uploaded during sync")
}
if syncFile {
err = os.WriteFile("out.vcard", []byte(vcardString), 0644)
if err != nil {
log.Fatal(err)
}
}
},
}

func init() {
syncCmd.Flags().BoolVar(&syncDropbox, "dropbox", false, "if set, dropbox will be synced")
syncCmd.Flags().BoolVar(&syncCardDAV, "carddav", false, "if set, carddav will be synced")
syncCmd.Flags().BoolVar(&syncFile, "file", false, "if set, local will saved")
rootCmd.AddCommand(syncCmd)
}
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ go 1.16

require (
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 // indirect
github.com/antchfx/xmlquery v1.3.6 // indirect
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6 // indirect
github.com/charlieegan3/special-days v0.0.0-20210701231319-175bc74510e3 // indirect
github.com/coreos/go-etcd v2.0.0+incompatible // indirect
Expand All @@ -14,7 +15,9 @@ require (
github.com/emersion/go-vcard v0.0.0-20210521075357-3445b9171995 // indirect
github.com/fsnotify/fsnotify v1.4.7 // indirect
github.com/gregdel/pushover v1.1.0 // indirect
github.com/maxatome/go-testdeep v1.10.0 // indirect
github.com/mehanizm/airtable v0.2.4 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sergi/go-diff v1.2.0 // indirect
github.com/spf13/cobra v1.1.3 // indirect
github.com/spf13/viper v1.7.0 // indirect
Expand Down
11 changes: 11 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/antchfx/xmlquery v1.3.6 h1:kaEVzH1mNo/2AJZrhZjAaAUTy2Nn2zxGfYYU8jWfXOo=
github.com/antchfx/xmlquery v1.3.6/go.mod h1:64w0Xesg2sTaawIdNqMB+7qaW/bSqkQm+ssPaCMWNnc=
github.com/antchfx/xpath v1.1.10 h1:cJ0pOvEdN/WvYXxvRrzQH9x5QWKpzHacYO8qzCcDYAg=
github.com/antchfx/xpath v1.1.10/go.mod h1:Yee4kTMuNiPYJ7nSNorELQMr1J33uOpXDMByNYhvtNk=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY=
Expand Down Expand Up @@ -67,6 +71,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsr
github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt9U=
github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
Expand Down Expand Up @@ -97,6 +102,7 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e h1:1r7pUrabqp18hOBcwBwiTsbnFeTZHV9eER/QT5JVZxY=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
Expand Down Expand Up @@ -189,6 +195,8 @@ github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
github.com/maxatome/go-testdeep v1.10.0 h1:s1pP+gqiylIm2Fqhscvec1bnvGmCLayVQW8iFkGNGKw=
github.com/maxatome/go-testdeep v1.10.0/go.mod h1:011SgQ6efzZYAen6fDn4BqQ+lUR72ysdyKe7Dyogw70=
github.com/mehanizm/airtable v0.2.4 h1:D4vSdtAWaAnjCkjFMEvU2QwEo6z6zLEbOpc6/kK9Np0=
github.com/mehanizm/airtable v0.2.4/go.mod h1:VcLiruKmStKYMtX9o77Dq+GHpVuJzMa9wjJcHKdgQNs=
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
Expand All @@ -210,6 +218,8 @@ github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
Expand Down Expand Up @@ -343,6 +353,7 @@ golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200813134508-3edf25e44fcc/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b h1:iFwSg7t5GZmB/Q5TjiEAsdoLDrdJRC1RiF2WhuV29Qw=
golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
Expand Down
116 changes: 116 additions & 0 deletions pkg/carddav/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package carddav

import (
"fmt"
"io/ioutil"
"net/http"
"strings"

"github.com/antchfx/xmlquery"
"github.com/pkg/errors"
)

// Client wraps functionality for calling carddav methods
type Client struct {
URL string
User string
Password string
}

func (c *Client) List() (items []string, err error) {
req, err := http.NewRequest("PROPFIND", c.URL, nil)
if err != nil {
return items, errors.Wrap(err, "failed to make PROPFIND request to list items in carddav endpoint")
}
req.SetBasicAuth(c.User, c.Password)
req.Header.Add("Depth", "1")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return items, errors.Wrap(err, "failed to make request")
}
defer resp.Body.Close()

if resp.StatusCode > 399 || resp.StatusCode < 100 {
return items, errors.Wrap(err, "server returned error: ")
}

doc, err := xmlquery.Parse(resp.Body)
if err != nil {
return items, errors.Wrap(err, "failed parse body as xml")
}

list, err := xmlquery.QueryAll(doc, "D:multistatus/D:response/D:href")
if err != nil {
return items, errors.Wrap(err, "failed to query body for hrefs")
}
for _, v := range list {
// only take vcf files from the list
if strings.HasSuffix(v.InnerText(), "vcf") {
id, err := extractID(v.InnerText())
if err != nil {
return items, errors.Wrap(err, "failed to get response item ID")
}
items = append(items, id)
}
}

return items, nil
}

func (c *Client) Delete(id string) (err error) {
url := fmt.Sprintf("%s/%s.vcf", strings.TrimSuffix(c.URL, "/"), id)

req, err := http.NewRequest("DELETE", url, nil)
if err != nil {
return errors.Wrap(err, "failed to make DELETE request")
}
req.SetBasicAuth(c.User, c.Password)

resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "failed to make request")
}
defer resp.Body.Close()

if resp.StatusCode > 399 || resp.StatusCode < 100 {
return errors.Wrap(err, "server returned error: ")
}
return nil
}

func (c *Client) Put(id string, vcardData string) (err error) {
url := fmt.Sprintf("%s/%s.vcf", strings.TrimSuffix(c.URL, "/"), id)

req, err := http.NewRequest("PUT", url, strings.NewReader(vcardData))
if err != nil {
return errors.Wrap(err, "failed to make PUT request")
}
req.SetBasicAuth(c.User, c.Password)
req.Header.Add("Content-Type", "text/vcard")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return errors.Wrap(err, "failed to make request")
}
defer resp.Body.Close()

if resp.StatusCode > 399 || resp.StatusCode < 100 {
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return errors.Wrap(err, "failed to read error body")
}
return fmt.Errorf("server returned error: %d, %s", resp.StatusCode, body)
}

return nil
}

func extractID(fullPath string) (id string, err error) {
parts := strings.Split(fullPath, "/")
if len(parts) != 7 {
return id, fmt.Errorf("unexpected number of path items: %d", len(parts))
}

return strings.TrimSuffix(parts[6], ".vcf"), nil
}
32 changes: 32 additions & 0 deletions pkg/carddav/delete_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package carddav

import (
"net/http"
"net/http/httptest"
"testing"

"github.com/maxatome/go-testdeep/td"
)

func TestDelete(t *testing.T) {
testServerCalled := false
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
td.Cmp(t, r.Method, "DELETE")
td.Cmp(t, r.Header["Authorization"], []string{"Basic YWxpY2U6cGFzc3dvcmQ="})
td.Cmp(t, r.URL.Path, "/dav/addressbooks/user/charlieegan3@fastmail.com/Default/test-id.vcf")
testServerCalled = true
}))

cardDavClient := Client{
URL: testServer.URL + "/dav/addressbooks/user/charlieegan3@fastmail.com/Default/",
User: "alice",
Password: "password",
}

err := cardDavClient.Delete("test-id")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

td.Cmp(t, testServerCalled, true)
}
40 changes: 40 additions & 0 deletions pkg/carddav/list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package carddav

import (
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"

"github.com/maxatome/go-testdeep/td"
)

func TestList(t *testing.T) {
testServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
td.Cmp(t, r.Method, "PROPFIND")
td.Cmp(t, r.Header["Authorization"], []string{"Basic YWxpY2U6cGFzc3dvcmQ="})
td.Cmp(t, r.Header["Depth"], []string{"1"})

b, err := ioutil.ReadFile("propfind.xml")
td.Cmp(t, err, nil)
w.Write(b)
}))

expectedItems := []string{
"6316e64d-176c-42bc-9d59-30ed53c1a06b",
"a5fadfc4-27fc-459c-847d-c8dabe3f789e",
}

cardDavClient := Client{
URL: testServer.URL + "/dav/addressbooks/user/charlieegan3@fastmail.com/Default",
User: "alice",
Password: "password",
}

gotItems, err := cardDavClient.List()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

td.Cmp(t, gotItems, expectedItems)
}

0 comments on commit 0ee7a75

Please sign in to comment.