;;; notmuch-status.el --- notmuch status reporting
;;
;; Copyright © David Edmondson
;;
;; This file is not (yet?) part of Notmuch.
;;
;; Notmuch 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.
;;
;; Notmuch 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 Notmuch.  If not, see <https://www.gnu.org/licenses/>.
;;
;; Authors: David Edmondson <dme@dme.org>

;; notmuch-status aims to provide a concise and unintrusive summary of
;; your saved searches in emacs.
;;
;; There are two usage modes:
;; - an on-demand summary,
;; - a period notification of the summary.
;;
;; The first of these is available via `notmuch-status-message', which
;; is typically bound to a key sequence:
;;
;;	(global-set-key "\C-cM" 'notmuch-status-message)
;;
;; This will provide a summary of the saved searches in the
;; minibuffer.
;;
;; Periodic notification of the summary is initiated with
;; `notmuch-status-start' and can be terminated with
;; `notmuch-status-stop'. The interval between notifications is by
;; default 60 seconds and can be modified by customising
;; `notmuch-status-interval'.
;;
;; By default the period notification is shown in the minibuffer area,
;; and so is cleared by other minibuffer using activities. Setting
;; `notmuch-status-modeline' to any non-nil value causes the summary
;; to be placed in the mode-line. The formatting of the mode-line
;; content is controlled via the `notmuch-status' face.
;;
;; The summary itself is a hopefully short string derived from the
;; variable `notmuch-saved-searches'. For each query with a `:key'
;; attribute in `notmuch-saved-searches', notmuch-status will
;; determine how many read and unread messages match the query. This
;; information then forms part of the summary in the form:
;;
;;	KEY:READ+UNREAD
;;
;; If there are no read messages, READ is elided. If there are no
;; unread messages, +UNREAD is elided. If there are neither read nor
;; unread messages no status is shown for this query.
;;
;; As a simple example, if `notmuch-saved-searches' includes a query:
;;
;;	(:key "i" :query "tag:inbox")
;;
;; and there are ten read and four unread messages matching tag:inbox,
;; the status would include "i:10+4".
;;
;; The use of the `:key' attribute to label the result is intended to
;; provide an indication of they key sequence to use with
;; `notmuch-jump' to see the corresponding messages.
;;
;; In some cases `notmuch-saved-searches' has many elements, some of
;; which are not interesting to monitor closely. To allow the list
;; shown by notmuch-status to be pruned, only those with a `:check'
;; attribute will be examined by notmuch-status. If no queries in the
;; list have a `:check' attribute all queries will be used.

;;; Code:

(require 'seq)

;;

(defgroup notmuch-status nil
  "Mail status notification."
  :group 'mail)

(defface notmuch-status '((t :foreground "darkslateblue" :weight bold))
  "Face used for modeline notification."
  :group 'notmuch-status)

(defcustom notmuch-status-interval 60
  "Interval between mail check in seconds."
  :group 'notmuch-status
  :type 'integer)

(defcustom notmuch-status-modeline nil
  "Show mail status in the modeline."
  :group 'notmuch-status
  :type 'boolean)

;;

(defvar notmuch-status--timer nil)
(defvar notmuch-status--object "")

;; Allow properties, in particular faces.
(put 'notmuch-status--object 'risky-local-variable t)

(unless global-mode-string
  (setq global-mode-string '("")))
(unless (memq 'notmuch-status--object global-mode-string)
  (setq global-mode-string (append global-mode-string '(notmuch-status--object))))

;;

(defun notmuch-status--filter-for-checked (searches)
  "Determine which of SEARCHES should be checked for messages."
  ;; We need a :key as it's used to display the results.

  (or (seq-filter (lambda (search)
		    ;; :check is not a standard property of
		    ;; `notmuch-saved-searches' elements, but it is a
		    ;; way of reducing the number of saved searches
		    ;; that are checked and potentially notified.
		    (and (plist-get search :key)
			 (plist-get search :check)))
		  searches)
      ;; If none of the searches have :check marked, do all that have
      ;; a :key.
      (seq-filter (lambda (search)
		    (plist-get search :key))
		  searches)))

(defun notmuch-status--generate-queries (searches)
  "Given SEARCHES, generate two queries for each search.

Return a string of queries suitable for batch processing."
  (mapconcat (lambda (search)
	       (let ((query (or (plist-get search :count-query)
				(plist-get search :query))))
		 (format "(%s) and not tag:unread\n(%s) and tag:unread" query query)))
	     searches
	     "\n"))

(defun notmuch-status--run-queries (queries)
  "Use \"notmuch count\" in batch mode to run a string of QUERIES,
returning the results as a list."
  (with-temp-buffer
    (call-process-region queries nil
			 notmuch-command nil (current-buffer) nil
			 "count" "--batch")
    (split-string (buffer-substring (point-min) (point-max)))))

(defun notmuch-status--pair-results (searches results)
  "Given a set of SEARCHES and the RESULTS produced by the
searches (two results for each search), pair up the :key from the
search with the results. At the same time, convert the results
from strings to numbers."
  (mapcar (lambda (search)
	    (list (plist-get search :key)
		  (string-to-number (pop results))
		  (string-to-number (pop results))))
	  searches))

(defun notmuch-status--process-tuples (results)
  "Process the tuples produced by combining the searches and the
results into a form suitable for display, eliding queries with
no matching read or unread messages.

The output is of the form `LABEL:READ+UNREAD', where the label
comes from the :key used in `notmuch-saved-searches'. If there
are no read messages, READ is removed. If there are no unread
messages, +UNREAD is removed."
  (mapconcat (lambda (r)
	       (let ((label (pop r))
		     (total (pop r))
		     (unread (pop r)))
		 (concat label ":"
			 (if (zerop total)
			     ""
			   (number-to-string total))
			 (if (zerop unread)
			     ""
			   (concat "+" (number-to-string unread))))))
	     ;; Remove elements where there are neither read nor
	     ;; unread messages.
	     (seq-filter (lambda (result)
			   (or (not (zerop (nth 1 result)))
			       (not (zerop (nth 2 result)))))
			 results)
	     " "))

(defun notmuch-status--get ()
  (let ((searches (notmuch-status--filter-for-checked notmuch-saved-searches)))
    (notmuch-status--process-tuples
     (notmuch-status--pair-results
      searches
      (notmuch-status--run-queries
       (notmuch-status--generate-queries searches))))))

(defun notmuch-status-set (status)
  (if notmuch-status-modeline
      (progn
	(setq notmuch-status--object
	      (if (string-equal status "")
		  status
		(list (propertize (concat " {" status "}") 'face 'notmuch-status))))
	(force-mode-line-update t))
    (message status)))

(defun notmuch-status--periodic ()
  (notmuch-status-set (notmuch-status--get)))

;;

;;;###autoload
(defun notmuch-status-start ()
  "Start displaying the status periodically."
  (interactive)
  (when notmuch-status--timer
    (notmuch-status-stop))
  (setq notmuch-status--timer
	(run-with-timer 0 notmuch-status-interval
			'notmuch-status--periodic)))

(defun notmuch-status-stop ()
  "Stop displaying the status periodically."
  (interactive)
  (when notmuch-status--timer
    (cancel-timer notmuch-status--timer))
  (setq notmuch-status--timer nil
	notmuch-status--object ""))

;;

;;;###autoload
(defun notmuch-status-message ()
  "Display status in the modeline."
  (interactive)
  (message "%s" (let ((status (notmuch-status--get)))
		  (if (string= "" status)
		      "No mail."
		    status))))

;;

(provide 'notmuch-status)
