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
|
@ -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
|
# Default: To|From,Subject
|
||||||
header-layout=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]
|
||||||
#
|
#
|
||||||
# Filters allow you to pipe an email body through a shell command to render
|
# Filters allow you to pipe an email body through a shell command to render
|
||||||
|
|
|
@ -81,6 +81,7 @@ type BindingConfig struct {
|
||||||
type ComposeConfig struct {
|
type ComposeConfig struct {
|
||||||
Editor string `ini:"editor"`
|
Editor string `ini:"editor"`
|
||||||
HeaderLayout [][]string `ini:"-"`
|
HeaderLayout [][]string `ini:"-"`
|
||||||
|
AddressBookCmd string `ini:"address-book-cmd"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterConfig struct {
|
type FilterConfig struct {
|
||||||
|
|
|
@ -218,6 +218,22 @@ These options are configured in the *[compose]* section of aerc.conf.
|
||||||
|
|
||||||
Default: To|From,Subject
|
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
|
||||||
|
|
||||||
Filters allow you to pipe an email body through a shell command to render
|
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/mitchellh/go-homedir"
|
||||||
"github.com/pkg/errors"
|
"github.com/pkg/errors"
|
||||||
|
|
||||||
|
"git.sr.ht/~sircmpwn/aerc/completer"
|
||||||
"git.sr.ht/~sircmpwn/aerc/config"
|
"git.sr.ht/~sircmpwn/aerc/config"
|
||||||
"git.sr.ht/~sircmpwn/aerc/lib/templates"
|
"git.sr.ht/~sircmpwn/aerc/lib/templates"
|
||||||
"git.sr.ht/~sircmpwn/aerc/lib/ui"
|
"git.sr.ht/~sircmpwn/aerc/lib/ui"
|
||||||
|
@ -45,6 +46,7 @@ type Composer struct {
|
||||||
msgId string
|
msgId string
|
||||||
review *reviewMessage
|
review *reviewMessage
|
||||||
worker *types.Worker
|
worker *types.Worker
|
||||||
|
completer *completer.Completer
|
||||||
|
|
||||||
layout HeaderLayout
|
layout HeaderLayout
|
||||||
focusable []ui.MouseableDrawableInteractive
|
focusable []ui.MouseableDrawableInteractive
|
||||||
|
@ -67,8 +69,11 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
|
||||||
}
|
}
|
||||||
|
|
||||||
templateData := templates.ParseTemplateData(defaults)
|
templateData := templates.ParseTemplateData(defaults)
|
||||||
layout, editors, focusable := buildComposeHeader(
|
cmpl := completer.New(conf.Compose.AddressBookCmd, func(err error) {
|
||||||
conf.Compose.HeaderLayout, defaults)
|
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")
|
email, err := ioutil.TempFile("", "aerc-compose-*.eml")
|
||||||
if err != nil {
|
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
|
// You have to backtab to get to "From", since you usually don't edit it
|
||||||
focused: 1,
|
focused: 1,
|
||||||
focusable: focusable,
|
focusable: focusable,
|
||||||
|
completer: cmpl,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.AddSignature()
|
c.AddSignature()
|
||||||
|
@ -103,17 +109,22 @@ func NewComposer(aerc *Aerc, conf *config.AercConfig,
|
||||||
return c, nil
|
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,
|
newLayout HeaderLayout,
|
||||||
editors map[string]*headerEditor,
|
editors map[string]*headerEditor,
|
||||||
focusable []ui.MouseableDrawableInteractive,
|
focusable []ui.MouseableDrawableInteractive,
|
||||||
) {
|
) {
|
||||||
|
layout := conf.Compose.HeaderLayout
|
||||||
editors = make(map[string]*headerEditor)
|
editors = make(map[string]*headerEditor)
|
||||||
focusable = make([]ui.MouseableDrawableInteractive, 0)
|
focusable = make([]ui.MouseableDrawableInteractive, 0)
|
||||||
|
|
||||||
for _, row := range layout {
|
for _, row := range layout {
|
||||||
for _, h := range row {
|
for _, h := range row {
|
||||||
e := newHeaderEditor(h, "")
|
e := newHeaderEditor(h, "")
|
||||||
|
if conf.Ui.CompletionPopovers {
|
||||||
|
e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
|
||||||
|
}
|
||||||
editors[h] = e
|
editors[h] = e
|
||||||
switch h {
|
switch h {
|
||||||
case "From":
|
case "From":
|
||||||
|
@ -130,6 +141,9 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
|
||||||
if val, ok := defaults[h]; ok && val != "" {
|
if val, ok := defaults[h]; ok && val != "" {
|
||||||
if _, ok := editors[h]; !ok {
|
if _, ok := editors[h]; !ok {
|
||||||
e := newHeaderEditor(h, "")
|
e := newHeaderEditor(h, "")
|
||||||
|
if conf.Ui.CompletionPopovers {
|
||||||
|
e.input.TabComplete(cmpl.ForHeader(h), conf.Ui.CompletionDelay)
|
||||||
|
}
|
||||||
editors[h] = e
|
editors[h] = e
|
||||||
focusable = append(focusable, e)
|
focusable = append(focusable, e)
|
||||||
layout = append(layout, []string{h})
|
layout = append(layout, []string{h})
|
||||||
|
@ -725,6 +739,9 @@ func (c *Composer) AddEditor(header string, value string, appendHeader bool) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
e := newHeaderEditor(header, value)
|
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.editors[header] = e
|
||||||
c.layout = append(c.layout, []string{header})
|
c.layout = append(c.layout, []string{header})
|
||||||
// Insert focus of new editor before terminal editor
|
// Insert focus of new editor before terminal editor
|
||||||
|
|
Loading…
Reference in New Issue