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:
parent
4d00a2b4d6
commit
fad375c673
5 changed files with 204 additions and 5 deletions
153
completer/completer.go
Normal file
153
completer/completer.go
Normal 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)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in a new issue