~sircmpwn/aerc

28fc9fa53da1449498392f83d63a8502a5a958a3 — Galen Abell 1 year, 6 months ago 62cd0b0
Add :save and :pipe commands to viewer

* :save takes a path and saves the current message part to that location
* :pipe is the same as pipe on the account page, but uses the current
  message part rather than the whole email (ie :pipe gzip -d)
* Refactored account:pipe and extracted common pipe code to
  commands.util.QuickTerm
* Added helper command aerc.PushError
M commands/account/pipe.go => commands/account/pipe.go +3 -37
@@ 3,12 3,9 @@ package account
import (
	"errors"
	"io"
	"os/exec"
	"time"

	"git.sr.ht/~sircmpwn/aerc/commands"
	"git.sr.ht/~sircmpwn/aerc/widgets"

	"github.com/gdamore/tcell"
)

func init() {


@@ 23,44 20,13 @@ func Pipe(aerc *widgets.Aerc, args []string) error {
	store := acct.Messages().Store()
	msg := acct.Messages().Selected()
	store.FetchFull([]uint32{msg.Uid}, func(reader io.Reader) {
		cmd := exec.Command(args[1], args[2:]...)
		pipe, err := cmd.StdinPipe()
		if err != nil {
			aerc.PushStatus(" "+err.Error(), 10*time.Second).
				Color(tcell.ColorDefault, tcell.ColorRed)
			return
		}
		term, err := widgets.NewTerminal(cmd)
		term, err := commands.QuickTerm(aerc, args[1:], reader)
		if err != nil {
			aerc.PushStatus(" "+err.Error(), 10*time.Second).
				Color(tcell.ColorDefault, tcell.ColorRed)
			aerc.PushError(" " + err.Error())
			return
		}
		name := args[1] + " <" + msg.Envelope.Subject
		aerc.NewTab(term, name)
		term.OnClose = func(err error) {
			if err != nil {
				aerc.PushStatus(" "+err.Error(), 10*time.Second).
					Color(tcell.ColorDefault, tcell.ColorRed)
			} else {
				aerc.PushStatus("Process complete, press any key to close.",
					10*time.Second)
				term.OnEvent = func(event tcell.Event) bool {
					aerc.RemoveTab(term)
					return true
				}
			}
		}
		term.OnStart = func() {
			go func() {
				_, err := io.Copy(pipe, reader)
				if err != nil {
					aerc.PushStatus(" "+err.Error(), 10*time.Second).
						Color(tcell.ColorDefault, tcell.ColorRed)
				}
				pipe.Close()
			}()
		}
	})
	return nil
}

A commands/msgview/pipe.go => commands/msgview/pipe.go +45 -0
@@ 0,0 1,45 @@
package msgview

import (
	"encoding/base64"
	"errors"
	"fmt"
	"io"
	"mime/quotedprintable"

	"git.sr.ht/~sircmpwn/aerc/commands"
	"git.sr.ht/~sircmpwn/aerc/widgets"
)

func init() {
	register("pipe", Pipe)
}

func Pipe(aerc *widgets.Aerc, args []string) error {
	if len(args) < 2 {
		return errors.New("Usage: :pipe <cmd> [args...]")
	}

	mv := aerc.SelectedTab().(*widgets.MessageViewer)
	p := mv.CurrentPart()

	p.Store.FetchBodyPart(p.Msg.Uid, p.Index, func(reader io.Reader) {
		// email parts are encoded as 7bit (plaintext), quoted-printable, or base64
		switch p.Part.Encoding {
		case "base64":
			reader = base64.NewDecoder(base64.StdEncoding, reader)
		case "quoted-printable":
			reader = quotedprintable.NewReader(reader)
		}

		term, err := commands.QuickTerm(aerc, args[1:], reader)
		if err != nil {
			aerc.PushError(" " + err.Error())
			return
		}
		name := fmt.Sprintf("%s <%s/[%d]", args[1], p.Msg.Envelope.Subject, p.Index)
		aerc.NewTab(term, name)
	})

	return nil
}

A commands/msgview/save.go => commands/msgview/save.go +59 -0
@@ 0,0 1,59 @@
package msgview

import (
	"encoding/base64"
	"errors"
	"io"
	"mime/quotedprintable"
	"os"
	"time"

	"git.sr.ht/~sircmpwn/aerc/widgets"
	"github.com/mitchellh/go-homedir"
)

func init() {
	register("save", Save)
}

func Save(aerc *widgets.Aerc, args []string) error {
	if len(args) < 2 {
		return errors.New("Usage: :save <path>")
	}

	mv := aerc.SelectedTab().(*widgets.MessageViewer)
	p := mv.CurrentPart()

	p.Store.FetchBodyPart(p.Msg.Uid, p.Index, func(reader io.Reader) {
		// email parts are encoded as 7bit (plaintext), quoted-printable, or base64
		switch p.Part.Encoding {
		case "base64":
			reader = base64.NewDecoder(base64.StdEncoding, reader)
		case "quoted-printable":
			reader = quotedprintable.NewReader(reader)
		}

		target, err := homedir.Expand(args[1])
		if err != nil {
			aerc.PushError(" " + err.Error())
			return
		}

		f, err := os.Create(target)
		if err != nil {
			aerc.PushError(" " + err.Error())
			return
		}
		defer f.Close()

		_, err = io.Copy(f, reader)
		if err != nil {
			aerc.PushError(" " + err.Error())
			return
		}

		aerc.PushStatus("Saved to "+target, 10*time.Second)
	})

	return nil
}

A commands/util.go => commands/util.go +56 -0
@@ 0,0 1,56 @@
package commands

import (
	"io"
	"os/exec"
	"time"

	"git.sr.ht/~sircmpwn/aerc/widgets"
	"github.com/gdamore/tcell"
)

// QuickTerm is an ephemeral terminal for running a single command and quiting.
func QuickTerm(aerc *widgets.Aerc, args []string, stdin io.Reader) (*widgets.Terminal, error) {
	cmd := exec.Command(args[0], args[1:]...)
	pipe, err := cmd.StdinPipe()
	if err != nil {
		return nil, err
	}

	term, err := widgets.NewTerminal(cmd)
	if err != nil {
		return nil, err
	}

	term.OnClose = func(err error) {
		if err != nil {
			aerc.PushError(" " + err.Error())
			// remove the tab on error, otherwise it gets stuck
			aerc.RemoveTab(term)
		} else {
			aerc.PushStatus("Process complete, press any key to close.",
				10*time.Second)
			term.OnEvent = func(event tcell.Event) bool {
				aerc.RemoveTab(term)
				return true
			}
		}
	}

	term.OnStart = func() {
		status := make(chan error, 1)

		go func() {
			_, err := io.Copy(pipe, stdin)
			defer pipe.Close()
			status <- err
		}()

		err := <-status
		if err != nil {
			aerc.PushError(" " + err.Error())
		}
	}

	return term, nil
}

M config/binds.conf => config/binds.conf +1 -0
@@ 51,6 51,7 @@ Rr = :reply -a<Enter>
Rq = :reply -aq<Enter>
<C-k> = :prev-part<Enter>
<C-j> = :next-part<Enter>
S = :save<space>

[compose]
# Keybindings used when the embedded terminal is not selected in the compose

M doc/aerc.1.scd => doc/aerc.1.scd +7 -0
@@ 91,6 91,13 @@ These commands work in any context.

## MESSAGE VIEW COMMANDS

*pipe* <cmd>
	Downloads and pipes the current message part into the given shell command,
	and opens a new terminal tab to show the result.

*save* <path>
	Saves the current message part to the given path.

*close*
	Closes the message viewer.


M widgets/aerc.go => widgets/aerc.go +4 -0
@@ 243,6 243,10 @@ func (aerc *Aerc) PushStatus(text string, expiry time.Duration) *StatusMessage {
	return aerc.statusline.Push(text, expiry)
}

func (aerc *Aerc) PushError(text string) {
	aerc.PushStatus(text, 10*time.Second).Color(tcell.ColorDefault, tcell.ColorRed)
}

func (aerc *Aerc) focus(item libui.Interactive) {
	if aerc.focused == item {
		return

M widgets/msgviewer.go => widgets/msgviewer.go +19 -0
@@ 199,6 199,18 @@ func (mv *MessageViewer) OnInvalidate(fn func(d ui.Drawable)) {
	})
}

func (mv *MessageViewer) CurrentPart() *PartInfo {
	switcher := mv.switcher
	part := switcher.parts[switcher.selected]

	return &PartInfo{
		Index: part.index,
		Msg:   part.msg,
		Part:  part.part,
		Store: part.store,
	}
}

func (mv *MessageViewer) PreviousPart() {
	switcher := mv.switcher
	for {


@@ 291,6 303,13 @@ type PartViewer struct {
	term    *Terminal
}

type PartInfo struct {
	Index []int
	Msg   *types.MessageInfo
	Part  *imap.BodyStructure
	Store *lib.MessageStore
}

func NewPartViewer(conf *config.AercConfig,
	store *lib.MessageStore, msg *types.MessageInfo,
	part *imap.BodyStructure, index []int) (*PartViewer, error) {