;;; org-super-agenda.el --- Supercharge your agenda  -*- lexical-binding: t; -*-

;; Author: Adam Porter <adam@alphapapa.net>
;; Url: http://github.com/alphapapa/org-super-agenda
;; Version: 1.3-pre
;; Package-Requires: ((emacs "26.1") (s "1.10.0") (dash "2.13") (org "9.0") (ht "2.2") (ts "0.2"))
;; Keywords: hypermedia, outlines, Org, agenda

;;; Commentary:

;; This package lets you "supercharge" your Org daily/weekly agenda.
;; The idea is to group items into sections, rather than having them
;; all in one big list.

;; Now you can sort-of do this already with custom agenda commands,
;; but when you do that, you lose the daily/weekly aspect of the
;; agenda: items are no longer shown based on deadline/scheduled
;; timestamps, but are shown no-matter-what.

;; So this package filters the results from
;; `org-agenda-finalize-entries', which runs just before items are
;; inserted into agenda views.  It runs them through a set of filters
;; that separate them into groups.  Then the groups are inserted into
;; the agenda buffer, and any remaining items are inserted at the end.
;; Empty groups are not displayed.

;; The end result is your standard daily/weekly agenda, but arranged
;; into groups defined by you.  You might put items with certain tags
;; in one group, habits in another group, items with certain todo
;; keywords in another, and items with certain priorities in another.
;; The possibilities are only limited by the grouping functions.

;; The primary use of this package is for the daily/weekly agenda,
;; made by the `org-agenda-list' command, but it also works for other
;; agenda views, like `org-tags-view', `org-todo-list',
;; `org-search-view', etc.

;; Here's an example which you can test by evaluating the `let' form:

;; (let ((org-super-agenda-groups
;;        '(;; Each group has an implicit boolean OR operator between its selectors.
;;          (:name "Today" ; Optionally specify section name
;;                 :time-grid t ; Items that appear on the time grid
;;                 :todo "TODAY") ; Items that have this TODO keyword
;;          (:name "Important"
;;                 ;; Single arguments given alone
;;                 :tag "bills"
;;                 :priority "A")
;;          ;; Set order of multiple groups at once
;;          (:order-multi (2 (:name "Shopping in town"
;;                                  ;; Boolean AND group matches items that match all subgroups
;;                                  :and (:tag "shopping" :tag "@town"))
;;                           (:name "Food-related"
;;                                  ;; Multiple args given in list with implicit OR
;;                                  :tag ("food" "dinner"))
;;                           (:name "Personal"
;;                                  :habit t
;;                                  :tag "personal")
;;                           (:name "Space-related (non-moon-or-planet-related)"
;;                                  ;; Regexps match case-insensitively on the entire entry
;;                                  :and (:regexp ("space" "NASA")
;;                                                ;; Boolean NOT also has implicit OR between selectors
;;                                                :not (:regexp "moon" :tag "planet")))))
;;          ;; Groups supply their own section names when none are given
;;          (:todo "WAITING" :order 8) ; Set order of this section
;;          (:todo ("SOMEDAY" "TO-READ" "CHECK" "TO-WATCH" "WATCHING")
;;                 ;; Show this group at the end of the agenda (since it has the
;;                 ;; highest number). If you specified this group last, items
;;                 ;; with these todo keywords that e.g. have priority A would be
;;                 ;; displayed in that group instead, because items are grouped
;;                 ;; out in the order the groups are listed.
;;                 :order 9)
;;          (:priority<= "B"
;;                       ;; Show this section after "Today" and "Important", because
;;                       ;; their order is unspecified, defaulting to 0. Sections
;;                       ;; are displayed lowest-number-first.
;;                       :order 1)
;;          ;; After the last group, the agenda will display items that didn't
;;          ;; match any of these groups, with the default order position of 99
;;          )))
;;   (org-agenda nil "a"))

;; You can adjust the `org-super-agenda-groups' to create as many different
;; groups as you like.

;;; License:

;; 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 <http://www.gnu.org/licenses/>.

;;; Code:

;;;; Requirements

(require 'subr-x)
(require 'org)
(require 'org-agenda)
(require 'org-element)
(require 'org-habit)
(require 'cl-lib)
(require 'dash)
(require 's)
(require 'ht)
(require 'seq)
(require 'ts)

;; I think this is the right way to do this...
(eval-when-compile
  (require 'org-macs))

;;;; Variables

(defconst org-super-agenda-special-selectors
  '(:name :order :face :transformer)
  ;; This needs to be manually updated if any are added.
  "Special, non-grouping selectors.")

(defvar org-super-agenda-group-types nil
  "List of agenda grouping keywords and associated functions.
Populated automatically by `org-super-agenda--defgroup'.")

(defvar org-super-agenda-auto-selector-keywords nil
  "Keywords used as auto-grouping selectors.
Populated automatically by `org-super-agenda--def-auto-group'.")

(defvar org-super-agenda-group-transformers nil
  "List of agenda group transformers.")

(defvar org-super-agenda-header-map (copy-keymap org-agenda-mode-map)
  "Keymap applied to agenda group headers.
This is initialized to a copy of `org-agenda-mode-map'; changes
made to that map after this variable is defined will not be
included.  This map is useful for binding commands which apply
only with point on the group headers (e.g. use `origami' to fold
group headings by binding a key to `origami-toggle-node' in this
map).")

;; Silence byte-compiler.
(defvar org-element--timestamp-regexp)

(defvar org-super-agenda-allow-unsafe-groups t
  "When nil, groups that could be unsafe do not function.
This includes, e.g. `:pred' and `:auto-map', which call arbitrary
functions.  This variable is intended to be bound around calls to
grouping functions by code that might read values from untrusted
origin (e.g. Org QL's link-handling code).")

;;;; Customization

(defgroup org-super-agenda nil
  "Settings for `org-super-agenda'."
  :group 'org
  :link '(url-link "http://github.com/alphapapa/org-super-agenda"))

(defcustom org-super-agenda-groups nil
  "List of groups to apply to agenda views.
See readme for information."
  :type 'list)

(defcustom org-super-agenda-group-property-name "agenda-group"
  "Name of the Org property used by the :auto-group selector."
  :type 'string)

(defcustom org-super-agenda-properties-inherit t
  "Use property inheritance when checking properties with :auto-group selector.
With this enabled, you can set the \"agenda-group\" property for
an entire subtree, and every entry below it will inherit the
agenda group.  It seems most natural for it to be enabled, so the
default is.  But in case of performance problems, it can be
disabled.  This sets the INHERIT argument to `org-entry-get'."
  :type 'boolean)

(defcustom org-super-agenda-unmatched-name "Other items"
  "Default name for agenda section containing items unmatched by any filter."
  :type 'string)

(defcustom org-super-agenda-unmatched-order 99
  "Default order setting for agenda section containing items unmatched by any filter."
  :type 'integer)

(defcustom org-super-agenda-header-separator "\n"
  "Separator inserted before group headers.
If a string, a newline is added.  If a character, it is repeated
to fill window width, and a newline is added."
  :type '(choice character string))

(defcustom org-super-agenda-header-prefix " "
  "String prepended to group headers."
  :type 'string)

(defcustom org-super-agenda-final-group-separator ""
  "Separator inserted after final agenda group.
If a character, it is repeated to fill window width, and a
newline is added."
  :type '(choice character string))

(defcustom org-super-agenda-date-format "%e %B %Y"
  "Format string for date headers.
See `format-time-string'."
  :type 'string)

(defcustom org-super-agenda-header-properties
  '(face org-super-agenda-header
         org-agenda-structural-header t)
  "Text properties added to group headers."
  :type 'plist)

(defcustom org-super-agenda-hide-empty-groups nil
  "Hide empty groups.
Note that `org-super-agenda-mode' must be toggled for this option
to take effect."
  :type 'boolean)

(defcustom org-super-agenda-keep-order nil
  "Keep items' original sort order.
When multiple selectors are used, items' sort order may be
changed by the grouping process.  This option re-sorts items
after grouping.  The cost of this may range from negligible to
considerable, depending on the number of items."
  :type 'boolean)

;;;; Faces

(defface org-super-agenda-header '((t (:inherit org-agenda-structure)))
  "Face used in agenda for `org-super-agenda' group name header.")

;;;; Macros

(defmacro org-super-agenda--when-with-marker-buffer (form &rest body)
  "When FORM is a marker, run BODY in the marker's buffer, with point starting at it."
  (declare (indent defun) (debug (form body)))
  (org-with-gensyms (marker)
    `(let ((,marker ,form))
       (when (markerp ,marker)
         (with-current-buffer (marker-buffer ,marker)
           (save-excursion
             (goto-char ,marker)
             ,@body))))))

(cl-defmacro org-super-agenda--map-children (&key form any)
  "Return FORM mapped across child entries of entry at point, if it has any.
If ANY is non-nil, return as soon as FORM returns non-nil."
  (declare (indent defun)
           (debug (":form" form [&optional ":any" sexp])))
  (org-with-gensyms (tree-start all-results)
    `(save-excursion
       (save-restriction
         (let ((,tree-start (point))
               ,all-results)
           (when (save-excursion
                   (org-goto-first-child))
             (org-narrow-to-subtree)
             (cond (,any (cl-loop while (outline-next-heading)
                                  thereis ,form))
                   (t (cl-loop while (outline-next-heading)
                               collect ,form)))))))))

;;;; Support functions

(defun org-super-agenda--org-timestamp-element< (a b)
  "Return non-nil if A's date element is earlier than B's.
A and B are Org timestamp elements."
  ;; Copied from `org-ql'.
  (cl-macrolet ((ts (ts)
                    `(when ,ts
                       (org-timestamp-format ,ts "%s"))))
    (let* ((a-ts (ts a))
           (b-ts (ts b)))
      (cond ((and a-ts b-ts)
             (string< a-ts b-ts))
            (a-ts t)
            (b-ts nil)))))

(defsubst org-super-agenda--get-marker (s)
  "Return `org-marker' text properties of string S."
  (org-find-text-property-in-string 'org-marker s))

(defsubst org-super-agenda--get-category (s)
  "Return category of agenda item string S."
  (org-find-text-property-in-string 'org-category s))

(defsubst org-super-agenda--get-tags (s)
  "Return list of tags in agenda item string S."
  (org-find-text-property-in-string 'tags s))

(defun org-super-agenda--make-agenda-header (name)
  "Return agenda header named NAME.
If NAME is nil or `none', return empty string.  Otherwise, return
string NAME prepended with `org-super-agenda-header-separator',
which see.  NAME has the face `org-super-agenda-header' appended,
and the text properties `keymap' and `local-map' set to the value
of `org-super-agenda-header-map', which see."
  (pcase name
    ((or `nil 'none) "")
    (_ (let* ((properties (text-properties-at 0 name))
              (header (concat org-super-agenda-header-prefix name))
              (separator
               (cl-etypecase org-super-agenda-header-separator
                 (character (concat (make-string (window-width) org-super-agenda-header-separator)
                                    "\n"))
                 (string org-super-agenda-header-separator))))
         (set-text-properties 0 (length header) properties header)
         (add-face-text-property 0 (length header) 'org-super-agenda-header t header)
         (org-add-props header org-super-agenda-header-properties
           'keymap org-super-agenda-header-map
           ;; NOTE: According to the manual, only `keymap' should be necessary, but in my
           ;; testing, it only takes effect in Agenda buffers when `local-map' is set, so
           ;; we'll use both.
           'local-map org-super-agenda-header-map)
         ;; Don't apply faces and properties to the separator part of the string.
         (concat separator header)))))

(defsubst org-super-agenda--get-priority-cookie (s)
  "Return priority character for string S.
Matches `org-priority-regexp'."
  (when (string-match org-priority-regexp s)
    (match-string-no-properties 2 s)))

(defun org-super-agenda--get-item-entry (item)
  "Get entry for ITEM.
ITEM should be a string with the `org-marker' property set to a
marker."
  (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
    (buffer-substring (org-entry-beginning-position)
                      (org-entry-end-position))))

;;;; Minor mode

;;;###autoload
(define-minor-mode org-super-agenda-mode
  "Global minor mode to group items in Org agenda views according to `org-super-agenda-groups'.
With prefix argument ARG, turn on if positive, otherwise off."
  :global t
  (let ((advice-function-filter-return (if org-super-agenda-mode
                                           (lambda (to fn)
                                             ;; Enable mode
                                             (advice-add to :filter-return fn))
                                         (lambda (from fn)
                                           ;; Disable mode
                                           (advice-remove from fn))))
        (advice-function-after (if org-super-agenda-mode
                                   (lambda (to fn)
                                     ;; Enable mode
                                     (advice-add to :after fn))
                                 (lambda (from fn)
                                   ;; Disable mode
                                   (advice-remove from fn))))
        (hook-function (if org-super-agenda-mode #'add-hook #'remove-hook)))
    (funcall advice-function-filter-return #'org-agenda-finalize-entries
             #'org-super-agenda--filter-finalize-entries)
    (when org-super-agenda-hide-empty-groups
      (funcall advice-function-after #'org-agenda-filter-apply
               #'org-super-agenda--hide-or-show-groups)
      (funcall hook-function 'org-agenda-finalize-hook
               'org-super-agenda--hide-or-show-groups))

    ;; Add variable to list of variables (see issue #22).
    (if org-super-agenda-mode
        (add-to-list 'org-agenda-local-vars 'org-super-agenda-groups)
      (setq org-agenda-local-vars (remove 'org-super-agenda-groups org-agenda-local-vars)))
    ;; Display message
    (message (if org-super-agenda-mode
                 "org-super-agenda-mode enabled."
               "org-super-agenda-mode disabled."))))

;;;; Group selectors

;; TODO: Write TODOs for places to use this custom error.
(define-error 'org-super-agenda-invalid-selector "Invalid org-super-agenda selector" 'user-error)

(cl-defmacro org-super-agenda--defgroup (name docstring &key section-name test let*)
  "Define an agenda-item group function.
NAME is a symbol that will be appended to `org-super-agenda--group-' to
construct the name of the group function.  A symbol like `:name'
will be added to the `org-super-agenda-group-types' list, associated
with the function, which is used by the dispatcher.

DOCSTRING is a string used for the function's docstring.

:SECTION-NAME is a string or a lisp form that is run once, with
the variable `items' available.

:TEST is a lisp form that is run for each item, with the variable
`item' available.  Items passing this test are filtered into a
separate list.

:LET* is a `let*' binding form that is bound around the function
body after the ARGS are made a list.

Finally a list of three items is returned, with the value
returned by :SECTION-NAME as the first item, a list of items not
matching the :TEST as the second, and a list of items matching as
the third."
  (declare (indent defun)
           (debug (&define symbolp stringp
                           &rest [&or [":section-name" [&or stringp def-form]]
                                      [":test" def-form]
                                      [":let*" (&rest &or symbolp (gate symbolp &optional def-form))]])))
  (let ((group-type (intern (concat ":" (symbol-name name))))
        (function-name (intern (concat "org-super-agenda--group-" (symbol-name name)))))
    ;; Associate the group type with this function so the dispatcher can find it
    `(progn
       (setq org-super-agenda-group-types (plist-put org-super-agenda-group-types ,group-type ',function-name))
       (defun ,function-name (items args)
         ,docstring
         (unless (listp args)
           (setq args (list args)))
         (let* ,let*
           (cl-loop with section-name = ,section-name
                    for item in items
                    if ,test
                    collect item into matching
                    else collect item into non-matching
                    finally return (list section-name non-matching matching)))))))

;;;;; Date/time-related

;; TODO: I guess these should be in a date-matcher macro

(org-super-agenda--defgroup date
  "Group items that have a date associated.
Argument can be `t' to match items with any date, `nil' to match
items without a date, or `today' to match items with today's
date.  The `ts-date' text-property is matched against. "
  :section-name "Dated items"  ; Note: this does not mean the item has a "SCHEDULED:" line
  :let* ((today (org-today)))
  :test (pcase (car args)
          ('t ;; Test for any date
           (org-find-text-property-in-string 'ts-date item))
          ('nil ;; Test for not having a date
           (not (org-find-text-property-in-string 'ts-date item)))
          ('today  ;; Items that have a time sometime today
           ;; TODO: Maybe I can use the ts-date property in some other places, might be faster
           (when-let ((day (org-find-text-property-in-string 'ts-date item)))
             (= day today)))
          (_ ;; Oops
           (user-error "Argument to `:date' must be `t', `nil', or `today'"))))

(org-super-agenda--defgroup time-grid
  "Group items that appear on a time grid.
This matches the `dotime' text-property, which, if NOT set to
`time' (I know, this gets confusing), means it WILL appear in the
agenda time-grid. "
  :section-name "Timed items"  ; Note: this does not mean the item has a "SCHEDULED:" line
  :test (or (--when-let (org-find-text-property-in-string 'time item)
              ;; This property is a string; if empty, it doesn't match
              (not (string-empty-p it)))
            ;; This property is nil if it doesn't match
            (org-find-text-property-in-string 'time-of-day item)
            (--when-let (org-find-text-property-in-string 'dotime item)
              ;; For this to match, the 'dotime property must be set, and
              ;; it must not be equal to 'time.  If it is not set, or if
              ;; it is set and is equal to 'time, the item is not part of
              ;; the time-grid.  Yes, this is confusing.  :)
              (not (eql it 'time)))))

(org-super-agenda--defgroup deadline
  "Group items that have a deadline.
Argument can be `t' (to match items with any deadline), `nil' (to
match items that have no deadline), `past` (to match items with a
deadline in the past), `today' (to match items whose deadline is
today), or `future' (to match items with a deadline in the
future).  Argument may also be given like `before DATE' or `after
DATE', where DATE is a date string that
`org-time-string-to-absolute' can process."
  :section-name (pcase (car args)
                  ('t "Deadline items")
                  ('nil "Items without deadlines")
                  ('past "Past due")
                  ('today "Due today")
                  ('future "Due soon")
                  ('before (concat "Due before " (cadr args)))
                  ('on (concat "Due on " (cadr args)))
                  ('after (concat "Due after " (cadr args))))
  :let* ((today (pcase (car args)  ; Perhaps premature optimization
                  ((or 'past 'today 'future 'before 'on 'after)
                   (org-today))))
         (target-date (pcase (car args)
                        ((or 'before 'on 'after)
                         (org-time-string-to-absolute (cadr args))))))
  :test (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
          (let ((entry-time (org-entry-get (point) "DEADLINE")))
            (pcase (car args)
              ('t entry-time)  ; Has any deadline info
              ('nil (not entry-time))  ; Has no deadline info
              (comparison
               (when entry-time
                 (let ((entry-time (org-time-string-to-absolute entry-time))
                       (compare-date (pcase comparison
                                       ((or 'past 'today 'future) today)
                                       ((or 'before 'on 'after) target-date))))
                   (org-super-agenda--compare-dates comparison entry-time compare-date))))))))

(org-super-agenda--defgroup scheduled
  "Group items that are scheduled.
Argument can be `t' (to match items scheduled for any date),
`nil' (to match items that are not schedule), `past` (to match
items scheduled for the past), `today' (to match items scheduled
for today), or `future' (to match items scheduled for the
future).  Argument may also be given like `before DATE' or `after
DATE', where DATE is a date string that
`org-time-string-to-absolute' can process."
  :section-name (pcase (car args)
                  ('t "Scheduled items")
                  ('nil "Unscheduled items ")
                  ('past "Past scheduled")
                  ('today "Scheduled today")
                  ('future "Scheduled soon")
                  ('before (concat "Scheduled before " (cadr args)))
                  ('on (concat "Scheduled on " (cadr args)))
                  ('after (concat "Scheduled after " (cadr args))))
  :let* ((today (pcase (car args)  ; Perhaps premature optimization
                  ((or 'past 'today 'future 'before 'on 'after)
                   (org-today))))
         (target-date (pcase (car args)
                        ((or 'before 'on 'after)
                         (org-time-string-to-absolute (cadr args))))))
  :test (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
          (let ((entry-time (org-entry-get (point) "SCHEDULED")))
            (pcase (car args)
              ('t entry-time)  ; Has any scheduled info
              ('nil (not entry-time))  ; Has no scheduled info
              (comparison
               (when entry-time
                 (let ((entry-time (org-time-string-to-absolute entry-time))
                       (compare-date (pcase comparison
                                       ((or 'past 'today 'future) today)
                                       ((or 'before 'on 'after) target-date))))
                   (org-super-agenda--compare-dates comparison entry-time compare-date))))))))

(defun org-super-agenda--compare-dates (comparison date-a date-b)
  "Compare DATE-A and DATE-B according to COMPARISON.
COMPARISON should be a symbol, one of: `past' or `before',
`today' or `on', `future' or `after'."
  (pcase comparison
    ((or 'past 'before) (< date-a date-b))
    ((or 'today 'on) (= date-a date-b))
    ((or 'future 'after) (> date-a date-b))))

;;;;; Effort

(cl-defmacro org-super-agenda--defeffort-group (name docstring &key comparator)
  (declare (indent defun))
  `(org-super-agenda--defgroup ,(intern (concat "effort" (symbol-name name)))
     ,(concat docstring "\nArgument is a time-duration string, like \"5\" or \"0:05\" for 5 minutes.")
     :section-name (concat "Effort " ,(symbol-name name) " "
                           (s-join " or " args) " items")
     :let* ((effort-minutes (org-duration-string-to-minutes (car args))))
     :test (when-let ((item-effort (org-find-text-property-in-string 'effort item)))
             (,comparator (org-duration-string-to-minutes item-effort) effort-minutes))))

(org-super-agenda--defeffort-group <
  "Group items that are less than (or equal to) the given effort."
  :comparator <=)

(org-super-agenda--defeffort-group >
  "Group items that are higher than (or equal to) the given effort."
  :comparator >=)

;;;;; Misc

(org-super-agenda--defgroup anything
  "Select any item, no matter what.
This is a catch-all, probably most useful with the `:discard'
selector."
  :test t)

;; TODO: Rename this to something like :descendants and make a new
;; one-level-deep-only :children matcher that will be much faster
(org-super-agenda--defgroup children
  "Select any item that has child entries.
Argument may be `t' to match if it has any children, `nil' to
match if it has no children, `todo' to match if it has children
with any to-do keywords, or a string to match if it has specific
to-do keywords."
  :section-name (pcase (car args)
                  ('todo "Items with child to-dos")
                  ((pred stringp) (concat "Items with children " (car args)))
                  ('t "Items with children")
                  ('nil "Items without children"))
  :let* ((case-fold-search t))
  :test (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
          (pcase (car args)
            ('todo ;; Match if entry has child to-dos
             (org-super-agenda--map-children
               :form (org-entry-is-todo-p)
               :any t))
            ((pred stringp)  ;; Match child to-do keywords
             (org-super-agenda--map-children
               :form (cl-member (org-get-todo-state) args :test #'string=)
               :any t))
            ('t  ;; Match if it has any children
             (org-goto-first-child))
            ('nil  ;; Match if it has no children
             (not (org-goto-first-child))))))

(org-super-agenda--defgroup habit
  "Group habit items.
Habit items have a \"STYLE: habit\" Org property."
  :section-name "Habits"
  :test (org-is-habit-p (org-super-agenda--get-marker item)))

(org-super-agenda--defgroup file-path
  "Group items by file path.
Argument can be `t' to match items from files at any
path (i.e. all items from file-backed buffers), `nil' to match
items from non-file-backed buffers, or one or a list of regexp
strings to match against file paths."
  :section-name (concat "File path: " (s-join " OR " args))
  :test (-when-let* ((marker (or (get-text-property 0 'org-marker item)
                                 (get-text-property 0 'org-hd-marker item)))
                     (file-path (->> marker marker-buffer buffer-file-name)))
          (pcase args
            ('t t)
            ('nil nil)
            ((pred stringp) (s-matches? args file-path))
            (_ (cl-loop for path in args
                        thereis (s-matches? path file-path))))))

(org-super-agenda--defgroup log
  "Group Agenda Log Mode items.
Argument may be `close' or `closed' to select items closed today;
`clock' or `clocked' to select items clocked today; `changed' or
`state' to select items whose to-do state was changed today; `t'
to select any logged item, or `nil' to select any non-logged
item.  (See also variable `org-agenda-log-mode-items'.)  Note
that these items may also be matched by the :time-grid selector,
so if you want these displayed in their own group, you may need
to select them in a group before a group containing the
:time-grid selector."
  :section-name (pcase (car args)
                  ((or 'close 'closed) "Log: Closed")
                  ((or 'clock 'clocked) "Log: Clocked")
                  ((or 'changed 'state) "Log: State changed")
                  ('t "Logged")
                  ('nil "Not logged"))
  ;; I don't know why the property's value is a string instead of a
  ;; symbol, because `org-agenda-log-mode-items' is a list of symbols.

  ;; TODO: Rather than hard-coding these strings and symbols, it would be good to get them smartly
  ;; from `org-agenda-log-mode-items', but I don't want to give up accepting both e.g. `close' and
  ;; `closed', because it's easily confusing and error-prone without that flexibility.
  :test (let ((value (org-find-text-property-in-string 'type item)))
          (pcase (car args)
            ((or 'close 'closed) (string= value "closed"))
            ((or 'clock 'clocked) (string= value "clock"))
            ((or 'changed 'state) (string= value "state"))
            ('t (cl-member value '("closed" "clock" "state") :test #'string=))
            ('nil (not (cl-member value '("closed" "clock" "state") :test #'string=))))))

(org-super-agenda--defgroup heading-regexp
  "Group items whose headings match any of the given regular expressions.
Argument may be a string or list of strings, each of which should
be a regular expression.  You'll probably want to override the
section name for this group."
  :section-name (concat "Headings matching regexps: "
                        (s-join " OR "
                                (--map (s-wrap it "\"")
                                       args)))
  :let* ((case-fold-search t))
  :test (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
          (let ((heading (org-get-heading 'no-tags 'no-todo)))
            (cl-loop for regexp in args
                     thereis (string-match-p regexp heading)))))

(org-super-agenda--defgroup pred
  "Group items that match a predicate.
Argument can be one or a list of functions, to match items that
return non-nil for any function.

Each predicate is called with a single argument: the agenda item
being tested, as a string.  Agenda-related attributes will have
been applied to the string as text-properties.  Use
`describe-text-properties' in an agenda buffer to see what's
available."
  :section-name (progn
                  (unless org-super-agenda-allow-unsafe-groups
                    ;; Check here so the test is run once, not for every item.
                    (error "Unsafe groups disallowed (:pred): %s" args))
                  (concat "Predicate: "
                          (cl-labels ((to-string (arg)
                                                 (pcase-exhaustive arg
                                                   ;; FIXME: What if the lambda's byte-compiled?
                                                   (`(lambda . ,_) "Lambda")
                                                   ((pred functionp) (symbol-name arg))
                                                   ((pred listp) (s-join " OR " (-map #'to-string arg))))))
                            (to-string args))))
  :test (pcase args
          ((pred functionp) (funcall args item))
          (_ (cl-loop for fn in args
                      thereis (funcall fn item)))))

(org-super-agenda--defgroup property
  "Group items with a property, optionally matching a value.
Argument may be a property name string, or a list of property
name string and either value string or predicate with which to
test the value."
  :section-name (concat "Property: " (car args)
                        (pcase (cadr args)
                          (`nil nil)
                          ((pred stringp) (concat ": " (cadr args)))
                          ((and (pred functionp) (pred symbolp))
                           (concat " matches predicate " (symbol-name (cadr args))))
                          ((pred functionp) (concat " matches lambda predicate"))))
  :test  (when-let* ((found-value
		      (org-entry-get (org-super-agenda--get-marker item)
				     (car-safe args)
				     org-super-agenda-properties-inherit)))
           (pcase args
             ((or (and property (pred stringp))
                  `(,(and property (pred stringp)) . nil))
              ;; Only property, no value given.
              t)
             (`(,property ,(and value (pred stringp)))
              (string= value found-value))
             (`(,property ,(and predicate (pred functionp)))
              (funcall predicate found-value))
             (_ ;; Oops
              (signal 'org-super-agenda-invalid-selector (list (cons :property args)))))))

(org-super-agenda--defgroup regexp
  "Group items that match any of the given regular expressions.
Argument may be a string or list of strings, each of which should
be a regular expression.  You'll probably want to override the
section name for this group."
  :section-name (concat "Items matching regexps: "
                        (s-join " OR "
                                (--map (s-wrap it "\"")
                                       args)))
  :let* ((case-fold-search t))
  :test (when-let ((entry (org-super-agenda--get-item-entry item)))
          (cl-loop for regexp in args
                   thereis (string-match-p regexp entry))))

(org-super-agenda--defgroup tag
  "Group items that match any of the given tags.
Argument may be a string or list of strings."
  :section-name (concat "Tags: " (s-join " OR " args))
  :test (seq-intersection (org-super-agenda--get-tags item) args 'cl-equalp))

(org-super-agenda--defgroup category
  "Group items that match any of the given categories.
Argument may be a string or list of strings."
  :section-name (concat "Items categorized as: " (s-join " OR " args))
  :test (cl-member (org-super-agenda--get-category item)
                   args :test #'string=))

(org-super-agenda--defgroup todo
  "Group items that match any of the given TODO keywords.
Argument may be a string or list of strings, or `t' to match any
keyword, or `nil' to match only non-todo items."
  :section-name (pcase (car args)
                  ((pred stringp) ;; To-do keyword given
                   (concat (s-join " and " (--map (propertize it 'face (org-get-todo-face it))
                                                  args))
                           " items"))
                  ('t ;; Test for any to-do keyword
                   "Any TODO keyword")
                  ('nil ;; Test for not having a to-do keyword
                   "Non-todo items")
                  (_ ;; Oops
                   (user-error "Argument to `:todo' must be a string, list of strings, t, or nil")))
  :test (pcase (car args)
          ((pred stringp) ;; To-do keyword given
           (cl-member (org-find-text-property-in-string 'todo-state item) args :test 'string=))
          ('t ;; Test for any to-do keyword
           (org-find-text-property-in-string 'todo-state item))
          ('nil ;; Test for not having a to-do keyword
           (not (org-find-text-property-in-string 'todo-state item)))
          (_ ;; Oops
           (user-error "Argument to `:todo' must be a string, list of strings, t, or nil"))))

;;;;; Priority

(org-super-agenda--defgroup priority
  "Group items that match any of the given priorities.
Argument may be a string or list of strings, which should be,
e.g. \"A\" or (\"B\" \"C\")."
  :section-name (concat "Priority " (s-join " and " args) " items")
  :test (cl-member (org-super-agenda--get-priority-cookie item) args :test 'string=))

(cl-defmacro org-super-agenda--defpriority-group (name docstring &key comparator)
  (declare (indent defun))
  `(org-super-agenda--defgroup ,(intern (concat "priority" (symbol-name name)))
     ,(concat docstring "\nArgument is a string; it may also be a list of
strings, in which case only the first will be used.
The string should be the priority cookie letter, e.g. \"A\".")
     :section-name (concat "Priority " ,(symbol-name name) " "
                           (s-join " or " args) " items")
     :let* ((priority-number (string-to-char (car args))))
     :test (let ((item-priority (org-super-agenda--get-priority-cookie item)))
             (when item-priority
               ;; Higher priority means lower number
               (,comparator (string-to-char item-priority) priority-number)))))

(org-super-agenda--defpriority-group >
  "Group items that are higher than the given priority."
  :comparator <)

(org-super-agenda--defpriority-group >=
  "Group items that are greater than or equal to the given priority."
  :comparator <=)

(org-super-agenda--defpriority-group <
  "Group items that are lower than the given priority."
  :comparator >)

(org-super-agenda--defpriority-group <=
  "Group items that are lower than or equal to the given priority."
  :comparator >=)

;;;; Grouping functions

;; TODO: cl-loop is great, but when it gets this big, it's rather ugly, and it
;; probably scares some people away.  This should probably be refactored.
(defun org-super-agenda--group-items (all-items)
  "Divide ALL-ITEMS into groups based on `org-super-agenda-groups'."
  (if (bound-and-true-p org-super-agenda-groups)
      ;; Transform groups
      (let ((org-super-agenda-groups (org-super-agenda--transform-groups org-super-agenda-groups)))
        ;; Collect and insert groups
        (cl-loop with section-name
                 for filter in org-super-agenda-groups
                 for custom-section-name = (plist-get filter :name)
                 for order = (or (plist-get filter :order) 0)  ; Lowest number first, 0 by default
                 for (auto-section-name non-matching matching) = (org-super-agenda--group-dispatch all-items filter)

                 do (when org-super-agenda-keep-order
                      (setf matching (sort matching #'org-entries-lessp)))

                 ;; Transformer
                 for transformer = (plist-get filter :transformer)
                 when transformer
                 do (setq matching (-map (pcase transformer
                                           (`(function ,transformer) transformer)
                                           ((pred symbolp) transformer)
                                           (_ `(lambda (it) ,transformer)))
                                         matching))

                 ;; Face
                 for face = (plist-get filter :face)
                 when face
                 do (let ((append (plist-get face :append)))
                      (when append (cl-remf face :append))
                      (--each matching
                        (add-face-text-property 0 (length it) face append it)))

                 ;; Auto category/group
                 if (cl-member auto-section-name org-super-agenda-auto-selector-keywords)
                 do (setq section-name (or custom-section-name "Auto category/group"))
                 and append (cl-loop for group in matching
                                     collect (list :name (plist-get group :name)
                                                   :items (plist-get group :items)
                                                   :order order))
                 into sections
                 and do (setq all-items non-matching)

                 ;; Manual groups
                 else
                 do (setq section-name (or custom-section-name auto-section-name))
                 and collect (list :name section-name :items matching :order order) into sections
                 and do (setq all-items non-matching)

                 ;; Sort sections by :order then :name
                 finally do (setq non-matching (list :name org-super-agenda-unmatched-name
                                                     :items non-matching
                                                     :order org-super-agenda-unmatched-order))
                 finally do (setq sections (--sort (let ((o-it (plist-get it :order))
                                                         (o-other (plist-get other :order)))
                                                     (cond ((and
                                                             ;; FIXME: This is now quite ugly.  I'm not sure that all of these tests
                                                             ;; are necessary, but at the moment it works, so I'm leaving it alone.
                                                             (equal o-it o-other)
                                                             (not (equal o-it 0))
                                                             (stringp (plist-get it :name))
                                                             (stringp (plist-get other :name)))
                                                            ;; Sort by string only for items with a set order
                                                            (string< (plist-get it :name)
                                                                     (plist-get other :name)))
                                                           ((and (numberp o-it)
                                                                 (numberp o-other))
                                                            (< o-it o-other))
                                                           (t nil)))
                                                   (push non-matching sections)))
                 ;; Insert sections
                 finally return (cl-loop for (_ name _ items) in sections
                                         when items
                                         collect (org-super-agenda--make-agenda-header name)
                                         and append items)))
    ;; No super-filters; return list unmodified
    all-items))

;;;;; Auto-grouping

(cl-defmacro org-super-agenda--def-auto-group (name docstring-ending
                                                    &key keyword key-form
                                                    (header-form 'key) (key-sort-fn #'string<))
  "Define an auto-grouping function.

The function will be named `org-super-agenda--auto-group-NAME'.

The docstring will be, \"Divide ALL-ITEMS into groups based on DOCSTRING_ENDING.\".

The selector keyword will be `:auto-NAME'.

Items will be grouped by the value of KEY-FORM evaluated for each
item, with the variable `item' bound to the string from the
agenda buffer.

Group headers will be sorted by KEY-SORT-FN; usually the default
will suffice.

The groups' headers will be the value of HEADER-FORM, evaluated
for each group after items are grouped, with the variable `key'
bound to the group's key.  The form defaults to `key'.

In the body of the function, the variable `all-items' will be
bound to all agenda items being grouped, and `args' to the rest
of the arguments to the function."
  (declare (indent defun))
  (cl-labels ((form-contains (form symbol)
                             (cl-typecase form
                               (atom (eq form symbol))
                               (list (or (form-contains (car form) symbol)
                                         (form-contains (cdr form) symbol))))))
    (let* ((fn-name (intern (format "org-super-agenda--auto-group-%s" name)))
           (docstring (format "Divide ALL-ITEMS into groups based on %s." docstring-ending))
           (keyword (or keyword (intern (format ":auto-%s" name))))
           (fn-args (if (or (form-contains key-form 'args)
                            (form-contains header-form 'args))
                        '(all-items &rest args)
                      '(all-items &rest _args))))
      `(progn
         (defun ,fn-name ,fn-args
           ,docstring
           (cl-loop with groups = (ht-create)
                    for item in all-items
                    for key = ,key-form
                    if key
                    do (ht-set! groups key (cons item (ht-get groups key)))
                    else collect item into non-matching
                    finally return (list ,keyword
                                         non-matching
                                         (cl-loop for key in (sort (ht-keys groups) #',key-sort-fn)
                                                  for name = ,header-form
                                                  collect (list :name name
                                                                :items (nreverse (ht-get groups key)))))))
         (setq org-super-agenda-group-types (plist-put org-super-agenda-group-types
                                                       ,keyword #',fn-name))
         (add-to-list 'org-super-agenda-auto-selector-keywords ,keyword)))))

;; TODO: auto-year and auto-month groups.  Maybe also auto-quarter,
;; auto-week, etc.  Maybe also auto-next-7-days, something like that.

(org-super-agenda--def-auto-group planning
  "their earliest deadline or scheduled date (formatted according to `org-super-agenda-date-format', which see)"
  :keyword :auto-planning
  ;; This is convoluted, mainly because dates and times in Emacs are kind of
  ;; insane.  Good luck parsing a simple "%e %B %Y"-formatted time back to a
  ;; time value that can be compared.  It's virtually impossible, at least
  ;; without a lot of work (hence my ts.el package, but it's not yet mature
  ;; enough to use here).  So we store the Org timestamp element in the text
  ;; properties of the formatted time.
  ;; TODO: Use `ts' for this.
  :key-form (cl-flet ((get-date-type (type)
                                     (when-let* ((date-string (org-entry-get (point) type)))
                                       (with-temp-buffer
                                         ;; FIXME: Hack: since we're using (org-element-property
                                         ;; :type date-element) below, we need this date parsed
                                         ;; into an org-element element.
                                         (insert date-string)
                                         (goto-char 0)
                                         (org-element-timestamp-parser)))))
              (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
                ;; MAYBE: Also check CLOSED date.
                (let ((earliest-ts (car (sort (list (get-date-type "SCHEDULED")
                                                    (get-date-type "DEADLINE"))
                                              #'org-super-agenda--org-timestamp-element<))))
                  (pcase earliest-ts
                    ('nil nil)
                    (_ (propertize (org-timestamp-format earliest-ts org-super-agenda-date-format)
                                   'org-super-agenda-ts earliest-ts))))))
  :key-sort-fn (lambda (a b)
                 (org-super-agenda--org-timestamp-element<
                  (get-text-property 0 'org-super-agenda-ts a)
                  (get-text-property 0 'org-super-agenda-ts b))))

(org-super-agenda--def-auto-group tags
  "their tags"
  :keyword :auto-tags
  :key-form (--when-let (org-super-agenda--get-tags item)
              (->> it (-sort #'string<) (s-join ", ")
                   (concat "Tags: ")))
  :key-sort-fn string<)

(org-super-agenda--def-auto-group ts
  "the date of their latest timestamp anywhere in the entry (formatted according to `org-super-agenda-date-format', which see)"
  :keyword :auto-ts
  :key-form (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
              (ignore args)
              (let* ((limit (org-entry-end-position))
                     (latest-ts (->> (cl-loop for next-ts =
                                              (when (re-search-forward org-element--timestamp-regexp limit t)
                                                (ts-parse-org (match-string 1)))
                                              while next-ts
                                              collect next-ts)
                                     (-sort #'ts>)
                                     car)))
                (when latest-ts
                  (propertize (ts-format org-super-agenda-date-format latest-ts)
                              'org-super-agenda-ts latest-ts))))
  :key-sort-fn (lambda (a b)
                 (funcall (if (member 'reverse args)
                              #'ts> #'ts<)
                          (get-text-property 0 'org-super-agenda-ts a)
                          (get-text-property 0 'org-super-agenda-ts b))))

(org-super-agenda--def-auto-group items "their AGENDA-GROUP property"
  :keyword :auto-group
  :key-form (org-entry-get (org-super-agenda--get-marker item)
                           org-super-agenda-group-property-name
                           org-super-agenda-properties-inherit)
  :header-form (concat "Group: " key))

(org-super-agenda--def-auto-group category "their org-category property"
  :key-form (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
              (org-get-category))
  :header-form (concat "Category: " key))

(org-super-agenda--def-auto-group map "the value returned by calling function ARGS with each item.  The function should return a string to be used as the grouping key and as the header for its group"
  :key-form (progn
              (unless org-super-agenda-allow-unsafe-groups
                ;; This check gets run for every item because the `def-auto-group' macro
                ;; doesn't have a form that is eval'ed once.  NOTE: If there are no
                ;; results, the key-form never gets evaluated, so the check doesn't either.
                ;; TODO: Add a form to the macro so this test can be run once.
                (error "Unsafe groups disallowed (:auto-map): %s" args))
              (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
                (funcall (car args) item))))

(org-super-agenda--def-auto-group priority "their priority"
  :key-form (org-super-agenda--get-priority-cookie item)
  :header-form (format "Priority: %s" key))

(org-super-agenda--def-auto-group property "the given property"
  :key-form (org-entry-get (org-super-agenda--get-marker item)
                           (car args)
                           org-super-agenda-properties-inherit)
  :header-form (format "%s: %s" (car args) key))

(org-super-agenda--def-auto-group todo "their to-do keyword"
  :keyword :auto-todo
  ;; NOTE: I'm not sure why sometimes items have the `todo-state' property set and other
  ;; times `todo-keyword', but that seems to be the case, so we need to handle both.
  :key-form (when-let* ((keyword (or (org-find-text-property-in-string 'todo-state item)
                                     (org-find-text-property-in-string 'todo-keyword item))))
              (propertize keyword 'face (org-get-todo-face keyword)))
  :header-form (concat "To-do: " key))

(org-super-agenda--def-auto-group dir-name "their parent heading"
  :key-form (-when-let* ((marker (org-super-agenda--get-marker item))
                         (file-path (->> marker marker-buffer buffer-file-name))
                         (directory-name (->> file-path file-name-directory directory-file-name file-name-nondirectory)))
              (concat "Directory: " directory-name)))

(org-super-agenda--def-auto-group outline-path "their outline paths"
  :key-form (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
              (s-join "/" (org-get-outline-path))))

(org-super-agenda--def-auto-group parent "their parent heading"
  :key-form (org-super-agenda--when-with-marker-buffer (org-super-agenda--get-marker item)
              (when (org-up-heading-safe)
                (org-get-heading 'notags 'notodo))))

;;;;; Dispatchers

(defun org-super-agenda--get-selector-fn (selector)
  "Return function for SELECTOR, or nil if special selector.
Raise error if invalid selector."
  (cond
   ((cl-member selector org-super-agenda-special-selectors)
    ;; Special selector, so no associated function; return nil
    nil)
   ;; Valid selector: return function
   ((plist-get org-super-agenda-group-types selector))
   ((eq selector :habit)
    ;; :habit selector used but `org-habit' not loaded
    (user-error "Please `require' the `org-habit' library to use the :habit selector"))
   ;; Invalid selector: raise error
   ((user-error "Invalid org-super-agenda-groups selector: %s" selector))))

(defun org-super-agenda--group-dispatch (items group)
  "Group ITEMS with the appropriate grouping functions for GROUP.
Grouping functions are listed in `org-super-agenda-group-types', which
see."
  (cl-loop for (selector args) on group by 'cddr  ; plist access
           for fn = (org-super-agenda--get-selector-fn selector)
           ;; This double "when fn" is an ugly hack, but it lets us
           ;; use the destructuring-bind; otherwise we'd have to put
           ;; all the collection logic in a progn, or do the
           ;; destructuring ourselves, which would be uglier.
           when fn
           for (auto-section-name non-matching matching) = (funcall fn items args)
           when fn
           ;; This is the implicit OR
           append matching into all-matches
           and collect auto-section-name into names
           and do (setq items non-matching)
           for name = (if (stringp (car names))
                          (s-join " and " (-non-nil names))
                        ;; Probably an :auto-group
                        (car names))
           finally return (list name items all-matches)))

;; TODO: This works, but it seems inelegant to basically copy the
;; group-dispatch function.  A more pure-functional approach might be
;; more DRY, but that would preclude using the loop macro, and might
;; be slower.  Decisions, decisions...

(defun org-super-agenda--group-dispatch-and (items group)
  "Group ITEMS that match all selectors in GROUP."
  ;; Used for the `:and' selector.
  (cl-loop with final-non-matches with final-matches
           with all-items = items  ; Save for later
           for (selector args) on group by 'cddr  ; plist access
           for fn = (org-super-agenda--get-selector-fn selector)
           ;; This double "when fn" is an ugly hack, but it lets us
           ;; use the destructuring-bind; otherwise we'd have to put
           ;; all the collection logic in a progn, or do the
           ;; destructuring ourselves, which would be uglier.
           when fn
           for (auto-section-name _ matching) = (funcall fn items args)
           when fn
           collect matching into all-matches
           and collect auto-section-name into names

           ;; Now for the AND
           finally do (setq final-matches (cl-reduce 'seq-intersection all-matches))
           finally do (setq final-non-matches (seq-difference all-items final-matches))
           finally return (list (s-join " AND " (-non-nil names))
                                final-non-matches
                                final-matches)))
(setq org-super-agenda-group-types (plist-put org-super-agenda-group-types
                                              :and 'org-super-agenda--group-dispatch-and))

(defun org-super-agenda--group-dispatch-not (items group)
  "Group ITEMS that match no selectors in GROUP."
  ;; Used for the `:not' selector.
  ;; I think all I need to do is re-dispatch and reverse the results
  (-let (((name non-matching matching) (org-super-agenda--group-dispatch items group)))
    (list name matching non-matching)))
(setq org-super-agenda-group-types (plist-put org-super-agenda-group-types
                                              :not 'org-super-agenda--group-dispatch-not))

(cl-defun org-super-agenda--group-dispatch-take (items (n &rest group))
  "Take N ITEMS that match selectors in GROUP.
If N is positive, take the first N items, otherwise take the last N items.
Note: the ordering of entries is not guaranteed to be preserved, so this may
not always show the expected results."
  (-let* (((name non-matching matching) (org-super-agenda--group-dispatch items group))
          (take-fn (if (cl-minusp n) #'-take-last #'-take))
          (placement (if (cl-minusp n) "Last" "First"))
          (name (format "%s %d %s" placement (abs n) name)))
    (list name non-matching (funcall take-fn (abs n) matching))))
(setq org-super-agenda-group-types (plist-put org-super-agenda-group-types
                                              :take 'org-super-agenda--group-dispatch-take))

(defun org-super-agenda--group-dispatch-discard (items group)
  "Discard ITEMS that match GROUP.
Any groups processed after this will not see these items."
  (cl-loop for (selector args) on group by 'cddr  ; plist access
           for fn = (org-super-agenda--get-selector-fn selector)
           ;; This double "when fn" is an ugly hack, but it lets us
           ;; use the destructuring-bind; otherwise we'd have to put
           ;; all the collection logic in a progn, or do the
           ;; destructuring ourselves, which would be uglier.
           when fn
           for (auto-section-name non-matching matching) = (funcall fn items args)
           when fn
           ;; This is the implicit OR
           append matching into all-matches
           and collect auto-section-name into names
           and do (setq items non-matching)
           finally return (list (s-join " and " (-non-nil names))
                                items
                                nil)))
(setq org-super-agenda-group-types (plist-put org-super-agenda-group-types
                                              :discard 'org-super-agenda--group-dispatch-discard))

;;;;; Transformers

(defun org-super-agenda--transform-groups (groups)
  "Transform GROUPS according to `org-super-agenda-group-transformers'."
  (cl-loop for group in groups
           for fn = (plist-get org-super-agenda-group-transformers (car group))
           if fn
           do (setq group (funcall fn (cadr group)))
           and append group
           else collect group))

(defun org-super-agenda--transform-group-order (groups)
  "Return GROUPS with their order set.
GROUPS is a list of groups, but the first element of the list is
actually the ORDER for the groups."
  (cl-loop with order = (pop groups)
           for group in groups
           collect (plist-put group :order order)))
(setq org-super-agenda-group-transformers (plist-put org-super-agenda-group-transformers
                                                     :order-multi 'org-super-agenda--transform-group-order))

;;;; Filters

(defun org-super-agenda--filter-finalize-entries (string)
  "Filter STRING through `org-super-agenda--group-items'.
STRING should be that returned by `org-agenda-finalize-entries'"
  (--> string
       (split-string it "\n" 'omit-nulls)
       org-super-agenda--group-items
       (-remove #'s-blank-str? it)
       (s-join "\n" it)
       (concat it (cl-etypecase org-super-agenda-final-group-separator
                    (character (concat "\n" (make-string (window-width) org-super-agenda-final-group-separator)))
                    (string org-super-agenda-final-group-separator)))))

(defun org-super-agenda--hide-or-show-groups (&rest _)
  "Hide/Show any empty/non-empty groups.
Should be done after `org-agenda-finalize' or
`org-agenda-filter-apply' is called."
  (cl-labels ((header-p () (org-get-at-bol 'org-super-agenda-header))
              (grid-p () (not (cl-intersection
                               '(org-agenda-structural-header org-agenda-date-header org-super-agenda-header type)
                               (text-properties-at (point-at-bol)))))
              (group-item-visible-p () (and (org-get-at-bol 'type) (not (org-get-at-bol 'invisible))))
              (next-header
               () (let ((hide-p t) header grid-end)
                    (while (not (or (bobp) header))
                      (cond ((header-p)
                             (setq header (list (1- (or (previous-single-property-change
                                                         (point-at-eol) 'org-super-agenda-header)
                                                        (1+ (point-min))))
                                                (or grid-end (point-at-eol))
                                                hide-p)))
                            ((group-item-visible-p)
                             (setq hide-p nil))
                            ((and (grid-p) (not grid-end))
                             (setq grid-end (point-at-eol))))
                      (beginning-of-line 0))
                    header))
              (hide-or-show-header
               (header) (when header
                          (cl-loop
                           with (start end hide-p) = header
                           with props = '(invisible org-filtered org-filter-type org-super-agenda-filtered)
                           initially do (goto-char end)
                           while (and start (> (point) start))
                           do (when (or (grid-p) (header-p))
                                (let ((beg (1- (point-at-bol)))
                                      (end (point-at-eol)))
                                  (if hide-p
                                      (add-text-properties beg end props)
                                    (remove-text-properties beg end props))))
                           (beginning-of-line 0)))))
    (let ((inhibit-read-only t))
      (save-excursion
        (goto-char (point-max))
        (beginning-of-line 0)
        (while (not (bobp))
          (hide-or-show-header (next-header)))))))

;;;; Footer

(provide 'org-super-agenda)

;;; org-super-agenda.el ends here
