branch: externals/minimail
commit f16fbf90c5db0c4827244e30accca818df28dc0b
Author: Augusto Stoffel <[email protected]>
Commit: Augusto Stoffel <[email protected]>

    Add sorting by thread
---
 README.org        |  15 +++++---
 minimail-tests.el |  72 ++++++++++++++++++++++++++++++++++++
 minimail.el       | 108 ++++++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 186 insertions(+), 9 deletions(-)

diff --git a/README.org b/README.org
index 19245e0f7d..9800797716 100644
--- a/README.org
+++ b/README.org
@@ -6,14 +6,17 @@ Currently Minimail provides the bare minimum necessary to 
read and
 reply to messages.  Here is a listing of implemented and planned
 features.
 
-- [X] Multi-account support
 - [X] Read messages, including MIME (rendering via Gnus)
 - [X] Compose, reply to and forward messages
-- [X] Full-text search
-- [X] Move messages (also archive, trash, junk)
-- [ ] Structured search
-- [ ] Sort by thread
-- [ ] Mark and operate on sets of messages (move etc.)
+- [X] Multi-account support
+- Search
+  - [X] Full text
+  - [ ] Structured (by sender, subject, etc.)
+- Sorting by thread
+  - [X] Simple algorithm based on subject lines
+  - [ ] Fancy algorithm based on reference message IDs.
+- [X] Move messages (also archive, move to trash, flag as junk)
+- [ ] Mark and operate on sets of messages (move, etc.)
 - [ ] Notifications (polling or IDLE)
 - [ ] OAuth
 
diff --git a/minimail-tests.el b/minimail-tests.el
index b78bb6c27d..79bfa5034b 100644
--- a/minimail-tests.el
+++ b/minimail-tests.el
@@ -117,6 +117,78 @@
       (should (equal (mapcar #'car v)
                      '("INBOX" "[Gmail]" "[Gmail]/All Mail"))))))
 
+;;; Message threading
+
+(ert-deftest minimail-tests-thread-position ()
+  (let ((-thread-tree '((2) (3 6 (4 23) (44 7 96)))))
+    (should (= 0 (-thread-position 2)))
+    (should (= 1 (-thread-position 3)))
+    (should (= 2 (-thread-position 6)))
+    (should (= 3 (-thread-position 4)))
+    (should (= 4 (-thread-position 23)))
+    (should (= 5 (-thread-position 44)))
+    (should (= 6 (-thread-position 7)))
+    (should (= 7 (-thread-position 96)))
+    (should-not (-thread-position 999))
+    (should-not (-thread-position nil)))
+
+  (let ((-thread-tree '(((3)(5)))))
+    (should (= 0 (-thread-position 3)))
+    (should (= 1 (-thread-position 5)))))
+
+(ert-deftest minimail-tests-thread-root ()
+  (let ((-thread-tree '((2) (3 6 (4 23) (44 7 96)))))
+    (should (= 2 (-thread-root 2)))
+    (should (= 3 (-thread-root 3)))
+    (should (= 3 (-thread-root 6)))
+    (should (= 3 (-thread-root 4)))
+    (should (= 3 (-thread-root 23)))
+    (should (= 3 (-thread-root 44)))
+    (should (= 3 (-thread-root 7)))
+    (should (= 3 (-thread-root 96)))
+    (should-not (-thread-root 999))
+    (should-not (-thread-root nil)))
+
+  (let ((-thread-tree '(((3)(5)))))
+    (should (= 3 (-thread-root 3)))
+    (should (= 5 (-thread-root 5)))))
+
+(ert-deftest minimail-tests-thread-level ()
+  (let ((-thread-tree '((2) (3 6 (4 23) (44 7 96)))))
+    (should (= 0 (-thread-level 2)))
+    (should (= 0 (-thread-level 3)))
+    (should (= 1 (-thread-level 6)))
+    (should (= 2 (-thread-level 4)))
+    (should (= 3 (-thread-level 23)))
+    (should (= 2 (-thread-level 44)))
+    (should (= 3 (-thread-level 7)))
+    (should (= 4 (-thread-level 96)))
+    (should-not (-thread-level 999))
+    (should-not (-thread-level nil)))
+
+  (let ((-thread-tree '(((3)(5)))))
+    (should (= 0 (-thread-level 3)))
+    (should (= 0 (-thread-level 5)))))
+
+(ert-deftest minimail-tests-thread-huge-tree ()
+  (let* ((n 10000) (-thread-tree `(,(number-sequence 0 n))))
+    (should (= n (-thread-position n)))
+    (should (= 0 (-thread-root n)))
+    (should (= n (-thread-level n))))
+
+  (let* ((n 100)                        ;This test consumes stack
+         (-thread-tree (seq-reduce (lambda (i v) (list v i))
+                                   (number-sequence n 0 -1) -1)))
+    (should (= n (-thread-position n)))
+    (should (= 0 (-thread-root n)))
+    (should (= n (-thread-level n))))
+
+  (let* ((n 100)                        ;This test consumes stack
+         (-thread-tree (seq-reduce #'list (number-sequence 1 n) '(0))))
+    (should (eq n (-thread-position n)))
+    (should (eq n (-thread-root n)))
+    (should (eq 0 (-thread-level n)))))
+
 ;; Local Variables:
 ;; read-symbol-shorthands: (("-" . "minimail--") ("athunk-" . 
"minimail--athunk-"))
 ;; End:
diff --git a/minimail.el b/minimail.el
index fb85219c2e..3195bd90fd 100644
--- a/minimail.el
+++ b/minimail.el
@@ -32,14 +32,14 @@
 
 ;;; Code:
 
-(require 'let-alist)
 (require 'gnus-art)
 (require 'peg)      ;need peg.el from Emacs 30, which is ahead of ELPA
-(require 'rx)
 (require 'smtpmail)
 (require 'vtable)
 
 (eval-when-compile
+  (require 'let-alist)
+  (require 'rx)
   (require 'subr-x))
 
 ;;; Syntactic sugar for cooperative multitasking
@@ -1062,6 +1062,9 @@ Return a cons cell consisting of the account symbol and 
mailbox name."
 
 ;;; Mailbox buffer
 
+(defvar-local -thread-tree nil
+  "The thread tree for the current buffer, as in RFC 5256.")
+
 (defvar-keymap minimail-mailbox-mode-map
   "RET" #'minimail-show-message
   "n" #'minimail-next-message
@@ -1072,6 +1075,7 @@ Return a cons cell consisting of the account symbol and 
mailbox name."
   "s" #'minimail-search
   "g" #'revert-buffer
   "q" #'minimail-quit-windows
+  "T" #'minimail-sort-by-thread
   "SPC" #'minimail-message-scroll-up
   "S-SPC" #'minimail-message-scroll-down
   "DEL" #'minimail-message-scroll-down)
@@ -1101,7 +1105,8 @@ Return a cons cell consisting of the account symbol and 
mailbox name."
    ", "))
 
 (defun -format-date (date &rest _)
-  (setq date (-get-data date))
+  (when (stringp date)
+    (setq date (-get-data date)))
   (let* ((current-time-list nil)
          (timestamp (encode-time date))
          (today (let* ((v (decode-time)))
@@ -1201,6 +1206,7 @@ Return a cons cell consisting of the account symbol and 
mailbox name."
                               (signal (car err) (cdr err))))))
          (with-current-buffer buffer
            (setq -mode-line-suffix nil)
+           (setq -thread-tree (-thread-by-subject messages))
            (let ((inhibit-read-only t))
              (if-let* ((vtable (vtable-current-table)))
                  (progn
@@ -1254,6 +1260,102 @@ Return a cons cell consisting of the account symbol and 
mailbox name."
     (when-let* ((window (get-buffer-window)))
       (quit-window kill window))))
 
+;;;; Sorting by thread
+
+(defun -thread-position (uid)
+  "Position of UID in the thread tree when regarded as a flat list."
+  (let ((i 0))
+    (named-let recur ((tree -thread-tree))
+      (pcase (car tree)
+        ((pred null))
+        ((pred (eq uid)) i)
+        ((pred numberp) (cl-incf i) (recur (cdr tree)))
+        (subtree (or (recur subtree) (recur (cdr tree))))))))
+
+(defun -thread-root (uid)
+  "The root of the thread to which the given UID belongs."
+  (named-let recur ((root nil) (tree -thread-tree))
+    (pcase (car tree)
+      ((pred null))
+      ((pred (eq uid)) (or root uid))
+      ((and (pred numberp) n) (recur (or root n) (cdr tree)))
+      (subtree (or (recur root subtree) (recur root (cdr tree)))))))
+
+(defun -thread-level (uid)
+  "The nesting level of UID in the thread tree."
+  (named-let recur ((level 0) (tree -thread-tree))
+    (pcase (car tree)
+      ((pred null) nil)
+      ((pred (eq uid)) level)
+      ((pred numberp) (recur (1+ level) (cdr tree)))
+      (subtree (or (recur level subtree) (recur level (cdr tree)))))))
+
+(defun -thread-subject-prefix (uid)
+  "A prefix added to message subjects when sorting by thread."
+  (make-string (* 2 (or (-thread-level uid) 0)) ?\s))
+
+(defun -thread-by-subject (messages)
+  "Compute a message thread tree from MESSAGES based on subject strings.
+This is the ORDEREDSUBJECT algorithm described in RFC 5256.  The return
+value is as described in loc. cit. §4, with message UIDs as tree leaves."
+  (let* ((hash (make-hash-table :test #'equal))
+         (threads (progn
+                    (dolist (msg messages)
+                      (let-alist msg
+                        (push msg (gethash (replace-regexp-in-string
+                                            message-subject-re-regexp ""
+                                            (or .envelope.subject ""))
+                                           hash))))
+                    (mapcar (lambda (thread) (sort thread :key 
#'-message-timestamp))
+                            (hash-table-values hash))))
+         (sorted (sort threads :key (lambda (v) (-message-timestamp (car 
v))))))
+    (mapcar (lambda (thread)
+              (cons (let-alist (car thread) .uid)
+                    (mapcar (lambda (v) (let-alist v (list .uid))) (cdr 
thread))))
+            sorted)))
+
+(defun minimail-sort-by-thread (&optional descending)
+  "Sort messages with grouping by threads.
+
+Within a thread, sort each message after its parents.  Across threads,
+preserve the existing order, in the sense that thread A sorts before
+thread B if some message from A comes before all messages of B.  This
+makes sense when the current sort order is in the “most relevant at top”
+style.  If DESCENDING is non-nil, use the opposite convention."
+  (interactive nil minimail-mailbox-mode)
+  (let* ((table (or (vtable-current-table)
+                    (user-error "No table under point")))
+         (mhash (make-hash-table)) ;maps message id -> root id and position 
within thread
+         (rhash (make-hash-table)) ;maps root id -> position across threads
+         (lessp (lambda (o1 o2)
+                  (pcase-let ((`(,ri . ,pi) (gethash (let-alist o1 .uid) 
mhash))
+                              (`(,rj . ,pj) (gethash (let-alist o2 .uid) 
mhash)))
+                    (if (eq ri rj)
+                        (< pi pj)
+                      (< (gethash ri rhash)
+                         (gethash rj rhash))))))
+         objects)
+    (save-excursion
+      ;; Get objects in current sort order (unlike `vtable-objects').
+      (goto-char (vtable-beginning-of-table))
+      (while-let ((obj (vtable-current-object)))
+        (push obj objects)
+        (forward-line)))
+    (cl-callf nreverse objects)
+    (dolist (obj objects)
+      (let* ((count (hash-table-count mhash))
+             (msgid (let-alist obj .uid))
+             (rootid (or (-thread-root msgid) -1))
+             (pos (or (-thread-position msgid) -1)))
+        (puthash msgid (cons rootid pos) mhash)
+        (if descending
+            (puthash rootid count rhash)
+          (cl-callf (lambda (i) (or i count)) (gethash rootid rhash)))))
+    (setf (vtable-objects table) (sort objects :lessp lessp :in-place t))
+    ;; Little hack to force vtable to redisplay with our new sorting.
+    (cl-letf (((vtable-sort-by table) nil))
+      (vtable-revert-command))))
+
 ;;; Message buffer
 
 (defvar-keymap minimail-message-mode-map

Reply via email to