init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -0,0 +1,64 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type Align struct {
|
||||
Pos app.AlignPosition `opt:"pos" metavar:"top|center|bottom" action:"ParsePos" complete:"CompletePos" desc:"Position."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Align{})
|
||||
}
|
||||
|
||||
func (Align) Description() string {
|
||||
return "Align the message list view."
|
||||
}
|
||||
|
||||
var posNames []string = []string{"top", "center", "bottom"}
|
||||
|
||||
func (a *Align) ParsePos(arg string) error {
|
||||
switch arg {
|
||||
case "top":
|
||||
a.Pos = app.AlignTop
|
||||
case "center":
|
||||
a.Pos = app.AlignCenter
|
||||
case "bottom":
|
||||
a.Pos = app.AlignBottom
|
||||
default:
|
||||
return errors.New("invalid alignment")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Align) CompletePos(arg string) []string {
|
||||
return commands.FilterList(posNames, arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (Align) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (Align) Aliases() []string {
|
||||
return []string{"align"}
|
||||
}
|
||||
|
||||
func (a Align) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("no account selected")
|
||||
}
|
||||
msgList := acct.Messages()
|
||||
if msgList == nil {
|
||||
return errors.New("no message list available")
|
||||
}
|
||||
msgList.AlignMessage(a.Pos)
|
||||
ui.Invalidate()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
type ChangeFolder struct {
|
||||
Account string `opt:"-a" complete:"CompleteAccount" desc:"Change to specified account."`
|
||||
Folder string `opt:"..." complete:"CompleteFolderAndNotmuch" desc:"Folder name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(ChangeFolder{})
|
||||
}
|
||||
|
||||
func (ChangeFolder) Description() string {
|
||||
return "Change the folder shown in the message list."
|
||||
}
|
||||
|
||||
func (ChangeFolder) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (ChangeFolder) Aliases() []string {
|
||||
return []string{"cf"}
|
||||
}
|
||||
|
||||
func (c *ChangeFolder) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (c *ChangeFolder) CompleteFolderAndNotmuch(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
retval := commands.FilterList(
|
||||
acct.Directories().List(), arg,
|
||||
func(s string) string {
|
||||
dir := acct.Directories().Directory(s)
|
||||
if dir != nil && dir.Role != models.QueryRole {
|
||||
s = opt.QuoteArg(s)
|
||||
}
|
||||
return s
|
||||
},
|
||||
)
|
||||
if acct.AccountConfig().Backend == "notmuch" {
|
||||
notmuchcomps := handleNotmuchComplete(arg)
|
||||
for _, prefix := range notmuch_search_terms {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
return notmuchcomps
|
||||
}
|
||||
}
|
||||
retval = append(retval, notmuchcomps...)
|
||||
|
||||
}
|
||||
return retval
|
||||
}
|
||||
|
||||
func (c ChangeFolder) Execute([]string) error {
|
||||
var target string
|
||||
var acct *app.AccountView
|
||||
var err error
|
||||
|
||||
args := opt.LexArgs(c.Folder)
|
||||
|
||||
if c.Account != "" {
|
||||
acct, err = app.Account(c.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
acct = app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
}
|
||||
|
||||
if args.Count() == 0 {
|
||||
return errors.New("<folder> is required. Usage: cf [-a <account>] <folder>")
|
||||
}
|
||||
|
||||
if acct.AccountConfig().Backend == "notmuch" {
|
||||
// With notmuch, :cf can change to a "dynamic folder" that
|
||||
// contains the result of a query. Preserve the entered
|
||||
// arguments verbatim.
|
||||
target = args.String()
|
||||
} else {
|
||||
if args.Count() != 1 {
|
||||
return errors.New("Unexpected argument(s). Usage: cf [-a <account>] <folder>")
|
||||
}
|
||||
target = args.Arg(0)
|
||||
}
|
||||
|
||||
finalize := func(msg types.WorkerMessage) {
|
||||
handleDirOpenResponse(acct, msg)
|
||||
}
|
||||
|
||||
dirlist := acct.Directories()
|
||||
if dirlist == nil {
|
||||
return errors.New("No directory list found")
|
||||
}
|
||||
|
||||
if target == "-" {
|
||||
dir := dirlist.Previous()
|
||||
if dir != "" {
|
||||
target = dir
|
||||
} else {
|
||||
return errors.New("No previous folder to return to")
|
||||
}
|
||||
}
|
||||
|
||||
dirlist.Open(target, "", 0*time.Second, finalize, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleDirOpenResponse(acct *app.AccountView, msg types.WorkerMessage) {
|
||||
// As we're waiting for the worker to report status we must run
|
||||
// the rest of the actions in this callback.
|
||||
switch msg := msg.(type) {
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
case *types.Done:
|
||||
// reset store filtering if we switched folders
|
||||
store := acct.Store()
|
||||
if store != nil {
|
||||
store.ApplyClear()
|
||||
acct.SetStatus(state.SearchFilterClear())
|
||||
}
|
||||
// focus account tab
|
||||
acct.Select()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type CheckMail struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(CheckMail{})
|
||||
}
|
||||
|
||||
func (CheckMail) Description() string {
|
||||
return "Check for new mail on the selected account."
|
||||
}
|
||||
|
||||
func (CheckMail) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (CheckMail) Aliases() []string {
|
||||
return []string{"check-mail"}
|
||||
}
|
||||
|
||||
func (CheckMail) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
acct.CheckMailReset()
|
||||
acct.CheckMail()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
)
|
||||
|
||||
type Clear struct {
|
||||
Selected bool `opt:"-s" desc:"Select first message after clearing."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Clear{})
|
||||
}
|
||||
|
||||
func (Clear) Description() string {
|
||||
return "Clear the current search or filter criteria."
|
||||
}
|
||||
|
||||
func (Clear) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (Clear) Aliases() []string {
|
||||
return []string{"clear"}
|
||||
}
|
||||
|
||||
func (c Clear) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
if c.Selected {
|
||||
defer store.Select("")
|
||||
}
|
||||
store.ApplyClear()
|
||||
acct.SetStatus(state.SearchFilterClear())
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
gomail "net/mail"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/emersion/go-message/mail"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
)
|
||||
|
||||
type Compose struct {
|
||||
Headers string `opt:"-H" action:"ParseHeader" desc:"Add the specified header to the message."`
|
||||
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||
Body string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Compose{})
|
||||
}
|
||||
|
||||
func (Compose) Description() string {
|
||||
return "Open the compose window to write a new email."
|
||||
}
|
||||
|
||||
func (Compose) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (c *Compose) ParseHeader(arg string) error {
|
||||
if strings.Contains(arg, ":") {
|
||||
// ensure first colon is followed by a single space
|
||||
re := regexp.MustCompile(`^(.*?):\s*(.*)`)
|
||||
c.Headers += re.ReplaceAllString(arg, "$1: $2\r\n")
|
||||
} else {
|
||||
c.Headers += arg + ":\r\n"
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*Compose) CompleteTemplate(arg string) []string {
|
||||
return commands.GetTemplates(arg)
|
||||
}
|
||||
|
||||
func (Compose) Aliases() []string {
|
||||
return []string{"compose"}
|
||||
}
|
||||
|
||||
func (c Compose) Execute(args []string) error {
|
||||
if c.Headers != "" {
|
||||
if c.Body != "" {
|
||||
c.Body = c.Headers + "\r\n" + c.Body
|
||||
} else {
|
||||
c.Body = c.Headers + "\r\n\r\n"
|
||||
}
|
||||
}
|
||||
if c.Template == "" {
|
||||
c.Template = config.Templates.NewMessage
|
||||
}
|
||||
editHeaders := (config.Compose.EditHeaders || c.Edit) && !c.NoEdit
|
||||
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
|
||||
msg, err := gomail.ReadMessage(strings.NewReader(c.Body))
|
||||
if errors.Is(err, io.EOF) { // completely empty
|
||||
msg = &gomail.Message{Body: strings.NewReader("")}
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("mail.ReadMessage: %w", err)
|
||||
}
|
||||
headers := mail.HeaderFromMap(msg.Header)
|
||||
|
||||
composer, err := app.NewComposer(acct,
|
||||
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||
c.Template, &headers, nil, msg.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composer.Tab = app.NewTab(composer, "New email")
|
||||
if c.SkipEditor {
|
||||
composer.Terminal().Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Connection struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Connection{})
|
||||
}
|
||||
|
||||
func (Connection) Description() string {
|
||||
return "Disconnect or reconnect the current account."
|
||||
}
|
||||
|
||||
func (Connection) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (Connection) Aliases() []string {
|
||||
return []string{"connect", "disconnect"}
|
||||
}
|
||||
|
||||
func (c Connection) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
cb := func(msg types.WorkerMessage) {
|
||||
acct.SetStatus(state.ConnectionActivity(""))
|
||||
}
|
||||
if args[0] == "connect" {
|
||||
acct.Worker().PostAction(&types.Connect{}, cb)
|
||||
acct.SetStatus(state.ConnectionActivity("Connecting..."))
|
||||
} else {
|
||||
acct.Worker().PostAction(&types.Disconnect{}, cb)
|
||||
acct.SetStatus(state.ConnectionActivity("Disconnecting..."))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type ExpandCollapseFolder struct {
|
||||
Folder string `opt:"folder" required:"false" complete:"CompleteFolder" desc:"Folder name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(ExpandCollapseFolder{})
|
||||
}
|
||||
|
||||
func (ExpandCollapseFolder) Description() string {
|
||||
return "Expand or collapse the current folder."
|
||||
}
|
||||
|
||||
func (ExpandCollapseFolder) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (ExpandCollapseFolder) Aliases() []string {
|
||||
return []string{"expand-folder", "collapse-folder"}
|
||||
}
|
||||
|
||||
func (*ExpandCollapseFolder) CompleteFolder(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(acct.Directories().List(), arg, nil)
|
||||
}
|
||||
|
||||
func (e ExpandCollapseFolder) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
if e.Folder == "" {
|
||||
e.Folder = acct.Directories().Selected()
|
||||
}
|
||||
if args[0] == "expand-folder" {
|
||||
acct.Directories().ExpandFolder(e.Folder)
|
||||
} else {
|
||||
acct.Directories().CollapseFolder(e.Folder)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type ExportMbox struct {
|
||||
Filename string `opt:"filename" complete:"CompleteFilename" desc:"Output file path."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(ExportMbox{})
|
||||
}
|
||||
|
||||
func (ExportMbox) Description() string {
|
||||
return "Export messages in the current folder to an mbox file."
|
||||
}
|
||||
|
||||
func (ExportMbox) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (ExportMbox) Aliases() []string {
|
||||
return []string{"export-mbox"}
|
||||
}
|
||||
|
||||
func (*ExportMbox) CompleteFilename(arg string) []string {
|
||||
return commands.CompletePath(arg, false)
|
||||
}
|
||||
|
||||
func (e ExportMbox) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("No message store selected")
|
||||
}
|
||||
|
||||
e.Filename = xdg.ExpandHome(e.Filename)
|
||||
|
||||
fi, err := os.Stat(e.Filename)
|
||||
if err == nil && fi.IsDir() {
|
||||
if path := acct.SelectedDirectory(); path != "" {
|
||||
if f := filepath.Base(path); f != "" {
|
||||
e.Filename = filepath.Join(e.Filename, f+".mbox")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
app.PushStatus("Exporting to "+e.Filename, 10*time.Second)
|
||||
|
||||
// uids of messages to export
|
||||
var uids []models.UID
|
||||
|
||||
// check if something is marked - we export that then
|
||||
msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages)
|
||||
if !ok {
|
||||
msgProvider = app.SelectedAccount()
|
||||
}
|
||||
if msgProvider != nil {
|
||||
marked, err := msgProvider.MarkedMessages()
|
||||
if err == nil && len(marked) > 0 {
|
||||
uids, err = sortMarkedUids(marked, store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if no messages were marked, we export everything
|
||||
if len(uids) == 0 {
|
||||
var err error
|
||||
uids, err = sortAllUids(store)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
file, err := os.Create(e.Filename)
|
||||
if err != nil {
|
||||
log.Errorf("failed to create file: %v", err)
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
var mu sync.Mutex
|
||||
var ctr uint
|
||||
var retries int
|
||||
|
||||
done := make(chan bool)
|
||||
|
||||
t := time.Now()
|
||||
total := len(uids)
|
||||
|
||||
for len(uids) > 0 {
|
||||
if retries > 0 {
|
||||
if retries > 10 {
|
||||
errorMsg := fmt.Sprintf("too many retries: %d; stopping export", retries)
|
||||
log.Errorf(errorMsg)
|
||||
app.PushError(args[0] + " " + errorMsg)
|
||||
break
|
||||
}
|
||||
sleeping := time.Duration(retries * 1e9 * 2)
|
||||
log.Debugf("sleeping for %s before retrying; retries: %d", sleeping, retries)
|
||||
time.Sleep(sleeping)
|
||||
}
|
||||
|
||||
log.Debugf("fetching %d for export", len(uids))
|
||||
acct.Worker().PostAction(&types.FetchFullMessages{
|
||||
Uids: uids,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
done <- true
|
||||
case *types.Error:
|
||||
log.Errorf("failed to fetch message: %v", msg.Error)
|
||||
app.PushError(args[0] + " error encountered: " + msg.Error.Error())
|
||||
done <- false
|
||||
case *types.FullMessage:
|
||||
mu.Lock()
|
||||
err := mboxer.Write(file, msg.Content.Reader, "", t)
|
||||
if err != nil {
|
||||
log.Warnf("failed to write mbox: %v", err)
|
||||
}
|
||||
for i, uid := range uids {
|
||||
if uid == msg.Content.Uid {
|
||||
uids = append(uids[:i], uids[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
ctr++
|
||||
mu.Unlock()
|
||||
}
|
||||
})
|
||||
if ok := <-done; ok {
|
||||
break
|
||||
}
|
||||
retries++
|
||||
}
|
||||
statusInfo := fmt.Sprintf("Exported %d of %d messages to %s.", ctr, total, e.Filename)
|
||||
app.PushStatus(statusInfo, 10*time.Second)
|
||||
log.Debugf(statusInfo)
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sortMarkedUids(marked []models.UID, store *lib.MessageStore) ([]models.UID, error) {
|
||||
lookup := map[models.UID]bool{}
|
||||
for _, uid := range marked {
|
||||
lookup[uid] = true
|
||||
}
|
||||
uids := []models.UID{}
|
||||
iter := store.UidsIterator()
|
||||
for iter.Next() {
|
||||
uid, ok := iter.Value().(models.UID)
|
||||
if !ok {
|
||||
return nil, errors.New("Invalid message UID value")
|
||||
}
|
||||
_, marked := lookup[uid]
|
||||
if marked {
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
|
||||
func sortAllUids(store *lib.MessageStore) ([]models.UID, error) {
|
||||
uids := []models.UID{}
|
||||
iter := store.UidsIterator()
|
||||
for iter.Next() {
|
||||
uid, ok := iter.Value().(models.UID)
|
||||
if !ok {
|
||||
return nil, errors.New("Invalid message UID value")
|
||||
}
|
||||
uids = append(uids, uid)
|
||||
}
|
||||
return uids, nil
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type ImportMbox struct {
|
||||
Path string `opt:"path" complete:"CompleteFilename" desc:"Input file path or URL."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(ImportMbox{})
|
||||
}
|
||||
|
||||
func (ImportMbox) Description() string {
|
||||
return "Import all messages from an (gzipped) mbox file to the current folder."
|
||||
}
|
||||
|
||||
func (ImportMbox) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (ImportMbox) Aliases() []string {
|
||||
return []string{"import-mbox"}
|
||||
}
|
||||
|
||||
func (*ImportMbox) CompleteFilename(arg string) []string {
|
||||
return commands.CompletePath(arg, false)
|
||||
}
|
||||
|
||||
func (i ImportMbox) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("No message store selected")
|
||||
}
|
||||
|
||||
folder := acct.SelectedDirectory()
|
||||
if folder == "" {
|
||||
return errors.New("No directory selected")
|
||||
}
|
||||
|
||||
importFolder := func(r io.ReadCloser) {
|
||||
defer log.PanicHandler()
|
||||
defer r.Close()
|
||||
|
||||
messages, err := mboxer.Read(r)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var appended uint32
|
||||
for i, m := range messages {
|
||||
done := make(chan bool)
|
||||
var retries int = 4
|
||||
for retries > 0 {
|
||||
var buf bytes.Buffer
|
||||
r, err := m.NewReader()
|
||||
if err != nil {
|
||||
log.Errorf("could not get reader for uid %d", m.UID())
|
||||
break
|
||||
}
|
||||
nbytes, _ := io.Copy(&buf, r)
|
||||
store.Append(
|
||||
folder,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
&buf,
|
||||
int(nbytes),
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Unsupported:
|
||||
errMsg := fmt.Sprintf("%s: AppendMessage is unsupported", args[0])
|
||||
log.Errorf(errMsg)
|
||||
app.PushError(errMsg)
|
||||
return
|
||||
case *types.Error:
|
||||
log.Errorf("AppendMessage failed: %v", msg.Error)
|
||||
done <- false
|
||||
case *types.Done:
|
||||
atomic.AddUint32(&appended, 1)
|
||||
done <- true
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
select {
|
||||
case ok := <-done:
|
||||
if ok {
|
||||
retries = 0
|
||||
} else {
|
||||
// error encountered; try to append again after a quick nap
|
||||
retries -= 1
|
||||
sleeping := time.Duration((5 - retries) * 1e9)
|
||||
|
||||
log.Debugf("sleeping for %s before append message %d again", sleeping, i)
|
||||
time.Sleep(sleeping)
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
log.Warnf("timed-out; appended %d of %d", appended, len(messages))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
infoStr := fmt.Sprintf("%s: imported %d of %d successfully.", args[0], appended, len(messages))
|
||||
log.Debugf(infoStr)
|
||||
app.PushSuccess(infoStr)
|
||||
}
|
||||
|
||||
var buf []byte
|
||||
|
||||
path := i.Path
|
||||
if ok, err := regexp.MatchString("^(http[s]\\:|www\\.)", path); ok && err == nil {
|
||||
resp, err := http.Get(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buf, err = io.ReadAll(resp.Body)
|
||||
_ = resp.Body.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
path = xdg.ExpandHome(path)
|
||||
buf, err = os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
var r io.ReadCloser
|
||||
|
||||
// detect gzip format compressed files as specified in RFC 1952
|
||||
if len(buf) >= 2 && buf[0] == 0x1f && buf[1] == 0x8b {
|
||||
var err error
|
||||
r, err = gzip.NewReader(bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
r = io.NopCloser(bytes.NewReader(buf))
|
||||
}
|
||||
|
||||
statusInfo := fmt.Sprintln("Importing", path, "to folder", folder)
|
||||
app.PushStatus(statusInfo, 10*time.Second)
|
||||
log.Debugf(statusInfo)
|
||||
|
||||
if len(store.Uids()) > 0 {
|
||||
confirm := app.NewSelectorDialog(
|
||||
"Selected directory is not empty",
|
||||
fmt.Sprintf("Import mbox file to %s anyways?", folder),
|
||||
[]string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(),
|
||||
func(option string, err error) {
|
||||
app.CloseDialog()
|
||||
if option == "Yes" {
|
||||
go importFolder(r)
|
||||
} else {
|
||||
_ = r.Close()
|
||||
}
|
||||
},
|
||||
)
|
||||
app.AddDialog(confirm)
|
||||
} else {
|
||||
go importFolder(r)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
type MakeDir struct {
|
||||
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Folder name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(MakeDir{})
|
||||
}
|
||||
|
||||
func (MakeDir) Description() string {
|
||||
return "Create and change to a new folder."
|
||||
}
|
||||
|
||||
func (MakeDir) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (MakeDir) Aliases() []string {
|
||||
return []string{"mkdir"}
|
||||
}
|
||||
|
||||
func (*MakeDir) CompleteFolder(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
sep := app.SelectedAccount().Worker().PathSeparator()
|
||||
return commands.FilterList(
|
||||
acct.Directories().List(), arg,
|
||||
func(s string) string {
|
||||
return opt.QuoteArg(s) + sep
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (m MakeDir) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
acct.Worker().PostAction(&types.CreateDirectory{
|
||||
Directory: m.Folder,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus("Directory created.", 10*time.Second)
|
||||
acct.Directories().Open(m.Folder, "", 0, nil, false)
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type NextPrevFolder struct {
|
||||
Offset int `opt:"n" default:"1"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(NextPrevFolder{})
|
||||
}
|
||||
|
||||
func (NextPrevFolder) Description() string {
|
||||
return "Cycle to the next or previous folder shown in the sidebar."
|
||||
}
|
||||
|
||||
func (NextPrevFolder) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (NextPrevFolder) Aliases() []string {
|
||||
return []string{"next-folder", "prev-folder"}
|
||||
}
|
||||
|
||||
func (np NextPrevFolder) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
if args[0] == "prev-folder" {
|
||||
acct.Directories().NextPrev(-np.Offset)
|
||||
} else {
|
||||
acct.Directories().NextPrev(np.Offset)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type NextPrevResult struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(NextPrevResult{})
|
||||
}
|
||||
|
||||
func (NextPrevResult) Description() string {
|
||||
return "Select the next or previous search result."
|
||||
}
|
||||
|
||||
func (NextPrevResult) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (NextPrevResult) Aliases() []string {
|
||||
return []string{"next-result", "prev-result"}
|
||||
}
|
||||
|
||||
func (NextPrevResult) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
if args[0] == "prev-result" {
|
||||
store := acct.Store()
|
||||
if store != nil {
|
||||
store.PrevResult()
|
||||
}
|
||||
ui.Invalidate()
|
||||
} else {
|
||||
store := acct.Store()
|
||||
if store != nil {
|
||||
store.NextResult()
|
||||
}
|
||||
ui.Invalidate()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type NextPrevMsg struct {
|
||||
Amount int `opt:"n" default:"1" metavar:"<n>[%]" action:"ParseAmount"`
|
||||
Percent bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(NextPrevMsg{})
|
||||
}
|
||||
|
||||
func (NextPrevMsg) Description() string {
|
||||
return "Select the next or previous message in the message list."
|
||||
}
|
||||
|
||||
func (NextPrevMsg) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (np *NextPrevMsg) ParseAmount(arg string) error {
|
||||
if strings.HasSuffix(arg, "%") {
|
||||
np.Percent = true
|
||||
arg = strings.TrimSuffix(arg, "%")
|
||||
}
|
||||
i, err := strconv.ParseInt(arg, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
np.Amount = int(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (NextPrevMsg) Aliases() []string {
|
||||
return []string{"next", "next-message", "prev", "prev-message"}
|
||||
}
|
||||
|
||||
func (np NextPrevMsg) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return fmt.Errorf("No message store set.")
|
||||
}
|
||||
|
||||
n := np.Amount
|
||||
if np.Percent {
|
||||
n = int(float64(acct.Messages().Height()) * (float64(n) / 100.0))
|
||||
}
|
||||
if args[0] == "prev-message" || args[0] == "prev" {
|
||||
store.NextPrev(-n)
|
||||
} else {
|
||||
store.NextPrev(n)
|
||||
}
|
||||
|
||||
if mv, ok := app.SelectedTabContent().(*app.MessageViewer); ok {
|
||||
reloadViewer := func(nextMsg *models.MessageInfo) {
|
||||
if nextMsg.Error != nil {
|
||||
app.PushError(nextMsg.Error.Error())
|
||||
return
|
||||
}
|
||||
lib.NewMessageStoreView(nextMsg, mv.MessageView().SeenFlagSet(),
|
||||
store, app.CryptoProvider(), app.DecryptKeys,
|
||||
func(view lib.MessageView, err error) {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
nextMv, err := app.NewMessageViewer(acct, view)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
app.ReplaceTab(mv, nextMv,
|
||||
nextMsg.Envelope.Subject, true)
|
||||
})
|
||||
}
|
||||
if nextMsg := store.Selected(); nextMsg != nil {
|
||||
reloadViewer(nextMsg)
|
||||
} else {
|
||||
store.FetchHeaders([]models.UID{store.SelectedUid()},
|
||||
func(msg types.WorkerMessage) {
|
||||
if m, ok := msg.(*types.MessageInfo); ok {
|
||||
reloadViewer(m.Info)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ui.Invalidate()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Query struct {
|
||||
Account string `opt:"-a" complete:"CompleteAccount" desc:"Account name."`
|
||||
Name string `opt:"-n" desc:"Force name of virtual folder."`
|
||||
Force bool `opt:"-f" desc:"Replace existing query if any."`
|
||||
Query string `opt:"..." complete:"CompleteNotmuch" desc:"Notmuch query."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Query{})
|
||||
}
|
||||
|
||||
func (Query) Description() string {
|
||||
return "Create a virtual folder using the specified notmuch query."
|
||||
}
|
||||
|
||||
func (Query) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (Query) Aliases() []string {
|
||||
return []string{"query"}
|
||||
}
|
||||
|
||||
func (Query) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (q Query) Execute([]string) error {
|
||||
var acct *app.AccountView
|
||||
|
||||
if q.Account == "" {
|
||||
acct = app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
acct, err = app.Account(q.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if acct.AccountConfig().Backend != "notmuch" {
|
||||
return errors.New(":query is only available for notmuch accounts")
|
||||
}
|
||||
|
||||
finalize := func(msg types.WorkerMessage) {
|
||||
handleDirOpenResponse(acct, msg)
|
||||
}
|
||||
|
||||
name := q.Name
|
||||
if name == "" {
|
||||
name = q.Query
|
||||
}
|
||||
acct.Directories().Open(name, q.Query, 0*time.Second, finalize, q.Force)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*Query) CompleteNotmuch(arg string) []string {
|
||||
return handleNotmuchComplete(arg)
|
||||
}
|
||||
|
||||
var notmuch_search_terms = []string{
|
||||
"from:",
|
||||
"to:",
|
||||
"tag:",
|
||||
"date:",
|
||||
"attachment:",
|
||||
"mimetype:",
|
||||
"subject:",
|
||||
"body:",
|
||||
"id:",
|
||||
"thread:",
|
||||
"folder:",
|
||||
"path:",
|
||||
}
|
||||
|
||||
func handleNotmuchComplete(arg string) []string {
|
||||
prefixes := []string{"from:", "to:"}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
arg = strings.TrimPrefix(arg, prefix)
|
||||
return commands.FilterList(
|
||||
commands.GetAddress(arg), arg,
|
||||
func(v string) string { return prefix + v },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
prefixes = []string{"tag:"}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
arg = strings.TrimPrefix(arg, prefix)
|
||||
return commands.FilterList(
|
||||
commands.GetLabels(arg), arg,
|
||||
func(v string) string { return prefix + v },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
prefixes = []string{"path:", "folder:"}
|
||||
dbPath := strings.TrimPrefix(app.SelectedAccount().AccountConfig().Source, "notmuch://")
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
arg = strings.TrimPrefix(arg, prefix)
|
||||
return commands.FilterList(
|
||||
commands.CompletePath(dbPath+arg, true), arg,
|
||||
func(v string) string { return prefix + strings.TrimPrefix(v, dbPath) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return commands.FilterList(notmuch_search_terms, arg, nil)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
)
|
||||
|
||||
type Recover struct {
|
||||
Force bool `opt:"-f" desc:"Delete recovered file after opening the composer."`
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
File string `opt:"file" complete:"CompleteFile" desc:"Recover file path."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Recover{})
|
||||
}
|
||||
|
||||
func (Recover) Description() string {
|
||||
return "Resume composing a message that was not sent nor postponed."
|
||||
}
|
||||
|
||||
func (Recover) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (Recover) Aliases() []string {
|
||||
return []string{"recover"}
|
||||
}
|
||||
|
||||
func (Recover) Options() string {
|
||||
return "feE"
|
||||
}
|
||||
|
||||
func (*Recover) CompleteFile(arg string) []string {
|
||||
// file name of temp file is hard-coded in the NewComposer() function
|
||||
files, err := filepath.Glob(
|
||||
filepath.Join(os.TempDir(), "aerc-compose-*.eml"),
|
||||
)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(files, arg, nil)
|
||||
}
|
||||
|
||||
func (r Recover) Execute(args []string) error {
|
||||
file, err := os.Open(r.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
data, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
|
||||
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
|
||||
|
||||
composer, err := app.NewComposer(acct,
|
||||
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||
"", nil, nil, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composer.Tab = app.NewTab(composer, "Recovered")
|
||||
|
||||
// remove file if force flag is set
|
||||
if r.Force {
|
||||
err = os.Remove(r.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
type RemoveDir struct {
|
||||
Force bool `opt:"-f" desc:"Remove the directory even if it contains messages."`
|
||||
Folder string `opt:"folder" complete:"CompleteFolder" required:"false" desc:"Folder name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(RemoveDir{})
|
||||
}
|
||||
|
||||
func (RemoveDir) Description() string {
|
||||
return "Remove folder."
|
||||
}
|
||||
|
||||
func (RemoveDir) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (RemoveDir) Aliases() []string {
|
||||
return []string{"rmdir"}
|
||||
}
|
||||
|
||||
func (RemoveDir) CompleteFolder(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(acct.Directories().List(), arg, opt.QuoteArg)
|
||||
}
|
||||
|
||||
func (r RemoveDir) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
|
||||
current := acct.Directories().SelectedDirectory()
|
||||
toRemove := current
|
||||
if r.Folder != "" {
|
||||
toRemove = acct.Directories().Directory(r.Folder)
|
||||
if toRemove == nil {
|
||||
return fmt.Errorf("No such directory: %s", r.Folder)
|
||||
}
|
||||
}
|
||||
|
||||
role := toRemove.Role
|
||||
|
||||
// Check for any messages in the directory.
|
||||
if role != models.QueryRole && toRemove.Exists > 0 && !r.Force {
|
||||
return errors.New("Refusing to remove non-empty directory; use -f")
|
||||
}
|
||||
|
||||
if role == models.VirtualRole {
|
||||
return errors.New("Cannot remove a virtual node")
|
||||
}
|
||||
|
||||
if toRemove != current {
|
||||
r.remove(acct, toRemove, func() {})
|
||||
return nil
|
||||
}
|
||||
|
||||
curDir := current.Name
|
||||
var newDir string
|
||||
dirFound := false
|
||||
|
||||
oldDir := acct.Directories().Previous()
|
||||
if oldDir != "" {
|
||||
present := false
|
||||
for _, dir := range acct.Directories().List() {
|
||||
if dir == oldDir {
|
||||
present = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if oldDir != curDir && present {
|
||||
newDir = oldDir
|
||||
dirFound = true
|
||||
}
|
||||
}
|
||||
|
||||
defaultDir := acct.AccountConfig().Default
|
||||
if !dirFound && defaultDir != curDir {
|
||||
for _, dir := range acct.Directories().List() {
|
||||
if defaultDir == dir {
|
||||
newDir = dir
|
||||
dirFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dirFound {
|
||||
for _, dir := range acct.Directories().List() {
|
||||
if dir != curDir {
|
||||
newDir = dir
|
||||
dirFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !dirFound {
|
||||
return errors.New("No directory to move to afterwards!")
|
||||
}
|
||||
|
||||
reopenCurrentDir := func() { acct.Directories().Open(curDir, "", 0, nil, false) }
|
||||
|
||||
acct.Directories().Open(newDir, "", 0, func(msg types.WorkerMessage) {
|
||||
switch msg.(type) {
|
||||
case *types.Done:
|
||||
break
|
||||
case *types.Error:
|
||||
app.PushError("Could not change directory")
|
||||
reopenCurrentDir()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
r.remove(acct, toRemove, reopenCurrentDir)
|
||||
}, false)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RemoveDir) remove(acct *app.AccountView, dir *models.Directory, onErr func()) {
|
||||
acct.Worker().PostAction(&types.RemoveDirectory{
|
||||
Directory: dir.Name,
|
||||
Quiet: r.Force,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus("Directory removed.", 10*time.Second)
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
onErr()
|
||||
case *types.Unsupported:
|
||||
app.PushError(":rmdir is not supported by the backend.")
|
||||
onErr()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/imap/extensions/xgmext"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type SearchFilter struct {
|
||||
Read bool `opt:"-r" action:"ParseRead" desc:"Search for read messages."`
|
||||
Unread bool `opt:"-u" action:"ParseUnread" desc:"Search for unread messages."`
|
||||
Body bool `opt:"-b" desc:"Search in the body of the messages."`
|
||||
All bool `opt:"-a" desc:"Search in the entire text of the messages."`
|
||||
UseExtension bool `opt:"-e" desc:"Use custom search backend extension."`
|
||||
Headers textproto.MIMEHeader `opt:"-H" action:"ParseHeader" metavar:"<header>:<value>" desc:"Search for messages with the specified header."`
|
||||
WithFlags models.Flags `opt:"-x" action:"ParseFlag" complete:"CompleteFlag" desc:"Search messages with specified flag."`
|
||||
WithoutFlags models.Flags `opt:"-X" action:"ParseNotFlag" complete:"CompleteFlag" desc:"Search messages without specified flag."`
|
||||
To []string `opt:"-t" action:"ParseTo" complete:"CompleteAddress" desc:"Search for messages To:<address>."`
|
||||
From []string `opt:"-f" action:"ParseFrom" complete:"CompleteAddress" desc:"Search for messages From:<address>."`
|
||||
Cc []string `opt:"-c" action:"ParseCc" complete:"CompleteAddress" desc:"Search for messages Cc:<address>."`
|
||||
StartDate time.Time `opt:"-d" action:"ParseDate" complete:"CompleteDate" desc:"Search for messages within a particular date range."`
|
||||
EndDate time.Time
|
||||
Terms string `opt:"..." required:"false" complete:"CompleteTerms" desc:"Search term."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(SearchFilter{})
|
||||
}
|
||||
|
||||
func (SearchFilter) Description() string {
|
||||
return "Search or filter the current folder."
|
||||
}
|
||||
|
||||
func (SearchFilter) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (SearchFilter) Aliases() []string {
|
||||
return []string{"search", "filter"}
|
||||
}
|
||||
|
||||
func (*SearchFilter) CompleteFlag(arg string) []string {
|
||||
return commands.FilterList(commands.GetFlagList(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (*SearchFilter) CompleteAddress(arg string) []string {
|
||||
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (*SearchFilter) CompleteDate(arg string) []string {
|
||||
return commands.FilterList(commands.GetDateList(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (s *SearchFilter) CompleteTerms(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
if acct.AccountConfig().Backend == "notmuch" {
|
||||
return handleNotmuchComplete(arg)
|
||||
}
|
||||
caps := acct.Worker().Backend.Capabilities()
|
||||
if caps != nil && caps.Has("X-GM-EXT-1") && s.UseExtension {
|
||||
return handleXGMEXTComplete(arg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseRead(arg string) error {
|
||||
s.WithFlags |= models.SeenFlag
|
||||
s.WithoutFlags &^= models.SeenFlag
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseUnread(arg string) error {
|
||||
s.WithFlags &^= models.SeenFlag
|
||||
s.WithoutFlags |= models.SeenFlag
|
||||
return nil
|
||||
}
|
||||
|
||||
var flagValues = map[string]models.Flags{
|
||||
"seen": models.SeenFlag,
|
||||
"answered": models.AnsweredFlag,
|
||||
"forwarded": models.ForwardedFlag,
|
||||
"flagged": models.FlaggedFlag,
|
||||
"draft": models.DraftFlag,
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseFlag(arg string) error {
|
||||
f, ok := flagValues[strings.ToLower(arg)]
|
||||
if !ok {
|
||||
return fmt.Errorf("%q unknown flag", arg)
|
||||
}
|
||||
s.WithFlags |= f
|
||||
s.WithoutFlags &^= f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseNotFlag(arg string) error {
|
||||
f, ok := flagValues[strings.ToLower(arg)]
|
||||
if !ok {
|
||||
return fmt.Errorf("%q unknown flag", arg)
|
||||
}
|
||||
s.WithFlags &^= f
|
||||
s.WithoutFlags |= f
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseHeader(arg string) error {
|
||||
name, value, hasColon := strings.Cut(arg, ":")
|
||||
if !hasColon {
|
||||
return fmt.Errorf("%q invalid syntax", arg)
|
||||
}
|
||||
if s.Headers == nil {
|
||||
s.Headers = make(textproto.MIMEHeader)
|
||||
}
|
||||
s.Headers.Add(name, strings.TrimSpace(value))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseTo(arg string) error {
|
||||
s.To = append(s.To, arg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseFrom(arg string) error {
|
||||
s.From = append(s.From, arg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseCc(arg string) error {
|
||||
s.Cc = append(s.Cc, arg)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SearchFilter) ParseDate(arg string) error {
|
||||
start, end, err := parse.DateRange(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.StartDate = start
|
||||
s.EndDate = end
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s SearchFilter) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
criteria := types.SearchCriteria{
|
||||
WithFlags: s.WithFlags,
|
||||
WithoutFlags: s.WithoutFlags,
|
||||
From: s.From,
|
||||
To: s.To,
|
||||
Cc: s.Cc,
|
||||
Headers: s.Headers,
|
||||
StartDate: s.StartDate,
|
||||
EndDate: s.EndDate,
|
||||
SearchBody: s.Body,
|
||||
SearchAll: s.All,
|
||||
Terms: []string{s.Terms},
|
||||
UseExtension: s.UseExtension,
|
||||
}
|
||||
|
||||
if args[0] == "filter" {
|
||||
if len(args[1:]) == 0 {
|
||||
return Clear{}.Execute([]string{"clear"})
|
||||
}
|
||||
acct.SetStatus(state.FilterActivity("Filtering..."), state.Search(""))
|
||||
store.SetFilter(&criteria)
|
||||
cb := func(msg types.WorkerMessage) {
|
||||
if _, ok := msg.(*types.Done); ok {
|
||||
acct.SetStatus(state.FilterResult(strings.Join(args, " ")))
|
||||
log.Tracef("Filter results: %v", store.Uids())
|
||||
}
|
||||
}
|
||||
store.Sort(store.GetCurrentSortCriteria(), cb)
|
||||
} else {
|
||||
acct.SetStatus(state.Search("Searching..."))
|
||||
cb := func(uids []models.UID) {
|
||||
acct.SetStatus(state.Search(strings.Join(args, " ")))
|
||||
log.Tracef("Search results: %v", uids)
|
||||
store.ApplySearch(uids)
|
||||
// TODO: Remove when stores have multiple OnUpdate handlers
|
||||
ui.Invalidate()
|
||||
}
|
||||
store.Search(&criteria, cb)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleXGMEXTComplete(arg string) []string {
|
||||
prefixes := []string{"from:", "to:", "deliveredto:", "cc:", "bcc:"}
|
||||
for _, prefix := range prefixes {
|
||||
if strings.HasPrefix(arg, prefix) {
|
||||
arg = strings.TrimPrefix(arg, prefix)
|
||||
return commands.FilterList(
|
||||
commands.GetAddress(arg), arg,
|
||||
func(v string) string { return prefix + v },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return commands.FilterList(xgmext.Terms, arg, nil)
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type SelectMessage struct {
|
||||
Index int `opt:"n"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(SelectMessage{})
|
||||
}
|
||||
|
||||
func (SelectMessage) Description() string {
|
||||
return "Select the <N>th message in the message list."
|
||||
}
|
||||
|
||||
func (SelectMessage) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (SelectMessage) Aliases() []string {
|
||||
return []string{"select", "select-message"}
|
||||
}
|
||||
|
||||
func (s SelectMessage) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
if acct.Messages().Empty() {
|
||||
return nil
|
||||
}
|
||||
acct.Messages().Select(s.Index)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/sort"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Sort struct {
|
||||
Unused struct{} `opt:"-"`
|
||||
// these fields are only used for completion
|
||||
Reverse bool `opt:"-r" desc:"Sort in the reverse order."`
|
||||
Criteria []string `opt:"criteria" complete:"CompleteCriteria" desc:"Sort criterion."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Sort{})
|
||||
}
|
||||
|
||||
func (Sort) Description() string {
|
||||
return "Sort the message list by the given criteria."
|
||||
}
|
||||
|
||||
func (Sort) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (Sort) Aliases() []string {
|
||||
return []string{"sort"}
|
||||
}
|
||||
|
||||
var supportedCriteria = []string{
|
||||
"arrival",
|
||||
"cc",
|
||||
"date",
|
||||
"from",
|
||||
"read",
|
||||
"size",
|
||||
"subject",
|
||||
"to",
|
||||
"flagged",
|
||||
}
|
||||
|
||||
func (*Sort) CompleteCriteria(arg string) []string {
|
||||
return commands.FilterList(supportedCriteria, arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (Sort) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected.")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("Messages still loading.")
|
||||
}
|
||||
|
||||
if c := store.Capabilities(); c != nil {
|
||||
if !c.Sort {
|
||||
return errors.New("Sorting is not available for this backend.")
|
||||
}
|
||||
}
|
||||
|
||||
var err error
|
||||
var sortCriteria []*types.SortCriterion
|
||||
if len(args[1:]) == 0 {
|
||||
sortCriteria = acct.GetSortCriteria()
|
||||
} else {
|
||||
sortCriteria, err = sort.GetSortCriteria(args[1:])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
acct.SetStatus(state.Sorting(true))
|
||||
store.Sort(sortCriteria, func(msg types.WorkerMessage) {
|
||||
if _, ok := msg.(*types.Done); ok {
|
||||
acct.SetStatus(state.Sorting(false))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Split struct {
|
||||
Size int `opt:"n" required:"false" action:"ParseSize"`
|
||||
Delta bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Split{})
|
||||
}
|
||||
|
||||
func (Split) Description() string {
|
||||
return "Split the message list with a preview pane."
|
||||
}
|
||||
|
||||
func (Split) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (s *Split) ParseSize(arg string) error {
|
||||
i, err := strconv.ParseInt(arg, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
s.Size = int(i)
|
||||
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
|
||||
s.Delta = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Split) Aliases() []string {
|
||||
return []string{"split", "vsplit", "hsplit"}
|
||||
}
|
||||
|
||||
func (s Split) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := app.SelectedAccount().Store()
|
||||
if store == nil {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
if s.Size == 0 && acct.SplitSize() == 0 {
|
||||
if args[0] == "split" || args[0] == "hsplit" {
|
||||
s.Size = app.SelectedAccount().Messages().Height() / 4
|
||||
} else {
|
||||
s.Size = app.SelectedAccount().Messages().Width() / 2
|
||||
}
|
||||
}
|
||||
if s.Delta {
|
||||
acct.SetSplitSize(acct.SplitSize() + s.Size)
|
||||
return nil
|
||||
}
|
||||
if s.Size == acct.SplitSize() {
|
||||
// Repeated commands of the same size have the effect of
|
||||
// toggling the split
|
||||
s.Size = 0
|
||||
}
|
||||
if s.Size < 0 {
|
||||
// Don't allow split to go negative
|
||||
s.Size = 1
|
||||
}
|
||||
switch args[0] {
|
||||
case "split", "hsplit":
|
||||
acct.Split(s.Size)
|
||||
case "vsplit":
|
||||
acct.Vsplit(s.Size)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type ViewMessage struct {
|
||||
Peek bool `opt:"-p" desc:"Peek message without marking it as read."`
|
||||
Background bool `opt:"-b" desc:"Open message in a background tab."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(ViewMessage{})
|
||||
}
|
||||
|
||||
func (ViewMessage) Description() string {
|
||||
return "View the selected message in a new tab."
|
||||
}
|
||||
|
||||
func (ViewMessage) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST
|
||||
}
|
||||
|
||||
func (ViewMessage) Aliases() []string {
|
||||
return []string{"view-message", "view"}
|
||||
}
|
||||
|
||||
func (v ViewMessage) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
if acct.Messages().Empty() {
|
||||
return nil
|
||||
}
|
||||
store := acct.Messages().Store()
|
||||
msg := acct.Messages().Selected()
|
||||
if msg == nil {
|
||||
return nil
|
||||
}
|
||||
_, deleted := store.Deleted[msg.Uid]
|
||||
if deleted {
|
||||
return nil
|
||||
}
|
||||
if msg.Error != nil {
|
||||
app.PushError(msg.Error.Error())
|
||||
return nil
|
||||
}
|
||||
lib.NewMessageStoreView(
|
||||
msg,
|
||||
!v.Peek && acct.UiConfig().AutoMarkRead,
|
||||
store,
|
||||
app.CryptoProvider(),
|
||||
app.DecryptKeys,
|
||||
func(view lib.MessageView, err error) {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
viewer, err := app.NewMessageViewer(acct, view)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
data := state.NewDataSetter()
|
||||
data.SetAccount(acct.AccountConfig())
|
||||
data.SetFolder(acct.Directories().SelectedDirectory())
|
||||
data.SetHeaders(msg.RFC822Headers, &models.OriginalMail{})
|
||||
var buf bytes.Buffer
|
||||
err = templates.Render(acct.UiConfig().TabTitleViewer, &buf,
|
||||
data.Data())
|
||||
if err != nil {
|
||||
acct.PushError(err)
|
||||
return
|
||||
}
|
||||
if v.Background {
|
||||
app.NewBackgroundTab(viewer, buf.String())
|
||||
} else {
|
||||
app.NewTab(viewer, buf.String())
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
)
|
||||
|
||||
var previousDir string
|
||||
|
||||
type ChangeDirectory struct {
|
||||
Target string `opt:"directory" default:"~" complete:"CompleteTarget" desc:"Target directory."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(ChangeDirectory{})
|
||||
}
|
||||
|
||||
func (ChangeDirectory) Description() string {
|
||||
return "Change aerc's current working directory."
|
||||
}
|
||||
|
||||
func (ChangeDirectory) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (ChangeDirectory) Aliases() []string {
|
||||
return []string{"cd"}
|
||||
}
|
||||
|
||||
func (*ChangeDirectory) CompleteTarget(arg string) []string {
|
||||
return CompletePath(arg, true)
|
||||
}
|
||||
|
||||
func (cd ChangeDirectory) Execute(args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cd.Target == "-" {
|
||||
if previousDir == "" {
|
||||
return errors.New("No previous folder to return to")
|
||||
} else {
|
||||
cd.Target = previousDir
|
||||
}
|
||||
}
|
||||
target := xdg.ExpandHome(cd.Target)
|
||||
if err := os.Chdir(target); err == nil {
|
||||
previousDir = cwd
|
||||
app.UpdateStatus()
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type Choose struct {
|
||||
Unused struct{} `opt:"-"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Choose{})
|
||||
}
|
||||
|
||||
func (Choose) Description() string {
|
||||
return "Prompt to choose from various options."
|
||||
}
|
||||
|
||||
func (Choose) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Choose) Aliases() []string {
|
||||
return []string{"choose"}
|
||||
}
|
||||
|
||||
func (Choose) Execute(args []string) error {
|
||||
if len(args) < 5 || len(args)%4 != 1 {
|
||||
return chooseUsage(args[0])
|
||||
}
|
||||
|
||||
choices := []app.Choice{}
|
||||
for i := 0; i+4 < len(args); i += 4 {
|
||||
if args[i+1] != "-o" {
|
||||
return chooseUsage(args[0])
|
||||
}
|
||||
choices = append(choices, app.Choice{
|
||||
Key: args[i+2],
|
||||
Text: args[i+3],
|
||||
Command: args[i+4],
|
||||
})
|
||||
}
|
||||
|
||||
app.RegisterChoices(choices)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func chooseUsage(cmd string) error {
|
||||
return fmt.Errorf("Usage: %s -o <key> <text> <command> [-o <key> <text> <command>]...", cmd)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type Close struct{}
|
||||
|
||||
func init() {
|
||||
Register(Close{})
|
||||
}
|
||||
|
||||
func (Close) Description() string {
|
||||
return "Close the focused tab."
|
||||
}
|
||||
|
||||
func (Close) Context() CommandContext {
|
||||
return MESSAGE_VIEWER | TERMINAL
|
||||
}
|
||||
|
||||
func (Close) Aliases() []string {
|
||||
return []string{"close"}
|
||||
}
|
||||
|
||||
func (Close) Execute([]string) error {
|
||||
app.RemoveTab(app.SelectedTabContent(), true)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"path"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/lib/templates"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type CommandContext uint32
|
||||
|
||||
const (
|
||||
NONE = 1 << iota
|
||||
// available everywhere
|
||||
GLOBAL
|
||||
// only when a message list is focused
|
||||
MESSAGE_LIST
|
||||
// only when a message viewer is focused
|
||||
MESSAGE_VIEWER
|
||||
// only when a message composer editor is focused
|
||||
COMPOSE_EDIT
|
||||
// only when a message composer review screen is focused
|
||||
COMPOSE_REVIEW
|
||||
// only when a terminal
|
||||
TERMINAL
|
||||
)
|
||||
|
||||
func CurrentContext() CommandContext {
|
||||
var context CommandContext = GLOBAL
|
||||
|
||||
switch tab := app.SelectedTabContent().(type) {
|
||||
case *app.AccountView:
|
||||
context |= MESSAGE_LIST
|
||||
case *app.Composer:
|
||||
if tab.Bindings() == "compose::review" {
|
||||
context |= COMPOSE_REVIEW
|
||||
} else {
|
||||
context |= COMPOSE_EDIT
|
||||
}
|
||||
case *app.MessageViewer:
|
||||
context |= MESSAGE_VIEWER
|
||||
case *app.Terminal:
|
||||
context |= TERMINAL
|
||||
}
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
type Command interface {
|
||||
Description() string
|
||||
Context() CommandContext
|
||||
Aliases() []string
|
||||
Execute([]string) error
|
||||
}
|
||||
|
||||
var allCommands map[string]Command
|
||||
|
||||
func Register(cmd Command) {
|
||||
if allCommands == nil {
|
||||
allCommands = make(map[string]Command)
|
||||
}
|
||||
for _, alias := range cmd.Aliases() {
|
||||
if allCommands[alias] != nil {
|
||||
panic("duplicate command alias: " + alias)
|
||||
}
|
||||
allCommands[alias] = cmd
|
||||
}
|
||||
}
|
||||
|
||||
func ActiveCommands() []Command {
|
||||
var cmds []Command
|
||||
context := CurrentContext()
|
||||
seen := make(map[reflect.Type]bool)
|
||||
|
||||
for _, cmd := range allCommands {
|
||||
t := reflect.TypeOf(cmd)
|
||||
if seen[t] {
|
||||
continue
|
||||
}
|
||||
seen[t] = true
|
||||
if cmd.Context()&context != 0 {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
}
|
||||
|
||||
return cmds
|
||||
}
|
||||
|
||||
func ActiveCommandNames() []string {
|
||||
var names []string
|
||||
context := CurrentContext()
|
||||
|
||||
for alias, cmd := range allCommands {
|
||||
if cmd.Context()&context != 0 {
|
||||
names = append(names, alias)
|
||||
}
|
||||
}
|
||||
|
||||
return names
|
||||
}
|
||||
|
||||
type NoSuchCommand string
|
||||
|
||||
func (err NoSuchCommand) Error() string {
|
||||
return "Unknown command " + string(err)
|
||||
}
|
||||
|
||||
// Expand non-ambiguous command abbreviations.
|
||||
//
|
||||
// q --> quit
|
||||
// ar --> archive
|
||||
// im --> import-mbox
|
||||
func ExpandAbbreviations(name string) (string, Command, error) {
|
||||
context := CurrentContext()
|
||||
name = strings.TrimLeft(name, ": \t")
|
||||
|
||||
cmd, found := allCommands[name]
|
||||
if found && cmd.Context()&context != 0 {
|
||||
return name, cmd, nil
|
||||
}
|
||||
|
||||
var candidate Command
|
||||
var candidateName string
|
||||
|
||||
for alias, cmd := range allCommands {
|
||||
if cmd.Context()&context == 0 || !strings.HasPrefix(alias, name) {
|
||||
continue
|
||||
}
|
||||
if candidate != nil {
|
||||
// We have more than one command partially
|
||||
// matching the input.
|
||||
return name, nil, NoSuchCommand(name)
|
||||
}
|
||||
// We have a partial match.
|
||||
candidate = cmd
|
||||
candidateName = alias
|
||||
}
|
||||
|
||||
if candidate == nil {
|
||||
return name, nil, NoSuchCommand(name)
|
||||
}
|
||||
|
||||
return candidateName, candidate, nil
|
||||
}
|
||||
|
||||
func ResolveCommand(
|
||||
cmdline string, acct *config.AccountConfig, msg *models.MessageInfo,
|
||||
) (string, Command, error) {
|
||||
cmdline, err := ExpandTemplates(cmdline, acct, msg)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
name, rest, didCut := strings.Cut(cmdline, " ")
|
||||
name, cmd, err := ExpandAbbreviations(name)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
cmdline = name
|
||||
if didCut {
|
||||
cmdline += " " + rest
|
||||
}
|
||||
return cmdline, cmd, nil
|
||||
}
|
||||
|
||||
func templateData(
|
||||
cfg *config.AccountConfig,
|
||||
msg *models.MessageInfo,
|
||||
) models.TemplateData {
|
||||
var folder *models.Directory
|
||||
|
||||
acct := app.SelectedAccount()
|
||||
if acct != nil {
|
||||
folder = acct.Directories().SelectedDirectory()
|
||||
}
|
||||
if cfg == nil && acct != nil {
|
||||
cfg = acct.AccountConfig()
|
||||
}
|
||||
if msg == nil && acct != nil {
|
||||
msg, _ = acct.SelectedMessage()
|
||||
}
|
||||
|
||||
data := state.NewDataSetter()
|
||||
data.SetAccount(cfg)
|
||||
data.SetFolder(folder)
|
||||
data.SetInfo(msg, 0, false)
|
||||
if acct != nil {
|
||||
acct.SetStatus(func(s *state.AccountState, _ string) {
|
||||
data.SetState(s)
|
||||
})
|
||||
}
|
||||
|
||||
return data.Data()
|
||||
}
|
||||
|
||||
func ExecuteCommand(cmd Command, cmdline string) error {
|
||||
args := opt.LexArgs(cmdline)
|
||||
if args.Count() == 0 {
|
||||
return errors.New("No arguments")
|
||||
}
|
||||
log.Tracef("executing command %s", args.String())
|
||||
// copy zeroed struct
|
||||
tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
|
||||
if err := opt.ArgsToStruct(args.Clone(), tmp); err != nil {
|
||||
return err
|
||||
}
|
||||
return tmp.Execute(args.Args())
|
||||
}
|
||||
|
||||
// expand template expressions
|
||||
func ExpandTemplates(
|
||||
s string, cfg *config.AccountConfig, msg *models.MessageInfo,
|
||||
) (string, error) {
|
||||
if strings.Contains(s, "{{") && strings.Contains(s, "}}") {
|
||||
t, err := templates.ParseTemplate("execute", s)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
data := templateData(cfg, msg)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err = templates.Render(t, &buf, data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
s = buf.String()
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func GetTemplateCompletion(
|
||||
cmd string,
|
||||
) ([]string, string, bool) {
|
||||
countLeft := strings.Count(cmd, "{{")
|
||||
if countLeft == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
countRight := strings.Count(cmd, "}}")
|
||||
|
||||
switch {
|
||||
case countLeft > countRight:
|
||||
// complete template terms
|
||||
var i int
|
||||
for i = len(cmd) - 1; i >= 0; i-- {
|
||||
if strings.ContainsRune("{()| ", rune(cmd[i])) {
|
||||
break
|
||||
}
|
||||
}
|
||||
search, prefix := cmd[i+1:], cmd[:i+1]
|
||||
padding := strings.Repeat(" ",
|
||||
len(search)-len(strings.TrimLeft(search, " ")))
|
||||
options := FilterList(
|
||||
templates.Terms(),
|
||||
strings.TrimSpace(search),
|
||||
nil,
|
||||
)
|
||||
return options, prefix + padding, true
|
||||
case countLeft == countRight:
|
||||
// expand template
|
||||
s, err := ExpandTemplates(cmd, nil, nil)
|
||||
if err != nil {
|
||||
log.Warnf("template rendering failed: %v", err)
|
||||
return nil, "", false
|
||||
}
|
||||
return []string{s}, "", true
|
||||
}
|
||||
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
// GetCompletions returns the completion options and the command prefix
|
||||
func GetCompletions(
|
||||
cmd Command, args *opt.Args,
|
||||
) (options []opt.Completion, prefix string) {
|
||||
// copy zeroed struct
|
||||
tmp := reflect.New(reflect.TypeOf(cmd)).Interface().(Command)
|
||||
s, err := args.ArgSafe(0)
|
||||
if err != nil {
|
||||
log.Errorf("completions error: %v", err)
|
||||
return options, prefix
|
||||
}
|
||||
spec := opt.NewCmdSpec(s, tmp)
|
||||
return spec.GetCompletions(args)
|
||||
}
|
||||
|
||||
func GetFolders(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return make([]string, 0)
|
||||
}
|
||||
return FilterList(acct.Directories().List(), arg, nil)
|
||||
}
|
||||
|
||||
func GetTemplates(arg string) []string {
|
||||
templates := make(map[string]bool)
|
||||
for _, dir := range config.Templates.TemplateDirs {
|
||||
for _, f := range listDir(dir, false) {
|
||||
if !isDir(path.Join(dir, f)) {
|
||||
templates[f] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
names := make([]string, 0, len(templates))
|
||||
for n := range templates {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return FilterList(names, arg, nil)
|
||||
}
|
||||
|
||||
func GetLabels(arg string) []string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return make([]string, 0)
|
||||
}
|
||||
var prefix string
|
||||
if arg != "" {
|
||||
// + and - are used to denote tag addition / removal and need to
|
||||
// be striped only the last tag should be completed, so that
|
||||
// multiple labels can be selected
|
||||
switch arg[0] {
|
||||
case '+':
|
||||
prefix = "+"
|
||||
case '-':
|
||||
prefix = "-"
|
||||
}
|
||||
arg = strings.TrimLeft(arg, "+-")
|
||||
}
|
||||
return FilterList(acct.Labels(), arg, func(s string) string {
|
||||
return opt.QuoteArg(prefix+s) + " "
|
||||
})
|
||||
}
|
||||
|
||||
// hasCaseSmartPrefix checks whether s starts with prefix, using a case
|
||||
// sensitive match if and only if prefix contains upper case letters.
|
||||
func hasCaseSmartPrefix(s, prefix string) bool {
|
||||
if hasUpper(prefix) {
|
||||
return strings.HasPrefix(s, prefix)
|
||||
}
|
||||
return strings.HasPrefix(strings.ToLower(s), strings.ToLower(prefix))
|
||||
}
|
||||
|
||||
func hasUpper(s string) bool {
|
||||
for _, r := range s {
|
||||
if unicode.IsUpper(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/mail"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/completer"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
)
|
||||
|
||||
// GetAddress uses the address-book-cmd for address completion
|
||||
func GetAddress(search string) []string {
|
||||
var options []string
|
||||
|
||||
cmd := app.SelectedAccount().AccountConfig().AddressBookCmd
|
||||
if cmd == "" {
|
||||
cmd = config.Compose.AddressBookCmd
|
||||
if cmd == "" {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
cmpl := completer.New(cmd, func(err error) {
|
||||
app.PushError(
|
||||
fmt.Sprintf("could not complete header: %v", err))
|
||||
log.Warnf("could not complete header: %v", err)
|
||||
})
|
||||
|
||||
if cmpl != nil {
|
||||
addrList, _ := cmpl.ForHeader("to")(context.Background(), search)
|
||||
for _, full := range addrList {
|
||||
addr, err := mail.ParseAddress(full.Value)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
options = append(options, addr.Address)
|
||||
}
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
// GetFlagList returns a list of available flags for completion
|
||||
func GetFlagList() []string {
|
||||
return []string{"Seen", "Answered", "Forwarded", "Flagged", "Draft"}
|
||||
}
|
||||
|
||||
// GetDateList returns a list of date terms for completion
|
||||
func GetDateList() []string {
|
||||
return []string{
|
||||
"today", "yesterday", "this_week", "this_month",
|
||||
"this_year", "last_week", "last_month", "last_year",
|
||||
"Monday", "Tuesday", "Wednesday", "Thursday", "Friday",
|
||||
"Saturday", "Sunday",
|
||||
}
|
||||
}
|
||||
|
||||
// Operands returns a slice without any option flags or mandatory option
|
||||
// arguments
|
||||
func Operands(args []string, spec string) []string {
|
||||
var result []string
|
||||
for i := 0; i < len(args); i++ {
|
||||
if s := args[i]; s == "--" {
|
||||
return args[i+1:]
|
||||
} else if strings.HasPrefix(s, "-") && len(spec) > 0 {
|
||||
r := string(s[len(s)-1]) + ":"
|
||||
if strings.Contains(spec, r) {
|
||||
i++
|
||||
}
|
||||
continue
|
||||
}
|
||||
result = append(result, args[i])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package commands_test
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
func TestCommands_Operand(t *testing.T) {
|
||||
tests := []struct {
|
||||
args []string
|
||||
spec string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"},
|
||||
spec: "ab:c",
|
||||
want: "cmdbla",
|
||||
},
|
||||
{
|
||||
args: []string{"cmd", "-a", "-b", "arg1", "-c", "--", "bla"},
|
||||
spec: "ab:c",
|
||||
want: "bla",
|
||||
},
|
||||
{
|
||||
args: []string{"cmd", "-a", "-b", "arg1", "-c", "bla"},
|
||||
spec: "ab:c:",
|
||||
want: "cmd",
|
||||
},
|
||||
{
|
||||
args: nil,
|
||||
spec: "ab:c:",
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for i, test := range tests {
|
||||
arg := strings.Join(commands.Operands(test.args, test.spec), "")
|
||||
if arg != test.want {
|
||||
t.Errorf("failed test %d: want '%s', got '%s'", i,
|
||||
test.want, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Abort struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Abort{})
|
||||
}
|
||||
|
||||
func (Abort) Description() string {
|
||||
return "Close the composer without sending."
|
||||
}
|
||||
|
||||
func (Abort) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Abort) Aliases() []string {
|
||||
return []string{"abort"}
|
||||
}
|
||||
|
||||
func (Abort) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
app.RemoveTab(composer, true)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type AttachKey struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(AttachKey{})
|
||||
}
|
||||
|
||||
func (AttachKey) Description() string {
|
||||
return "Attach the public key of the current account."
|
||||
}
|
||||
|
||||
func (AttachKey) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (AttachKey) Aliases() []string {
|
||||
return []string{"attach-key"}
|
||||
}
|
||||
|
||||
func (AttachKey) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
return composer.SetAttachKey(!composer.AttachKey())
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Attach struct {
|
||||
Menu bool `opt:"-m" desc:"Select files from file-picker-cmd."`
|
||||
Name string `opt:"-r" desc:"<name> <cmd...>: Generate attachment from command output."`
|
||||
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
|
||||
Args string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Attach{})
|
||||
}
|
||||
|
||||
func (Attach) Description() string {
|
||||
return "Attach the file at the given path to the email."
|
||||
}
|
||||
|
||||
func (Attach) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Attach) Aliases() []string {
|
||||
return []string{"attach"}
|
||||
}
|
||||
|
||||
func (*Attach) CompletePath(arg string) []string {
|
||||
return commands.CompletePath(arg, false)
|
||||
}
|
||||
|
||||
func (a Attach) Execute(args []string) error {
|
||||
if a.Menu && a.Name != "" {
|
||||
return errors.New("-m and -r are mutually exclusive")
|
||||
}
|
||||
switch {
|
||||
case a.Menu:
|
||||
return a.openMenu()
|
||||
case a.Name != "":
|
||||
if a.Path == "" {
|
||||
return errors.New("command is required")
|
||||
}
|
||||
return a.readCommand()
|
||||
default:
|
||||
if a.Args != "" {
|
||||
return errors.New("only a single path is supported")
|
||||
}
|
||||
return a.addPath(a.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func (a Attach) addPath(path string) error {
|
||||
path = xdg.ExpandHome(path)
|
||||
attachments, err := filepath.Glob(path)
|
||||
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
|
||||
log.Warnf("failed to parse as globbing pattern: %v", err)
|
||||
attachments = []string{path}
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(path, ".") && !strings.Contains(path, "/.") {
|
||||
log.Debugf("removing hidden files from glob results")
|
||||
for i := len(attachments) - 1; i >= 0; i-- {
|
||||
if strings.HasPrefix(filepath.Base(attachments[i]), ".") {
|
||||
if i == len(attachments)-1 {
|
||||
attachments = attachments[:i]
|
||||
continue
|
||||
}
|
||||
attachments = append(attachments[:i], attachments[i+1:]...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
for _, attach := range attachments {
|
||||
log.Debugf("attaching '%s'", attach)
|
||||
|
||||
pathinfo, err := os.Stat(attach)
|
||||
if err != nil {
|
||||
log.Errorf("failed to stat file: %v", err)
|
||||
app.PushError(err.Error())
|
||||
return err
|
||||
} else if pathinfo.IsDir() && len(attachments) == 1 {
|
||||
app.PushError("Attachment must be a file, not a directory")
|
||||
return nil
|
||||
}
|
||||
|
||||
composer.AddAttachment(attach)
|
||||
}
|
||||
|
||||
if len(attachments) == 1 {
|
||||
app.PushSuccess(fmt.Sprintf("Attached %s", path))
|
||||
} else {
|
||||
app.PushSuccess(fmt.Sprintf("Attached %d files", len(attachments)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a Attach) openMenu() error {
|
||||
filePickerCmd := config.Compose.FilePickerCmd
|
||||
if filePickerCmd == "" {
|
||||
return fmt.Errorf("no file-picker-cmd defined")
|
||||
}
|
||||
|
||||
if strings.Contains(filePickerCmd, "%s") {
|
||||
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%s", a.Path)
|
||||
}
|
||||
|
||||
picks, err := os.CreateTemp("", "aerc-filepicker-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var filepicker *exec.Cmd
|
||||
if strings.Contains(filePickerCmd, "%f") {
|
||||
filePickerCmd = strings.ReplaceAll(filePickerCmd, "%f", picks.Name())
|
||||
filepicker = exec.Command("sh", "-c", filePickerCmd)
|
||||
} else {
|
||||
filepicker = exec.Command("sh", "-c", filePickerCmd+" >&3")
|
||||
filepicker.ExtraFiles = append(filepicker.ExtraFiles, picks)
|
||||
}
|
||||
|
||||
t, err := app.NewTerminal(filepicker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Focus(true)
|
||||
t.OnClose = func(err error) {
|
||||
defer func() {
|
||||
if err := picks.Close(); err != nil {
|
||||
log.Errorf("error closing file: %v", err)
|
||||
}
|
||||
if err := os.Remove(picks.Name()); err != nil {
|
||||
log.Errorf("could not remove tmp file: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
app.CloseDialog()
|
||||
|
||||
if err != nil {
|
||||
log.Errorf("terminal closed with error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = picks.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
log.Errorf("seek failed: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(picks)
|
||||
for scanner.Scan() {
|
||||
f := strings.TrimSpace(scanner.Text())
|
||||
if _, err := os.Stat(f); err != nil {
|
||||
continue
|
||||
}
|
||||
log.Tracef("File picker attaches: %v", f)
|
||||
err := a.addPath(f)
|
||||
if err != nil {
|
||||
log.Errorf("attach failed for file %s: %v", f, err)
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
app.AddDialog(app.DefaultDialog(
|
||||
ui.NewBox(t, "File Picker", "", app.SelectedAccountUiConfig()),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a Attach) readCommand() error {
|
||||
cmd := exec.Command("sh", "-c", a.Path+" "+a.Args)
|
||||
|
||||
data, err := cmd.Output()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Output")
|
||||
}
|
||||
|
||||
reader := bufio.NewReader(bytes.NewReader(data))
|
||||
|
||||
mimeType, mimeParams, err := lib.FindMimeType(a.Name, reader)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "FindMimeType")
|
||||
}
|
||||
|
||||
mimeParams["name"] = a.Name
|
||||
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
err = composer.AddPartAttachment(a.Name, mimeType, mimeParams, reader)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "AddPartAttachment")
|
||||
}
|
||||
|
||||
app.PushSuccess(fmt.Sprintf("Attached %s", a.Name))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type CC struct {
|
||||
Recipients string `opt:"recipients" complete:"CompleteAddress" desc:"Recipient from address book."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(CC{})
|
||||
}
|
||||
|
||||
func (CC) Description() string {
|
||||
return "Add the given address(es) to the Cc or Bcc header."
|
||||
}
|
||||
|
||||
func (CC) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (CC) Aliases() []string {
|
||||
return []string{"cc", "bcc"}
|
||||
}
|
||||
|
||||
func (*CC) CompleteAddress(arg string) []string {
|
||||
return commands.GetAddress(arg)
|
||||
}
|
||||
|
||||
func (c CC) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
switch args[0] {
|
||||
case "cc":
|
||||
return composer.AddEditor("Cc", c.Recipients, true)
|
||||
case "bcc":
|
||||
return composer.AddEditor("Bcc", c.Recipients, true)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Detach struct {
|
||||
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Attachment file path."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Detach{})
|
||||
}
|
||||
|
||||
func (Detach) Description() string {
|
||||
return "Detach the file with the given path from the composed email."
|
||||
}
|
||||
|
||||
func (Detach) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Detach) Aliases() []string {
|
||||
return []string{"detach"}
|
||||
}
|
||||
|
||||
func (*Detach) CompletePath(arg string) []string {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
return commands.FilterList(composer.GetAttachments(), arg, nil)
|
||||
}
|
||||
|
||||
func (d Detach) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
if d.Path == "" {
|
||||
// if no attachment is specified, delete the first in the list
|
||||
atts := composer.GetAttachments()
|
||||
if len(atts) > 0 {
|
||||
d.Path = atts[0]
|
||||
} else {
|
||||
return fmt.Errorf("No attachments to delete")
|
||||
}
|
||||
}
|
||||
|
||||
return d.removePath(d.Path)
|
||||
}
|
||||
|
||||
func (d Detach) removePath(path string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
// If we don't get an error here, the path was not a pattern.
|
||||
if err := composer.DeleteAttachment(path); err == nil {
|
||||
log.Debugf("detaching '%s'", path)
|
||||
app.PushSuccess(fmt.Sprintf("Detached %s", path))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
currentAttachments := composer.GetAttachments()
|
||||
detached := make([]string, 0, len(currentAttachments))
|
||||
for _, a := range currentAttachments {
|
||||
// Don't use filepath.Glob like :attach does. Not all files
|
||||
// that match the glob are already attached to the message.
|
||||
matches, err := filepath.Match(path, a)
|
||||
if err != nil && errors.Is(err, filepath.ErrBadPattern) {
|
||||
log.Warnf("failed to parse as globbing pattern: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
if matches {
|
||||
log.Debugf("detaching '%s'", a)
|
||||
if err := composer.DeleteAttachment(a); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
detached = append(detached, a)
|
||||
}
|
||||
}
|
||||
|
||||
if len(detached) == 1 {
|
||||
app.PushSuccess(fmt.Sprintf("Detached %s", detached[0]))
|
||||
} else {
|
||||
app.PushSuccess(fmt.Sprintf("Detached %d files", len(detached)))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
)
|
||||
|
||||
type Edit struct {
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Edit{})
|
||||
}
|
||||
|
||||
func (Edit) Description() string {
|
||||
return "(Re-)open text editor to edit the message in progress."
|
||||
}
|
||||
|
||||
func (Edit) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Edit) Aliases() []string {
|
||||
return []string{"edit"}
|
||||
}
|
||||
|
||||
func (e Edit) Execute(args []string) error {
|
||||
composer, ok := app.SelectedTabContent().(*app.Composer)
|
||||
if !ok {
|
||||
return errors.New("only valid while composing")
|
||||
}
|
||||
|
||||
editHeaders := (config.Compose.EditHeaders || e.Edit) && !e.NoEdit
|
||||
|
||||
err := composer.ShowTerminal(editHeaders)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composer.FocusTerminal()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Encrypt struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Encrypt{})
|
||||
}
|
||||
|
||||
func (Encrypt) Description() string {
|
||||
return "Toggle encryption of the message to all recipients."
|
||||
}
|
||||
|
||||
func (Encrypt) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Encrypt) Aliases() []string {
|
||||
return []string{"encrypt"}
|
||||
}
|
||||
|
||||
func (Encrypt) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
composer.SetEncrypt(!composer.Encrypt())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Header struct {
|
||||
Force bool `opt:"-f" desc:"Overwrite any existing header."`
|
||||
Remove bool `opt:"-d" desc:"Remove the header instead of adding it."`
|
||||
Name string `opt:"name" complete:"CompleteHeaders" desc:"Header name."`
|
||||
Value string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
var headers = []string{
|
||||
"From",
|
||||
"To",
|
||||
"Cc",
|
||||
"Bcc",
|
||||
"Subject",
|
||||
"Comments",
|
||||
"Keywords",
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Header{})
|
||||
}
|
||||
|
||||
func (Header) Description() string {
|
||||
return "Add or remove the specified email header."
|
||||
}
|
||||
|
||||
func (Header) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Header) Aliases() []string {
|
||||
return []string{"header"}
|
||||
}
|
||||
|
||||
func (Header) Options() string {
|
||||
return "fd"
|
||||
}
|
||||
|
||||
func (*Header) CompleteHeaders(arg string) []string {
|
||||
return commands.FilterList(headers, arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (h Header) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
name := strings.TrimRight(h.Name, ":")
|
||||
|
||||
if h.Remove {
|
||||
return composer.DelEditor(name)
|
||||
}
|
||||
|
||||
if !h.Force {
|
||||
headers, err := composer.PrepareHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if headers.Get(name) != "" && h.Value != "" {
|
||||
return fmt.Errorf(
|
||||
"Header %s is already set to %q (use -f to overwrite)",
|
||||
name, headers.Get(name))
|
||||
}
|
||||
}
|
||||
|
||||
return composer.AddEditor(name, h.Value, false)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
)
|
||||
|
||||
type Multipart struct {
|
||||
Remove bool `opt:"-d" desc:"Remove the specified mime/type."`
|
||||
Mime string `opt:"mime" metavar:"<mime/type>" complete:"CompleteMime" desc:"MIME/type name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Multipart{})
|
||||
}
|
||||
|
||||
func (Multipart) Description() string {
|
||||
return "Convert the message to multipart with the given mime/type part."
|
||||
}
|
||||
|
||||
func (Multipart) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Multipart) Aliases() []string {
|
||||
return []string{"multipart"}
|
||||
}
|
||||
|
||||
func (*Multipart) CompleteMime(arg string) []string {
|
||||
var completions []string
|
||||
for mime := range config.Converters {
|
||||
completions = append(completions, mime)
|
||||
}
|
||||
return commands.FilterList(completions, arg, nil)
|
||||
}
|
||||
|
||||
func (m Multipart) Execute(args []string) error {
|
||||
composer, ok := app.SelectedTabContent().(*app.Composer)
|
||||
if !ok {
|
||||
return fmt.Errorf(":multipart is only available on the compose::review screen")
|
||||
}
|
||||
|
||||
if m.Remove {
|
||||
return composer.RemovePart(m.Mime)
|
||||
} else {
|
||||
_, found := config.Converters[m.Mime]
|
||||
if !found {
|
||||
return fmt.Errorf("no command defined for MIME type: %s", m.Mime)
|
||||
}
|
||||
err := composer.AppendPart(
|
||||
m.Mime,
|
||||
map[string]string{"Charset": "UTF-8"},
|
||||
// the actual content of the part will be rendered
|
||||
// every time the body of the email is updated
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type NextPrevField struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(NextPrevField{})
|
||||
}
|
||||
|
||||
func (NextPrevField) Description() string {
|
||||
return "Cycle between header input fields."
|
||||
}
|
||||
|
||||
func (NextPrevField) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT
|
||||
}
|
||||
|
||||
func (NextPrevField) Aliases() []string {
|
||||
return []string{"next-field", "prev-field"}
|
||||
}
|
||||
|
||||
func (NextPrevField) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
var ok bool
|
||||
if args[0] == "prev-field" {
|
||||
ok = composer.PrevField()
|
||||
} else {
|
||||
ok = composer.NextField()
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("%s not available when edit-headers=true", args[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Postpone struct {
|
||||
Folder string `opt:"-t" complete:"CompleteFolder" desc:"Override the target folder."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Postpone{})
|
||||
}
|
||||
|
||||
func (Postpone) Description() string {
|
||||
return "Save the current state of the message to the postpone folder."
|
||||
}
|
||||
|
||||
func (Postpone) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Postpone) Aliases() []string {
|
||||
return []string{"postpone"}
|
||||
}
|
||||
|
||||
func (*Postpone) CompleteFolder(arg string) []string {
|
||||
return commands.GetFolders(arg)
|
||||
}
|
||||
|
||||
func (p Postpone) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("No message store selected")
|
||||
}
|
||||
tab := app.SelectedTab()
|
||||
if tab == nil {
|
||||
return errors.New("No tab selected")
|
||||
}
|
||||
composer, _ := tab.Content.(*app.Composer)
|
||||
config := composer.Config()
|
||||
tabName := tab.Name
|
||||
|
||||
targetFolder := config.Postpone
|
||||
if composer.RecalledFrom() != "" {
|
||||
targetFolder = composer.RecalledFrom()
|
||||
}
|
||||
if p.Folder != "" {
|
||||
targetFolder = p.Folder
|
||||
}
|
||||
if targetFolder == "" {
|
||||
return errors.New("No Postpone location configured")
|
||||
}
|
||||
|
||||
log.Tracef("Postponing mail")
|
||||
|
||||
header, err := composer.PrepareHeader()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "PrepareHeader")
|
||||
}
|
||||
header.SetContentType("text/plain", map[string]string{"charset": "UTF-8"})
|
||||
header.Set("Content-Transfer-Encoding", "quoted-printable")
|
||||
worker := composer.Worker()
|
||||
dirs := acct.Directories().List()
|
||||
alreadyCreated := false
|
||||
for _, dir := range dirs {
|
||||
if dir == targetFolder {
|
||||
alreadyCreated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
errChan := make(chan string)
|
||||
|
||||
// run this as a goroutine so we can make other progress. The message
|
||||
// will be saved once the directory is created.
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
errStr := <-errChan
|
||||
if errStr != "" {
|
||||
app.PushError(errStr)
|
||||
return
|
||||
}
|
||||
|
||||
handleErr := func(err error) {
|
||||
app.PushError(err.Error())
|
||||
log.Errorf("Postponing failed: %v", err)
|
||||
app.NewTab(composer, tabName)
|
||||
}
|
||||
|
||||
app.RemoveTab(composer, false)
|
||||
buf := &bytes.Buffer{}
|
||||
|
||||
err = composer.WriteMessage(header, buf)
|
||||
if err != nil {
|
||||
handleErr(errors.Wrap(err, "WriteMessage"))
|
||||
return
|
||||
}
|
||||
store.Append(
|
||||
targetFolder,
|
||||
models.SeenFlag|models.DraftFlag,
|
||||
time.Now(),
|
||||
buf,
|
||||
buf.Len(),
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus("Message postponed.", 10*time.Second)
|
||||
composer.SetPostponed()
|
||||
composer.Close()
|
||||
case *types.Error:
|
||||
handleErr(msg.Error)
|
||||
}
|
||||
},
|
||||
)
|
||||
}()
|
||||
|
||||
if !alreadyCreated {
|
||||
// to synchronise the creating of the directory
|
||||
worker.PostAction(&types.CreateDirectory{
|
||||
Directory: targetFolder,
|
||||
}, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
errChan <- ""
|
||||
case *types.Error:
|
||||
errChan <- msg.Error.Error()
|
||||
}
|
||||
})
|
||||
} else {
|
||||
errChan <- ""
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/commands/mode"
|
||||
"git.sr.ht/~rjarry/aerc/commands/msg"
|
||||
"git.sr.ht/~rjarry/aerc/lib/hooks"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/send"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type Send struct {
|
||||
Archive string `opt:"-a" action:"ParseArchive" metavar:"flat|year|month" complete:"CompleteArchive" desc:"Archive the message being replied to."`
|
||||
CopyTo []string `opt:"-t" complete:"CompleteFolders" action:"ParseCopyTo" desc:"Override the Copy-To folders."`
|
||||
|
||||
CopyToReplied bool `opt:"-r" desc:"Save sent message to current folder."`
|
||||
NoCopyToReplied bool `opt:"-R" desc:"Do not save sent message to current folder."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Send{})
|
||||
}
|
||||
|
||||
func (Send) Description() string {
|
||||
return "Send the message using the configured outgoing transport."
|
||||
}
|
||||
|
||||
func (Send) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Send) Aliases() []string {
|
||||
return []string{"send"}
|
||||
}
|
||||
|
||||
func (*Send) CompleteArchive(arg string) []string {
|
||||
return commands.FilterList(msg.ARCHIVE_TYPES, arg, nil)
|
||||
}
|
||||
|
||||
func (*Send) CompleteFolders(arg string) []string {
|
||||
return commands.GetFolders(arg)
|
||||
}
|
||||
|
||||
func (s *Send) ParseArchive(arg string) error {
|
||||
for _, a := range msg.ARCHIVE_TYPES {
|
||||
if a == arg {
|
||||
s.Archive = arg
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("unsupported archive type")
|
||||
}
|
||||
|
||||
func (o *Send) ParseCopyTo(arg string) error {
|
||||
o.CopyTo = append(o.CopyTo, strings.Split(arg, ",")...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s Send) Execute(args []string) error {
|
||||
tab := app.SelectedTab()
|
||||
if tab == nil {
|
||||
return errors.New("No selected tab")
|
||||
}
|
||||
composer, _ := tab.Content.(*app.Composer)
|
||||
|
||||
err := composer.CheckForMultipartErrors()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
config := composer.Config()
|
||||
|
||||
if len(s.CopyTo) == 0 {
|
||||
s.CopyTo = config.CopyTo
|
||||
}
|
||||
copyToReplied := config.CopyToReplied || (s.CopyToReplied && !s.NoCopyToReplied)
|
||||
|
||||
outgoing, err := config.Outgoing.ConnectionString()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "ReadCredentials(outgoing)")
|
||||
}
|
||||
if outgoing == "" {
|
||||
return errors.New(
|
||||
"No outgoing mail transport configured for this account")
|
||||
}
|
||||
|
||||
header, err := composer.PrepareHeader()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "PrepareHeader")
|
||||
}
|
||||
rcpts, err := listRecipients(header)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "listRecipients")
|
||||
}
|
||||
if len(rcpts) == 0 {
|
||||
return errors.New("Cannot send message with no recipients")
|
||||
}
|
||||
|
||||
if config.StripBcc {
|
||||
// Do NOT leak Bcc addresses to all recipients.
|
||||
header.Del("Bcc")
|
||||
}
|
||||
|
||||
uri, err := url.Parse(outgoing)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "url.Parse(outgoing)")
|
||||
}
|
||||
|
||||
var domain string
|
||||
if domain_, ok := config.Params["smtp-domain"]; ok {
|
||||
domain = domain_
|
||||
}
|
||||
from := config.From
|
||||
if config.UseEnvelopeFrom {
|
||||
if fl, _ := header.AddressList("from"); len(fl) != 0 {
|
||||
from = fl[0]
|
||||
}
|
||||
}
|
||||
|
||||
log.Debugf("send config uri: %s", uri.Redacted())
|
||||
log.Debugf("send config from: %s", from)
|
||||
log.Debugf("send config rcpts: %s", rcpts)
|
||||
log.Debugf("send config domain: %s", domain)
|
||||
|
||||
warnSubject := composer.ShouldWarnSubject()
|
||||
warnAttachment := composer.ShouldWarnAttachment()
|
||||
if warnSubject || warnAttachment {
|
||||
var msg string
|
||||
switch {
|
||||
case warnSubject && warnAttachment:
|
||||
msg = "The subject is empty, and you may have forgotten an attachment."
|
||||
case warnSubject:
|
||||
msg = "The subject is empty."
|
||||
default:
|
||||
msg = "You may have forgotten an attachment."
|
||||
}
|
||||
|
||||
prompt := app.NewPrompt(
|
||||
msg+" Abort send? [Y/n] ",
|
||||
func(text string) {
|
||||
if text == "n" || text == "N" {
|
||||
sendHelper(composer, header, uri, domain,
|
||||
from, rcpts, tab.Name, s.CopyTo,
|
||||
s.Archive, copyToReplied)
|
||||
}
|
||||
}, func(ctx context.Context, cmd string) ([]opt.Completion, string) {
|
||||
var comps []opt.Completion
|
||||
if cmd == "" {
|
||||
comps = append(comps, opt.Completion{Value: "y"})
|
||||
comps = append(comps, opt.Completion{Value: "n"})
|
||||
}
|
||||
return comps, ""
|
||||
},
|
||||
)
|
||||
|
||||
app.PushPrompt(prompt)
|
||||
} else {
|
||||
sendHelper(composer, header, uri, domain, from, rcpts, tab.Name,
|
||||
s.CopyTo, s.Archive, copyToReplied)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func sendHelper(composer *app.Composer, header *mail.Header, uri *url.URL, domain string,
|
||||
from *mail.Address, rcpts []*mail.Address, tabName string, copyTo []string,
|
||||
archive string, copyToReplied bool,
|
||||
) {
|
||||
// we don't want to block the UI thread while we are sending
|
||||
// so we do everything in a goroutine and hide the composer from the user
|
||||
app.RemoveTab(composer, false)
|
||||
app.PushStatus("Sending...", 10*time.Second)
|
||||
|
||||
// enter no-quit mode
|
||||
mode.NoQuit()
|
||||
|
||||
var shouldCopy bool = (len(copyTo) > 0 || copyToReplied) && !strings.HasPrefix(uri.Scheme, "jmap")
|
||||
var copyBuf bytes.Buffer
|
||||
|
||||
failCh := make(chan error)
|
||||
// writer
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
var folders []string
|
||||
folders = append(folders, copyTo...)
|
||||
if copyToReplied && composer.Parent() != nil {
|
||||
folders = append(folders, composer.Parent().Folder)
|
||||
}
|
||||
sender, err := send.NewSender(
|
||||
composer.Worker(), uri, domain, from, rcpts, folders)
|
||||
if err != nil {
|
||||
failCh <- errors.Wrap(err, "send:")
|
||||
return
|
||||
}
|
||||
|
||||
var writer io.Writer = sender
|
||||
|
||||
if shouldCopy {
|
||||
writer = io.MultiWriter(writer, ©Buf)
|
||||
}
|
||||
|
||||
err = composer.WriteMessage(header, writer)
|
||||
if err != nil {
|
||||
failCh <- err
|
||||
return
|
||||
}
|
||||
failCh <- sender.Close()
|
||||
}()
|
||||
|
||||
// cleanup + copy to sent
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
// leave no-quit mode
|
||||
defer mode.NoQuitDone()
|
||||
|
||||
err := <-failCh
|
||||
if err != nil {
|
||||
app.PushError(strings.ReplaceAll(err.Error(), "\n", " "))
|
||||
app.NewTab(composer, tabName)
|
||||
return
|
||||
}
|
||||
if shouldCopy {
|
||||
app.PushStatus("Copying to copy-to folders", 10*time.Second)
|
||||
errch := copyToSent(copyTo, copyToReplied, copyBuf.Len(),
|
||||
©Buf, composer)
|
||||
err = <-errch
|
||||
if err != nil {
|
||||
errmsg := fmt.Sprintf(
|
||||
"message sent, but copying to %v failed: %v",
|
||||
copyTo, err.Error())
|
||||
app.PushError(errmsg)
|
||||
composer.SetSent(archive)
|
||||
composer.Close()
|
||||
return
|
||||
}
|
||||
}
|
||||
app.PushStatus("Message sent.", 10*time.Second)
|
||||
composer.SetSent(archive)
|
||||
err = hooks.RunHook(&hooks.MailSent{
|
||||
Account: composer.Account().Name(),
|
||||
Backend: composer.Account().AccountConfig().Backend,
|
||||
Header: header,
|
||||
})
|
||||
if err != nil {
|
||||
log.Errorf("failed to trigger mail-sent hook: %v", err)
|
||||
composer.Account().PushError(fmt.Errorf("[hook.mail-sent] failed: %w", err))
|
||||
}
|
||||
composer.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
func listRecipients(h *mail.Header) ([]*mail.Address, error) {
|
||||
var rcpts []*mail.Address
|
||||
for _, key := range []string{"to", "cc", "bcc"} {
|
||||
list, err := h.AddressList(key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rcpts = append(rcpts, list...)
|
||||
}
|
||||
return rcpts, nil
|
||||
}
|
||||
|
||||
func copyToSent(dests []string, copyToReplied bool, n int, msg *bytes.Buffer, composer *app.Composer) <-chan error {
|
||||
errCh := make(chan error, 1)
|
||||
acct := composer.Account()
|
||||
if acct == nil {
|
||||
errCh <- errors.New("No account selected")
|
||||
return errCh
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
errCh <- errors.New("No message store selected")
|
||||
return errCh
|
||||
}
|
||||
for _, dest := range dests {
|
||||
store.Append(
|
||||
dest,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
bytes.NewReader(msg.Bytes()),
|
||||
n,
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
errCh <- nil
|
||||
case *types.Error:
|
||||
errCh <- msg.Error
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
if copyToReplied && composer.Parent() != nil {
|
||||
store.Append(
|
||||
composer.Parent().Folder,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
bytes.NewReader(msg.Bytes()),
|
||||
n,
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
errCh <- nil
|
||||
case *types.Error:
|
||||
errCh <- msg.Error
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
return errCh
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type Sign struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(Sign{})
|
||||
}
|
||||
|
||||
func (Sign) Description() string {
|
||||
return "Sign the message using the account default key."
|
||||
}
|
||||
|
||||
func (Sign) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (Sign) Aliases() []string {
|
||||
return []string{"sign"}
|
||||
}
|
||||
|
||||
func (Sign) Execute(args []string) error {
|
||||
composer, _ := app.SelectedTabContent().(*app.Composer)
|
||||
|
||||
err := composer.SetSign(!composer.Sign())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var statusline string
|
||||
|
||||
if composer.Sign() {
|
||||
statusline = "Message will be signed."
|
||||
} else {
|
||||
statusline = "Message will not be signed."
|
||||
}
|
||||
|
||||
app.PushStatus(statusline, 10*time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package compose
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type AccountSwitcher interface {
|
||||
SwitchAccount(*app.AccountView) error
|
||||
}
|
||||
|
||||
type SwitchAccount struct {
|
||||
Prev bool `opt:"-p" desc:"Switch to previous account."`
|
||||
Next bool `opt:"-n" desc:"Switch to next account."`
|
||||
Account string `opt:"account" required:"false" complete:"CompleteAccount" desc:"Account name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(SwitchAccount{})
|
||||
}
|
||||
|
||||
func (SwitchAccount) Description() string {
|
||||
return "Change composing from the specified account."
|
||||
}
|
||||
|
||||
func (SwitchAccount) Context() commands.CommandContext {
|
||||
return commands.COMPOSE_EDIT | commands.COMPOSE_REVIEW
|
||||
}
|
||||
|
||||
func (SwitchAccount) Aliases() []string {
|
||||
return []string{"switch-account"}
|
||||
}
|
||||
|
||||
func (*SwitchAccount) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, nil)
|
||||
}
|
||||
|
||||
func (s SwitchAccount) Execute(args []string) error {
|
||||
if !s.Prev && !s.Next && s.Account == "" {
|
||||
return errors.New("Usage: switch-account -n | -p | <account-name>")
|
||||
}
|
||||
|
||||
switcher, ok := app.SelectedTabContent().(AccountSwitcher)
|
||||
if !ok {
|
||||
return errors.New("this tab cannot switch accounts")
|
||||
}
|
||||
|
||||
var acct *app.AccountView
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case s.Prev:
|
||||
acct, err = app.PrevAccount()
|
||||
case s.Next:
|
||||
acct, err = app.NextAccount()
|
||||
default:
|
||||
acct, err = app.Account(s.Account)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = switcher.SwitchAccount(acct); err != nil {
|
||||
return err
|
||||
}
|
||||
acct.UpdateStatus()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type ChangeTab struct {
|
||||
Tab string `opt:"tab" complete:"CompleteTab" desc:"Tab name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(ChangeTab{})
|
||||
}
|
||||
|
||||
func (ChangeTab) Description() string {
|
||||
return "Change the focus to the specified tab."
|
||||
}
|
||||
|
||||
func (ChangeTab) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (ChangeTab) Aliases() []string {
|
||||
return []string{"ct", "change-tab"}
|
||||
}
|
||||
|
||||
func (*ChangeTab) CompleteTab(arg string) []string {
|
||||
return FilterList(app.TabNames(), arg, nil)
|
||||
}
|
||||
|
||||
func (c ChangeTab) Execute(args []string) error {
|
||||
if c.Tab == "-" {
|
||||
ok := app.SelectPreviousTab()
|
||||
if !ok {
|
||||
return errors.New("No previous tab to return to")
|
||||
}
|
||||
} else {
|
||||
n, err := strconv.Atoi(c.Tab)
|
||||
if err == nil {
|
||||
if strings.HasPrefix(c.Tab, "+") || strings.HasPrefix(c.Tab, "-") {
|
||||
app.SelectTabAtOffset(n)
|
||||
} else {
|
||||
ok := app.SelectTabIndex(n)
|
||||
if !ok {
|
||||
return errors.New("No tab with that index")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ok := app.SelectTab(c.Tab)
|
||||
if !ok {
|
||||
return errors.New("No tab with that name")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type Echo struct {
|
||||
Template string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Echo{})
|
||||
}
|
||||
|
||||
func (Echo) Description() string {
|
||||
return "Print text after template expansion."
|
||||
}
|
||||
|
||||
func (Echo) Aliases() []string {
|
||||
return []string{"echo"}
|
||||
}
|
||||
|
||||
func (Echo) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (e Echo) Execute(args []string) error {
|
||||
app.PushSuccess(e.Template)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
)
|
||||
|
||||
type Eml struct {
|
||||
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"EML file path."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Eml{})
|
||||
}
|
||||
|
||||
func (Eml) Description() string {
|
||||
return "Open an eml file into the message viewer."
|
||||
}
|
||||
|
||||
func (Eml) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Eml) Aliases() []string {
|
||||
return []string{"eml", "preview"}
|
||||
}
|
||||
|
||||
func (*Eml) CompletePath(arg string) []string {
|
||||
return CompletePath(arg, false)
|
||||
}
|
||||
|
||||
func (e Eml) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return fmt.Errorf("no account selected")
|
||||
}
|
||||
|
||||
showEml := func(r io.Reader) {
|
||||
data, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
lib.NewEmlMessageView(data, app.CryptoProvider(), app.DecryptKeys,
|
||||
func(view lib.MessageView, err error) {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
msgView, err := app.NewMessageViewer(acct, view)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
app.NewTab(msgView,
|
||||
view.MessageInfo().Envelope.Subject)
|
||||
})
|
||||
}
|
||||
|
||||
if e.Path == "" {
|
||||
switch tab := app.SelectedTabContent().(type) {
|
||||
case *app.MessageViewer:
|
||||
part := tab.SelectedMessagePart()
|
||||
tab.MessageView().FetchBodyPart(part.Index, showEml)
|
||||
case *app.Composer:
|
||||
var buf bytes.Buffer
|
||||
h, err := tab.PrepareHeader()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tab.WriteMessage(h, &buf); err != nil {
|
||||
return err
|
||||
}
|
||||
showEml(&buf)
|
||||
default:
|
||||
return fmt.Errorf("unsupported operation")
|
||||
}
|
||||
} else {
|
||||
f, err := os.Open(xdg.ExpandHome(e.Path))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
showEml(f)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
)
|
||||
|
||||
type ExecCmd struct {
|
||||
Args []string `opt:"..."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(ExecCmd{})
|
||||
}
|
||||
|
||||
func (ExecCmd) Description() string {
|
||||
return "Execute an arbitrary command in the background."
|
||||
}
|
||||
|
||||
func (ExecCmd) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (ExecCmd) Aliases() []string {
|
||||
return []string{"exec"}
|
||||
}
|
||||
|
||||
func (e ExecCmd) Execute(args []string) error {
|
||||
cmd := exec.Command(e.Args[0], e.Args[1:]...)
|
||||
env := os.Environ()
|
||||
|
||||
switch view := app.SelectedTabContent().(type) {
|
||||
case *app.AccountView:
|
||||
env = append(env, fmt.Sprintf("account=%s", view.AccountConfig().Name))
|
||||
env = append(env, fmt.Sprintf("folder=%s", view.Directories().Selected()))
|
||||
case *app.MessageViewer:
|
||||
acct := view.SelectedAccount()
|
||||
env = append(env, fmt.Sprintf("account=%s", acct.AccountConfig().Name))
|
||||
env = append(env, fmt.Sprintf("folder=%s", acct.Directories().Selected()))
|
||||
}
|
||||
|
||||
cmd.Env = env
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
} else {
|
||||
if cmd.ProcessState.ExitCode() != 0 {
|
||||
app.PushError(fmt.Sprintf(
|
||||
"%s: completed with status %d", args[0],
|
||||
cmd.ProcessState.ExitCode()))
|
||||
} else {
|
||||
app.PushStatus(fmt.Sprintf(
|
||||
"%s: completed with status %d", args[0],
|
||||
cmd.ProcessState.ExitCode()), 10*time.Second)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type Help struct {
|
||||
Topic string `opt:"topic" action:"ParseTopic" default:"aerc" complete:"CompleteTopic" desc:"Help topic."`
|
||||
}
|
||||
|
||||
var pages = []string{
|
||||
"aerc",
|
||||
"accounts",
|
||||
"binds",
|
||||
"config",
|
||||
"imap",
|
||||
"jmap",
|
||||
"notmuch",
|
||||
"search",
|
||||
"sendmail",
|
||||
"smtp",
|
||||
"stylesets",
|
||||
"templates",
|
||||
"tutorial",
|
||||
"patch",
|
||||
"keys",
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Help{})
|
||||
}
|
||||
|
||||
func (Help) Description() string {
|
||||
return "Display one of aerc's man pages in the embedded terminal."
|
||||
}
|
||||
|
||||
func (Help) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Help) Aliases() []string {
|
||||
return []string{"help", "man"}
|
||||
}
|
||||
|
||||
func (*Help) CompleteTopic(arg string) []string {
|
||||
return FilterList(pages, arg, nil)
|
||||
}
|
||||
|
||||
func (h *Help) ParseTopic(arg string) error {
|
||||
for _, page := range pages {
|
||||
if arg == page {
|
||||
if arg != "aerc" {
|
||||
arg = "aerc-" + arg
|
||||
}
|
||||
h.Topic = arg
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("unknown topic %q", arg)
|
||||
}
|
||||
|
||||
func (h Help) Execute(args []string) error {
|
||||
if h.Topic == "aerc-keys" {
|
||||
app.AddDialog(app.DefaultDialog(
|
||||
app.NewListBox(
|
||||
"Bindings: Press <Esc> or <Enter> to close. "+
|
||||
"Start typing to filter bindings.",
|
||||
app.HumanReadableBindings(),
|
||||
app.SelectedAccountUiConfig(),
|
||||
func(_ string) {
|
||||
app.CloseDialog()
|
||||
},
|
||||
),
|
||||
))
|
||||
return nil
|
||||
}
|
||||
term := Term{Cmd: []string{"man", h.Topic}}
|
||||
return term.Execute(args)
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
)
|
||||
|
||||
type cmdHistory struct {
|
||||
// rolling buffer of prior commands
|
||||
//
|
||||
// most recent command is at the end of the list,
|
||||
// least recent is index 0
|
||||
cmdList []string
|
||||
|
||||
// current placement in list
|
||||
current int
|
||||
|
||||
// initialize history storage
|
||||
initHistfile sync.Once
|
||||
histfile io.ReadWriter
|
||||
}
|
||||
|
||||
// number of commands to keep in history
|
||||
const cmdLimit = 1000
|
||||
|
||||
// CmdHistory is the history of executed commands
|
||||
var CmdHistory = cmdHistory{}
|
||||
|
||||
func (h *cmdHistory) Add(cmd string) {
|
||||
h.initHistfile.Do(h.initialize)
|
||||
|
||||
// if we're at cap, cut off the first element
|
||||
if len(h.cmdList) >= cmdLimit {
|
||||
h.cmdList = h.cmdList[1:]
|
||||
}
|
||||
|
||||
if len(h.cmdList) == 0 || h.cmdList[len(h.cmdList)-1] != cmd {
|
||||
h.cmdList = append(h.cmdList, cmd)
|
||||
|
||||
h.writeHistory()
|
||||
}
|
||||
|
||||
// whenever we add a new command, reset the current
|
||||
// pointer to the "beginning" of the list
|
||||
h.Reset()
|
||||
}
|
||||
|
||||
// Prev returns the previous command in history.
|
||||
// Since the list is reverse-order, this will return elements
|
||||
// increasingly towards index 0.
|
||||
func (h *cmdHistory) Prev() string {
|
||||
h.initHistfile.Do(h.initialize)
|
||||
|
||||
if h.current <= 0 || len(h.cmdList) == 0 {
|
||||
h.current = -1
|
||||
return "(Already at beginning)"
|
||||
}
|
||||
h.current--
|
||||
|
||||
return h.cmdList[h.current]
|
||||
}
|
||||
|
||||
// Next returns the next command in history.
|
||||
// Since the list is reverse-order, this will return elements
|
||||
// increasingly towards index len(cmdList).
|
||||
func (h *cmdHistory) Next() string {
|
||||
h.initHistfile.Do(h.initialize)
|
||||
|
||||
if h.current >= len(h.cmdList)-1 || len(h.cmdList) == 0 {
|
||||
h.current = len(h.cmdList)
|
||||
return "(Already at end)"
|
||||
}
|
||||
h.current++
|
||||
|
||||
return h.cmdList[h.current]
|
||||
}
|
||||
|
||||
// Reset the current pointer to the beginning of history.
|
||||
func (h *cmdHistory) Reset() {
|
||||
h.current = len(h.cmdList)
|
||||
}
|
||||
|
||||
func (h *cmdHistory) initialize() {
|
||||
var err error
|
||||
openFlags := os.O_RDWR | os.O_EXCL
|
||||
|
||||
histPath := xdg.StatePath("aerc", "history")
|
||||
if _, err := os.Stat(histPath); os.IsNotExist(err) {
|
||||
_ = os.MkdirAll(xdg.StatePath("aerc"), 0o700) // caught by OpenFile
|
||||
openFlags |= os.O_CREATE
|
||||
}
|
||||
|
||||
// O_EXCL to make sure that only one aerc writes to the file
|
||||
h.histfile, err = os.OpenFile(
|
||||
histPath,
|
||||
openFlags,
|
||||
0o600,
|
||||
)
|
||||
if err != nil {
|
||||
log.Errorf("failed to open history file: %v", err)
|
||||
// basically mirror the old behavior
|
||||
h.histfile = bytes.NewBuffer([]byte{})
|
||||
return
|
||||
}
|
||||
|
||||
s := bufio.NewScanner(h.histfile)
|
||||
|
||||
for s.Scan() {
|
||||
h.cmdList = append(h.cmdList, s.Text())
|
||||
}
|
||||
|
||||
h.Reset()
|
||||
}
|
||||
|
||||
func (h *cmdHistory) writeHistory() {
|
||||
if fh, ok := h.histfile.(*os.File); ok {
|
||||
err := fh.Truncate(0)
|
||||
if err != nil {
|
||||
// if we can't delete it, don't break it.
|
||||
return
|
||||
}
|
||||
_, err = fh.Seek(0, io.SeekStart)
|
||||
if err != nil {
|
||||
// if we can't delete it, don't break it.
|
||||
return
|
||||
}
|
||||
for _, entry := range h.cmdList {
|
||||
fmt.Fprintln(fh, entry)
|
||||
}
|
||||
|
||||
fh.Sync() //nolint:errcheck // if your computer can't sync you're in bigger trouble
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
type Menu struct {
|
||||
ErrExit bool `opt:"-e" desc:"Stop executing commands on the first error."`
|
||||
Background bool `opt:"-b" desc:"Do NOT spawn the popover dialog."`
|
||||
Accounts bool `opt:"-a" desc:"Feed command with account names."`
|
||||
Directories bool `opt:"-d" desc:"Feed command with folder names."`
|
||||
Command string `opt:"-c" desc:"Override [general].default-menu-cmd."`
|
||||
Xargs string `opt:"..." complete:"CompleteXargs" desc:"Command name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Menu{})
|
||||
}
|
||||
|
||||
func (Menu) Description() string {
|
||||
return "Open a popover dialog."
|
||||
}
|
||||
|
||||
func (Menu) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Menu) Aliases() []string {
|
||||
return []string{"menu"}
|
||||
}
|
||||
|
||||
func (*Menu) CompleteXargs(arg string) []string {
|
||||
return FilterList(ActiveCommandNames(), arg, nil)
|
||||
}
|
||||
|
||||
func (m Menu) Execute([]string) error {
|
||||
if m.Command == "" {
|
||||
m.Command = config.General.DefaultMenuCmd
|
||||
}
|
||||
useFallback := m.useFallback()
|
||||
if m.Background && useFallback {
|
||||
return errors.New("Either -c <command> or " +
|
||||
"default-menu-cmd is required to run " +
|
||||
"in the background.")
|
||||
}
|
||||
if _, _, err := ResolveCommand(m.Xargs, nil, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lines, err := m.feedLines()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
title := " :" + strings.TrimLeft(m.Xargs, ": \t") + " ... "
|
||||
|
||||
if useFallback {
|
||||
return m.fallback(title, lines)
|
||||
}
|
||||
|
||||
pick, err := os.CreateTemp("", "aerc-menu-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var proc *exec.Cmd
|
||||
if strings.Contains(m.Command, "%f") {
|
||||
proc = exec.Command("sh", "-c",
|
||||
strings.ReplaceAll(m.Command, "%f", opt.QuoteArg(pick.Name())))
|
||||
} else {
|
||||
proc = exec.Command("sh", "-c", m.Command+" >&3")
|
||||
proc.ExtraFiles = append(proc.ExtraFiles, pick)
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
proc.Stdin = strings.NewReader(strings.Join(lines, "\n"))
|
||||
}
|
||||
|
||||
xargs := func(err error) {
|
||||
var buf []byte
|
||||
if err == nil {
|
||||
_, err = pick.Seek(0, io.SeekStart)
|
||||
}
|
||||
if err == nil {
|
||||
buf, err = io.ReadAll(pick)
|
||||
}
|
||||
pick.Close()
|
||||
os.Remove(pick.Name())
|
||||
if err != nil {
|
||||
app.PushError("command failed: " + err.Error())
|
||||
return
|
||||
}
|
||||
if len(buf) == 0 {
|
||||
return
|
||||
}
|
||||
m.runCmd(string(buf))
|
||||
}
|
||||
|
||||
if m.Background {
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
xargs(proc.Run())
|
||||
}()
|
||||
} else {
|
||||
term, err := app.NewTerminal(proc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
term.Focus(true)
|
||||
term.OnClose = func(err error) {
|
||||
app.CloseDialog()
|
||||
xargs(err)
|
||||
}
|
||||
|
||||
widget := ui.NewBox(term, title, "", app.SelectedAccountUiConfig())
|
||||
app.AddDialog(app.DefaultDialog(widget))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Menu) useFallback() bool {
|
||||
if m.Command == "" || m.Command == "-" {
|
||||
warnMsg := "no command provided, falling back on aerc's picker."
|
||||
log.Warnf(warnMsg)
|
||||
app.PushWarning(warnMsg)
|
||||
return true
|
||||
}
|
||||
cmd, _, _ := strings.Cut(m.Command, " ")
|
||||
_, err := exec.LookPath(cmd)
|
||||
if err != nil {
|
||||
warnMsg := "command '" + cmd + "' not found in PATH, " +
|
||||
"falling back on aerc's picker."
|
||||
log.Warnf(warnMsg)
|
||||
app.PushWarning(warnMsg)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m Menu) runCmd(buffer string) {
|
||||
var (
|
||||
cmd Command
|
||||
cmdline string
|
||||
err error
|
||||
)
|
||||
|
||||
for _, line := range strings.Split(buffer, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
cmdline = m.Xargs + " " + line
|
||||
cmdline, cmd, err = ResolveCommand(cmdline, nil, nil)
|
||||
if err == nil {
|
||||
err = ExecuteCommand(cmd, cmdline)
|
||||
}
|
||||
if err != nil {
|
||||
app.PushError(m.Xargs + ": " + err.Error())
|
||||
if m.ErrExit {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Menu) fallback(title string, lines []string) error {
|
||||
listBox := app.NewListBox(
|
||||
title, lines, app.SelectedAccountUiConfig(),
|
||||
func(line string) {
|
||||
app.CloseDialog()
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
m.runCmd(line)
|
||||
})
|
||||
listBox.SetTextFilter(func(list []string, term string) []string {
|
||||
return FilterList(list, term, func(s string) string { return s })
|
||||
})
|
||||
widget := ui.NewBox(listBox, "", "", app.SelectedAccountUiConfig())
|
||||
app.AddDialog(app.DefaultDialog(widget))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Menu) feedLines() ([]string, error) {
|
||||
var lines []string
|
||||
|
||||
switch {
|
||||
case m.Accounts && m.Directories:
|
||||
for _, a := range app.AccountNames() {
|
||||
account, _ := app.Account(a)
|
||||
a = opt.QuoteArg(a)
|
||||
for _, d := range account.Directories().List() {
|
||||
dir := account.Directories().Directory(d)
|
||||
if dir != nil && dir.Role != models.QueryRole {
|
||||
d = opt.QuoteArg(d)
|
||||
}
|
||||
lines = append(lines, a+" "+d)
|
||||
}
|
||||
}
|
||||
|
||||
case m.Accounts:
|
||||
for _, account := range app.AccountNames() {
|
||||
lines = append(lines, opt.QuoteArg(account))
|
||||
}
|
||||
|
||||
case m.Directories:
|
||||
account := app.SelectedAccount()
|
||||
if account == nil {
|
||||
return nil, errors.New("No account selected.")
|
||||
}
|
||||
for _, d := range account.Directories().List() {
|
||||
dir := account.Directories().Directory(d)
|
||||
if dir != nil && dir.Role != models.QueryRole {
|
||||
d = opt.QuoteArg(d)
|
||||
}
|
||||
lines = append(lines, d)
|
||||
}
|
||||
}
|
||||
|
||||
return lines, nil
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package mode
|
||||
|
||||
import "sync/atomic"
|
||||
|
||||
// noquit is a counter for goroutines that requested the no-quit mode
|
||||
var noquit int32
|
||||
|
||||
// NoQuit enters no-quit mode where aerc cannot be exited (unless the force
|
||||
// option is used)
|
||||
func NoQuit() {
|
||||
atomic.AddInt32(&noquit, 1)
|
||||
}
|
||||
|
||||
// NoQuitDone leaves the no-quit mode
|
||||
func NoQuitDone() {
|
||||
atomic.AddInt32(&noquit, -1)
|
||||
}
|
||||
|
||||
// QuitAllowed checks if aerc can exit normally (only when all goroutines that
|
||||
// requested a no-quit mode were done and called the NoQuitDone() function)
|
||||
func QuitAllowed() bool {
|
||||
return atomic.LoadInt32(&noquit) <= 0
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type MoveTab struct {
|
||||
Index int `opt:"index" metavar:"[+|-]<index>" action:"ParseIndex"`
|
||||
Relative bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(MoveTab{})
|
||||
}
|
||||
|
||||
func (MoveTab) Description() string {
|
||||
return "Move the selected tab to the given index."
|
||||
}
|
||||
|
||||
func (MoveTab) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (m *MoveTab) ParseIndex(arg string) error {
|
||||
i, err := strconv.ParseInt(arg, 10, 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Index = int(i)
|
||||
if strings.HasPrefix(arg, "+") || strings.HasPrefix(arg, "-") {
|
||||
m.Relative = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (MoveTab) Aliases() []string {
|
||||
return []string{"move-tab"}
|
||||
}
|
||||
|
||||
func (m MoveTab) Execute(args []string) error {
|
||||
app.MoveTab(m.Index, m.Relative)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
const (
|
||||
ARCHIVE_FLAT = "flat"
|
||||
ARCHIVE_YEAR = "year"
|
||||
ARCHIVE_MONTH = "month"
|
||||
)
|
||||
|
||||
var ARCHIVE_TYPES = []string{ARCHIVE_FLAT, ARCHIVE_YEAR, ARCHIVE_MONTH}
|
||||
|
||||
type Archive struct {
|
||||
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||
Type string `opt:"type" action:"ParseArchiveType" metavar:"flat|year|month" complete:"CompleteType" desc:"Archiving scheme."`
|
||||
}
|
||||
|
||||
func (a *Archive) ParseMFS(arg string) error {
|
||||
if arg != "" {
|
||||
mfs, ok := types.StrToStrategy[arg]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||
}
|
||||
a.MultiFileStrategy = &mfs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *Archive) ParseArchiveType(arg string) error {
|
||||
for _, t := range ARCHIVE_TYPES {
|
||||
if t == arg {
|
||||
a.Type = arg
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("invalid archive type")
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Archive{})
|
||||
}
|
||||
|
||||
func (Archive) Description() string {
|
||||
return "Move the selected message to the archive."
|
||||
}
|
||||
|
||||
func (Archive) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Archive) Aliases() []string {
|
||||
return []string{"archive"}
|
||||
}
|
||||
|
||||
func (Archive) CompleteMFS(arg string) []string {
|
||||
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||
}
|
||||
|
||||
func (*Archive) CompleteType(arg string) []string {
|
||||
return commands.FilterList(ARCHIVE_TYPES, arg, nil)
|
||||
}
|
||||
|
||||
func (a Archive) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
msgs, err := h.messages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = archive(msgs, a.MultiFileStrategy, a.Type)
|
||||
return err
|
||||
}
|
||||
|
||||
func archive(msgs []*models.MessageInfo, mfs *types.MultiFileStrategy,
|
||||
archiveType string,
|
||||
) error {
|
||||
h := newHelper()
|
||||
acct, err := h.account()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var uids []models.UID
|
||||
for _, msg := range msgs {
|
||||
uids = append(uids, msg.Uid)
|
||||
}
|
||||
archiveDir := acct.AccountConfig().Archive
|
||||
marker := store.Marker()
|
||||
marker.ClearVisualMark()
|
||||
next := findNextNonDeleted(uids, store)
|
||||
|
||||
var uidMap map[string][]models.UID
|
||||
switch archiveType {
|
||||
case ARCHIVE_MONTH:
|
||||
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
|
||||
dir := strings.Join([]string{
|
||||
archiveDir,
|
||||
fmt.Sprintf("%d", msg.Envelope.Date.Year()),
|
||||
fmt.Sprintf("%02d", msg.Envelope.Date.Month()),
|
||||
}, app.SelectedAccount().Worker().PathSeparator(),
|
||||
)
|
||||
return dir
|
||||
})
|
||||
case ARCHIVE_YEAR:
|
||||
uidMap = groupBy(msgs, func(msg *models.MessageInfo) string {
|
||||
dir := strings.Join([]string{
|
||||
archiveDir,
|
||||
fmt.Sprintf("%v", msg.Envelope.Date.Year()),
|
||||
}, app.SelectedAccount().Worker().PathSeparator(),
|
||||
)
|
||||
return dir
|
||||
})
|
||||
case ARCHIVE_FLAT:
|
||||
uidMap = make(map[string][]models.UID)
|
||||
uidMap[archiveDir] = commands.UidsFromMessageInfos(msgs)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(len(uidMap))
|
||||
success := true
|
||||
|
||||
for dir, uids := range uidMap {
|
||||
store.Move(uids, dir, true, mfs, func(
|
||||
msg types.WorkerMessage,
|
||||
) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
wg.Done()
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
success = false
|
||||
wg.Done()
|
||||
marker.Remark()
|
||||
}
|
||||
})
|
||||
}
|
||||
// we need to do that in the background, else we block the main thread
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
wg.Wait()
|
||||
if success {
|
||||
var s string
|
||||
if len(uids) > 1 {
|
||||
s = "%d messages archived to %s"
|
||||
} else {
|
||||
s = "%d message archived to %s"
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf(s, len(uids), archiveDir), 10*time.Second)
|
||||
handleDone(acct, next, store)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func groupBy(msgs []*models.MessageInfo,
|
||||
grouper func(*models.MessageInfo) string,
|
||||
) map[string][]models.UID {
|
||||
m := make(map[string][]models.UID)
|
||||
for _, msg := range msgs {
|
||||
group := grouper(msg)
|
||||
m[group] = append(m[group], msg.Uid)
|
||||
}
|
||||
return m
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/commands/mode"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/send"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Bounce struct {
|
||||
Account string `opt:"-A" complete:"CompleteAccount" desc:"Account from which to re-send the message."`
|
||||
To []string `opt:"..." required:"true" complete:"CompleteTo" desc:"Recipient from address book."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Bounce{})
|
||||
}
|
||||
|
||||
func (Bounce) Description() string {
|
||||
return "Re-send the selected message(s) to the specified addresses."
|
||||
}
|
||||
|
||||
func (Bounce) Aliases() []string {
|
||||
return []string{"bounce", "resend"}
|
||||
}
|
||||
|
||||
func (*Bounce) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (*Bounce) CompleteTo(arg string) []string {
|
||||
return commands.FilterList(commands.GetAddress(arg), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (Bounce) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (b Bounce) Execute(args []string) error {
|
||||
if len(b.To) == 0 {
|
||||
return errors.New("No recipients specified")
|
||||
}
|
||||
addresses := strings.Join(b.To, ", ")
|
||||
|
||||
app.PushStatus("Bouncing to "+addresses, 10*time.Second)
|
||||
|
||||
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
|
||||
var err error
|
||||
acct := widget.SelectedAccount()
|
||||
if b.Account != "" {
|
||||
acct, err = app.Account(b.Account)
|
||||
}
|
||||
switch {
|
||||
case err != nil:
|
||||
return fmt.Errorf("Failed to select account %q: %w", b.Account, err)
|
||||
case acct == nil:
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
|
||||
store := widget.Store()
|
||||
if store == nil {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
config := acct.AccountConfig()
|
||||
|
||||
outgoing, err := config.Outgoing.ConnectionString()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "ReadCredentials()")
|
||||
}
|
||||
if outgoing == "" {
|
||||
return errors.New("No outgoing mail transport configured for this account")
|
||||
}
|
||||
uri, err := url.Parse(outgoing)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "url.Parse()")
|
||||
}
|
||||
|
||||
rcpts, err := mail.ParseAddressList(addresses)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "ParseAddressList()")
|
||||
}
|
||||
|
||||
var domain string
|
||||
if domain_, ok := config.Params["smtp-domain"]; ok {
|
||||
domain = domain_
|
||||
}
|
||||
|
||||
hostname, err := send.GetMessageIdHostname(config.SendWithHostname, config.From)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "GetMessageIdHostname()")
|
||||
}
|
||||
|
||||
// According to RFC2822, all of the resent fields corresponding
|
||||
// to a particular resending of the message SHOULD be together.
|
||||
// Each new set of resent fields is prepended to the message;
|
||||
// that is, the most recent set of resent fields appear earlier in the
|
||||
// message.
|
||||
headers := fmt.Sprintf("Resent-From: %s\r\n", config.From)
|
||||
headers += "Resent-Date: %s\r\n"
|
||||
headers += "Resent-Message-ID: <%s>\r\n"
|
||||
headers += fmt.Sprintf("Resent-To: %s\r\n", addresses)
|
||||
|
||||
helper := newHelper()
|
||||
uids, err := helper.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mode.NoQuit()
|
||||
|
||||
marker := store.Marker()
|
||||
marker.ClearVisualMark()
|
||||
|
||||
errCh := make(chan error)
|
||||
store.FetchFull(uids, func(fm *types.FullMessage) {
|
||||
defer log.PanicHandler()
|
||||
|
||||
var header mail.Header
|
||||
var msgId string
|
||||
var err, errClose error
|
||||
|
||||
uid := fm.Content.Uid
|
||||
msg := store.Messages[uid]
|
||||
if msg == nil {
|
||||
errCh <- fmt.Errorf("no message info: %v", uid)
|
||||
return
|
||||
}
|
||||
if err = header.GenerateMessageIDWithHostname(hostname); err != nil {
|
||||
errCh <- errors.Wrap(err, "GenerateMessageIDWithHostname()")
|
||||
return
|
||||
}
|
||||
if msgId, err = header.MessageID(); err != nil {
|
||||
errCh <- errors.Wrap(err, "MessageID()")
|
||||
return
|
||||
}
|
||||
reader := strings.NewReader(fmt.Sprintf(headers,
|
||||
time.Now().Format(time.RFC1123Z), msgId))
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
defer func() { errCh <- err }()
|
||||
|
||||
var sender io.WriteCloser
|
||||
|
||||
log.Debugf("Bouncing email <%s> to %s",
|
||||
msg.Envelope.MessageId, addresses)
|
||||
|
||||
if sender, err = send.NewSender(acct.Worker(), uri,
|
||||
domain, config.From, rcpts, nil); err != nil {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
errClose = sender.Close()
|
||||
// If there has already been an error,
|
||||
// we don't want to clobber it.
|
||||
if err == nil {
|
||||
err = errClose
|
||||
} else if errClose != nil {
|
||||
app.PushError(errClose.Error())
|
||||
}
|
||||
}()
|
||||
if _, err = io.Copy(sender, reader); err != nil {
|
||||
return
|
||||
}
|
||||
_, err = io.Copy(sender, fm.Content.Reader)
|
||||
}()
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
defer mode.NoQuitDone()
|
||||
|
||||
var total, success int
|
||||
|
||||
for err = range errCh {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
} else {
|
||||
success++
|
||||
}
|
||||
total++
|
||||
if total == len(uids) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if success != total {
|
||||
marker.Remark()
|
||||
app.PushError(fmt.Sprintf("Failed to bounce %d of the messages",
|
||||
total-success))
|
||||
} else {
|
||||
plural := ""
|
||||
if success > 1 {
|
||||
plural = "s"
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf("Bounced %d message%s",
|
||||
success, plural), 10*time.Second)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-message/mail"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type Copy struct {
|
||||
CreateFolders bool `opt:"-p" desc:"Create folder if it does not exist."`
|
||||
Decrypt bool `opt:"-d" desc:"Decrypt the message before copying."`
|
||||
Account string `opt:"-a" complete:"CompleteAccount" desc:"Copy to the specified account."`
|
||||
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Copy{})
|
||||
}
|
||||
|
||||
func (Copy) Description() string {
|
||||
return "Copy the selected message(s) to the specified folder."
|
||||
}
|
||||
|
||||
func (Copy) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Copy) Aliases() []string {
|
||||
return []string{"cp", "copy"}
|
||||
}
|
||||
|
||||
func (c *Copy) ParseMFS(arg string) error {
|
||||
if arg != "" {
|
||||
mfs, ok := types.StrToStrategy[arg]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||
}
|
||||
c.MultiFileStrategy = &mfs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*Copy) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (c *Copy) CompleteFolder(arg string) []string {
|
||||
var acct *app.AccountView
|
||||
if len(c.Account) > 0 {
|
||||
acct, _ = app.Account(c.Account)
|
||||
} else {
|
||||
acct = app.SelectedAccount()
|
||||
}
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(acct.Directories().List(), arg, nil)
|
||||
}
|
||||
|
||||
func (Copy) CompleteMFS(arg string) []string {
|
||||
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||
}
|
||||
|
||||
func (c Copy) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
uids, err := h.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// when the decrypt flag is set, add the current account to c.Account to
|
||||
// ensure that we do not take the store.Copy route.
|
||||
if c.Decrypt {
|
||||
if acct := app.SelectedAccount(); acct != nil {
|
||||
c.Account = acct.Name()
|
||||
} else {
|
||||
return errors.New("no account name found")
|
||||
}
|
||||
}
|
||||
|
||||
if len(c.Account) == 0 {
|
||||
store.Copy(uids, c.Folder, c.CreateFolders, c.MultiFileStrategy,
|
||||
func(msg types.WorkerMessage) {
|
||||
c.CallBack(msg, uids, store)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
destAcct, err := app.Account(c.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destStore := destAcct.Store()
|
||||
if destStore == nil {
|
||||
app.PushError(fmt.Sprintf("No message store in %s", c.Account))
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []*types.FullMessage
|
||||
fetchDone := make(chan bool, 1)
|
||||
store.FetchFull(uids, func(fm *types.FullMessage) {
|
||||
if fm == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if c.Decrypt {
|
||||
h := new(mail.Header)
|
||||
msg, ok := store.Messages[fm.Content.Uid]
|
||||
if ok {
|
||||
h = msg.RFC822Headers
|
||||
}
|
||||
cleartext, err := cryptoutil.Cleartext(fm.Content.Reader, *h)
|
||||
if err != nil {
|
||||
log.Debugf("could not decrypt message %v", fm.Content.Uid)
|
||||
} else {
|
||||
fm.Content.Reader = bytes.NewReader(cleartext)
|
||||
}
|
||||
}
|
||||
|
||||
messages = append(messages, fm)
|
||||
if len(messages) == len(uids) {
|
||||
fetchDone <- true
|
||||
}
|
||||
})
|
||||
|
||||
// Since this operation can take some time with some backends
|
||||
// (e.g. IMAP), provide some feedback to inform the user that
|
||||
// something is happening
|
||||
app.PushStatus("Copying messages...", 10*time.Second)
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
select {
|
||||
case <-fetchDone:
|
||||
break
|
||||
case <-time.After(30 * time.Second):
|
||||
// TODO: find a better way to determine if store.FetchFull()
|
||||
// has finished with some errors.
|
||||
app.PushError("Failed to fetch all messages")
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
for _, fm := range messages {
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.ReadFrom(fm.Content.Reader)
|
||||
if err != nil {
|
||||
log.Warnf("failed to read message: %v", err)
|
||||
continue
|
||||
}
|
||||
destStore.Append(
|
||||
c.Folder,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
buf,
|
||||
buf.Len(),
|
||||
func(msg types.WorkerMessage) {
|
||||
c.CallBack(msg, uids, store)
|
||||
},
|
||||
)
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Copy) CallBack(msg types.WorkerMessage, uids []models.UID, store *lib.MessageStore) {
|
||||
dest := c.Folder
|
||||
if len(c.Account) != 0 {
|
||||
dest = fmt.Sprintf("%s in %s", c.Folder, c.Account)
|
||||
}
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
var s string
|
||||
if len(uids) > 1 {
|
||||
s = "%d messages copied to %s"
|
||||
} else {
|
||||
s = "%d message copied to %s"
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
|
||||
store.Marker().ClearVisualMark()
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Delete struct {
|
||||
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Delete{})
|
||||
}
|
||||
|
||||
func (Delete) Description() string {
|
||||
return "Delete the selected message(s)."
|
||||
}
|
||||
|
||||
func (Delete) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Delete) Aliases() []string {
|
||||
return []string{"delete", "delete-message"}
|
||||
}
|
||||
|
||||
func (d *Delete) ParseMFS(arg string) error {
|
||||
if arg != "" {
|
||||
mfs, ok := types.StrToStrategy[arg]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||
}
|
||||
d.MultiFileStrategy = &mfs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (Delete) CompleteMFS(arg string) []string {
|
||||
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||
}
|
||||
|
||||
func (d Delete) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uids, err := h.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
acct, err := h.account()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sel := store.Selected()
|
||||
marker := store.Marker()
|
||||
marker.ClearVisualMark()
|
||||
// caution, can be nil
|
||||
next := findNextNonDeleted(uids, store)
|
||||
store.Delete(uids, d.MultiFileStrategy, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
var s string
|
||||
if len(uids) > 1 {
|
||||
s = "%d messages deleted"
|
||||
} else {
|
||||
s = "%d message deleted"
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf(s, len(uids)), 10*time.Second)
|
||||
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
|
||||
if isMsgView {
|
||||
if !config.Ui.NextMessageOnDelete {
|
||||
app.RemoveTab(h.msgProvider, true)
|
||||
} else {
|
||||
// no more messages in the list
|
||||
if next == nil {
|
||||
app.RemoveTab(h.msgProvider, true)
|
||||
acct.Messages().Select(-1)
|
||||
ui.Invalidate()
|
||||
return
|
||||
}
|
||||
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
|
||||
store, app.CryptoProvider(), app.DecryptKeys,
|
||||
func(view lib.MessageView, err error) {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
nextMv, err := app.NewMessageViewer(acct, view)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if next == nil {
|
||||
// We deleted the last message, select the new last message
|
||||
// instead of the first message
|
||||
acct.Messages().Select(-1)
|
||||
}
|
||||
}
|
||||
case *types.Error:
|
||||
marker.Remark()
|
||||
store.Select(sel.Uid)
|
||||
app.PushError(msg.Error.Error())
|
||||
case *types.Unsupported:
|
||||
marker.Remark()
|
||||
store.Select(sel.Uid)
|
||||
// notmuch doesn't support it, we want the user to know
|
||||
app.PushError(" error, unsupported for this worker")
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
func findNextNonDeleted(deleted []models.UID, store *lib.MessageStore) *models.MessageInfo {
|
||||
var next, previous *models.MessageInfo
|
||||
stepper := []func(){store.Next, store.Prev}
|
||||
for _, stepFn := range stepper {
|
||||
previous = nil
|
||||
for {
|
||||
next = store.Selected()
|
||||
if next != nil && !contains(deleted, next.Uid) {
|
||||
if _, deleted := store.Deleted[next.Uid]; !deleted {
|
||||
return next
|
||||
}
|
||||
}
|
||||
if next == nil || previous == next {
|
||||
// If previous == next, this is the last
|
||||
// message. Set next to nil either way
|
||||
next = nil
|
||||
break
|
||||
}
|
||||
stepFn()
|
||||
previous = next
|
||||
}
|
||||
}
|
||||
|
||||
if next != nil {
|
||||
store.Select(next.Uid)
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
func contains(uids []models.UID, uid models.UID) bool {
|
||||
for _, item := range uids {
|
||||
if item == uid {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/format"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type Envelope struct {
|
||||
Header bool `opt:"-h" desc:"Show all header fields."`
|
||||
Format string `opt:"-s" default:"%-20.20s: %s" desc:"Format specifier."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Envelope{})
|
||||
}
|
||||
|
||||
func (Envelope) Description() string {
|
||||
return "Open the message envelope in a dialog popup."
|
||||
}
|
||||
|
||||
func (Envelope) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Envelope) Aliases() []string {
|
||||
return []string{"envelope"}
|
||||
}
|
||||
|
||||
func (e Envelope) Execute(args []string) error {
|
||||
provider, ok := app.SelectedTabContent().(app.ProvidesMessages)
|
||||
if !ok {
|
||||
return fmt.Errorf("current tab does not implement app.ProvidesMessage interface")
|
||||
}
|
||||
|
||||
acct := provider.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
|
||||
var list []string
|
||||
if msg, err := provider.SelectedMessage(); err != nil {
|
||||
return err
|
||||
} else {
|
||||
if msg != nil {
|
||||
if e.Header {
|
||||
list = parseHeader(msg, e.Format)
|
||||
} else {
|
||||
list = parseEnvelope(msg, e.Format,
|
||||
acct.UiConfig().TimestampFormat)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("Selected message is empty.")
|
||||
}
|
||||
}
|
||||
|
||||
app.AddDialog(app.DefaultDialog(
|
||||
app.NewListBox(
|
||||
"Message Envelope. Press <Esc> or <Enter> to close. "+
|
||||
"Start typing to filter.",
|
||||
list,
|
||||
app.SelectedAccountUiConfig(),
|
||||
func(_ string) {
|
||||
app.CloseDialog()
|
||||
},
|
||||
),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseEnvelope(msg *models.MessageInfo, fmtStr, fmtTime string,
|
||||
) (result []string) {
|
||||
if envlp := msg.Envelope; envlp != nil {
|
||||
addStr := func(key, text string) {
|
||||
result = append(result, fmt.Sprintf(fmtStr, key, text))
|
||||
}
|
||||
addAddr := func(key string, ls []*mail.Address) {
|
||||
for _, l := range ls {
|
||||
result = append(result,
|
||||
fmt.Sprintf(fmtStr, key,
|
||||
format.AddressForHumans(l)))
|
||||
}
|
||||
}
|
||||
|
||||
addStr("Date", envlp.Date.Format(fmtTime))
|
||||
addStr("Subject", envlp.Subject)
|
||||
addStr("Message-Id", envlp.MessageId)
|
||||
|
||||
addAddr("From", envlp.From)
|
||||
addAddr("To", envlp.To)
|
||||
addAddr("ReplyTo", envlp.ReplyTo)
|
||||
addAddr("Cc", envlp.Cc)
|
||||
addAddr("Bcc", envlp.Bcc)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parseHeader(msg *models.MessageInfo, fmtStr string) (result []string) {
|
||||
if h := msg.RFC822Headers; h != nil {
|
||||
hf := h.Fields()
|
||||
for hf.Next() {
|
||||
text, err := hf.Text()
|
||||
if err != nil {
|
||||
log.Errorf(err.Error())
|
||||
text = hf.Value()
|
||||
}
|
||||
result = append(result,
|
||||
headerExpand(fmtStr, hf.Key(), text)...)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func headerExpand(fmtStr, key, text string) []string {
|
||||
var result []string
|
||||
switch strings.ToLower(key) {
|
||||
case "to", "from", "bcc", "cc":
|
||||
for _, item := range strings.Split(text, ",") {
|
||||
result = append(result, fmt.Sprintf(fmtStr, key,
|
||||
strings.TrimSpace(item)))
|
||||
}
|
||||
default:
|
||||
result = append(result, fmt.Sprintf(fmtStr, key, text))
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type Fold struct {
|
||||
All bool `opt:"-a" desc:"Fold/unfold all threads."`
|
||||
Toggle bool `opt:"-t" desc:"Toggle between folded/unfolded."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Fold{})
|
||||
}
|
||||
|
||||
func (Fold) Description() string {
|
||||
return "Collapse or expand the thread children of the selected message."
|
||||
}
|
||||
|
||||
func (Fold) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Fold) Aliases() []string {
|
||||
return []string{"fold", "unfold"}
|
||||
}
|
||||
|
||||
func (f Fold) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if f.All {
|
||||
point := store.SelectedUid()
|
||||
uids := store.Uids()
|
||||
for _, uid := range uids {
|
||||
t, err := store.Thread(uid)
|
||||
if err == nil && t.Parent == nil {
|
||||
switch args[0] {
|
||||
case "fold":
|
||||
err = store.Fold(uid, f.Toggle)
|
||||
case "unfold":
|
||||
err = store.Unfold(uid, f.Toggle)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
store.Select(point)
|
||||
ui.Invalidate()
|
||||
return err
|
||||
}
|
||||
|
||||
msg := store.Selected()
|
||||
if msg == nil {
|
||||
return errors.New("No message selected")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "fold":
|
||||
err = store.Fold(msg.Uid, f.Toggle)
|
||||
case "unfold":
|
||||
err = store.Unfold(msg.Uid, f.Toggle)
|
||||
}
|
||||
ui.Invalidate()
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/crypto"
|
||||
"git.sr.ht/~rjarry/aerc/lib/format"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type forward struct {
|
||||
AttachAll bool `opt:"-A" desc:"Forward the message and all attachments."`
|
||||
AttachFull bool `opt:"-F" desc:"Forward the full message as an RFC 2822 attachment."`
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
|
||||
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||
To []string `opt:"..." required:"false" complete:"CompleteTo" desc:"Recipient from address book."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(forward{})
|
||||
}
|
||||
|
||||
func (forward) Description() string {
|
||||
return "Open the composer to forward the selected message to another recipient."
|
||||
}
|
||||
|
||||
func (forward) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (forward) Aliases() []string {
|
||||
return []string{"forward"}
|
||||
}
|
||||
|
||||
func (*forward) CompleteTemplate(arg string) []string {
|
||||
return commands.GetTemplates(arg)
|
||||
}
|
||||
|
||||
func (*forward) CompleteTo(arg string) []string {
|
||||
return commands.GetAddress(arg)
|
||||
}
|
||||
|
||||
func (f forward) Execute(args []string) error {
|
||||
if f.AttachAll && f.AttachFull {
|
||||
return errors.New("Options -A and -F are mutually exclusive")
|
||||
}
|
||||
editHeaders := (config.Compose.EditHeaders || f.Edit) && !f.NoEdit
|
||||
|
||||
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
acct := widget.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
msg, err := widget.SelectedMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Debugf("Forwarding email <%s>", msg.Envelope.MessageId)
|
||||
|
||||
h := &mail.Header{}
|
||||
subject := "Fwd: " + msg.Envelope.Subject
|
||||
h.SetSubject(subject)
|
||||
|
||||
var tolist []*mail.Address
|
||||
to := strings.Join(f.To, ", ")
|
||||
if strings.Contains(to, "@") {
|
||||
tolist, err = mail.ParseAddressList(to)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid to address(es): %w", err)
|
||||
}
|
||||
}
|
||||
if len(tolist) > 0 {
|
||||
h.SetAddressList("to", tolist)
|
||||
}
|
||||
|
||||
original := models.OriginalMail{
|
||||
From: format.FormatAddresses(msg.Envelope.From),
|
||||
Date: msg.Envelope.Date,
|
||||
RFC822Headers: msg.RFC822Headers,
|
||||
}
|
||||
|
||||
addTab := func() (*app.Composer, error) {
|
||||
composer, err := app.NewComposer(acct,
|
||||
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||
f.Template, h, &original, nil)
|
||||
if err != nil {
|
||||
app.PushError("Error: " + err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
composer.Tab = app.NewTab(composer, subject)
|
||||
switch {
|
||||
case f.SkipEditor:
|
||||
composer.Terminal().Close()
|
||||
case !h.Has("to"):
|
||||
composer.FocusEditor("to")
|
||||
default:
|
||||
composer.FocusTerminal()
|
||||
}
|
||||
return composer, nil
|
||||
}
|
||||
|
||||
mv, isMsgViewer := widget.(*app.MessageViewer)
|
||||
store := widget.Store()
|
||||
noStore := store == nil
|
||||
if noStore && !isMsgViewer {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
if f.AttachFull {
|
||||
tmpDir, err := os.MkdirTemp("", "aerc-tmp-attachment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpFileName := path.Join(tmpDir,
|
||||
strings.ReplaceAll(fmt.Sprintf("%s.eml", msg.Envelope.Subject), "/", "-"))
|
||||
|
||||
var fetchFull func(func(io.Reader))
|
||||
|
||||
if isMsgViewer {
|
||||
fetchFull = mv.MessageView().FetchFull
|
||||
} else {
|
||||
fetchFull = func(cb func(io.Reader)) {
|
||||
store.FetchFull([]models.UID{msg.Uid}, func(fm *types.FullMessage) {
|
||||
if fm == nil || (fm != nil && fm.Content == nil) {
|
||||
return
|
||||
}
|
||||
cb(fm.Content.Reader)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fetchFull(func(r io.Reader) {
|
||||
tmpFile, err := os.Create(tmpFileName)
|
||||
if err != nil {
|
||||
log.Warnf("failed to create temporary attachment: %v", err)
|
||||
_, err = addTab()
|
||||
if err != nil {
|
||||
log.Warnf("failed to add tab: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
defer tmpFile.Close()
|
||||
_, err = io.Copy(tmpFile, r)
|
||||
if err != nil {
|
||||
log.Warnf("failed to write to tmpfile: %v", err)
|
||||
return
|
||||
}
|
||||
composer, err := addTab()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
composer.AddAttachment(tmpFileName)
|
||||
composer.OnClose(func(c *app.Composer) {
|
||||
if c.Sent() && store != nil {
|
||||
store.Forwarded([]models.UID{msg.Uid}, true, nil)
|
||||
}
|
||||
os.RemoveAll(tmpDir)
|
||||
})
|
||||
})
|
||||
} else {
|
||||
if f.Template == "" {
|
||||
f.Template = config.Templates.Forwards
|
||||
}
|
||||
|
||||
var fetchBodyPart func([]int, func(io.Reader))
|
||||
|
||||
if isMsgViewer {
|
||||
fetchBodyPart = mv.MessageView().FetchBodyPart
|
||||
} else {
|
||||
fetchBodyPart = func(part []int, cb func(io.Reader)) {
|
||||
store.FetchBodyPart(msg.Uid, part, cb)
|
||||
}
|
||||
}
|
||||
|
||||
if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer {
|
||||
return fmt.Errorf("message is encrypted. " +
|
||||
"can only forward from the message viewer")
|
||||
}
|
||||
|
||||
part := getMessagePart(msg, widget)
|
||||
if part == nil {
|
||||
part = lib.FindFirstNonMultipart(msg.BodyStructure, nil)
|
||||
// if it's still nil here, we don't have a multipart msg, that's fine
|
||||
}
|
||||
|
||||
err = addMimeType(msg, part, &original)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fetchBodyPart(part, func(reader io.Reader) {
|
||||
buf := new(bytes.Buffer)
|
||||
scanner := bufio.NewScanner(reader)
|
||||
for scanner.Scan() {
|
||||
buf.WriteString(scanner.Text() + "\n")
|
||||
}
|
||||
original.Text = buf.String()
|
||||
|
||||
// create composer
|
||||
composer, err := addTab()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
composer.OnClose(func(c *app.Composer) {
|
||||
if c.Sent() && store != nil {
|
||||
store.Forwarded([]models.UID{msg.Uid}, true, nil)
|
||||
}
|
||||
})
|
||||
|
||||
// add attachments
|
||||
if f.AttachAll {
|
||||
var mu sync.Mutex
|
||||
parts := lib.FindAllNonMultipart(msg.BodyStructure, nil, nil)
|
||||
for _, p := range parts {
|
||||
if lib.EqualParts(p, part) {
|
||||
continue
|
||||
}
|
||||
bs, err := msg.BodyStructure.PartAtIndex(p)
|
||||
if err != nil {
|
||||
log.Errorf("cannot get PartAtIndex %v: %v", p, err)
|
||||
continue
|
||||
}
|
||||
fetchBodyPart(p, func(reader io.Reader) {
|
||||
mime := bs.FullMIMEType()
|
||||
params := lib.SetUtf8Charset(bs.Params)
|
||||
name := bs.FileName()
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64())
|
||||
}
|
||||
mu.Lock()
|
||||
err := composer.AddPartAttachment(name, mime, params, reader)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
log.Errorf(err.Error())
|
||||
app.PushError(err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/calendar"
|
||||
"git.sr.ht/~rjarry/aerc/lib/format"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type invite struct {
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(invite{})
|
||||
}
|
||||
|
||||
func (invite) Description() string {
|
||||
return "Accept or decline a meeting invitation."
|
||||
}
|
||||
|
||||
func (invite) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (invite) Aliases() []string {
|
||||
return []string{"accept", "accept-tentative", "decline"}
|
||||
}
|
||||
|
||||
func (i invite) Execute(args []string) error {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("no account selected")
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return errors.New("cannot perform action: messages still loading")
|
||||
}
|
||||
msg, err := acct.SelectedMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
part := lib.FindCalendartext(msg.BodyStructure, nil)
|
||||
if part == nil {
|
||||
return fmt.Errorf("no invitation found (missing text/calendar)")
|
||||
}
|
||||
|
||||
editHeaders := (config.Compose.EditHeaders || i.Edit) && !i.NoEdit
|
||||
|
||||
subject := trimLocalizedRe(msg.Envelope.Subject, acct.AccountConfig().LocalizedRe)
|
||||
switch args[0] {
|
||||
case "accept":
|
||||
subject = "Accepted: " + subject
|
||||
case "accept-tentative":
|
||||
subject = "Tentatively Accepted: " + subject
|
||||
case "decline":
|
||||
subject = "Declined: " + subject
|
||||
default:
|
||||
return fmt.Errorf("no participation status defined")
|
||||
}
|
||||
|
||||
from := chooseFromAddr(acct.AccountConfig(), msg)
|
||||
|
||||
var to []*mail.Address
|
||||
|
||||
if len(msg.Envelope.ReplyTo) != 0 {
|
||||
to = msg.Envelope.ReplyTo
|
||||
} else {
|
||||
to = msg.Envelope.From
|
||||
}
|
||||
|
||||
if !config.Compose.ReplyToSelf {
|
||||
for i, v := range to {
|
||||
if v.Address == from.Address {
|
||||
to = append(to[:i], to[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if len(to) == 0 {
|
||||
to = msg.Envelope.To
|
||||
}
|
||||
}
|
||||
|
||||
recSet := newAddrSet() // used for de-duping
|
||||
recSet.AddList(to)
|
||||
|
||||
h := &mail.Header{}
|
||||
h.SetAddressList("from", []*mail.Address{from})
|
||||
h.SetSubject(subject)
|
||||
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
|
||||
err = setReferencesHeader(h, msg.RFC822Headers)
|
||||
if err != nil {
|
||||
app.PushError(fmt.Sprintf("could not set references: %v", err))
|
||||
}
|
||||
original := models.OriginalMail{
|
||||
From: format.FormatAddresses(msg.Envelope.From),
|
||||
Date: msg.Envelope.Date,
|
||||
RFC822Headers: msg.RFC822Headers,
|
||||
}
|
||||
|
||||
handleInvite := func(reader io.Reader) (*calendar.Reply, error) {
|
||||
cr, err := calendar.CreateReply(reader, from, args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, org := range cr.Organizers {
|
||||
organizer, err := mail.ParseAddress(org)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !recSet.Contains(organizer) {
|
||||
to = append(to, organizer)
|
||||
}
|
||||
}
|
||||
h.SetAddressList("to", to)
|
||||
return cr, nil
|
||||
}
|
||||
|
||||
addTab := func(cr *calendar.Reply) error {
|
||||
composer, err := app.NewComposer(acct,
|
||||
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||
"", h, &original, cr.PlainText)
|
||||
if err != nil {
|
||||
app.PushError("Error: " + err.Error())
|
||||
return err
|
||||
}
|
||||
err = composer.AppendPart(cr.MimeType, cr.Params, cr.CalendarText)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write invitation: %w", err)
|
||||
}
|
||||
if i.SkipEditor {
|
||||
composer.Terminal().Close()
|
||||
} else {
|
||||
composer.FocusTerminal()
|
||||
}
|
||||
|
||||
composer.Tab = app.NewTab(composer, subject)
|
||||
|
||||
composer.OnClose(func(c *app.Composer) {
|
||||
switch {
|
||||
case c.Sent() && c.Archive() != "":
|
||||
store.Answered([]models.UID{msg.Uid}, true, nil)
|
||||
err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
|
||||
if err != nil {
|
||||
app.PushStatus("Archive failed", 10*time.Second)
|
||||
}
|
||||
case c.Sent():
|
||||
store.Answered([]models.UID{msg.Uid}, true, nil)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
store.FetchBodyPart(msg.Uid, part, func(reader io.Reader) {
|
||||
if cr, err := handleInvite(reader); err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
} else {
|
||||
err := addTab(cr)
|
||||
if err != nil {
|
||||
log.Warnf("failed to add tab: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type Mark struct {
|
||||
All bool `opt:"-a" aliases:"mark,unmark" desc:"Mark all messages in current folder."`
|
||||
Toggle bool `opt:"-t" aliases:"mark,unmark" desc:"Toggle the marked state."`
|
||||
Visual bool `opt:"-v" aliases:"mark,unmark" desc:"Enter / leave visual mark mode."`
|
||||
VisualClear bool `opt:"-V" aliases:"mark,unmark" desc:"Same as -v but does not clear existing selection."`
|
||||
Thread bool `opt:"-T" aliases:"mark,unmark" desc:"Mark all messages from the selected thread."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Mark{})
|
||||
}
|
||||
|
||||
func (Mark) Description() string {
|
||||
return "Mark, unmark or remark messages."
|
||||
}
|
||||
|
||||
func (Mark) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Mark) Aliases() []string {
|
||||
return []string{"mark", "unmark", "remark"}
|
||||
}
|
||||
|
||||
func (m Mark) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
OnSelectedMessage := func(fn func(models.UID)) error {
|
||||
if fn == nil {
|
||||
return fmt.Errorf("no operation selected")
|
||||
}
|
||||
selected, err := h.msgProvider.SelectedMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fn(selected.Uid)
|
||||
return nil
|
||||
}
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
marker := store.Marker()
|
||||
|
||||
if m.Thread && m.All {
|
||||
return fmt.Errorf("-a and -T are mutually exclusive")
|
||||
}
|
||||
|
||||
if m.Thread && (m.Visual || m.VisualClear) {
|
||||
return fmt.Errorf("-v and -T are mutually exclusive")
|
||||
}
|
||||
if m.Visual && m.All {
|
||||
return fmt.Errorf("-a and -v are mutually exclusive")
|
||||
}
|
||||
|
||||
switch args[0] {
|
||||
case "mark":
|
||||
var modFunc func(models.UID)
|
||||
if m.Toggle {
|
||||
modFunc = marker.ToggleMark
|
||||
} else {
|
||||
modFunc = marker.Mark
|
||||
}
|
||||
switch {
|
||||
case m.All:
|
||||
uids := store.Uids()
|
||||
for _, uid := range uids {
|
||||
modFunc(uid)
|
||||
}
|
||||
return nil
|
||||
case m.Visual || m.VisualClear:
|
||||
marker.ToggleVisualMark(m.VisualClear)
|
||||
return nil
|
||||
default:
|
||||
if m.Thread {
|
||||
threadPtr, err := store.SelectedThread()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, uid := range threadPtr.Root().Uids() {
|
||||
modFunc(uid)
|
||||
}
|
||||
} else {
|
||||
return OnSelectedMessage(modFunc)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
case "unmark":
|
||||
if m.Visual || m.VisualClear {
|
||||
return fmt.Errorf("visual mode not supported for this command")
|
||||
}
|
||||
|
||||
switch {
|
||||
case m.All && m.Toggle:
|
||||
uids := store.Uids()
|
||||
for _, uid := range uids {
|
||||
marker.ToggleMark(uid)
|
||||
}
|
||||
return nil
|
||||
case m.All && !m.Toggle:
|
||||
marker.ClearVisualMark()
|
||||
return nil
|
||||
default:
|
||||
if m.Thread {
|
||||
threadPtr, err := store.SelectedThread()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, uid := range threadPtr.Root().Uids() {
|
||||
marker.Unmark(uid)
|
||||
}
|
||||
} else {
|
||||
return OnSelectedMessage(marker.Unmark)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
case "remark":
|
||||
marker.Remark()
|
||||
return nil
|
||||
}
|
||||
return nil // never reached
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type ModifyLabels struct {
|
||||
Labels []string `opt:"..." metavar:"[+-]<label>" complete:"CompleteLabels" desc:"Message label."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(ModifyLabels{})
|
||||
}
|
||||
|
||||
func (ModifyLabels) Description() string {
|
||||
return "Modify message labels."
|
||||
}
|
||||
|
||||
func (ModifyLabels) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (ModifyLabels) Aliases() []string {
|
||||
return []string{"modify-labels", "tag"}
|
||||
}
|
||||
|
||||
func (*ModifyLabels) CompleteLabels(arg string) []string {
|
||||
return commands.GetLabels(arg)
|
||||
}
|
||||
|
||||
func (m ModifyLabels) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uids, err := h.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var add, remove []string
|
||||
for _, l := range m.Labels {
|
||||
switch l[0] {
|
||||
case '+':
|
||||
add = append(add, l[1:])
|
||||
case '-':
|
||||
remove = append(remove, l[1:])
|
||||
default:
|
||||
// if no operand is given assume add
|
||||
add = append(add, l)
|
||||
}
|
||||
}
|
||||
store.ModifyLabels(uids, add, remove, func(
|
||||
msg types.WorkerMessage,
|
||||
) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus("labels updated", 10*time.Second)
|
||||
store.Marker().ClearVisualMark()
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/marker"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Move struct {
|
||||
CreateFolders bool `opt:"-p" desc:"Create missing folders if required."`
|
||||
Account string `opt:"-a" complete:"CompleteAccount" desc:"Move to specified account."`
|
||||
MultiFileStrategy *types.MultiFileStrategy `opt:"-m" action:"ParseMFS" complete:"CompleteMFS" desc:"Multi-file strategy."`
|
||||
Folder string `opt:"folder" complete:"CompleteFolder" desc:"Target folder."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Move{})
|
||||
}
|
||||
|
||||
func (Move) Description() string {
|
||||
return "Move the selected message(s) to the specified folder."
|
||||
}
|
||||
|
||||
func (Move) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Move) Aliases() []string {
|
||||
return []string{"mv", "move"}
|
||||
}
|
||||
|
||||
func (m *Move) ParseMFS(arg string) error {
|
||||
if arg != "" {
|
||||
mfs, ok := types.StrToStrategy[arg]
|
||||
if !ok {
|
||||
return fmt.Errorf("invalid multi-file strategy %s", arg)
|
||||
}
|
||||
m.MultiFileStrategy = &mfs
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (*Move) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (m *Move) CompleteFolder(arg string) []string {
|
||||
var acct *app.AccountView
|
||||
if len(m.Account) > 0 {
|
||||
acct, _ = app.Account(m.Account)
|
||||
} else {
|
||||
acct = app.SelectedAccount()
|
||||
}
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(acct.Directories().List(), arg, nil)
|
||||
}
|
||||
|
||||
func (Move) CompleteMFS(arg string) []string {
|
||||
return commands.FilterList(types.StrategyStrs(), arg, nil)
|
||||
}
|
||||
|
||||
func (m Move) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
acct, err := h.account()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
uids, err := h.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
next := findNextNonDeleted(uids, store)
|
||||
marker := store.Marker()
|
||||
marker.ClearVisualMark()
|
||||
|
||||
if len(m.Account) == 0 {
|
||||
store.Move(uids, m.Folder, m.CreateFolders, m.MultiFileStrategy,
|
||||
func(msg types.WorkerMessage) {
|
||||
m.CallBack(msg, acct, uids, next, marker, false)
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
destAcct, err := app.Account(m.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
destStore := destAcct.Store()
|
||||
if destStore == nil {
|
||||
app.PushError(fmt.Sprintf("No message store in %s", m.Account))
|
||||
return nil
|
||||
}
|
||||
|
||||
var messages []*types.FullMessage
|
||||
fetchDone := make(chan bool, 1)
|
||||
store.FetchFull(uids, func(fm *types.FullMessage) {
|
||||
messages = append(messages, fm)
|
||||
if len(messages) == len(uids) {
|
||||
fetchDone <- true
|
||||
}
|
||||
})
|
||||
|
||||
// Since this operation can take some time with some backends
|
||||
// (e.g. IMAP), provide some feedback to inform the user that
|
||||
// something is happening
|
||||
app.PushStatus("Moving messages...", 10*time.Second)
|
||||
|
||||
var appended []models.UID
|
||||
var timeout bool
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
select {
|
||||
case <-fetchDone:
|
||||
break
|
||||
case <-time.After(30 * time.Second):
|
||||
// TODO: find a better way to determine if store.FetchFull()
|
||||
// has finished with some errors.
|
||||
app.PushError("Failed to fetch all messages")
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
AppendLoop:
|
||||
for _, fm := range messages {
|
||||
done := make(chan bool, 1)
|
||||
uid := fm.Content.Uid
|
||||
buf := new(bytes.Buffer)
|
||||
_, err = buf.ReadFrom(fm.Content.Reader)
|
||||
if err != nil {
|
||||
log.Errorf("could not get reader for uid %d", uid)
|
||||
break
|
||||
}
|
||||
destStore.Append(
|
||||
m.Folder,
|
||||
models.SeenFlag,
|
||||
time.Now(),
|
||||
buf,
|
||||
buf.Len(),
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
appended = append(appended, uid)
|
||||
done <- true
|
||||
case *types.Error:
|
||||
log.Errorf("AppendMessage failed: %v", msg.Error)
|
||||
done <- false
|
||||
}
|
||||
},
|
||||
)
|
||||
select {
|
||||
case ok := <-done:
|
||||
if !ok {
|
||||
break AppendLoop
|
||||
}
|
||||
case <-time.After(30 * time.Second):
|
||||
log.Warnf("timed-out: appended %d of %d", len(appended), len(messages))
|
||||
timeout = true
|
||||
break AppendLoop
|
||||
}
|
||||
}
|
||||
if len(appended) > 0 {
|
||||
mfs := types.Refuse
|
||||
store.Delete(appended, &mfs, func(msg types.WorkerMessage) {
|
||||
m.CallBack(msg, acct, appended, next, marker, timeout)
|
||||
})
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Move) CallBack(
|
||||
msg types.WorkerMessage,
|
||||
acct *app.AccountView,
|
||||
uids []models.UID,
|
||||
next *models.MessageInfo,
|
||||
marker marker.Marker,
|
||||
timeout bool,
|
||||
) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
var s string
|
||||
if len(uids) > 1 {
|
||||
s = "%d messages moved to %s"
|
||||
} else {
|
||||
s = "%d message moved to %s"
|
||||
}
|
||||
dest := m.Folder
|
||||
if len(m.Account) > 0 {
|
||||
dest = fmt.Sprintf("%s in %s", m.Folder, m.Account)
|
||||
}
|
||||
if timeout {
|
||||
s = "timed-out: only " + s
|
||||
app.PushError(fmt.Sprintf(s, len(uids), dest))
|
||||
} else {
|
||||
app.PushStatus(fmt.Sprintf(s, len(uids), dest), 10*time.Second)
|
||||
}
|
||||
if store := acct.Store(); store != nil {
|
||||
handleDone(acct, next, store)
|
||||
}
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
marker.Remark()
|
||||
case *types.Unsupported:
|
||||
marker.Remark()
|
||||
app.PushError("error, unsupported for this worker")
|
||||
}
|
||||
}
|
||||
|
||||
func handleDone(
|
||||
acct *app.AccountView,
|
||||
next *models.MessageInfo,
|
||||
store *lib.MessageStore,
|
||||
) {
|
||||
h := newHelper()
|
||||
mv, isMsgView := h.msgProvider.(*app.MessageViewer)
|
||||
switch {
|
||||
case isMsgView && !config.Ui.NextMessageOnDelete:
|
||||
app.RemoveTab(h.msgProvider, true)
|
||||
case isMsgView:
|
||||
if next == nil {
|
||||
app.RemoveTab(h.msgProvider, true)
|
||||
acct.Messages().Select(-1)
|
||||
ui.Invalidate()
|
||||
return
|
||||
}
|
||||
lib.NewMessageStoreView(next, mv.MessageView().SeenFlagSet(),
|
||||
store, app.CryptoProvider(), app.DecryptKeys,
|
||||
func(view lib.MessageView, err error) {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
nextMv, err := app.NewMessageViewer(acct, view)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
app.ReplaceTab(mv, nextMv, next.Envelope.Subject, true)
|
||||
})
|
||||
default:
|
||||
if next == nil {
|
||||
// We moved the last message, select the new last message
|
||||
// instead of the first message
|
||||
acct.Messages().Select(-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
cryptoutil "git.sr.ht/~rjarry/aerc/lib/crypto/util"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
mboxer "git.sr.ht/~rjarry/aerc/worker/mbox"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Pipe struct {
|
||||
Background bool `opt:"-b" desc:"Run the command in the background."`
|
||||
Silent bool `opt:"-s" desc:"Silently close the terminal tab after the command exits."`
|
||||
Full bool `opt:"-m" desc:"Pipe the full message."`
|
||||
Decrypt bool `opt:"-d" desc:"Decrypt the full message before piping."`
|
||||
Part bool `opt:"-p" desc:"Only pipe the selected message part."`
|
||||
Command string `opt:"..."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Pipe{})
|
||||
}
|
||||
|
||||
func (Pipe) Description() string {
|
||||
return "Pipe the selected message(s) into the given shell command."
|
||||
}
|
||||
|
||||
func (Pipe) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Pipe) Aliases() []string {
|
||||
return []string{"pipe"}
|
||||
}
|
||||
|
||||
func (p Pipe) Execute(args []string) error {
|
||||
return p.Run(nil)
|
||||
}
|
||||
|
||||
func (p Pipe) Run(cb func()) error {
|
||||
if p.Decrypt {
|
||||
// Decrypt implies fetching the full message
|
||||
p.Full = true
|
||||
}
|
||||
if p.Full && p.Part {
|
||||
return errors.New("-m and -p are mutually exclusive")
|
||||
}
|
||||
name, _, _ := strings.Cut(p.Command, " ")
|
||||
|
||||
provider := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
if !p.Full && !p.Part {
|
||||
if _, ok := provider.(*app.MessageViewer); ok {
|
||||
p.Part = true
|
||||
} else if _, ok := provider.(*app.AccountView); ok {
|
||||
p.Full = true
|
||||
} else {
|
||||
return errors.New(
|
||||
"Neither -m nor -p specified and cannot infer default")
|
||||
}
|
||||
}
|
||||
|
||||
doTerm := func(reader io.Reader, name string) {
|
||||
cmd := []string{"sh", "-c", p.Command}
|
||||
term, err := commands.QuickTerm(cmd, reader, p.Silent)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
if cb != nil {
|
||||
last := term.OnClose
|
||||
term.OnClose = func(err error) {
|
||||
if last != nil {
|
||||
last(err)
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
app.NewTab(term, name)
|
||||
}
|
||||
|
||||
doExec := func(reader io.Reader) {
|
||||
ecmd := exec.Command("sh", "-c", p.Command)
|
||||
pipe, err := ecmd.StdinPipe()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
defer pipe.Close()
|
||||
_, err := io.Copy(pipe, reader)
|
||||
if err != nil {
|
||||
log.Errorf("failed to send data to pipe: %v", err)
|
||||
}
|
||||
}()
|
||||
err = ecmd.Run()
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
} else {
|
||||
if ecmd.ProcessState.ExitCode() != 0 {
|
||||
app.PushError(fmt.Sprintf(
|
||||
"%s: completed with status %d", name,
|
||||
ecmd.ProcessState.ExitCode()))
|
||||
} else {
|
||||
app.PushStatus(fmt.Sprintf(
|
||||
"%s: completed with status %d", name,
|
||||
ecmd.ProcessState.ExitCode()), 10*time.Second)
|
||||
}
|
||||
}
|
||||
if cb != nil {
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
app.PushStatus("Fetching messages ...", 10*time.Second)
|
||||
|
||||
if p.Full {
|
||||
var uids []models.UID
|
||||
var title string
|
||||
|
||||
h := newHelper()
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
if mv, ok := provider.(*app.MessageViewer); ok {
|
||||
mv.MessageView().FetchFull(func(reader io.Reader) {
|
||||
if p.Background {
|
||||
doExec(reader)
|
||||
} else {
|
||||
doTerm(reader,
|
||||
fmt.Sprintf("%s <%s",
|
||||
name, title))
|
||||
}
|
||||
})
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
uids, err = h.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(uids) == 1 {
|
||||
info := store.Messages[uids[0]]
|
||||
if info != nil {
|
||||
envelope := info.Envelope
|
||||
if envelope != nil {
|
||||
title = envelope.Subject
|
||||
}
|
||||
}
|
||||
}
|
||||
if title == "" {
|
||||
title = fmt.Sprintf("%d messages", len(uids))
|
||||
}
|
||||
|
||||
var messages []*types.FullMessage
|
||||
var errors []error
|
||||
done := make(chan bool, 1)
|
||||
|
||||
store.FetchFull(uids, func(fm *types.FullMessage) {
|
||||
if p.Decrypt {
|
||||
info := store.Messages[fm.Content.Uid]
|
||||
if info == nil {
|
||||
goto addMessage
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
cleartext, err := cryptoutil.Cleartext(
|
||||
io.TeeReader(fm.Content.Reader, &buf),
|
||||
info.RFC822Headers.Copy(),
|
||||
)
|
||||
if err != nil {
|
||||
log.Warnf("continue encrypted: %v", err)
|
||||
fm.Content.Reader = bytes.NewReader(buf.Bytes())
|
||||
} else {
|
||||
fm.Content.Reader = bytes.NewReader(cleartext)
|
||||
}
|
||||
}
|
||||
addMessage:
|
||||
info := store.Messages[fm.Content.Uid]
|
||||
switch {
|
||||
case info != nil && info.Envelope != nil:
|
||||
messages = append(messages, fm)
|
||||
case info != nil && info.Error != nil:
|
||||
app.PushError(info.Error.Error())
|
||||
errors = append(errors, info.Error)
|
||||
default:
|
||||
err := fmt.Errorf("%v nil info", fm.Content.Uid)
|
||||
app.PushError(err.Error())
|
||||
errors = append(errors, err)
|
||||
}
|
||||
if len(messages)+len(errors) == len(uids) {
|
||||
done <- true
|
||||
}
|
||||
})
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
break
|
||||
case <-time.After(30 * time.Second):
|
||||
// TODO: find a better way to determine if store.FetchFull()
|
||||
// has finished with some errors.
|
||||
app.PushError("Failed to fetch all messages")
|
||||
if len(messages) == 0 {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
is_git_patches := false
|
||||
for _, msg := range messages {
|
||||
info := store.Messages[msg.Content.Uid]
|
||||
if info == nil || info.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
if patchSeriesRe.MatchString(info.Envelope.Subject) {
|
||||
is_git_patches = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if is_git_patches {
|
||||
// Sort all messages by increasing Message-Id header.
|
||||
// This will ensure that patch series are applied in order.
|
||||
sort.Slice(messages, func(i, j int) bool {
|
||||
infoi := store.Messages[messages[i].Content.Uid]
|
||||
infoj := store.Messages[messages[j].Content.Uid]
|
||||
if infoi == nil || infoi.Envelope == nil ||
|
||||
infoj == nil || infoj.Envelope == nil {
|
||||
return false
|
||||
}
|
||||
return infoi.Envelope.Subject < infoj.Envelope.Subject
|
||||
})
|
||||
}
|
||||
|
||||
reader := newMessagesReader(messages, len(messages) > 1)
|
||||
if p.Background {
|
||||
doExec(reader)
|
||||
} else {
|
||||
doTerm(reader, fmt.Sprintf("%s <%s", name, title))
|
||||
}
|
||||
}()
|
||||
} else if p.Part {
|
||||
mv, ok := provider.(*app.MessageViewer)
|
||||
if !ok {
|
||||
return fmt.Errorf("can only pipe message part from a message view")
|
||||
}
|
||||
part := provider.SelectedMessagePart()
|
||||
if part == nil {
|
||||
return fmt.Errorf("could not fetch message part")
|
||||
}
|
||||
mv.MessageView().FetchBodyPart(part.Index, func(reader io.Reader) {
|
||||
if p.Background {
|
||||
doExec(reader)
|
||||
} else {
|
||||
name := fmt.Sprintf("%s <%s/[%d]",
|
||||
name, part.Msg.Envelope.Subject, part.Index)
|
||||
doTerm(reader, name)
|
||||
}
|
||||
})
|
||||
}
|
||||
if store := provider.Store(); store != nil {
|
||||
store.Marker().ClearVisualMark()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newMessagesReader(messages []*types.FullMessage, useMbox bool) io.Reader {
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
defer pw.Close()
|
||||
for _, msg := range messages {
|
||||
var err error
|
||||
if useMbox {
|
||||
err = mboxer.Write(pw, msg.Content.Reader, "", time.Now())
|
||||
} else {
|
||||
_, err = io.Copy(pw, msg.Content.Reader)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("failed to write data: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
return pr
|
||||
}
|
||||
|
||||
var patchSeriesRe = regexp.MustCompile(
|
||||
`^.*\[(RFC )?PATCH( [^\]]+)? \d+/\d+] .+$`,
|
||||
)
|
||||
@@ -0,0 +1,162 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type FlagMsg struct {
|
||||
Toggle bool `opt:"-t" desc:"Toggle between set and unset."`
|
||||
Answered bool `opt:"-a" aliases:"flag,unflag" desc:"Set/unset the answered flag."`
|
||||
Forwarded bool `opt:"-f" aliases:"flag,unflag" desc:"Set/unset the forwarded flag."`
|
||||
Flag models.Flags `opt:"-x" aliases:"flag,unflag" action:"ParseFlag" complete:"CompleteFlag" desc:"Flag name."`
|
||||
FlagName string
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(FlagMsg{})
|
||||
}
|
||||
|
||||
func (FlagMsg) Description() string {
|
||||
return "Set or unset a flag on the marked or selected messages."
|
||||
}
|
||||
|
||||
func (FlagMsg) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (FlagMsg) Aliases() []string {
|
||||
return []string{"flag", "unflag", "read", "unread"}
|
||||
}
|
||||
|
||||
func (f *FlagMsg) ParseFlag(arg string) error {
|
||||
switch strings.ToLower(arg) {
|
||||
case "seen":
|
||||
f.Flag = models.SeenFlag
|
||||
f.FlagName = "seen"
|
||||
case "answered":
|
||||
f.Flag = models.AnsweredFlag
|
||||
f.FlagName = "answered"
|
||||
case "forwarded":
|
||||
f.Flag = models.ForwardedFlag
|
||||
f.FlagName = "forwarded"
|
||||
case "flagged":
|
||||
f.Flag = models.FlaggedFlag
|
||||
f.FlagName = "flagged"
|
||||
case "draft":
|
||||
f.Flag = models.DraftFlag
|
||||
f.FlagName = "draft"
|
||||
default:
|
||||
return fmt.Errorf("Unknown flag %q", arg)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var validFlags = []string{"seen", "answered", "forwarded", "flagged", "draft"}
|
||||
|
||||
func (*FlagMsg) CompleteFlag(arg string) []string {
|
||||
return commands.FilterList(validFlags, arg, nil)
|
||||
}
|
||||
|
||||
// If this was called as 'flag' or 'unflag', without the toggle (-t)
|
||||
// option, then it will flag the corresponding messages with the given
|
||||
// flag. If the toggle option was given, it will individually toggle
|
||||
// the given flag for the corresponding messages.
|
||||
//
|
||||
// If this was called as 'read' or 'unread', it has the same effect as
|
||||
// 'flag' or 'unflag', respectively, but the 'Seen' flag is affected.
|
||||
func (f FlagMsg) Execute(args []string) error {
|
||||
// User-readable name for the action being performed
|
||||
var actionName string
|
||||
|
||||
switch args[0] {
|
||||
case "read", "unread":
|
||||
f.Flag = models.SeenFlag
|
||||
f.FlagName = "seen"
|
||||
case "flag", "unflag":
|
||||
if f.Answered {
|
||||
f.Flag = models.AnsweredFlag
|
||||
f.FlagName = "answered"
|
||||
}
|
||||
if f.Forwarded {
|
||||
f.Flag = models.ForwardedFlag
|
||||
f.FlagName = "forwarded"
|
||||
}
|
||||
if f.Flag == 0 {
|
||||
f.Flag = models.FlaggedFlag
|
||||
f.FlagName = "flagged"
|
||||
}
|
||||
}
|
||||
|
||||
h := newHelper()
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// UIDs of messages to enable or disable the flag for.
|
||||
var toEnable []models.UID
|
||||
var toDisable []models.UID
|
||||
|
||||
if f.Toggle {
|
||||
// If toggling, split messages into those that need to
|
||||
// be enabled / disabled.
|
||||
msgs, err := h.messages()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, m := range msgs {
|
||||
if m.Flags.Has(f.Flag) {
|
||||
toDisable = append(toDisable, m.Uid)
|
||||
} else {
|
||||
toEnable = append(toEnable, m.Uid)
|
||||
}
|
||||
}
|
||||
actionName = "Toggling"
|
||||
} else {
|
||||
msgUids, err := h.markedOrSelectedUids()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch args[0] {
|
||||
case "read", "flag":
|
||||
toEnable = msgUids
|
||||
actionName = "Setting"
|
||||
default:
|
||||
toDisable = msgUids
|
||||
actionName = "Unsetting"
|
||||
}
|
||||
}
|
||||
|
||||
status := fmt.Sprintf("%s flag %q successful", actionName, f.FlagName)
|
||||
|
||||
if len(toEnable) != 0 {
|
||||
store.Flag(toEnable, f.Flag, true, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus(status, 10*time.Second)
|
||||
store.Marker().ClearVisualMark()
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
if len(toDisable) != 0 {
|
||||
store.Flag(toDisable, f.Flag, false, func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus(status, 10*time.Second)
|
||||
store.Marker().ClearVisualMark()
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "github.com/emersion/go-message/charset"
|
||||
"github.com/pkg/errors"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"git.sr.ht/~rjarry/aerc/worker/types"
|
||||
)
|
||||
|
||||
type Recall struct {
|
||||
Force bool `opt:"-f" desc:"Force recall if not in postpone directory."`
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Recall{})
|
||||
}
|
||||
|
||||
func (Recall) Description() string {
|
||||
return "Open a postponed message for re-editing."
|
||||
}
|
||||
|
||||
func (Recall) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Recall) Aliases() []string {
|
||||
return []string{"recall"}
|
||||
}
|
||||
|
||||
func (r Recall) Execute(args []string) error {
|
||||
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
|
||||
|
||||
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
acct := widget.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
store := widget.Store()
|
||||
if store == nil {
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
|
||||
msgInfo, err := widget.SelectedMessage()
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Recall failed")
|
||||
}
|
||||
|
||||
if acct.SelectedDirectory() != acct.AccountConfig().Postpone &&
|
||||
!msgInfo.Flags.Has(models.DraftFlag) && !r.Force {
|
||||
return errors.New("Use -f to recall non-draft messages from outside the " +
|
||||
acct.AccountConfig().Postpone + " directory.")
|
||||
}
|
||||
|
||||
log.Debugf("Recalling message <%s>", msgInfo.Envelope.MessageId)
|
||||
|
||||
addTab := func(composer *app.Composer) {
|
||||
subject := msgInfo.Envelope.Subject
|
||||
if subject == "" {
|
||||
subject = "Recalled email"
|
||||
}
|
||||
composer.Tab = app.NewTab(composer, subject)
|
||||
composer.OnClose(func(composer *app.Composer) {
|
||||
uids := []models.UID{msgInfo.Uid}
|
||||
|
||||
deleteMessage := func() {
|
||||
store.Delete(
|
||||
uids,
|
||||
nil,
|
||||
func(msg types.WorkerMessage) {
|
||||
switch msg := msg.(type) {
|
||||
case *types.Done:
|
||||
app.PushStatus("Recalled message deleted", 10*time.Second)
|
||||
case *types.Error:
|
||||
app.PushError(msg.Error.Error())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if composer.Sent() || composer.Postponed() {
|
||||
deleteMessage()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
lib.NewMessageStoreView(msgInfo, acct.UiConfig().AutoMarkRead,
|
||||
store, app.CryptoProvider(), app.DecryptKeys,
|
||||
func(msg lib.MessageView, err error) {
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
var path []int
|
||||
if len(msg.BodyStructure().Parts) != 0 {
|
||||
path = lib.FindPlaintext(msg.BodyStructure(), path)
|
||||
}
|
||||
|
||||
msg.FetchBodyPart(path, func(reader io.Reader) {
|
||||
composer, err := app.NewComposer(acct,
|
||||
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||
"", msgInfo.RFC822Headers, nil, reader)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
if md := msg.MessageDetails(); md != nil {
|
||||
if md.IsEncrypted {
|
||||
composer.SetEncrypt(md.IsEncrypted)
|
||||
}
|
||||
if md.IsSigned {
|
||||
err = composer.SetSign(md.IsSigned)
|
||||
if err != nil {
|
||||
log.Warnf("failed to set signed state: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add attachments if present
|
||||
var mu sync.Mutex
|
||||
parts := lib.FindAllNonMultipart(msg.BodyStructure(), nil, nil)
|
||||
for _, p := range parts {
|
||||
if lib.EqualParts(p, path) {
|
||||
continue
|
||||
}
|
||||
bs, err := msg.BodyStructure().PartAtIndex(p)
|
||||
if err != nil {
|
||||
log.Warnf("cannot get PartAtIndex %v: %v", p, err)
|
||||
continue
|
||||
}
|
||||
msg.FetchBodyPart(p, func(reader io.Reader) {
|
||||
mime := bs.FullMIMEType()
|
||||
params := lib.SetUtf8Charset(bs.Params)
|
||||
name, ok := params["name"]
|
||||
if !ok {
|
||||
name = fmt.Sprintf("%s_%s_%d", bs.MIMEType, bs.MIMESubType, rand.Uint64())
|
||||
}
|
||||
mu.Lock()
|
||||
err := composer.AddPartAttachment(name, mime, params, reader)
|
||||
mu.Unlock()
|
||||
if err != nil {
|
||||
log.Errorf(err.Error())
|
||||
app.PushError(err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if r.Force {
|
||||
composer.SetRecalledFrom(acct.SelectedDirectory())
|
||||
}
|
||||
|
||||
if r.SkipEditor {
|
||||
composer.Terminal().Close()
|
||||
} else {
|
||||
// focus the terminal since the header fields are likely already done
|
||||
composer.FocusTerminal()
|
||||
}
|
||||
addTab(composer)
|
||||
})
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/commands/account"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/crypto"
|
||||
"git.sr.ht/~rjarry/aerc/lib/format"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/parse"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
"github.com/danwakefield/fnmatch"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
type reply struct {
|
||||
All bool `opt:"-a" desc:"Reply to all recipients."`
|
||||
Close bool `opt:"-c" desc:"Close the view tab when replying."`
|
||||
From bool `opt:"-f" desc:"Reply to all addresses in From and Reply-To headers."`
|
||||
Quote bool `opt:"-q" desc:"Alias of -T quoted-reply."`
|
||||
Template string `opt:"-T" complete:"CompleteTemplate" desc:"Template name."`
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
Account string `opt:"-A" complete:"CompleteAccount" desc:"Reply with the specified account."`
|
||||
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(reply{})
|
||||
}
|
||||
|
||||
func (reply) Description() string {
|
||||
return "Open the composer to reply to the selected message."
|
||||
}
|
||||
|
||||
func (reply) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (reply) Aliases() []string {
|
||||
return []string{"reply"}
|
||||
}
|
||||
|
||||
func (*reply) CompleteTemplate(arg string) []string {
|
||||
return commands.GetTemplates(arg)
|
||||
}
|
||||
|
||||
func (*reply) CompleteAccount(arg string) []string {
|
||||
return commands.FilterList(app.AccountNames(), arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (r reply) Execute(args []string) error {
|
||||
editHeaders := (config.Compose.EditHeaders || r.Edit) && !r.NoEdit
|
||||
|
||||
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
|
||||
var acct *app.AccountView
|
||||
var err error
|
||||
|
||||
if r.Account == "" {
|
||||
acct = widget.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
} else {
|
||||
acct, err = app.Account(r.Account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
conf := acct.AccountConfig()
|
||||
|
||||
msg, err := widget.SelectedMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
from := chooseFromAddr(conf, msg)
|
||||
|
||||
var (
|
||||
to []*mail.Address
|
||||
cc []*mail.Address
|
||||
)
|
||||
|
||||
recSet := newAddrSet() // used for de-duping
|
||||
dedupe := func(addrs []*mail.Address) []*mail.Address {
|
||||
deduped := make([]*mail.Address, 0, len(addrs))
|
||||
for _, addr := range addrs {
|
||||
if recSet.Contains(addr) {
|
||||
continue
|
||||
}
|
||||
recSet.Add(addr)
|
||||
deduped = append(deduped, addr)
|
||||
}
|
||||
return deduped
|
||||
}
|
||||
|
||||
if !config.Compose.ReplyToSelf {
|
||||
recSet.Add(from)
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(msg.Envelope.ReplyTo) != 0:
|
||||
to = dedupe(msg.Envelope.ReplyTo)
|
||||
case len(msg.Envelope.From) != 0:
|
||||
to = dedupe(msg.Envelope.From)
|
||||
default:
|
||||
to = dedupe(msg.Envelope.Sender)
|
||||
}
|
||||
|
||||
if r.From {
|
||||
to = append(to, dedupe(msg.Envelope.From)...)
|
||||
}
|
||||
|
||||
if !config.Compose.ReplyToSelf && len(to) == 0 {
|
||||
recSet = newAddrSet()
|
||||
to = dedupe(msg.Envelope.To)
|
||||
}
|
||||
|
||||
if r.All {
|
||||
// order matters, due to the deduping
|
||||
// in order of importance, first parse the To, then the Cc header
|
||||
|
||||
// we add our from address, so that we don't self address ourselves
|
||||
recSet.Add(from)
|
||||
|
||||
to = append(to, dedupe(msg.Envelope.To)...)
|
||||
|
||||
cc = append(cc, dedupe(msg.Envelope.Cc)...)
|
||||
cc = append(cc, dedupe(msg.Envelope.Sender)...)
|
||||
}
|
||||
|
||||
subject := "Re: " + trimLocalizedRe(msg.Envelope.Subject, conf.LocalizedRe)
|
||||
|
||||
h := &mail.Header{}
|
||||
h.SetAddressList("to", to)
|
||||
h.SetAddressList("cc", cc)
|
||||
h.SetAddressList("from", []*mail.Address{from})
|
||||
h.SetSubject(subject)
|
||||
h.SetMsgIDList("in-reply-to", []string{msg.Envelope.MessageId})
|
||||
err = setReferencesHeader(h, msg.RFC822Headers)
|
||||
if err != nil {
|
||||
app.PushError(fmt.Sprintf("could not set references: %v", err))
|
||||
}
|
||||
original := models.OriginalMail{
|
||||
From: format.FormatAddresses(msg.Envelope.From),
|
||||
Date: msg.Envelope.Date,
|
||||
RFC822Headers: msg.RFC822Headers,
|
||||
}
|
||||
|
||||
mv, isMsgViewer := app.SelectedTabContent().(*app.MessageViewer)
|
||||
|
||||
store := widget.Store()
|
||||
noStore := store == nil
|
||||
switch {
|
||||
case noStore && isMsgViewer:
|
||||
app.PushWarning("No message store found: answered flag cannot be set")
|
||||
case noStore:
|
||||
return errors.New("Cannot perform action. Messages still loading")
|
||||
default:
|
||||
original.Folder = store.Name
|
||||
}
|
||||
|
||||
addTab := func() error {
|
||||
composer, err := app.NewComposer(acct,
|
||||
acct.AccountConfig(), acct.Worker(), editHeaders,
|
||||
r.Template, h, &original, nil)
|
||||
if err != nil {
|
||||
app.PushError("Error: " + err.Error())
|
||||
return err
|
||||
}
|
||||
if mv != nil && r.Close {
|
||||
app.RemoveTab(mv, true)
|
||||
}
|
||||
|
||||
if r.SkipEditor {
|
||||
composer.Terminal().Close()
|
||||
} else if args[0] == "reply" {
|
||||
composer.FocusTerminal()
|
||||
}
|
||||
|
||||
composer.Tab = app.NewTab(composer, subject)
|
||||
|
||||
composer.OnClose(func(c *app.Composer) {
|
||||
switch {
|
||||
case c.Sent() && c.Archive() != "" && !noStore:
|
||||
store.Answered([]models.UID{msg.Uid}, true, nil)
|
||||
err := archive([]*models.MessageInfo{msg}, nil, c.Archive())
|
||||
if err != nil {
|
||||
app.PushStatus("Archive failed", 10*time.Second)
|
||||
}
|
||||
case c.Sent() && !noStore:
|
||||
store.Answered([]models.UID{msg.Uid}, true, nil)
|
||||
case mv != nil && r.Close:
|
||||
view := account.ViewMessage{Peek: true}
|
||||
//nolint:errcheck // who cares?
|
||||
view.Execute([]string{"view", "-p"})
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.Quote && r.Template == "" {
|
||||
r.Template = config.Templates.QuotedReply
|
||||
}
|
||||
|
||||
if r.Template != "" {
|
||||
var fetchBodyPart func([]int, func(io.Reader))
|
||||
|
||||
if isMsgViewer {
|
||||
fetchBodyPart = mv.MessageView().FetchBodyPart
|
||||
} else {
|
||||
fetchBodyPart = func(part []int, cb func(io.Reader)) {
|
||||
store.FetchBodyPart(msg.Uid, part, cb)
|
||||
}
|
||||
}
|
||||
|
||||
if crypto.IsEncrypted(msg.BodyStructure) && !isMsgViewer {
|
||||
return fmt.Errorf("message is encrypted. " +
|
||||
"can only include reply from the message viewer")
|
||||
}
|
||||
|
||||
part := getMessagePart(msg, widget)
|
||||
if part == nil {
|
||||
// mkey... let's get the first thing that isn't a container
|
||||
// if that's still nil it's either not a multipart msg (ok) or
|
||||
// broken (containers only)
|
||||
part = lib.FindFirstNonMultipart(msg.BodyStructure, nil)
|
||||
}
|
||||
|
||||
err = addMimeType(msg, part, &original)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fetchBodyPart(part, func(reader io.Reader) {
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
log.Warnf("failed to read bodypart: %v", err)
|
||||
}
|
||||
original.Text = string(data)
|
||||
err = addTab()
|
||||
if err != nil {
|
||||
log.Warnf("failed to add tab: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return nil
|
||||
} else {
|
||||
r.Template = config.Templates.NewMessage
|
||||
return addTab()
|
||||
}
|
||||
}
|
||||
|
||||
func chooseFromAddr(conf *config.AccountConfig, msg *models.MessageInfo) *mail.Address {
|
||||
if len(conf.Aliases) == 0 {
|
||||
return conf.From
|
||||
}
|
||||
|
||||
rec := newAddrSet()
|
||||
rec.AddList(msg.Envelope.From)
|
||||
rec.AddList(msg.Envelope.To)
|
||||
rec.AddList(msg.Envelope.Cc)
|
||||
// test the from first, it has priority over any present alias
|
||||
if rec.Contains(conf.From) {
|
||||
// do nothing
|
||||
} else {
|
||||
for _, a := range conf.Aliases {
|
||||
if match := rec.FindMatch(a); match != "" {
|
||||
return &mail.Address{Name: a.Name, Address: match}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conf.From
|
||||
}
|
||||
|
||||
type addrSet map[string]struct{}
|
||||
|
||||
func newAddrSet() addrSet {
|
||||
s := make(map[string]struct{})
|
||||
return addrSet(s)
|
||||
}
|
||||
|
||||
func (s addrSet) Add(a *mail.Address) {
|
||||
s[a.Address] = struct{}{}
|
||||
}
|
||||
|
||||
func (s addrSet) AddList(al []*mail.Address) {
|
||||
for _, a := range al {
|
||||
s[a.Address] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (s addrSet) Contains(a *mail.Address) bool {
|
||||
_, ok := s[a.Address]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s addrSet) FindMatch(a *mail.Address) string {
|
||||
for addr := range s {
|
||||
if fnmatch.Match(a.Address, addr, 0) {
|
||||
return addr
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// setReferencesHeader adds the references header to target based on parent
|
||||
// according to RFC2822
|
||||
func setReferencesHeader(target, parent *mail.Header) error {
|
||||
refs := parse.MsgIDList(parent, "references")
|
||||
if len(refs) == 0 {
|
||||
// according to the RFC we need to fall back to in-reply-to only if
|
||||
// References is not set
|
||||
refs = parse.MsgIDList(parent, "in-reply-to")
|
||||
}
|
||||
msgID, err := parent.MessageID()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
refs = append(refs, msgID)
|
||||
target.SetMsgIDList("references", refs)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addMimeType adds the proper mime type of the part to the originalMail struct
|
||||
func addMimeType(msg *models.MessageInfo, part []int,
|
||||
orig *models.OriginalMail,
|
||||
) error {
|
||||
// caution, :forward uses the code as well, keep that in mind when modifying
|
||||
bs, err := msg.BodyStructure.PartAtIndex(part)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
orig.MIMEType = bs.FullMIMEType()
|
||||
return nil
|
||||
}
|
||||
|
||||
// trimLocalizedRe removes known localizations of Re: commonly used by Outlook.
|
||||
func trimLocalizedRe(subject string, localizedRe *regexp.Regexp) string {
|
||||
return strings.TrimPrefix(subject, localizedRe.FindString(subject))
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type ToggleThreadContext struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(ToggleThreadContext{})
|
||||
}
|
||||
|
||||
func (ToggleThreadContext) Description() string {
|
||||
return "Show/hide message thread context."
|
||||
}
|
||||
|
||||
func (ToggleThreadContext) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (ToggleThreadContext) Aliases() []string {
|
||||
return []string{"toggle-thread-context"}
|
||||
}
|
||||
|
||||
func (ToggleThreadContext) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.ToggleThreadContext()
|
||||
ui.Invalidate()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type ToggleThreads struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(ToggleThreads{})
|
||||
}
|
||||
|
||||
func (ToggleThreads) Description() string {
|
||||
return "Toggle between message threading and the normal message list."
|
||||
}
|
||||
|
||||
func (ToggleThreads) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (ToggleThreads) Aliases() []string {
|
||||
return []string{"toggle-threads"}
|
||||
}
|
||||
|
||||
func (ToggleThreads) Execute(args []string) error {
|
||||
h := newHelper()
|
||||
acct, err := h.account()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
store.SetThreadedView(!store.ThreadedView())
|
||||
acct.SetStatus(state.Threading(store.ThreadedView()))
|
||||
ui.Invalidate()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"github.com/emersion/go-message/mail"
|
||||
)
|
||||
|
||||
// Unsubscribe helps people unsubscribe from mailing lists by way of the
|
||||
// List-Unsubscribe header.
|
||||
type Unsubscribe struct {
|
||||
Edit bool `opt:"-e" desc:"Force [compose].edit-headers = true."`
|
||||
NoEdit bool `opt:"-E" desc:"Force [compose].edit-headers = false."`
|
||||
SkipEditor bool `opt:"-s" desc:"Skip the editor and go directly to the review screen."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Unsubscribe{})
|
||||
}
|
||||
|
||||
func (Unsubscribe) Description() string {
|
||||
return "Attempt to automatically unsubscribe from mailing lists."
|
||||
}
|
||||
|
||||
func (Unsubscribe) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
// Aliases returns a list of aliases for the :unsubscribe command
|
||||
func (Unsubscribe) Aliases() []string {
|
||||
return []string{"unsubscribe"}
|
||||
}
|
||||
|
||||
// Execute runs the Unsubscribe command
|
||||
func (u Unsubscribe) Execute(args []string) error {
|
||||
editHeaders := (config.Compose.EditHeaders || u.Edit) && !u.NoEdit
|
||||
|
||||
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
msg, err := widget.SelectedMessage()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
headers := msg.RFC822Headers
|
||||
if !headers.Has("list-unsubscribe") {
|
||||
return errors.New("No List-Unsubscribe header found")
|
||||
}
|
||||
text, err := headers.Text("list-unsubscribe")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
methods := parseUnsubscribeMethods(text)
|
||||
if len(methods) == 0 {
|
||||
return fmt.Errorf("no methods found to unsubscribe")
|
||||
}
|
||||
log.Debugf("unsubscribe: found %d methods", len(methods))
|
||||
|
||||
unsubscribe := func(method *url.URL) {
|
||||
log.Debugf("unsubscribe: trying to unsubscribe using %s", method.Scheme)
|
||||
var err error
|
||||
switch strings.ToLower(method.Scheme) {
|
||||
case "mailto":
|
||||
err = unsubscribeMailto(method, editHeaders, u.SkipEditor)
|
||||
case "http", "https":
|
||||
err = unsubscribeHTTP(method)
|
||||
default:
|
||||
err = fmt.Errorf("unsubscribe: skipping unrecognized scheme: %s", method.Scheme)
|
||||
}
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
var title string = "Select method to unsubscribe"
|
||||
if msg != nil && msg.Envelope != nil && len(msg.Envelope.From) > 0 {
|
||||
title = fmt.Sprintf("%s from %s", title, msg.Envelope.From[0])
|
||||
}
|
||||
|
||||
options := make([]string, len(methods))
|
||||
for i, method := range methods {
|
||||
options[i] = method.Scheme
|
||||
}
|
||||
|
||||
dialog := app.NewSelectorDialog(
|
||||
title,
|
||||
"Press <Enter> to confirm or <ESC> to cancel",
|
||||
options, 0, app.SelectedAccountUiConfig(),
|
||||
func(option string, err error) {
|
||||
app.CloseDialog()
|
||||
if err != nil {
|
||||
if errors.Is(err, app.ErrNoOptionSelected) {
|
||||
app.PushStatus("Unsubscribe: "+err.Error(),
|
||||
5*time.Second)
|
||||
} else {
|
||||
app.PushError("Unsubscribe: " + err.Error())
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, m := range methods {
|
||||
if m.Scheme == option {
|
||||
unsubscribe(m)
|
||||
return
|
||||
}
|
||||
}
|
||||
app.PushError("Unsubscribe: selected method not found")
|
||||
},
|
||||
)
|
||||
app.AddDialog(dialog)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseUnsubscribeMethods reads the list-unsubscribe header and parses it as a
|
||||
// list of angle-bracket <> deliminated URLs. See RFC 2369.
|
||||
func parseUnsubscribeMethods(header string) (methods []*url.URL) {
|
||||
r := bufio.NewReader(strings.NewReader(header))
|
||||
for {
|
||||
// discard until <
|
||||
_, err := r.ReadSlice('<')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
// read until <
|
||||
m, err := r.ReadSlice('>')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
m = m[:len(m)-1]
|
||||
if u, err := url.Parse(string(m)); err == nil {
|
||||
methods = append(methods, u)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func unsubscribeMailto(u *url.URL, editHeaders, skipEditor bool) error {
|
||||
widget := app.SelectedTabContent().(app.ProvidesMessage)
|
||||
acct := widget.SelectedAccount()
|
||||
if acct == nil {
|
||||
return errors.New("No account selected")
|
||||
}
|
||||
|
||||
h := &mail.Header{}
|
||||
h.SetSubject(u.Query().Get("subject"))
|
||||
if to, err := mail.ParseAddressList(u.Opaque); err == nil {
|
||||
h.SetAddressList("to", to)
|
||||
}
|
||||
|
||||
composer, err := app.NewComposer(
|
||||
acct,
|
||||
acct.AccountConfig(),
|
||||
acct.Worker(),
|
||||
editHeaders,
|
||||
"",
|
||||
h,
|
||||
nil,
|
||||
strings.NewReader(u.Query().Get("body")),
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
composer.Tab = app.NewTab(composer, "unsubscribe")
|
||||
if skipEditor {
|
||||
composer.Terminal().Close()
|
||||
} else {
|
||||
composer.FocusTerminal()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func unsubscribeHTTP(u *url.URL) error {
|
||||
confirm := app.NewSelectorDialog(
|
||||
"Do you want to open this link?",
|
||||
u.String(),
|
||||
[]string{"No", "Yes"}, 0, app.SelectedAccountUiConfig(),
|
||||
func(option string, _ error) {
|
||||
app.CloseDialog()
|
||||
switch option {
|
||||
case "Yes":
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
mime := fmt.Sprintf("x-scheme-handler/%s", u.Scheme)
|
||||
if err := lib.XDGOpenMime(u.String(), mime, ""); err != nil {
|
||||
app.PushError("Unsubscribe:" + err.Error())
|
||||
}
|
||||
}()
|
||||
default:
|
||||
app.PushError("Unsubscribe: link will not be opened")
|
||||
}
|
||||
},
|
||||
)
|
||||
app.AddDialog(confirm)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseUnsubscribe(t *testing.T) {
|
||||
type tc struct {
|
||||
hdr string
|
||||
expected []string
|
||||
}
|
||||
cases := []*tc{
|
||||
{"", []string{}},
|
||||
{"invalid", []string{}},
|
||||
{"<https://example.com>, <http://example.com>", []string{
|
||||
"https://example.com", "http://example.com",
|
||||
}},
|
||||
{"<https://example.com> is a URL", []string{
|
||||
"https://example.com",
|
||||
}},
|
||||
{
|
||||
"<mailto:user@host?subject=unsubscribe>, <https://example.com>",
|
||||
[]string{
|
||||
"mailto:user@host?subject=unsubscribe", "https://example.com",
|
||||
},
|
||||
},
|
||||
{"<>, <https://example> ", []string{
|
||||
"", "https://example",
|
||||
}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
result := parseUnsubscribeMethods(c.hdr)
|
||||
if len(result) != len(c.expected) {
|
||||
t.Errorf("expected %d methods but got %d", len(c.expected), len(result))
|
||||
continue
|
||||
}
|
||||
for idx := 0; idx < len(result); idx++ {
|
||||
if result[idx].String() != c.expected[idx] {
|
||||
t.Errorf("expected %v but got %v", c.expected[idx], result[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package msg
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type helper struct {
|
||||
msgProvider app.ProvidesMessages
|
||||
statusInfo func(string)
|
||||
}
|
||||
|
||||
func newHelper() *helper {
|
||||
msgProvider, ok := app.SelectedTabContent().(app.ProvidesMessages)
|
||||
if !ok {
|
||||
msgProvider = app.SelectedAccount()
|
||||
}
|
||||
return &helper{
|
||||
msgProvider: msgProvider,
|
||||
statusInfo: func(s string) {
|
||||
app.PushStatus(s, 10*time.Second)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *helper) markedOrSelectedUids() ([]models.UID, error) {
|
||||
return commands.MarkedOrSelected(h.msgProvider)
|
||||
}
|
||||
|
||||
func (h *helper) store() (*lib.MessageStore, error) {
|
||||
store := h.msgProvider.Store()
|
||||
if store == nil {
|
||||
return nil, errors.New("Cannot perform action. Messages still loading")
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
func (h *helper) account() (*app.AccountView, error) {
|
||||
acct := h.msgProvider.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil, errors.New("No account selected")
|
||||
}
|
||||
return acct, nil
|
||||
}
|
||||
|
||||
func (h *helper) messages() ([]*models.MessageInfo, error) {
|
||||
uid, err := commands.MarkedOrSelected(h.msgProvider)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
store, err := h.store()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return commands.MsgInfoFromUids(store, uid, h.statusInfo)
|
||||
}
|
||||
|
||||
func getMessagePart(msg *models.MessageInfo, provider app.ProvidesMessage) []int {
|
||||
p := provider.SelectedMessagePart()
|
||||
if p != nil {
|
||||
return p.Index
|
||||
}
|
||||
for _, mime := range config.Viewer.Alternatives {
|
||||
part := lib.FindMIMEPart(mime, msg.BodyStructure, nil)
|
||||
if part != nil {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package msgview
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type NextPrevPart struct {
|
||||
Offset int `opt:"n" default:"1"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(NextPrevPart{})
|
||||
}
|
||||
|
||||
func (NextPrevPart) Description() string {
|
||||
return "Cycle between message parts being shown."
|
||||
}
|
||||
|
||||
func (NextPrevPart) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (NextPrevPart) Aliases() []string {
|
||||
return []string{"next-part", "prev-part"}
|
||||
}
|
||||
|
||||
func (np NextPrevPart) Execute(args []string) error {
|
||||
mv, _ := app.SelectedTabContent().(*app.MessageViewer)
|
||||
for n := 0; n < np.Offset; n++ {
|
||||
if args[0] == "prev-part" {
|
||||
mv.PreviousPart()
|
||||
} else {
|
||||
mv.NextPart()
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package msgview
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
)
|
||||
|
||||
type OpenLink struct {
|
||||
Url *url.URL `opt:"url" action:"ParseUrl" complete:"CompleteUrl"`
|
||||
Cmd string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(OpenLink{})
|
||||
}
|
||||
|
||||
func (OpenLink) Description() string {
|
||||
return "Open the specified URL with an external program."
|
||||
}
|
||||
|
||||
func (OpenLink) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (OpenLink) Aliases() []string {
|
||||
return []string{"open-link"}
|
||||
}
|
||||
|
||||
func (*OpenLink) CompleteUrl(arg string) []string {
|
||||
mv := app.SelectedTabContent().(*app.MessageViewer)
|
||||
if mv != nil {
|
||||
if p := mv.SelectedMessagePart(); p != nil {
|
||||
return commands.FilterList(p.Links, arg, nil)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OpenLink) ParseUrl(arg string) error {
|
||||
u, err := url.Parse(arg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
o.Url = u
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o OpenLink) Execute(args []string) error {
|
||||
mime := fmt.Sprintf("x-scheme-handler/%s", o.Url.Scheme)
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
if err := lib.XDGOpenMime(o.Url.String(), mime, o.Cmd); err != nil {
|
||||
app.PushError("open-link: " + err.Error())
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package msgview
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"mime"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
)
|
||||
|
||||
type Open struct {
|
||||
Delete bool `opt:"-d" desc:"Delete temp file after the opener exits."`
|
||||
Cmd string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Open{})
|
||||
}
|
||||
|
||||
func (Open) Description() string {
|
||||
return "Save the current message part to a temporary file, then open it."
|
||||
}
|
||||
|
||||
func (Open) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Open) Aliases() []string {
|
||||
return []string{"open"}
|
||||
}
|
||||
|
||||
func (o Open) Execute(args []string) error {
|
||||
mv := app.SelectedTabContent().(*app.MessageViewer)
|
||||
if mv == nil {
|
||||
return errors.New("open only supported selected message parts")
|
||||
}
|
||||
p := mv.SelectedMessagePart()
|
||||
|
||||
mv.MessageView().FetchBodyPart(p.Index, func(reader io.Reader) {
|
||||
mimeType := ""
|
||||
|
||||
part, err := mv.MessageView().BodyStructure().PartAtIndex(p.Index)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
mimeType = part.FullMIMEType()
|
||||
|
||||
tmpDir, err := os.MkdirTemp(os.TempDir(), "aerc-*")
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
filename := path.Base(part.FileName())
|
||||
var tmpFile *os.File
|
||||
if filename == "." {
|
||||
extension := ""
|
||||
if exts, _ := mime.ExtensionsByType(mimeType); len(exts) > 0 {
|
||||
extension = exts[0]
|
||||
}
|
||||
tmpFile, err = os.CreateTemp(tmpDir, "aerc-*"+extension)
|
||||
} else {
|
||||
tmpFile, err = os.Create(filepath.Join(tmpDir, filename))
|
||||
}
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
_, err = io.Copy(tmpFile, reader)
|
||||
tmpFile.Close()
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
if o.Delete {
|
||||
defer os.RemoveAll(tmpDir)
|
||||
}
|
||||
err = lib.XDGOpenMime(tmpFile.Name(), mimeType, o.Cmd)
|
||||
if err != nil {
|
||||
app.PushError("open: " + err.Error())
|
||||
}
|
||||
}()
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package msgview
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/xdg"
|
||||
"git.sr.ht/~rjarry/aerc/models"
|
||||
)
|
||||
|
||||
type Save struct {
|
||||
Force bool `opt:"-f" desc:"Overwrite destination path."`
|
||||
CreateDirs bool `opt:"-p" desc:"Create missing directories."`
|
||||
Attachments bool `opt:"-a" desc:"Save all attachments parts."`
|
||||
AllAttachments bool `opt:"-A" desc:"Save all named parts."`
|
||||
Path string `opt:"path" required:"false" complete:"CompletePath" desc:"Target file path."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Save{})
|
||||
}
|
||||
|
||||
func (Save) Description() string {
|
||||
return "Save the current message part to the given path."
|
||||
}
|
||||
|
||||
func (Save) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Save) Aliases() []string {
|
||||
return []string{"save"}
|
||||
}
|
||||
|
||||
func (*Save) CompletePath(arg string) []string {
|
||||
defaultPath := config.General.DefaultSavePath
|
||||
if defaultPath != "" && !isAbsPath(arg) {
|
||||
arg = filepath.Join(defaultPath, arg)
|
||||
}
|
||||
return commands.CompletePath(arg, false)
|
||||
}
|
||||
|
||||
func (s Save) Execute(args []string) error {
|
||||
// we either need a path or a defaultPath
|
||||
if s.Path == "" && config.General.DefaultSavePath == "" {
|
||||
return errors.New("No default save path in config")
|
||||
}
|
||||
|
||||
// Absolute paths are taken as is so that the user can override the default
|
||||
// if they want to
|
||||
if !isAbsPath(s.Path) {
|
||||
s.Path = filepath.Join(config.General.DefaultSavePath, s.Path)
|
||||
}
|
||||
|
||||
s.Path = xdg.ExpandHome(s.Path)
|
||||
|
||||
mv, ok := app.SelectedTabContent().(*app.MessageViewer)
|
||||
if !ok {
|
||||
return fmt.Errorf("SelectedTabContent is not a MessageViewer")
|
||||
}
|
||||
|
||||
if s.Attachments || s.AllAttachments {
|
||||
parts := mv.AttachmentParts(s.AllAttachments)
|
||||
if len(parts) == 0 {
|
||||
return fmt.Errorf("This message has no attachments")
|
||||
}
|
||||
names := make(map[string]struct{})
|
||||
for _, pi := range parts {
|
||||
if err := s.savePart(pi, mv, names); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
pi := mv.SelectedMessagePart()
|
||||
return s.savePart(pi, mv, make(map[string]struct{}))
|
||||
}
|
||||
|
||||
func (s *Save) savePart(
|
||||
pi *app.PartInfo,
|
||||
mv *app.MessageViewer,
|
||||
names map[string]struct{},
|
||||
) error {
|
||||
path := s.Path
|
||||
if s.Attachments || s.AllAttachments || isDirExists(path) {
|
||||
filename := generateFilename(pi.Part)
|
||||
path = filepath.Join(path, filename)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(path)
|
||||
if s.CreateDirs && dir != "" {
|
||||
err := os.MkdirAll(dir, 0o755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
path = getCollisionlessFilename(path, names)
|
||||
names[path] = struct{}{}
|
||||
|
||||
if pathExists(path) && !s.Force {
|
||||
return fmt.Errorf("%q already exists and -f not given", path)
|
||||
}
|
||||
|
||||
ch := make(chan error, 1)
|
||||
mv.MessageView().FetchBodyPart(pi.Index, func(reader io.Reader) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
ch <- err
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.Copy(f, reader)
|
||||
if err != nil {
|
||||
ch <- err
|
||||
return
|
||||
}
|
||||
ch <- nil
|
||||
})
|
||||
|
||||
// we need to wait for the callback prior to displaying a result
|
||||
go func() {
|
||||
defer log.PanicHandler()
|
||||
|
||||
err := <-ch
|
||||
if err != nil {
|
||||
app.PushError(fmt.Sprintf("Save failed: %v", err))
|
||||
return
|
||||
}
|
||||
app.PushStatus("Saved to "+path, 10*time.Second)
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func getCollisionlessFilename(path string, existing map[string]struct{}) string {
|
||||
ext := filepath.Ext(path)
|
||||
name := strings.TrimSuffix(path, ext)
|
||||
_, exists := existing[path]
|
||||
counter := 1
|
||||
for exists {
|
||||
path = fmt.Sprintf("%s_%d%s", name, counter, ext)
|
||||
counter++
|
||||
_, exists = existing[path]
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// isDir returns true if path is a directory and exists
|
||||
func isDirExists(path string) bool {
|
||||
pathinfo, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return false // we don't really care
|
||||
}
|
||||
if pathinfo.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pathExists returns true if path exists
|
||||
func pathExists(path string) bool {
|
||||
_, err := os.Stat(path)
|
||||
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// isAbsPath returns true if path given is anchored to / or . or ~
|
||||
func isAbsPath(path string) bool {
|
||||
if len(path) == 0 {
|
||||
return false
|
||||
}
|
||||
switch path[0] {
|
||||
case '/':
|
||||
return true
|
||||
case '.':
|
||||
return true
|
||||
case '~':
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// generateFilename tries to get the filename from the given part.
|
||||
// if that fails it will fallback to a generated one based on the date
|
||||
func generateFilename(part *models.BodyStructure) string {
|
||||
filename := part.FileName()
|
||||
// Some MUAs send attachments with names like /some/stupid/idea/happy.jpeg
|
||||
// Assuming non hostile intent it does make sense to use just the last
|
||||
// portion of the pathname as the filename for saving it.
|
||||
filename = filename[strings.LastIndex(filename, "/")+1:]
|
||||
switch filename {
|
||||
case "", ".", "..":
|
||||
timestamp := time.Now().Format("2006-01-02-150405")
|
||||
filename = fmt.Sprintf("aerc_%v", timestamp)
|
||||
default:
|
||||
// already have a valid name
|
||||
}
|
||||
return filename
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package msgview
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestGetCollisionlessFilename(t *testing.T) {
|
||||
tests := []struct {
|
||||
originalFilename string
|
||||
expectedNewName string
|
||||
existingFiles map[string]struct{}
|
||||
}{
|
||||
{"test", "test", map[string]struct{}{}},
|
||||
{"test", "test", map[string]struct{}{"other-file": {}}},
|
||||
{"test.txt", "test.txt", map[string]struct{}{"test.log": {}}},
|
||||
{"test.txt", "test_1.txt", map[string]struct{}{"test.txt": {}}},
|
||||
{"test.txt", "test_2.txt", map[string]struct{}{"test.txt": {}, "test_1.txt": {}}},
|
||||
{"test.txt", "test_1.txt", map[string]struct{}{"test.txt": {}, "test_2.txt": {}}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
actual := getCollisionlessFilename(tt.originalFilename, tt.existingFiles)
|
||||
if actual != tt.expectedNewName {
|
||||
t.Errorf("expected %s, actual %s", tt.expectedNewName, actual)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package msgview
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
)
|
||||
|
||||
type ToggleHeaders struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(ToggleHeaders{})
|
||||
}
|
||||
|
||||
func (ToggleHeaders) Description() string {
|
||||
return "Toggle the visibility of message headers."
|
||||
}
|
||||
|
||||
func (ToggleHeaders) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (ToggleHeaders) Aliases() []string {
|
||||
return []string{"toggle-headers"}
|
||||
}
|
||||
|
||||
func (ToggleHeaders) Execute(args []string) error {
|
||||
mv, _ := app.SelectedTabContent().(*app.MessageViewer)
|
||||
mv.ToggleHeaders()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package msgview
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/state"
|
||||
)
|
||||
|
||||
type ToggleKeyPassthrough struct{}
|
||||
|
||||
func init() {
|
||||
commands.Register(ToggleKeyPassthrough{})
|
||||
}
|
||||
|
||||
func (ToggleKeyPassthrough) Description() string {
|
||||
return "Enter or exit the passthrough key bindings context."
|
||||
}
|
||||
|
||||
func (ToggleKeyPassthrough) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (ToggleKeyPassthrough) Aliases() []string {
|
||||
return []string{"toggle-key-passthrough"}
|
||||
}
|
||||
|
||||
func (ToggleKeyPassthrough) Execute(args []string) error {
|
||||
mv, _ := app.SelectedTabContent().(*app.MessageViewer)
|
||||
keyPassthroughEnabled := mv.ToggleKeyPassthrough()
|
||||
if acct := mv.SelectedAccount(); acct != nil {
|
||||
acct.SetStatus(state.Passthrough(keyPassthroughEnabled))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type NewAccount struct {
|
||||
Temp bool `opt:"-t" desc:"Create a temporary account."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(NewAccount{})
|
||||
}
|
||||
|
||||
func (NewAccount) Description() string {
|
||||
return "Start the new account wizard."
|
||||
}
|
||||
|
||||
func (NewAccount) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (NewAccount) Aliases() []string {
|
||||
return []string{"new-account"}
|
||||
}
|
||||
|
||||
func (n NewAccount) Execute(args []string) error {
|
||||
wizard := app.NewAccountWizard()
|
||||
wizard.ConfigureTemporaryAccount(n.Temp)
|
||||
wizard.Focus(true)
|
||||
app.NewTab(wizard, "New account")
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type NextPrevTab struct {
|
||||
Offset int `opt:"n" default:"1"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(NextPrevTab{})
|
||||
}
|
||||
|
||||
func (NextPrevTab) Description() string {
|
||||
return "Cycle to the previous or next tab."
|
||||
}
|
||||
|
||||
func (NextPrevTab) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (NextPrevTab) Aliases() []string {
|
||||
return []string{"next-tab", "prev-tab"}
|
||||
}
|
||||
|
||||
func (np NextPrevTab) Execute(args []string) error {
|
||||
if np.Offset <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
offset := np.Offset
|
||||
if args[0] == "prev-tab" {
|
||||
offset *= -1
|
||||
}
|
||||
|
||||
app.SelectTabAtOffset(offset)
|
||||
app.UpdateStatus()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/commands/msg"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama/models"
|
||||
)
|
||||
|
||||
type Apply struct {
|
||||
Cmd string `opt:"-c" desc:"Apply patches with provided command."`
|
||||
Worktree string `opt:"-w" desc:"Create linked worktree on this <commit-ish>."`
|
||||
Tag string `opt:"tag" required:"true" complete:"CompleteTag" desc:"Identify patches with tag."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Apply{})
|
||||
}
|
||||
|
||||
func (Apply) Description() string {
|
||||
return "Apply the selected message(s) to the current project."
|
||||
}
|
||||
|
||||
func (Apply) Context() commands.CommandContext {
|
||||
return commands.MESSAGE_LIST | commands.MESSAGE_VIEWER
|
||||
}
|
||||
|
||||
func (Apply) Aliases() []string {
|
||||
return []string{"apply"}
|
||||
}
|
||||
|
||||
func (*Apply) CompleteTag(arg string) []string {
|
||||
patches, err := pama.New().CurrentPatches()
|
||||
if err != nil {
|
||||
log.Errorf("failed to current patches for completion: %v", err)
|
||||
patches = nil
|
||||
}
|
||||
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
uids, err := acct.MarkedMessages()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if len(uids) == 0 {
|
||||
msg, err := acct.SelectedMessage()
|
||||
if err == nil {
|
||||
uids = append(uids, msg.Uid)
|
||||
}
|
||||
}
|
||||
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var subjects []string
|
||||
for _, uid := range uids {
|
||||
if msg, ok := store.Messages[uid]; !ok || msg == nil || msg.Envelope == nil {
|
||||
continue
|
||||
} else {
|
||||
subjects = append(subjects, msg.Envelope.Subject)
|
||||
}
|
||||
}
|
||||
return proposePatchName(patches, subjects)
|
||||
}
|
||||
|
||||
func (a Apply) Execute(args []string) error {
|
||||
patch := a.Tag
|
||||
worktree := a.Worktree
|
||||
applyCmd := a.Cmd
|
||||
|
||||
m := pama.New()
|
||||
p, err := m.CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Tracef("Current project: %v", p)
|
||||
|
||||
if worktree != "" {
|
||||
p, err = m.CreateWorktree(p, worktree, patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = m.SwitchProject(p.Name)
|
||||
if err != nil {
|
||||
log.Warnf("could not switch to worktree project: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if models.Commits(p.Commits).HasTag(patch) {
|
||||
return fmt.Errorf("Patch name '%s' already exists.", patch)
|
||||
}
|
||||
|
||||
if !m.Clean(p) {
|
||||
return fmt.Errorf("Aborting... There are unstaged changes in " +
|
||||
"your repository.")
|
||||
}
|
||||
|
||||
commit, err := m.Head(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Tracef("HEAD commit before: %s", commit)
|
||||
|
||||
if applyCmd != "" {
|
||||
rootFmt := "%r"
|
||||
if strings.Contains(applyCmd, rootFmt) {
|
||||
applyCmd = strings.ReplaceAll(applyCmd, rootFmt, p.Root)
|
||||
}
|
||||
log.Infof("use custom apply command: %s", applyCmd)
|
||||
} else {
|
||||
applyCmd, err = m.ApplyCmd(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
msgData := collectMessageData()
|
||||
|
||||
// apply patches with the pipe cmd
|
||||
pipe := msg.Pipe{
|
||||
Background: false,
|
||||
Full: true,
|
||||
Part: false,
|
||||
Command: applyCmd,
|
||||
}
|
||||
return pipe.Run(func() {
|
||||
p, err = m.ApplyUpdate(p, patch, commit, msgData)
|
||||
if err != nil {
|
||||
log.Errorf("Failed to save patch data: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// collectMessageData returns a map where the key is the message id and the
|
||||
// value the subject of the marked messages
|
||||
func collectMessageData() map[string]string {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
uids, err := commands.MarkedOrSelected(acct)
|
||||
if err != nil {
|
||||
log.Errorf("error occurred: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
kv := make(map[string]string)
|
||||
for _, uid := range uids {
|
||||
msginfo, ok := store.Messages[uid]
|
||||
if !ok || msginfo == nil {
|
||||
continue
|
||||
}
|
||||
id, err := msginfo.MsgId()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if msginfo.Envelope == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
kv[id] = msginfo.Envelope.Subject
|
||||
}
|
||||
|
||||
return kv
|
||||
}
|
||||
|
||||
func proposePatchName(patches, subjects []string) []string {
|
||||
parse := func(s string) (string, string, bool) {
|
||||
var tag strings.Builder
|
||||
var version string
|
||||
var i, j int
|
||||
|
||||
i = strings.Index(s, "[")
|
||||
if i < 0 {
|
||||
goto noPatch
|
||||
}
|
||||
s = s[i+1:]
|
||||
|
||||
j = strings.Index(s, "]")
|
||||
if j < 0 {
|
||||
goto noPatch
|
||||
}
|
||||
for _, elem := range strings.Fields(s[:j]) {
|
||||
vers := strings.ToLower(elem)
|
||||
if !strings.HasPrefix(vers, "v") {
|
||||
continue
|
||||
}
|
||||
isVersion := true
|
||||
for _, r := range vers[1:] {
|
||||
if !unicode.IsDigit(r) {
|
||||
isVersion = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if isVersion {
|
||||
version = vers
|
||||
break
|
||||
}
|
||||
}
|
||||
s = strings.TrimSpace(s[j+1:])
|
||||
|
||||
for _, r := range s {
|
||||
if unicode.IsSpace(r) || r == ':' {
|
||||
break
|
||||
}
|
||||
_, err := tag.WriteRune(r)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return tag.String(), version, true
|
||||
noPatch:
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
summary := make(map[string]struct{})
|
||||
|
||||
var results []string
|
||||
for _, s := range subjects {
|
||||
tag, version, isPatch := parse(s)
|
||||
if tag == "" || !isPatch {
|
||||
continue
|
||||
}
|
||||
if version == "" {
|
||||
version = "v1"
|
||||
}
|
||||
result := fmt.Sprintf("%s_%s", tag, version)
|
||||
result = strings.ReplaceAll(result, " ", "")
|
||||
|
||||
collision := false
|
||||
for _, name := range patches {
|
||||
if name == result {
|
||||
collision = true
|
||||
}
|
||||
}
|
||||
if collision {
|
||||
continue
|
||||
}
|
||||
|
||||
_, ok := summary[result]
|
||||
if ok {
|
||||
continue
|
||||
}
|
||||
results = append(results, result)
|
||||
summary[result] = struct{}{}
|
||||
}
|
||||
|
||||
sort.Strings(results)
|
||||
return results
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPatchApply_ProposeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
exist []string
|
||||
subjects []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "base case",
|
||||
exist: nil,
|
||||
subjects: []string{
|
||||
"[PATCH aerc v3 3/3] notmuch: remove unused code",
|
||||
"[PATCH aerc v3 2/3] notmuch: replace notmuch library with internal bindings",
|
||||
"[PATCH aerc v3 1/3] notmuch: add notmuch bindings",
|
||||
},
|
||||
want: []string{"notmuch_v3"},
|
||||
},
|
||||
{
|
||||
name: "distorted case",
|
||||
exist: nil,
|
||||
subjects: []string{
|
||||
"[PATCH vaerc v3 3/3] notmuch: remove unused code",
|
||||
"[PATCH aerc 3v 2/3] notmuch: replace notmuch library with internal bindings",
|
||||
},
|
||||
want: []string{"notmuch_v1", "notmuch_v3"},
|
||||
},
|
||||
{
|
||||
name: "invalid patches",
|
||||
exist: nil,
|
||||
subjects: []string{
|
||||
"notmuch: remove unused code",
|
||||
": replace notmuch library with internal bindings",
|
||||
},
|
||||
want: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
got := proposePatchName(test.exist, test.subjects)
|
||||
if !reflect.DeepEqual(got, test.want) {
|
||||
t.Errorf("test '%s' failed to propose the correct "+
|
||||
"name: got '%v', but want '%v'", test.name,
|
||||
got, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
)
|
||||
|
||||
type Cd struct{}
|
||||
|
||||
func init() {
|
||||
register(Cd{})
|
||||
}
|
||||
|
||||
func (Cd) Description() string {
|
||||
return "Change aerc's working directory to the current project."
|
||||
}
|
||||
|
||||
func (Cd) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Cd) Aliases() []string {
|
||||
return []string{"cd"}
|
||||
}
|
||||
|
||||
func (Cd) Execute(args []string) error {
|
||||
p, err := pama.New().CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if cwd == p.Root {
|
||||
app.PushStatus("Already here.", 10*time.Second)
|
||||
return nil
|
||||
}
|
||||
err = os.Chdir(p.Root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf("Changed to %s.", p.Root),
|
||||
10*time.Second)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
)
|
||||
|
||||
type Drop struct {
|
||||
Tag string `opt:"tag" complete:"CompleteTag" desc:"Repository patch tag."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Drop{})
|
||||
}
|
||||
|
||||
func (Drop) Description() string {
|
||||
return "Drop a patch from the repository."
|
||||
}
|
||||
|
||||
func (Drop) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Drop) Aliases() []string {
|
||||
return []string{"drop"}
|
||||
}
|
||||
|
||||
func (*Drop) CompleteTag(arg string) []string {
|
||||
patches, err := pama.New().CurrentPatches()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get current patches: %v", err)
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(patches, arg, nil)
|
||||
}
|
||||
|
||||
func (r Drop) Execute(args []string) error {
|
||||
patch := r.Tag
|
||||
err := pama.New().DropPatch(patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf("Patch %s has been dropped", patch),
|
||||
10*time.Second)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/textproto"
|
||||
"strings"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/commands/account"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama/models"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
type Find struct {
|
||||
Filter bool `opt:"-f" desc:"Filter message list instead of search."`
|
||||
Commit []string `opt:"..." required:"true" complete:"Complete" desc:"Search for <commit-ish>."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Find{})
|
||||
}
|
||||
|
||||
func (Find) Description() string {
|
||||
return "Search for applied patches."
|
||||
}
|
||||
|
||||
func (Find) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Find) Aliases() []string {
|
||||
return []string{"find"}
|
||||
}
|
||||
|
||||
func (*Find) Complete(arg string) []string {
|
||||
m := pama.New()
|
||||
p, err := m.CurrentProject()
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
options := make([]string, len(p.Commits))
|
||||
for i, c := range p.Commits {
|
||||
options[i] = fmt.Sprintf("%-6.6s %s", c.ID, c.Subject)
|
||||
}
|
||||
|
||||
return commands.FilterList(options, arg, nil)
|
||||
}
|
||||
|
||||
func (s Find) Execute(_ []string) error {
|
||||
m := pama.New()
|
||||
p, err := m.CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(s.Commit) == 0 {
|
||||
return errors.New("missing commit hash")
|
||||
}
|
||||
|
||||
lexed := opt.LexArgs(strings.TrimSpace(s.Commit[0]))
|
||||
|
||||
hash, err := lexed.ArgSafe(0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(hash) < 4 {
|
||||
return errors.New("Commit hash is too short.")
|
||||
}
|
||||
|
||||
var c models.Commit
|
||||
for _, commit := range p.Commits {
|
||||
if strings.Contains(commit.ID, hash) {
|
||||
c = commit
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.ID == "" {
|
||||
var err error
|
||||
c, err = m.Find(hash, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// If Message-Id is provided, find it in store
|
||||
if c.MessageId != "" {
|
||||
if selectMessageId(c.MessageId) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to a search based on the subject line
|
||||
args := []string{"search"}
|
||||
if s.Filter {
|
||||
args[0] = "filter"
|
||||
}
|
||||
|
||||
headers := make(textproto.MIMEHeader)
|
||||
args = append(args, fmt.Sprintf("-H Subject:%s", c.Subject))
|
||||
headers.Add("Subject", c.Subject)
|
||||
|
||||
cmd := account.SearchFilter{
|
||||
Headers: headers,
|
||||
}
|
||||
|
||||
return cmd.Execute(args)
|
||||
}
|
||||
|
||||
func selectMessageId(msgid string) bool {
|
||||
acct := app.SelectedAccount()
|
||||
if acct == nil {
|
||||
return false
|
||||
}
|
||||
store := acct.Store()
|
||||
if store == nil {
|
||||
return false
|
||||
}
|
||||
for uid, msg := range store.Messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
if msg.RFC822Headers == nil {
|
||||
continue
|
||||
}
|
||||
id, err := msg.RFC822Headers.MessageID()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if id == msgid {
|
||||
store.Select(uid)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
)
|
||||
|
||||
type Init struct {
|
||||
Force bool `opt:"-f" desc:"Overwrite any existing project."`
|
||||
Name string `opt:"name" required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Init{})
|
||||
}
|
||||
|
||||
func (Init) Description() string {
|
||||
return "Create a new project."
|
||||
}
|
||||
|
||||
func (Init) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Init) Aliases() []string {
|
||||
return []string{"init"}
|
||||
}
|
||||
|
||||
func (i Init) Execute(args []string) error {
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return fmt.Errorf("Could not get current directory: %w", err)
|
||||
}
|
||||
|
||||
name := i.Name
|
||||
if name == "" {
|
||||
name = filepath.Base(cwd)
|
||||
}
|
||||
|
||||
return pama.New().Init(name, cwd, i.Force)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama/models"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
)
|
||||
|
||||
type List struct {
|
||||
All bool `opt:"-a" desc:"List all projects."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(List{})
|
||||
}
|
||||
|
||||
func (List) Description() string {
|
||||
return "List the current project with the tracked patch sets."
|
||||
}
|
||||
|
||||
func (List) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (List) Aliases() []string {
|
||||
return []string{"list", "ls"}
|
||||
}
|
||||
|
||||
func (l List) Execute(args []string) error {
|
||||
m := pama.New()
|
||||
current, err := m.CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
projects := []models.Project{current}
|
||||
if l.All {
|
||||
projects, err = m.Projects("")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
app.PushStatus(fmt.Sprintf("Current project: %s", current.Name), 30*time.Second)
|
||||
|
||||
createWidget := func(r io.Reader) (ui.DrawableInteractive, error) {
|
||||
pagerCmd, err := app.CmdFallbackSearch(config.PagerCmds(), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cmd := opt.SplitArgs(pagerCmd)
|
||||
pager := exec.Command(cmd[0], cmd[1:]...)
|
||||
pager.Stdin = r
|
||||
|
||||
term, err := app.NewTerminal(pager)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
start := time.Now()
|
||||
term.OnClose = func(err error) {
|
||||
if time.Since(start) > 250*time.Millisecond {
|
||||
app.CloseDialog()
|
||||
return
|
||||
}
|
||||
term.OnEvent = func(_ vaxis.Event) bool {
|
||||
app.CloseDialog()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return term, nil
|
||||
}
|
||||
|
||||
viewer, err := createWidget(m.NewReader(projects))
|
||||
if err != nil {
|
||||
viewer = app.NewListBox(
|
||||
"Press <Esc> or <Enter> to close. "+
|
||||
"Start typing to filter.",
|
||||
numerify(m.NewReader(projects)), app.SelectedAccountUiConfig(),
|
||||
func(_ string) { app.CloseDialog() },
|
||||
)
|
||||
}
|
||||
|
||||
app.AddDialog(app.DefaultDialog(
|
||||
ui.NewBox(viewer, "Patch Management", "",
|
||||
app.SelectedAccountUiConfig(),
|
||||
),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func numerify(r io.Reader) []string {
|
||||
var lines []string
|
||||
nr := 1
|
||||
scanner := bufio.NewScanner(r)
|
||||
for scanner.Scan() {
|
||||
s := scanner.Text()
|
||||
lines = append(lines, fmt.Sprintf("%3d %s", nr, s))
|
||||
nr++
|
||||
}
|
||||
return lines
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
)
|
||||
|
||||
var subCommands map[string]commands.Command
|
||||
|
||||
func register(cmd commands.Command) {
|
||||
if subCommands == nil {
|
||||
subCommands = make(map[string]commands.Command)
|
||||
}
|
||||
for _, alias := range cmd.Aliases() {
|
||||
if subCommands[alias] != nil {
|
||||
panic("duplicate sub command alias: " + alias)
|
||||
}
|
||||
subCommands[alias] = cmd
|
||||
}
|
||||
}
|
||||
|
||||
type Patch struct {
|
||||
SubCmd commands.Command `opt:"command" action:"ParseSub" complete:"CompleteSubNames" desc:"Sub command."`
|
||||
Args string `opt:"..." required:"false" complete:"CompleteSubArgs"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
commands.Register(Patch{})
|
||||
}
|
||||
|
||||
func (Patch) Description() string {
|
||||
return "Local patch management commands."
|
||||
}
|
||||
|
||||
func (Patch) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Patch) Aliases() []string {
|
||||
return []string{"patch"}
|
||||
}
|
||||
|
||||
func (p *Patch) ParseSub(arg string) error {
|
||||
cmd, ok := subCommands[arg]
|
||||
if ok {
|
||||
context := commands.CurrentContext()
|
||||
if cmd.Context()&context != 0 {
|
||||
p.SubCmd = cmd
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s unknown sub-command", arg)
|
||||
}
|
||||
|
||||
func (*Patch) CompleteSubNames(arg string) []string {
|
||||
context := commands.CurrentContext()
|
||||
options := make([]string, 0, len(subCommands))
|
||||
for alias, cmd := range subCommands {
|
||||
if cmd.Context()&context != 0 {
|
||||
options = append(options, alias)
|
||||
}
|
||||
}
|
||||
return commands.FilterList(options, arg, commands.QuoteSpace)
|
||||
}
|
||||
|
||||
func (p *Patch) CompleteSubArgs(arg string) []string {
|
||||
if p.SubCmd == nil {
|
||||
return nil
|
||||
}
|
||||
// prepend arbitrary string to arg to work with sub-commands
|
||||
options, _ := commands.GetCompletions(p.SubCmd, opt.LexArgs("a "+arg))
|
||||
completions := make([]string, 0, len(options))
|
||||
for _, o := range options {
|
||||
completions = append(completions, o.Value)
|
||||
}
|
||||
return completions
|
||||
}
|
||||
|
||||
func (p Patch) Execute(args []string) error {
|
||||
if p.SubCmd == nil {
|
||||
return errors.New("no subcommand found")
|
||||
}
|
||||
a := opt.QuoteArgs(args[1:]...)
|
||||
return commands.ExecuteCommand(p.SubCmd, a.String())
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama/models"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type Rebase struct {
|
||||
Commit string `opt:"commit" required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Rebase{})
|
||||
}
|
||||
|
||||
func (Rebase) Description() string {
|
||||
return "Rebase the patch data."
|
||||
}
|
||||
|
||||
func (Rebase) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Rebase) Aliases() []string {
|
||||
return []string{"rebase"}
|
||||
}
|
||||
|
||||
func (r Rebase) Execute(args []string) error {
|
||||
m := pama.New()
|
||||
current, err := m.CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseID := r.Commit
|
||||
if baseID == "" {
|
||||
baseID = current.Base.ID
|
||||
}
|
||||
|
||||
commits, err := m.RebaseCommits(current, baseID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(commits) == 0 {
|
||||
err := m.SaveRebased(current, baseID, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("No commits to rebase, but saving of new reference failed: %w", err)
|
||||
}
|
||||
app.PushStatus("No commits to rebase.", 10*time.Second)
|
||||
return nil
|
||||
}
|
||||
|
||||
rebase := newRebase(commits)
|
||||
f, err := os.CreateTemp("", "aerc-patch-rebase-*")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name := f.Name()
|
||||
_, err = io.Copy(f, rebase.content())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Close()
|
||||
|
||||
createWidget := func() (ui.DrawableInteractive, error) {
|
||||
editorCmd, err := app.CmdFallbackSearch(config.EditorCmds(), true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
editor := exec.Command("/bin/sh", "-c", editorCmd+" "+name)
|
||||
term, err := app.NewTerminal(editor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
term.OnClose = func(_ error) {
|
||||
app.CloseDialog()
|
||||
defer os.Remove(name)
|
||||
defer term.Focus(false)
|
||||
|
||||
f, err := os.Open(name)
|
||||
if err != nil {
|
||||
app.PushError(fmt.Sprintf("failed to open file: %v", err))
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if editor.ProcessState.ExitCode() > 0 {
|
||||
app.PushError("Quitting rebase without saving.")
|
||||
return
|
||||
}
|
||||
err = m.SaveRebased(current, baseID, rebase.parse(f))
|
||||
if err != nil {
|
||||
app.PushError(fmt.Sprintf("Failed to save rebased commits: %v", err))
|
||||
return
|
||||
}
|
||||
app.PushStatus("Successfully rebased.", 10*time.Second)
|
||||
}
|
||||
term.Show(true)
|
||||
term.Focus(true)
|
||||
return term, nil
|
||||
}
|
||||
|
||||
viewer, err := createWidget()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.AddDialog(app.DefaultDialog(
|
||||
ui.NewBox(viewer, fmt.Sprintf("Patch Rebase on %-6.6s", baseID), "",
|
||||
app.SelectedAccountUiConfig(),
|
||||
),
|
||||
))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type rebase struct {
|
||||
commits []models.Commit
|
||||
table map[string]models.Commit
|
||||
order []string
|
||||
}
|
||||
|
||||
func newRebase(commits []models.Commit) *rebase {
|
||||
return &rebase{
|
||||
commits: commits,
|
||||
table: make(map[string]models.Commit),
|
||||
}
|
||||
}
|
||||
|
||||
const (
|
||||
header string = ""
|
||||
footer string = `
|
||||
# Rebase aerc's patch data. This will not affect the underlying repository in
|
||||
# any way.
|
||||
#
|
||||
# Change the name in the first column to assign a new tag to a commit. To group
|
||||
# multiple commits, use the same tag name.
|
||||
#
|
||||
# An 'untracked' tag indicates that aerc lost track of that commit, either due
|
||||
# to a commit-hash change or because that commit was applied outside of aerc.
|
||||
#
|
||||
# Do not change anything else besides the tag names (first column).
|
||||
#
|
||||
# Do not reorder the lines. The ordering should remain as in the repository.
|
||||
#
|
||||
# If you remove a line or keep an 'untracked' tag, those commits will be removed
|
||||
# from aerc's patch tracking.
|
||||
#
|
||||
`
|
||||
)
|
||||
|
||||
func (r *rebase) content() io.Reader {
|
||||
var buf bytes.Buffer
|
||||
buf.WriteString(header)
|
||||
for _, c := range r.commits {
|
||||
tag := c.Tag
|
||||
if tag == "" {
|
||||
tag = models.Untracked
|
||||
}
|
||||
shortHash := fmt.Sprintf("%6.6s", c.ID)
|
||||
buf.WriteString(
|
||||
fmt.Sprintf("%-12s %6.6s %s\n", tag, shortHash, c.Info()))
|
||||
r.table[shortHash] = c
|
||||
r.order = append(r.order, shortHash)
|
||||
}
|
||||
buf.WriteString(footer)
|
||||
return &buf
|
||||
}
|
||||
|
||||
func (r *rebase) parse(reader io.Reader) []models.Commit {
|
||||
var commits []models.Commit
|
||||
var hashes []string
|
||||
scanner := bufio.NewScanner(reader)
|
||||
duplicated := make(map[string]struct{})
|
||||
for scanner.Scan() {
|
||||
s := scanner.Text()
|
||||
i := strings.Index(s, "#")
|
||||
if i >= 0 {
|
||||
s = s[:i]
|
||||
}
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fds := strings.Fields(s)
|
||||
if len(fds) < 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
tag, shortHash := fds[0], fds[1]
|
||||
if tag == models.Untracked {
|
||||
continue
|
||||
}
|
||||
_, dedup := duplicated[shortHash]
|
||||
if dedup {
|
||||
log.Warnf("rebase: skipping duplicated hash: %s", shortHash)
|
||||
continue
|
||||
}
|
||||
|
||||
hashes = append(hashes, shortHash)
|
||||
c, ok := r.table[shortHash]
|
||||
if !ok {
|
||||
log.Errorf("Looks like the commit hashes were changed "+
|
||||
"during the rebase. Dropping: %v", shortHash)
|
||||
continue
|
||||
}
|
||||
log.Tracef("save commit %s with tag %s", shortHash, tag)
|
||||
c.Tag = tag
|
||||
commits = append(commits, c)
|
||||
duplicated[shortHash] = struct{}{}
|
||||
}
|
||||
reorder(commits, hashes, r.order)
|
||||
return commits
|
||||
}
|
||||
|
||||
func reorder(toSort []models.Commit, now []string, by []string) {
|
||||
byMap := make(map[string]int)
|
||||
for i, s := range by {
|
||||
byMap[s] = i
|
||||
}
|
||||
|
||||
complete := true
|
||||
for _, s := range now {
|
||||
_, ok := byMap[s]
|
||||
complete = complete && ok
|
||||
}
|
||||
if !complete {
|
||||
return
|
||||
}
|
||||
|
||||
sort.SliceStable(toSort, func(i, j int) bool {
|
||||
return byMap[now[i]] < byMap[now[j]]
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama/models"
|
||||
)
|
||||
|
||||
func TestRebase_reorder(t *testing.T) {
|
||||
newCommits := func(order []string) []models.Commit {
|
||||
var commits []models.Commit
|
||||
for _, s := range order {
|
||||
commits = append(commits, models.Commit{ID: s})
|
||||
}
|
||||
return commits
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
commits []models.Commit
|
||||
now []string
|
||||
by []string
|
||||
want []models.Commit
|
||||
}{
|
||||
{
|
||||
name: "nothing to reorder",
|
||||
commits: newCommits([]string{"1", "2", "3"}),
|
||||
now: []string{"1", "2", "3"},
|
||||
by: []string{"1", "2", "3"},
|
||||
want: newCommits([]string{"1", "2", "3"}),
|
||||
},
|
||||
{
|
||||
name: "reorder",
|
||||
commits: newCommits([]string{"1", "3", "2"}),
|
||||
now: []string{"1", "3", "2"},
|
||||
by: []string{"1", "2", "3"},
|
||||
want: newCommits([]string{"1", "2", "3"}),
|
||||
},
|
||||
{
|
||||
name: "reorder inverted",
|
||||
commits: newCommits([]string{"3", "2", "1"}),
|
||||
now: []string{"3", "2", "1"},
|
||||
by: []string{"1", "2", "3"},
|
||||
want: newCommits([]string{"1", "2", "3"}),
|
||||
},
|
||||
{
|
||||
name: "changed hash: do not sort",
|
||||
commits: newCommits([]string{"1", "6", "3"}),
|
||||
now: []string{"1", "6", "3"},
|
||||
by: []string{"1", "2", "3"},
|
||||
want: newCommits([]string{"1", "6", "3"}),
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
reorder(test.commits, test.now, test.by)
|
||||
if !reflect.DeepEqual(test.commits, test.want) {
|
||||
t.Errorf("test '%s' failed to reorder: got %v but "+
|
||||
"want %v", test.name, test.commits, test.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func newCommit(id, subj, tag string) models.Commit {
|
||||
return models.Commit{
|
||||
ID: id,
|
||||
Subject: subj,
|
||||
Tag: tag,
|
||||
}
|
||||
}
|
||||
|
||||
func TestRebase_parse(t *testing.T) {
|
||||
input := `
|
||||
# some header info
|
||||
hello_v1 123 same info
|
||||
hello_v1 456 same info
|
||||
untracked 789 same info
|
||||
hello_v2 012 diff info
|
||||
untracked 345 diff info # not very useful comment
|
||||
# some footer info
|
||||
`
|
||||
commits := []models.Commit{
|
||||
newCommit("123123", "same info", "hello_v1"),
|
||||
newCommit("456456", "same info", "hello_v1"),
|
||||
newCommit("789789", "same info", models.Untracked),
|
||||
newCommit("012012", "diff info", "hello_v2"),
|
||||
newCommit("345345", "diff info", models.Untracked),
|
||||
}
|
||||
|
||||
var order []string
|
||||
for _, c := range commits {
|
||||
order = append(order, fmt.Sprintf("%3.3s", c.ID))
|
||||
}
|
||||
|
||||
table := make(map[string]models.Commit)
|
||||
for i, shortId := range order {
|
||||
table[shortId] = commits[i]
|
||||
}
|
||||
|
||||
rebase := &rebase{
|
||||
commits: commits,
|
||||
table: table,
|
||||
order: order,
|
||||
}
|
||||
|
||||
results := rebase.parse(strings.NewReader(input))
|
||||
|
||||
if len(results) != 3 {
|
||||
t.Errorf("failed to return correct number of commits: "+
|
||||
"got %d but wanted 3", len(results))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
)
|
||||
|
||||
type Switch struct {
|
||||
Project string `opt:"project" complete:"Complete" desc:"Project name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Switch{})
|
||||
}
|
||||
|
||||
func (Switch) Description() string {
|
||||
return "Switch context to the specified project."
|
||||
}
|
||||
|
||||
func (Switch) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Switch) Aliases() []string {
|
||||
return []string{"switch"}
|
||||
}
|
||||
|
||||
func (s Switch) Complete(arg string) []string {
|
||||
m := pama.New()
|
||||
names, err := m.Names()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get completion: %v", err)
|
||||
return nil
|
||||
}
|
||||
cur, err := m.CurrentProject()
|
||||
if err == nil {
|
||||
i := 0
|
||||
for ; i < len(names); i++ {
|
||||
if cur.Name == names[i] {
|
||||
names = append(names[:i], names[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return commands.FilterList(names, arg, nil)
|
||||
}
|
||||
|
||||
func (s Switch) Execute(_ []string) error {
|
||||
name := s.Project
|
||||
err := pama.New().SwitchProject(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushStatus(fmt.Sprintf("Project switched to '%s'", name),
|
||||
10*time.Second)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
)
|
||||
|
||||
type Term struct {
|
||||
Cmd []string `opt:"..." required:"false"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Term{})
|
||||
}
|
||||
|
||||
func (Term) Description() string {
|
||||
return "Open a shell or run a command in the current project's directory."
|
||||
}
|
||||
|
||||
func (Term) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Term) Aliases() []string {
|
||||
return []string{"term"}
|
||||
}
|
||||
|
||||
func (t Term) Execute(_ []string) error {
|
||||
p, err := pama.New().CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return commands.TermCoreDirectory(t.Cmd, p.Root)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package patch
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/commands"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/pama"
|
||||
)
|
||||
|
||||
type Unlink struct {
|
||||
Tag string `opt:"tag" required:"false" complete:"Complete" desc:"Project tag name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
register(Unlink{})
|
||||
}
|
||||
|
||||
func (Unlink) Description() string {
|
||||
return "Delete all patch tracking data for the specified project."
|
||||
}
|
||||
|
||||
func (Unlink) Context() commands.CommandContext {
|
||||
return commands.GLOBAL
|
||||
}
|
||||
|
||||
func (Unlink) Aliases() []string {
|
||||
return []string{"unlink"}
|
||||
}
|
||||
|
||||
func (*Unlink) Complete(arg string) []string {
|
||||
names, err := pama.New().Names()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get completion: %v", err)
|
||||
return nil
|
||||
}
|
||||
return commands.FilterList(names, arg, nil)
|
||||
}
|
||||
|
||||
func (d Unlink) Execute(args []string) error {
|
||||
m := pama.New()
|
||||
|
||||
name := d.Tag
|
||||
if name == "" {
|
||||
p, err := m.CurrentProject()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name = p.Name
|
||||
}
|
||||
|
||||
err := m.Unlink(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
app.PushStatus(fmt.Sprintf("Project '%s' unlinked.", name),
|
||||
10*time.Second)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type PinTab struct{}
|
||||
|
||||
func init() {
|
||||
Register(PinTab{})
|
||||
}
|
||||
|
||||
func (PinTab) Description() string {
|
||||
return "Move the current tab to the left and mark it as pinned."
|
||||
}
|
||||
|
||||
func (PinTab) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (PinTab) Aliases() []string {
|
||||
return []string{"pin-tab", "unpin-tab"}
|
||||
}
|
||||
|
||||
func (PinTab) Execute(args []string) error {
|
||||
switch args[0] {
|
||||
case "pin-tab":
|
||||
app.PinTab()
|
||||
case "unpin-tab":
|
||||
app.UnpinTab()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/go-opt/v2"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type Prompt struct {
|
||||
Text string `opt:"text"`
|
||||
Cmd []string `opt:"..." complete:"CompleteCommand" desc:"Command name."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Prompt{})
|
||||
}
|
||||
|
||||
func (Prompt) Description() string {
|
||||
return "Prompt for user input and execute a command."
|
||||
}
|
||||
|
||||
func (Prompt) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Prompt) Aliases() []string {
|
||||
return []string{"prompt"}
|
||||
}
|
||||
|
||||
func (*Prompt) CompleteCommand(arg string) []string {
|
||||
return FilterList(ActiveCommandNames(), arg, nil)
|
||||
}
|
||||
|
||||
func (p Prompt) Execute(args []string) error {
|
||||
cmd := opt.QuoteArgs(p.Cmd...)
|
||||
app.RegisterPrompt(p.Text, cmd.String())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
)
|
||||
|
||||
type PrintWorkDir struct{}
|
||||
|
||||
func init() {
|
||||
Register(PrintWorkDir{})
|
||||
}
|
||||
|
||||
func (PrintWorkDir) Description() string {
|
||||
return "Display aerc's current working directory."
|
||||
}
|
||||
|
||||
func (PrintWorkDir) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (PrintWorkDir) Aliases() []string {
|
||||
return []string{"pwd"}
|
||||
}
|
||||
|
||||
func (PrintWorkDir) Execute(args []string) error {
|
||||
pwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushStatus(pwd, 10*time.Second)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/commands/mode"
|
||||
)
|
||||
|
||||
type Quit struct {
|
||||
Force bool `opt:"-f" desc:"Force quit even if a task is pending."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Quit{})
|
||||
}
|
||||
|
||||
func (Quit) Description() string {
|
||||
return "Exit aerc."
|
||||
}
|
||||
|
||||
func (Quit) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Quit) Aliases() []string {
|
||||
return []string{"quit", "q", "exit"}
|
||||
}
|
||||
|
||||
type ErrorExit int
|
||||
|
||||
func (err ErrorExit) Error() string {
|
||||
return "exit"
|
||||
}
|
||||
|
||||
func (q Quit) Execute(args []string) error {
|
||||
if q.Force || mode.QuitAllowed() {
|
||||
return ErrorExit(1)
|
||||
}
|
||||
return fmt.Errorf("A task is not done yet. Use -f to force an exit.")
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package commands
|
||||
|
||||
import "git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
|
||||
type Redraw struct{}
|
||||
|
||||
func init() {
|
||||
Register(Redraw{})
|
||||
}
|
||||
|
||||
func (Redraw) Description() string {
|
||||
return "Force a full redraw of the screen."
|
||||
}
|
||||
|
||||
func (Redraw) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Redraw) Aliases() []string {
|
||||
return []string{"redraw"}
|
||||
}
|
||||
|
||||
func (Redraw) Execute(args []string) error {
|
||||
ui.QueueRefresh()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rjarry/aerc/lib/log"
|
||||
"git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
)
|
||||
|
||||
type Reload struct {
|
||||
Binds bool `opt:"-B" desc:"Reload binds.conf."`
|
||||
Conf bool `opt:"-C" desc:"Reload aerc.conf."`
|
||||
Style string `opt:"-s" complete:"CompleteStyle" desc:"Reload the specified styleset."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(Reload{})
|
||||
}
|
||||
|
||||
func (Reload) Description() string {
|
||||
return "Hot-reload configuration files."
|
||||
}
|
||||
|
||||
func (r *Reload) CompleteStyle(s string) []string {
|
||||
var files []string
|
||||
for _, dir := range config.Ui.StyleSetDirs {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
log.Debugf("could not read directory '%s': %v", dir,
|
||||
err)
|
||||
continue
|
||||
}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
files = append(files, e.Name())
|
||||
}
|
||||
}
|
||||
return FilterList(files, s, nil)
|
||||
}
|
||||
|
||||
func (Reload) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Reload) Aliases() []string {
|
||||
return []string{"reload"}
|
||||
}
|
||||
|
||||
func (r Reload) Execute(args []string) error {
|
||||
if !r.Binds && !r.Conf && r.Style == "" {
|
||||
r.Binds = true
|
||||
r.Conf = true
|
||||
r.Style = config.Ui.StyleSetName
|
||||
}
|
||||
|
||||
reconfigure := false
|
||||
|
||||
if r.Binds {
|
||||
f, err := config.ReloadBinds()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushSuccess("Binds reloaded: " + f)
|
||||
}
|
||||
|
||||
if r.Conf {
|
||||
f, err := config.ReloadConf()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushSuccess("Conf reloaded: " + f)
|
||||
reconfigure = true
|
||||
}
|
||||
|
||||
if r.Style != "" {
|
||||
config.Ui.ClearCache()
|
||||
config.Ui.StyleSetName = r.Style
|
||||
err := config.Ui.LoadStyle()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
app.PushSuccess("Styleset: " + r.Style)
|
||||
reconfigure = true
|
||||
}
|
||||
|
||||
if !reconfigure {
|
||||
return nil
|
||||
}
|
||||
|
||||
// reload account views and message stores
|
||||
for _, name := range app.AccountNames() {
|
||||
|
||||
// rebuild account view
|
||||
view, err := app.Account(name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
dirlist := view.Directories()
|
||||
if dirlist == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
wantTree := config.Ui.ForAccount(name).DirListTree
|
||||
dirlist = adjustDirlist(dirlist, wantTree)
|
||||
view.SetDirectories(dirlist)
|
||||
|
||||
// now rebuild grid with correct dirlist
|
||||
view.Configure()
|
||||
|
||||
// reconfigure the message stores
|
||||
for _, dir := range dirlist.List() {
|
||||
store, ok := dirlist.MsgStore(dir)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
uiConf := dirlist.UiConfig(dir)
|
||||
store.Configure(view.SortCriteria(uiConf))
|
||||
}
|
||||
ui.Invalidate()
|
||||
}
|
||||
|
||||
// reload message viewers
|
||||
doTabs(func(tab *ui.Tab) {
|
||||
mv, ok := tab.Content.(*app.MessageViewer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
reloaded, err := app.NewMessageViewer(
|
||||
mv.SelectedAccount(),
|
||||
mv.MessageView(),
|
||||
)
|
||||
if err != nil {
|
||||
app.PushError(err.Error())
|
||||
return
|
||||
}
|
||||
app.ReplaceTab(mv, reloaded, tab.Name, false)
|
||||
})
|
||||
|
||||
// reload composers
|
||||
doTabs(func(tab *ui.Tab) {
|
||||
c, ok := tab.Content.(*app.Composer)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
_ = c.SwitchAccount(c.Account())
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func adjustDirlist(d app.DirectoryLister, wantTree bool) app.DirectoryLister {
|
||||
switch d := d.(type) {
|
||||
case *app.DirectoryList:
|
||||
if wantTree {
|
||||
log.Tracef("dirlist: build tree")
|
||||
tree := app.NewDirectoryTree(d)
|
||||
tree.SelectedMsgStore()
|
||||
return tree
|
||||
}
|
||||
log.Tracef("dirlist: already dirlist")
|
||||
return d
|
||||
case *app.DirectoryTree:
|
||||
if !wantTree {
|
||||
log.Tracef("dirtree: get dirlist")
|
||||
return d.DirectoryList
|
||||
}
|
||||
log.Tracef("dirtree: already tree")
|
||||
return d
|
||||
default:
|
||||
return d
|
||||
}
|
||||
}
|
||||
|
||||
func doTabs(do func(*ui.Tab)) {
|
||||
var tabname string
|
||||
if t := app.SelectedTab(); t != nil {
|
||||
tabname = t.Name
|
||||
}
|
||||
for i := range app.TabNames() {
|
||||
tab := app.GetTab(i)
|
||||
if tab == nil {
|
||||
continue
|
||||
}
|
||||
do(tab)
|
||||
}
|
||||
if tabname != "" {
|
||||
app.SelectTab(tabname)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package commands
|
||||
|
||||
import (
|
||||
"git.sr.ht/~rjarry/aerc/app"
|
||||
"git.sr.ht/~rjarry/aerc/config"
|
||||
"git.sr.ht/~rockorager/vaxis"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
type SendKeys struct {
|
||||
Keys string `opt:"..."`
|
||||
}
|
||||
|
||||
func init() {
|
||||
Register(SendKeys{})
|
||||
}
|
||||
|
||||
func (SendKeys) Description() string {
|
||||
return "Send keystrokes to the currently visible terminal."
|
||||
}
|
||||
|
||||
func (SendKeys) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (SendKeys) Aliases() []string {
|
||||
return []string{"send-keys"}
|
||||
}
|
||||
|
||||
func (s SendKeys) Execute(args []string) error {
|
||||
tab, ok := app.SelectedTabContent().(app.HasTerminal)
|
||||
if !ok {
|
||||
return errors.New("There is no terminal here")
|
||||
}
|
||||
|
||||
term := tab.Terminal()
|
||||
if term == nil {
|
||||
return errors.New("The terminal is not active")
|
||||
}
|
||||
|
||||
keys2send, err := config.ParseKeyStrokes(s.Keys)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "Unable to parse keystroke: %q", s.Keys)
|
||||
}
|
||||
|
||||
for _, key := range keys2send {
|
||||
ev := vaxis.Key{
|
||||
Keycode: key.Key,
|
||||
Modifiers: key.Modifiers,
|
||||
}
|
||||
term.Event(ev)
|
||||
}
|
||||
|
||||
term.Invalidate()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package commands
|
||||
|
||||
import "git.sr.ht/~rjarry/aerc/lib/ui"
|
||||
|
||||
type Suspend struct{}
|
||||
|
||||
func init() {
|
||||
Register(Suspend{})
|
||||
}
|
||||
|
||||
func (Suspend) Description() string {
|
||||
return "Suspend the aerc process."
|
||||
}
|
||||
|
||||
func (Suspend) Context() CommandContext {
|
||||
return GLOBAL
|
||||
}
|
||||
|
||||
func (Suspend) Aliases() []string {
|
||||
return []string{"suspend"}
|
||||
}
|
||||
|
||||
func (Suspend) Execute(args []string) error {
|
||||
ui.QueueSuspend()
|
||||
return nil
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user