~rjarry/aerc-devel

aerc: Allow filters to implement their own paging v1 APPLIED

Hey folks, this has been requested multiple times recently. I thought
I would tackle it since nobody stepped up :)

This is a follow up on https://todo.sr.ht/~rjarry/aerc/250. I changed my
mind about the way it is configured in the filters section.

The filter pattern was already pretty complex with all the headers and
regular expression stuff. Since the pager thing was more related to the
command itself, I chose to condition the paging based on an exclamation
mark prefix in the filter command itself. For example:

    text/html = ! w3m -I UTF-8 -T text/html

This will effectively run w3m as the main process running in the
embedded terminal of the part viewer. It will provide interactive access
to w3m and will effectively allow navigating using w3m paging and
coloring.

Robin Jarry (4):
  viewer: limit indentation in writeMailHeaders
  viewer: avoid potential race condition
  viewer: set term size environment vars before filter command starts
  viewer: allow filters to provide their own paging

 app/msgviewer.go      | 192 +++++++++++++++++++++++-------------------
 config/aerc.conf      |   1 +
 config/filters.go     |  22 +++--
 doc/aerc-config.5.scd |  13 +++
 4 files changed, 135 insertions(+), 93 deletions(-)

-- 
2.47.0
#1373181 alpine-edge.yml success
#1373182 openbsd.yml success
aerc/patches: SUCCESS in 2m4s

[Allow filters to implement their own paging][0] from [Robin Jarry][1]

[0]: https://lists.sr.ht/~rjarry/aerc-devel/patches/56048
[1]: mailto:robin@jarry.cc

✓ #1373181 SUCCESS aerc/patches/alpine-edge.yml https://builds.sr.ht/~rjarry/job/1373181
✓ #1373182 SUCCESS aerc/patches/openbsd.yml     https://builds.sr.ht/~rjarry/job/1373182
Export patchset (mbox)
How do I use this?

Copy & paste the following snippet into your terminal to import this patchset into git:

curl -s https://lists.sr.ht/~rjarry/aerc-devel/patches/56048/mbox | git am -3
Learn more about email & git

[PATCH aerc 1/4] viewer: limit indentation in writeMailHeaders Export this patch

There is an extraneous level of indentation in writeMailHeaders().
Remove it.

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 app/msgviewer.go | 104 ++++++++++++++++++++++++-----------------------
 1 file changed, 53 insertions(+), 51 deletions(-)

diff --git a/app/msgviewer.go b/app/msgviewer.go
index 0c2c1bef0f86..804bc6007348 100644
--- a/app/msgviewer.go
+++ b/app/msgviewer.go
@@ -635,69 +635,71 @@ func (pv *PartViewer) attemptCopy() {

func (pv *PartViewer) writeMailHeaders() {
	info := pv.msg.MessageInfo()
	if config.Viewer.ShowHeaders && info.RFC822Headers != nil {
		var file io.WriteCloser
	if !config.Viewer.ShowHeaders || info.RFC822Headers == nil {
		return
	}
	var file io.WriteCloser

		for _, f := range config.Filters {
			if f.Type != config.FILTER_HEADERS {
				continue
			}
			log.Debugf("<%s> piping headers in filter: %s",
				info.Envelope.MessageId, f.Command)
			filter := exec.Command("sh", "-c", f.Command)
			if pv.filter != nil {
				// inherit from filter env
				filter.Env = pv.filter.Env
			}
	for _, f := range config.Filters {
		if f.Type != config.FILTER_HEADERS {
			continue
		}
		log.Debugf("<%s> piping headers in filter: %s",
			info.Envelope.MessageId, f.Command)
		filter := exec.Command("sh", "-c", f.Command)
		if pv.filter != nil {
			// inherit from filter env
			filter.Env = pv.filter.Env
		}

			stdin, err := filter.StdinPipe()
		stdin, err := filter.StdinPipe()
		if err == nil {
			filter.Stdout = pv.pagerin
			filter.Stderr = pv.pagerin
			err := filter.Start()
			if err == nil {
				filter.Stdout = pv.pagerin
				filter.Stderr = pv.pagerin
				err := filter.Start()
				if err == nil {
					//nolint:errcheck // who cares?
					defer filter.Wait()
					file = stdin
				} else {
					log.Errorf(
						"failed to start header filter: %v",
						err)
				}
				//nolint:errcheck // who cares?
				defer filter.Wait()
				file = stdin
			} else {
				log.Errorf("failed to create pipe: %v", err)
				log.Errorf(
					"failed to start header filter: %v",
					err)
			}
			break
		}
		if file == nil {
			file = pv.pagerin
		} else {
			defer file.Close()
			log.Errorf("failed to create pipe: %v", err)
		}
		break
	}

		var buf bytes.Buffer
		err := textproto.WriteHeader(&buf, info.RFC822Headers.Header.Header)
		if err != nil {
			log.Errorf("failed to format headers: %v", err)
		}
		_, err = file.Write(bytes.TrimRight(buf.Bytes(), "\r\n"))
		if err != nil {
			log.Errorf("failed to write headers: %v", err)
		}
	if file == nil {
		file = pv.pagerin
	} else {
		defer file.Close()
	}

		// virtual header
		if len(info.Labels) != 0 {
			labels := fmtHeader(info, "Labels", "", "", "", "")
			_, err := file.Write([]byte(fmt.Sprintf("\r\nLabels: %s", labels)))
			if err != nil {
				log.Errorf("failed to write to labels: %v", err)
			}
		}
		_, err = file.Write([]byte{'\r', '\n', '\r', '\n'})
	var buf bytes.Buffer
	err := textproto.WriteHeader(&buf, info.RFC822Headers.Header.Header)
	if err != nil {
		log.Errorf("failed to format headers: %v", err)
	}
	_, err = file.Write(bytes.TrimRight(buf.Bytes(), "\r\n"))
	if err != nil {
		log.Errorf("failed to write headers: %v", err)
	}

	// virtual header
	if len(info.Labels) != 0 {
		labels := fmtHeader(info, "Labels", "", "", "", "")
		_, err := file.Write([]byte(fmt.Sprintf("\r\nLabels: %s", labels)))
		if err != nil {
			log.Errorf("failed to write empty line: %v", err)
			log.Errorf("failed to write to labels: %v", err)
		}
	}
	_, err = file.Write([]byte{'\r', '\n', '\r', '\n'})
	if err != nil {
		log.Errorf("failed to write empty line: %v", err)
	}
}

func (pv *PartViewer) hyperlinks(r io.Reader) (reader io.Reader) {
-- 
2.47.0

[PATCH aerc 2/4] viewer: avoid potential race condition Export this patch

Make sure to do a test and set atomically on the copying boolean.

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 app/msgviewer.go | 3 +--
 1 file changed, 1 insertion(+), 2 deletions(-)

diff --git a/app/msgviewer.go b/app/msgviewer.go
index 804bc6007348..f4bc5801b0c2 100644
--- a/app/msgviewer.go
+++ b/app/msgviewer.go
@@ -601,10 +601,9 @@ func (pv *PartViewer) decodeImage() {
func (pv *PartViewer) attemptCopy() {
	if pv.source == nil ||
		pv.filter == nil ||
		atomic.LoadInt32(&pv.copying) == copying {
		atomic.SwapInt32(&pv.copying, copying) == copying {
		return
	}
	atomic.StoreInt32(&pv.copying, copying)
	pv.writeMailHeaders()
	if strings.EqualFold(pv.part.MIMEType, "text") {
		pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
-- 
2.47.0

[PATCH aerc 3/4] viewer: set term size environment vars before filter command starts Export this patch

When the part viewer embedded terminal starts we take the opportunity to
initialize the COLUMNS and LINES environment variables of the filter
command before starting the terminal.

Unfortunately, the part viewer Draw() method will start the terminal
after fetching the body part which will (when complete) start the filter
command in attemptCopy(). There is a race which can lead to the env
variables being updated after the filter command has been started.

Set the environment variables before triggering the part fetch. This
will guarantee that they are present when the filter command starts.

Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 app/msgviewer.go | 14 ++++----------
 1 file changed, 4 insertions(+), 10 deletions(-)

diff --git a/app/msgviewer.go b/app/msgviewer.go
index f4bc5801b0c2..2785d03cb7a4 100644
--- a/app/msgviewer.go
+++ b/app/msgviewer.go
@@ -560,16 +560,6 @@ func NewPartViewer(
		uiConfig:   acct.UiConfig(),
	}

	if term != nil {
		term.OnStart = func() {
			if term.ctx != nil {
				filter.Env = append(filter.Env, fmt.Sprintf("COLUMNS=%d", term.ctx.Window().Width))
				filter.Env = append(filter.Env, fmt.Sprintf("LINES=%d", term.ctx.Window().Height))
			}
			pv.attemptCopy()
		}
	}

	return pv, nil
}

@@ -782,6 +772,10 @@ func (pv *PartViewer) Draw(ctx *ui.Context) {
		ctx.Fill(0, 0, ctx.Width(), ctx.Height(), ' ', style)
		pv.noFilter.Draw(ctx)
		return
	case !pv.fetched:
		w, h := ctx.Window().Size()
		pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("COLUMNS=%d", w))
		pv.filter.Env = append(pv.filter.Env, fmt.Sprintf("LINES=%d", h))
	}
	if !pv.fetched {
		pv.msg.FetchBodyPart(pv.index, pv.SetSource)
-- 
2.47.0

[PATCH aerc 4/4] viewer: allow filters to provide their own paging Export this patch

Allow filter commands to declare that they will provide their own paging
functionality. To do so, a filter command must start with an exclamation
mark "!". For example:

	text/html = ! w3m -I UTF-8 -T text/html

This will effectively run w3m as the main process running in the
embedded terminal of the part viewer. It will provide interactive access
to w3m and will effectively allow navigating using w3m paging and
coloring.

Implements: https://todo.sr.ht/~rjarry/aerc/250
Signed-off-by: Robin Jarry <robin@jarry.cc>
---
 app/msgviewer.go      | 71 +++++++++++++++++++++++++++++--------------
 config/aerc.conf      |  1 +
 config/filters.go     | 22 +++++++++-----
 doc/aerc-config.5.scd | 13 ++++++++
 4 files changed, 77 insertions(+), 30 deletions(-)

diff --git a/app/msgviewer.go b/app/msgviewer.go
index 2785d03cb7a4..22cc1f0ae06a 100644
--- a/app/msgviewer.go
+++ b/app/msgviewer.go
@@ -452,14 +452,6 @@ func NewPartViewer(
		pagerin io.WriteCloser
		term    *Terminal
	)
	pagerCmd, err := CmdFallbackSearch(config.PagerCmds(), false)
	if err != nil {
		acct.PushError(fmt.Errorf("could not start pager: %w", err))
		return nil, err
	}
	cmd := opt.SplitArgs(pagerCmd)
	pager = exec.Command(cmd[0], cmd[1:]...)

	info := msg.MessageInfo()
	mime := part.FullMIMEType()

@@ -492,9 +484,21 @@ func NewPartViewer(
				log.Tracef("command %v", f.Command)
			}
		}
		if filter != nil {
		if filter == nil {
			continue
		}
		if !f.NeedsPager {
			pager = filter
			break
		}
		pagerCmd, err := CmdFallbackSearch(config.PagerCmds(), false)
		if err != nil {
			acct.PushError(fmt.Errorf("could not start pager: %w", err))
			return nil, err
		}
		cmd := opt.SplitArgs(pagerCmd)
		pager = exec.Command(cmd[0], cmd[1:]...)
		break
	}
	var noFilter *ui.Grid
	if filter != nil {
@@ -524,8 +528,14 @@ func NewPartViewer(
		if config.General.EnableOSC8 {
			filter.Env = append(filter.Env, "AERC_OSC8_URLS=1")
		}
		log.Debugf("<%s> part=%v %s: %v | %v",
			info.Envelope.MessageId, curindex, mime, filter, pager)
		if pager == filter {
			log.Debugf("<%s> part=%v %s: %v",
				info.Envelope.MessageId, curindex, mime, filter)
		} else {
			log.Debugf("<%s> part=%v %s: %v | %v",
				info.Envelope.MessageId, curindex, mime, filter, pager)
		}
		var err error
		if pagerin, err = pager.StdinPipe(); err != nil {
			return nil, err
		}
@@ -598,26 +608,36 @@ func (pv *PartViewer) attemptCopy() {
	if strings.EqualFold(pv.part.MIMEType, "text") {
		pv.source = parse.StripAnsi(pv.hyperlinks(pv.source))
	}
	pv.filter.Stdin = pv.source
	pv.filter.Stdout = pv.pagerin
	pv.filter.Stderr = pv.pagerin
	err := pv.filter.Start()
	if err != nil {
		log.Errorf("error running filter: %v", err)
		return
	if pv.filter != pv.pager {
		// Filter is a separate process that needs to output to the pager.
		pv.filter.Stdin = pv.source
		pv.filter.Stdout = pv.pagerin
		pv.filter.Stderr = pv.pagerin
		err := pv.filter.Start()
		if err != nil {
			log.Errorf("error running filter: %v", err)
			return
		}
	}
	go func() {
		defer log.PanicHandler()
		defer atomic.StoreInt32(&pv.copying, 0)
		err = pv.filter.Wait()
		if err != nil {
			log.Errorf("error waiting for filter: %v", err)
			return
		var err error
		if pv.filter == pv.pager {
			// Filter already implements its own paging.
			_, err = io.Copy(pv.pagerin, pv.source)
			if err != nil {
				log.Errorf("io.Copy: %s", err)
			}
		} else {
			err = pv.filter.Wait()
			if err != nil {
				log.Errorf("filter.Wait: %v", err)
			}
		}
		err = pv.pagerin.Close()
		if err != nil {
			log.Errorf("error closing pager pipe: %v", err)
			return
		}
	}()
}
@@ -627,6 +647,11 @@ func (pv *PartViewer) writeMailHeaders() {
	if !config.Viewer.ShowHeaders || info.RFC822Headers == nil {
		return
	}
	if pv.filter == pv.pager {
		// Filter already implements its own paging.
		// Piping another filter into it will cause mayhem.
		return
	}
	var file io.WriteCloser

	for _, f := range config.Filters {
diff --git a/config/aerc.conf b/config/aerc.conf
index 9070bcf3f4a8..3a6cf25159fc 100644
--- a/config/aerc.conf
+++ b/config/aerc.conf
@@ -768,6 +768,7 @@ message/delivery-status=colorize
message/rfc822=colorize
#text/html=pandoc -f html -t plain | colorize
#text/html=html | colorize
#text/html=! w3m -T text/html -I UTF-8
#text/*=bat -fP --file-name="$AERC_FILENAME"
#application/x-sh=bat -fP -l sh
#image/*=catimg -w $(tput cols) -
diff --git a/config/filters.go b/config/filters.go
index e843c5faa1d4..60372664e8fc 100644
--- a/config/filters.go
+++ b/config/filters.go
@@ -18,11 +18,12 @@ const (
)

type FilterConfig struct {
	Type    FilterType
	Filter  string
	Command string
	Header  string
	Regex   *regexp.Regexp
	Type       FilterType
	Filter     string
	Command    string
	NeedsPager bool
	Header     string
	Regex      *regexp.Regexp
}

var Filters []*FilterConfig
@@ -34,9 +35,16 @@ func parseFilters(file *ini.File) error {
	}

	for _, key := range filters.Keys() {
		pager := true
		cmd := key.Value()
		if strings.HasPrefix(cmd, "!") {
			cmd = strings.TrimLeft(cmd, "! \t")
			pager = false
		}
		filter := FilterConfig{
			Command: key.Value(),
			Filter:  key.Name(),
			Command:    cmd,
			NeedsPager: pager,
			Filter:     key.Name(),
		}

		switch {
diff --git a/doc/aerc-config.5.scd b/doc/aerc-config.5.scd
index 56c4e2099fa0..1d49f11a3a47 100644
--- a/doc/aerc-config.5.scd
+++ b/doc/aerc-config.5.scd
@@ -1011,6 +1011,13 @@ filters need to be able to read from standard input. Many programs support
reading from stdin by putting _-_ instead of a path to a file. You can also
chain together multiple filters by piping with _|_.

Some filter commands may require interactive user input. If a filter command
starts with an exclamation mark _!_, the configured *pager* will *not* be used.
Instead, the filter command will be executed as the main process in the embedded
terminal of the part viewer. The filter command standard input, output and error
will be set to the terminal TTY. The filter is expected to implement its own
paging.

aerc ships with some default filters installed in the libexec directory (usually
_/usr/libexec/aerc/filters_). Note that these may have additional dependencies
that aerc does not have alone.
@@ -1100,6 +1107,12 @@ _text/html_
	text/html=pandoc -f html -t plain
	```

	Use w3m internal pager to interactively view an HTML part with coloring:

	```
	text/html=! w3m -I UTF-8 -T text/html
	```

_text/calendar_
	Parse calendar invites:

-- 
2.47.0
aerc/patches: SUCCESS in 2m4s

[Allow filters to implement their own paging][0] from [Robin Jarry][1]

[0]: https://lists.sr.ht/~rjarry/aerc-devel/patches/56048
[1]: mailto:robin@jarry.cc

✓ #1373181 SUCCESS aerc/patches/alpine-edge.yml https://builds.sr.ht/~rjarry/job/1373181
✓ #1373182 SUCCESS aerc/patches/openbsd.yml     https://builds.sr.ht/~rjarry/job/1373182