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.
This commit is contained in:
Ben Burwell 2019-12-20 13:21:35 -05:00 committed by Drew DeVault
parent 4d00a2b4d6
commit fad375c673
5 changed files with 204 additions and 5 deletions

153
completer/completer.go Normal file
View file

@ -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)
}
}

View file

@ -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

View file

@ -81,6 +81,7 @@ type BindingConfig struct {
type ComposeConfig struct {
Editor string `ini:"editor"`
HeaderLayout [][]string `ini:"-"`
AddressBookCmd string `ini:"address-book-cmd"`
}
type FilterConfig struct {

View file

@ -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

View file

@ -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