~sircmpwn/aerc

This thread contains a patchset. You're looking at the original emails, but you may wish to use the patch review UI. Review patch
2 2

[PATCH] Implement address completion in composer

Details
Message ID
<20190730191326.31038-1-ben@benburwell.com>
DKIM signature
missing
Download raw message
Patch: +177 -8
Add a new address-book-cmd to the composer section of aerc.conf. This
specifies a command to use for getting address completions. The output
format expected for this command mirrors that of mutt's query_command
for easy interoperability.
---
To actually be able to use this, users need to unbind tab from its
default :next-field in [composer]. Should we handle this differently?

 config/aerc.conf.in                    |  12 +++
 config/config.go                       |   5 +-
 doc/aerc-config.5.scd                  |  16 +++
 lib/headercompleter/headercompleter.go | 130 +++++++++++++++++++++++++
 widgets/compose.go                     |  22 +++--
 5 files changed, 177 insertions(+), 8 deletions(-)
 create mode 100644 lib/headercompleter/headercompleter.go

diff --git a/config/aerc.conf.in b/config/aerc.conf.in
index 4d0f9fd..d5df5c0 100644
--- a/config/aerc.conf.in
+++ b/config/aerc.conf.in
@@ -94,6 +94,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 bfcbecf..d89a23d 100644
--- a/config/config.go
+++ b/config/config.go
@@ -66,8 +66,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 a57f760..10dccc8 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -162,6 +162,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/lib/headercompleter/headercompleter.go b/lib/headercompleter/headercompleter.go
new file mode 100644
index 0000000..05f44d6
--- /dev/null
+++ b/lib/headercompleter/headercompleter.go
@@ -0,0 +1,130 @@
package headercompleter

import (
	"bufio"
	"fmt"
	"io"
	"net/mail"
	"os/exec"
	"strings"

	"github.com/google/shlex"
)

// A HeaderCompleter is used to autocomplete message headers based on the
// configured completion commands.
type HeaderCompleter 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
}

// A CompleteFn accepts a string to be completed and returns a slice of
// possible completions.
type CompleteFn func(string) []string

// New creates a new HeaderCompleter with the specified address book command.
func New(AddressBookCmd string) *HeaderCompleter {
	return &HeaderCompleter{AddressBookCmd: AddressBookCmd}
}

// ForHeader returns a CompleteFn 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 (hc *HeaderCompleter) ForHeader(h string, errHandler func(error)) CompleteFn {
	if isAddressHeader(h) {
		// wrap completeAddress in an error handler
		return func(s string) []string {
			completions, err := hc.completeAddress(s)
			if err != nil && errHandler != nil {
				errHandler(err)
				return []string{}
			}
			return completions
		}
	}
	return func(_ string) []string { return []string{} }
}

// 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 (hc *HeaderCompleter) completeAddress(s string) ([]string, error) {
	cmd, err := hc.getAddressCmd(s)
	if err != nil {
		return nil, err
	}
	stdout, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}
	if err := cmd.Start(); err != nil {
		return nil, err
	}
	completions, err := readCompletions(stdout)
	if err != nil {
		return nil, err
	}
	if err := cmd.Wait(); err != nil {
		return nil, err
	}
	return completions, nil
}

// getAddressCmd constructs an exec.Cmd based on the configured command and
// specified query.
func (hc *HeaderCompleter) getAddressCmd(s string) (*exec.Cmd, error) {
	if strings.TrimSpace(hc.AddressBookCmd) == "" {
		return nil, fmt.Errorf("no command configured")
	}
	queryCmd := strings.Replace(hc.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())
		}
	}
}
diff --git a/widgets/compose.go b/widgets/compose.go
index 3dd569d..aa370ae 100644
--- a/widgets/compose.go
+++ b/widgets/compose.go
@@ -4,6 +4,7 @@ import (
	"bufio"
	"io"
	"io/ioutil"
	"log"
	"mime"
	"net/http"
	gomail "net/mail"
@@ -19,6 +20,7 @@ import (
	"github.com/pkg/errors"

	"git.sr.ht/~sircmpwn/aerc/config"
	hc "git.sr.ht/~sircmpwn/aerc/lib/headercompleter"
	"git.sr.ht/~sircmpwn/aerc/lib/ui"
	"git.sr.ht/~sircmpwn/aerc/worker/types"
)
@@ -51,8 +53,10 @@ func NewComposer(conf *config.AercConfig,
		defaults["From"] = acct.From
	}

	completer := hc.New(conf.Compose.AddressBookCmd)

	layout, editors, focusable := buildComposeHeader(
		conf.Compose.HeaderLayout, defaults)
		conf.Compose.HeaderLayout, completer, worker.Logger, defaults)

	header, headerHeight := layout.grid(
		func(header string) ui.Drawable { return editors[header] },
@@ -91,7 +95,8 @@ func NewComposer(conf *config.AercConfig,
	return c
}

func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
func buildComposeHeader(layout HeaderLayout, completer *hc.HeaderCompleter,
	l *log.Logger, defaults map[string]string) (
	newLayout HeaderLayout,
	editors map[string]*headerEditor,
	focusable []ui.DrawableInteractive,
@@ -101,7 +106,9 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (

	for _, row := range layout {
		for _, h := range row {
			e := newHeaderEditor(h, "")
			e := newHeaderEditor(h, "", completer.ForHeader(h, func(err error) {
				l.Printf("could not complete header %s: %v", h, err)
			}))
			editors[h] = e
			switch h {
			case "From":
@@ -117,7 +124,9 @@ func buildComposeHeader(layout HeaderLayout, defaults map[string]string) (
	for _, h := range []string{"Cc", "Bcc"} {
		if val, ok := defaults[h]; ok && val != "" {
			if _, ok := editors[h]; !ok {
				e := newHeaderEditor(h, "")
				e := newHeaderEditor(h, "", completer.ForHeader(h, func(err error) {
					l.Printf("could not complete header %s: %v", h, err)
				}))
				editors[h] = e
				focusable = append(focusable, e)
				layout = append(layout, []string{h})
@@ -527,9 +536,10 @@ type headerEditor struct {
	input *ui.TextInput
}

func newHeaderEditor(name string, value string) *headerEditor {
func newHeaderEditor(name string, value string,
	complete func(string) []string) *headerEditor {
	return &headerEditor{
		input: ui.NewTextInput(value),
		input: ui.NewTextInput(value).TabComplete(complete),
		name:  name,
	}
}
-- 
2.22.0
Details
Message ID
<BX52JP8GZ0YW.OTJ1TDI48TF4@koishi>
In-Reply-To
<20190730191326.31038-1-ben@benburwell.com> (view parent)
DKIM signature
missing
Download raw message
I know I've been putting off reviewing this patch for a while, sorry for
the delay. I had to think about whether or not this was a good long-term
path for integrating contacts with aerc. I think that, at least, it's
not painting us into a corner if we decide to add first-class support
later.

But, it's not going to be quite this easy regardless.

On Tue Jul 30, 2019 at 3:13 PM Ben Burwell wrote:
> To actually be able to use this, users need to unbind tab from its
> default :next-field in [composer]. Should we handle this differently?

This is a problem. I think a blocker for this patch is going to be
something like autocompletion or type-ahead, where as you're typing we
fetch completions and show you your choices beneath the cursor. You can
then press tab to cycle through these, or escape to dismiss them. If
completions are not being shown, then pressing tab cycles fields.
Details
Message ID
<BX52TJY91CB4.22YAGFT5PHM2R@jupiter.local>
In-Reply-To
<BX52JP8GZ0YW.OTJ1TDI48TF4@koishi> (view parent)
DKIM signature
missing
Download raw message
Not to worry! I appreciate taking a measured approach.

I'm pretty busy at the moment, but will plan to have a look at the
under-cursor completion when I get some time.
Reply to thread Export thread (mbox)