config

Personal configuration.
git clone git://code.dwrz.net/config
Log | Files | Refs

consult-notmuch.el (13847B)


      1 ;;; consult-notmuch.el --- Notmuch search using consult  -*- lexical-binding: t; -*-
      2 
      3 ;; Author: Jose A Ortega Ruiz <jao@gnu.org>
      4 ;; Maintainer: Jose A Ortega Ruiz
      5 ;; Keywords: mail
      6 ;; License: GPL-3.0-or-later
      7 ;; Version: 0.8.1
      8 ;; Package-Requires: ((emacs "26.1") (consult "0.9") (notmuch "0.31"))
      9 ;; Homepage: https://codeberg.org/jao/consult-notmuch
     10 
     11 
     12 ;; Copyright (C) 2021, 2022, 2024  Jose A Ortega Ruiz
     13 
     14 ;; This program is free software; you can redistribute it and/or modify
     15 ;; it under the terms of the GNU General Public License as published by
     16 ;; the Free Software Foundation, either version 3 of the License, or
     17 ;; (at your option) any later version.
     18 
     19 ;; This program is distributed in the hope that it will be useful,
     20 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
     21 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     22 ;; GNU General Public License for more details.
     23 
     24 ;; You should have received a copy of the GNU General Public License
     25 ;; along with this program.  If not, see <https://www.gnu.org/licenses/>.
     26 
     27 ;;; Commentary:
     28 
     29 ;; This package provides two commands using consult to query notmuch
     30 ;; emails and present results either as single emails
     31 ;; (`consult-notmuch') or full trees (`consult-notmuch-tree').
     32 ;;
     33 ;; The package also defines a narrowing source for `consult-buffer',
     34 ;; which can be activated with
     35 ;;
     36 ;;   (add-to-list 'consult-buffer-sources 'consult-notmuch-buffer-source)
     37 
     38 ;; This elisp file is automatically generated from its literate
     39 ;; counterpart at
     40 ;; https://codeberg.org/jao/consult-notmuch/src/branch/main/readme.org
     41 
     42 ;;; Code:
     43 
     44 (require 'consult)
     45 (require 'notmuch)
     46 
     47 (defgroup consult-notmuch nil
     48   "Options for `consult-notmuch'."
     49   :group 'consult)
     50 
     51 (defcustom consult-notmuch-show-single-message t
     52   "Show only the matching message or the whole thread in listings."
     53   :type 'boolean)
     54 
     55 (defcustom consult-notmuch-result-format
     56   '(("date" . "%12s  ")
     57     ("count" . "%-7s ")
     58     ("authors" . "%-20s")
     59     ("subject" . "  %-54s")
     60     ("tags" . " (%s)"))
     61   "Format for matching candidates in minibuffer.
     62 Supported fields are: date, authors, subject, count and tags."
     63   :type '(alist :key-type string :value-type string))
     64 
     65 (defcustom consult-notmuch-newest-first t
     66   "List messages newest first (defaults to oldest first)."
     67   :type 'boolean)
     68 
     69 
     70 (defun consult-notmuch--command (input)
     71   "Construct a search command for emails containing INPUT."
     72   (let ((sort (if consult-notmuch-newest-first
     73                   "--sort=newest-first"
     74                 "--sort=oldest-first")))
     75     (if consult-notmuch-show-single-message
     76         `(,notmuch-command "show" "--body=false" ,sort ,input)
     77       `(,notmuch-command "search" ,sort ,input))))
     78 
     79 (defun consult-notmuch--search (&optional initial)
     80   "Perform an asynchronous notmuch search via `consult--read'.
     81 If given, use INITIAL as the starting point of the query."
     82   (setq consult-notmuch--partial-parse nil)
     83   (consult--read (consult--async-command
     84                      #'consult-notmuch--command
     85                    (consult--async-filter #'identity)
     86                    (consult--async-map #'consult-notmuch--transformer))
     87                  :prompt "Notmuch search: "
     88                  :require-match t
     89                  :initial (consult--async-split-initial initial)
     90                  :history '(:input consult-notmuch-history)
     91                  :state #'consult-notmuch--preview
     92                  :lookup #'consult--lookup-member
     93                  :category 'notmuch-result
     94                  :sort nil))
     95 
     96 (defvar consult-notmuch-history nil
     97   "History for `consult-notmuch'.")
     98 
     99 (defun consult-notmuch--transformer (str)
    100   "Transform STR to notmuch display style."
    101   (if consult-notmuch-show-single-message
    102       (consult-notmuch--show-transformer str)
    103     (consult-notmuch--search-transformer str)))
    104 
    105 (defun consult-notmuch--format-field (spec msg)
    106   "Return a string for SPEC given the MSG metadata."
    107   (let ((field (car spec)))
    108     (cond ((equal field "count")
    109            (when-let (cnt (plist-get msg :count))
    110              (format (cdr spec) cnt)))
    111           ((equal field "tags")
    112            (when (plist-get msg :tags)
    113              (notmuch-tree-format-field "tags" (cdr spec) msg)))
    114           (t (notmuch-tree-format-field field (cdr spec) msg)))))
    115 
    116 (defun consult-notmuch--format-candidate (msg)
    117   "Format the result (MSG) of parsing a notmuch show information unit."
    118   (when-let (id (plist-get msg :id))
    119     (let ((result-string))
    120       (dolist (spec consult-notmuch-result-format)
    121         (when-let (field (consult-notmuch--format-field spec msg))
    122           (setq result-string (concat result-string field))))
    123       (propertize result-string 'id id 'tags (plist-get msg :tags)))))
    124 
    125 (defun consult-notmuch--candidate-id (candidate)
    126   "Recover the thread id for the given CANDIDATE string."
    127   (when candidate (get-text-property 0 'id candidate)))
    128 
    129 (defun consult-notmuch--candidate-tags (candidate)
    130   "Recover the message tags for the given CANDIDATE string."
    131   (when candidate (get-text-property 0 'tags candidate)))
    132 
    133 (defvar consult-notmuch--partial-parse nil
    134   "Internal variable for parsing status.")
    135 (defvar consult-notmuch--partial-headers nil
    136   "Internal variable for parsing status.")
    137 (defvar consult-notmuch--info nil
    138   "Internal variable for parsing status.")
    139 
    140 (defun consult-notmuch--set (k v)
    141   "Set the value V for property K in the message we're currently parsing."
    142   (setq consult-notmuch--partial-parse
    143         (plist-put consult-notmuch--partial-parse k v)))
    144 
    145 (defun consult-notmuch--show-transformer (str)
    146   "Parse output STR of notmuch show, extracting its components."
    147   (if (string-prefix-p "message}" str)
    148       (prog1
    149           (consult-notmuch--format-candidate
    150            (consult-notmuch--set :headers consult-notmuch--partial-headers))
    151         (setq consult-notmuch--partial-parse nil
    152               consult-notmuch--partial-headers nil
    153               consult-notmuch--info nil))
    154     (cond ((string-match "message{ \\(id:[^ ]+\\) .+" str)
    155            (consult-notmuch--set :id (match-string 1 str))
    156            (consult-notmuch--set :match t))
    157           ((string-prefix-p "header{" str)
    158            (setq consult-notmuch--info t))
    159           ((and str consult-notmuch--info)
    160            (when (string-match "\\(.+\\) (\\([^)]+\\)) (\\([^)]*\\))$" str)
    161              (consult-notmuch--set :Subject (match-string 1 str))
    162              (consult-notmuch--set :date_relative (match-string 2 str))
    163              (consult-notmuch--set :tags (split-string (match-string 3 str))))
    164            (setq consult-notmuch--info nil))
    165           ((string-match "\\(Subject\\|From\\|To\\|Cc\\|Date\\): \\(.+\\)?" str)
    166            (let ((k (intern (format ":%s" (match-string 1 str))))
    167                  (v (or (match-string 2 str) "")))
    168              (setq consult-notmuch--partial-headers
    169                    (plist-put consult-notmuch--partial-headers k v)))))
    170     nil))
    171 
    172 (defun consult-notmuch--search-transformer (str)
    173   "Transform STR from notmuch search to notmuch display style."
    174   (when (string-match "thread:" str)
    175     (let* ((id (car (split-string str "\\ +")))
    176            (date (substring str 24 37))
    177            (mid (substring str 24))
    178            (c0 (string-match "[[]" mid))
    179            (c1 (string-match "[]]" mid))
    180            (count (substring mid c0 (1+ c1)))
    181            (auths (string-trim (nth 1 (split-string mid "[];]"))))
    182            (subject (string-trim (nth 1 (split-string mid "[;]"))))
    183            (headers (list :Subject subject :From auths))
    184            (t0 (string-match "([^)]*)\\s-*$" mid))
    185            (tags (split-string (substring mid (1+  t0) -1)))
    186            (msg (list :id id
    187                       :match t
    188                       :headers headers
    189                       :count count
    190                       :date_relative date
    191                       :tags tags)))
    192       (consult-notmuch--format-candidate msg))))
    193 
    194 
    195 (defvar consult-notmuch--buffer-name "*consult-notmuch*"
    196   "Name of preview and result buffers.")
    197 
    198 (defun consult-notmuch--show-id (id buffer)
    199   "Show message or thread id in the requested buffer"
    200   (let ((notmuch-show-only-matching-messages
    201          consult-notmuch-show-single-message))
    202     (notmuch-show id nil nil nil buffer)))
    203 
    204 (defun consult-notmuch--preview (action candidate)
    205   "Preview CANDIDATE when ACTION is 'preview."
    206   (cond ((eq action 'preview)
    207          (when-let ((id (consult-notmuch--candidate-id candidate)))
    208            (when (get-buffer consult-notmuch--buffer-name)
    209              (kill-buffer consult-notmuch--buffer-name))
    210            (consult-notmuch--show-id id consult-notmuch--buffer-name)))
    211         ((eq action 'exit)
    212          (when (get-buffer consult-notmuch--buffer-name)
    213            (kill-buffer consult-notmuch--buffer-name)))))
    214 
    215 
    216 (defun consult-notmuch--show (candidate)
    217   "Open resulting CANDIDATE in ‘notmuch-show’ view."
    218   (when-let ((id (consult-notmuch--candidate-id candidate)))
    219     (let* ((subject (car (last (split-string candidate "\t"))))
    220            (title (concat consult-notmuch--buffer-name " " subject)))
    221       (consult-notmuch--show-id id title))))
    222 
    223 
    224 (defun consult-notmuch--tree (candidate)
    225   "Open resulting CANDIDATE in ‘notmuch-tree’."
    226   (when-let ((thread-id (consult-notmuch--candidate-id candidate)))
    227     (notmuch-tree thread-id nil nil)))
    228 
    229 
    230 ;; Embark Integration:
    231 (with-eval-after-load 'embark
    232   (defvar consult-notmuch-map
    233     (let ((map (make-sparse-keymap)))
    234       (define-key map (kbd "+") 'consult-notmuch-tag)
    235       (define-key map (kbd "-") 'consult-notmuch-tag)
    236       map)
    237     "Keymap for actions on Notmuch entries.")
    238   
    239   (set-keymap-parent consult-notmuch-map embark-general-map)
    240   (add-to-list 'embark-keymap-alist '(notmuch-result . consult-notmuch-map))
    241   
    242   (defun consult-notmuch--address-to-multi-select (address)
    243     "Select more email addresses, in addition to the current selection"
    244     (consult-notmuch-address t address))
    245   
    246   (defvar consult-notmuch-address-map
    247     (let ((map (make-sparse-keymap)))
    248       (define-key map (kbd "c") #'consult-notmuch-address-compose)
    249       (define-key map (kbd "m") #'consult-notmuch--address-to-multi-select)
    250       map))
    251   
    252   (set-keymap-parent consult-notmuch-address-map embark-general-map)
    253   (add-to-list 'embark-keymap-alist
    254                '(notmuch-address . consult-notmuch-address-map))
    255   
    256   (defun consult-notmuch-tag (msg)
    257     (when-let* ((id (consult-notmuch--candidate-id msg))
    258                 (tags (consult-notmuch--candidate-tags msg))
    259                 (tag-changes (notmuch-read-tag-changes tags "Tags: " "+")))
    260       (notmuch-tag (concat "(" id ")") tag-changes)))
    261   
    262   (defvar consult-notmuch-export-function #'notmuch-search
    263     "Function used to ask notmuch to display a list of found ids.
    264   Typical options are notmuch-search and notmuch-tree.")
    265   
    266   (defun consult-notmuch-export (msgs)
    267     "Create a notmuch search buffer listing messages."
    268     (funcall consult-notmuch-export-function
    269      (concat "(" (mapconcat #'consult-notmuch--candidate-id msgs " ") ")")))
    270   (add-to-list 'embark-exporters-alist
    271                '(notmuch-result . consult-notmuch-export)))
    272 
    273 ;;;###autoload
    274 (defun consult-notmuch (&optional initial)
    275   "Search for your email in notmuch, showing single messages.
    276 If given, use INITIAL as the starting point of the query."
    277   (interactive)
    278   (consult-notmuch--show (consult-notmuch--search initial)))
    279 
    280 ;;;###autoload
    281 (defun consult-notmuch-tree (&optional initial)
    282   "Search for your email in notmuch, showing full candidate tree.
    283 If given, use INITIAL as the starting point of the query."
    284   (interactive)
    285   (consult-notmuch--tree (consult-notmuch--search initial)))
    286 
    287 (defun consult-notmuch--address-command (input)
    288   "Spec for an async command querying a notmuch address with INPUT."
    289   `(,notmuch-command "address" "--format=text" ,input))
    290 
    291 (defun consult-notmuch-address-compose (address)
    292   "Compose an email to a given ADDRESS."
    293   (let ((other-headers (and notmuch-always-prompt-for-sender
    294                             `((From . ,(notmuch-mua-prompt-for-sender))))))
    295     (notmuch-mua-mail address
    296                       nil
    297                       other-headers
    298                       nil
    299                       (notmuch-mua-get-switch-function))))
    300 
    301 (defun consult-notmuch--address-prompt ()
    302   (consult--read (consult--async-command #'consult-notmuch--address-command)
    303                  :prompt "Notmuch addresses: "
    304                  :sort nil
    305                  :category 'notmuch-address))
    306 
    307 ;;;###autoload
    308 (defun consult-notmuch-address (&optional multi-select-p initial-addr)
    309   "Search the notmuch db for an email address and compose mail to it.
    310 With a prefix argument, prompt multiple times until there
    311 is an empty input."
    312   (interactive "P")
    313   (if multi-select-p
    314       (cl-loop for addr = (consult-notmuch--address-prompt)
    315                until (eql (length addr) 0)
    316                collect addr into addrs
    317                finally (consult-notmuch-address-compose
    318                         (mapconcat #'identity
    319                                    (if initial-addr
    320                                        (cons initial-addr addrs)
    321                                      addrs)
    322                                    ", ")))
    323     (consult-notmuch-address-compose (consult-notmuch--address-prompt))))
    324 
    325 
    326 (defun consult-notmuch--interesting-buffers ()
    327   "Return a list of names of buffers with interesting notmuch data."
    328   (consult--buffer-query
    329    :as (lambda (buf)
    330          (when (notmuch-interesting-buffer buf)
    331            (buffer-name buf)))))
    332 
    333 ;;;###autoload
    334 (defvar consult-notmuch-buffer-source
    335   '(:name "Notmuch Buffer"
    336     :narrow (?n . "Notmuch")
    337     :hidden t
    338     :category buffer
    339     :face consult-buffer
    340     :history buffer-name-history
    341     :state consult--buffer-state
    342     :items consult-notmuch--interesting-buffers)
    343   "Notmuch buffer candidate source for `consult-buffer'.")
    344 
    345 (provide 'consult-notmuch)
    346 ;;; consult-notmuch.el ends here