From fad375c673e7bab4b01bbe6a774fae460ce62b86 Mon Sep 17 00:00:00 2001 From: Ben Burwell Date: Fri, 20 Dec 2019 13:21:35 -0500 Subject: [PATCH] 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. --- completer/completer.go | 153 +++++++++++++++++++++++++++++++++++++++++ config/aerc.conf.in | 12 ++++ config/config.go | 5 +- doc/aerc-config.5.scd | 16 +++++ widgets/compose.go | 23 ++++++- 5 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 completer/completer.go diff --git a/completer/completer.go b/completer/completer.go new file mode 100644 index 0000000..baa897d --- /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 660a525..5feeac0 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 d6afef6..e5f7395 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 01abefe..615c3ab 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 242b6db..091eb70 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