From a2b1a7cc3186f4900414d8153e1a821ef7fe86b5 Mon Sep 17 00:00:00 2001 From: "Dirk-Jan C. Binnema" Date: Wed, 27 Aug 2025 21:45:37 +0300 Subject: [PATCH] mu4e: implement label updating through marks Add support for updating label, using the marking interface. Add some helpers for dealing with (delta) labels Add keybindings to headers / views Update docs --- mu4e/meson.build | 2 +- mu4e/mu4e-headers.el | 5 ++ mu4e/mu4e-labels.el | 153 +++++++++++++++++++++++++++++++++++++++++++ mu4e/mu4e-mark.el | 48 ++++++++++---- mu4e/mu4e-search.el | 3 - mu4e/mu4e-server.el | 4 ++ mu4e/mu4e-view.el | 5 ++ mu4e/mu4e.texi | 111 +++++++++++++++++++++++-------- 8 files changed, 289 insertions(+), 42 deletions(-) create mode 100644 mu4e/mu4e-labels.el diff --git a/mu4e/meson.build b/mu4e/meson.build index cfccce12..f117f790 100644 --- a/mu4e/meson.build +++ b/mu4e/meson.build @@ -52,6 +52,7 @@ mu4e_srcs=[ 'mu4e-headers.el', 'mu4e-helpers.el', 'mu4e-icalendar.el', + 'mu4e-labels.el', 'mu4e-lists.el', 'mu4e-main.el', 'mu4e-mark.el', @@ -115,7 +116,6 @@ foreach src : mu4e_srcs '--eval', '(setq byte-compile-warnings \'(not obsolete))', '--eval', target_func, '--funcall', 'batch-byte-compile', '@INPUT@']) - endforeach # this depends on the above hack: all mu4e elisp files needs to be in builddir diff --git a/mu4e/mu4e-headers.el b/mu4e/mu4e-headers.el index 0f90be01..203d9002 100644 --- a/mu4e/mu4e-headers.el +++ b/mu4e/mu4e-headers.el @@ -913,6 +913,8 @@ COUNT is the number of messages found." (mu4e~headers-defun-mark-for untrash) (mu4e~headers-defun-mark-for unmark) (mu4e~headers-defun-mark-for unread) +(mu4e~headers-defun-mark-for label) +(mu4e~headers-defun-mark-for unlabel) (mu4e~headers-defun-mark-for action) (declare-function mu4e-view-pipe "mu4e-view") @@ -964,6 +966,9 @@ COUNT is the number of messages found." (define-key map (kbd "?") #'mu4e-headers-mark-for-unread) (define-key map (kbd "!") #'mu4e-headers-mark-for-read) + (define-key map (kbd "l") #'mu4e-headers-mark-for-label) + (define-key map (kbd "L") #'mu4e-headers-mark-for-unlabel) + (define-key map (kbd "A") #'mu4e-headers-mark-for-action) (define-key map (kbd "u") #'mu4e-headers-mark-for-unmark) diff --git a/mu4e/mu4e-labels.el b/mu4e/mu4e-labels.el new file mode 100644 index 00000000..a3a6f04c --- /dev/null +++ b/mu4e/mu4e-labels.el @@ -0,0 +1,153 @@ +;;; mu4e-labels.el --- Dealing with labels -*- lexical-binding: t -*- + +;; Copyright (C) 2025 Dirk-Jan C. Binnema + +;; Author: Dirk-Jan C. Binnema +;; Maintainer: Dirk-Jan C. Binnema + +;; This file is not part of GNU Emacs. + +;; mu4e 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. + +;; mu4e 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 mu4e. If not, see . + +;;; Commentary: + +;; In this file, add some helpers for dealing with message labels + +;;; Code: +(require 'mu4e-message) +(require 'mu4e-server) +(require 'mu4e-helpers) + +(defconst mu4e-label-regex + (rx-let ((taboo (any cntrl blank "\"'\\/`$")) + (taboo1-extra (any "-+"))) + (rx (seq + ;; First character: base forbidden + extra chars + (not (or taboo taboo1-extra)) + ;; Rest: just base forbidden chars + (zero-or-more (not taboo))))) + "Unanchored regular expression matching a valid label. + +Any character is allowed that is not a control-character, a +blank, or ASCII single/double quotes,backtick or +forward/backward slash; additionally, the first character cannot +be a \"+\" or \"-\", \"$\" either.") + +(defun mu4e-label-validate (str) + "Validate label STR. + +If STR is a valid label, return STR. Otherwise, raise an warning. +This function attempts to be a bit more informative than simply +checking a regular expression. + +See `mu4e-label-regex' for the definition of the valid format." + (when (string-empty-p str) ;; i. must not be empty + (mu4e-warn "invalid: empty string")) + (let ((first (aref str 0))) + ;; ii. must not start with + or - + (when (or (char-equal first ?+) (char-equal first ?-)) + (mu4e-warn "invalid: starts with '%c'" first)) + ;; iii. check all characters a valid: + (seq-do (lambda (kar) + (unless (string-match-p mu4e-label-regex + (string ?_ kar)) + (mu4e-warn "invalid: character '%c'" kar))) + str)) + str) + +(defun mu4e-label-parse-expr (expr) + "Parse a single delta expression EXPR. + +If EXPR is non-empty, raises an error if EXPR is not a valid +delta. Otherwise, returns EXPR with extra whitespace removed. + +If STR is empty, return nil." + (let ((op (aref expr 0)) + (label (substring expr 1))) + (unless (or (char-equal op ?+) (char-equal op ?-)) + (mu4e-warn "invalid: delta-expression must start with '+' or '-'")) + (concat (char-to-string op) + (mu4e-label-validate label)))) + +(defun mu4e-label-parse-exprs (exprs) + "Parse a string EXPRS with deltas to a string. + +If EXPRS is non-empty, raises an error if EXPRS is invalid. +Otherwise, return EXPRS with extra whitespace removed. + +If EXPRS is empty, return nil." + (mapconcat #'mu4e-label-parse-expr (split-string exprs) " ")) + +(defvar mu4e-labels-list nil "Cached list of labels.") + +(defun mu4e--labels-completion-at-point () + "Provide completion when entering a label delta expressions." + (cond + ((not (looking-back "[^ \t]*" nil)) + (let ((bounds (bounds-of-thing-at-point 'word))) + (list (or (car bounds) (point)) + (or (cdr bounds) (point))))) + ((looking-back + (rx (any "+" "-") + (group + (opt (regex mu4e-label-regex)))) nil) + (list (match-beginning 1) + (match-end 1) + mu4e-labels-list)))) + +(defvar mu4e-minibuffer-label-expr-map + (let ((map (copy-keymap minibuffer-local-map))) + (define-key map (kbd "TAB") #'completion-at-point) + map) + "The keymap for reading label delta expression.") + +(defun mu4e-labels-delta-read () + "Ask for a labels delta expression." + (minibuffer-with-setup-hook + (lambda () + (setq-local completion-at-point-functions + #'mu4e--labels-completion-at-point) + (use-local-map mu4e-minibuffer-label-expr-map)) + (mu4e-label-parse-exprs + (read-string "Label delta expression: ")))) + +(defun mu4e--labels-update-server (docid expr) + "Tell the server to update message with DOCID with EXPR. +EXPR is a label delta-expression, such as \"+foo -bar\". + +Update the label cache while doing so." + ;; update the cache + (let ((expr (mu4e-label-parse-exprs expr)) + (labels ;; the list of labels without +/- prefix + (seq-map (lambda (pmlabel) + (substring pmlabel 1)) + (split-string expr " ")))) + ;; don't care about dups etc.; the list + ;; will be replaced by a fresh server-side one, after + ;; update / restart + (setq mu4e-labels-list + (append labels mu4e-labels-list)) + ;; update the server + (mu4e--server-label docid expr))) + +(defun mu4e--labels-clear-server (docid) + "Clear all labels from message with DOCID." + ;; update the server + ;; '-*' is not a valid label, but special-cased + ;; on the server-side + (mu4e--server-label docid "-*")) + + +(provide 'mu4e-labels) +;;; mu4e-labels.el ends here diff --git a/mu4e/mu4e-mark.el b/mu4e/mu4e-mark.el index 411cd146..b4668170 100644 --- a/mu4e/mu4e-mark.el +++ b/mu4e/mu4e-mark.el @@ -1,6 +1,6 @@ ;;; mu4e-mark.el --- Marking messages -*- lexical-binding: t -*- -;; Copyright (C) 2011-2024 Dirk-Jan C. Binnema +;; Copyright (C) 2011-2025 Dirk-Jan C. Binnema ;; Author: Dirk-Jan C. Binnema ;; Maintainer: Dirk-Jan C. Binnema @@ -30,6 +30,7 @@ (require 'mu4e-server) (require 'mu4e-message) (require 'mu4e-folders) +(require 'mu4e-labels) ;; keep byte-compiler happy (declare-function mu4e~headers-mark "mu4e-headers") @@ -155,6 +156,11 @@ The current buffer must be either a headers or view buffer." :show-target (lambda (target) "flag") :action (lambda (docid msg target) (mu4e--server-move docid nil "+F-u-N"))) + (unflag + :char ("-" . "➖") + :prompt "-unflag" + :show-target (lambda (target) "unflag") + :action (lambda (docid msg target) (mu4e--server-move docid nil "-F-N"))) (move :char ("m" . "▷") :prompt "move" @@ -166,6 +172,12 @@ The current buffer must be either a headers or view buffer." :prompt "!read" :show-target (lambda (target) "read") :action (lambda (docid msg target) (mu4e--server-move docid nil "+S-u-N"))) + (unread + :char ("?" . "◻") + :prompt "?unread" + :show-target (lambda (target) "unread") + :action (lambda (docid msg target) (mu4e--server-move docid nil "-S+u-N"))) + (trash :char ("d" . "▼") :prompt "dtrash" @@ -174,25 +186,29 @@ The current buffer must be either a headers or view buffer." (mu4e--server-move docid (mu4e--mark-check-target target) (if mu4e-trash-without-flag "-N" "+T-N")))) - (unflag - :char ("-" . "➖") - :prompt "-unflag" - :show-target (lambda (target) "unflag") - :action (lambda (docid msg target) (mu4e--server-move docid nil "-F-N"))) (untrash :char ("=" . "▲") :prompt "=untrash" :show-target (lambda (target) "untrash") :action (lambda (docid msg target) (mu4e--server-move docid nil "-T"))) - (unread - :char ("?" . "◻") - :prompt "?unread" - :show-target (lambda (target) "unread") - :action (lambda (docid msg target) (mu4e--server-move docid nil "-S+u-N"))) + + (label + :char ("l" . "🏷") + :prompt "label" + :ask-target mu4e-labels-delta-read + :action (lambda (docid msg expr) + (mu4e--labels-update-server docid expr))) + (unlabel + :char ("L" . "∅") + :prompt "unlabel" + :show-target (lambda (target) "clear labels") + :action (lambda (docid msg expr) + (mu4e--labels-clear-server docid))) (unmark :char " " :prompt "unmark" :action (mu4e-error "No action for unmarking")) + (action :char ( "a" . "◯") :prompt "action" @@ -251,6 +267,8 @@ The following marks are available, and the corresponding props: `untrash' n remove the `trashed' flag from a message `unmark' n unmark this message `unread' n mark the message as unread + `label' y (re)label the message + `unlabel' n clear all labels `action' y mark the message for some action." (interactive) (let* ((msg (mu4e-message-at-point)) @@ -311,6 +329,11 @@ The following marks are available, and the corresponding props: (when (mu4e-create-maildir-maybe fulltarget) target))) +(defun mu4e--mark-get-labels-target () + "Ask for a labels expression." + (let* ((exprs (read-from-minibuffer "Label expression: "))) + (mu4e-label-parse-exprs exprs))) + (defun mu4e--mark-ask-target (mark) "Ask the target for MARK, if the user should be asked the target." (let ((getter (plist-get (cdr (assq mark mu4e-marks)) :ask-target))) @@ -431,7 +454,8 @@ If NO-CONFIRMATION is non-nil, don't ask user for confirmation." (message nil))))) (defun mu4e-mark-unmark-all (&optional no-confirmation) - "Unmark all marked messages." + "Unmark all marked messages. +If NO-CONFIRMATION is non-nil, do not ask for confirmation." (interactive) (mu4e--mark-in-context (when (zerop (mu4e-mark-marks-num)) diff --git a/mu4e/mu4e-search.el b/mu4e/mu4e-search.el index 165dc08d..e21f33da 100644 --- a/mu4e/mu4e-search.el +++ b/mu4e/mu4e-search.el @@ -154,9 +154,6 @@ but also manually invoked searches." :type 'hook :group 'mu4e-search) -(defvar mu4e-labels-list nil - "Cached list of labels.") - ;; Internals ;;; History diff --git a/mu4e/mu4e-server.el b/mu4e/mu4e-server.el index b28f6f73..256894b2 100644 --- a/mu4e/mu4e-server.el +++ b/mu4e/mu4e-server.el @@ -708,6 +708,10 @@ the directory time stamp." :lazy-check ,(and lazy-check t))) (setq mu4e--server-indexing t)) ;; remember we're indexing. +(defun mu4e--server-label (docid delta-expr) + "Apply the label DELTA-EXPR to the message with DOCID." + (mu4e--server-call-mu `(label :docid ,docid :labels ,delta-expr))) + (defun mu4e--server-mkdir (path &optional update) "Create a new maildir-directory at file system PATH. When UPDATE is non-nil, send a update when completed. diff --git a/mu4e/mu4e-view.el b/mu4e/mu4e-view.el index 48974864..9f9d6d59 100644 --- a/mu4e/mu4e-view.el +++ b/mu4e/mu4e-view.el @@ -411,6 +411,8 @@ list." (mu4e--view-defun-mark-for unread) (mu4e--view-defun-mark-for trash) (mu4e--view-defun-mark-for untrash) +(mu4e--view-defun-mark-for label) +(mu4e--view-defun-mark-for unlabel) (defun mu4e-view-marked-execute () "Execute the marked actions." @@ -1008,6 +1010,9 @@ This is useful for advising some Gnus-functionality that does not work in mu4e." (define-key map (kbd "=") #'mu4e-view-mark-for-untrash) (define-key map (kbd "&") #'mu4e-view-mark-custom) + (define-key map (kbd "l") #'mu4e-view-mark-for-label) + (define-key map (kbd "L") #'mu4e-view-mark-for-unlabel) + (define-key map (kbd "*") #'mu4e-view-mark-for-something) (define-key map (kbd "") #'mu4e-view-mark-for-something) (define-key map (kbd "") #'mu4e-view-mark-for-something) diff --git a/mu4e/mu4e.texi b/mu4e/mu4e.texi index 271d2d40..f88fb74d 100644 --- a/mu4e/mu4e.texi +++ b/mu4e/mu4e.texi @@ -196,9 +196,10 @@ following information: @itemize @item What did you expect or wish to happen? what actually happened? -Please describe in detail what you saw. Also, try some related scenarios and -whether the problem either @emph{always} happens, or only @emph{sometimes}. -E.g., it might happen with only one particular type of message +Please describe in detail what you saw; what did you expect to happen and what +actually happened? Also, please try some related scenarios and whether the +problem either @emph{always} happens, or only @emph{sometimes}. E.g., the issue +might happen with only one particular type of message @item Provide some exact steps to reproduce? In particular, some minimal steps, starting from @t{emacs -Q} and with minimal configuration. If the problem is easily producible, it may be easily fixable as @@ -215,7 +216,6 @@ reproducing your problem, not the ``remix'' version that some Emacs In general, imagine you would be the person receiving the bug-report, and think about the information you would need to diagnose the problem. - @node Getting started @chapter Getting started @@ -1046,6 +1046,7 @@ DEL,D mark for complete deletion m mark for moving to another maildir folder r mark for refiling +,- mark for flagging/unflagging +l mark for labels ?,! mark message as unread, read u unmark message at point @@ -1090,11 +1091,13 @@ Some keybindings are available through minor modes: @node HV Marking @section Marking -You can @emph{mark} messages for a certain action, such as deletion or -move. After one or more messages are marked, you can then execute +You can @emph{mark} messages for a certain action, such as deletion or move or +changing the labels. + +After one or more messages are marked, you can then execute (@code{mu4e-mark-execute-all}, @key{x}) these actions. This two-step -mark-execute sequence is similar to what e.g. @t{dired} does. It is how -@t{mu4e} tries to be as quick as possible, while avoiding accidents. +mark-execute sequence is similar to what e.g. @t{dired} does. It is how @t{mu4e} +tries to be as quick as possible, while avoiding accidents. The mark/unmark commands support the @emph{region} (i.e., ``selection'') --- so, for example, if you select some messages and press @key{DEL}, @@ -1423,6 +1426,7 @@ d mark for moving to the trash folder DEL,D mark for complete deletion m mark for moving to another maildir folder r mark for refiling +l mark for labels +,- mark for flagging/unflagging u unmark message at point @@ -2431,7 +2435,7 @@ Note, messages are considered duplicates when they have the same In @t{mu4e}, the common way to do things with messages is a two-step process - first you @emph{mark} them for a certain action, then you @emph{execute} (@key{x}) those marks. This is similar to the way @t{dired} operates. Marking -can happen in both the @ref{Headers view} and the @ref{Message view}. +can happen from both the @ref{Headers view} and the @ref{Message view}. @menu * Marking messages::Selecting message do something with them @@ -2472,22 +2476,84 @@ messages. @verbatim mark for/as | keybinding | description -------------+-------------+------------------------------ -'something' | *, | mark now, decide later delete | D, | delete -flag | + | mark as 'flagged' ('starred') move | m | move to some maildir -read | ! | mark as read refile | r | mark for refiling +-------------+-------------+------------------------------ +flag | + | mark as 'flagged' ('starred') +unflag | - | remove 'flagged' mark +read | ! | mark as read +unread | ? | marks as unread trash | d | move to the trash folder untrash | = | remove 'trash' flag -unflag | - | remove 'flagged' mark +-------------+-------------+------------------------------ +label | l | change the labels +unlabel | L | clear all labels +action | a | apply some action +'something' | *, | mark now, decide later +-------------+------------------------------------------- unmark | u | remove mark at point unmark all | U | remove all marks -unread | ? | marks as unread -action | a | apply some action +-------------+------------------------------------------- @end verbatim @end cartouche +@itemize +@item @emph{delete} deletes the message from database and file-system +@item @emph{move} moves the message to some different maildir +@item @emph{refile} is similar @emph{move}, but determines the target maildir based +on the characteristics of the message; this is very powerful. See +@code{mu4e-refile-folder} and especially @xref{Smart refiling} for details. +@item @emph{flag}/@emph{unflag}, @emph{read}/@emph{unread} and @emph{trash}/@emph{untrash} +set or unset the corresponding Maildir flags +@item @emph{label} lets you enter a label delta expression which gets applied; see below +@item @emph{unlabel} lets you clear @emph{all} labels; see below +@item @emph{action} lets you apply some pre-defined action on the message, as per @code{mu4e-headers-actions} +@item @emph{'something'} marks the message for ``something'', and then decide +later what this something should be; see below +@item @emph{unmark} and @emph{unmark all} are ``meta'' marks, which remove the mark from the currently selected message(s) or all +@end itemize + +@node Applying and clearing labels +@subsection Applying and clearing labels +@cindex labels + +The @t{label} mark lets you apply @emph{labels} to messages, which are short +strings to attach to this message which you can query using the @t{label:} +field. To see them in your headers / message views, you need to add the field to +the @code{mu4e-headers-fields} and @code{mu4e-view-fields}, respectively. + +To change the labels for some message, you specify a @emph{label expression}, +which consists of a space-separated sequence of labels, each prefixed either +with a @t{+} to add the label or @t{-} to remove it. + +For instance, to remove the @t{boring} label and add @t{urgent} from the message +at point or the messages in region, press @kbd{l} and enter: +@example ++urgent -boring +@end example + +For @emph{clearing all labels}, you mark with @kbd{L} ('unlabel'). + +You can search for labels using the @t{label:} field. For instance, +@t{label:urgent} retrieves all messages labeled @t{urgent}. + +It is important to note that the labels are only stored in the database (the +message files are not changed). This means that you loose this information when +you remove the database and recrate it; it is however possible to @emph{export} +the labels and re-@emph{import} them later; see the @t{mu-label} man-page for +further details. + +@subsection Doing @emph{something} + +@t{something} is a special kind of mark; you can use it to mark messages for +`something', and then decide later what the `something' should be@footnote{This +kind of `deferred marking' is similar to the facility in @t{dired}, @t{midnight +commander} (@url{https://www.midnight-commander.org/}) and the like, and uses +the same key binding (@key{insert}).} Later, you can set the actual mark using +@kbd{M-x mu4e-mark-resolve-deferred-marks} (@key{#}). Alternatively, @t{mu4e} +will ask you when you try to execute the marks (@key{x}). + After marking a message, the left-most columns in the headers view indicate the kind of mark. This is informative, but if you mark many (say, thousands) messages, this slows things down significantly@footnote{this uses an @@ -2495,15 +2561,6 @@ Emacs feature called @emph{overlays}, which are slow when used a lot in a buffer}. For this reason, you can disable this by setting @code{mu4e-headers-show-target} to @code{nil}. -@t{something} is a special kind of mark; you can use it to mark messages -for `something', and then decide later what the `something' should -be@footnote{This kind of `deferred marking' is similar to the facility -in @t{dired}, @t{midnight commander} -(@url{https://www.midnight-commander.org/}) and the like, and uses the -same key binding (@key{insert}).} Later, you can set the actual mark -using @kbd{M-x mu4e-mark-resolve-deferred-marks} -(@key{#}). Alternatively, @t{mu4e} will ask you when you try to execute -the marks (@key{x}). @node Executing the marks @section Executing the marks @@ -2598,7 +2655,6 @@ messages. There are more examples in the defaults for @code{mu4e-headers-custom-markers}; see @file{mu4e-headers.el} and see @ref{Extending mu4e} for general information about writing your own functions. - @node Adding a new kind of mark @section Adding a new kind of mark @@ -3128,7 +3184,10 @@ viewing messages in an external web browser or tagging. It is easy to add such actions to your configuration; for instance, to enable @emph{tagging}@footnote{@t{mu4e} does not offer tagging by default since it mutates the message files, something that @t{mu}/@t{mu4e} generally try to -avoid.} both in the message view and headers view, you could add: +avoid. An alternative to tagging is @emph{labeling}, @xref{Applying and clearing +labels} + +} both in the message view and headers view, you could add: @lisp (add-to-list 'mu4e-headers-actions '("Tag message" . mu4e-action-retag-message))