aboutsummaryrefslogtreecommitdiff
path: root/modules/mu4e-attachments.el
blob: 4acdfd6a104cf982356d187f997b5c37cdfd2f9c (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
;;; mu4e-attachments.el --- Save attachments from mu4e view messages -*- lexical-binding: t; coding: utf-8; -*-
;; author Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
;; Project-owned commands for saving attachments out of a mu4e message view.
;;
;; - `cj/mu4e-save-all-attachments' saves every attachment.
;; - `cj/mu4e-save-attachment-here' prompts for one attachment and saves it.
;; - `cj/mu4e-save-some-attachments' opens a selection buffer: RET toggles a
;;   row, a marks all, u unmarks all, s saves the marked rows, q quits.
;; All three prompt for a destination directory.
;;
;; The keybindings live in `mail-config' under `cj/email-map' (C-; e).

;;; Code:

(require 'seq)

(defvar mu4e-uniquify-save-file-name-function)
(defvar-local cj/mu4e-attachment-selection-directory nil
  "Destination directory for the current attachment selection buffer.")
(defvar-local cj/mu4e-attachment-selection-entries nil
  "Attachment selection entries for the current selection buffer.")

(declare-function mm-save-part-to-file "mm-decode" (handle filename))
(declare-function mu4e-join-paths "mu4e-helpers" (directory &rest components))
(declare-function mu4e-view-mime-parts "mu4e-mime-parts" ())

;; --------------------------- Attachment Saving ------------------------------

(defun cj/mu4e--attachment-parts (&optional parts)
  "Return attachment-like MIME PARTS for the current mu4e view message.
When PARTS is nil, read parts from `mu4e-view-mime-parts'."
  (seq-filter (lambda (part) (plist-get part :attachment-like))
              (or parts
                  (progn
                    (unless (fboundp 'mu4e-view-mime-parts)
                      (require 'mu4e-mime-parts))
                    (mu4e-view-mime-parts)))))

(defun cj/mu4e--attachment-duplicate-filenames (parts)
  "Return filenames that appear more than once in PARTS."
  (let ((counts (make-hash-table :test 'equal))
        duplicates)
    (dolist (part parts)
      (let ((filename (plist-get part :filename)))
        (puthash filename (1+ (gethash filename counts 0)) counts)))
    (maphash (lambda (filename count)
               (when (> count 1)
                 (push filename duplicates)))
             counts)
    duplicates))

(defun cj/mu4e--attachment-label (part duplicate-filenames)
  "Return a completion label for PART.
DUPLICATE-FILENAMES is a list of filenames that need part-index disambiguation."
  (let ((filename (or (plist-get part :filename) "unnamed-attachment")))
    (if (member filename duplicate-filenames)
        (format "%s <part %s>" filename (plist-get part :part-index))
      filename)))

(defun cj/mu4e--attachment-candidates (parts)
  "Return completion candidates for attachment PARTS.
The result is an alist of display labels to MIME part plists."
  (let ((duplicates (cj/mu4e--attachment-duplicate-filenames parts)))
    (mapcar (lambda (part)
              (cons (cj/mu4e--attachment-label part duplicates) part))
            parts)))

(defun cj/mu4e--attachment-default-directory (parts)
  "Return a sensible default save directory for attachment PARTS."
  (file-name-as-directory
   (or (plist-get (car parts) :target-dir)
       (expand-file-name "~/Downloads/"))))

(defun cj/mu4e--read-attachment-directory (parts)
  "Prompt for a destination directory for attachment PARTS."
  (file-name-as-directory
   (read-directory-name "Save attachments to: "
                        (cj/mu4e--attachment-default-directory parts))))

(defun cj/mu4e--ensure-attachment-save-functions ()
  "Load mu4e MIME support when attachment save helpers need it."
  (unless (and (boundp 'mu4e-uniquify-save-file-name-function)
               (fboundp 'mu4e-join-paths))
    (require 'mu4e-mime-parts)))

(defun cj/mu4e--save-attachment-part (part directory)
  "Save attachment PART to DIRECTORY and return the final path."
  (let ((handle (plist-get part :handle)))
    (unless handle
      (user-error "Attachment has no MIME handle: %s"
                  (or (plist-get part :filename) "<unnamed>")))
    (cj/mu4e--ensure-attachment-save-functions)
    (let* ((path (funcall mu4e-uniquify-save-file-name-function
                          (mu4e-join-paths directory
                                           (plist-get part :filename)))))
      (mm-save-part-to-file handle path)
      path)))

(defun cj/mu4e--save-attachment-parts (parts directory)
  "Save attachment PARTS to DIRECTORY and return the saved paths."
  (mapcar (lambda (part)
            (cj/mu4e--save-attachment-part part directory))
          parts))

(defun cj/mu4e-save-all-attachments ()
  "Prompt for a directory and save all attachments in the current mu4e message."
  (interactive)
  (let ((parts (cj/mu4e--attachment-parts)))
    (unless parts
      (user-error "No attachments for this message"))
    (let* ((directory (cj/mu4e--read-attachment-directory parts))
           (paths (cj/mu4e--save-attachment-parts parts directory)))
      (message "Saved %d attachment%s to %s"
               (length paths)
               (if (= (length paths) 1) "" "s")
               directory)
      paths)))

(defun cj/mu4e-save-attachment-here ()
  "Prompt for one attachment and a directory, then save that attachment."
  (interactive)
  (let ((parts (cj/mu4e--attachment-parts)))
    (unless parts
      (user-error "No attachments for this message"))
    (let* ((directory (cj/mu4e--read-attachment-directory parts))
           (candidates (cj/mu4e--attachment-candidates parts))
           (choice (completing-read "Save attachment: " candidates nil t))
           (part (cdr (assoc choice candidates)))
           (path (cj/mu4e--save-attachment-part part directory)))
      (message "Saved attachment to %s" path)
      path)))

(defvar cj/mu4e-attachment-selection-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "RET") #'cj/mu4e-attachment-selection-toggle)
    (define-key map (kbd "a") #'cj/mu4e-attachment-selection-mark-all)
    (define-key map (kbd "u") #'cj/mu4e-attachment-selection-unmark-all)
    (define-key map (kbd "s") #'cj/mu4e-attachment-selection-save-marked)
    (define-key map (kbd "q") #'quit-window)
    map)
  "Keymap for `cj/mu4e-attachment-selection-mode'.")

(define-derived-mode cj/mu4e-attachment-selection-mode special-mode "Mail Attachments"
  "Mode for selecting mu4e attachments to save.")

(defun cj/mu4e--attachment-selection-entry-at-point ()
  "Return the attachment selection entry at point."
  (or (get-text-property (point) 'cj/mu4e-attachment-entry)
      (get-text-property (line-beginning-position) 'cj/mu4e-attachment-entry)
      (user-error "No attachment on this line")))

(defun cj/mu4e--attachment-selection-render ()
  "Render the current attachment selection buffer."
  (let ((inhibit-read-only t)
        (point-line (line-number-at-pos)))
    (erase-buffer)
    (insert (format "Save attachments to: %s\n\n"
                    cj/mu4e-attachment-selection-directory))
    (insert "RET toggle   a mark all   u unmark all   s save marked   q quit\n\n")
    (dolist (entry cj/mu4e-attachment-selection-entries)
      (let* ((part (plist-get entry :part))
             (mark (if (plist-get entry :selected) "[x]" "[ ]"))
             (label (plist-get entry :label))
             (mime-type (or (plist-get part :mime-type) ""))
             (size (if-let ((bytes (plist-get part :decoded-size-approx)))
                       (file-size-human-readable bytes)
                     "")))
        (insert
         (propertize
          (format "%s %-40s %-24s %s\n" mark label mime-type size)
          'cj/mu4e-attachment-entry entry))))
    (goto-char (point-min))
    (forward-line (max 0 (1- point-line)))))

(defun cj/mu4e--attachment-selection-setup (parts directory)
  "Populate the current selection buffer with attachment PARTS and DIRECTORY."
  (setq cj/mu4e-attachment-selection-directory directory)
  (setq cj/mu4e-attachment-selection-entries
        (mapcar (lambda (candidate)
                  (list :label (car candidate)
                        :part (cdr candidate)
                        :selected nil))
                (cj/mu4e--attachment-candidates parts)))
  (cj/mu4e--attachment-selection-render))

(defun cj/mu4e-attachment-selection-toggle ()
  "Toggle the attachment entry at point."
  (interactive)
  (let ((entry (cj/mu4e--attachment-selection-entry-at-point)))
    (setf (plist-get entry :selected)
          (not (plist-get entry :selected)))
    (cj/mu4e--attachment-selection-render)))

(defun cj/mu4e-attachment-selection-mark-all ()
  "Mark all attachments in the selection buffer."
  (interactive)
  (dolist (entry cj/mu4e-attachment-selection-entries)
    (setf (plist-get entry :selected) t))
  (cj/mu4e--attachment-selection-render))

(defun cj/mu4e-attachment-selection-unmark-all ()
  "Unmark all attachments in the selection buffer."
  (interactive)
  (dolist (entry cj/mu4e-attachment-selection-entries)
    (setf (plist-get entry :selected) nil))
  (cj/mu4e--attachment-selection-render))

(defun cj/mu4e-attachment-selection-save-marked ()
  "Save the marked attachments, then clear the marks.
Clearing the marks keeps a second `s' from silently re-saving the same set;
quit the buffer with `q' or RET when done.  With no marks set, this is a
`user-error'."
  (interactive)
  (let ((parts (mapcar (lambda (entry) (plist-get entry :part))
                       (seq-filter (lambda (entry)
                                     (plist-get entry :selected))
                                   cj/mu4e-attachment-selection-entries))))
    (unless parts
      (user-error "No attachments selected"))
    (let ((paths (cj/mu4e--save-attachment-parts
                  parts cj/mu4e-attachment-selection-directory)))
      (dolist (entry cj/mu4e-attachment-selection-entries)
        (setf (plist-get entry :selected) nil))
      (cj/mu4e--attachment-selection-render)
      (message "Saved %d attachment%s to %s"
               (length paths)
               (if (= (length paths) 1) "" "s")
               cj/mu4e-attachment-selection-directory)
      paths)))

(defun cj/mu4e--open-attachment-selection-buffer (parts directory)
  "Open an attachment selection buffer for PARTS and DIRECTORY."
  (let ((buffer (get-buffer-create "*mu4e attachments*")))
    (with-current-buffer buffer
      (cj/mu4e-attachment-selection-mode)
      (cj/mu4e--attachment-selection-setup parts directory))
    (pop-to-buffer buffer)))

(defun cj/mu4e-save-some-attachments ()
  "Prompt for a directory and open a buffer to select attachments to save."
  (interactive)
  (let ((parts (cj/mu4e--attachment-parts)))
    (unless parts
      (user-error "No attachments for this message"))
    (let ((directory (cj/mu4e--read-attachment-directory parts)))
      (cj/mu4e--open-attachment-selection-buffer parts directory))))

(provide 'mu4e-attachments)
;;; mu4e-attachments.el ends here