aboutsummaryrefslogtreecommitdiff
path: root/main.go
diff options
context:
space:
mode:
Diffstat (limited to 'main.go')
-rw-r--r--main.go193
1 files changed, 193 insertions, 0 deletions
diff --git a/main.go b/main.go
new file mode 100644
index 0000000..3273867
--- /dev/null
+++ b/main.go
@@ -0,0 +1,193 @@
+package main
+
+import (
+ "encoding/xml"
+ "fmt"
+ "io/ioutil"
+ "log"
+ "net/http"
+ "os"
+ "os/exec"
+ "path"
+ "strings"
+
+ "github.com/BurntSushi/toml"
+ "github.com/emersion/go-vcard"
+ "github.com/google/shlex"
+ "github.com/kyoh86/xdg"
+)
+
+func main() {
+ if err := run(); err != nil {
+ log.Fatal(err)
+ }
+}
+
+func run() error {
+ cfg, err := loadConfig()
+ if err != nil {
+ return err
+ }
+ book := &AddressBook{
+ Endpoint: cfg.Endpoint,
+ Username: cfg.Username,
+ }
+ pw, err := cfg.getPassword()
+ if err != nil {
+ return fmt.Errorf("could not get password: %w", err)
+ }
+ book.Password = pw
+ var c CardDAVClient
+
+ if len(os.Args) < 2 {
+ return fmt.Errorf("insufficient command line arguments")
+ }
+
+ resp, err := c.Report(book, os.Args[1])
+ if err != nil {
+ return fmt.Errorf("could not fetch completions: %w", err)
+ }
+
+ body, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return fmt.Errorf("could not read response: %w", err)
+ }
+ defer resp.Body.Close()
+
+ var ms Multistatus
+ if err := xml.Unmarshal(body, &ms); err != nil {
+ return fmt.Errorf("could not unmarshal XML: %w", err)
+ }
+ for _, resp := range ms.Responses {
+ decoder := vcard.NewDecoder(strings.NewReader(resp.Propstat.Prop.AddressData))
+ card, err := decoder.Decode()
+ if err != nil {
+ return fmt.Errorf("could not decode vcard: %w", err)
+ }
+ emails := card.Values(vcard.FieldEmail)
+ fn := card.Get(vcard.FieldFormattedName)
+ if len(emails) == 0 {
+ continue
+ }
+ if fn == nil {
+ fn = &vcard.Field{}
+ }
+ for _, email := range emails {
+ fmt.Printf("%s\t%s\n", email, fn.Value)
+ }
+ }
+ return nil
+}
+
+type config struct {
+ Endpoint string `toml:"endpoint"`
+ Username string `toml:"username"`
+ Password string `toml:"password"`
+ PasswordCmd string `toml:"password-cmd"`
+}
+
+func (c config) getPassword() (string, error) {
+ if c.PasswordCmd != "" {
+ cmd, err := c.getPasswordCmd()
+ if err != nil {
+ return "", err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return "", fmt.Errorf("could not set up stdout pipe: %w", err)
+ }
+ if err := cmd.Start(); err != nil {
+ return "", fmt.Errorf("could not start password-cmd: %w", err)
+ }
+ res, err := ioutil.ReadAll(stdout)
+ if err != nil {
+ return "", fmt.Errorf("could not read stdout: %w", err)
+ }
+ if err := cmd.Wait(); err != nil {
+ return "", fmt.Errorf("could not run password-cmd: %w", err)
+ }
+ return strings.TrimSpace(string(res)), nil
+ }
+ return c.Password, nil
+}
+
+func (c config) getPasswordCmd() (*exec.Cmd, error) {
+ parts, err := shlex.Split(c.PasswordCmd)
+ if err != nil {
+ return nil, fmt.Errorf("could not lex password-cmd: %w", err)
+ }
+ if len(parts) < 1 {
+ return nil, fmt.Errorf("empty command")
+ }
+ if len(parts) > 1 {
+ return exec.Command(parts[0], parts[1:]...), nil
+ }
+ return exec.Command(parts[0]), nil
+}
+
+func loadConfig() (*config, error) {
+ filename := path.Join(xdg.ConfigHome(), "cdc", "config")
+ var cfg config
+ if _, err := toml.DecodeFile(filename, &cfg); err != nil {
+ return nil, fmt.Errorf("could not read config: %w", err)
+ }
+ return &cfg, nil
+}
+
+type AddressBook struct {
+ Endpoint string
+ Username string
+ Password string
+}
+
+type CardDAVClient struct {
+ hc http.Client
+}
+
+func (c *CardDAVClient) Report(book *AddressBook, search string) (*http.Response, error) {
+ req, err := http.NewRequest("REPORT", book.Endpoint, strings.NewReader(`<?xml version="1.0" encoding="utf-8"?>
+<C:addressbook-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:carddav">
+<D:prop>
+ <D:getetag />
+ <C:address-data>
+ <C:prop name="FN" />
+ <C:prop name="EMAIL" />
+ </C:address-data>
+</D:prop>
+<C:filter test="anyof">
+ <C:prop-filter name="EMAIL">
+ <C:text-match collation="i;unicode-casemap" match-type="contains">`+search+`</C:text-match>
+ </C:prop-filter>
+ <C:prop-filter name="FN">
+ <C:text-match collation="i;unicode-casemap" match-type="contains">`+search+`</C:text-match>
+ </C:prop-filter>
+</C:filter>
+</C:addressbook-query>`))
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Add("Depth", "1")
+ req.Header.Add("Content-Type", "text/xml; charset=\"utf-8\"")
+ if book.Username != "" || book.Password != "" {
+ req.SetBasicAuth(book.Username, book.Password)
+ }
+ return c.hc.Do(req)
+}
+
+type Multistatus struct {
+ Responses []Response `xml:"response"`
+}
+
+type Response struct {
+ HREF string `xml:"href"`
+ Propstat Propstat `xml:"propstat"`
+}
+
+type Propstat struct {
+ Prop Prop `xml:"prop"`
+}
+
+type Prop struct {
+ GetETag string `xml:"getetag"`
+ AddressData string `xml:"address-data"`
+}