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
}