ccc1321f4daf554c855a4c29893dab9d3d08ce68
[notmuch] / emacs / notmuch-tag.el
1 ;;; notmuch-tag.el --- tag messages within emacs
2 ;;
3 ;; Copyright © Damien Cassou
4 ;; Copyright © Carl Worth
5 ;;
6 ;; This file is part of Notmuch.
7 ;;
8 ;; Notmuch is free software: you can redistribute it and/or modify it
9 ;; under the terms of the GNU General Public License as published by
10 ;; the Free Software Foundation, either version 3 of the License, or
11 ;; (at your option) any later version.
12 ;;
13 ;; Notmuch is distributed in the hope that it will be useful, but
14 ;; WITHOUT ANY WARRANTY; without even the implied warranty of
15 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
16 ;; General Public License for more details.
17 ;;
18 ;; You should have received a copy of the GNU General Public License
19 ;; along with Notmuch.  If not, see <https://www.gnu.org/licenses/>.
20 ;;
21 ;; Authors: Carl Worth <cworth@cworth.org>
22 ;;          Damien Cassou <damien.cassou@gmail.com>
23 ;;
24 ;;; Code:
25 ;;
26
27 (require 'cl-lib)
28 (eval-when-compile
29   (require 'pcase))
30
31 (require 'crm)
32
33 (require 'notmuch-lib)
34
35 (declare-function notmuch-search-tag "notmuch"
36                   (tag-changes &optional beg end only-matched))
37 (declare-function notmuch-show-tag "notmuch-show" (tag-changes))
38 (declare-function notmuch-tree-tag "notmuch-tree" (tag-changes))
39 (declare-function notmuch-jump "notmuch-jump" (action-map prompt))
40
41 (define-widget 'notmuch-tag-key-type 'list
42   "A single key tagging binding."
43   :format "%v"
44   :args '((list :inline t
45                 :format "%v"
46                 (key-sequence :tag "Key")
47                 (radio :tag "Tag operations"
48                        (repeat :tag "Tag list"
49                                (string :format "%v" :tag "change"))
50                        (variable :tag "Tag variable"))
51                 (string :tag "Name"))))
52
53 (defcustom notmuch-tagging-keys
54   `((,(kbd "a") notmuch-archive-tags "Archive")
55     (,(kbd "u") notmuch-show-mark-read-tags "Mark read")
56     (,(kbd "f") ("+flagged") "Flag")
57     (,(kbd "s") ("+spam" "-inbox") "Mark as spam")
58     (,(kbd "d") ("+deleted" "-inbox") "Delete"))
59   "A list of keys and corresponding tagging operations.
60
61 For each key (or key sequence) you can specify a sequence of
62 tagging operations to apply, or a variable which contains a list
63 of tagging operations such as `notmuch-archive-tags'. The final
64 element is a name for this tagging operation. If the name is
65 omitted or empty then the list of tag changes, or the variable
66 name is used as the name.
67
68 The key `notmuch-tag-jump-reverse-key' (k by default) should not
69 be used (either as a key, or as the start of a key sequence) as
70 it is already bound: it switches the menu to a menu of the
71 reverse tagging operations. The reverse of a tagging operation is
72 the same list of individual tag-ops but with `+tag` replaced by
73 `-tag` and vice versa.
74
75 If setting this variable outside of customize then it should be a
76 list of triples (lists of three elements). Each triple should be
77 of the form (key-binding tagging-operations name). KEY-BINDING
78 can be a single character or a key sequence; TAGGING-OPERATIONS
79 should either be a list of individual tag operations each of the
80 form `+tag` or `-tag`, or the variable name of a variable that is
81 a list of tagging operations; NAME should be a name for the
82 tagging operation, if omitted or empty than then name is taken
83 from TAGGING-OPERATIONS."
84   :tag "List of tagging bindings"
85   :type '(repeat notmuch-tag-key-type)
86   :group 'notmuch-tag)
87
88 (define-widget 'notmuch-tag-format-type 'lazy
89   "Customize widget for notmuch-tag-format and friends."
90   :type '(alist :key-type (regexp :tag "Tag")
91                 :extra-offset -3
92                 :value-type
93                 (radio :format "%v"
94                        (const :tag "Hidden" nil)
95                        (set :tag "Modified"
96                             (string :tag "Display as")
97                             (list :tag "Face" :extra-offset -4
98                                   (const :format "" :inline t
99                                          (notmuch-apply-face tag))
100                                   (list :format "%v"
101                                         (const :format "" quote)
102                                         custom-face-edit))
103                             (list :format "%v" :extra-offset -4
104                                   (const :format "" :inline t
105                                          (notmuch-tag-format-image-data tag))
106                                   (choice :tag "Image"
107                                           (const :tag "Star"
108                                                  (notmuch-tag-star-icon))
109                                           (const :tag "Empty star"
110                                                  (notmuch-tag-star-empty-icon))
111                                           (const :tag "Tag"
112                                                  (notmuch-tag-tag-icon))
113                                           (string :tag "Custom")))
114                             (sexp :tag "Custom")))))
115
116 (defface notmuch-tag-unread
117   '((t :foreground "red"))
118   "Default face used for the unread tag.
119
120 Used in the default value of `notmuch-tag-formats`."
121   :group 'notmuch-faces)
122
123 (defface notmuch-tag-flagged
124   '((((class color)
125       (background dark))
126      (:foreground "LightBlue1"))
127     (((class color)
128       (background light))
129      (:foreground "blue")))
130   "Face used for the flagged tag.
131
132 Used in the default value of `notmuch-tag-formats`."
133   :group 'notmuch-faces)
134
135 (defcustom notmuch-tag-formats
136   '(("unread" (propertize tag 'face 'notmuch-tag-unread))
137     ("flagged" (propertize tag 'face 'notmuch-tag-flagged)
138      (notmuch-tag-format-image-data tag (notmuch-tag-star-icon))))
139   "Custom formats for individual tags.
140
141 This is an association list that maps from tag name regexps to
142 lists of formatting expressions.  The first entry whose car
143 regexp-matches a tag will be used to format that tag.  The regexp
144 is implicitly anchored, so to match a literal tag name, just use
145 that tag name (if it contains special regexp characters like
146 \".\" or \"*\", these have to be escaped).  The cdr of the
147 matching entry gives a list of Elisp expressions that modify the
148 tag.  If the list is empty, the tag will simply be hidden.
149 Otherwise, each expression will be evaluated in order: for the
150 first expression, the variable `tag' will be bound to the tag
151 name; for each later expression, the variable `tag' will be bound
152 to the result of the previous expression.  In this way, each
153 expression can build on the formatting performed by the previous
154 expression.  The result of the last expression will displayed in
155 place of the tag.
156
157 For example, to replace a tag with another string, simply use
158 that string as a formatting expression.  To change the foreground
159 of a tag to red, use the expression
160   (propertize tag 'face '(:foreground \"red\"))
161
162 See also `notmuch-tag-format-image', which can help replace tags
163 with images."
164   :group 'notmuch-search
165   :group 'notmuch-show
166   :group 'notmuch-faces
167   :type 'notmuch-tag-format-type)
168
169 (defface notmuch-tag-deleted
170   '((((class color) (supports :strike-through "red")) :strike-through "red")
171     (t :inverse-video t))
172   "Face used to display deleted tags.
173
174 Used in the default value of `notmuch-tag-deleted-formats`."
175   :group 'notmuch-faces)
176
177 (defcustom notmuch-tag-deleted-formats
178   '(("unread" (notmuch-apply-face bare-tag `notmuch-tag-deleted))
179     (".*" (notmuch-apply-face tag `notmuch-tag-deleted)))
180   "Custom formats for tags when deleted.
181
182 For deleted tags the formats in `notmuch-tag-formats` are applied
183 first and then these formats are applied on top; that is `tag'
184 passed to the function is the tag with all these previous
185 formattings applied. The formatted can access the original
186 unformatted tag as `bare-tag'.
187
188 By default this shows deleted tags with strike-through in red,
189 unless strike-through is not available (e.g., emacs is running in
190 a terminal) in which case it uses inverse video. To hide deleted
191 tags completely set this to
192   '((\".*\" nil))
193
194 See `notmuch-tag-formats' for full documentation."
195   :group 'notmuch-show
196   :group 'notmuch-faces
197   :type 'notmuch-tag-format-type)
198
199 (defface notmuch-tag-added
200   '((t :underline "green"))
201   "Default face used for added tags.
202
203 Used in the default value for `notmuch-tag-added-formats`."
204   :group 'notmuch-faces)
205
206 (defcustom notmuch-tag-added-formats
207   '((".*" (notmuch-apply-face tag 'notmuch-tag-added)))
208   "Custom formats for tags when added.
209
210 For added tags the formats in `notmuch-tag-formats` are applied
211 first and then these formats are applied on top.
212
213 To disable special formatting of added tags, set this variable to
214 nil.
215
216 See `notmuch-tag-formats' for full documentation."
217   :group 'notmuch-show
218   :group 'notmuch-faces
219   :type 'notmuch-tag-format-type)
220
221 (defun notmuch-tag-format-image-data (tag data)
222   "Replace TAG with image DATA, if available.
223
224 This function returns a propertized string that will display image
225 DATA in place of TAG.This is designed for use in
226 `notmuch-tag-formats'.
227
228 DATA is the content of an SVG picture (e.g., as returned by
229 `notmuch-tag-star-icon')."
230   (propertize tag 'display
231               `(image :type svg
232                       :data ,data
233                       :ascent center
234                       :mask heuristic)))
235
236 (defun notmuch-tag-star-icon ()
237   "Return SVG data representing a star icon.
238 This can be used with `notmuch-tag-format-image-data'."
239   "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>
240 <svg version=\"1.1\" width=\"16\" height=\"16\">
241   <g transform=\"translate(-242.81601,-315.59635)\">
242     <path
243        d=\"m 290.25762,334.31206 -17.64143,-11.77975 -19.70508,7.85447 5.75171,-20.41814 -13.55925,-16.31348 21.19618,-0.83936 11.325,-17.93675 7.34825,19.89939 20.55849,5.22795 -16.65471,13.13786 z\"
244        transform=\"matrix(0.2484147,-0.02623394,0.02623394,0.2484147,174.63605,255.37691)\"
245        style=\"fill:#ffff00;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1\" />
246   </g>
247 </svg>")
248
249 (defun notmuch-tag-star-empty-icon ()
250   "Return SVG data representing an empty star icon.
251 This can be used with `notmuch-tag-format-image-data'."
252   "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>
253 <svg version=\"1.1\" width=\"16\" height=\"16\">
254   <g transform=\"translate(-242.81601,-315.59635)\">
255     <path
256        d=\"m 290.25762,334.31206 -17.64143,-11.77975 -19.70508,7.85447 5.75171,-20.41814 -13.55925,-16.31348 21.19618,-0.83936 11.325,-17.93675 7.34825,19.89939 20.55849,5.22795 -16.65471,13.13786 z\"
257        transform=\"matrix(0.2484147,-0.02623394,0.02623394,0.2484147,174.63605,255.37691)\"
258        style=\"fill:#d6d6d1;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1\" />
259   </g>
260 </svg>")
261
262 (defun notmuch-tag-tag-icon ()
263   "Return SVG data representing a tag icon.
264 This can be used with `notmuch-tag-format-image-data'."
265   "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?>
266 <svg version=\"1.1\" width=\"16\" height=\"16\">
267   <g transform=\"translate(0,-1036.3622)\">
268     <path
269        d=\"m 0.44642857,1040.9336 12.50000043,0 2.700893,3.6161 -2.700893,3.616 -12.50000043,0 z\"
270        style=\"fill:#ffff00;fill-opacity:1;fill-rule:evenodd;stroke:#000000;stroke-width:0.25;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-opacity:1\" />
271   </g>
272 </svg>")
273
274 (defvar notmuch-tag--format-cache (make-hash-table :test 'equal)
275   "Cache of tag format lookup.  Internal to `notmuch-tag-format-tag'.")
276
277 (defun notmuch-tag-clear-cache ()
278   "Clear the internal cache of tag formats."
279   (clrhash notmuch-tag--format-cache))
280
281 (defun notmuch-tag--get-formats (tag format-alist)
282   "Find the first item whose car regexp-matches TAG."
283   (save-match-data
284     ;; Don't use assoc-default since there's no way to distinguish a
285     ;; missing key from a present key with a null cdr.
286     (cl-assoc tag format-alist
287               :test (lambda (tag key)
288                       (and (eq (string-match key tag) 0)
289                            (= (match-end 0) (length tag)))))))
290
291 (defun notmuch-tag--do-format (tag formatted-tag formats)
292   "Apply a tag-formats entry to TAG."
293   (cond ((null formats)         ;; - Tag not in `formats',
294          formatted-tag)         ;;   the format is the tag itself.
295         ((null (cdr formats))   ;; - Tag was deliberately hidden,
296          nil)                   ;;   no format must be returned
297         (t
298          ;; Tag was found and has formats, we must apply all the
299          ;; formats.  TAG may be null so treat that as a special case.
300          (let ((bare-tag tag)
301                (tag (copy-sequence (or formatted-tag ""))))
302            (dolist (format (cdr formats))
303              (setq tag (eval format)))
304            (if (and (null formatted-tag) (equal tag ""))
305                nil
306              tag)))))
307
308 (defun notmuch-tag-format-tag (tags orig-tags tag)
309   "Format TAG according to `notmuch-tag-formats'.
310
311 TAGS and ORIG-TAGS are lists of the current tags and the original
312 tags; tags which have been deleted (i.e., are in ORIG-TAGS but
313 are not in TAGS) are shown using formats from
314 `notmuch-tag-deleted-formats'; tags which have been added (i.e.,
315 are in TAGS but are not in ORIG-TAGS) are shown using formats
316 from `notmuch-tag-added-formats' and tags which have not been
317 changed (the normal case) are shown using formats from
318 `notmuch-tag-formats'."
319   (let* ((tag-state (cond ((not (member tag tags)) 'deleted)
320                           ((not (member tag orig-tags)) 'added)))
321          (formatted-tag (gethash (cons tag tag-state)
322                                  notmuch-tag--format-cache
323                                  'missing)))
324     (when (eq formatted-tag 'missing)
325       (let ((base (notmuch-tag--get-formats tag notmuch-tag-formats))
326             (over (cl-case tag-state
327                     (deleted (notmuch-tag--get-formats
328                               tag notmuch-tag-deleted-formats))
329                     (added (notmuch-tag--get-formats
330                             tag notmuch-tag-added-formats))
331                     (otherwise nil))))
332         (setq formatted-tag (notmuch-tag--do-format tag tag base))
333         (setq formatted-tag (notmuch-tag--do-format tag formatted-tag over))
334         (puthash (cons tag tag-state) formatted-tag notmuch-tag--format-cache)))
335     formatted-tag))
336
337 (defun notmuch-tag-format-tags (tags orig-tags &optional face)
338   "Return a string representing formatted TAGS."
339   (let ((face (or face 'notmuch-tag-face))
340         (all-tags (sort (delete-dups (append tags orig-tags nil)) #'string<)))
341     (notmuch-apply-face
342      (mapconcat #'identity
343                 ;; nil indicated that the tag was deliberately hidden
344                 (delq nil (mapcar (apply-partially #'notmuch-tag-format-tag
345                                                    tags orig-tags)
346                                   all-tags))
347                 " ")
348      face
349      t)))
350
351 (defcustom notmuch-before-tag-hook nil
352   "Hooks that are run before tags of a message are modified.
353
354 'tag-changes' will contain the tags that are about to be added or removed as
355 a list of strings of the form \"+TAG\" or \"-TAG\".
356 'query' will be a string containing the search query that determines
357 the messages that are about to be tagged."
358   :type 'hook
359   :options '(notmuch-hl-line-mode)
360   :group 'notmuch-hooks)
361
362 (defcustom notmuch-after-tag-hook nil
363   "Hooks that are run after tags of a message are modified.
364
365 'tag-changes' will contain the tags that were added or removed as
366 a list of strings of the form \"+TAG\" or \"-TAG\".
367 'query' will be a string containing the search query that determines
368 the messages that were tagged."
369   :type 'hook
370   :options '(notmuch-hl-line-mode)
371   :group 'notmuch-hooks)
372
373 (defvar notmuch-select-tag-history nil
374   "Variable to store minibuffer history for
375 `notmuch-select-tag-with-completion' function.")
376
377 (defvar notmuch-read-tag-changes-history nil
378   "Variable to store minibuffer history for
379 `notmuch-read-tag-changes' function.")
380
381 (defun notmuch-tag-completions (&rest search-terms)
382   "Return a list of tags for messages matching SEARCH-TERMS.
383
384 Returns all tags if no search terms are given."
385   (unless search-terms
386     (setq search-terms (list "*")))
387   (split-string
388    (with-output-to-string
389      (with-current-buffer standard-output
390        (apply 'call-process notmuch-command nil t
391               nil "search" "--output=tags" "--exclude=false" search-terms)))
392    "\n+" t))
393
394 (defun notmuch-select-tag-with-completion (prompt &rest search-terms)
395   (let ((tag-list (apply #'notmuch-tag-completions search-terms)))
396     (completing-read prompt tag-list nil nil nil 'notmuch-select-tag-history)))
397
398 (defun notmuch-read-tag-changes (current-tags &optional prompt initial-input)
399   "Prompt for tag changes in the minibuffer.
400
401 CURRENT-TAGS is a list of tags that are present on the message or
402 messages to be changed.  These are offered as tag removal
403 completions.  CURRENT-TAGS may contain duplicates.  PROMPT, if
404 non-nil, is the query string to present in the minibuffer.  It
405 defaults to \"Tags\".  INITIAL-INPUT, if non-nil, will be the
406 initial input in the minibuffer."
407   (let* ((all-tag-list (notmuch-tag-completions))
408          (add-tag-list (mapcar (apply-partially 'concat "+") all-tag-list))
409          (remove-tag-list (mapcar (apply-partially 'concat "-") current-tags))
410          (tag-list (append add-tag-list remove-tag-list))
411          (prompt (concat (or prompt "Tags") " (+add -drop): "))
412          (crm-separator " ")
413          ;; By default, space is bound to "complete word" function.
414          ;; Re-bind it to insert a space instead.  Note that <tab>
415          ;; still does the completion.
416          (crm-local-completion-map
417           (let ((map (make-sparse-keymap)))
418             (set-keymap-parent map crm-local-completion-map)
419             (define-key map " " 'self-insert-command)
420             map)))
421     (delete "" (completing-read-multiple
422                 prompt
423                 ;; Append the separator to each completion so when the
424                 ;; user completes a tag they can immediately begin
425                 ;; entering another.  `completing-read-multiple'
426                 ;; ultimately splits the input on crm-separator, so we
427                 ;; don't need to strip this back off (we just need to
428                 ;; delete "empty" entries caused by trailing spaces).
429                 (mapcar (lambda (tag-op) (concat tag-op crm-separator)) tag-list)
430                 nil nil initial-input
431                 'notmuch-read-tag-changes-history))))
432
433 (defun notmuch-update-tags (tags tag-changes)
434   "Return a copy of TAGS with additions and removals from TAG-CHANGES.
435
436 TAG-CHANGES must be a list of tags names, each prefixed with
437 either a \"+\" to indicate the tag should be added to TAGS if not
438 present or a \"-\" to indicate that the tag should be removed
439 from TAGS if present."
440   (let ((result-tags (copy-sequence tags)))
441     (dolist (tag-change tag-changes)
442       (let ((op (string-to-char tag-change))
443             (tag (unless (string= tag-change "") (substring tag-change 1))))
444         (cl-case op
445           (?+ (unless (member tag result-tags)
446                 (push tag result-tags)))
447           (?- (setq result-tags (delete tag result-tags)))
448           (otherwise
449            (error "Changed tag must be of the form `+this_tag' or `-that_tag'")))))
450     (sort result-tags 'string<)))
451
452 (defconst notmuch-tag-argument-limit 1000
453   "Use batch tagging if the tagging query is longer than this.
454
455 This limits the length of arguments passed to the notmuch CLI to
456 avoid system argument length limits and performance problems.")
457
458 (defun notmuch-tag (query tag-changes)
459   "Add/remove tags in TAG-CHANGES to messages matching QUERY.
460
461 QUERY should be a string containing the search-terms.
462 TAG-CHANGES is a list of strings of the form \"+tag\" or
463 \"-tag\" to add or remove tags, respectively.
464
465 Note: Other code should always use this function to alter tags of
466 messages instead of running (notmuch-call-notmuch-process \"tag\" ..)
467 directly, so that hooks specified in notmuch-before-tag-hook and
468 notmuch-after-tag-hook will be run."
469   ;; Perform some validation
470   (mapc (lambda (tag-change)
471           (unless (string-match-p "^[-+]\\S-+$" tag-change)
472             (error "Tag must be of the form `+this_tag' or `-that_tag'")))
473         tag-changes)
474   (unless query
475     (error "Nothing to tag!"))
476   (unless (null tag-changes)
477     (run-hooks 'notmuch-before-tag-hook)
478     (if (<= (length query) notmuch-tag-argument-limit)
479         (apply 'notmuch-call-notmuch-process "tag"
480                (append tag-changes (list "--" query)))
481       ;; Use batch tag mode to avoid argument length limitations
482       (let ((batch-op (concat (mapconcat #'notmuch-hex-encode tag-changes " ")
483                               " -- " query)))
484         (notmuch-call-notmuch-process :stdin-string batch-op "tag" "--batch")))
485     (run-hooks 'notmuch-after-tag-hook)))
486
487 (defun notmuch-tag-change-list (tags &optional reverse)
488   "Convert TAGS into a list of tag changes.
489
490 Add a \"+\" prefix to any tag in TAGS list that doesn't already
491 begin with a \"+\" or a \"-\". If REVERSE is non-nil, replace all
492 \"+\" prefixes with \"-\" and vice versa in the result."
493   (mapcar (lambda (str)
494             (let ((s (if (string-match "^[+-]" str) str (concat "+" str))))
495               (if reverse
496                   (concat (if (= (string-to-char s) ?-) "+" "-")
497                           (substring s 1))
498                 s)))
499           tags))
500
501 (defvar notmuch-tag-jump-reverse-key "k"
502   "The key in tag-jump to switch to the reverse tag changes.")
503
504 (defun notmuch-tag-jump (reverse)
505   "Create a jump menu for tagging operations.
506
507 Creates and displays a jump menu for the tagging operations
508 specified in `notmuch-tagging-keys'. If REVERSE is set then it
509 offers a menu of the reverses of the operations specified in
510 `notmuch-tagging-keys'; i.e. each `+tag` is replaced by `-tag`
511 and vice versa."
512   ;; In principle this function is simple, but it has to deal with
513   ;; lots of cases: different modes (search/show/tree), whether a name
514   ;; is specified, whether the tagging operations is a list of
515   ;; tag-ops, or a symbol that evaluates to such a list, and whether
516   ;; REVERSE is specified.
517   (interactive "P")
518   (let (action-map)
519     (pcase-dolist (`(,key ,tag ,name) notmuch-tagging-keys)
520       (let* ((tag-function (cl-case major-mode
521                              (notmuch-search-mode #'notmuch-search-tag)
522                              (notmuch-show-mode #'notmuch-show-tag)
523                              (notmuch-tree-mode #'notmuch-tree-tag)))
524              (tag (if (symbolp tag)
525                       (symbol-value tag)
526                     tag))
527              (tag-change (if reverse
528                              (notmuch-tag-change-list tag 't)
529                            tag))
530              (name (or (and (not (string= name ""))
531                             name)
532                        (and (symbolp name)
533                             (symbol-name name))))
534              (name-string (if name
535                               (if reverse
536                                   (concat "Reverse " name)
537                                 name)
538                             (mapconcat #'identity tag-change " "))))
539         (push (list key name-string
540                     `(lambda () (,tag-function ',tag-change)))
541               action-map)))
542     (push (list notmuch-tag-jump-reverse-key
543                 (if reverse
544                     "Forward tag changes "
545                   "Reverse tag changes")
546                 (apply-partially 'notmuch-tag-jump (not reverse)))
547           action-map)
548     (setq action-map (nreverse action-map))
549     (notmuch-jump action-map "Tag: ")))
550
551 ;;
552
553 (provide 'notmuch-tag)