diff options
-rw-r--r-- | .emacs.d/init.el | 772 | ||||
-rwxr-xr-x | .fmail/.notmuch/hooks/post-new | 5 | ||||
-rwxr-xr-x | .fmail/.notmuch/hooks/pre-new | 5 | ||||
-rw-r--r-- | .mbsyncrc | 4 | ||||
-rw-r--r-- | .mrconfig.in | 22 | ||||
-rw-r--r-- | .notmuch-config | 14 | ||||
-rwxr-xr-x | bin/movemymail | 30 | ||||
-rwxr-xr-x | bin/nmbug | 852 | ||||
-rwxr-xr-x | bin/xdg-open | 3 |
9 files changed, 136 insertions, 1571 deletions
diff --git a/.emacs.d/init.el b/.emacs.d/init.el index 73f2efca..a35979f8 100644 --- a/.emacs.d/init.el +++ b/.emacs.d/init.el @@ -230,14 +230,22 @@ windows side-by-side in the frame." '(magit-define-global-key-bindings nil) '(magit-diff-refine-hunk 'all) '(magit-save-repository-buffers nil) - '(mail-user-agent 'notmuch-user-agent) + '(mail-envelope-from 'header nil nil "Bypass MTA rewriting user@localhost.") + '(mail-specify-envelope-from t nil nil "Bypass MTA rewriting user@localhost.") + '(mail-user-agent 'gnus-user-agent) '(mailscripts-detach-head-from-existing-branch 'ask) '(mailscripts-extract-patches-branch-prefix "mail/") '(mailscripts-project-library 'project) '(make-pointer-invisible t nil nil "Works only for self-insert chars and undone by changes in window manager focus, but less annoying than `mouse-avoidance-mode'.") - '(message-auto-save-directory nil nil nil "Don't have Message, Gnus and notmuch all saving drafts.") + '(message-auto-save-directory "~/tmp/" nil nil "So locmaint will catch them.") '(message-citation-line-format "On %a %d %b %Y at %I:%M%p %Z, %N wrote:\12") '(message-citation-line-function 'message-insert-formatted-citation-line) + '(message-forward-as-mime nil nil nil "For compatibility.") + '(message-forward-before-signature nil nil nil "For compatibility.") + '(message-forward-included-headers + '("^From:" "^Subject:" "^Date:" "^To:" "^Cc:" "^Message-ID:") nil nil "For compatibility.") + '(message-make-forward-subject-function '(message-forward-subject-fwd) nil nil "For compatibility.") + '(message-sendmail-envelope-from 'header nil nil "Bypass MTA rewriting user@localhost.") '(message-templ-alist '(("default" ("From" . "Sean Whitton <spwhitton@spwhitton.name>")) @@ -254,15 +262,6 @@ windows side-by-side in the frame." '(native-comp-async-report-warnings-errors 'silent) '(network-security-level 'high) '(notmuch-address-use-company nil) - '(notmuch-archive-tags '("-unread")) - '(notmuch-fcc-dirs "sent -unread") - '(notmuch-mua-cite-function 'message-cite-original-without-signature) - '(notmuch-mua-user-agent-function - '(lambda nil - (format "Notmuch/%s Emacs/%s (%s)" notmuch-emacs-version emacs-version system-configuration)) nil nil "Drop notmuch homepage URI to reduce length.") - '(notmuch-show-all-tags-list t) - '(notmuch-show-insert-text/plain-hook - '(notmuch-wash-convert-inline-patch-to-part notmuch-wash-wrap-long-lines notmuch-wash-tidy-citations notmuch-wash-elide-blank-lines notmuch-wash-excerpt-citations)) '(nov-text-width 78) '(org-adapt-indentation t nil nil "Sometimes set to nil in .dir-locals.el, e.g. in ~/doc/newpapers.") '(org-agenda-entry-text-maxlines 3) @@ -1149,6 +1148,8 @@ to open them using `spw/try-external-open'") (define-key icomplete-fido-mode-map [?\M-.] #'icomplete-forward-completions) (define-key icomplete-fido-mode-map [?\M-,] #'icomplete-backward-completions) +(setq skeleton-end-newline nil) + ;;;; Buffers and windows @@ -2114,31 +2115,29 @@ Used in my `message-mode' yasnippets." full-name)) "")) -;;; With these skeletons, and taking advantage of how `notmuch-message-mode' -;;; leaves point and mark around the quoted text, can immediately use C-x C-d -;;; to kill it all if no need to quote, or C-x C-x to hop back to the top for -;;; using M-RET to interleave responses. - (spw/define-skeleton spw/message-dear - (notmuch-message-mode :abbrev "dear" :file 'notmuch-mua) + (message-mode :abbrev "dear" :file "message") "" (completing-read "Dear " (ignore-errors (list (spw/recipient-first-name)))) - "Dear " str "," \n ?\n - (when (> (mark) (point)) (exchange-point-and-mark) '\n) - "\n") + '(when (setq v1 (looking-at ">")) (forward-line -2)) + "Dear " str "," \n \n + '(when v1 (forward-line 2))) (spw/define-skeleton spw/message-hello - (notmuch-message-mode :abbrev "hl" :file 'notmuch-mua) + (message-mode :abbrev "hl" :file "message") "" (completing-read "Hello " (ignore-errors (list (spw/recipient-first-name)))) - "Hello " str "," \n ?\n - (when (> (mark) (point)) (exchange-point-and-mark) '\n) - "\n") + '(when (setq v1 (looking-at ">")) (forward-line -2)) + "Hello " str '(when (zerop (length str)) (delete-backward-char 1)) "," \n \n + '(when v1 (forward-line 2))) (spw/define-skeleton spw/message-thanks - (notmuch-message-mode :abbrev "ty" :file 'notmuch-mua) + (message-mode :abbrev "ty" :file "message") "" (completing-read "Dear " (ignore-errors (list (spw/recipient-first-name)))) + '(when (setq v1 (looking-at ">")) (forward-line -2)) "Dear " str "," \n \n "Thank you for your e-mail." \n \n - '(exchange-point-and-mark) \n - "\n") + '(when v1 (forward-line 2))) (defun spw/copy-to-annotated () (interactive) @@ -2170,12 +2169,6 @@ Used in my `message-mode' yasnippets." (message-goto-body) (insert type ": " package "\n" "Version: " version "\n"))) -;; this one doesn't need a binding as it doesn't come up enough -(defun spw/notmuch-decrypt-inline () - (interactive) - (call-interactively #'mark-whole-buffer) - (call-interactively #'epa-decrypt-armor-in-region)) - ;; if we're going to be using multiple frames, make `frame-title-format' not ;; depend on whether there are multiple frames right now (add-function :after after-focus-change-function #'spw/set-frame-title-format) @@ -2539,6 +2532,12 @@ mutt's review view, after exiting EDITOR." (message-kill-to-signature) (spw/normalise-message)) +(defun spw/message-send-and-exit () + (interactive) + (when (or spw/message-normalised + (y-or-n-p "Send message which has not been auto-formatted?")) + (call-interactively #'message-send-and-exit))) + (defun spw/message-maybe-sign () ;; no PGP signing on athena (unless (spw/on-host-p "athena.silentflame.com") @@ -2589,19 +2588,6 @@ mutt's review view, after exiting EDITOR." (while (looking-at re) (delete-region (point) (1+ (line-end-position)))))))) -;; Use this to mark sent mail as containing unresolved comments., e.g. when -;; responding to a patch posting. Remove the flag from the message when the -;; next version of the patch series is seen to resolve the review comments. -;; -;; Don't use this for review comments where I'll notice, without effort, -;; that the revised series does not address the comments. E.g. don't flag a -;; review comment only objecting to a clone-and-hack of a function: I'll -;; notice the clone-and-hack if it still remains in the revised series, so -;; no need to go back and look at that review comment on the previous series -(defun spw/message-fcc-flag () - (interactive) - (save-excursion (message-goto-fcc) (insert " +spw::unresolved"))) - (with-eval-after-load 'message (spw/when-library-available message-templ (define-key message-mode-map [f7] #'spw/unfinalise-message) @@ -2621,6 +2607,10 @@ mutt's review view, after exiting EDITOR." (add-to-list 'auto-mode-alist '("/mutt-.+$" . message-mode)) (add-hook 'message-mode-hook #'spw/mutt-mail-header-separator) + ;; This relies on user.primary_email, user.other_email notmuch config keys. + (spw/when-library-available notmuch-address + (require 'notmuch-address) (notmuch-address-setup)) + (add-hook 'message-mode-hook #'footnote-mode) ;; this is for the benefit of mutt @@ -2629,7 +2619,8 @@ mutt's review view, after exiting EDITOR." (define-key message-mode-map [remap message-newline-and-reformat] #'spw/message-newline-and-reformat) - (define-key message-mode-map "\C-ciu" #'spw/message-fcc-flag)) + (define-key message-mode-map + [remap message-send-and-exit] #'spw/message-send-and-exit)) ;;;; Dired @@ -2687,555 +2678,6 @@ mutt's review view, after exiting EDITOR." #'spw/bookmark-eww-bookmark-make-record))) -;;;; The Notmuch e-mail system's Emacs interface - -(setq notmuch-tagging-keys '(("u" ("+unread") "Mark as unread") - ("s" ("-unread" "+spam") "Mark as spam") - - ;; 'm' for 'mute' - ("m" ("-unread" "+spw::killed") "Kill thread") - - ;; for work mail sent to a personal - ;; address, or similar - ("w" ("+spw::work") "Mark as work-related") - - ("b" ("+spw::browse") "Mark for browsing") - ("d" ("-unread" "+deleted") "Send to trash") - ("f" ("-unread" "+flagged") "Unread->flagged") - ("F" ("-flagged") "Unflag message")) - - ;; default is t, but given that notmuch searches run to the - ;; beginning of time, and we are likely to want recent mail, we want - ;; newer e-mails at the top - notmuch-search-oldest-first nil - - ;; Don't collapse cited text. We ought to be able to just remove - ;; `notmuch-wash-excerpt-citations' from - ;; `notmuch-show-insert-text/plain-hook', but that function is also - ;; responsible for colouring cited text (this is an upstream bug: - ;; that function does the colouring for performance reasons but the - ;; right answer is to use fontlocking, not overlays, for the - ;; colouring) - notmuch-wash-citation-lines-prefix 10000 - notmuch-wash-citation-lines-suffix 10000 - - ;; have Emacs set envelope-from to bypass my MTA rewriting of - ;; user@localhost - mail-specify-envelope-from t - mail-envelope-from 'header - message-sendmail-envelope-from 'header - - ;; when 'unread' is being used as an inbox, want manual resolution - ;; of messages - notmuch-show-mark-read-function 'ignore - notmuch-show-mark-read-tags nil - ;; but always resolve when I write a reply - notmuch-message-replied-tags '("-unread" "+replied") - - ;; for compatibility - message-forward-before-signature nil - message-forward-as-mime nil - message-forward-included-headers - '("^From:" "^Subject:" "^Date:" "^To:" "^Cc:" "^Message-ID:") - message-make-forward-subject-function #'message-forward-subject-fwd) - -(spw/when-library-available notmuch - ;; Ensure `notmuch-user-agent' is loaded, `notmuch-saved-searches' is - ;; populated etc. when I invoke certain commands soon after starting Emacs. - (defun spw/require-notmuch (&rest ignore) (require 'notmuch)) - (dolist (cmd '(compose-mail notmuch-jump-search notmuch-hello - compose-mail-other-window compose-mail-other-frame)) - (advice-add cmd :before #'spw/require-notmuch))) - -(advice-add #'notmuch-bury-or-kill-this-buffer :override #'bury-buffer) - -;; An alternative would be just to bind `notmuch-hello' to C-c m, as s, j -;; and <f9> have appropriate bindings in `notmuch-hello-mode' such that the -;; following complete sequences would still call their associated commands. -(global-set-key "\C-cms" #'notmuch-search) -(global-set-key "\C-cmj" #'notmuch-jump-search) -(global-set-key "\C-cmm" #'notmuch-hello) -(global-set-key [?\C-c ?m f9] #'spw/next-unread-group) - -(with-eval-after-load 'notmuch - (require 'notmuch) (require 'notmuch-hello) (require 'notmuch-message) - - (advice-add 'notmuch-tree-archive-thread :after #'notmuch-tree-next-thread) - - (define-key notmuch-message-mode-map "\C-c\C-s" #'message-goto-subject) - - (define-key notmuch-show-mode-map "\C-cg.g" #'spw/notmuch-import-gpg) - (define-key notmuch-show-mode-map "\C-cg.a" - #'spw/notmuch-show-apply-part-to-project) - - ;; we want these not to be adjacent keys - (define-key notmuch-search-mode-map [f5] #'spw/spam-message) - (define-key notmuch-search-mode-map "S" #'spw/spam-message) - (define-key notmuch-search-mode-map [f7] #'spw/kill-thread) - (define-key notmuch-search-mode-map "\M-k" #'spw/kill-thread) - (define-key notmuch-search-mode-map "," #'spw/maybe-kill-thread) - (define-key notmuch-search-mode-map [f9] #'spw/next-unread-group) - - ;; ditto - (define-key notmuch-show-mode-map [f5] #'spw/spam-message) - (define-key notmuch-show-mode-map "S" #'spw/spam-message) - (define-key notmuch-show-mode-map [f7] #'spw/kill-thread) - (define-key notmuch-show-mode-map "\M-k" #'spw/kill-thread) - (define-key notmuch-show-mode-map "," #'spw/maybe-kill-thread) - - ;; ditto - (define-key notmuch-tree-mode-map [f5] #'spw/spam-message) - (define-key notmuch-tree-mode-map "S" #'spw/spam-message) - (define-key notmuch-tree-mode-map [f7] #'spw/kill-thread) - (define-key notmuch-tree-mode-map "\M-k" #'spw/kill-thread) - (define-key notmuch-tree-mode-map [f9] #'spw/next-unread-group) - - (define-key notmuch-hello-mode-map [f9] #'spw/next-unread-group) - - (define-key notmuch-tree-mode-map "\C-cgo" #'spw/notmuch-reader) - (define-key notmuch-tree-mode-map "\C-z\C-c" #'spw/notmuch-catchup) - - (define-key notmuch-show-mode-map "\C-cgo" #'spw/notmuch-reader) - (define-key notmuch-search-mode-map "\C-z\C-c" #'spw/notmuch-catchup) - - (define-key notmuch-show-mode-map " " - #'spw/notmuch-show-advance-and-archive) - - (define-key notmuch-message-mode-map [remap message-send-and-exit] - #'spw/notmuch-mua-send-and-exit) - - (define-key notmuch-show-mode-map "\C-cgf" - #'spw/notmuch-show-filter-thread-patches) - (define-key notmuch-show-mode-map "\C-cgi" - #'spw/notmuch-show-with-remote-images) - - (add-hook 'notmuch-show-mode-hook #'variable-pitch-mode) - - (unless spw/lists-browse-searches (spw/standard-notmuch-saved-searches))) - -(defun spw/notmuch-import-gpg () - (interactive) - (when (get-buffer "*notmuch-pipe*") - (with-current-buffer "*notmuch-pipe*" - (let ((buffer-read-only nil)) - (erase-buffer)))) - (notmuch-show-pipe-message t "gpg --decrypt | gpg --import") - (display-buffer "*notmuch-pipe*")) - -;; unlike `notmuch-extract-thread-patches' and -;; `notmuch-extract-message-patches', it does not make sense to -;; check out a branch when performing an action which will not make -;; a commit. If that's wanted, the code which calls -;; `spw/notmuch-show-apply-part-to-project' should perform the checkout -(defun spw/notmuch-show-apply-part-to-project () - (interactive) - (let ((default-directory (expand-file-name (project-prompt-project-dir)))) - (notmuch-show-apply-to-current-part-handle - (lambda (handle) - (mm-pipe-part handle "git apply"))))) - -(defun spw/kill-thread () - (interactive) - (cl-case major-mode - (notmuch-show-mode - (notmuch-show-tag '("+spw::killed")) - (notmuch-show-archive-thread-then-next)) - (notmuch-tree-mode - (notmuch-tree-close-message-window) - (notmuch-tree-tag '("+spw::killed")) - (notmuch-tree-archive-thread) - (unless (notmuch-tree-get-match) - (notmuch-tree-next-matching-message)) - (notmuch-tree-show-message nil)) - (notmuch-search-mode - ;; here we want to avoid tagging every message in the thread to reduce - ;; pressure on nmbug-spw.git -- so we just pick the first of the matched - ;; messages - (notmuch-tag - (car (split-string (car (plist-get (notmuch-search-get-result) :query)))) - '("+spw::killed")) - (notmuch-search-archive-thread))) - (message "Thread killed")) - -(defun spw/spam-message () - (interactive) - (cl-case major-mode - (notmuch-show-mode - (notmuch-show-tag '("-unread" "+spam")) - (notmuch-show-archive-message-then-next-or-next-thread)) - (notmuch-tree-mode - (notmuch-tree-tag '("-unread" "+spam")) - (notmuch-tree-next-matching-message))) - (message "Message marked as spam")) - -(defun spw/notmuch-reader () - (interactive) - (with-current-buffer (or notmuch-tree-message-buffer (current-buffer)) - (save-excursion - (cond - ((re-search-forward - "https://www.wsj.com/.*-WSJNewsPaper-[0-9-]+\\.pdf" nil t) - (call-process-shell-command - (format "evince %s" - (shell-quote-argument - (buffer-substring-no-properties (match-beginning 0) (point)))) - nil 0)) - (t - (re-search-forward "^URL:\\( \\|\n\\)") - (let ((url (buffer-substring-no-properties (point) (line-end-position)))) - ;; alternative to eww readable view: - ;; (start-process "firefox" nil "firefox" - ;; "-new-window" - ;; (concat "about:reader?url=" url)) - (spw/add-once-hook 'eww-after-render-hook #'eww-readable) - (eww url))))))) - -(defun spw/notmuch-show-stable-matching-query () - (let (ids) - (notmuch-show-mapc - (lambda () - (let ((props (notmuch-show-get-message-properties))) - (when (plist-get props :match) - (push (concat "id:" (plist-get props :id)) ids))))) - (string-join ids " "))) - -(defun spw/notmuch-connective (word) - (let ((sep (format " %s " word)) - (f (apply-partially #'format "(%s)"))) - (lambda (&rest queries) - (mapconcat f (flatten-tree queries) sep)))) - -(defalias 'spw/nm| (spw/notmuch-connective "or")) -(defalias 'spw/nm& (spw/notmuch-connective "and")) -(defalias 'spw/nm~ (apply-partially #'format "not (%s)")) -(defalias 'spw/th{ (apply-partially #'format "thread:{%s}")) - -(defvar spw/non-readall nil - "Mail addressed directly to me that is to be processed as though -it were instead addressed to a mailing list.") - -(defvar spw/lists-readall nil - "Lists where I want to read all posts as though they're addressed -directly to me. These get inserted into my main inbox view.") - -(defvar spw/lists-browse nil - "Lists I want to read like newsgroups, though with no expiry -and manual catchup. Use `spw/next-unread-group' to read new postings.") - -(defvar spw/lists-archiveonly nil - "Lists to which I'm subscribed only because I want to archive all -postings. notmuch post-new hook should mark as read.") - -(defvar spw/weekday-only-mail - (spw/nm| "to:spwhitton@email.arizona.edu" "from:arizona.edu" - (spw/th{ "tag:spw::work")) - "Mail to be filtered out of processing views at the weekend.") - -(defvar spw/readall nil) -(defvar spw/lists-browse-searches nil) -(defun spw/standard-notmuch-saved-searches () - (interactive) - (setq notmuch-saved-searches nil spw/lists-browse-searches nil) - (when (file-exists-p (locate-user-emacs-file "notmuch-private.el")) - (load (locate-user-emacs-file "notmuch-private")) - (cl-loop for group in spw/lists-browse - for name = (if (atom group) - ;; Assume we got a List: search and extract the - ;; first component of the List-Id to use as the - ;; name of the search. - (if (string-match ":\\([^.]+\\)\\." group) - (match-string 1 group) - (error "Could not extract a list name")) - (plist-get group :name)) - for query - = (if (atom group) group (spw/nm| (plist-get group :queries))) - for usearch = `(:name ,(concat name " unread") - :search-type nil :sort-order newest-first - :query ,(spw/nm& "tag:unread" query)) - - ;; We used to add the search without tag:unread with the idea of - ;; accessing from `notmuch-hello' and then using - ;; `notmuch-search-filter' to find something in particular. But - ;; I just do toplevel searches. - ;; collect `(:name ,name :search-type nil :sort-order newest-first - ;; :query ,query :key ,(plist-get group :key)) - ;; into searches - - ;; Add tag:unread search as a saved search so buffers created by - ;; `spw/next-unread-group' get a reasonable name. - collect usearch into searches - collect (list :search usearch - :catchup-method (plist-get group :catchup-method)) - into browse-searches - finally (setq notmuch-saved-searches searches - spw/lists-browse-searches browse-searches))) - (setq spw/readall (spw/nm& (spw/nm~ (spw/nm| spw/non-readall)) - (spw/nm~ (spw/th{ "tag:spw::browse")) - (spw/nm| "query:inbox" spw/lists-readall))) - (let* ((to-process (spw/nm& "tag:unread" spw/readall)) - (feeds (spw/nm| "from:rss2email@athena.silentflame.com" - "from:gmi2email@athena.silentflame.com")) - (categorised - (spw/nm| spw/readall spw/lists-archiveonly - (cl-loop for search in spw/lists-browse - if (atom search) collect search - else collect (plist-get search :queries)))) - - ;; Content not from mailing lists and not otherwise categorised -- - ;; previously such items would fall into "uncategorised unread" but - ;; that's wrong because I've explicitly subscribed to each of these. - (uncategorised-feeds - (spw/nm& "tag:unread" feeds (spw/nm~ categorised))) - - ;; Groups/lists where I don't know how or whether I want to follow - ;; them; I may have subscribed just to post something. - (uncategorised-other - (spw/nm& "tag:unread" (spw/nm~ feeds) (spw/nm~ categorised)))) - - (push `(:search (:name "Uncategorised feeds" :query ,uncategorised-feeds) - :catchup-method :archive) - spw/lists-browse-searches) - (rplacd (last spw/lists-browse-searches) - `((:search - (:name "uncategorised unread" :query ,uncategorised-other)))) - - ;; Prepend inbox views for processing the day's mail addressed to me. - (setq notmuch-saved-searches - (cl-list* - `(:name "Weekday unread" :key "u" :search-type nil - :sort-order oldest-first :query ,to-process) - `(:name "Weekend unread" :key "w" :search-type nil - :sort-order oldest-first - :query ,(spw/nm& to-process - (spw/nm~ spw/weekday-only-mail))) - `(:name "Uncategorised feeds" :key "r" :search-type nil - :sort-order newest-first :query ,uncategorised-feeds) - notmuch-saved-searches)) - - ;; Append some miscellaneous views. - (rplacd (last notmuch-saved-searches) - `((:name "Flagged" :key "f" :search-type tree :query "tag:flagged") - (:name "Sent" :key "s" :search-type nil - :sort-order newest-first - :query ,(spw/nm| - (mapcar (apply-partially #'concat "from:") - (notmuch-user-emails)))) - (:name "Drafts" :key "d" :search-type nil - :sort-order newest-first :query "tag:draft") - (:name "Imported series" :key "p" :search-type nil - :sort-order newest-first - :query "subject:\"/PATCH .+ imported/\"") - (:name "Phone notes" :key "n" :search-type nil - :sort-order newest-first :query "folder:notes") - (:name "Uncategorised unread" :key "U" :search-type nil - :sort-order newest-first :query ,uncategorised-other))))) - -(defun spw/notmuch-catchup-by-archive () - (interactive) - (when (and (memq major-mode '(notmuch-tree-mode notmuch-search-mode)) - (y-or-n-p "Are you sure you want to mark all as read?") - spw/readall) - (let ((query (if (eq major-mode 'notmuch-tree-mode) - (notmuch-tree-get-query) - (notmuch-search-get-query)))) - (notmuch-tag (spw/nm& query (spw/nm~ spw/readall)) '("-unread"))) - (spw/next-unread-group))) - -(defun spw/maybe-kill-thread (&optional resolve) - (interactive "p") - (unless (bound-and-true-p spw/readall) - (error "`spw/readall' not defined; unsafe to proceed")) - (let* ((thread-id - (cl-ecase major-mode - (notmuch-search-mode - (concat "thread:" - (plist-get (notmuch-search-get-result) :thread))) - (notmuch-show-mode notmuch-show-thread-id))) - (message-ids - (cl-ecase major-mode - (notmuch-search-mode - (car (notmuch-search-find-stable-query))) - (notmuch-show-mode (spw/notmuch-show-stable-matching-query)))) - (method-buffer (or notmuch-show-parent-buffer (current-buffer))) - (catchup-method (and (buffer-local-boundp - 'spw/notmuch-catchup-method method-buffer) - (buffer-local-value - 'spw/notmuch-catchup-method method-buffer))) - (killp (not (eq :archive catchup-method)))) - ;; If any messages match `spw/readall' then for safety user must call - ;; `spw/kill-thread', which has a harder-to-press binding. - (unless (zerop - (string-to-number - (notmuch-saved-search-count (spw/nm& thread-id spw/readall)))) - (user-error "Some messages in thread match `spw/readall'")) - ;; Catchup only the messages that were matched by the saved search. - (notmuch-tag message-ids '("-unread")) - ;; Kill unless we are in / came from a search in which we catchup by - ;; marking all as read. This means we can call this function to work - ;; through groups with either catchup method. - ;; - ;; As in `spw/kill-thread' for `notmuch-search-mode', want to tag only a - ;; single message with spw::killed. - (when killp - (notmuch-tag (car (split-string message-ids)) '("+spw::killed"))) - (when resolve - (cl-case major-mode - (notmuch-search-mode - (let* ((result (notmuch-search-get-result)) - (tags (remove "unread" (plist-get result :tags)))) - (notmuch-search-update-result - (plist-put result - :tags (if killp (cons "spw::killed" tags) tags)))) - (notmuch-search-next-thread)) - (notmuch-show-mode (notmuch-show-next-thread t)))))) - -(defun spw/notmuch-catchup-by-killing () - (interactive) - (when (and (eq major-mode 'notmuch-search-mode) - (y-or-n-p "Are you sure you want to kill all threads?")) - (goto-char (point-min)) - (while (notmuch-search-get-result) - ;; Don't touch unless there are unread messages, so that we skip over - ;; threads which have been manually processed -- this is in case I - ;; just archived the thread without killing it, and want any new - ;; messages to show up as unread. - ;; - ;; We can't rely on (plist-get (notmuch-show-get-result) :tags) here - ;; because that might be out-of-date if the thread was archived from - ;; `notmuch-show-mode' rather than this buffer, and we can't refresh - ;; the buffer as we don't want to kill any newly-arrived threads - (unless - (zerop - (string-to-number - (notmuch-saved-search-count - (spw/nm& "tag:unread" (car (notmuch-search-find-stable-query)))))) - (ignore-error user-error (spw/maybe-kill-thread))) - (notmuch-search-next-thread)) - (spw/next-unread-group))) - -(defun spw/notmuch-show-advance-and-archive () - "Like `notmuch-show-advance-and-archive' but confirm thread archive. - -Note that this does not archive individual messages are you -scroll through them." - (interactive) - (when (or ;; since we have a confirmation, it's fine to archive when point - ;; it not yet at the bottom of the window - (pos-visible-in-window-p (point-max)) - (notmuch-show-advance)) - (if (or (pos-visible-in-window-p (point-min)) - (let ((map (make-sparse-keymap))) - (set-keymap-parent map query-replace-map) - (define-key map " " 'ignore) - ;; override usual map so SPC cannot confirm the archive, to - ;; avoid accidental archives - (let ((query-replace-map map)) - (y-or-n-p "Mark all as read before moving on?")))) - (when (and notmuch-show-thread-id notmuch-archive-tags) - ;; only tag messages which would have been expanded when we opened - ;; the thread - (notmuch-tag (spw/notmuch-show-stable-matching-query) - (notmuch-tag-change-list notmuch-archive-tags nil)) - (notmuch-show-next-thread t)) - (notmuch-show-next-thread-show)))) - -;; use on views produced by `spw/next-unread-group' -(defun spw/notmuch-catchup (arg) - (interactive "P") - (if (or arg (and (boundp 'spw/notmuch-catchup-method) - (eq :archive spw/notmuch-catchup-method))) - (spw/notmuch-catchup-by-archive) - (spw/notmuch-catchup-by-killing)) - (message "Group caught up")) - -(defun spw/next-unread-group () - (interactive) - (let ((already-looking (boundp 'spw/more-unread-groups)) - (queries (bound-and-true-p spw/more-unread-groups)) - (remaining)) - (when already-looking - (when (eq major-mode 'notmuch-tree-mode) - (notmuch-tree-close-message-window)) - (bury-buffer)) - (if (or (and already-looking (not queries)) - (not (setq remaining - (cl-loop with queries - = (or queries spw/lists-browse-searches) - if (and queries - (zerop - (string-to-number - (notmuch-saved-search-count - (plist-get - (plist-get (car queries) :search) - :query))))) - do (pop queries) else return queries)))) - (set-transient-map - (let ((map (make-sparse-keymap))) - (define-key map [f9] #'spw/next-unread-group) map)) - (let* ((search (plist-get (car remaining) :search)) - (name (plist-get search :name))) - ;; I think that a tree-style view is probably best for browsing - ;; groups, but atm notmuch-tree's use of windows is a bit inflexible, - ;; so use notmuch-search - ;; (notmuch-tree search nil nil - ;; (concat "*notmuch-tree-saved-search-" name "*")) - (notmuch-search (plist-get search :query) t) - ;; renaming the buffer seems to break refreshing it & reversing the - ;; sort order - ;; (rename-buffer - ;; (concat "*notmuch-saved-search-" - ;; (plist-get (plist-get (car remaining) :search) :name) "*") - ;; t) - (set (make-local-variable 'spw/more-unread-groups) (cdr remaining)) - (set (make-local-variable 'spw/notmuch-catchup-method) - (plist-get (car remaining) :catchup-method))) - (put 'spw/more-unread-groups 'permanent-local t) - (put 'spw/notmuch-catchup-method 'permanent-local t)))) - -(defun spw/notmuch-mua-send-and-exit () - (interactive) - (when (or spw/message-normalised - (y-or-n-p "Send message which has not been auto-formatted?")) - (call-interactively #'notmuch-mua-send-and-exit))) - -;; In a thread with patches, try to collapse messages not relevant for -;; reviewing those patches. Optional numeric prefix argument specifies the -;; version of the series to review, in case there is more than one series in -;; the thread. Include spw::unresolved mail, as these may contain unresolved -;; review comments on older versions of the series. -;; -;; In the case where you want to compare the new series against unresolved -;; review comments on the old series, and the series are in different threads, -;; open each thread in a separate buffer (probably in separate frames). Run -;; this command in the new series' buffer and hit `l tag:spw::unresolved RET' -;; in the old series' buffer -(defun spw/notmuch-show-filter-thread-patches (&optional reroll-count) - (interactive "P") - (let ((subject-filter - (if reroll-count - (let ((n (prefix-numeric-value reroll-count))) - (if (= n 1) - (concat "(" - "subject:/\\[.*PATCH[^v]*\\]/" - "or" - "subject:/\\[.*PATCH.*v1.*\\]/" - ")") - (concat "subject:/\\[.*PATCH.*v" - (number-to-string n) - ".*\\]/"))) - "subject:/\\[.*PATCH.*\\]/ "))) - (notmuch-show-filter-thread - (concat "tag:unread or tag:spw::unresolved or (" - subject-filter - " and not subject:'Re:' and not subject:'Info received')")))) - -(defun spw/notmuch-show-with-remote-images () - (interactive) - (setq-local notmuch-show-text/html-blocked-images nil - notmuch-multipart/alternative-discouraged '("text/plain")) - (notmuch-show-refresh-view)) - - ;;;; Gnus ;; If NNTPSERVER has been configured by the local administrator, accept Gnus' @@ -3304,6 +2746,16 @@ scroll through them." `((nnselect-specs . ,specs) (nnselect-artlist . nil)))))))) +(defun spw/notmuch-connective (word) + (let ((sep (format " %s " word)) + (f (apply-partially #'format "(%s)"))) + (lambda (&rest queries) + (mapconcat f (flatten-tree queries) sep)))) + +(defalias 'spw/nm| (spw/notmuch-connective "or")) +(defalias 'spw/nm& (spw/notmuch-connective "and")) +(defalias 'spw/nm~ (apply-partially #'format "not (%s)")) + (defun spw/gnus-group-nnselect-query (group) (when-let ((specs (gnus-group-get-parameter group 'nnselect-specs t))) (cdr (assq 'query @@ -3398,6 +2850,86 @@ scroll through them." (define-key gnus-summary-mode-map [remap gnus-summary-mark-as-read-forward] #'spw/gnus-summary-mark-as-read-forward)) +;; Unlike `notmuch-extract-thread-patches' and +;; `notmuch-extract-message-patches', it does not make sense to check out a +;; branch when performing an action which will not make a commit. If that's +;; wanted, the code which calls `spw/gnus-mime-apply-part' should perform the +;; checkout. +;; +;; Probably also want `spw/gnus-article-apply-part' for summary buffers. +(defun spw/gnus-mime-apply-part () + (interactive) + (let ((default-directory (expand-file-name (project-prompt-project-dir)))) + (gnus-mime-pipe-part "git apply"))) +(with-eval-after-load 'gnus-art + (define-key gnus-mime-button-map "a" #'spw/gnus-mime-apply-part)) + +;; There's an alternative to having a dedicated command for this described in +;; (info "(gnus) Security"), "Snarfing OpenPGP keys". +(defun spw/gnus-import-gpg () + (interactive) + (gnus-summary-save-in-pipe "gpg --decrypt | gpg --import" t) + (display-buffer "*Shell Command Output*")) +(with-eval-after-load 'gnus-sum + (define-key gnus-summary-mode-map "\C-zg" #'spw/gnus-import-gpg)) + +(defun spw/gnus-reader () + (interactive) + ;; Can't use `gnus-eval-in-buffer-window' because we want eww buffer to be + ;; left selected, if that's what we use. + (gnus-summary-select-article-buffer) + (save-excursion + (cond + ((re-search-forward + "https://www.wsj.com/.*-WSJNewsPaper-[0-9-]+\\.pdf" nil t) + (call-process-shell-command + (format "evince %s" + (shell-quote-argument + (buffer-substring-no-properties (match-beginning 0) (point)))) + nil 0) + ;; We used an external program, so switch back. + (gnus-article-show-summary)) + (t + (re-search-forward "^URL:\\( \\|\n\\)") + (let ((url (buffer-substring-no-properties (point) (line-end-position)))) + ;; alternative to eww readable view: + ;; (start-process "firefox" nil "firefox" + ;; "-new-window" + ;; (concat "about:reader?url=" url)) + ;; + ;; There is also Gnus's `A w' binding. + (spw/add-once-hook 'eww-after-render-hook #'eww-readable) + (eww url)))))) +(with-eval-after-load 'gnus-sum + (define-key gnus-summary-mode-map "\C-cgo" #'spw/gnus-reader)) + +;; In a group with patches, try to expunge messages not relevant for reviewing +;; those patches. Optional numeric prefix argument specifies the version of +;; the series to review, in case there is more than one series in the summary +;; buffer. Include ticked messages, as these may contain unresolved review +;; comments on older versions of the series. +;; +;; In the case where you want to compare the new series against review +;; comments on the old series, and the series are in different threads, use +;; C-c g m to open a distinct summary buffer for each thread, in two frames, +;; use this command in the buffer with the new series, and possibly use / m to +;; see only ticked articles in the old series' summary buffer. +(defun spw/gnus-summary-limit-to-patches (&optional reroll-count) + (interactive "P") + (gnus-summary-limit-to-subject + (if reroll-count + (cl-case (prefix-numeric-value reroll-count) + (1 "\\[.*PATCH\\(?:[^v]*\\|.*v1.*\\)\\]") + (t (format "\\[.*PATCH.*v%s.*\\]" + (prefix-numeric-value reroll-count)))) + "\\[.*PATCH.*\\]")) + (gnus-summary-limit-to-subject (regexp-opt '("Re:" "Info received")) nil t) + ;; Would be good also to reinsert all unread messages. + (gnus-summary-insert-ticked-articles)) +(with-eval-after-load 'gnus-sum + (define-key gnus-summary-mode-map + "\C-cgf" #'spw/gnus-summary-limit-to-patches)) + (defun spw/org-gnus-follow-link (orig-fun &optional group article) (if (not article) (apply orig-fun group nil) @@ -3504,11 +3036,6 @@ scroll through them." (spw/when-library-available nov (add-to-list 'auto-mode-alist '("\\.epub\\'" . nov-mode))) -(global-set-key "\C-cvt" #'notmuch-extract-thread-patches-to-project) -(global-set-key "\C-cvw" #'notmuch-extract-message-patches-to-project) -(global-set-key "\C-cgb" #'notmuch-slurp-debbug) -(global-set-key "\C-cgB" #'notmuch-slurp-this-debbug) - (setq ggtags-mode-line-project-name nil) (spw/when-library-available ggtags @@ -3915,11 +3442,10 @@ before uploading to NEW again." \n \n ("NOAGENDA" . ?A)) org-capture-templates-contexts - '(("t" "m" ((in-mode . "notmuch-show-mode"))) - ("t" ((not-in-mode . "notmuch-show-mode"))) - ("T" ((in-mode . "notmuch-show-mode"))) - ("m" ((in-mode . "notmuch-show-mode"))) - ("f" ((in-mode . "notmuch-show-mode")))) + '(("t" "m" ((in-mode . "gnus-summary-mode"))) + ("t" ((not-in-mode . "gnus-summary-mode"))) + ("T" ((in-mode . "gnus-summary-mode"))) + ("m" ((in-mode . "gnus-summary-mode")))) org-capture-templates '(("t" "Task to be refiled" entry (file org-default-notes-file) "* TODO %^{Title}\n%?") @@ -3932,20 +3458,7 @@ before uploading to NEW again." \n \n ;; the Org-mode link does not properly form. In Org 9.3, the escaping ;; syntax for links has changed, so might be able to do something smarter ;; than this - "* TODO [[notmuch:id:%:message-id][%^{Title|\"%(replace-regexp-in-string \"\\\\\\[\\\\\\|\\\\\\]\" \"\" \"%:subject\")\" from %:fromname}]]\n%?") - - ;; This will show a thread with only flagged messages expanded. - ;; - ;; The purpose of this is for cases where there are multiple actionable - ;; messages in a single thread, such that I want to view them all in a - ;; single buffer. I flag those, and create a link to the thread using - ;; this snippet. Creating Org links to individual messages would not - ;; achieve this. And having an 'inbox' tag which represents actionable - ;; but read messages would add overhead as I'd have to get used to - ;; removing that tag from messages, and sort out syncing the tag. The - ;; case comes up too rarely for it to be worth doing that. - ("f" "All flagged messages in current thread" entry (file org-default-notes-file) - "* TODO [[notmuch:thread:{id:%:message-id} and tag:flagged][%^{Title|Flagged messages in thread \"%(replace-regexp-in-string \"\\\\\\[\\\\\\|\\\\\\]\" \"\" \"%:subject\")\"}]]\n%?") + "* TODO [[gnus:%:group#%:message-id][%^{Title|\"%(replace-regexp-in-string \"\\\\\\[\\\\\\|\\\\\\]\" \"\" \"%:subject\")\" from %:fromname}]]\n%?") ;; ("a" "Appointment" entry (file+datetree "~/doc/org/diary.org") ;; "* %^{Time} %^{Title & location} @@ -3978,8 +3491,9 @@ before uploading to NEW again." \n \n (spw/remap-mark-commands org org-mode-map org-mark-subtree org-mark-element) (with-eval-after-load 'org - (require 'org-agenda) (require 'org-inlinetask) - (require 'ol-notmuch nil t) (require 'org-checklist nil t) + (require 'org-agenda) + (require 'org-inlinetask) + (require 'org-checklist nil t) ;; With the new exporter in Org version 8, must explicitly require the ;; exporters I want to use. @@ -4043,26 +3557,6 @@ Called by doccheckin script." (when (string-prefix-p root (buffer-file-name buffer)) (with-current-buffer buffer (basic-save-buffer)))))) -;; the default value for `org-notmuch-open-function' is -;; `org-notmuch-follow-link', but that function is broken: it calls -;; `notmuch-show' with a search query rather than a thread ID. This -;; causes `notmuch-show-thread-id' to be populated with a value -;; which is not a thread ID, which breaks various other things -;; -;; so use a custom function instead -(defun spw/org-notmuch-follow-link (search) - (let ((thread-id - (car (process-lines notmuch-command - "search" - "--output=threads" - "--limit=1" - "--format=text" - "--format-version=4" - search)))) - (notmuch-show thread-id nil nil search - (concat "*notmuch-" search "*")))) -(setq org-notmuch-open-function #'spw/org-notmuch-follow-link) - ;;; Org-mode export ;;; ;;; Org-mode's export engine is great for producing versions of arbitrary Org diff --git a/.fmail/.notmuch/hooks/post-new b/.fmail/.notmuch/hooks/post-new index bd716980..78a68631 100755 --- a/.fmail/.notmuch/hooks/post-new +++ b/.fmail/.notmuch/hooks/post-new @@ -15,8 +15,3 @@ notmuch tag +spam -- folder:spam # mark all trash as trash notmuch tag +deleted -- folder:trash - -[ -r "$HOME/doc/notmuch-killfile" ] && perl \ - -we'length and system "notmuch", "tag", "-unread", "--", "($_)" - for join ") or (", grep length, map s/^\s+|\s*(#.*)?$//gr, <>' \ - "$HOME/doc/notmuch-killfile" diff --git a/.fmail/.notmuch/hooks/pre-new b/.fmail/.notmuch/hooks/pre-new index a445a51e..302651d8 100755 --- a/.fmail/.notmuch/hooks/pre-new +++ b/.fmail/.notmuch/hooks/pre-new @@ -2,11 +2,6 @@ . $HOME/.shenv -# In this script (actually within movemymail), we move mail according to -# tags that may have been added since the last 'notmuch new' run. -# Then we sync with my IMAP server. Then in the post-new script we -# add tags to new messages in particular folders - offline || movemymail # ensure that notmuch is able to detect renames by archive-fmail-to-annex @@ -71,8 +71,7 @@ Slave :fmmaildir:sent # it doesn't matter much. At the present time the script is *not* installed. # If I find myself having to use webmail a lot more, could create the # INBOX.Lists folder and install it again. Cf. ~/doc/archive/fastmail.sieve -# for the most recent more involved script I had installed, now replaced by -# query:inbox and query:bulk. +# for the most recent more involved script I had installed. # if header :regex ["List-Id","List-Post"] ".+" # { @@ -88,6 +87,7 @@ Slave :fmmaildir:sent # Master :fmimap:Lists # Slave :fmmaildir:lists +# drafts written outside of Emacs, in other clients Channel fmaild Master :fmimap:Drafts Slave :fmmaildir:drafts diff --git a/.mrconfig.in b/.mrconfig.in index 40c39fed..491631e8 100644 --- a/.mrconfig.in +++ b/.mrconfig.in @@ -762,28 +762,6 @@ isclean = checkout = git clone demeterp:realloc realloc skip = ! mine -[lib/nmbug-spw] -checkout = notmuch git clone demeterp:nmbug-spw -update = notmuch git pull -push = notmuch git push -sync = mr autoci; notmuch git pull && notmuch git push -status = - notmuch git status | grep -v "^U\s" || true - # `nmbug status` does not catch committed but unpushed changes - git --no-pager log --branches \ - --not --remotes \ - --simplify-by-decoration --decorate --oneline -log = notmuch git log -# 'notmuch git' has safety checks to avoid wiping out git because the db -# contains no spw:: tags (`notmuch git checkout` needed), so this autocommit -# should be safe. -# -# TODO (Script to) periodically drop old spw::killed tags from repo. This -# should speed up 'notmuch git' runs on athena. -autoci = notmuch git commit -commit = notmuch git commit --force -skip = ! [ -e "$HOME/local/auth/fmailsyncpass" ] - # --- my personal documents. Override my global update command back # --- to the myrepos default so that git automatically pulls and # --- merges. Skipped on non-local hosts diff --git a/.notmuch-config b/.notmuch-config index f49cc4d9..597482a7 100644 --- a/.notmuch-config +++ b/.notmuch-config @@ -95,17 +95,3 @@ synchronize_flags=true [index] header.List=List-Id header.Team=X-Distro-Tracker-Team - -[query] -# These are the base search queries for inboxes vs. non-inboxes. We need the -# new sexp: modifier in notmuch 0.36 to be able to express "(not (List *))". -# The other "List:" terms catch some bogus List-Id headers on some automated -# mail that is in fact addressed directly to me. -# -# If this has to become any more complex I might move this file ->athpriv.git. -bulk=sexp:"(List *)" or from:rss2email@athena.silentflame.com or from:gmi2email@athena.silentflame.com or to:ftpmaster@debian.org or to:ftpmaster@ftp-master.debian.org or to:cron@ftp-master.debian.org -inbox=(to:spwhitton@spwhitton.name or to:spwhitton@email.arizona.edu or to:"Sean Whitton" or not query:bulk or List:".xt.local>" or List:".list-id.mcsv.net") and not (folder:sent or folder:/^annex/sent-/ or folder:drafts or folder:notes) - -[git] -tag_prefix=spw:: -path=/home/spwhitton/lib/nmbug-spw/ diff --git a/bin/movemymail b/bin/movemymail index 39f30eb6..16ce96ab 100755 --- a/bin/movemymail +++ b/bin/movemymail @@ -14,25 +14,12 @@ open our $us, "<", $0 or die $!; exit 0 unless flock $us, LOCK_EX|LOCK_NB; our $root = "$ENV{HOME}/.fmail"; -our $on_athena = hostfqdn eq "athena.silentflame.com"; die "no movemymail\n" if -e "$ENV{HOME}/.nomovemymail"; open my $df, "-|", "df", "-kP", $root; <$df>, my @df_fields = split " ", <$df>; $df_fields[3] > 1048576 or die "free space low; no movemymail\n"; -# Sync notmuch's database to maildir and to git. -if (-d "$root/.notmuch/xapian") { - # Skip on athena because it's v. slow atm. - system "mr", "-d", "$ENV{HOME}/lib/nmbug-spw", "autoci" unless $on_athena; - - # Ensure messages tagged in Emacs are in the appropriate folder. In the - # case of spam, this should train FastMail's spam filter. - search2folder("drafts", "tag:draft", "and", "not", "tag:deleted"); - search2folder("spam", "tag:spam"); - search2folder("trash", "tag:deleted"); -} - system [0, 1], "offline"; $? >> 8 == 0 and die "we're offline; cannot further sync mail\n"; @@ -48,7 +35,7 @@ if (my $exception = $@) { # athena's 'notmuch new' cronjob is responsible for imap-dl(1) runs. We have # this here rather than separate cronjob entries so that hitting 'G' in # athena's Emacs inbox displays new mail from the other accounts. -if ($on_athena) { +if (hostfqdn eq "athena.silentflame.com") { system "imap-dl", "$ENV{HOME}/.config/mailscripts/imap-dl.selene"; system "imap-dl", "$ENV{HOME}/.config/mailscripts/imap-dl.catmail"; } @@ -64,18 +51,3 @@ for (<$root/*/new/*>) { # Useful to see if any mail has got stuck before closing laptop lid. `mailq` =~ "Mail queue is empty" or warn "WARNING: Outbox not empty.\n"; - -sub search2folder { - my ($folder, @terms) = @_; - open my $search, "-|", "notmuch", "search", "--output=files", "--", - "(", @terms, ")", "and", "not", "folder:$folder"; - my @matches; - for (<$search>) { - next if m{^\Q$root\E/annex/}; - chomp; - # If notmuch's database is out-of-date the file may no longer exist - # because it's already been moved by a previous run of this script. - -f and push @matches, $_ - } - @matches and system "mdmv", @matches, "$root/$folder"; -} diff --git a/bin/nmbug b/bin/nmbug deleted file mode 100755 index 6580c31a..00000000 --- a/bin/nmbug +++ /dev/null @@ -1,852 +0,0 @@ -#!/usr/bin/env python3 -# -# Copyright (c) 2011-2014 David Bremner <david@tethera.net> -# W. Trevor King <wking@tremily.us> -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see https://www.gnu.org/licenses/ . - -""" -Manage notmuch tags with Git - -Environment variables: - -* NMBGIT specifies the location of the git repository used by nmbug. - If not specified $HOME/.nmbug is used. -* NMBPREFIX specifies the prefix in the notmuch database for tags of - interest to nmbug. If not specified 'notmuch::' is used. -""" - -from __future__ import print_function -from __future__ import unicode_literals - -import codecs as _codecs -import collections as _collections -import functools as _functools -import inspect as _inspect -import locale as _locale -import logging as _logging -import os as _os -import re as _re -import shutil as _shutil -import subprocess as _subprocess -import sys as _sys -import tempfile as _tempfile -import textwrap as _textwrap -try: # Python 3 - from urllib.parse import quote as _quote - from urllib.parse import unquote as _unquote -except ImportError: # Python 2 - from urllib import quote as _quote - from urllib import unquote as _unquote - - -__version__ = '0.3' - -_LOG = _logging.getLogger('nmbug') -_LOG.setLevel(_logging.WARNING) -_LOG.addHandler(_logging.StreamHandler()) - -NMBGIT = _os.path.expanduser( - _os.getenv('NMBGIT', _os.path.join('~', '.nmbug'))) -_NMBGIT = _os.path.join(NMBGIT, '.git') -if _os.path.isdir(_NMBGIT): - NMBGIT = _NMBGIT - -TAG_PREFIX = _os.getenv('NMBPREFIX', 'notmuch::') -_HEX_ESCAPE_REGEX = _re.compile('%[0-9A-F]{2}') -_TAG_DIRECTORY = 'tags/' -_TAG_FILE_REGEX = _re.compile(_TAG_DIRECTORY + '(?P<id>[^/]*)/(?P<tag>[^/]*)') - -# magic hash for Git (git hash-object -t blob /dev/null) -_EMPTYBLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391' - - -try: - getattr(_tempfile, 'TemporaryDirectory') -except AttributeError: # Python < 3.2 - class _TemporaryDirectory(object): - """ - Fallback context manager for Python < 3.2 - - See PEP 343 for details on context managers [1]. - - [1]: https://www.python.org/dev/peps/pep-0343/ - """ - def __init__(self, **kwargs): - self.name = _tempfile.mkdtemp(**kwargs) - - def __enter__(self): - return self.name - - def __exit__(self, type, value, traceback): - _shutil.rmtree(self.name) - - - _tempfile.TemporaryDirectory = _TemporaryDirectory - - -def _hex_quote(string, safe='+@=:,'): - """ - quote('abc def') -> 'abc%20def'. - - Wrap urllib.parse.quote with additional safe characters (in - addition to letters, digits, and '_.-') and lowercase hex digits - (e.g. '%3a' instead of '%3A'). - """ - uppercase_escapes = _quote(string, safe) - return _HEX_ESCAPE_REGEX.sub( - lambda match: match.group(0).lower(), - uppercase_escapes) - - -_ENCODED_TAG_PREFIX = _hex_quote(TAG_PREFIX, safe='+@=,') # quote ':' - - -def _xapian_quote(string): - """ - Quote a string for Xapian's QueryParser. - - Xapian uses double-quotes for quoting strings. You can escape - internal quotes by repeating them [1,2,3]. - - [1]: https://trac.xapian.org/ticket/128#comment:2 - [2]: https://trac.xapian.org/ticket/128#comment:17 - [3]: https://trac.xapian.org/changeset/13823/svn - """ - return '"{0}"'.format(string.replace('"', '""')) - - -def _xapian_unquote(string): - """ - Unquote a Xapian-quoted string. - """ - if string.startswith('"') and string.endswith('"'): - return string[1:-1].replace('""', '"') - return string - - -class SubprocessError(RuntimeError): - "A subprocess exited with a nonzero status" - def __init__(self, args, status, stdout=None, stderr=None): - self.status = status - self.stdout = stdout - self.stderr = stderr - msg = '{args} exited with {status}'.format(args=args, status=status) - if stderr: - msg = '{msg}: {stderr}'.format(msg=msg, stderr=stderr) - super(SubprocessError, self).__init__(msg) - - -class _SubprocessContextManager(object): - """ - PEP 343 context manager for subprocesses. - - 'expect' holds a tuple of acceptable exit codes, otherwise we'll - raise a SubprocessError in __exit__. - """ - def __init__(self, process, args, expect=(0,)): - self._process = process - self._args = args - self._expect = expect - - def __enter__(self): - return self._process - - def __exit__(self, type, value, traceback): - for name in ['stdin', 'stdout', 'stderr']: - stream = getattr(self._process, name) - if stream: - stream.close() - setattr(self._process, name, None) - status = self._process.wait() - _LOG.debug( - 'collect {args} with status {status} (expected {expect})'.format( - args=self._args, status=status, expect=self._expect)) - if status not in self._expect: - raise SubprocessError(args=self._args, status=status) - - def wait(self): - return self._process.wait() - - -def _spawn(args, input=None, additional_env=None, wait=False, stdin=None, - stdout=None, stderr=None, encoding=_locale.getpreferredencoding(), - expect=(0,), **kwargs): - """Spawn a subprocess, and optionally wait for it to finish. - - This wrapper around subprocess.Popen has two modes, depending on - the truthiness of 'wait'. If 'wait' is true, we use p.communicate - internally to write 'input' to the subprocess's stdin and read - from it's stdout/stderr. If 'wait' is False, we return a - _SubprocessContextManager instance for fancier handling - (e.g. piping between processes). - - For 'wait' calls when you want to write to the subprocess's stdin, - you only need to set 'input' to your content. When 'input' is not - None but 'stdin' is, we'll automatically set 'stdin' to PIPE - before calling Popen. This avoids having the subprocess - accidentally inherit the launching process's stdin. - """ - _LOG.debug('spawn {args} (additional env. var.: {env})'.format( - args=args, env=additional_env)) - if not stdin and input is not None: - stdin = _subprocess.PIPE - if additional_env: - if not kwargs.get('env'): - kwargs['env'] = dict(_os.environ) - kwargs['env'].update(additional_env) - p = _subprocess.Popen( - args, stdin=stdin, stdout=stdout, stderr=stderr, **kwargs) - if wait: - if hasattr(input, 'encode'): - input = input.encode(encoding) - (stdout, stderr) = p.communicate(input=input) - status = p.wait() - _LOG.debug( - 'collect {args} with status {status} (expected {expect})'.format( - args=args, status=status, expect=expect)) - if stdout is not None: - stdout = stdout.decode(encoding) - if stderr is not None: - stderr = stderr.decode(encoding) - if status not in expect: - raise SubprocessError( - args=args, status=status, stdout=stdout, stderr=stderr) - return (status, stdout, stderr) - if p.stdin and not stdin: - p.stdin.close() - p.stdin = None - if p.stdin: - p.stdin = _codecs.getwriter(encoding=encoding)(stream=p.stdin) - stream_reader = _codecs.getreader(encoding=encoding) - if p.stdout: - p.stdout = stream_reader(stream=p.stdout) - if p.stderr: - p.stderr = stream_reader(stream=p.stderr) - return _SubprocessContextManager(args=args, process=p, expect=expect) - - -def _git(args, **kwargs): - args = ['git', '--git-dir', NMBGIT] + list(args) - return _spawn(args=args, **kwargs) - - -def _get_current_branch(): - """Get the name of the current branch. - - Return 'None' if we're not on a branch. - """ - try: - (status, branch, stderr) = _git( - args=['symbolic-ref', '--short', 'HEAD'], - stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True) - except SubprocessError as e: - if 'not a symbolic ref' in e: - return None - raise - return branch.strip() - - -def _get_remote(): - "Get the default remote for the current branch." - local_branch = _get_current_branch() - (status, remote, stderr) = _git( - args=['config', 'branch.{0}.remote'.format(local_branch)], - stdout=_subprocess.PIPE, wait=True) - return remote.strip() - - -def get_tags(prefix=None): - "Get a list of tags with a given prefix." - if prefix is None: - prefix = TAG_PREFIX - (status, stdout, stderr) = _spawn( - args=['notmuch', 'search', '--output=tags', '*'], - stdout=_subprocess.PIPE, wait=True) - return [tag for tag in stdout.splitlines() if tag.startswith(prefix)] - - -def archive(treeish='HEAD', args=()): - """ - Dump a tar archive of the current nmbug tag set. - - Using 'git archive'. - - Each tag $tag for message with Message-Id $id is written to - an empty file - - tags/encode($id)/encode($tag) - - The encoding preserves alphanumerics, and the characters - "+-_@=.:," (not the quotes). All other octets are replaced with - '%' followed by a two digit hex number. - """ - _git(args=['archive', treeish] + list(args), wait=True) - - -def clone(repository): - """ - Create a local nmbug repository from a remote source. - - This wraps 'git clone', adding some options to avoid creating a - working tree while preserving remote-tracking branches and - upstreams. - """ - with _tempfile.TemporaryDirectory(prefix='nmbug-clone.') as workdir: - _spawn( - args=[ - 'git', 'clone', '--no-checkout', '--separate-git-dir', NMBGIT, - repository, workdir], - wait=True) - _git(args=['config', '--unset', 'core.worktree'], wait=True, expect=(0, 5)) - _git(args=['config', 'core.bare', 'true'], wait=True) - _git(args=['branch', 'config', 'origin/config'], wait=True) - existing_tags = get_tags() - if existing_tags: - _LOG.warning( - 'Not checking out to avoid clobbering existing tags: {}'.format( - ', '.join(existing_tags))) - else: - checkout() - - -def _is_committed(status): - return len(status['added']) + len(status['deleted']) == 0 - - -def commit(treeish='HEAD', message=None): - """ - Commit prefix-matching tags from the notmuch database to Git. - """ - status = get_status() - - if _is_committed(status=status): - _LOG.warning('Nothing to commit') - return - - _git(args=['read-tree', '--empty'], wait=True) - _git(args=['read-tree', treeish], wait=True) - try: - _update_index(status=status) - (_, tree, _) = _git( - args=['write-tree'], - stdout=_subprocess.PIPE, - wait=True) - (_, parent, _) = _git( - args=['rev-parse', treeish], - stdout=_subprocess.PIPE, - wait=True) - (_, commit, _) = _git( - args=['commit-tree', tree.strip(), '-p', parent.strip()], - input=message, - stdout=_subprocess.PIPE, - wait=True) - _git( - args=['update-ref', treeish, commit.strip()], - stdout=_subprocess.PIPE, - wait=True) - except Exception as e: - _git(args=['read-tree', '--empty'], wait=True) - _git(args=['read-tree', treeish], wait=True) - raise - -def _update_index(status): - with _git( - args=['update-index', '--index-info'], - stdin=_subprocess.PIPE) as p: - for id, tags in status['deleted'].items(): - for line in _index_tags_for_message(id=id, status='D', tags=tags): - p.stdin.write(line) - for id, tags in status['added'].items(): - for line in _index_tags_for_message(id=id, status='A', tags=tags): - p.stdin.write(line) - - -def fetch(remote=None): - """ - Fetch changes from the remote repository. - - See 'merge' to bring those changes into notmuch. - """ - args = ['fetch'] - if remote: - args.append(remote) - _git(args=args, wait=True) - - -def checkout(): - """ - Update the notmuch database from Git. - - This is mainly useful to discard your changes in notmuch relative - to Git. - """ - status = get_status() - with _spawn( - args=['notmuch', 'tag', '--batch'], stdin=_subprocess.PIPE) as p: - for id, tags in status['added'].items(): - p.stdin.write(_batch_line(action='-', id=id, tags=tags)) - for id, tags in status['deleted'].items(): - p.stdin.write(_batch_line(action='+', id=id, tags=tags)) - - -def _batch_line(action, id, tags): - """ - 'notmuch tag --batch' line for adding/removing tags. - - Set 'action' to '-' to remove a tag or '+' to add the tags to a - given message id. - """ - tag_string = ' '.join( - '{action}{prefix}{tag}'.format( - action=action, prefix=_ENCODED_TAG_PREFIX, tag=_hex_quote(tag)) - for tag in tags) - line = '{tags} -- id:{id}\n'.format( - tags=tag_string, id=_xapian_quote(string=id)) - return line - - -def _insist_committed(): - "Die if the the notmuch tags don't match the current HEAD." - status = get_status() - if not _is_committed(status=status): - _LOG.error('\n'.join([ - 'Uncommitted changes to {prefix}* tags in notmuch', - '', - "For a summary of changes, run 'nmbug status'", - "To save your changes, run 'nmbug commit' before merging/pull", - "To discard your changes, run 'nmbug checkout'", - ]).format(prefix=TAG_PREFIX)) - _sys.exit(1) - - -def pull(repository=None, refspecs=None): - """ - Pull (merge) remote repository changes to notmuch. - - 'pull' is equivalent to 'fetch' followed by 'merge'. We use the - Git-configured repository for your current branch - (branch.<name>.repository, likely 'origin', and - branch.<name>.merge, likely 'master'). - """ - _insist_committed() - if refspecs and not repository: - repository = _get_remote() - args = ['pull'] - if repository: - args.append(repository) - if refspecs: - args.extend(refspecs) - with _tempfile.TemporaryDirectory(prefix='nmbug-pull.') as workdir: - for command in [ - ['reset', '--hard'], - args]: - _git( - args=command, - additional_env={'GIT_WORK_TREE': workdir}, - wait=True) - checkout() - - -def merge(reference='@{upstream}'): - """ - Merge changes from 'reference' into HEAD and load the result into notmuch. - - The default reference is '@{upstream}'. - """ - _insist_committed() - with _tempfile.TemporaryDirectory(prefix='nmbug-merge.') as workdir: - for command in [ - ['reset', '--hard'], - ['merge', reference]]: - _git( - args=command, - additional_env={'GIT_WORK_TREE': workdir}, - wait=True) - checkout() - - -def log(args=()): - """ - A simple wrapper for 'git log'. - - After running 'nmbug fetch', you can inspect the changes with - 'nmbug log HEAD..@{upstream}'. - """ - # we don't want output trapping here, because we want the pager. - args = ['log', '--name-status', '--no-renames'] + list(args) - with _git(args=args, expect=(0, 1, -13)) as p: - p.wait() - - -def push(repository=None, refspecs=None): - "Push the local nmbug Git state to a remote repository." - if refspecs and not repository: - repository = _get_remote() - args = ['push'] - if repository: - args.append(repository) - if refspecs: - args.extend(refspecs) - _git(args=args, wait=True) - - -def status(): - """ - Show pending updates in notmuch or git repo. - - Prints lines of the form - - ng Message-Id tag - - where n is a single character representing notmuch database status - - * A - - Tag is present in notmuch database, but not committed to nmbug - (equivalently, tag has been deleted in nmbug repo, e.g. by a - pull, but not restored to notmuch database). - - * D - - Tag is present in nmbug repo, but not restored to notmuch - database (equivalently, tag has been deleted in notmuch). - - * U - - Message is unknown (missing from local notmuch database). - - The second character (if present) represents a difference between - local and upstream branches. Typically 'nmbug fetch' needs to be - run to update this. - - * a - - Tag is present in upstream, but not in the local Git branch. - - * d - - Tag is present in local Git branch, but not upstream. - """ - status = get_status() - # 'output' is a nested defaultdict for message status: - # * The outer dict is keyed by message id. - # * The inner dict is keyed by tag name. - # * The inner dict values are status strings (' a', 'Dd', ...). - output = _collections.defaultdict( - lambda : _collections.defaultdict(lambda : ' ')) - for id, tags in status['added'].items(): - for tag in tags: - output[id][tag] = 'A' - for id, tags in status['deleted'].items(): - for tag in tags: - output[id][tag] = 'D' - for id, tags in status['missing'].items(): - for tag in tags: - output[id][tag] = 'U' - if _is_unmerged(): - for id, tag in _diff_refs(filter='A'): - output[id][tag] += 'a' - for id, tag in _diff_refs(filter='D'): - output[id][tag] += 'd' - for id, tag_status in sorted(output.items()): - for tag, status in sorted(tag_status.items()): - print('{status}\t{id}\t{tag}'.format( - status=status, id=id, tag=tag)) - - -def _is_unmerged(ref='@{upstream}'): - try: - (status, fetch_head, stderr) = _git( - args=['rev-parse', ref], - stdout=_subprocess.PIPE, stderr=_subprocess.PIPE, wait=True) - except SubprocessError as e: - if 'No upstream configured' in e.stderr: - return - raise - (status, base, stderr) = _git( - args=['merge-base', 'HEAD', ref], - stdout=_subprocess.PIPE, wait=True) - return base != fetch_head - - -def get_status(): - status = { - 'deleted': {}, - 'missing': {}, - } - index = _index_tags() - maybe_deleted = _diff_index(index=index, filter='D') - for id, tags in maybe_deleted.items(): - (_, stdout, stderr) = _spawn( - args=['notmuch', 'search', '--output=files', 'id:{0}'.format(id)], - stdout=_subprocess.PIPE, - wait=True) - if stdout: - status['deleted'][id] = tags - else: - status['missing'][id] = tags - status['added'] = _diff_index(index=index, filter='A') - _os.remove(index) - return status - - -def _index_tags(): - "Write notmuch tags to the nmbug.index." - (_, path) = _tempfile.mkstemp(dir=NMBGIT, prefix="nmbug", suffix=".index") - query = ' '.join('tag:"{tag}"'.format(tag=tag) for tag in get_tags()) - prefix = '+{0}'.format(_ENCODED_TAG_PREFIX) - _git( - args=['read-tree', '--empty'], - additional_env={'GIT_INDEX_FILE': path}, wait=True) - with _spawn( - args=['notmuch', 'dump', '--format=batch-tag', '--', query], - stdout=_subprocess.PIPE) as notmuch: - with _git( - args=['update-index', '--index-info'], - stdin=_subprocess.PIPE, - additional_env={'GIT_INDEX_FILE': path}) as git: - for line in notmuch.stdout: - if line.strip().startswith('#'): - continue - (tags_string, id) = [_.strip() for _ in line.split(' -- id:')] - tags = [ - _unquote(tag[len(prefix):]) - for tag in tags_string.split() - if tag.startswith(prefix)] - id = _xapian_unquote(string=id) - for line in _index_tags_for_message( - id=id, status='A', tags=tags): - git.stdin.write(line) - return path - - -def _index_tags_for_message(id, status, tags): - """ - Update the Git index to either create or delete an empty file. - - Neither 'id' nor the tags in 'tags' should be encoded/escaped. - """ - mode = '100644' - hash = _EMPTYBLOB - - if status == 'D': - mode = '0' - hash = '0000000000000000000000000000000000000000' - - for tag in tags: - path = 'tags/{id}/{tag}'.format( - id=_hex_quote(string=id), tag=_hex_quote(string=tag)) - yield '{mode} {hash}\t{path}\n'.format(mode=mode, hash=hash, path=path) - - -def _diff_index(index, filter): - """ - Get an {id: {tag, ...}} dict for a given filter. - - For example, use 'A' to find added tags, and 'D' to find deleted tags. - """ - s = _collections.defaultdict(set) - with _git( - args=[ - 'diff-index', '--cached', '--diff-filter', filter, - '--name-only', 'HEAD'], - additional_env={'GIT_INDEX_FILE': index}, - stdout=_subprocess.PIPE) as p: - # Once we drop Python < 3.3, we can use 'yield from' here - for id, tag in _unpack_diff_lines(stream=p.stdout): - s[id].add(tag) - return s - - -def _diff_refs(filter, a='HEAD', b='@{upstream}'): - with _git( - args=['diff', '--diff-filter', filter, '--name-only', a, b], - stdout=_subprocess.PIPE) as p: - # Once we drop Python < 3.3, we can use 'yield from' here - for id, tag in _unpack_diff_lines(stream=p.stdout): - yield id, tag - - -def _unpack_diff_lines(stream): - "Iterate through (id, tag) tuples in a diff stream." - for line in stream: - match = _TAG_FILE_REGEX.match(line.strip()) - if not match: - message = 'non-tag line in diff: {!r}'.format(line.strip()) - if line.startswith(_TAG_DIRECTORY): - raise ValueError(message) - _LOG.info(message) - continue - id = _unquote(match.group('id')) - tag = _unquote(match.group('tag')) - yield (id, tag) - - -def _help(parser, command=None): - """ - Show help for an nmbug command. - - Because some folks prefer: - - $ nmbug help COMMAND - - to - - $ nmbug COMMAND --help - """ - if command: - parser.parse_args([command, '--help']) - else: - parser.parse_args(['--help']) - - -if __name__ == '__main__': - import argparse - - parser = argparse.ArgumentParser( - description=__doc__.strip(), - formatter_class=argparse.RawDescriptionHelpFormatter) - parser.add_argument( - '-v', '--version', action='version', - version='%(prog)s {}'.format(__version__)) - parser.add_argument( - '-l', '--log-level', - choices=['critical', 'error', 'warning', 'info', 'debug'], - help='Log verbosity. Defaults to {!r}.'.format( - _logging.getLevelName(_LOG.level).lower())) - - help = _functools.partial(_help, parser=parser) - help.__doc__ = _help.__doc__ - subparsers = parser.add_subparsers( - title='commands', - description=( - 'For help on a particular command, run: ' - "'%(prog)s ... <command> --help'.")) - for command in [ - 'archive', - 'checkout', - 'clone', - 'commit', - 'fetch', - 'help', - 'log', - 'merge', - 'pull', - 'push', - 'status', - ]: - func = locals()[command] - doc = _textwrap.dedent(func.__doc__).strip().replace('%', '%%') - subparser = subparsers.add_parser( - command, - help=doc.splitlines()[0], - description=doc, - formatter_class=argparse.RawDescriptionHelpFormatter) - subparser.set_defaults(func=func) - if command == 'archive': - subparser.add_argument( - 'treeish', metavar='TREE-ISH', nargs='?', default='HEAD', - help=( - 'The tree or commit to produce an archive for. Defaults ' - "to 'HEAD'.")) - subparser.add_argument( - 'args', metavar='ARG', nargs='*', - help=( - "Argument passed through to 'git archive'. Set anything " - 'before <tree-ish>, see git-archive(1) for details.')) - elif command == 'clone': - subparser.add_argument( - 'repository', - help=( - 'The (possibly remote) repository to clone from. See the ' - 'URLS section of git-clone(1) for more information on ' - 'specifying repositories.')) - elif command == 'commit': - subparser.add_argument( - 'message', metavar='MESSAGE', default='', nargs='?', - help='Text for the commit message.') - elif command == 'fetch': - subparser.add_argument( - 'remote', metavar='REMOTE', nargs='?', - help=( - 'Override the default configured in branch.<name>.remote ' - 'to fetch from a particular remote repository (e.g. ' - "'origin').")) - elif command == 'help': - subparser.add_argument( - 'command', metavar='COMMAND', nargs='?', - help='The command to show help for.') - elif command == 'log': - subparser.add_argument( - 'args', metavar='ARG', nargs='*', - help="Additional argument passed through to 'git log'.") - elif command == 'merge': - subparser.add_argument( - 'reference', metavar='REFERENCE', default='@{upstream}', - nargs='?', - help=( - 'Reference, usually other branch heads, to merge into ' - "our branch. Defaults to '@{upstream}'.")) - elif command == 'pull': - subparser.add_argument( - 'repository', metavar='REPOSITORY', default=None, nargs='?', - help=( - 'The "remote" repository that is the source of the pull. ' - 'This parameter can be either a URL (see the section GIT ' - 'URLS in git-pull(1)) or the name of a remote (see the ' - 'section REMOTES in git-pull(1)).')) - subparser.add_argument( - 'refspecs', metavar='REFSPEC', default=None, nargs='*', - help=( - 'Refspec (usually a branch name) to fetch and merge. See ' - 'the <refspec> entry in the OPTIONS section of ' - 'git-pull(1) for other possibilities.')) - elif command == 'push': - subparser.add_argument( - 'repository', metavar='REPOSITORY', default=None, nargs='?', - help=( - 'The "remote" repository that is the destination of the ' - 'push. This parameter can be either a URL (see the ' - 'section GIT URLS in git-push(1)) or the name of a remote ' - '(see the section REMOTES in git-push(1)).')) - subparser.add_argument( - 'refspecs', metavar='REFSPEC', default=None, nargs='*', - help=( - 'Refspec (usually a branch name) to push. See ' - 'the <refspec> entry in the OPTIONS section of ' - 'git-push(1) for other possibilities.')) - - args = parser.parse_args() - - if args.log_level: - level = getattr(_logging, args.log_level.upper()) - _LOG.setLevel(level) - - if not getattr(args, 'func', None): - parser.print_usage() - _sys.exit(1) - - if args.func == help: - arg_names = ['command'] - else: - (arg_names, varargs, varkw) = _inspect.getargs(args.func.__code__) - kwargs = {key: getattr(args, key) for key in arg_names if key in args} - try: - args.func(**kwargs) - except SubprocessError as e: - if _LOG.level == _logging.DEBUG: - raise # don't mask the traceback - _LOG.error(str(e)) - _sys.exit(1) diff --git a/bin/xdg-open b/bin/xdg-open index f080b31c..f6d2ad99 100755 --- a/bin/xdg-open +++ b/bin/xdg-open @@ -14,9 +14,6 @@ case "${1%%:*}" in *.xlsx) exec soffice "$1" ;; - mailto) - exec notmuch emacs-mua --client --create-frame "$1" - ;; *) exec /usr/bin/xdg-open "$@" ;; |