init: pristine aerc 0.20.0 source
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user