mirror of
https://git.sr.ht/~rjarry/aerc
synced 2025-02-22 23:23:57 +01:00

Add `desc:""` struct field tags in all command arguments where it makes sense. The description values will be returned along with completion choices. Implements: https://todo.sr.ht/~rjarry/aerc/271 Signed-off-by: Robin Jarry <robin@jarry.cc> Tested-by: Bojan Gabric <bojan@bojangabric.com> Tested-by: Jason Cox <me@jasoncarloscox.com> Acked-by: Tim Culverhouse <tim@timculverhouse.com>
210 lines
4.8 KiB
Go
210 lines
4.8 KiB
Go
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
|
|
}
|