aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorBen Burwell <ben@benburwell.com>2019-12-20 13:21:35 -0500
committerDrew DeVault <sir@cmpwn.com>2019-12-21 09:23:22 -0500
commitfad375c673e7bab4b01bbe6a774fae460ce62b86 (patch)
tree52b5f904a53dca3d5e8b98adac91ac0ffcdbb646
parent4d00a2b4d6ef3b4bea5c06553538b4010b7c09c2 (diff)
downloadaerc-fad375c673e7bab4b01bbe6a774fae460ce62b86.tar.gz
Add address book completion in composer
Complete email address fields in the message composer with an external address book command, compatible with mutt's query_cmd.
-rw-r--r--completer/completer.go153
-rw-r--r--config/aerc.conf.in12
-rw-r--r--config/config.go5
-rw-r--r--doc/aerc-config.5.scd16
-rw-r--r--widgets/compose.go23
5 files changed, 204 insertions, 5 deletions
diff --git a/completer/completer.go b/completer/completer.go
new file mode 100644
index 00000000..baa897d0
--- /dev/null
+++ b/completer/completer.go
@@ -0,0 +1,153 @@
+package completer
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "log"
+ "net/mail"
+ "os/exec"
+ "strings"
+
+ "github.com/google/shlex"
+)
+
+// A Completer is used to autocomplete text inputs based on the configured
+// completion commands.
+type Completer struct {
+ // AddressBookCmd is the command to run for completing email addresses. This
+ // command must output one completion on each line with fields separated by a
+ // tab character. The first field must be the address, and the second field,
+ // if present, the contact name. Only the email address field is required.
+ // The name field is optional. Additional fields are ignored.
+ AddressBookCmd string
+
+ errHandler func(error)
+ logger *log.Logger
+}
+
+// A CompleteFunc accepts a string to be completed and returns a slice of
+// possible completions.
+type CompleteFunc func(string) []string
+
+// New creates a new Completer with the specified address book command.
+func New(addressBookCmd string, errHandler func(error), logger *log.Logger) *Completer {
+ return &Completer{
+ AddressBookCmd: addressBookCmd,
+ errHandler: errHandler,
+ logger: logger,
+ }
+}
+
+// ForHeader returns a CompleteFunc appropriate for the specified mail header. In
+// the case of To, From, etc., the completer will get completions from the
+// configured address book command. For other headers, a noop completer will be
+// returned. If errors arise during completion, the errHandler will be called.
+func (c *Completer) ForHeader(h string) CompleteFunc {
+ if isAddressHeader(h) {
+ if c.AddressBookCmd == "" {
+ return nil
+ }
+ // wrap completeAddress in an error handler
+ return func(s string) []string {
+ completions, err := c.completeAddress(s)
+ if err != nil {
+ c.handleErr(err)
+ return []string{}
+ }
+ return completions
+ }
+ }
+ return nil
+}
+
+// isAddressHeader determines whether the address completer should be used for
+// header h.
+func isAddressHeader(h string) bool {
+ switch strings.ToLower(h) {
+ case "to", "from", "cc", "bcc":
+ return true
+ }
+ return false
+}
+
+// completeAddress uses the configured address book completion command to fetch
+// completions for the specified string, returning a slice of completions or an
+// error.
+func (c *Completer) completeAddress(s string) ([]string, error) {
+ cmd, err := c.getAddressCmd(s)
+ if err != nil {
+ return nil, err
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("stdout: %v", err)
+ }
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("cmd start: %v", err)
+ }
+ completions, err := readCompletions(stdout)
+ if err != nil {
+ return nil, fmt.Errorf("read completions: %v", err)
+ }
+
+ // Wait returns an error if the exit status != 0, which some completion
+ // programs will do to signal no matches. We don't want to spam the user with
+ // spurious error messages, so we'll ignore any errors that arise at this
+ // point.
+ if err := cmd.Wait(); err != nil {
+ c.logger.Printf("completion error: %v", err)
+ }
+
+ return completions, nil
+}
+
+// getAddressCmd constructs an exec.Cmd based on the configured command and
+// specified query.
+func (c *Completer) getAddressCmd(s string) (*exec.Cmd, error) {
+ if strings.TrimSpace(c.AddressBookCmd) == "" {
+ return nil, fmt.Errorf("no command configured")
+ }
+ queryCmd := strings.Replace(c.AddressBookCmd, "%s", s, -1)
+ parts, err := shlex.Split(queryCmd)
+ if err != nil {
+ return nil, fmt.Errorf("could not lex command")
+ }
+ if len(parts) < 1 {
+ return nil, fmt.Errorf("empty command")
+ }
+ if len(parts) > 1 {
+ return exec.Command(parts[0], parts[1:]...), nil
+ }
+ return exec.Command(parts[0]), nil
+}
+
+// readCompletions reads a slice of completions from r line by line. Each line
+// must consist of tab-delimited fields. Only the first field (the email
+// address field) is required, the second field (the contact name) is optional,
+// and subsequent fields are ignored.
+func readCompletions(r io.Reader) ([]string, error) {
+ buf := bufio.NewReader(r)
+ completions := []string{}
+ for {
+ line, err := buf.ReadString('\n')
+ if err == io.EOF {
+ return completions, nil
+ } else if err != nil {
+ return nil, err
+ }
+ parts := strings.SplitN(line, "\t", 3)
+ if addr, err := mail.ParseAddress(parts[0]); err == nil {
+ if len(parts) > 1 {
+ addr.Name = parts[1]
+ }
+ completions = append(completions, addr.String())
+ }
+ }
+}
+
+func (c *Completer) handleErr(err error) {
+ if c.errHandler != nil {
+ c.errHandler(err)
+ }
+}
diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index 660a5258..5feeac00 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -124,6 +124,18 @@ editor=
# Default: To|From,Subject
header-layout=To|From,Subject
+#
+# Specifies the command to be used to tab-complete email addresses. Any
+# occurrence of "%s" in the address-book-cmd will be replaced with what the
+# user has typed so far.
+#
+# The command must output the completions to standard output, one completion
+# per line. Each line must be tab-delimited, with an email address occurring as
+# the first field. Only the email address field is required. The second field,
+# if present, will be treated as the contact name. Additional fields are
+# ignored.
+address-book-cmd=
+
[filters]
#
# Filters allow you to pipe an email body through a shell command to render
diff --git a/config/config.go b/config/config.go
index d6afef66..e5f73959 100644
--- a/config/config.go
+++ b/config/config.go
@@ -79,8 +79,9 @@ type BindingConfig struct {
}
type ComposeConfig struct {
- Editor string `ini:"editor"`
- HeaderLayout [][]string `ini:"-"`
+ Editor string `ini:"editor"`
+ HeaderLayout [][]string `ini:"-"`
+ AddressBookCmd string `ini:"address-book-cmd"`
}
type FilterConfig struct {
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 01abefee..615c3ab8 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -218,6 +218,22 @@ These options are configured in the *[compose]* section of aerc.conf.
Default: To|From,Subject
+*address-book-cmd*
+ Specifies the command to be used to tab-complete email addresses. Any
+ occurrence of "%s" in the address-book-cmd will be replaced with what the
+ user has typed so far.
+
+ The command must output the completions to standard output, one completion
+ per line. Each line must be tab-delimited, with an email address occurring as
+ the first field. Only the email address field is required. The second field,
+ if present, will be treated as the contact name. Additional fields are
+ ignored.
+
+ Example:
+ khard email --parsable '%s'
+
+ Default: none
+
## FILTERS
Filters allow you to pipe an email body through a shell command to render
diff --git a/widgets/compose.go b/widgets/compose.go
index 242b6dba..091eb707 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -22,6 +22,7 @@ import (
"github.com/mitchellh/go-homedir"
"github.com/pkg/errors"
+ "git.sr.ht/~sircmpwn/aerc/completer"
"git.sr.ht/~sircmpwn/aerc/config"
"git.sr.ht/~sircmpwn/aerc/lib/templates"
"git.sr.ht/~sircmpwn/aerc/lib/ui"
@@ -45,6 +46,7 @@ type Composer struct {
msgId string
review *reviewMessage
worker *types.Worker
+ completer *completer.Completer
layout HeaderLayout
focusable []ui.MouseableDrawableInteractive
@@ -67,8 +69,11 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
}
templateData := templates.ParseTemplateData(defaults)
- layout, editors, focusable := buildComposeHeader(
- conf.Compose.HeaderLayout, defaults)
+ cmpl := completer.New(conf.Compose.AddressBookCmd, func(err error) {
+ aerc.PushError(fmt.Sprintf("could not complete header: %v", err))
+ worker.Logger.Printf("could not complete header: %v", err)
+ }, aerc.Logger())
+ layout, editors, focusable := buildComposeHeader(conf, cmpl, defaults)
email, err := ioutil.TempFile("", "aerc-compose-*.eml")
if err != nil {
@@ -90,6 +95,7 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
// You have to backtab to get to "From", since you usually don't edit it
focused: 1,
focusable: focusable,
+ completer: cmpl,
}
c.AddSignature()
@@ -103,17 +109,22 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
return c, nil
}
-func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
+func buildComposeHeader(conf *config.AercConfig, cmpl *completer.Completer,
+ defaults map[string]string) (
newLayout HeaderLayout,
editors map[string]*headerEditor,
focusable []ui.MouseableDrawableInteractive,
) {
+ layout := conf.Compose.HeaderLayout
editors = make(map[string]*headerEditor)
focusable = make([]ui.MouseableDrawableInteractive, 0)
for _, row := range layout {
for _, h := range row {
e := newHeaderEditor(h, "")
+ if conf.Ui.CompletionPopovers {
+ e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
+ }
editors[h] = e
switch h {
case "From":
@@ -130,6 +141,9 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
if val, ok := defaults[h]; ok && val != "" {
if _, ok := editors[h]; !ok {
e := newHeaderEditor(h, "")
+ if conf.Ui.CompletionPopovers {
+ e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
+ }
editors[h] = e
focusable = append(focusable, e)
layout = append(layout, []string{h})
@@ -725,6 +739,9 @@ func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
return
}
e := newHeaderEditor(header, value)
+ if c.config.Ui.CompletionPopovers {
+ e.input.TabComplete(c.completer.ForHeader(header), c.config.Ui.CompletionDelay)
+ }
c.editors[header] = e
c.layout = append(c.layout, []string{header})
// Insert focus of new editor before terminal editor