~psic4t/public-inbox

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
1

[PATCH] Adds support for local ICS directories

Details
Message ID
<20221011192909.3520019-1-ser@ser1.net>
DKIM signature
missing
Download raw message
Patch: +636 -488
---
 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
Details
Message ID
<CNJCINWRFO3K.1OBBOITRUN3D6@glamdring>
In-Reply-To
<20221011192909.3520019-1-ser@ser1.net> (view parent)
DKIM signature
missing
Download raw message
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.

> <~psic4t/qcard@todo.sr.ht>: host todo.sr.ht[173.195.146.145] said: 550 Ticket
>    body must be less than 16 KiB in size (in reply to end of DATA command)

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
Reply to thread Export thread (mbox)