---
README.md | 8 +-
caldavserver.go | 202 +++++++++++++++++++++++++++++
config-sample.json | 5 +-
defines.go | 25 ++--
directory.go | 131 +++++++++++++++++++
helpers.go | 308 +++++++++++++++++---------------------------
main.go | 310 +++++++++++++++------------------------------
parse.go | 114 ++++++-----------
vcard.templ | 21 +++
9 files changed, 636 insertions(+), 488 deletions(-)
create mode 100644 caldavserver.go
create mode 100644 directory.go
create mode 100644 vcard.templ
diff --git a/README.md b/README.md
index 96fa2c9..e5fcca8 100644
--- a/README.md
+++ b/README.md
@@ -16,12 +16,13 @@ creation and editing of entries.
- Import VCF files
- Display VCF files
- Easy setup
-
+- Supports ICS directories, as made by vdirsyncer
## Installation / Configuration
- Have Go installed
-- make && sudo make install (for MacOS: make darwin)
+- make && sudo make install (for MacOS: make darwin) OR
+- go build .
- copy config-sample.json to ~/.config/qcard/config.json and modify accordingly
### Arch AUR package
@@ -111,6 +112,5 @@ To use qcard as your addressbook in neomutt, put the following in your neomuttrc
## About
-Questions? Ideas? File bugs and TODOs through the [issue
-tracker](https://todo.sr.ht/~psic4t/qcard) or send an email to
+Questions? Ideas? File bugs and TODOs through the [issue tracker](https://todo.sr.ht/~psic4t/qcard) or send an email to
[~psic4t/qcard@todo.sr.ht](mailto:~psic4t/qcard@todo.sr.ht)
diff --git a/caldavserver.go b/caldavserver.go
new file mode 100644
index 0000000..9732c40
--- /dev/null
+++ b/caldavserver.go
@@ -0,0 +1,202 @@
+package main
+
+import (
+ "bufio"
+ "encoding/xml"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "os"
+ "path"
+ "strings"
+)
+
+type caldavserver struct {
+ Url string
+ Username string
+ Password string
+ calNo int
+}
+
+func (c caldavserver) getAbProps(receiver chan calProps, errChan chan error) {
+ defer close(receiver)
+ defer close(errChan)
+ req, err := http.NewRequest("PROPFIND", c.Url, nil)
+ req.SetBasicAuth(c.Username, c.Password)
+
+ cli := &http.Client{}
+ resp, err := cli.Do(req)
+
+ if err != nil {
+ errChan <- err
+ return
+ }
+
+ xmlContent, _ := ioutil.ReadAll(resp.Body)
+ defer resp.Body.Close()
+
+ xmlProps := xmlProps{}
+ err = xml.Unmarshal(xmlContent, &xmlProps)
+ if err != nil {
+ errChan <- err
+ return
+ }
+ displayName := xmlProps.DisplayName
+
+ thisCal := calProps{
+ calNo: c.calNo,
+ displayName: displayName,
+ source: c.Url,
+ }
+ receiver <- thisCal
+}
+
+func (c caldavserver) deleteContact(contactFilename string) error {
+ if contactFilename == "" {
+ fmt.Errorf("No contact filename given")
+ }
+
+ req, _ := http.NewRequest("DELETE", c.Url+contactFilename, nil)
+ req.SetBasicAuth(c.Username, c.Password)
+
+ cli := &http.Client{}
+ resp, err := cli.Do(req)
+ defer resp.Body.Close()
+ if err != nil {
+ return err
+ }
+ return nil
+}
+
+func (c caldavserver) dumpContact(contactFilename string, toFile bool) error {
+ req, _ := http.NewRequest("GET", c.Url+contactFilename, nil)
+ req.SetBasicAuth(c.Username, c.Password)
+
+ cli := &http.Client{}
+ resp, err := cli.Do(req)
+ defer resp.Body.Close()
+ if err != nil {
+ return err
+ }
+ //fmt.Println(resp.Status)
+ xmlContent, _ := ioutil.ReadAll(resp.Body)
+
+ if toFile {
+ // create cache dir if not exists
+ os.MkdirAll(cacheLocation, os.ModePerm)
+ err := ioutil.WriteFile(cacheLocation+"/"+contactFilename, xmlContent, 0644)
+ if err != nil {
+ return err
+ }
+ } else {
+ fmt.Println(string(xmlContent))
+ }
+ return nil
+}
+
+func (c caldavserver) uploadVCF(contactFilePath string, contactEdit bool) error {
+ var vcfData string
+ var contactVCF string
+ var contactFileName string
+
+ if contactFilePath == "-" {
+ scanner := bufio.NewScanner(os.Stdin)
+
+ for scanner.Scan() {
+ vcfData += scanner.Text() + "\n"
+ }
+ //eventICS, _ = explodeEvent(&icsData)
+ contactVCF = vcfData
+ contactFileName = genUUID() + `.ics`
+ fmt.Println(contactVCF)
+
+ } else {
+ //eventICS, err := ioutil.ReadFile(cacheLocation + "/" + eventFilename)
+ contactVCFByte, err := ioutil.ReadFile(contactFilePath)
+ if err != nil {
+ return err
+ }
+
+ contactVCF = string(contactVCFByte)
+ if contactEdit == true {
+ contactFileName = path.Base(contactFilePath) // use old filename again
+ } else {
+ contactFileName = genUUID() + `.ics` // no edit, so new filename
+ }
+ }
+ req, _ := http.NewRequest("PUT", c.Url+contactFileName, strings.NewReader(contactVCF))
+ req.SetBasicAuth(c.Username, c.Password)
+ req.Header.Add("Content-Type", "text/calendar; charset=utf-8")
+
+ cli := &http.Client{}
+ resp, err := cli.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ fmt.Println(resp.Status)
+
+ return nil
+}
+
+func (c caldavserver) fetchAbData(receiver chan contactStruct, errChan chan error) {
+ defer close(receiver)
+ defer close(errChan)
+ var xmlBody string
+
+ xmlBody = `<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop>
+ <d:getetag /><c:address-data />
+ </d:prop></c:addressbook-query>`
+
+ //fmt.Println(xmlBody)
+ req, err := http.NewRequest("REPORT", c.Url, strings.NewReader(xmlBody))
+ req.SetBasicAuth(c.Username, c.Password)
+ req.Header.Add("Content-Type", "application/xml; charset=utf-8")
+ req.Header.Add("Depth", "1") // needed for SabreDAV
+ req.Header.Add("Prefer", "return-minimal")
+
+ cli := &http.Client{}
+ resp, err := cli.Do(req)
+ if err != nil {
+ errChan <- err
+ return
+ }
+
+ xmlContent, _ := ioutil.ReadAll(resp.Body)
+ defer resp.Body.Close()
+
+ //fmt.Println(string(xmlContent))
+ xmlData := XmlDataStruct{}
+ err = xml.Unmarshal(xmlContent, &xmlData)
+ if err != nil {
+ errChan <- err
+ return
+ }
+
+ for i := range xmlData.Elements {
+ contactData := xmlData.Elements[i].Data
+ contactHref := xmlData.Elements[i].Href
+ ABColor := Colors[c.calNo%len(Colors)]
+ rv := parseMain(contactData, contactHref, ABColor)
+ if rv.fullName != "" {
+ receiver <- rv
+ }
+ }
+}
+
+func (c caldavserver) createContact(contactData string) error {
+ str, err := createContact(contactData)
+ if err != nil {
+ return err
+ }
+ newElem := genUUID() + `.vcf`
+
+ req, _ := http.NewRequest("PUT", c.Url+newElem, strings.NewReader(str))
+ req.SetBasicAuth(c.Username, c.Password)
+ req.Header.Add("Content-Type", "application/xml; charset=utf-8")
+
+ cli := &http.Client{}
+ resp, err := cli.Do(req)
+ resp.Body.Close()
+ return err
+}
diff --git a/config-sample.json b/config-sample.json
index d13f47a..532c494 100644
--- a/config-sample.json
+++ b/config-sample.json
@@ -4,8 +4,11 @@
"Url":"https://my.server.de/calendar/",
"Username":"username",
"Password":"supersecret"
+ },
+ {
+ "Path": "/home/user/.contacts"
}
],
- "DetailThreshold": 3,
+ "DetailThreshold": 3,
"SortByLastname": false
}
diff --git a/defines.go b/defines.go
index ea751b2..865b31f 100644
--- a/defines.go
+++ b/defines.go
@@ -1,6 +1,7 @@
package main
import (
+ _ "embed"
"encoding/xml"
"os"
"time"
@@ -20,12 +21,10 @@ var showEmailOnly *bool
var displayFlag bool
var toFile bool
var filter string
-var orgFilter string
var searchterm string
-//var colorBlock string = "█"
+// var colorBlock string = "█"
var colorBlock string = "|"
-var contactsSlice []contactStruct
var Colors = [10]string{"\033[0;31m", "\033[0;32m", "\033[1;33m", "\033[1;34m", "\033[1;35m", "\033[1;36m", "\033[1;37m", "\033[1;38m", "\033[1;39m", "\033[1;40m"}
var showColor bool = true
var qcardversion string = "0.6.0"
@@ -41,12 +40,17 @@ const (
ColBlue = "\033[1;34m"
)
+type addressBook interface {
+ getAbProps(chan calProps, chan error)
+ fetchAbData(chan contactStruct, chan error)
+ deleteContact(string) error
+ dumpContact(string, bool) error
+ uploadVCF(string, bool) error
+ createContact(string) error
+}
+
type configStruct struct {
- Addressbooks []struct {
- Url string
- Username string
- Password string
- }
+ Addressbooks []addressBook
DetailThreshold int
SortByLastname bool
}
@@ -86,7 +90,7 @@ type xmlProps struct {
type calProps struct {
calNo int
displayName string
- url string
+ source string
color string
}
@@ -101,3 +105,6 @@ type xmlDataElements struct {
ETag string `xml:"propstat>prop>getetag"`
Data string `xml:"propstat>prop>address-data"`
}
+
+//go:embed "vcard.templ"
+var contactSkel string
diff --git a/directory.go b/directory.go
new file mode 100644
index 0000000..a131704
--- /dev/null
+++ b/directory.go
@@ -0,0 +1,131 @@
+package main
+
+import (
+ "fmt"
+ "io"
+ "io/ioutil"
+ "os"
+ "path"
+ "path/filepath"
+)
+
+type directory struct {
+ Path string
+ calNo int
+}
+
+func (c directory) getAbProps(receiver chan calProps, errChan chan error) {
+ defer close(receiver)
+ defer close(errChan)
+ ds, err := os.ReadDir(c.Path)
+ fmt.Printf("getAbProps have %d contacts\n", len(ds))
+ if err != nil {
+ errChan <- err
+ return
+ }
+ for i, d := range ds {
+ xp := calProps{
+ calNo: i,
+ displayName: d.Name(),
+ source: d.Name(),
+ }
+ receiver <- xp
+ }
+}
+
+func (c directory) deleteContact(contactFilename string) error {
+ if contactFilename == "" {
+ return fmt.Errorf("No contact filename given")
+ }
+ if err := os.Remove(contactFilename); err != nil {
+ return err
+ }
+ return nil
+}
+
+func (c directory) dumpContact(contactFilename string, toFile bool) error {
+ xmlContent, err := ioutil.ReadFile(contactFilename)
+ if err != nil {
+ return err
+ }
+
+ if toFile {
+ // create cache dir if not exists
+ os.MkdirAll(cacheLocation, os.ModePerm)
+ err := ioutil.WriteFile(cacheLocation+"/"+contactFilename, xmlContent, 0644)
+ if err != nil {
+ return err
+ }
+ } else {
+ fmt.Println(string(xmlContent))
+ }
+ return nil
+}
+
+func (c directory) uploadVCF(contactFilePath string, contactEdit bool) error {
+ var contactFileName string
+ var fin io.Reader
+
+ if contactFilePath == "-" {
+ fin = os.Stdin
+ contactFileName = genUUID() + `.ics` // no edit, so new filename
+ } else {
+ var err error
+ fin, err = os.Open(contactFilePath)
+ if err != nil {
+ return err
+ }
+ if contactEdit == true {
+ contactFileName = path.Base(contactFilePath) // use old filename again
+ } else {
+ contactFileName = genUUID() + `.ics` // no edit, so new filename
+ }
+ }
+ fout, err := os.Create(filepath.Join(c.Path, contactFileName))
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(fout, fin)
+ return err
+}
+
+func (c directory) fetchAbData(receiver chan contactStruct, errChan chan error) {
+ defer close(receiver)
+ defer close(errChan)
+ ds, err := os.ReadDir(c.Path)
+ if err != nil {
+ errChan <- err
+ return
+ }
+ for _, file := range ds {
+ contactData, err := ioutil.ReadFile(filepath.Join(c.Path, file.Name()))
+ if err != nil {
+ errChan <- err
+ return
+ }
+ contactHref := file.Name()
+ ABColor := Colors[c.calNo%len(Colors)]
+ rv := parseMain(string(contactData), contactHref, ABColor)
+ if rv.fullName != "" {
+ receiver <- rv
+ }
+ }
+}
+
+func (c directory) createContact(contactData string) error {
+ str, err := createContact(contactData)
+ if err != nil {
+ return err
+ }
+ newElem := genUUID() + `.vcf`
+ // write to file
+ fout, err := os.Create(filepath.Join(c.Path, newElem))
+ if err != nil {
+ return err
+ }
+ _, err = fout.WriteString(str)
+ if err != nil {
+ return err
+ }
+ return fout.Close()
+}
diff --git a/helpers.go b/helpers.go
index a5df934..c22484b 100644
--- a/helpers.go
+++ b/helpers.go
@@ -4,103 +4,144 @@ import (
"bufio"
"crypto/rand"
"encoding/json"
- "encoding/xml"
"fmt"
"io/ioutil"
"log"
- "net/http"
- "net/url"
"os"
- "os/exec"
"path"
"regexp"
"sort"
"strconv"
"strings"
- "sync"
+ "text/template"
+ "time"
)
-func getConf() *configStruct {
+func getConf() configStruct {
configData, err := ioutil.ReadFile(configLocation)
if err != nil {
fmt.Print("Config not found. \n\nPlease copy config-sample.json to ~/.config/qcard/config.json and modify it accordingly.\n\n")
log.Fatal(err)
}
+ configMap := make(map[string]interface{})
- conf := configStruct{}
- err = json.Unmarshal(configData, &conf)
- //fmt.Println(conf)
+ err = json.Unmarshal(configData, &configMap)
if err != nil {
- log.Fatal(err)
+ log.Fatalf("error loading config from %s: %s", configLocation, err)
}
-
- return &conf
-}
-
-func getAbProps(calNo int, p *[]calProps, wg *sync.WaitGroup) {
- req, err := http.NewRequest("PROPFIND", config.Addressbooks[calNo].Url, nil)
- req.SetBasicAuth(config.Addressbooks[calNo].Username, config.Addressbooks[calNo].Password)
-
- /*tr := &http.Transport{
- TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
+ conf := configStruct{
+ Addressbooks: make([]addressBook, 0),
}
- cli := &http.Client{Transport: tr}*/
- cli := &http.Client{}
- resp, err := cli.Do(req)
-
- if err != nil {
- log.Fatal(err)
+ if dt, ok := configMap["DetailThreshold"].(float64); ok {
+ conf.DetailThreshold = int(dt)
}
-
- xmlContent, _ := ioutil.ReadAll(resp.Body)
- defer resp.Body.Close()
-
- xmlProps := xmlProps{}
- err = xml.Unmarshal(xmlContent, &xmlProps)
- if err != nil {
- log.Fatal(err)
+ if dt, ok := configMap["SortByLastname"].(bool); ok {
+ conf.SortByLastname = dt
}
- displayName := xmlProps.DisplayName
-
- thisCal := calProps{
- calNo: calNo,
- displayName: displayName,
- url: config.Addressbooks[calNo].Url,
+ var ctr int
+ if ab, ok := configMap["Addressbooks"].([]interface{}); ok {
+ for _, ma := range ab {
+ if m, ok := ma.(map[string]interface{}); ok {
+ if m["Url"] != nil {
+ conf.Addressbooks = append(conf.Addressbooks, caldavserver{
+ Url: m["Url"].(string),
+ Username: m["Username"].(string),
+ Password: m["Password"].(string),
+ calNo: ctr,
+ })
+ } else if m["Path"] != "" {
+ conf.Addressbooks = append(conf.Addressbooks, directory{
+ Path: m["Path"].(string),
+ calNo: ctr,
+ })
+ }
+ }
+ }
+ ctr++
}
- *p = append(*p, thisCal)
- wg.Done()
+ return conf
}
func getAbList() {
- p := []calProps{}
-
- var wg sync.WaitGroup
- wg.Add(len(config.Addressbooks)) // waitgroup length = num calendars
+ props := make(chan calProps, 10)
+ errs := make(chan error, 3)
+ for _, a := range config.Addressbooks {
+ go a.getAbProps(props, errs)
+ }
- for i := range config.Addressbooks {
- go getAbProps(i, &p, &wg)
+ p := []calProps{}
+ for prop := range props {
+ p = append(p, prop)
+ }
+ for err := range errs {
+ fmt.Printf("error getting ab list: %v\n", err)
}
- wg.Wait()
sort.Slice(p, func(i, j int) bool {
return p[i].calNo < (p[j].calNo)
})
- for i := range p {
- u, err := url.Parse(config.Addressbooks[i].Url)
- if err != nil {
- log.Fatal(err)
- }
- fmt.Println(`[` + fmt.Sprintf("%v", i) + `] - ` + Colors[i] + colorBlock + ColDefault +
- ` ` + p[i].displayName + ` (` + u.Hostname() + `)`)
+ for i, pi := range p {
+ fmt.Printf("[%v] - %s%s%s %s (%s)\n", i, Colors[i], colorBlock, ColDefault, pi.displayName, pi.source)
}
}
-func checkError(e error) {
- if e != nil {
- fmt.Println(e)
+var keyMap map[string]string = map[string]string{
+ "M": "phoneCell",
+ "P": "phoneHome",
+ "p": "phoneWork",
+ "E": "emailHome",
+ "e": "emailWork",
+ "A": "addressHome",
+ "a": "addressWork",
+ "O": "organisation",
+ "B": "birthday",
+ "T": "title",
+ "R": "role",
+ "I": "nickname",
+ "n": "note",
+}
+
+// makeMap parses a bespoke string and returns a map of attributes
+// M: phoneCell
+// P: phoneHome
+// p: phoneWork
+// E: emailHome
+// e: emailWork
+// A: addressHome
+// a: addressWork
+// O: organisation
+// B: birthday
+// T: title
+// R: role
+// I: nickname
+// n: note
+//
+// FIXME this is so horribly fragile it's frightening
+func makeMap(s string) (map[string]string, error) {
+ re := regexp.MustCompile(`\s[a-z,A-Z]:`)
+ re.ReplaceAllStringFunc(s, func(x string) string {
+ s = strings.Replace(s, x, "::"+x, -1)
+ return s
+ })
+ rv := make(map[string]string)
+ for _, sets := range strings.Split(s, "::") {
+ ks := strings.Split(sets, ":")
+ k := len(ks)
+ switch {
+ case k > 2:
+ return rv, fmt.Errorf("parse error at %s", sets)
+ case k == 2:
+ rv[ks[0]] = ks[1]
+ case k == 1:
+ // This is the name
+ rv["fullName"] = ks[0]
+ default:
+ // empty? skip
+ }
}
+ return rv, nil
}
func splitAfter(s string, re *regexp.Regexp) (r []string) {
@@ -139,7 +180,6 @@ func (e contactStruct) fancyOutput() {
fmt.Println(e.fullName)
}
}
-
if showDetails {
if e.title != "" {
fmt.Println(` T: ` + e.title)
@@ -230,109 +270,6 @@ func filterMatch(fullName string) bool {
return re.FindString(fullName) != ""
}
-func filterOrgMatch(org string) bool {
- re, _ := regexp.Compile(`(?i)` + orgFilter)
- return re.FindString(org) != ""
-}
-
-func deleteContact(abNo int, contactFilename string) (status string) {
- if contactFilename == "" {
- log.Fatal("No contact filename given")
- }
-
- req, _ := http.NewRequest("DELETE", config.Addressbooks[abNo].Url+contactFilename, nil)
- req.SetBasicAuth(config.Addressbooks[abNo].Username, config.Addressbooks[abNo].Password)
-
- cli := &http.Client{}
- resp, err := cli.Do(req)
- defer resp.Body.Close()
- if err != nil {
- log.Fatal(err)
- }
- fmt.Println(resp.Status)
-
- return
-}
-
-func dumpContact(abNo int, contactFilename string, toFile bool) (status string) {
- //fmt.Println(config.Addressbooks[calNo].Url + eventFilename)
- if abNo == 1000 {
- abNo = 0 // use first addressbook if not set
- }
-
- req, _ := http.NewRequest("GET", config.Addressbooks[abNo].Url+contactFilename, nil)
- req.SetBasicAuth(config.Addressbooks[abNo].Username, config.Addressbooks[abNo].Password)
-
- cli := &http.Client{}
- resp, err := cli.Do(req)
- defer resp.Body.Close()
- if err != nil {
- log.Fatal(err)
- }
- //fmt.Println(resp.Status)
- xmlContent, _ := ioutil.ReadAll(resp.Body)
-
- if toFile {
- // create cache dir if not exists
- os.MkdirAll(cacheLocation, os.ModePerm)
- err := ioutil.WriteFile(cacheLocation+"/"+contactFilename, xmlContent, 0644)
- if err != nil {
- log.Fatal(err)
- }
- return contactFilename + " written"
- } else {
- fmt.Println(string(xmlContent))
- return
- }
-}
-
-func uploadVCF(abNo int, contactFilePath string, contactEdit bool) (status string) {
- //fmt.Println(config.Calendars[calNo].Url + eventFilePath)
-
- var vcfData string
- var contactVCF string
- var contactFileName string
-
- if contactFilePath == "-" {
- scanner := bufio.NewScanner(os.Stdin)
-
- for scanner.Scan() {
- vcfData += scanner.Text() + "\n"
- }
- //eventICS, _ = explodeEvent(&icsData)
- contactVCF = vcfData
- contactFileName = genUUID() + `.ics`
- fmt.Println(contactVCF)
-
- } else {
- //eventICS, err := ioutil.ReadFile(cacheLocation + "/" + eventFilename)
- contactVCFByte, err := ioutil.ReadFile(contactFilePath)
- if err != nil {
- log.Fatal(err)
- }
-
- contactVCF = string(contactVCFByte)
- if contactEdit == true {
- contactFileName = path.Base(contactFilePath) // use old filename again
- } else {
- contactFileName = genUUID() + `.ics` // no edit, so new filename
- }
- }
- req, _ := http.NewRequest("PUT", config.Addressbooks[abNo].Url+contactFileName, strings.NewReader(contactVCF))
- req.SetBasicAuth(config.Addressbooks[abNo].Username, config.Addressbooks[abNo].Password)
- req.Header.Add("Content-Type", "text/calendar; charset=utf-8")
-
- cli := &http.Client{}
- resp, err := cli.Do(req)
- defer resp.Body.Close()
- if err != nil {
- log.Fatal(err)
- }
- fmt.Println(resp.Status)
-
- return
-}
-
func displayVCF() {
scanner := bufio.NewScanner(os.Stdin)
@@ -342,9 +279,9 @@ func displayVCF() {
vcfData += scanner.Text() + "\n"
}
- parseMain(&vcfData, &contactsSlice, "none", "none")
- for _, e := range contactsSlice {
- e.vcfOutput()
+ s := parseMain(vcfData, "none", "none")
+ if s.fullName != "" {
+ s.vcfOutput()
}
if err := scanner.Err(); err != nil {
@@ -353,35 +290,26 @@ func displayVCF() {
}
-func editContact(abNo int, contactFilename string) (status string) {
- toFile = true
- contactEdit := true
- dumpContact(abNo, contactFilename, toFile)
- //fmt.Println(appointmentEdit)
- filePath := cacheLocation + "/" + contactFilename
- fileInfo, err := os.Stat(filePath)
+func createContact(contactData string) (string, error) {
+ data, err := makeMap(contactData)
if err != nil {
- log.Fatal(err)
+ return "", err
}
- beforeMTime := fileInfo.ModTime()
+ data["time"] = time.Now().Format(time.RFC3339)
+ data["uuid"] = genUUID()
- shell := exec.Command(editor, filePath)
- shell.Stdout = os.Stdin
- shell.Stdin = os.Stdin
- shell.Stderr = os.Stderr
- shell.Run()
+ fullName := data["fullName"]
+ lastInd := strings.LastIndex(fullName, " ") // split name at last space
+ name := fullName[lastInd+1:] + ";" + fullName[0:lastInd] + ";;;" // lastname, givenname1 givenname2
+ data["name"] = name
- fileInfo, err = os.Stat(filePath)
+ var out strings.Builder
+ t := template.New("vcard")
+ te, err := t.Parse(contactSkel)
if err != nil {
- log.Fatal(err)
+ return "", err
}
- afterMTime := fileInfo.ModTime()
- if beforeMTime.Before(afterMTime) {
- uploadVCF(abNo, filePath, contactEdit)
- } else {
- log.Fatal("no changes")
- }
-
- return
+ err = te.Execute(&out, data)
+ return out.String(), err
}
diff --git a/main.go b/main.go
index 99c6392..69a7311 100644
--- a/main.go
+++ b/main.go
@@ -2,81 +2,103 @@ package main
import (
// "bytes"
- "encoding/xml"
"flag"
"fmt"
- "io/ioutil"
"log"
- "net/http"
"os"
- "regexp"
+ "os/exec"
"sort"
//"strconv"
- "strings"
- "sync"
- "time"
)
-var config = getConf()
+var config configStruct
-func fetchAbData(abNo int, wg *sync.WaitGroup) {
- var xmlBody string
- /*var xmlFilter string
- if filter != "" {
- xmlFilter = `<c:filter><c:prop-filter name="FN">
- <c:text-match collation="i;unicode-casemap" match-type="contains">` + filter + `</c:text-match>
- </c:prop-filter></c:filter>`
- }*/
-
- xmlBody = `<c:addressbook-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"><d:prop>
- <d:getetag /><c:address-data />
- </d:prop></c:addressbook-query>`
-
- //fmt.Println(xmlBody)
- req, err := http.NewRequest("REPORT", config.Addressbooks[abNo].Url, strings.NewReader(xmlBody))
- req.SetBasicAuth(config.Addressbooks[abNo].Username, config.Addressbooks[abNo].Password)
- req.Header.Add("Content-Type", "application/xml; charset=utf-8")
- req.Header.Add("Depth", "1") // needed for SabreDAV
- req.Header.Add("Prefer", "return-minimal")
+func main() {
+ config = getConf()
+ toFile := false
- cli := &http.Client{}
- resp, err := cli.Do(req)
- if err != nil {
- log.Fatal(err)
+ if len(os.Args[1:]) > 0 {
+ searchterm = os.Args[1]
}
+ flag.StringVar(&filter, "s", "", "Search term")
+ //flag.BoolVar(&showInfo, "i", false, "Show additional info like description and location for contacts")
+ flag.BoolVar(&showFilename, "f", false, "Show contact filename for editing or deletion")
+ flag.BoolVar(&displayFlag, "p", false, "Print VCF file piped to qcard (for CLI mail tools like mutt)")
+ abNo := flag.Int("a", 0, "Show only single addressbook (number).")
+ version := flag.Bool("v", false, "Show version")
+ showAddressbooks := flag.Bool("l", false, "List configured addressbooks with their corresponding numbers (for \"-a\")")
+ contactFile := flag.String("u", "", "Upload contact file. Provide filename and use with \"-c\"")
+ contactDelete := flag.String("delete", "", "Delete contact. Get filename with \"-f\" and use with \"-a\"")
+ contactDump := flag.String("d", "", "Dump raw contact data. Get filename with \"-f\" and use with \"-a\"")
+ contactEdit := flag.String("edit", "", "Edit + upload contact data. Get filename with \"-f\" and use with \"-a\"")
+ contactNew := flag.String("n", "", "Add a new contact. Check README.md for syntax")
+ showEmailOnly = flag.Bool("emailonly", false, "Show only email addresses and names without further formatting (for CLI mail tools like mutt)")
+ flag.Bool("h", false, "show the help")
+ flag.Parse()
+ flagset := make(map[string]bool) // map for flag.Visit. get bools to determine set flags
+ flag.Visit(func(f *flag.Flag) { flagset[f.Name] = true })
- xmlContent, _ := ioutil.ReadAll(resp.Body)
- defer resp.Body.Close()
-
- //fmt.Println(string(xmlContent))
- xmlData := XmlDataStruct{}
- err = xml.Unmarshal(xmlContent, &xmlData)
- if err != nil {
- log.Fatal(err)
+ // TODO
+ if *showAddressbooks {
}
-
- for i := range xmlData.Elements {
- contactData := xmlData.Elements[i].Data
- contactHref := xmlData.Elements[i].Href
- ABColor := Colors[abNo]
- //fmt.Println(contactData)
- parseMain(&contactData, &contactsSlice, contactHref, ABColor)
+ if flagset["h"] {
+ flag.Usage()
+ os.Exit(0)
+ }
+ if flagset["abNo"] {
+ if *abNo >= len(config.Addressbooks) {
+ fmt.Printf("there are no address books\n")
+ os.Exit(1)
+ }
+ ab := config.Addressbooks[*abNo]
+ if flagset["delete"] {
+ ab.deleteContact(*contactDelete)
+ } else if flagset["d"] {
+ ab.dumpContact(*contactDump, toFile)
+ } else if flagset["p"] {
+ displayVCF()
+ } else if flagset["n"] {
+ ab.createContact(*contactNew)
+ } else if flagset["u"] {
+ contactEdit := false
+ ab.uploadVCF(*contactFile, contactEdit)
+ } else if flagset["edit"] {
+ editContact(ab, *contactEdit)
+ }
+ } else if flagset["l"] {
+ getAbList()
+ } else if *version {
+ fmt.Println("qcard " + qcardversion)
+ } else if flagset["a"] {
+ showAddresses(*abNo)
+ } else {
+ showAddresses(-1)
}
-
- wg.Done()
}
-func showAddresses(abNo int) {
- var wg sync.WaitGroup // use waitgroups to fetch calendars in parallel
- wg.Add(len(config.Addressbooks)) // waitgroup length = num calendars
- for i := range config.Addressbooks {
- if abNo == i || abNo == 1000 {
- go fetchAbData(i, &wg)
- } else {
- wg.Done()
+func showAddresses(abn int) {
+ abs := make(chan contactStruct, 10)
+ ech := make(chan error, 3)
+ if abn < 0 {
+ for _, ab := range config.Addressbooks {
+ go ab.fetchAbData(abs, ech)
}
+ } else {
+ ab := config.Addressbooks[abn]
+ go ab.fetchAbData(abs, ech)
+ }
+
+ contactsSlice := make([]contactStruct, 0)
+ for cs := range abs {
+ contactsSlice = append(contactsSlice, cs)
+ }
+ for e := range ech {
+ fmt.Printf("error processing: %s\n", e)
+ }
+
+ if len(contactsSlice) == 0 {
+ log.Fatal("no contacts") // get out if nothing found
}
- wg.Wait()
// TODO: Allow sort by first and last name
sort.Slice(contactsSlice, func(i, j int) bool {
@@ -87,9 +109,6 @@ func showAddresses(abNo int) {
}
})
- if len(contactsSlice) == 0 {
- log.Fatal("no contacts") // get out if nothing found
- }
if len(contactsSlice) <= config.DetailThreshold {
showDetails = true
}
@@ -103,160 +122,35 @@ func showAddresses(abNo int) {
}
}
-func createContact(abNo int, contactData string) {
- curTime := time.Now()
- d := regexp.MustCompile(`\s[a-z,A-Z]:`)
- dataArr := splitAfter(contactData, d) // own function, splitAfter is not supported by regex module
-
- var fullName string
- var name string
- var phoneCell string
- var phoneHome string
- var phoneWork string
- var emailHome string
- var emailWork string
- var addressHome string
- var addressWork string
- var note string
- var birthday string
- var organisation string
- var title string
- var role string
- var nickname string
-
- newUUID := genUUID()
-
- for i, e := range dataArr {
- if i == 0 {
- fullName = e
- lastInd := strings.LastIndex(e, " ") // split name at last space
- name = e[lastInd+1:] + ";" + e[0:lastInd] + ";;;" // lastname, givenname1 givenname2
- } else {
- attr := strings.Split(e, ":")
-
- switch attr[0] {
- case " M":
- phoneCell = "\nTEL;TYPE=CELL:" + attr[1]
- case " P":
- phoneHome = "\nTEL;TYPE=HOME:" + attr[1]
- case " p":
- phoneWork = "\nTEL;TYPE=WORK:" + attr[1]
- case " E":
- emailHome = "\nEMAIL;TYPE=HOME:" + attr[1]
- case " e":
- emailWork = "\nEMAIL;TYPE=WORK:" + attr[1]
- case " A":
- if strings.Contains(attr[1], ";") == false {
- log.Fatal("Address must be splitted in Semicola")
- }
- addressHome = "\nADR;TYPE=HOME:" + attr[1]
- case " a":
- addressWork = "\nADR;TYPE=WORK:" + attr[1]
- case " O":
- organisation = "\nORG:" + attr[1]
- case " B":
- birthday = "\nBDAY:" + attr[1]
- case " n":
- note = "\nNOTE:" + attr[1]
- case " T":
- title = "\nTITLE:" + attr[1]
- case " R":
- role = "\nROLE:" + attr[1]
- case " I":
- role = "\nNICKNAME:" + attr[1]
- }
- }
- }
-
- var contactSkel = `BEGIN:VCARD
-VERSION:3.0
-PRODID:-//qcard
-UID:` + newUUID +
- emailHome +
- phoneCell +
- phoneHome +
- phoneWork +
- emailHome +
- emailWork +
- addressHome +
- addressWork +
- birthday +
- organisation +
- note +
- title +
- role +
- nickname + `
-FN:` + fullName + `
-N:` + name + `
-REV:` + curTime.UTC().Format(IcsFormat) + `
-END:VCARD`
- //fmt.Println(contactSkel)
- //os.Exit(3)
-
- newElem := newUUID + `.vcf`
-
- req, _ := http.NewRequest("PUT", config.Addressbooks[abNo].Url+newElem, strings.NewReader(contactSkel))
- req.SetBasicAuth(config.Addressbooks[abNo].Username, config.Addressbooks[abNo].Password)
- req.Header.Add("Content-Type", "application/xml; charset=utf-8")
-
- cli := &http.Client{}
- resp, err := cli.Do(req)
- defer resp.Body.Close()
-
+func editContact(c addressBook, contactFilename string) error {
+ toFile = true
+ contactEdit := true
+ c.dumpContact(contactFilename, toFile)
+ //fmt.Println(appointmentEdit)
+ filePath := cacheLocation + "/" + contactFilename
+ fileInfo, err := os.Stat(filePath)
if err != nil {
- log.Fatal(err)
+ return err
}
+ beforeMTime := fileInfo.ModTime()
- fmt.Println(resp.Status)
-}
+ shell := exec.Command(editor, filePath)
+ shell.Stdout = os.Stdin
+ shell.Stdin = os.Stdin
+ shell.Stderr = os.Stderr
+ shell.Run()
-func main() {
- toFile := false
-
- if len(os.Args[1:]) > 0 {
- searchterm = os.Args[1]
+ fileInfo, err = os.Stat(filePath)
+ if err != nil {
+ return err
}
- flag.StringVar(&filter, "s", "", "Search (part of) name")
- flag.StringVar(&orgFilter, "so", "", "Search (part of) organisation")
- //flag.BoolVar(&showInfo, "i", false, "Show additional info like description and location for contacts")
- flag.BoolVar(&showFilename, "f", false, "Show contact filename for editing or deletion")
- flag.BoolVar(&displayFlag, "p", false, "Print VCF file piped to qcard (for CLI mail tools like mutt)")
- abNo := flag.Int("a", 0, "Show only single addressbook (number).")
- version := flag.Bool("v", false, "Show version")
- showAddressbooks := flag.Bool("l", false, "List configured addressbooks with their corresponding numbers (for \"-a\")")
- contactFile := flag.String("u", "", "Upload contact file. Provide filename and use with \"-c\"")
- contactDelete := flag.String("delete", "", "Delete contact. Get filename with \"-f\" and use with \"-a\"")
- contactDump := flag.String("d", "", "Dump raw contact data. Get filename with \"-f\" and use with \"-a\"")
- contactEdit := flag.String("edit", "", "Edit + upload contact data. Get filename with \"-f\" and use with \"-a\"")
- contactNew := flag.String("n", "", "Add a new contact. Check README.md for syntax")
- showEmailOnly = flag.Bool("emailonly", false, "Show only email addresses and names without further formatting (for CLI mail tools like mutt)")
- flag.Parse()
- flagset := make(map[string]bool) // map for flag.Visit. get bools to determine set flags
- flag.Visit(func(f *flag.Flag) { flagset[f.Name] = true })
+ afterMTime := fileInfo.ModTime()
- //if *showAddressbooks {
- //}
- //if flagset["l"] {
- if *showAddressbooks {
- getAbList()
- } else if flagset["delete"] {
- deleteContact(*abNo, *contactDelete)
- } else if flagset["d"] {
- dumpContact(*abNo, *contactDump, toFile)
- } else if flagset["p"] {
- displayVCF()
- } else if flagset["n"] {
- createContact(*abNo, *contactNew)
- } else if flagset["edit"] {
- editContact(*abNo, *contactEdit)
- } else if flagset["u"] {
- contactEdit := false
- uploadVCF(*abNo, *contactFile, contactEdit)
- } else if *version {
- fmt.Println("qcard " + qcardversion)
- } else if flagset["a"] {
- showAddresses(*abNo)
+ if beforeMTime.Before(afterMTime) {
+ c.uploadVCF(filePath, contactEdit)
} else {
- showAddresses(1000)
+ return err
}
+
+ return nil
}
diff --git a/parse.go b/parse.go
index 81d55b8..f80b97d 100644
--- a/parse.go
+++ b/parse.go
@@ -1,7 +1,6 @@
package main
import (
- "fmt"
"regexp"
"strings"
)
@@ -12,142 +11,104 @@ func trimField(field, cutset string) string {
return strings.TrimRight(cutsetRem, "\r\n")
}
-func parseContactFullName(contactData *string) string {
+func parseContactFullName(contactData string) string {
re, _ := regexp.Compile(`\nFN:.*\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "\nFN:")
}
-func parseContactName(contactData *string) string {
+func parseContactName(contactData string) string {
re, _ := regexp.Compile(`\nN:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
result = strings.Replace(result, ";;;", "", -1) // remove triple semicola
return trimField(result, "\nN:")
}
-func parseContactPhoneCell(contactData *string) string {
+func parseContactPhoneCell(contactData string) string {
re, _ := regexp.Compile(`(?i)TEL;TYPE=CELL:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)TEL;TYPE=CELL:")
}
-func parseContactPhoneHome(contactData *string) string {
+func parseContactPhoneHome(contactData string) string {
re, _ := regexp.Compile(`(?i)TEL;TYPE=HOME:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)TEL;TYPE=HOME:")
}
-func parseContactPhoneWork(contactData *string) string {
+func parseContactPhoneWork(contactData string) string {
re, _ := regexp.Compile(`(?i)TEL;TYPE=WORK:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)TEL;TYPE=WORK:")
}
-func parseContactEmail(contactData *string) string {
- var emailType string
- re, _ := regexp.Compile(`(?i)EMAIL(;TYPE=(.*))?:.*?\n`)
- parts := re.FindStringSubmatch(*contactData)
-
- if len(parts) > 1 {
- types := strings.Split(parts[2], ",")
- for _, i := range types {
- //fmt.Println(i)
- switch strings.ToLower(i) {
- case "internet":
- emailType = "home"
- case "home":
- emailType = "home"
- case "pref":
- emailType = "home"
- case "work":
- emailType = "work"
- }
- }
- }
-
- fmt.Println(emailType)
- result := re.FindString(*contactData)
- return trimField(result, "(?i)EMAIL(;TYPE=(.*))?:")
-}
-
-func parseContactEmailHome(contactData *string) string {
- /*workre, _ := regexp.Compile(`(?i)EMAIL;TYPE=(.*WORK.*):.*?\n`) // check, if work email
- if workre.FindString(*contactData) != "" {
- fmt.Println("yes")
- return "" // if work email, get out
- }*/
- re, _ := regexp.Compile(`(?i)EMAIL(;TYPE=(HOME|INTERNET|PREF|INTERNET,HOME|HOME,INTERNET))?:.*?\n`)
- //re, _ := regexp.Compile(`(?i)EMAIL(;TYPE=(.*))?:.*?\n`)
- result := re.FindString(*contactData)
- return trimField(result, "(?i)EMAIL(;TYPE=(.*))?:")
-
+func parseContactEmailHome(contactData string) string {
+ re, _ := regexp.Compile(`(?i)EMAIL(;TYPE=(HOME|INTERNET|PREF|INTERNET,HOME))?:.*?\n`)
+ result := re.FindString(contactData)
+ return trimField(result, "(?i)EMAIL(;TYPE=(HOME|INTERNET|PREF|INTERNET,HOME))?:")
}
-func parseContactEmailWork(contactData *string) string {
- //re, _ := regexp.Compile(`(?i)EMAIL;TYPE=(WORK|INTERNET,WORK):.*?\n`)
- re, _ := regexp.Compile(`(?i)EMAIL;TYPE=(.*WORK.*):.*?\n`)
- result := re.FindString(*contactData)
+func parseContactEmailWork(contactData string) string {
+ re, _ := regexp.Compile(`(?i)EMAIL;TYPE=(WORK|INTERNET,WORK):.*?\n`)
+ result := re.FindString(contactData)
return trimField(result, "(?i)EMAIL;TYPE=(WORK|INTERNET,WORK):")
}
-func parseContactAddressHome(contactData *string) string {
+func parseContactAddressHome(contactData string) string {
re, _ := regexp.Compile(`(?i)ADR;TYPE=HOME:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
result = strings.Replace(result, ";;;", "", -1) // remove triple semicola
return trimField(result, "(?i)ADR;TYPE=HOME:")
}
-func parseContactAddressWork(contactData *string) string {
+func parseContactAddressWork(contactData string) string {
re, _ := regexp.Compile(`(?i)ADR;TYPE=WORK:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
result = strings.Replace(result, ";;;", "", -1) // remove triple semicola
return trimField(result, "(?i)ADR;TYPE=WORK:")
}
-func parseContactBirthday(contactData *string) string {
+func parseContactBirthday(contactData string) string {
re, _ := regexp.Compile(`(?i)BDAY:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)BDAY:")
}
-func parseContactNote(contactData *string) string {
+func parseContactNote(contactData string) string {
re, _ := regexp.Compile(`(?i)NOTE:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)NOTE:")
}
-func parseContactOrg(contactData *string) string {
+func parseContactOrg(contactData string) string {
re, _ := regexp.Compile(`(?i)ORG:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)ORG:")
}
-func parseContactTitle(contactData *string) string {
+func parseContactTitle(contactData string) string {
re, _ := regexp.Compile(`(?i)TITLE:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)TITLE:")
}
-func parseContactRole(contactData *string) string {
+func parseContactRole(contactData string) string {
re, _ := regexp.Compile(`(?i)ROLE:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)ROLE:")
}
-func parseContactNickname(contactData *string) string {
+func parseContactNickname(contactData string) string {
re, _ := regexp.Compile(`(?i)NICKNAME:.*?\n`)
- result := re.FindString(*contactData)
+ result := re.FindString(contactData)
return trimField(result, "(?i)NICKNAME:")
}
-func parseMain(contactData *string, contactsSlice *[]contactStruct, href, color string) {
+func parseMain(contactData, href, color string) contactStruct {
//fmt.Println(parseContactName(contactData))
fullName := parseContactFullName(contactData)
- organisation := parseContactOrg(contactData)
- //if flagset["so"] {
- // TODO: How to filter with name and org?
- if (filter == "") || ((filterMatch(fullName) == true) || (filterOrgMatch(organisation) == true)) {
+ if (filter == "") || (filterMatch(fullName) == true) {
data := contactStruct{
Href: href,
Color: color,
@@ -155,7 +116,7 @@ func parseMain(contactData *string, contactsSlice *[]contactStruct, href, color
name: parseContactName(contactData),
title: parseContactTitle(contactData),
role: parseContactRole(contactData),
- organisation: organisation,
+ organisation: parseContactOrg(contactData),
phoneCell: parseContactPhoneCell(contactData),
phoneHome: parseContactPhoneHome(contactData),
phoneWork: parseContactPhoneWork(contactData),
@@ -167,6 +128,7 @@ func parseMain(contactData *string, contactsSlice *[]contactStruct, href, color
nickname: parseContactNickname(contactData),
note: parseContactNote(contactData),
}
- *contactsSlice = append(*contactsSlice, data)
+ return data
}
+ return contactStruct{}
}
diff --git a/vcard.templ b/vcard.templ
new file mode 100644
index 0000000..65a02d1
--- /dev/null
+++ b/vcard.templ
@@ -0,0 +1,21 @@
+BEGIN:VCARD
+VERSION:3.0
+PRODID:-//qcard
+UID:{{.uuid}}
+{{if .phoneCell}}TEL;TYPE=CELL:{{.phoneCell}}{{end}}
+{{if .phoneHome}}TEL;TYPE=HOME:{{.phoneHome}}{{end}}
+{{if .phoneWork}}TEL;TYPE=WORK:{{.phoneWork}}{{end}}
+{{if .emailHome}}EMAIL;TYPE=HOME:{{.emailHome}}{{end}}
+{{if .emailWork}}EMAIL;TYPE=WORK:{{.emailWork}}{{end}}
+{{if .addressHome}}ADR;TYPE=HOME:{{.addressHome}}{{end}}
+{{if .addressWork}}ADR;TYPE=WORK:{{.addressWork}}{{end}}
+{{if .organization}}ORG:{{.organization}}{{end}}
+{{if .birthday}}BDAY:{{.birthday}}{{end}}
+{{if .note}}NOTE:{{.note}}{{end}}
+{{if .title}}TITLE:{{.title}}{{end}}
+{{if .role}}ROLE:{{.role}}{{end}}
+{{if .nickname}}NICKNAME:{{.nickname}}{{end}}
+FN:{{.fullName}}
+N:{{.name}}
+REV:{{.time}}
+END:VCARD
--
2.37.2