Sean E. Russell: 1 Adds support for local ICS directories 9 files changed, 636 insertions(+), 488 deletions(-)
Copy & paste the following snippet into your terminal to import this patchset into git:
curl -s https://lists.sr.ht/~psic4t/public-inbox/patches/36034/mbox | git am -3Learn more about email & git
--- 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
Sorry about sending patches to the mailing list. I got a rejection from the todo list saying 16k was too big a patch, so I assumed I sent it to the wrong place.
I don't know what that's about. --- SER Sean E. Russell Age: age195vpft7nzsy83medxagqqsge0lrcuf9txe3z2znlu2wsk69cdu4sx8nfvp Minisign: https://ser1.net/.well-known/minisign.pub GPG key: https://ser1.net/.well-known/pgp.asc Matrix: @ser:matrix.org